diff --git a/.claude/loopspec/sketch-focus-arbiter.spec.md b/.claude/loopspec/sketch-focus-arbiter.spec.md new file mode 100644 index 0000000000..f0c1287307 --- /dev/null +++ b/.claude/loopspec/sketch-focus-arbiter.spec.md @@ -0,0 +1,86 @@ +# DELEGATION SPECIFICATION: HARNESS-DRIVEN VALIDATION LOOP +slug: sketch-focus-arbiter · repo: /home/tommaso/projects/apps/orca_cad · branch: cad-mainline + +## 1. TARGET GOAL + +**Functional Objective.** Keyboard input in the Design tab is routed by WHAT THE KEY IS, not by +which widget the window manager decided to focus. Adopted from FreeCAD's +`DrawSketchKeyboardManager::detectKeyboardEventHandlingMode` +(src/Mod/Sketcher/Gui/DrawSketchKeyboardManager.cpp), which never queries focus at all: + + - digit, `-`, `.`, `,` -> the open value field + - Backspace / Delete -> the open value field (when one is open) + - Enter / Return / Tab -> commit the field, control returns to the view + - a letter -> the sketch-tool shortcut map, as today + - Esc -> the existing CadLevel LIFO (DesignInteraction.hpp), unchanged + - anything else -> sticky: whoever had it keeps it + +Observable postcondition: for EVERY sketch tool that opens a value field, a value typed +immediately after the field appears — with NO click into the field — is the value committed. +Today the prefill is committed instead whenever the WM withholds focus. + +**Target Files / Scope (writable).** + src/slic3r/GUI/CAD/DesignPanel.cpp (the arbiter lives in the existing wxEVT_CHAR_HOOK) + src/slic3r/GUI/CAD/DesignCanvas.cpp/.hpp (forwarding entry points only) + src/slic3r/GUI/CAD/SketchInlineEditor.cpp/.hpp (accept a programmatically delivered character) + scripts/CAD/check-gui-click-edit.py (F2P oracle — authoring exception, see §4) +Everything else read-only. No dependency additions, no reformatting. + +**Open Bindings.** + - The in-canvas ImGui field on wip/in-canvas-value-field is NOT in scope. Default: the arbiter + is implemented against the CURRENT wxFrame field on cad-mainline, because content-based + routing makes the window's focus irrelevant either way. If it later moves in-canvas the + arbiter is unchanged. + - Tools whose field is opened by a toolbar button rather than a gesture (Constrain path) are + covered by the same arbiter but are not in the F2P tool list. Default: assert them in P2P only. + +## 2. HARNESS ENVIRONMENT & GROUND TRUTH + +The rig container `orcacad-gui` on nativedev IS the harness. Xvfb `:11` + openbox, the app under +test, `xdotool` for synthetic input, and an MCP socket at `/tmp/mcp.sock` that reports sketch +state as JSON. It is a closed loop: drive input, read geometry back, assert. No window manager +politics, no human. + + Harness interface (ordered; each slot one invocation, one exit code): + S1 sync docker cp orcacad-gui:/OrcaSlicer/ + S2 build docker exec orcacad-gui ninja -C /OrcaSlicer/build orca-slicer + S3 restart docker exec orcacad-gui /OrcaSlicer/scripts/CAD/start-headless-gui.sh + S4 F2P docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-click-edit.py --attach + S5 P2P docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-sketching.py + +**F2P.** `scripts/CAD/check-gui-click-edit.py`. For each of Line, Rectangle, Circle, Slot, +Polygon, Ellipse and Rounded rectangle: arm the tool, draw it, and type a value that differs +from the prefill WITHOUT clicking the field. Assert the committed value equals the typed value. +The ladder must FAIL against unmodified cad-mainline — that is what proves it asserts something. + +**P2P.** `scripts/CAD/check-gui-sketching.py`, the existing gesture ladder, minus anything red at +baseline. NOTE: it calls `focus_field()` — one click into the field before typing — which is the +workaround this whole task removes. It stays green as a regression guard; it is NOT evidence. + +**Test Integrity Constraint.** `focus_field()` in check-gui-sketching.py must NOT be deleted to +make things pass, and check-gui-click-edit.py must NOT be weakened. Either invalidates the run. + +## 3. VERIFICATION COMMANDS +1. Static: `docker exec orcacad-gui ninja -C /OrcaSlicer/build orca-slicer` (warnings delta only; + this repo configures no linter — the compiler is the static gate. Absolute-zero is NOT the gate.) +2. Harness: `docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-click-edit.py --attach` +3. Regression: `docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-sketching.py` + +## 4. CONVERGENCE LOOP — ceiling 8 iterations +EDIT (scoped) -> EXECUTE S1..S5 -> PARSE the ladder's per-tool assertions and the [UX]/[KEYTRACE] +lines -> PATCH from the parsed cause. On ceiling without convergence: stop, report the last diff +and the unresolved failure set. Do not report success. + +F2P authoring exception: check-gui-click-edit.py is writable, and must be shown RED against +unmodified source before any source edit counts. + +## 5. TERMINATION CRITERIA +- [ ] S2 exits 0, and introduces no compiler warning absent from the baseline. +- [ ] S4 ALL_PASSED — every tool commits the typed value, no click into the field. +- [ ] S5 shows zero regressions against its recorded baseline pass count. +- [ ] F2P proven red without the fix (source stashed, ladder re-run, must FAIL). + +## 6. GUARDRAILS +Zero-assumption: no completion claim without captured stdout and exit codes. Oracle supremacy: +the ladder's verdict overrides my judgement. Blast radius: §1 files only. Baseline obligation: +run §3 once before the first edit and record it. diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e713d8c88..e3547281f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,7 @@ endif() option(SLIC3R_STATIC "Compile OrcaSlicer with static libraries (Boost, TBB)" ${SLIC3R_STATIC_INITIAL}) option(SLIC3R_GUI "Compile OrcaSlicer with GUI components (OpenGL, wxWidgets)" 1) +option(SLIC3R_CAD "Compile OrcaSlicer with the parametric Design/CAD tab (needs OCCT ModelingAlgorithms)" 1) option(SLIC3R_FHS "Assume OrcaSlicer is to be installed in a FHS directory structure" 0) option(SLIC3R_PROFILE "Compile OrcaSlicer with an invasive Shiny profiler" 0) option(SLIC3R_PCH "Use precompiled headers" 1) @@ -308,6 +309,10 @@ if (SLIC3R_GUI) add_definitions(-DSLIC3R_GUI) endif () +if (SLIC3R_CAD) + add_definitions(-DSLIC3R_CAD) +endif () + if(SLIC3R_DESKTOP_INTEGRATION) add_definitions(-DSLIC3R_DESKTOP_INTEGRATION) endif () @@ -1076,32 +1081,30 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${TOP_LEVEL_PROJECT_DIR}/deps/WebView2/lib/win-${_arch}/WebView2Loader.dll DESTINATION ${_out_dir}) - file(COPY ${CMAKE_PREFIX_PATH}/bin/occt/TKBO.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKBRep.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKCAF.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKCDF.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKernel.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKG2d.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKG3d.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKGeomAlgo.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKGeomBase.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKHLR.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKLCAF.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKMath.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKMesh.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKPrim.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKService.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKShHealing.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKSTEP.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKSTEP209.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKSTEPAttr.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKSTEPBase.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKTopAlgo.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKV3d.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKVCAF.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKXCAF.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKXDESTEP.dll - ${CMAKE_PREFIX_PATH}/bin/occt/TKXSBase.dll + # Stage the OCCT toolkits libslic3r links (published as OCCT_LIBS), not whatever the + # deps prefix happens to hold, and fail the configure if one of them is missing. + if (NOT OCCT_LIBS) + message(FATAL_ERROR "OCCT_LIBS is not set; libslic3r must be configured first.") + endif () + set(_occt_bin "${CMAKE_PREFIX_PATH}/bin/occt") + set(_occt_dlls "") + set(_occt_staged "") + set(_missing_occt "") + foreach (_tk IN LISTS OCCT_LIBS) + if (EXISTS "${_occt_bin}/${_tk}.dll") + list(APPEND _occt_dlls "${_occt_bin}/${_tk}.dll") + list(APPEND _occt_staged "${_out_dir}/${_tk}.dll") + else () + list(APPEND _missing_occt "${_tk}.dll") + endif () + endforeach () + if (_missing_occt) + message(FATAL_ERROR + "OCCT DLLs missing from ${_occt_bin}/: ${_missing_occt}\n" + "Rebuild the dependencies (build_release_vs2022.bat deps) with the same " + "SLIC3R_CAD setting as this project.") + endif () + file(COPY ${_occt_dlls} ${CMAKE_PREFIX_PATH}/bin/freetype.dll ${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll ${CMAKE_PREFIX_PATH}/bin/swresample-5.dll @@ -1109,38 +1112,11 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${CMAKE_PREFIX_PATH}/bin/avutil-59.dll DESTINATION ${_out_dir}) - set(${output_dlls} + set(_dll_list ${_out_dir}/libgmp-10.dll ${_out_dir}/libmpfr-4.dll ${_out_dir}/WebView2Loader.dll - ${_out_dir}/TKBO.dll - ${_out_dir}/TKBRep.dll - ${_out_dir}/TKCAF.dll - ${_out_dir}/TKCDF.dll - ${_out_dir}/TKernel.dll - ${_out_dir}/TKG2d.dll - ${_out_dir}/TKG3d.dll - ${_out_dir}/TKGeomAlgo.dll - ${_out_dir}/TKGeomBase.dll - ${_out_dir}/TKHLR.dll - ${_out_dir}/TKLCAF.dll - ${_out_dir}/TKMath.dll - ${_out_dir}/TKMesh.dll - ${_out_dir}/TKPrim.dll - ${_out_dir}/TKService.dll - ${_out_dir}/TKShHealing.dll - ${_out_dir}/TKSTEP.dll - ${_out_dir}/TKSTEP209.dll - ${_out_dir}/TKSTEPAttr.dll - ${_out_dir}/TKSTEPBase.dll - ${_out_dir}/TKTopAlgo.dll - ${_out_dir}/TKV3d.dll - ${_out_dir}/TKVCAF.dll - ${_out_dir}/TKXCAF.dll - ${_out_dir}/TKXDESTEP.dll - ${_out_dir}/TKXSBase.dll - ${_out_dir}/freetype.dll ${_out_dir}/avcodec-61.dll ${_out_dir}/swresample-5.dll @@ -1148,6 +1124,8 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${_out_dir}/avutil-59.dll PARENT_SCOPE ) + list(APPEND _dll_list ${_occt_staged}) + set(${output_dlls} ${_dll_list} PARENT_SCOPE) endfunction() diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 4b1daf5aa8..e02186705b 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -55,6 +55,7 @@ endif () set(DEP_DOWNLOAD_DIR ${CMAKE_CURRENT_SOURCE_DIR}/DL_CACHE CACHE PATH "Path for downloaded source packages.") set(FLATPAK FALSE CACHE BOOL "Toggles various build settings for flatpak, like /usr/local in DESTDIR or not building wxwidgets") +option(SLIC3R_CAD "Build the SolveSpace solver and OCCT ModelingAlgorithms module the parametric Design/CAD tab needs. Must match the main project's SLIC3R_CAD." ON) if ("${DESTDIR}" STREQUAL "" OR "${DESTDIR}" STREQUAL "${AUTOGENERATED_DESTDIR}") if (LINUX AND (NOT DEFINED USE_OLD_DESTDIR_PREV OR USE_OLD_DESTDIR_PREV) AND EXISTS "${CMAKE_BINARY_DIR}/destdir/usr/local" AND NOT EXISTS "${CMAKE_BINARY_DIR}/OrcaSlicer_dep/usr/local") @@ -363,6 +364,11 @@ include(GLEW/GLEW.cmake) include(GLFW/GLFW.cmake) include(OpenCSG/OpenCSG.cmake) +set(SLVS_PKG "") +if (SLIC3R_CAD) + include(SLVS/SLVS.cmake) + set(SLVS_PKG dep_SLVS) +endif () include(TBB/TBB.cmake) @@ -452,6 +458,7 @@ set(_dep_list dep_NLopt dep_OpenVDB dep_OpenCSG + ${SLVS_PKG} dep_OpenCV dep_Eigen dep_CGAL diff --git a/deps/OCCT/OCCT.cmake b/deps/OCCT/OCCT.cmake index 62bfcf8e76..45263d6ae3 100644 --- a/deps/OCCT/OCCT.cmake +++ b/deps/OCCT/OCCT.cmake @@ -11,6 +11,21 @@ else() set(library_build_type "Static") endif() +# SLIC3R_CAD (declared in deps/CMakeLists.txt) builds OCCT's ModelingAlgorithms module +# (fillet/offset/loft), whose only consumer is the parametric Design/CAD tab. With it OFF +# the deps prefix matches upstream exactly. +# +# With it ON the delta is THREE toolkits, not two: TKFillet (7.40 MiB archive, used via +# BRepFilletAPI), TKOffset (5.38 MiB, used via BRepOffsetAPI) and TKFeat (4.42 MiB), which +# nothing here references but which the module flag builds anyway -- it is all-or-nothing +# per module. The module's other nine toolkits are built either way, because DataExchange +# (the STEP path upstream already ships) depends on them. +# +# On macOS/Linux OCCT links statically, so an unreferenced toolkit costs build time and no +# shipped bytes. The Windows figure is a real DLL cost and has NOT been measured -- an +# earlier "3.77 MiB, Windows only" note here covered only two of the three toolkits and is +# not a number to quote. See docs/cad_dependency_weight.md. + if (IN_GIT_REPO) set(OCCT_DIRECTORY_FLAG --directory ${BINARY_DIR_REL}/dep_OCCT-prefix/src/dep_OCCT) endif () @@ -35,7 +50,7 @@ orcaslicer_add_cmake_project(OCCT #-DBUILD_MODULE_DataExchange=OFF -DBUILD_MODULE_Draw=OFF -DBUILD_MODULE_FoundationClasses=OFF - -DBUILD_MODULE_ModelingAlgorithms=OFF + -DBUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD} -DBUILD_MODULE_ModelingData=OFF -DBUILD_MODULE_Visualization=OFF ${_occt_compiler_args} diff --git a/deps/SLVS/CMakeLists.txt.in b/deps/SLVS/CMakeLists.txt.in new file mode 100644 index 0000000000..b09ad8da98 --- /dev/null +++ b/deps/SLVS/CMakeLists.txt.in @@ -0,0 +1,65 @@ +# Replaces the upstream SolveSpaceLib CMakeLists, which builds a demo executable and +# has no install rules. The sources themselves are used verbatim. +cmake_minimum_required(VERSION 3.13) + +project(SLVS VERSION 3.0) + +add_library(slvs + libslvs/constrainteq.cpp + libslvs/entity.cpp + libslvs/expr.cpp + libslvs/system.cpp + libslvs/util.cpp + libslvs/platform/unixutil.cpp + libslvs/lib.cpp + libslvs/SolveSpaceSystem.cpp) + +target_compile_features(slvs PUBLIC cxx_std_11) + +# LIBRARY strips the solver core out of the SolveSpace application it was extracted from. +target_compile_definitions(slvs PRIVATE -DLIBRARY) +if (MSVC) + target_compile_definitions(slvs PRIVATE -D_CRT_SECURE_NO_WARNINGS -D_SCL_SECURE_NO_WARNINGS) +endif () + +target_include_directories(slvs + PUBLIC $ + PRIVATE ${PROJECT_SOURCE_DIR}/libslvs) + +# libslic3r is linked into shared targets, so this has to be position independent. +set_target_properties(slvs PROPERTIES POSITION_INDEPENDENT_CODE ON) + +# 2018 code, predating the project's warning settings; it is not ours to clean up. +if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + target_compile_options(slvs PRIVATE -w -fno-strict-aliasing) +endif () + +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY AnyNewerVersion) + +install(TARGETS slvs + EXPORT ${PROJECT_NAME}Targets + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +set(ConfigPackageLocation ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}) + +install(EXPORT ${PROJECT_NAME}Targets + FILE "${PROJECT_NAME}Config.cmake" + NAMESPACE ${PROJECT_NAME}:: + DESTINATION ${ConfigPackageLocation}) + +install(FILES + ${PROJECT_SOURCE_DIR}/libslvs/include/slvs.h + ${PROJECT_SOURCE_DIR}/libslvs/include/SolveSpaceSystem.h + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake" + DESTINATION ${ConfigPackageLocation}) diff --git a/deps/SLVS/SLVS.cmake b/deps/SLVS/SLVS.cmake new file mode 100644 index 0000000000..69dcdc891f --- /dev/null +++ b/deps/SLVS/SLVS.cmake @@ -0,0 +1,13 @@ +# libslvs — the geometric constraint solver behind the Design tab's sketch constraints. +# Extraction of solvespace.com's libslvs, taken verbatim from JacobStoren/SolveSpaceLib; +# only the CMakeLists is ours, because upstream's builds a demo and installs nothing. +# GPLv3, compatible with this fork's licence. Self-contained: no external dependencies. +orcaslicer_add_cmake_project(SLVS + URL https://github.com/JacobStoren/SolveSpaceLib/archive/4d8704523e4bf212fadf5189f92484244f670fea.zip + URL_HASH SHA256=1c4bdde9c3c6ef20ea4b50b73601de56769f2eb131b36927d7c6489f102e6c30 + PATCH_COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_LIST_DIR}/CMakeLists.txt.in ./CMakeLists.txt +) + +if (MSVC) + add_debug_dep(dep_SLVS) +endif () diff --git a/docs/CAD/GAP_ANALYSIS_vs_ONSHAPE.md b/docs/CAD/GAP_ANALYSIS_vs_ONSHAPE.md new file mode 100644 index 0000000000..bf603cf2c1 --- /dev/null +++ b/docs/CAD/GAP_ANALYSIS_vs_ONSHAPE.md @@ -0,0 +1,160 @@ +# Orca-CAD vs Onshape — capability gap analysis + +Generated 2026-07-22 by enumerating the source, not from recollection: +`CadFeatureType` and `add_*` in `src/libslic3r/CAD/CadDocument.hpp`, `Tool` in +`src/slic3r/GUI/CAD/DesignPanel.hpp`, `Mode` in `src/slic3r/GUI/CAD/DesignSketchTool.hpp`, +`SketchConstraintType` + `SketchEntity::Type` in `src/libslic3r/CAD/SketchEngine.hpp`, +and the JSON-RPC dispatch in `src/slic3r/GUI/CAD/McpControl.cpp`. + +**Scope note.** Onshape is a cloud PLM platform; Orca is a Design tab inside a +slicer. A large share of Onshape's surface (release management, branching, real-time +collaboration, FEA, rendering, PDM) is out of scope by construction and is listed +separately at the bottom rather than counted as a "missing tool". + +--- + +## 1. What Orca already has + +### 2D sketcher — near parity with Onshape +This is the strongest area. Very little is missing. + +| Category | Orca | +|---|---| +| Entities | Line, Polyline, Arc (3-point / tangent / center), Circle (center / 2-point / 3-point), Point, Ellipse, Elliptical arc, B-spline | +| Shapes | Rectangle (corner / center / oblique / rounded), Slot, Arc-slot, Polygon | +| Edit ops | Fillet, Chamfer, Offset, Mirror, Trim, Extend | +| Transforms | Move, Rotate, Scale, Linear array, Polar array | +| Constraints (19) | Fix, Coincident, Horizontal, Vertical, Distance, LockX, LockY, EqualLength, Parallel, Perpendicular, Concentric, Tangent, Midpoint, Symmetric, Angle, Radius, Diameter, PointOnLine, PointOnObject | +| Dimensions | Length, Diameter, Radius, Angle, Distance, Distance-to-line | + +Solver: vendored SolveSpace (`libslvs`, GPL-3.0) — the same solver lineage as a +commercial-grade sketcher. + +### Part features + +| Present | Notes | +|---|---| +| Extrude | + up-to-face / up-to-point, taper, flip | +| Revolve | angle-arc gizmo | +| Sweep | along a path | +| Loft | multi-profile | +| Fillet / Chamfer | edge-level | +| Draft | face taper | +| Shell | wall thickness + open face | +| Hole / Thread | face-aware placement | +| Pattern | linear + circular | +| Boolean | New / Add / Cut / Intersect, with face-mating | +| Cut | plane-based, signed offset | +| Datum plane | offset / 2-face / 2-edge derived | +| Import | STEP (B-rep) + mesh→B-rep (native mesh2step port) | +| Export | STEP (native B-rep, not tessellated) | +| Multi-body | + per-body colour | +| Section view | with flip | +| Undo/redo | full feature-tree recompute | +| 3MF persistence | parametric recipe survives save/load | + +### Automation +9 MCP JSON-RPC methods: `describe_tools`, `describe_scene`, `query_topology`, +`measure`, `slice_body`, `import_step`, `import_mesh`, `validate_against`, plus +build actions `extrude`, `revolve`, `fillet`, `chamfer`, `hole`, `boolean`, `pattern`. +Onshape's equivalent is its REST API + FeatureScript. + +--- + +## 2. Missing tools — ranked by impact + +### Tier 1 — structural absences (whole subsystems) + +**1. Assemblies and mates.** Entirely absent. No assembly document, no mate +connectors, no fastened / revolute / slider / cylindrical / planar / ball / pin-slot +mates, no assembly patterns, no interference detection, no exploded views. +`bool_target_face` / `bool_tool_face` do face-to-face *mating* for a boolean, which +is geometric alignment, not a kinematic joint. +*Impact:* multi-part products cannot be positioned or validated as a mechanism. +*Note:* an MCP-side `align_instance_to_face` / `create_*_mate` vocabulary already +exists on the Onshape bridge in this workspace, so the target semantics are known. + +**2. Drawings / 2D documentation.** Absent. No drawing sheets, dimensioned views, +section/detail views, GD&T, title blocks, or BOM. +*Impact:* nothing manufacturable-by-a-third-party leaves the tool. For 3D printing +this matters less than for machining, which is the honest reason it is Tier 1 by +CAD convention but arguably Tier 3 for this product. + +**3. Variables, equations, configurations.** Absent — no `add_variable`, no +expression evaluation, no configuration table. Every dimension is a literal double. +*Impact:* this is the biggest *parametric* gap. "Make this bracket for an M4 vs M5 +bolt" requires re-editing every dependent feature by hand. Onshape's Variable +Studio + configurations are a core differentiator, and this is the cheapest Tier 1 +item to close for the size of the payoff. + +**4. Surface modelling.** Absent. No surface extrude/revolve/loft/sweep, no fill, +knit, trim/extend surface, offset surface, or thicken. Orca is solid-only. +*Impact:* organic/complex shapes and repair of imported junk geometry are impossible. +OCCT already provides all of it (`TKOffset`, `TKBRep`), so the kernel is not the +blocker — only UI and feature plumbing. + +**5. Sheet metal.** Absent. No flange, bend, tab, relief, or flat-pattern unfold. +*Impact:* arguably out of scope for an FDM slicer; listed for completeness. + +### Tier 2 — individual features with clear demand + +| Missing | Why it matters | Cheap? | +|---|---|---| +| **Mirror body** (part-level) | Sketch mirror exists; mirroring a *solid* about a plane does not. Extremely common. | Yes — OCCT `gp_Trsf` mirror + fuse | +| **Helix / spiral curve** | No helix ⇒ no springs, no custom threads, no spiral vase geometry. Sweep exists but has no helical path to sweep along. | Yes | +| **Move / rotate body as a real feature** | `m_body_xform` exists but is **display-only** (memory #1655) — it never enters the B-rep. Export/boolean see the original position. | Medium | +| **Split body** | Cut removes material; splitting one body into two independently-usable bodies is absent. Very relevant for print-in-parts. | Medium | +| **Thicken** | Solid from a surface/face offset. | Needs surfaces | +| **Rib** | Standard structural feature. | Medium | +| **Delete face / move face / replace face** | Direct/dumb-solid editing — the main tool for fixing imported STEP. Given Orca imports STEP *and* meshes, its absence is felt. | Medium | +| **Datum axis, coordinate system** | Only datum *planes* exist. Axes are needed for revolve/pattern references. | Yes | +| **Mass properties** | `GeometryEngine` computes a volume internally, but there is no volume/mass/COM/inertia readout. For print cost/time estimation this is nearly free to expose. | Yes — trivial | +| **Measure tool in the GUI** | `measure` exists over MCP but there is no interactive measure in the UI. | Yes | +| **Hole standards library** | Hole exists, but no counterbore/countersink/tapped standards (ISO/ANSI) with callouts. | Medium | +| **Project / convert edges into a sketch** | Cannot reference existing solid edges as sketch geometry ("Use" in SolidWorks). A significant sketcher gap given everything else is present. | Medium | +| **Construction geometry** | Could not confirm a construction/reference-line flag on sketch entities. | Yes if absent | +| **Curve tools** | Projected curve, bridging curve, composite curve, 3D fit spline. | Medium | +| **Pattern on curve / pattern faces** | Pattern is linear + circular of whole bodies only; no curve-driven pattern, no feature/face pattern. | Medium | +| **Wrap / emboss** | Text or sketch wrapped onto a curved face. | Hard | +| **Enclose** | Solid from bounded void regions. | Medium | + +### Tier 3 — platform capabilities (out of scope by construction) + +Version control with branching/merging, release management, real-time multi-user +collaboration, cloud PDM, FeatureScript custom-feature authoring, simulation/FEA, +photorealistic rendering, app store/integrations. These are Onshape-the-platform, +not Onshape-the-modeller. Not defects in Orca. + +--- + +## 3. Recommended priority + +If the goal is "credible parametric CAD inside a slicer", the ordering that buys +the most capability per unit of work: + +1. **Variables + expressions** — unlocks genuine parametric reuse; no new kernel work. +2. **Mass properties + GUI measure** — nearly free, immediately useful for printing. +3. **Mirror body, datum axis, helix** — small, self-contained, high-frequency features. +4. **Promote move/rotate body from display-only to a real B-rep feature** — closes a + correctness gap, not just a missing tool (exports currently disagree with the view). +5. **Split body** — high value for print-in-parts workflows. +6. **Project edges into sketch** — the sketcher's most conspicuous hole. +7. **Surface modelling** — large, but OCCT already ships the algorithms. +8. **Assemblies** — largest effort; only worth it if Orca targets multi-part products. + +Deliberately last: drawings and sheet metal — high cost, low relevance to an +FDM-oriented tool. + +--- + +## 4. Honest summary + +Orca's **sketcher is at or near Onshape parity**, and its **solid feature set +covers the mainstream modelling path** (sketch → extrude/revolve/sweep/loft → +dress-up → boolean/pattern). What is absent is *breadth*: assemblies, surfaces, +sheet metal, drawings, and — most importantly for a tool calling itself parametric — +**variables and configurations**. + +The single most defensible criticism is #3: without variables, the feature tree is +parametric in *structure* but not in *value*, so the promise of "change one number +and the model updates" is only half delivered. diff --git a/docs/CAD/cad_dependency_weight.md b/docs/CAD/cad_dependency_weight.md new file mode 100644 index 0000000000..0b64ac8097 --- /dev/null +++ b/docs/CAD/cad_dependency_weight.md @@ -0,0 +1,136 @@ +# Dependency weight of the Design/CAD subsystem + +What the Design tab actually costs a maintainer who merges it. Written to be checkable: +every number below is reproducible with the command that produced it, and the places where +a number is still missing say so instead of guessing. + +Measured on Linux x86_64, OCCT V7_6_0, in the `snapmaker-deps` build image. + +## Summary + +| | Cost | +|---|---| +| New third-party dependencies | **none** | +| OCCT build flag | `BUILD_MODULE_ModelingAlgorithms=ON` | +| Extra OCCT toolkits *built* | 3 (TKFillet, TKOffset, TKFeat) | +| Extra OCCT toolkits *linked* | 2 (TKFillet, TKOffset) | +| Vendored code | `src/libslic3r/slvs`, 9,339 lines, 380 KiB, GPLv3 | +| Own object code | 6.79 MiB unstripped `.o` (7.13 MiB with the solver) | + +OCCT is **already** an upstream dependency — Orca uses it for STEP import. The Design tab +does not add a library; it turns on one more OCCT module. + +## The OCCT module flag + +`deps/OCCT/OCCT.cmake` gates the module on `SLIC3R_CAD`: + +```cmake +-DBUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD} # was hard-coded OFF +``` + +With `SLIC3R_CAD=OFF` the deps prefix matches upstream exactly. + +`ModelingAlgorithms` contains 12 toolkits, but **most were already being built**, because +`DataExchange` — the STEP path upstream already ships — depends on them. The honest delta is +only the toolkits that DataExchange's dependency closure does *not* reach: + +``` +ModelingAlgorithms = TKGeomAlgo TKTopAlgo TKPrim TKBO TKBool TKHLR + TKFillet TKOffset TKFeat TKMesh TKXMesh TKShHealing + +already required by DataExchange: TKBO TKBool TKGeomAlgo TKHLR TKMesh + TKPrim TKShHealing TKTopAlgo +true delta: TKFeat TKFillet TKOffset TKXMesh +``` + +Reproduce by walking `adm/MODULES` and each toolkit's `src//EXTERNLIB` in the OCCT +source tree. + +### Sizes of the delta toolkits + +Static archives in the deps prefix. These are *build artifacts*, not shipped bytes — a +static link pulls in only the objects it references: + +| Toolkit | Archive | Referenced by the Design tab? | +|---|---|---| +| TKFillet | 7.40 MiB | yes — `BRepFilletAPI` | +| TKOffset | 5.38 MiB | yes — `BRepOffsetAPI`, `BRepOffset_` | +| TKFeat | 4.42 MiB | **no** | +| TKXMesh | — | not produced at all | + +TKFeat is worth calling out: nothing in the Design tab references it, and it is absent from +the `TKFillet`/`TKOffset` dependency closure, so it is built for nothing. OCCT's module flag +is all-or-nothing per module, which is why it comes along. It costs build time and zero +shipped bytes on any platform that links OCCT statically. + +**A correction to the record.** The comment in `deps/OCCT/OCCT.cmake` and the earlier +summary both said the delta was "TKFillet + TKOffset — 3.77 MiB, Windows only". The toolkit +list was incomplete: TKFeat is built too. The 3.77 MiB figure covers 2 of the 3 built +toolkits and has not been re-derived here — see the gap below. + +## What is not measured yet + +Two numbers a maintainer may reasonably ask for are **not** in this document, because +producing them honestly needs a build this machine cannot do: + +1. **Windows DLL delta.** OCCT builds shared on Windows, so the shipped cost there is real + DLL bytes rather than linker-selected objects. That needs a Windows build to size — + tracked as the cross-platform build proof (`gix`). +2. **Clean-build time delta.** Measuring it means building the deps prefix twice, with the + flag ON and OFF, on the same machine. The incremental figures from day-to-day work do not + answer the question and are not offered as if they did. + +Do not quote a number for either until it has been measured. + +## Vendored solver + +`src/libslic3r/slvs` — the 2D sketch constraint solver extracted from SolveSpace. + +- 19 files: 8 `.cpp`, 11 `.h`, plus `LICENSE` +- 9,339 lines, 380 KiB of source, 0.34 MiB of object code +- **GPLv3**, `LICENSE` preserved verbatim in the vendored directory + +The fork is **AGPLv3**. GPLv3 code combines into an AGPLv3 work without difficulty: AGPLv3 +§13 provides explicit compatibility in that direction. No licence question to resolve. + +It is live code, not a carried corpse — `SketchSolver.cpp` is its only consumer and drives +every sketch constraint in the Design tab. + +## Own code + +Object sizes from the release build (unstripped, so these include debug information and +overstate the shipped contribution): + +| Object | Size | +|---|---| +| DesignPanel.o | 2.22 MiB | +| McpControl.o | 1.69 MiB | +| DesignSketchTool.o | 0.88 MiB | +| CadDocument.o | 0.76 MiB | +| SketchEngine.o | 0.40 MiB | +| DesignCanvas.o | 0.37 MiB | +| GeometryEngine.o | 0.32 MiB | +| SketchSolver.o | 0.15 MiB | +| slvs (all objects) | 0.34 MiB | +| **total** | **7.13 MiB** | + +For scale, the linked binary is 137.1 MiB. + +## Reproducing + +```bash +# toolkit membership and dependency closure +R= +cat $R/adm/MODULES # module -> toolkits +cat $R/src//EXTERNLIB # toolkit -> its dependencies + +# archive sizes +ls -l /lib/libTK{Fillet,Offset,Feat}.a + +# what the Design tab actually references +grep -rE 'BRepFilletAPI|BRepOffsetAPI|BRepOffset_|BRepFeat' src/libslic3r/ + +# vendored solver +wc -l src/libslic3r/slvs/*.cpp src/libslic3r/slvs/**/*.h +head -3 src/libslic3r/slvs/LICENSE +``` diff --git a/docs/CAD/cad_ux_guidelines.md b/docs/CAD/cad_ux_guidelines.md new file mode 100644 index 0000000000..d0290a1d3d --- /dev/null +++ b/docs/CAD/cad_ux_guidelines.md @@ -0,0 +1,704 @@ +# Orca-CAD — UX guidelines and design charter + +Status: proposed, v1. Owner: design working group. Applies to the Design tab — +the parametric CAD environment inside OrcaSlicer. + +This document is a **review instrument**, not an essay. Sections 3–9 are written +so that a reviewer can hold a pull request against them and get a yes or a no. +If a rule here cannot be failed, it is badly written and should be rewritten. + +--- + +## 1. Why this exists + +A CAD tool acquires its interface by accretion. Every feature arrives needing +"just one more field", the side panel is the cheapest place to put it, and after +forty features the product is FreeCAD: complete, respected, and abandoned by +almost everyone who opens it once. That end state is not a failure of any single +decision. It is the sum of forty locally reasonable ones taken without a written +rule to violate. + +So we write the rule down first, and we make additions argue against it. + +## 2. Product thesis + +**Orca-CAD is a modelling space for people who want a part, inside the tool that +prints it.** + +Three audiences, one interface: + +- **The fourteen-year-old on a school laptop.** Free software, on the machine + they already have, with no account, no subscription, no licence and no + tutorial. They open the tab because they want a bracket for a bike light, and + an hour later it is printing. This is not the charity case at the bottom of + the list — it is the reason the project is worth doing. A CAD tool that only + the equipped can run is a tool for people who were already going to design + something; this one has to be a creative instrument in the hands of someone + who did not yet know they could make things. Everything in §6.1 exists to + keep that door open, and nothing gets to close it for the convenience of the + other two audiences. +- **The maker** who has an idea and a printer, and who has bounced off FreeCAD. + They should be modelling something real within ten minutes of first opening + the tab, without a tutorial, without knowing the word "constraint". +- **The mechanical designer** who needs assemblies, mates, exploded views, + variables, and a feature history they can edit six months later. They should + not have to leave for SolidWorks the moment the work gets serious. + +The order matters. When a decision helps one audience and hurts another, the +earlier one wins unless there is a written argument for why not. + +The reference for *how it feels* is Shapr3D: direct, gestural, quiet, almost no +chrome, depth revealed by what you touch rather than by what is on screen. The +anti-references are Blender (a modal keyboard language you must learn before the +first success) and FreeCAD (a workbench-and-dialog architecture where the +geometry is a preview of a form you fill in elsewhere). + +We are not cloning Shapr3D's feature set. We are adopting its *interaction +economy*: the smallest number of visible controls that still makes an expert +fast. + +**And one thing neither reference has:** Orca-CAD lives inside a slicer. The +plate, the nozzle, the material and the print constraints are known to the +application at design time. Designing for print is not a plugin here, it is the +home advantage. Where a rule below trades generality for print-awareness, it +trades in favour of print-awareness. + +## 3. The laws + +Non-negotiable. A change that breaks one of these does not get merged on the +grounds that it was easier, that the alternative is more work, or that another +CAD does it that way. Each law carries a test — the question a reviewer asks. + +### L1 — Geometry first: you point, then you act + +Controls live **on the geometry**: handles, arrows, points, small circles and +boxes, with an inline label tab for typed values. Not in a side panel of combos +and spin fields. + +The canonical gesture: **select a face or plane in the viewport, then click the +sketch tool.** Never: click the sketch tool, then choose a plane from a list. +The tool consumes what you pointed at — and, better still, the thing you pointed +at offers the tool itself (§4). + +> **Test.** Can the operation be performed start to finish without the pointer +> leaving the viewport, except to press the tool itself? If a control had to be +> added to a panel to make it work, the design is not finished. + +This is the law the others serve. It was stated after two proposals in a row +reached for a dropdown, and the failure mode it names is real and recurrent: a +fix that "adds a row to the plane combo" is the side-panel pattern wearing a +different hat. + +### L2 — Everything draggable is typable, and everything typable is draggable + +Any value produced by direct manipulation (a fillet radius, an extrude depth, a +pattern spacing, a plane offset) shows a live label on the geometry, and that +label is an editable field. Any value entered numerically has a corresponding +handle in the viewport. + +Dragging is for finding the answer. Typing is for committing to it. A tool that +offers only one of the two is half a tool. + +> **Test.** Point at the number the tool produces. Can you drag it? Can you +> click it and type? Both must be yes. + +### L3 — Noun then verb, always the same way round + +Selection precedes action, without exception, across sketch tools, features, +dress-up, booleans and mates. There is no tool in the product that is armed +first and asks for its input afterwards. + +> **Test.** Does this tool work if the user has already selected the thing they +> want it applied to? Does it work *only* that way? + +### L4 — No modal dialog in the modelling loop + +Dialogs belong to document-level actions: open, save, import, export, preferences. +Modelling never opens one. A feature that needs three values gets three labels on +the geometry, not a form; a feature that needs confirming gets a ghost preview and +a confirm/cancel puck in the scene beside it (§4.2) — an object, not a window: the +camera still orbits, the values are still editable, nothing is blocked. + +> **Test.** Between starting an operation and seeing its result, does a window +> appear that must be dismissed? If yes, redesign. + +### L5 — One click, one visible change + +Every click either changes what is on screen or tells the user why it did not. +A click that opens something invisible, arms an invisible state, or requires a +second identical click to have any effect is a defect, not a design. + +This law exists because we shipped its violation twice. Sketch-tool family +buttons were flyouts whose first click only rendered a pressed state — three +separate sessions filed bugs against tools that were working. Solid picking used +a click *cycle* (first click selects the body, second refines to the face), so +sketching on a face appeared broken to anyone who clicked a face once, the way +every human does. + +> **Test.** Perform the gesture exactly once, as a first-time user would. Take a +> screenshot. Is the state visibly different, and is the difference the one the +> user intended? + +### L6 — The default is the answer four times out of five + +Every option that has a default must have the *common* answer as its default, +measured against real parts, not against generality. "New body" as the default +result of an extrude is wrong: most extrudes join. Radius as the input for a +circle is wrong: drawings give diameter. + +> **Test.** Take ten real parts. In how many is the default correct? Below eight, +> change the default or infer it from context. + +### L7 — Errors are caught before the commit, in the user's words + +A self-intersecting profile, a cut that removes no material, a wall thinner than +the nozzle: these are reported at the moment they become knowable, on the +geometry that is wrong, phrased as what happened and what to do — not as a kernel +exception after the fact, and never silently. + +> **Test.** Is the failure detectable before the user commits? Then it must be +> reported before the user commits. Read the message aloud: does it name a thing +> the user can see and an action they can take? + +### L8 — The camera is the application's job + +Selecting a sketch plane orients the view to it. Committing a feature does not +throw the camera away. Zoom-to-fit exists and is one keystroke. The user is never +required to fight the view in order to reach the geometry, and orbit is bound to +the gesture people actually try. + +> **Test.** Count camera manipulations in a representative modelling session. +> Any camera action the application could have performed for the user is a bug. + +### L9 — Accessible by construction, not by retrofit + +The floor, applied to every new interaction (details in §6.2): full keyboard +reach, no meaning carried by colour alone, hit targets that survive a shaky hand +and a HiDPI screen, legible labels over an arbitrary 3D background, no gesture +that depends on timing. + +> **Test.** Drive the whole interaction from the keyboard. Then drive it in +> greyscale. Both must work. + +### L10 — Vocabulary from the drawing office + +Names come from the language of people who make parts: fillet, chamfer, boss, +rib, counterbore, mate, exploded view. Not from the kernel (no "boolean +subtract", no "B-rep"), not from invented product-speak. Where the drawing-office +word and the beginner's word differ, use the drawing-office word and make the +tooltip teach it — an approachable tool that leaves the user unable to talk to a +machinist has failed them. + +> **Test.** Would a shop-floor engineer recognise this word? Would a first-time +> user be able to look it up and find a real definition? + +### L11 — The floor is a school laptop, and nothing is behind a door + +The product runs, completely, on a low-end laptop with integrated graphics and a +small screen, offline, with no account, no subscription and no feature withheld. +No capability in this document is reserved for a paid tier, a cloud service, a +plugin, or a machine with a discrete GPU — there is one product and everybody +gets all of it. + +> **Test.** On the reference low-end machine (§6.1), at 1366×768, with the +> network cable pulled and no account ever created: does this feature work, and +> is it usable at an honest frame rate? Any "no" is a defect, not a limitation. + +## 4. Interaction grammar — object-driven + +The rules above compose into one sentence the whole product obeys: + +> **Point at geometry → the geometry offers what can be done to it → choose the +> tool → manipulate handles and type exact values → confirm or cancel.** + +The selection does not merely feed the tool. **The selection determines which +tools exist.** Pick a planar face and the product shows you the small set of +things a planar face can become — sketch on it, extrude it, hole it, shell it, +put a datum on it. Pick an edge and that set is fillet, chamfer, and the sketch +tools that can use it as a reference. Nothing else is offered, because nothing +else is possible. + +This is the single largest thing we can do for a first-time user, and it is +worth stating as the reason: a beginner's difficulty is not operating a tool, +it is **not knowing which tools apply to what they are looking at**. A palette +of sixty icons answers a question they cannot yet ask. A face that offers its +own five verbs teaches the model of the product by using it. It also removes an +entire class of failure — a tool that silently does nothing because the +selection was wrong can no longer be reached. + +### 4.1 The offer, and the one thing that makes it work + +The flow, in full: + +> **left-click the geometry to select it → right-click to open the offer → a +> vertical list, always in the same order, each row an icon, a name and its +> keyboard shortcut → click.** + +- **Selecting and acting are separate gestures.** Left-click only ever selects, + so pointing at things is quiet — nothing pops up while you look around. + Right-click on the selection opens the offer, at the pointer, over the + geometry it acts on. +- **Order is fixed and it is the whole point.** A verb occupies one permanent + row, and that row is the same in every selection where the verb appears. + Dress-up is the fourth row on an edge, on a face, on a body, on the day the + product ships and two years later. The hand learns the position; the eye stops + being needed. +- **What does not apply is DISABLED IN PLACE, never removed.** This is the + single strongest thing the list does, and it is why it beat the radial we + drew first: a greyed row still carries its name *and the reason it is grey* — + "Create a sketch, or pick a solid face, first", "Create a solid body to + pattern first" — in the words the product already ships. On a first-run + document the offer is therefore not a mostly-empty control but a map of what + the product does and what you have to do first. +- **It is an accelerator, not a toll gate.** The toolbar and the single-letter + shortcuts keep working exactly as they do now, and pressing a tool directly + consumes the same selection (L3). An expert never has to open the offer; a + beginner never has to know the toolbar exists. Both routes land in the same + place — this is the only way one interface serves §2's three audiences. +- **Every row shows its keyboard shortcut**, right-aligned so the keys stack + into a column the eye learns without trying, beside the icon and the + drawing-office word (L10). This is deliberate: the offer is the path by which + a user stops needing the offer. You reach for fillet in its row, the row says + "F", and one day your hand types F before the menu has finished opening. A + menu that teaches its own shortcut is how a beginner becomes the power user + who never opens it — the same interface at two speeds, with no "advanced mode" + between them (§7). +- **A family with more than one applicable verb opens a submenu** to the side, + in its own fixed order. A family with exactly one shows that verb directly, so + the common path is never one click longer than it needs to be. +- **It never blocks the view of what it acts on**: it opens beside the pick, + never over it, with a thin leader back to the point it belongs to, and it + dismisses the moment the selection changes. +- **The header names what is selected** ("Top face · Body 1"), because a user + who mis-picked should find that out before choosing a verb, not after. + +#### Opening the offer on every machine + +Right-click is the primary gesture and every platform must have a first-class +equivalent — this is a reach requirement (L11), not a nicety: + +| Input | Gesture | +|---|---| +| Two-button mouse | right-click | +| Trackpad | two-finger tap (the OS-standard secondary click) | +| macOS, one-button mouse | **long-press**, and Ctrl-click, which is the platform convention | +| Keyboard | the Menu key, or Shift+F10, on the current selection | +| Touch / pen | long-press | + +The long-press is an **additional** route, never the only one — §6.2 forbids +press-and-hold as a sole path to a function, and it stays forbidden. Every +opening gesture is reachable at least two ways on every platform, and the +keyboard route exists everywhere. A long-press must show that it is charging +(a growing ring under the finger) so a user who holds too briefly learns why +nothing happened rather than concluding the product is broken (L5). + +#### The row-constancy invariant + +This is the rule that has to survive every future feature, so it is written as +an invariant rather than as advice: + +> **Every verb has exactly one row index in the offer. That index is identical +> for every selection type in which the verb appears. Verbs that do not apply to +> the current selection are DISABLED IN PLACE, with their reason — the offer is +> never compacted, re-sorted or re-ordered. Adding a verb never changes the +> index of an existing one.** + +Two consequences the group must accept together with the invariant: + +- **No adaptive ordering. Ever.** Not most-used-first, not recently-used-first, + not per-selection frequency. An offer that rearranges itself to be helpful + destroys the only thing that made it fast, and it does so precisely for the + user who has just started to learn it. (Office 2000's adaptive menus are the + textbook case; they were removed.) +- **Greyed rows are the price, and they are cheap.** A compacted menu is shorter + and unlearnable. A constant one is a few rows longer, teaches while it waits, + and is memorised in a week. + +#### The map — RATIFIED 2026-07-31 + +The invariant is not negotiable, and as of 2026-07-31 neither is the assignment: +the row order below is **ratified**. It was argued once; it is not argued again. +Changing an index from here on is a breaking change to every user's muscle +memory and needs the group, not a pull request (§9 q12). + +Eight families, ordered so the sequence itself has a logic: material is created, +grows, is taken away, is refined, is repeated, is moved, is referred to, is +edited. + +| Row | Family | On a face | On an edge | On a body | On text/art | +|---|---|---|---|---|---| +| **1** | Create | Sketch on it | — | — | Edit text | +| **2** | Add material | Extrude, thicken | — | Combine, thicken | Extrude | +| **3** | Remove | Hole, shell | Thread | Shell, cut, split | — | +| **4** | Dress-up | Draft | Fillet, chamfer | Fillet, chamfer | — | +| **5** | Repeat | Pattern | Pattern along it | Pattern, mirror | Pattern | +| **6** | Transform | Align to, mate | — | Move, mate | Move, size | +| **7** | Reference | Plane, axis, measure | Axis, measure | Project, measure, mass | — | +| **8** | Modify | Delete face, edit | — | Edit, colour, delete | Replace art | + +A dash means the row is drawn greyed for that selection, with its reason. + +The authoritative version of this table is **`docs/ux/tool_atlas.json`**, which +carries all 52 verbs with their preconditions and their refusal strings, taken +from the code rather than from memory. Every state it produces — 20 selection +kinds × 2 document states, 40 primary menus and 73 submenus — is rendered by +`docs/ux/mockups/gen_offer_mockups.py` into `docs/ux/offer_atlas.html`. Read the +atlas before proposing a change to the map; the generator refuses to render an +address collision, so the map cannot silently rot. + +#### Rejected: the radial ring + +The first design put the eight families at eight compass points around the pick. +It is recorded here because it is a good idea that loses on evidence, and +someone will propose it again: + +- an inapplicable slot could only be drawn empty, and **an empty slot says + nothing** — the reason text above has nowhere to live; +- the measured fill was **3.45 of 8 slots**, so most of the control was blank + most of the time, and on a fresh document only two of eight were live; +- sketch-mode *Create* needs **nine** addresses; eight forced two primitives + behind a "More" slot, and a ninth position costs the 45° spacing that made the + ring worth having; +- long translated names do not fit around a circle, and screen readers and arrow + keys need bespoke handling a list gets for free; +- a 380 px disc over the model costs more on a 1366×768 screen than a 324 px + list beside it (§6.1). + +What it kept — equidistant targets and a future flick gesture — buys little in a +product whose experts live on the keyboard by design. + +### 4.2 Confirm and cancel are objects, not gestures + +The old rule — click empty space to commit — is withdrawn. It was an invisible +gesture with a destructive meaning: nothing on screen said it, and a stray click +committed a feature the user was still adjusting. That is exactly what L5 +forbids, and it is hostile to the audience §6.1 exists for. + +- **A pending feature carries a confirm/cancel puck**, attached to the geometry + it is editing, next to its handles: ✓ commits, ✗ discards. Enter and Escape + mirror them for the keyboard (L9). It is drawn where the user's attention + already is, and it is the only thing in the viewport that commits. +- **Empty space now means "clear the selection"** — the safe meaning, and the + same meaning everywhere. +- **This is not a dialog** (L4). It is two objects in the scene, on the + geometry, non-modal: the camera still orbits, the tree is still there, the + values are still editable while it waits. +- **Continuous tools do not ask.** Drawing a line, a rectangle, a circle commits + each entity as its own gesture completes — a ✓ per line would destroy the + inner loop. The puck belongs to *features* (extrude, fillet, hole, pattern, + mate) and to sketch edits that hold a pending state. Enter/Escape end a + continuous tool rather than confirming an entity. +- **Ambiguity resolves toward keeping work, never toward losing it.** Starting + another operation while a valid feature is pending commits it rather than + discarding it; if it is not valid, the product says why (L7) and keeps it + pending. Since undo reaches everything (§6.1), the recoverable direction is + always the right default. + +### 4.3 The rest of the grammar + +- **The status line is one imperative sentence** naming what the tool wants + next, and it names the target when the target came from a selection + ("Circle — click centre, then radius · on the picked face"). It is the + authoritative feedback surface for the armed tool; the toolbar is not. +- **Hover previews, click commits.** A hover shows the ghost of what a click + would do wherever this is cheap to compute. +- **Selection is persistent and visible** until consumed or cleared. A tool that + consumes a selection clears it, so the next feature cannot silently inherit it. +- **Every gesture is undoable**, and the feature tree is editable history, not a + log. Re-editing a feature re-enters the same on-geometry interaction that + created it — including its offer and its puck. + +## 5. Layout and screen budget + +The viewport is the application. Chrome is a tax on it. + +- **One toolbar**, contextual to the mode (model / sketch). Tools are grouped by + what they make, not by which subsystem implements them. +- **A left rail for the document, not for parameters**: feature tree, bodies, + variables. It answers "what exists", never "what value should this be". +- **No parameter panel.** Where one exists today it is technical debt with a + scheduled removal (§10). +- **Print context is ambient**, not a panel: the plate is visible in the design + space, and print-domain warnings appear on the geometry that will fail. +- **Nothing is added to permanent chrome without removing something**, or + demonstrating that the addition is used in the majority of sessions. +- **The budget is set by the smallest screen we serve**, 1366×768 (§6.1) — not + by the reviewer's monitor. Chrome that fits a 27-inch display and swallows a + laptop's has not fitted, it has just failed somewhere the author cannot see. + +## 6. Accessibility — reach first, then the assistive floor + +"Accessible" means two different things and the product owes both. §6.1 is about +**who can get in at all**; §6.2 is about **who can operate it once inside**. +Neither is a phase. Both are merge requirements. + +### 6.1 Reach — the door has to be open + +The premise of the whole project: someone with no money, no licence, no account, +no fast machine and no teacher can open this and make a real thing. Free +software on a school laptop is the only path to a CAD tool that reaches people +who were never going to be handed one. If a design decision quietly raises the +cost of entry, it has broken the premise, however elegant it is. + +- **The reference machine.** A 5-year-old laptop: dual/quad-core CPU, + **integrated graphics**, 8 GB RAM, **1366×768** screen, no discrete GPU. The + Design tab must be usable there, and any interaction that needs more is a + design failure to be solved, not a requirement to be documented. The GPU path + degrades gracefully to software rendering rather than refusing to start; the + viewport stays interactive while the kernel thinks. +- **1366×768 is the layout target, not the stretch case.** A form-heavy side + panel is not merely inelegant on that screen — it takes the model off it. + This is the second, independent argument for the whole of L1 and §5. +- **No account, no cloud, no connection.** The product works forever with the + network unplugged. Nothing is uploaded, no sign-in gates any feature, no + telemetry is required to use it. A school network that blocks everything must + not be able to block this. +- **No tier, no plugin wall, no "pro".** Every feature named in this document is + in the product everyone downloads. Assemblies and exploded views are not the + paid half. +- **Files belong to the user**, on their disk, in a format that outlives the + project: the design travels inside the ordinary project file, and the geometry + exports to STEP and mesh formats anyone can open. +- **Learnable without instruction.** The first solid comes with no + documentation, no video and no tutorial mode — from noticing that a face can + be clicked. Tooltips teach the vocabulary (L10) at the moment it is needed; + nothing is explained in a manual the user will never open. +- **Plain language at the entry tier.** The Make tier speaks in words a + thirteen-year-old reads without stopping. Precision comes with the tier that + needs it, and everything is translated, because "accessible" in English only + is not accessible. +- **Exploration must be free.** Undo reaches everything, work is never lost to a + wrong click, and no dialog ever asks the user to be sure. A tool that punishes + experiments teaches people to stop experimenting, which is the one thing this + audience cannot afford to learn. +- **The product never blames the user.** Failures are stated as what happened + and what to do (L7). "Invalid input" is not an acceptable sentence anywhere. + +### 6.2 Assistive floor + +- **Keyboard**: every operation reachable and completable without a pointer. + Single-letter shortcuts for sketch tools, shown in the offer itself (§4.1) as + well as in the tooltip. The offer opens from the keyboard (Menu key or + Shift+F10) and walks by arrow key and by type-ahead, so the row map works for + someone who never touches the pointer. A visible focus state on every + focusable element. No shortcut that only works while the pointer happens to be + over the canvas. +- **Colour**: never the sole carrier of meaning. Selection is colour *and* + outline; an error is colour *and* an icon *and* text. Verify in greyscale. +- **Contrast**: labels over the 3D viewport get a scrim or halo so 4.5:1 holds + against any background the model can produce, including a white body under a + white plate. +- **Targets**: handles and grips no smaller than 32 px at 100 % scale, scaling + with the OS factor; the grab tolerance is larger than the drawn glyph. +- **Timing**: no double-click-to-mean-something-else, no press-and-hold as the + only route to a function, no cycle that depends on repeated clicks + (see L5). The long-press that opens the offer on a one-button Mac and on touch + (§4.1) is explicitly an *additional* route — Ctrl-click, two-finger tap and + the keyboard all reach the same place — and it shows its own progress while + charging, so it never fails silently. +- **Motion**: animation is functional (showing where a thing went), never + decorative, and it respects the reduced-motion preference. +- **Text**: no fixed-width assumptions; the UI holds together in German and in + Chinese, at 125 % and 200 % scale. Every string routed through the normal + translation path. + +## 7. Depth without clutter — the three tiers + +Power for experts is delivered by **progressive disclosure of tools, never by +relocation of tools**. A tool that appears in a later tier is in the same place +it will always be; it is simply not shown yet. + +| Tier | Who | What appears | +|---|---|---| +| **Make** | first hour | Sketch, extrude, revolve, hole, fillet/chamfer, move, commit to plate | +| **Model** | competent user | Patterns, shell, draft, sweep/loft, booleans, reference geometry, variables, import/export | +| **Mechanism** | mechanical designer | Assemblies and mates, exploded views, interference detection, surfaces, feature-level editing of imported solids | + +Rules that keep this honest: + +1. **Tiers are non-modal.** No mode switch, no workbench selector, no "advanced + mode" toggle that changes the meaning of anything. The tier only governs what + is *offered*. +2. **A tier reveals itself by use.** Using a body reveals boolean tools; adding + a second body reveals assembly tools. The product notices what you are doing. +3. **Nothing moves when a tier appears.** A user who learned where fillet lives + finds it in the same place forever. +4. **An expert tool obeys the same grammar** as a beginner tool. Mates are + picked in 3D like everything else, not configured in a table. +5. **Exploded views are a view state**, not a document mode — reversible, + draggable along mate axes, and never a separate file. + +## 8. Designing for print — the home advantage + +Design-time knowledge the application already has, and must use: + +- **The plate is present** in the design space, at the real size, with the real + origin. Committing a body to the plate is one action and preserves placement. +- **Print-domain checks run on the model, on the geometry, before slicing**: + walls thinner than the nozzle, unsupported overhangs beyond the material's + angle, features smaller than the layer height, a part that does not fit the + build volume. +- **These are warnings on the geometry, never a report.** The thin wall glows; + the tooltip says how thin and what the nozzle is. +- **Material and machine context is inherited** from the active slicer profile, + not re-entered in the Design tab. +- **The round trip is preserved**: editing a design after slicing returns to the + feature history, not to a mesh. + +## 9. The review gate + +Every pull request that touches the Design tab UI answers these, in the PR body. +A "no" that is not accompanied by an argument is a request for changes. + +1. Which law (L1–L11) does the change most directly serve? +2. Can the whole operation be completed without the pointer leaving the + viewport? If not, why is this the exception? + And: does the relevant selection *offer* this tool (§4.1), or must the user + already know it exists? +3. Are the values draggable *and* typable? +4. Screenshot of the state after **exactly one** click of the new gesture, + performed as a first-time user. +5. Keyboard-only walkthrough: does it complete? +6. Greyscale screenshot: is every state still distinguishable? +7. What was **removed**? (Net additions to permanent chrome require an argument.) +8. Which tier does it belong to, and does it appear without moving anything else? +9. What does it do when the geometry is invalid, and is that reported before the + commit? +10. Interaction cost: actions required for the canonical task it addresses, + before and after. +11. Reach (L11): screenshot at 1366×768 with the panel open — is the model still + on screen? Does it run on integrated graphics? Does it need the network, an + account, or a file the user cannot keep? +12. If the change adds or moves a verb in the offer: which row, and is it that + verb's row in **every** selection where it appears? Did any existing verb's + index change? (If yes, this is not a UI change, it is a breaking change to + every user's muscle memory, and it needs the group — see §4.1.) Was + `docs/ux/tool_atlas.json` updated and the atlas regenerated? +13. If the change adds a pointer gesture: what is its keyboard equivalent, and + what does a one-button Mac, a trackpad and a touch screen do (§4.1)? + +## 10. Where we stand today — honest inventory + +Complying with the laws already: + +- Sketch inline editors — draw an entity and its dimension tab opens on the + geometry; Tab walks Length → Width → Angle. +- Fillet/chamfer draggable radius arrow with an editable value label. +- Extrude depth arrow; move-body three-axis arrows. +- Datum-plane resize handles and offset arrow; ghost reference planes picked in + 3D. +- Imported-art place/size gizmo. +- Sketch plane taken from the picked face, with the target named in the status + line, and the sketch-plane dropdown deleted outright. + +Violating them, with removal scheduled: + +- **Every tool card is a two-column form** of combos and spin fields in the left + panel. This is the single largest debt in the product and the reason this + document exists. Tracked as an epic; each card is replaced by its on-geometry + equivalent, not improved in place. It fails L1 and it fails L11 twice over — + on a 1366×768 screen the cards leave the model a strip. +- Seven remaining plane pickers still populate a combo instead of consuming a + viewport selection. +- Pattern has no on-geometry spacing arrow or count badge. +- Hole is positioned by X/Y fields rather than by a point on a face. +- Booleans and cuts pick their operands from lists rather than in 3D. +- Fillet/chamfer edge selection still requires the click cycle L5 forbids. +- **Selecting geometry offers nothing.** There is no contextual offer (§4.1): + the user faces the full toolbar whatever they have picked, and finds out that + a tool did not apply by it doing nothing. This is the largest single item of + new work the charter asks for. The map and every state of it are already + drawn (`docs/ux/offer_atlas.html`); what the group owes itself before the code + is ratifying the row order, since every verb built before that lands has to be + addressed afterwards anyway. +- **Committing is an invisible click in empty space** rather than the + confirm/cancel puck of §4.2 — the exact gesture that rule withdraws. + +Nothing on the violating list is defended. The only open question for each is +what its on-geometry replacement should be. + +## 11. How the group works + +**Roles.** Product/UX lead (owns this document and casts the tie-break vote on +interaction questions); kernel maintainer; GUI maintainer; a print-domain +reviewer; a mechanical-design reviewer who uses the product on real work; an +accessibility reviewer covering both senses of §6 — reach and assistive — who +owns the reference machine and actually runs on it. One person may hold more +than one role; the UX lead and the mechanical-design reviewer should not be the +same person, and nobody reviews reach from a workstation. + +**The absent audience needs a seat.** The fourteen-year-old is not in the room +and cannot file an issue. Someone in the group is accountable for B5 and B6, and +the group watches real first-timers use the product on the reference machine at +least once a quarter — school, makerspace, or a friend's kid. Everything else in +this document can be argued from principle; approachability can only be +observed. + +**Cadence.** A short weekly review of open interaction proposals. A monthly pass +over the violating inventory in §10 — anything that has not moved in two months +is either scheduled or explicitly accepted as permanent, with a reason written +into this document. + +**How a change moves.** + +1. *Problem* — a described user difficulty, ideally with an interaction-cost + measurement, never a solution in disguise. +2. *Sketch* — one or two on-geometry interaction proposals, drawn or described + as a gesture sequence. Reviewed against §3 before any code. +3. *Prototype* — built behind whatever the smallest safe path is, driven end to + end on a real display, and screenshotted at each state. +4. *Gate* — §9 answered in the PR. +5. *Merge*, then update §10. + +**Decisions are written down.** Any resolution that constrains future work is +appended to this document as a numbered law or as an accepted exception with its +reasoning. A decision that lives only in a call is not a decision. + +**How disagreements resolve.** Against the laws first. If the laws do not decide +it, the tie-break is the interaction cost measured on the canonical tasks in +§12; if that does not decide it, the UX lead chooses and records why. + +## 12. Canonical tasks — the benchmark + +The measure of every UX change is the cost of these five tasks. Each is timed and +counted (clicks, keystrokes, camera actions, mode switches) on the headless rig +and, periodically, with real users who have not seen the product. + +| # | Task | What it exercises | +|---|---|---| +| **B1** | Bracket: sketch an L, extrude, two holes, fillet the inside corner, send to plate | The inner loop | +| **B2** | Change a hole diameter and the plate thickness, six features deep, and rebuild | Parametric editability | +| **B3** | Take an imported STEP, delete a boss, close the face, thicken a wall to nozzle width | Direct editing + print awareness | +| **B4** | Two parts, one revolute mate, check interference, produce an exploded view | The Mechanism tier | +| **B5** | First-run: from opening the Design tab to a print-ready solid, no documentation | Approachability | +| **B6** | B1 again, on the reference machine at 1366×768, offline, on a fresh account-less install | Reach (L11) | + +Every task is run on the reference machine of §6.1, not on a workstation — a +number measured on a fast desktop describes an experience most of our users will +never have. B6 repeats the inner loop under the full entry conditions so that +reach is a measured quantity and not an intention. + +Targets are set once each task has been measured on the current build. B5's +target is expressed in minutes-to-first-solid **by someone who has never seen a +CAD program**, and it is the number this project is ultimately judged by. + +--- + +### Appendix — anti-patterns we have already paid for + +Kept because each cost real time and each is easy to reintroduce. + +- **The dropdown that grew a row.** Fixing "cannot sketch on a face" by adding a + "Face of Body 1" entry to a plane combo. It reads as a small fix and it is the + side-panel architecture reproducing itself. +- **The invisible first click.** Flyout buttons and pick cycles whose first click + changes nothing meaningful. Filed as bugs three separate times against working + code, and made a real bug look fixed when it was not. +- **The fix verified through a path the user will never take.** A face-sketch fix + confirmed by double-clicking to reach face level. Users click once. A fix + reachable only by an undiscoverable gesture is indistinguishable from no fix. +- **The wrong feedback surface.** Measuring an armed tool by the toolbar, which + never renders keyboard-armed state. The status line is the surface that + answers. +- **The silent success.** A cut that removed no material, reported as done. Now + an error naming the likely cause. diff --git a/docs/CAD/design/mate-connectors/BEAR_CONNECTOR_REVIEW.md b/docs/CAD/design/mate-connectors/BEAR_CONNECTOR_REVIEW.md new file mode 100644 index 0000000000..cc2d4b22aa --- /dev/null +++ b/docs/CAD/design/mate-connectors/BEAR_CONNECTOR_REVIEW.md @@ -0,0 +1,169 @@ +# BearConnector.step — examination + +> **Scope.** One file was supplied and it contains **one object: the male.** Everything below is +> measured from that single solid. Earlier drafts of this note reasoned about a female pocket and a +> mating pair — those objects were never supplied, so any statement about them was speculation and +> has been removed. The clearance, the fit, and the pocket's legibility are all **unassessed**. + +Measured, not eyeballed. Imported into the Design tab's own OpenCascade kernel +(`import_step` → one valid closed solid), topology queried, geometry checked numerically. +Flat drawing: `artifacts/shots/bear-flat.png`. Viewport: `artifacts/shots/bear-02-zoom.png`. + +**File:** AP242 Edition 2, ST-Developer. 1 `MANIFOLD_SOLID_BREP`, 1 `CLOSED_SHELL`. +**Size:** 83.06 × 66.69 × 17.27 mm. **Faces:** 30 — 24 planar + 6 cylindrical. +**Curves:** 69 lines + 12 circles. **No** splines, spheres, tori or cones. +**Relief:** only four Z levels — 0, 3.00, 10.66, 17.27. + +--- + +## What is right, and precisely so + +**The sloping ridge is implemented exactly as briefed.** From (0.00, 18.40, 17.27) to +(0.00, 46.72, 10.66): 28.3 mm long, 6.61 mm drop, **13.1° slope**, and both ends sit dead on +x = 0.00. It breaks 180° rotation on its own. + +**20.0° uniform draft on all four snout flanks**, identical to within 0.1°: +`(0,−0.94,0.342) (0.936,0.08,0.342) (0,0.94,0.342) (−0.936,0.08,0.342)`. That is a real, +deliberate lead-in — it self-centres into a matching pocket, and it demoulds and prints. + +**The eyes are exactly symmetric**: Ø9.87 at x = ±16.43, y = 48.01, matching to 0.01 mm. +Someone mirrored those on purpose. + +**The mating feature is extremely economical**: only **five edges** exist above the 3 mm plate — +the ridge plus two flank edges at each end. Base plate is exactly 3.00 mm. + +The low-poly constraint is honoured. All six cylinders are outline rounds and eye holes; none of +them is a mating surface. + +--- + +## The asymmetry is deliberate, and it is complete + +**Correction.** A first pass read the left/right differences as an unfinished mirror. That was wrong: +the asymmetry is intentional. Tested properly — every candidate self-symmetry, in the part's own +centred frame, with a generous 0.1 mm tolerance: + +| operation | edges mapped onto the part | +|---|---| +| identity | 81 / 81 — 100 % | +| mirror about x = 0 (left/right) | **0 / 81** | +| mirror about y = 0 (top/bottom) | **0 / 81** | +| rotate 180° about Z | **0 / 81** | +| rotate 90° about Z | **0 / 81** | +| mirror about the diagonal | **0 / 81** | + +**The symmetry group is trivial.** No rigid motion or reflection maps this part onto itself, so +**every partial view determines the orientation uniquely** — you never need to see the whole face to +know which way round it goes. That is the strongest possible result for a keying interface and it is +exactly what the earlier abstract glyph work kept failing to achieve: a symmetric shape seen at a +grazing angle, or half-occluded, gives an ambiguous read. + +### Does it let you GRASP the orientation? Measured, not asserted. + +Unique-in-principle and graspable-at-a-glance are different claims. The symmetry table proves the +first. For the second, the front-on picture (outline + eyes + mouth, filled) was rasterised and +compared against its own mirror and its own 180° rotation — the two ways a person can get it wrong. + +**By size** (percentage of pixels that differ): + +| width | vs mirror | vs rotated 180° | +|---|---|---| +| 16 px | 20.7 % | 26.0 % | +| 24 px | 21.9 % | 30.9 % | +| 32 px | 23.0 % | 28.1 % | +| 48 px | 22.4 % | 30.6 % | +| 80 px | 24.7 % | 31.0 % | +| 160 px | 23.6 % | 31.0 % | + +**The curve is flat.** The full signal is already there at 16 pixels and more resolution adds +nothing. That is the whole result: **the orientation cue lives at low spatial frequency**, carried by +the overall shape rather than by any detail. It therefore survives distance, blur, poor light, +peripheral vision, a small print and a low-resolution screen. It is the exact opposite of the abstract +disc glyph, whose roll cue was a small high-frequency feature and died at a grazing angle. + +**Partial views — a claim I made and then withdrew.** I ran a masked-window test and concluded that +a single quarter of the face was enough to read the orientation. **That test was invalid and the +conclusion is wrong.** It compared a window of the original against *the same window* of the mirrored +and rotated versions — which silently hands the observer the registration. It assumes you already +know that the patch you are looking at is the top-left quarter, which is exactly the thing you would +not know if you could only see a quarter. + +**You need to see the whole face.** The cues here are *relational*: the big ear only means something +next to the small ear, and the mouth offset only means something relative to the centreline. None of +them is self-locating. Whole-face is the operating condition, and the design should be judged and +used on that basis. + +That does not weaken the size result above, which always used the complete silhouette: the whole face +reads at 16 px. Needing all of it, and needing very little resolution of it, are compatible — and for +a part held in a hand, seeing all of it is the normal case. + +**The signal is allocated to the right risks.** The strongest cue (up to 41.7 %) guards against +inserting it upside down — the mistake people actually make. The weakest (~23 %) guards the mirror +case, which needs the part flipped over and which the protrusion already prevents mechanically. + +It also does mechanical work beyond the ridge. The ridge alone breaks 180° rotation; the asymmetric +outline additionally defeats the **mirrored-part** case — a mirror-image copy will not fit, so a +modelling or printing mirror is caught at assembly rather than three steps later. + +And for children specifically, a symmetric cartoon face reads as a mask; illustrators asymmetrise +deliberately so a face reads as a *character*. The asymmetry is earning its keep three ways at once. + +### What is worth keeping in mind anyway + +**The ears differ by 42 %** — left 8.33 mm wide (top y 65.68), right 11.81 mm (top y 66.69). Both +start at the same y = 60.79, so they read as a deliberate pair rather than an error. 42 % is well +above the perceptual threshold: you see it instantly. Good cue. + +**The mouth is a smirk** — x −21.93 … 0.00, centred at x = −10.96, stopping on the centreline. A +classic character device and a strong asymmetry. + +**The rounds are the best cue and the one safety question.** All four are on the left — Ø11.71 at +(−40.82, 7.38), Ø11.71 at (−34.76, 0.58), Ø10.00 at (−29.85, 60.83), Ø2.90 at (−26.70, 65.95) — and +the right side is entirely sharp. This is the *most locally readable* cue in the design: the ears +differ only by comparison (you must see both to know which is which), whereas a rounded corner tells +you "this is the left" from that corner alone, by eye **or by fingertip**. For children assembling by +feel that is the cue doing the real work. + +The tension is that "sharp" on a children's part is a hazard, and the obvious safety fix — round +everything — destroys the cue. The resolution is not round-vs-sharp but **large-vs-small radius**: +keep R≈6 on the left and give the right R≈1. R1 still reads and feels sharp locally, so the cue +survives, and the actual edge hazard goes away. That is the one recommendation that outlives the +correction. + +**One measurement that does not fit the story:** the outline is off-centre by **0.54 mm** (left reach +40.99, right reach 42.07). A deliberate cue should be unmissable; 0.54 mm is invisible. It is +probably a by-product of the other features rather than intent — worth a look, not a defect. + +--- + +## Two judgement calls, not defects + +**The snout is highest at the nose tip and slopes down toward the brow** — a real bear's muzzle +does the opposite. Anatomically it reads more like a beak or a horn than a snout. But mechanically +it is the better choice: the nose tip enters the pocket first and does the finding. Keep it if the +lead-in matters more than the likeness; flip it if "it must look like a bear" wins. + +**Only the male was supplied**, so the clearance, the fit and the pocket are unassessed. Nothing in +this note should be read as a judgement on them. + +--- + +## The strategic point, which is the real reason this design is good + +It gives orientation **a name**. "Ears up, nose down" needs no legend, no convention and no +documentation. Face recognition is the most robust pattern-matching humans have: it survives low +resolution, poor light, partial occlusion and peripheral vision. That is exactly the robustness the +abstract ridge key was reaching for, and here it comes for free. + +**One earlier objection does not transfer — noting it only so it is not carried over by mistake.** +In §8c of the design doc a female *pocket* measured as visually invisible — flat-shaded, a recess +reads as a blank rectangle — and I concluded male/female +is the wrong polarity cue. **That was a viewport finding, and it does not apply to a physical part.** +Nobody looks into the pocket of a toy; they feel it. For a part in a child's hands, male/female is +exactly the right polarity language. The earlier conclusion stands for the on-screen glyph and must +not be carried over to this. + +**The one rule to write down now:** the face and the key must never be allowed to disagree. People +will trust the face over the mechanics every time. Here they agree — ridge on the centreline, ears +up. If the face is ever restyled independently of the key, a user will orient by the bear and be +wrong. Tie them permanently, in the model and in whatever generates it. diff --git a/docs/CAD/design/mate-connectors/BearConnector_Cutter.step b/docs/CAD/design/mate-connectors/BearConnector_Cutter.step new file mode 100644 index 0000000000..c78e19e536 --- /dev/null +++ b/docs/CAD/design/mate-connectors/BearConnector_Cutter.step @@ -0,0 +1,998 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('FreeCAD Model'),'2;1'); +FILE_NAME('Open CASCADE Shape Model','2026-08-05T12:46:26',('FreeCAD'),( + 'FreeCAD'),'Open CASCADE STEP processor 7.8','FreeCAD','Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('Open CASCADE STEP translator 7.8 1', + 'Open CASCADE STEP translator 7.8 1','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#958); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#229,#260,#497,#514,#531,#548,#565,#582,#599, + #616,#633,#650,#667,#684,#701,#718,#735,#747,#770,#794,#810,#822, + #839,#856,#878,#895,#912,#929,#946)); +#17 = ADVANCED_FACE('',(#18,#68,#79,#213),#224,.F.); +#18 = FACE_BOUND('',#19,.F.); +#19 = EDGE_LOOP('',(#20,#30,#38,#46,#54,#62)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(19.029295926024,-0.2,-17.63009960955)); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(16.626582997737,-0.2,-8.940188245231)); +#26 = LINE('',#27,#28); +#27 = CARTESIAN_POINT('',(19.849519003668,-0.2,-20.59660707692)); +#28 = VECTOR('',#29,1.); +#29 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#30 = ORIENTED_EDGE('',*,*,#31,.F.); +#31 = EDGE_CURVE('',#32,#22,#34,.T.); +#32 = VERTEX_POINT('',#33); +#33 = CARTESIAN_POINT('',(22.059435554995,-0.2,-3.734519760785)); +#34 = LINE('',#35,#36); +#35 = CARTESIAN_POINT('',(17.698510515043,-0.2,-23.73280021221)); +#36 = VECTOR('',#37,1.); +#37 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#38 = ORIENTED_EDGE('',*,*,#39,.F.); +#39 = EDGE_CURVE('',#40,#32,#42,.T.); +#40 = VERTEX_POINT('',#41); +#41 = CARTESIAN_POINT('',(-21.72552223146,-0.2,-3.734519760785)); +#42 = LINE('',#43,#44); +#43 = CARTESIAN_POINT('',(0.297084840953,-0.2,-3.734519760785)); +#44 = VECTOR('',#45,1.); +#45 = DIRECTION('',(1.,0.,0.)); +#46 = ORIENTED_EDGE('',*,*,#47,.F.); +#47 = EDGE_CURVE('',#48,#40,#50,.T.); +#48 = VERTEX_POINT('',#49); +#49 = CARTESIAN_POINT('',(-21.72552223146,-0.2,-8.903751135252)); +#50 = LINE('',#51,#52); +#51 = CARTESIAN_POINT('',(-21.72552223146,-0.2,-19.83215600037)); +#52 = VECTOR('',#53,1.); +#53 = DIRECTION('',(0.,0.,1.)); +#54 = ORIENTED_EDGE('',*,*,#55,.F.); +#55 = EDGE_CURVE('',#56,#48,#58,.T.); +#56 = VERTEX_POINT('',#57); +#57 = CARTESIAN_POINT('',(4.383041634064E-04,-0.2,-8.903751615529)); +#58 = LINE('',#59,#60); +#59 = CARTESIAN_POINT('',(-5.279852300138,-0.2,-8.903751135252)); +#60 = VECTOR('',#61,1.); +#61 = DIRECTION('',(-1.,0.,0.)); +#62 = ORIENTED_EDGE('',*,*,#63,.F.); +#63 = EDGE_CURVE('',#24,#56,#64,.T.); +#64 = LINE('',#65,#66); +#65 = CARTESIAN_POINT('',(4.347099726942,-0.2,-8.913277437397)); +#66 = VECTOR('',#67,1.); +#67 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03)); +#68 = FACE_BOUND('',#69,.F.); +#69 = EDGE_LOOP('',(#70)); +#70 = ORIENTED_EDGE('',*,*,#71,.F.); +#71 = EDGE_CURVE('',#72,#72,#74,.T.); +#72 = VERTEX_POINT('',#73); +#73 = CARTESIAN_POINT('',(21.163799345768,-0.2,-48.00951684793)); +#74 = CIRCLE('',#75,4.735522705283); +#75 = AXIS2_PLACEMENT_3D('',#76,#77,#78); +#76 = CARTESIAN_POINT('',(16.428276640485,-0.2,-48.00951684793)); +#77 = DIRECTION('',(-0.,1.,0.)); +#78 = DIRECTION('',(1.,0.,0.)); +#79 = FACE_BOUND('',#80,.F.); +#80 = EDGE_LOOP('',(#81,#91,#100,#108,#116,#125,#133,#142,#150,#158,#166 + ,#174,#182,#190,#198,#206)); +#81 = ORIENTED_EDGE('',*,*,#82,.T.); +#82 = EDGE_CURVE('',#83,#85,#87,.T.); +#83 = VERTEX_POINT('',#84); +#84 = CARTESIAN_POINT('',(-27.86158468659,-0.2,-65.78852163128)); +#85 = VERTEX_POINT('',#86); +#86 = CARTESIAN_POINT('',(-29.08766684168,-0.2,-64.52156932717)); +#87 = LINE('',#88,#89); +#88 = CARTESIAN_POINT('',(-29.44004200272,-0.2,-64.15744811364)); +#89 = VECTOR('',#90,1.); +#90 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#91 = ORIENTED_EDGE('',*,*,#92,.F.); +#92 = EDGE_CURVE('',#93,#85,#95,.T.); +#93 = VERTEX_POINT('',#94); +#94 = CARTESIAN_POINT('',(-28.96712497021,-0.2,-57.16864680227)); +#95 = CIRCLE('',#96,5.2); +#96 = AXIS2_PLACEMENT_3D('',#97,#98,#99); +#97 = CARTESIAN_POINT('',(-25.35093464349,-0.2,-60.90537900045)); +#98 = DIRECTION('',(0.,-1.,0.)); +#99 = DIRECTION('',(-1.,0.,0.)); +#100 = ORIENTED_EDGE('',*,*,#101,.T.); +#101 = EDGE_CURVE('',#93,#102,#104,.T.); +#102 = VERTEX_POINT('',#103); +#103 = CARTESIAN_POINT('',(-26.93875323652,-0.2,-55.20570756731)); +#104 = LINE('',#105,#106); +#105 = CARTESIAN_POINT('',(-14.90185526362,-0.2,-43.55710346492)); +#106 = VECTOR('',#107,1.); +#107 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#108 = ORIENTED_EDGE('',*,*,#109,.T.); +#109 = EDGE_CURVE('',#102,#110,#112,.T.); +#110 = VERTEX_POINT('',#111); +#111 = CARTESIAN_POINT('',(-41.17904151244,-0.2,-10.52828909594)); +#112 = LINE('',#113,#114); +#113 = CARTESIAN_POINT('',(-32.39120436163,-0.2,-38.09921113329)); +#114 = VECTOR('',#115,1.); +#115 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#116 = ORIENTED_EDGE('',*,*,#117,.F.); +#117 = EDGE_CURVE('',#118,#110,#120,.T.); +#118 = VERTEX_POINT('',#119); +#119 = CARTESIAN_POINT('',(-39.69176606491,-0.2,-4.408923352436)); +#120 = CIRCLE('',#121,6.054044962965); +#121 = AXIS2_PLACEMENT_3D('',#122,#123,#124); +#122 = CARTESIAN_POINT('',(-35.41090981799,-0.2,-8.689779599357)); +#123 = DIRECTION('',(0.,-1.,0.)); +#124 = DIRECTION('',(-1.,0.,0.)); +#125 = ORIENTED_EDGE('',*,*,#126,.T.); +#126 = EDGE_CURVE('',#118,#127,#129,.T.); +#127 = VERTEX_POINT('',#128); +#128 = CARTESIAN_POINT('',(-36.85603142851,-0.2,-1.573188716044)); +#129 = LINE('',#130,#131); +#130 = CARTESIAN_POINT('',(-36.19319006079,-0.2,-0.910347348321)); +#131 = VECTOR('',#132,1.); +#132 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#133 = ORIENTED_EDGE('',*,*,#134,.F.); +#134 = EDGE_CURVE('',#135,#127,#137,.T.); +#135 = VERTEX_POINT('',#136); +#136 = CARTESIAN_POINT('',(-32.57517518159,-0.2,0.2)); +#137 = CIRCLE('',#138,6.054044962965); +#138 = AXIS2_PLACEMENT_3D('',#139,#140,#141); +#139 = CARTESIAN_POINT('',(-32.57517518159,-0.2,-5.854044962965)); +#140 = DIRECTION('',(0.,-1.,0.)); +#141 = DIRECTION('',(-1.,0.,0.)); +#142 = ORIENTED_EDGE('',*,*,#143,.T.); +#143 = EDGE_CURVE('',#135,#144,#146,.T.); +#144 = VERTEX_POINT('',#145); +#145 = CARTESIAN_POINT('',(35.082842712475,-0.2,0.2)); +#146 = LINE('',#147,#148); +#147 = CARTESIAN_POINT('',(-7.942265537672,-0.2,0.2)); +#148 = VECTOR('',#149,1.); +#149 = DIRECTION('',(1.,0.,0.)); +#150 = ORIENTED_EDGE('',*,*,#151,.F.); +#151 = EDGE_CURVE('',#152,#144,#154,.T.); +#152 = VERTEX_POINT('',#153); +#153 = CARTESIAN_POINT('',(42.298608189024,-0.2,-7.015765476549)); +#154 = LINE('',#155,#156); +#155 = CARTESIAN_POINT('',(36.596246576251,-0.2,-1.313403863776)); +#156 = VECTOR('',#157,1.); +#157 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#158 = ORIENTED_EDGE('',*,*,#159,.F.); +#159 = EDGE_CURVE('',#160,#152,#162,.T.); +#160 = VERTEX_POINT('',#161); +#161 = CARTESIAN_POINT('',(26.938753236523,-0.2,-55.20570756731)); +#162 = LINE('',#163,#164); +#163 = CARTESIAN_POINT('',(32.699020781567,-0.2,-37.13346923684)); +#164 = VECTOR('',#165,1.); +#165 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#166 = ORIENTED_EDGE('',*,*,#167,.F.); +#167 = EDGE_CURVE('',#168,#160,#170,.T.); +#168 = VERTEX_POINT('',#169); +#169 = CARTESIAN_POINT('',(32.703857168398,-0.2,-60.78483712899)); +#170 = LINE('',#171,#172); +#171 = CARTESIAN_POINT('',(16.008242280412,-0.2,-44.62779994931)); +#172 = VECTOR('',#173,1.); +#173 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#174 = ORIENTED_EDGE('',*,*,#175,.F.); +#175 = EDGE_CURVE('',#176,#168,#178,.T.); +#176 = VERTEX_POINT('',#177); +#177 = CARTESIAN_POINT('',(26.715163243538,-0.2,-66.97315781796)); +#178 = LINE('',#179,#180); +#179 = CARTESIAN_POINT('',(30.25240665457,-0.2,-63.31800414721)); +#180 = VECTOR('',#181,1.); +#181 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#182 = ORIENTED_EDGE('',*,*,#183,.F.); +#183 = EDGE_CURVE('',#184,#176,#186,.T.); +#184 = VERTEX_POINT('',#185); +#185 = CARTESIAN_POINT('',(20.532019001374,-0.2,-60.98947335481)); +#186 = LINE('',#187,#188); +#187 = CARTESIAN_POINT('',(9.922784884512,-0.2,-50.72247862452)); +#188 = VECTOR('',#189,1.); +#189 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#190 = ORIENTED_EDGE('',*,*,#191,.F.); +#191 = EDGE_CURVE('',#192,#184,#194,.T.); +#192 = VERTEX_POINT('',#193); +#193 = CARTESIAN_POINT('',(-20.53201900137,-0.2,-60.98947335481)); +#194 = LINE('',#195,#196); +#195 = CARTESIAN_POINT('',(5.354765181569,-0.2,-60.98947335481)); +#196 = VECTOR('',#197,1.); +#197 = DIRECTION('',(1.,0.,0.)); +#198 = ORIENTED_EDGE('',*,*,#199,.T.); +#199 = EDGE_CURVE('',#192,#200,#202,.T.); +#200 = VERTEX_POINT('',#201); +#201 = CARTESIAN_POINT('',(-25.53052705686,-0.2,-65.82673637491)); +#202 = LINE('',#203,#204); +#203 = CARTESIAN_POINT('',(-9.454421870603,-0.2,-50.26922436104)); +#204 = VECTOR('',#205,1.); +#205 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#206 = ORIENTED_EDGE('',*,*,#207,.F.); +#207 = EDGE_CURVE('',#83,#200,#208,.T.); +#208 = CIRCLE('',#209,1.648528137424); +#209 = AXIS2_PLACEMENT_3D('',#210,#211,#212); +#210 = CARTESIAN_POINT('',(-26.67694849991,-0.2,-64.64210018823)); +#211 = DIRECTION('',(0.,-1.,0.)); +#212 = DIRECTION('',(-1.,0.,0.)); +#213 = FACE_BOUND('',#214,.F.); +#214 = EDGE_LOOP('',(#215)); +#215 = ORIENTED_EDGE('',*,*,#216,.F.); +#216 = EDGE_CURVE('',#217,#217,#219,.T.); +#217 = VERTEX_POINT('',#218); +#218 = CARTESIAN_POINT('',(-11.6927539352,-0.2,-48.00951684793)); +#219 = CIRCLE('',#220,4.735522705283); +#220 = AXIS2_PLACEMENT_3D('',#221,#222,#223); +#221 = CARTESIAN_POINT('',(-16.42827664048,-0.2,-48.00951684793)); +#222 = DIRECTION('',(-0.,1.,0.)); +#223 = DIRECTION('',(1.,0.,0.)); +#224 = PLANE('',#225); +#225 = AXIS2_PLACEMENT_3D('',#226,#227,#228); +#226 = CARTESIAN_POINT('',(0.403056515455,-0.2,-33.34517655273)); +#227 = DIRECTION('',(0.,1.,0.)); +#228 = DIRECTION('',(1.,0.,0.)); +#229 = ADVANCED_FACE('',(#230),#255,.F.); +#230 = FACE_BOUND('',#231,.F.); +#231 = EDGE_LOOP('',(#232,#240,#241,#249)); +#232 = ORIENTED_EDGE('',*,*,#233,.T.); +#233 = EDGE_CURVE('',#234,#160,#236,.T.); +#234 = VERTEX_POINT('',#235); +#235 = CARTESIAN_POINT('',(26.938753236523,3.2,-55.20570756731)); +#236 = LINE('',#237,#238); +#237 = CARTESIAN_POINT('',(26.938753236523,3.,-55.20570756731)); +#238 = VECTOR('',#239,1.); +#239 = DIRECTION('',(0.,-1.,0.)); +#240 = ORIENTED_EDGE('',*,*,#159,.T.); +#241 = ORIENTED_EDGE('',*,*,#242,.F.); +#242 = EDGE_CURVE('',#243,#152,#245,.T.); +#243 = VERTEX_POINT('',#244); +#244 = CARTESIAN_POINT('',(42.298608189024,3.2,-7.015765476549)); +#245 = LINE('',#246,#247); +#246 = CARTESIAN_POINT('',(42.298608189024,3.,-7.015765476549)); +#247 = VECTOR('',#248,1.); +#248 = DIRECTION('',(0.,-1.,0.)); +#249 = ORIENTED_EDGE('',*,*,#250,.F.); +#250 = EDGE_CURVE('',#234,#243,#251,.T.); +#251 = LINE('',#252,#253); +#252 = CARTESIAN_POINT('',(32.699020781567,3.2,-37.13346923684)); +#253 = VECTOR('',#254,1.); +#254 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#255 = PLANE('',#256); +#256 = AXIS2_PLACEMENT_3D('',#257,#258,#259); +#257 = CARTESIAN_POINT('',(34.581352051556,3.,-31.22785130119)); +#258 = DIRECTION('',(-0.952773183837,0.,0.30368282823)); +#259 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#260 = ADVANCED_FACE('',(#261,#311,#322,#447,#481),#492,.T.); +#261 = FACE_BOUND('',#262,.T.); +#262 = EDGE_LOOP('',(#263,#273,#281,#289,#297,#305)); +#263 = ORIENTED_EDGE('',*,*,#264,.F.); +#264 = EDGE_CURVE('',#265,#267,#269,.T.); +#265 = VERTEX_POINT('',#266); +#266 = CARTESIAN_POINT('',(16.626582997737,3.2,-8.940188245231)); +#267 = VERTEX_POINT('',#268); +#268 = CARTESIAN_POINT('',(4.383041634064E-04,3.2,-8.903751615529)); +#269 = LINE('',#270,#271); +#270 = CARTESIAN_POINT('',(4.347099726942,3.2,-8.913277437397)); +#271 = VECTOR('',#272,1.); +#272 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03)); +#273 = ORIENTED_EDGE('',*,*,#274,.F.); +#274 = EDGE_CURVE('',#275,#265,#277,.T.); +#275 = VERTEX_POINT('',#276); +#276 = CARTESIAN_POINT('',(19.029295926024,3.2,-17.63009960955)); +#277 = LINE('',#278,#279); +#278 = CARTESIAN_POINT('',(19.849519003668,3.2,-20.59660707692)); +#279 = VECTOR('',#280,1.); +#280 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#281 = ORIENTED_EDGE('',*,*,#282,.F.); +#282 = EDGE_CURVE('',#283,#275,#285,.T.); +#283 = VERTEX_POINT('',#284); +#284 = CARTESIAN_POINT('',(22.059435554995,3.2,-3.734519760785)); +#285 = LINE('',#286,#287); +#286 = CARTESIAN_POINT('',(17.698510515043,3.2,-23.73280021221)); +#287 = VECTOR('',#288,1.); +#288 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#289 = ORIENTED_EDGE('',*,*,#290,.F.); +#290 = EDGE_CURVE('',#291,#283,#293,.T.); +#291 = VERTEX_POINT('',#292); +#292 = CARTESIAN_POINT('',(-21.72552223146,3.2,-3.734519760785)); +#293 = LINE('',#294,#295); +#294 = CARTESIAN_POINT('',(0.297084840953,3.2,-3.734519760785)); +#295 = VECTOR('',#296,1.); +#296 = DIRECTION('',(1.,0.,0.)); +#297 = ORIENTED_EDGE('',*,*,#298,.F.); +#298 = EDGE_CURVE('',#299,#291,#301,.T.); +#299 = VERTEX_POINT('',#300); +#300 = CARTESIAN_POINT('',(-21.72552223146,3.2,-8.903751135252)); +#301 = LINE('',#302,#303); +#302 = CARTESIAN_POINT('',(-21.72552223146,3.2,-19.83215600037)); +#303 = VECTOR('',#304,1.); +#304 = DIRECTION('',(0.,0.,1.)); +#305 = ORIENTED_EDGE('',*,*,#306,.F.); +#306 = EDGE_CURVE('',#267,#299,#307,.T.); +#307 = LINE('',#308,#309); +#308 = CARTESIAN_POINT('',(-5.279852300138,3.2,-8.903751135252)); +#309 = VECTOR('',#310,1.); +#310 = DIRECTION('',(-1.,0.,0.)); +#311 = FACE_BOUND('',#312,.T.); +#312 = EDGE_LOOP('',(#313)); +#313 = ORIENTED_EDGE('',*,*,#314,.F.); +#314 = EDGE_CURVE('',#315,#315,#317,.T.); +#315 = VERTEX_POINT('',#316); +#316 = CARTESIAN_POINT('',(21.163799345768,3.2,-48.00951684793)); +#317 = CIRCLE('',#318,4.735522705283); +#318 = AXIS2_PLACEMENT_3D('',#319,#320,#321); +#319 = CARTESIAN_POINT('',(16.428276640485,3.2,-48.00951684793)); +#320 = DIRECTION('',(-0.,1.,0.)); +#321 = DIRECTION('',(1.,0.,0.)); +#322 = FACE_BOUND('',#323,.T.); +#323 = EDGE_LOOP('',(#324,#334,#343,#351,#360,#368,#374,#375,#383,#391, + #399,#407,#415,#424,#432,#441)); +#324 = ORIENTED_EDGE('',*,*,#325,.T.); +#325 = EDGE_CURVE('',#326,#328,#330,.T.); +#326 = VERTEX_POINT('',#327); +#327 = CARTESIAN_POINT('',(-26.93875323652,3.2,-55.20570756731)); +#328 = VERTEX_POINT('',#329); +#329 = CARTESIAN_POINT('',(-41.17904151244,3.2,-10.52828909594)); +#330 = LINE('',#331,#332); +#331 = CARTESIAN_POINT('',(-32.39120436163,3.2,-38.09921113329)); +#332 = VECTOR('',#333,1.); +#333 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#334 = ORIENTED_EDGE('',*,*,#335,.F.); +#335 = EDGE_CURVE('',#336,#328,#338,.T.); +#336 = VERTEX_POINT('',#337); +#337 = CARTESIAN_POINT('',(-39.69176606491,3.2,-4.408923352436)); +#338 = CIRCLE('',#339,6.054044962965); +#339 = AXIS2_PLACEMENT_3D('',#340,#341,#342); +#340 = CARTESIAN_POINT('',(-35.41090981799,3.2,-8.689779599357)); +#341 = DIRECTION('',(0.,-1.,0.)); +#342 = DIRECTION('',(-1.,0.,0.)); +#343 = ORIENTED_EDGE('',*,*,#344,.T.); +#344 = EDGE_CURVE('',#336,#345,#347,.T.); +#345 = VERTEX_POINT('',#346); +#346 = CARTESIAN_POINT('',(-36.85603142851,3.2,-1.573188716044)); +#347 = LINE('',#348,#349); +#348 = CARTESIAN_POINT('',(-36.19319006079,3.2,-0.910347348321)); +#349 = VECTOR('',#350,1.); +#350 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#351 = ORIENTED_EDGE('',*,*,#352,.F.); +#352 = EDGE_CURVE('',#353,#345,#355,.T.); +#353 = VERTEX_POINT('',#354); +#354 = CARTESIAN_POINT('',(-32.57517518159,3.2,0.2)); +#355 = CIRCLE('',#356,6.054044962965); +#356 = AXIS2_PLACEMENT_3D('',#357,#358,#359); +#357 = CARTESIAN_POINT('',(-32.57517518159,3.2,-5.854044962965)); +#358 = DIRECTION('',(0.,-1.,0.)); +#359 = DIRECTION('',(-1.,0.,0.)); +#360 = ORIENTED_EDGE('',*,*,#361,.T.); +#361 = EDGE_CURVE('',#353,#362,#364,.T.); +#362 = VERTEX_POINT('',#363); +#363 = CARTESIAN_POINT('',(35.082842712475,3.2,0.2)); +#364 = LINE('',#365,#366); +#365 = CARTESIAN_POINT('',(-7.942265537672,3.2,0.2)); +#366 = VECTOR('',#367,1.); +#367 = DIRECTION('',(1.,0.,0.)); +#368 = ORIENTED_EDGE('',*,*,#369,.F.); +#369 = EDGE_CURVE('',#243,#362,#370,.T.); +#370 = LINE('',#371,#372); +#371 = CARTESIAN_POINT('',(36.596246576251,3.2,-1.313403863776)); +#372 = VECTOR('',#373,1.); +#373 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#374 = ORIENTED_EDGE('',*,*,#250,.F.); +#375 = ORIENTED_EDGE('',*,*,#376,.F.); +#376 = EDGE_CURVE('',#377,#234,#379,.T.); +#377 = VERTEX_POINT('',#378); +#378 = CARTESIAN_POINT('',(32.703857168398,3.2,-60.78483712899)); +#379 = LINE('',#380,#381); +#380 = CARTESIAN_POINT('',(16.008242280412,3.2,-44.62779994931)); +#381 = VECTOR('',#382,1.); +#382 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#383 = ORIENTED_EDGE('',*,*,#384,.F.); +#384 = EDGE_CURVE('',#385,#377,#387,.T.); +#385 = VERTEX_POINT('',#386); +#386 = CARTESIAN_POINT('',(26.715163243538,3.2,-66.97315781796)); +#387 = LINE('',#388,#389); +#388 = CARTESIAN_POINT('',(30.25240665457,3.2,-63.31800414721)); +#389 = VECTOR('',#390,1.); +#390 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#391 = ORIENTED_EDGE('',*,*,#392,.F.); +#392 = EDGE_CURVE('',#393,#385,#395,.T.); +#393 = VERTEX_POINT('',#394); +#394 = CARTESIAN_POINT('',(20.532019001374,3.2,-60.98947335481)); +#395 = LINE('',#396,#397); +#396 = CARTESIAN_POINT('',(9.922784884512,3.2,-50.72247862452)); +#397 = VECTOR('',#398,1.); +#398 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#399 = ORIENTED_EDGE('',*,*,#400,.F.); +#400 = EDGE_CURVE('',#401,#393,#403,.T.); +#401 = VERTEX_POINT('',#402); +#402 = CARTESIAN_POINT('',(-20.53201900137,3.2,-60.98947335481)); +#403 = LINE('',#404,#405); +#404 = CARTESIAN_POINT('',(5.354765181569,3.2,-60.98947335481)); +#405 = VECTOR('',#406,1.); +#406 = DIRECTION('',(1.,0.,0.)); +#407 = ORIENTED_EDGE('',*,*,#408,.T.); +#408 = EDGE_CURVE('',#401,#409,#411,.T.); +#409 = VERTEX_POINT('',#410); +#410 = CARTESIAN_POINT('',(-25.53052705686,3.2,-65.82673637491)); +#411 = LINE('',#412,#413); +#412 = CARTESIAN_POINT('',(-9.454421870603,3.2,-50.26922436104)); +#413 = VECTOR('',#414,1.); +#414 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#415 = ORIENTED_EDGE('',*,*,#416,.F.); +#416 = EDGE_CURVE('',#417,#409,#419,.T.); +#417 = VERTEX_POINT('',#418); +#418 = CARTESIAN_POINT('',(-27.86158468659,3.2,-65.78852163128)); +#419 = CIRCLE('',#420,1.648528137424); +#420 = AXIS2_PLACEMENT_3D('',#421,#422,#423); +#421 = CARTESIAN_POINT('',(-26.67694849991,3.2,-64.64210018823)); +#422 = DIRECTION('',(0.,-1.,0.)); +#423 = DIRECTION('',(-1.,0.,0.)); +#424 = ORIENTED_EDGE('',*,*,#425,.T.); +#425 = EDGE_CURVE('',#417,#426,#428,.T.); +#426 = VERTEX_POINT('',#427); +#427 = CARTESIAN_POINT('',(-29.08766684168,3.2,-64.52156932717)); +#428 = LINE('',#429,#430); +#429 = CARTESIAN_POINT('',(-29.44004200272,3.2,-64.15744811364)); +#430 = VECTOR('',#431,1.); +#431 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#432 = ORIENTED_EDGE('',*,*,#433,.F.); +#433 = EDGE_CURVE('',#434,#426,#436,.T.); +#434 = VERTEX_POINT('',#435); +#435 = CARTESIAN_POINT('',(-28.96712497021,3.2,-57.16864680227)); +#436 = CIRCLE('',#437,5.2); +#437 = AXIS2_PLACEMENT_3D('',#438,#439,#440); +#438 = CARTESIAN_POINT('',(-25.35093464349,3.2,-60.90537900045)); +#439 = DIRECTION('',(0.,-1.,0.)); +#440 = DIRECTION('',(-1.,0.,0.)); +#441 = ORIENTED_EDGE('',*,*,#442,.T.); +#442 = EDGE_CURVE('',#434,#326,#443,.T.); +#443 = LINE('',#444,#445); +#444 = CARTESIAN_POINT('',(-14.90185526362,3.2,-43.55710346492)); +#445 = VECTOR('',#446,1.); +#446 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#447 = FACE_BOUND('',#448,.T.); +#448 = EDGE_LOOP('',(#449,#459,#467,#475)); +#449 = ORIENTED_EDGE('',*,*,#450,.F.); +#450 = EDGE_CURVE('',#451,#453,#455,.T.); +#451 = VERTEX_POINT('',#452); +#452 = CARTESIAN_POINT('',(5.809375885494,3.2,-13.06417917474)); +#453 = VERTEX_POINT('',#454); +#454 = CARTESIAN_POINT('',(2.688069798796,3.2,-49.64588621989)); +#455 = LINE('',#456,#457); +#456 = CARTESIAN_POINT('',(4.914157977861,3.2,-23.55613296875)); +#457 = VECTOR('',#458,1.); +#458 = DIRECTION('',(-8.501532861635E-02,0.,-0.996379643459)); +#459 = ORIENTED_EDGE('',*,*,#460,.F.); +#460 = EDGE_CURVE('',#461,#451,#463,.T.); +#461 = VERTEX_POINT('',#462); +#462 = CARTESIAN_POINT('',(-5.809375885494,3.2,-13.06417917474)); +#463 = LINE('',#464,#465); +#464 = CARTESIAN_POINT('',(1.615747408047,3.2,-13.06417917474)); +#465 = VECTOR('',#466,1.); +#466 = DIRECTION('',(1.,0.,-3.066574716487E-16)); +#467 = ORIENTED_EDGE('',*,*,#468,.F.); +#468 = EDGE_CURVE('',#469,#461,#471,.T.); +#469 = VERTEX_POINT('',#470); +#470 = CARTESIAN_POINT('',(-2.688069798796,3.2,-49.64588621989)); +#471 = LINE('',#472,#473); +#472 = CARTESIAN_POINT('',(-4.890802006217,3.2,-23.82986495424)); +#473 = VECTOR('',#474,1.); +#474 = DIRECTION('',(-8.501532861635E-02,0.,0.996379643459)); +#475 = ORIENTED_EDGE('',*,*,#476,.F.); +#476 = EDGE_CURVE('',#453,#469,#477,.T.); +#477 = LINE('',#478,#479); +#478 = CARTESIAN_POINT('',(1.615747408047,3.2,-49.64588621989)); +#479 = VECTOR('',#480,1.); +#480 = DIRECTION('',(-1.,0.,0.)); +#481 = FACE_BOUND('',#482,.T.); +#482 = EDGE_LOOP('',(#483)); +#483 = ORIENTED_EDGE('',*,*,#484,.F.); +#484 = EDGE_CURVE('',#485,#485,#487,.T.); +#485 = VERTEX_POINT('',#486); +#486 = CARTESIAN_POINT('',(-11.6927539352,3.2,-48.00951684793)); +#487 = CIRCLE('',#488,4.735522705283); +#488 = AXIS2_PLACEMENT_3D('',#489,#490,#491); +#489 = CARTESIAN_POINT('',(-16.42827664048,3.2,-48.00951684793)); +#490 = DIRECTION('',(-0.,1.,0.)); +#491 = DIRECTION('',(1.,0.,0.)); +#492 = PLANE('',#493); +#493 = AXIS2_PLACEMENT_3D('',#494,#495,#496); +#494 = CARTESIAN_POINT('',(0.403056515455,3.2,-33.34517655273)); +#495 = DIRECTION('',(0.,1.,0.)); +#496 = DIRECTION('',(1.,0.,0.)); +#497 = ADVANCED_FACE('',(#498),#509,.F.); +#498 = FACE_BOUND('',#499,.F.); +#499 = EDGE_LOOP('',(#500,#506,#507,#508)); +#500 = ORIENTED_EDGE('',*,*,#501,.F.); +#501 = EDGE_CURVE('',#168,#377,#502,.T.); +#502 = LINE('',#503,#504); +#503 = CARTESIAN_POINT('',(32.703857168398,3.,-60.78483712899)); +#504 = VECTOR('',#505,1.); +#505 = DIRECTION('',(0.,1.,0.)); +#506 = ORIENTED_EDGE('',*,*,#167,.T.); +#507 = ORIENTED_EDGE('',*,*,#233,.F.); +#508 = ORIENTED_EDGE('',*,*,#376,.F.); +#509 = PLANE('',#510); +#510 = AXIS2_PLACEMENT_3D('',#511,#512,#513); +#511 = CARTESIAN_POINT('',(29.704873980143,3.,-57.88259703786)); +#512 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#513 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#514 = ADVANCED_FACE('',(#515),#526,.F.); +#515 = FACE_BOUND('',#516,.F.); +#516 = EDGE_LOOP('',(#517,#523,#524,#525)); +#517 = ORIENTED_EDGE('',*,*,#518,.F.); +#518 = EDGE_CURVE('',#176,#385,#519,.T.); +#519 = LINE('',#520,#521); +#520 = CARTESIAN_POINT('',(26.715163243538,3.,-66.97315781796)); +#521 = VECTOR('',#522,1.); +#522 = DIRECTION('',(0.,1.,0.)); +#523 = ORIENTED_EDGE('',*,*,#175,.T.); +#524 = ORIENTED_EDGE('',*,*,#501,.T.); +#525 = ORIENTED_EDGE('',*,*,#384,.F.); +#526 = PLANE('',#527); +#527 = AXIS2_PLACEMENT_3D('',#528,#529,#530); +#528 = CARTESIAN_POINT('',(29.709510205968,3.,-63.87899747347)); +#529 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#530 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#531 = ADVANCED_FACE('',(#532),#543,.F.); +#532 = FACE_BOUND('',#533,.F.); +#533 = EDGE_LOOP('',(#534,#540,#541,#542)); +#534 = ORIENTED_EDGE('',*,*,#535,.T.); +#535 = EDGE_CURVE('',#393,#184,#536,.T.); +#536 = LINE('',#537,#538); +#537 = CARTESIAN_POINT('',(20.532019001374,3.,-60.98947335481)); +#538 = VECTOR('',#539,1.); +#539 = DIRECTION('',(0.,-1.,0.)); +#540 = ORIENTED_EDGE('',*,*,#183,.T.); +#541 = ORIENTED_EDGE('',*,*,#518,.T.); +#542 = ORIENTED_EDGE('',*,*,#392,.F.); +#543 = PLANE('',#544); +#544 = AXIS2_PLACEMENT_3D('',#545,#546,#547); +#545 = CARTESIAN_POINT('',(23.522653113203,3.,-63.8836336993)); +#546 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#547 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#548 = ADVANCED_FACE('',(#549),#560,.F.); +#549 = FACE_BOUND('',#550,.F.); +#550 = EDGE_LOOP('',(#551,#557,#558,#559)); +#551 = ORIENTED_EDGE('',*,*,#552,.F.); +#552 = EDGE_CURVE('',#192,#401,#553,.T.); +#553 = LINE('',#554,#555); +#554 = CARTESIAN_POINT('',(-20.53201900137,3.,-60.98947335481)); +#555 = VECTOR('',#556,1.); +#556 = DIRECTION('',(0.,1.,0.)); +#557 = ORIENTED_EDGE('',*,*,#191,.T.); +#558 = ORIENTED_EDGE('',*,*,#535,.F.); +#559 = ORIENTED_EDGE('',*,*,#400,.F.); +#560 = PLANE('',#561); +#561 = AXIS2_PLACEMENT_3D('',#562,#563,#564); +#562 = CARTESIAN_POINT('',(10.306473847682,3.,-60.98947335481)); +#563 = DIRECTION('',(0.,0.,1.)); +#564 = DIRECTION('',(0.,-1.,0.)); +#565 = ADVANCED_FACE('',(#566),#577,.T.); +#566 = FACE_BOUND('',#567,.T.); +#567 = EDGE_LOOP('',(#568,#574,#575,#576)); +#568 = ORIENTED_EDGE('',*,*,#569,.F.); +#569 = EDGE_CURVE('',#409,#200,#570,.T.); +#570 = LINE('',#571,#572); +#571 = CARTESIAN_POINT('',(-25.53052705686,3.,-65.82673637491)); +#572 = VECTOR('',#573,1.); +#573 = DIRECTION('',(0.,-1.,0.)); +#574 = ORIENTED_EDGE('',*,*,#408,.F.); +#575 = ORIENTED_EDGE('',*,*,#552,.F.); +#576 = ORIENTED_EDGE('',*,*,#199,.T.); +#577 = PLANE('',#578); +#578 = AXIS2_PLACEMENT_3D('',#579,#580,#581); +#579 = CARTESIAN_POINT('',(-23.00219525444,3.,-63.37996509944)); +#580 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#581 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#582 = ADVANCED_FACE('',(#583),#594,.T.); +#583 = FACE_BOUND('',#584,.T.); +#584 = EDGE_LOOP('',(#585,#591,#592,#593)); +#585 = ORIENTED_EDGE('',*,*,#586,.F.); +#586 = EDGE_CURVE('',#417,#83,#587,.T.); +#587 = LINE('',#588,#589); +#588 = CARTESIAN_POINT('',(-27.86158468659,3.,-65.78852163128)); +#589 = VECTOR('',#590,1.); +#590 = DIRECTION('',(0.,-1.,0.)); +#591 = ORIENTED_EDGE('',*,*,#416,.T.); +#592 = ORIENTED_EDGE('',*,*,#569,.T.); +#593 = ORIENTED_EDGE('',*,*,#207,.F.); +#594 = CYLINDRICAL_SURFACE('',#595,1.648528137424); +#595 = AXIS2_PLACEMENT_3D('',#596,#597,#598); +#596 = CARTESIAN_POINT('',(-26.67694849991,3.,-64.64210018823)); +#597 = DIRECTION('',(0.,-1.,0.)); +#598 = DIRECTION('',(-1.,0.,0.)); +#599 = ADVANCED_FACE('',(#600),#611,.T.); +#600 = FACE_BOUND('',#601,.T.); +#601 = EDGE_LOOP('',(#602,#608,#609,#610)); +#602 = ORIENTED_EDGE('',*,*,#603,.F.); +#603 = EDGE_CURVE('',#426,#85,#604,.T.); +#604 = LINE('',#605,#606); +#605 = CARTESIAN_POINT('',(-29.08766684168,3.,-64.52156932717)); +#606 = VECTOR('',#607,1.); +#607 = DIRECTION('',(0.,-1.,0.)); +#608 = ORIENTED_EDGE('',*,*,#425,.F.); +#609 = ORIENTED_EDGE('',*,*,#586,.T.); +#610 = ORIENTED_EDGE('',*,*,#82,.T.); +#611 = PLANE('',#612); +#612 = AXIS2_PLACEMENT_3D('',#613,#614,#615); +#613 = CARTESIAN_POINT('',(-28.47462576413,3.,-65.15504547923)); +#614 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#615 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#616 = ADVANCED_FACE('',(#617),#628,.T.); +#617 = FACE_BOUND('',#618,.T.); +#618 = EDGE_LOOP('',(#619,#625,#626,#627)); +#619 = ORIENTED_EDGE('',*,*,#620,.F.); +#620 = EDGE_CURVE('',#434,#93,#621,.T.); +#621 = LINE('',#622,#623); +#622 = CARTESIAN_POINT('',(-28.96712497021,3.,-57.16864680227)); +#623 = VECTOR('',#624,1.); +#624 = DIRECTION('',(0.,-1.,0.)); +#625 = ORIENTED_EDGE('',*,*,#433,.T.); +#626 = ORIENTED_EDGE('',*,*,#603,.T.); +#627 = ORIENTED_EDGE('',*,*,#92,.F.); +#628 = CYLINDRICAL_SURFACE('',#629,5.2); +#629 = AXIS2_PLACEMENT_3D('',#630,#631,#632); +#630 = CARTESIAN_POINT('',(-25.35093464349,3.,-60.90537900045)); +#631 = DIRECTION('',(0.,-1.,0.)); +#632 = DIRECTION('',(-1.,0.,0.)); +#633 = ADVANCED_FACE('',(#634),#645,.T.); +#634 = FACE_BOUND('',#635,.T.); +#635 = EDGE_LOOP('',(#636,#642,#643,#644)); +#636 = ORIENTED_EDGE('',*,*,#637,.F.); +#637 = EDGE_CURVE('',#326,#102,#638,.T.); +#638 = LINE('',#639,#640); +#639 = CARTESIAN_POINT('',(-26.93875323652,3.,-55.20570756731)); +#640 = VECTOR('',#641,1.); +#641 = DIRECTION('',(0.,-1.,0.)); +#642 = ORIENTED_EDGE('',*,*,#442,.F.); +#643 = ORIENTED_EDGE('',*,*,#620,.T.); +#644 = ORIENTED_EDGE('',*,*,#101,.T.); +#645 = PLANE('',#646); +#646 = AXIS2_PLACEMENT_3D('',#647,#648,#649); +#647 = CARTESIAN_POINT('',(-27.90836811563,3.,-56.14404399617)); +#648 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#649 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#650 = ADVANCED_FACE('',(#651),#662,.T.); +#651 = FACE_BOUND('',#652,.T.); +#652 = EDGE_LOOP('',(#653,#659,#660,#661)); +#653 = ORIENTED_EDGE('',*,*,#654,.F.); +#654 = EDGE_CURVE('',#328,#110,#655,.T.); +#655 = LINE('',#656,#657); +#656 = CARTESIAN_POINT('',(-41.17904151244,3.,-10.52828909594)); +#657 = VECTOR('',#658,1.); +#658 = DIRECTION('',(0.,-1.,0.)); +#659 = ORIENTED_EDGE('',*,*,#325,.F.); +#660 = ORIENTED_EDGE('',*,*,#637,.T.); +#661 = ORIENTED_EDGE('',*,*,#109,.T.); +#662 = PLANE('',#663); +#663 = AXIS2_PLACEMENT_3D('',#664,#665,#666); +#664 = CARTESIAN_POINT('',(-34.04006158346,3.,-32.92609366041)); +#665 = DIRECTION('',(-0.952773183837,0.,-0.30368282823)); +#666 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#667 = ADVANCED_FACE('',(#668),#679,.T.); +#668 = FACE_BOUND('',#669,.T.); +#669 = EDGE_LOOP('',(#670,#676,#677,#678)); +#670 = ORIENTED_EDGE('',*,*,#671,.F.); +#671 = EDGE_CURVE('',#336,#118,#672,.T.); +#672 = LINE('',#673,#674); +#673 = CARTESIAN_POINT('',(-39.69176606491,3.,-4.408923352436)); +#674 = VECTOR('',#675,1.); +#675 = DIRECTION('',(0.,-1.,0.)); +#676 = ORIENTED_EDGE('',*,*,#335,.T.); +#677 = ORIENTED_EDGE('',*,*,#654,.T.); +#678 = ORIENTED_EDGE('',*,*,#117,.F.); +#679 = CYLINDRICAL_SURFACE('',#680,6.054044962965); +#680 = AXIS2_PLACEMENT_3D('',#681,#682,#683); +#681 = CARTESIAN_POINT('',(-35.41090981799,3.,-8.689779599357)); +#682 = DIRECTION('',(0.,-1.,0.)); +#683 = DIRECTION('',(-1.,0.,0.)); +#684 = ADVANCED_FACE('',(#685),#696,.T.); +#685 = FACE_BOUND('',#686,.T.); +#686 = EDGE_LOOP('',(#687,#693,#694,#695)); +#687 = ORIENTED_EDGE('',*,*,#688,.F.); +#688 = EDGE_CURVE('',#345,#127,#689,.T.); +#689 = LINE('',#690,#691); +#690 = CARTESIAN_POINT('',(-36.85603142851,3.,-1.573188716044)); +#691 = VECTOR('',#692,1.); +#692 = DIRECTION('',(0.,-1.,0.)); +#693 = ORIENTED_EDGE('',*,*,#344,.F.); +#694 = ORIENTED_EDGE('',*,*,#671,.T.); +#695 = ORIENTED_EDGE('',*,*,#126,.T.); +#696 = PLANE('',#697); +#697 = AXIS2_PLACEMENT_3D('',#698,#699,#700); +#698 = CARTESIAN_POINT('',(-38.27389874671,3.,-2.99105603424)); +#699 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#700 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#701 = ADVANCED_FACE('',(#702),#713,.T.); +#702 = FACE_BOUND('',#703,.T.); +#703 = EDGE_LOOP('',(#704,#710,#711,#712)); +#704 = ORIENTED_EDGE('',*,*,#705,.F.); +#705 = EDGE_CURVE('',#353,#135,#706,.T.); +#706 = LINE('',#707,#708); +#707 = CARTESIAN_POINT('',(-32.57517518159,3.,0.2)); +#708 = VECTOR('',#709,1.); +#709 = DIRECTION('',(0.,-1.,0.)); +#710 = ORIENTED_EDGE('',*,*,#352,.T.); +#711 = ORIENTED_EDGE('',*,*,#688,.T.); +#712 = ORIENTED_EDGE('',*,*,#134,.F.); +#713 = CYLINDRICAL_SURFACE('',#714,6.054044962965); +#714 = AXIS2_PLACEMENT_3D('',#715,#716,#717); +#715 = CARTESIAN_POINT('',(-32.57517518159,3.,-5.854044962965)); +#716 = DIRECTION('',(0.,-1.,0.)); +#717 = DIRECTION('',(-1.,0.,0.)); +#718 = ADVANCED_FACE('',(#719),#730,.T.); +#719 = FACE_BOUND('',#720,.T.); +#720 = EDGE_LOOP('',(#721,#727,#728,#729)); +#721 = ORIENTED_EDGE('',*,*,#722,.F.); +#722 = EDGE_CURVE('',#362,#144,#723,.T.); +#723 = LINE('',#724,#725); +#724 = CARTESIAN_POINT('',(35.082842712475,3.,0.2)); +#725 = VECTOR('',#726,1.); +#726 = DIRECTION('',(0.,-1.,0.)); +#727 = ORIENTED_EDGE('',*,*,#361,.F.); +#728 = ORIENTED_EDGE('',*,*,#705,.T.); +#729 = ORIENTED_EDGE('',*,*,#143,.T.); +#730 = PLANE('',#731); +#731 = AXIS2_PLACEMENT_3D('',#732,#733,#734); +#732 = CARTESIAN_POINT('',(-16.28758759079,3.,0.2)); +#733 = DIRECTION('',(0.,0.,1.)); +#734 = DIRECTION('',(0.,-1.,0.)); +#735 = ADVANCED_FACE('',(#736),#742,.F.); +#736 = FACE_BOUND('',#737,.F.); +#737 = EDGE_LOOP('',(#738,#739,#740,#741)); +#738 = ORIENTED_EDGE('',*,*,#151,.T.); +#739 = ORIENTED_EDGE('',*,*,#722,.F.); +#740 = ORIENTED_EDGE('',*,*,#369,.F.); +#741 = ORIENTED_EDGE('',*,*,#242,.T.); +#742 = PLANE('',#743); +#743 = AXIS2_PLACEMENT_3D('',#744,#745,#746); +#744 = CARTESIAN_POINT('',(38.67695526217,3.,-3.394112549695)); +#745 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#746 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#747 = ADVANCED_FACE('',(#748),#765,.T.); +#748 = FACE_BOUND('',#749,.T.); +#749 = EDGE_LOOP('',(#750,#758,#764)); +#750 = ORIENTED_EDGE('',*,*,#751,.T.); +#751 = EDGE_CURVE('',#451,#752,#754,.T.); +#752 = VERTEX_POINT('',#753); +#753 = CARTESIAN_POINT('',(3.256654205567E-15,17.8572529153, + -18.39898295202)); +#754 = LINE('',#755,#756); +#755 = CARTESIAN_POINT('',(5.649679875255,3.602918464526,-13.21082950266 + )); +#756 = VECTOR('',#757,1.); +#757 = DIRECTION('',(-0.349023821871,0.880598971639,-0.320511814002)); +#758 = ORIENTED_EDGE('',*,*,#759,.T.); +#759 = EDGE_CURVE('',#752,#461,#760,.T.); +#760 = LINE('',#761,#762); +#761 = CARTESIAN_POINT('',(-5.275833888477,4.546144602338, + -13.55413574101)); +#762 = VECTOR('',#763,1.); +#763 = DIRECTION('',(-0.349023821871,-0.880598971639,0.320511814002)); +#764 = ORIENTED_EDGE('',*,*,#460,.T.); +#765 = PLANE('',#766); +#766 = AXIS2_PLACEMENT_3D('',#767,#768,#769); +#767 = CARTESIAN_POINT('',(2.828438300639,3.068404028665,-13.01628215822 + )); +#768 = DIRECTION('',(2.881637632171E-16,0.342020143326,0.939692620786)); +#769 = DIRECTION('',(-1.048830324052E-16,0.939692620786,-0.342020143326) + ); +#770 = ADVANCED_FACE('',(#771),#789,.T.); +#771 = FACE_BOUND('',#772,.T.); +#772 = EDGE_LOOP('',(#773,#781,#782,#783)); +#773 = ORIENTED_EDGE('',*,*,#774,.T.); +#774 = EDGE_CURVE('',#775,#752,#777,.T.); +#775 = VERTEX_POINT('',#776); +#776 = CARTESIAN_POINT('',(1.480297366167E-15,11.242400581089, + -46.71869179633)); +#777 = LINE('',#778,#779); +#778 = CARTESIAN_POINT('',(2.6645352591E-15,18.133069549222, + -17.21814831317)); +#779 = VECTOR('',#780,1.); +#780 = DIRECTION('',(5.275122655166E-17,0.227455280238,0.97378852709)); +#781 = ORIENTED_EDGE('',*,*,#751,.F.); +#782 = ORIENTED_EDGE('',*,*,#450,.T.); +#783 = ORIENTED_EDGE('',*,*,#784,.T.); +#784 = EDGE_CURVE('',#453,#775,#785,.T.); +#785 = LINE('',#786,#787); +#786 = CARTESIAN_POINT('',(1.103762571829,7.940067898719,-47.92064259636 + )); +#787 = VECTOR('',#788,1.); +#788 = DIRECTION('',(-0.299648208284,0.896513522642,0.326304236859)); +#789 = PLANE('',#790); +#790 = AXIS2_PLACEMENT_3D('',#791,#792,#793); +#791 = CARTESIAN_POINT('',(5.823691883056,3.068404028665,-13.45978839622 + )); +#792 = DIRECTION('',(0.93629059846,0.342020143326,-7.988827695448E-02)); +#793 = DIRECTION('',(-0.340781908463,0.939692620786,2.907695487824E-02) + ); +#794 = ADVANCED_FACE('',(#795),#805,.T.); +#795 = FACE_BOUND('',#796,.T.); +#796 = EDGE_LOOP('',(#797,#803,#804)); +#797 = ORIENTED_EDGE('',*,*,#798,.T.); +#798 = EDGE_CURVE('',#469,#775,#799,.T.); +#799 = LINE('',#800,#801); +#800 = CARTESIAN_POINT('',(-0.871390517001,8.635298785064, + -47.66759924779)); +#801 = VECTOR('',#802,1.); +#802 = DIRECTION('',(0.299648208284,0.896513522642,0.326304236859)); +#803 = ORIENTED_EDGE('',*,*,#784,.F.); +#804 = ORIENTED_EDGE('',*,*,#476,.T.); +#805 = PLANE('',#806); +#806 = AXIS2_PLACEMENT_3D('',#807,#808,#809); +#807 = CARTESIAN_POINT('',(2.828438300639,3.068404028665,-49.69378323641 + )); +#808 = DIRECTION('',(0.,0.342020143326,-0.939692620786)); +#809 = DIRECTION('',(0.,0.939692620786,0.342020143326)); +#810 = ADVANCED_FACE('',(#811),#817,.T.); +#811 = FACE_BOUND('',#812,.T.); +#812 = EDGE_LOOP('',(#813,#814,#815,#816)); +#813 = ORIENTED_EDGE('',*,*,#468,.T.); +#814 = ORIENTED_EDGE('',*,*,#759,.F.); +#815 = ORIENTED_EDGE('',*,*,#774,.F.); +#816 = ORIENTED_EDGE('',*,*,#798,.F.); +#817 = PLANE('',#818); +#818 = AXIS2_PLACEMENT_3D('',#819,#820,#821); +#819 = CARTESIAN_POINT('',(-5.782806207227,3.068404028665, + -13.93896851312)); +#820 = DIRECTION('',(-0.93629059846,0.342020143326,-7.988827695448E-02) + ); +#821 = DIRECTION('',(0.340781908463,0.939692620786,2.907695487824E-02)); +#822 = ADVANCED_FACE('',(#823),#834,.F.); +#823 = FACE_BOUND('',#824,.F.); +#824 = EDGE_LOOP('',(#825,#831,#832,#833)); +#825 = ORIENTED_EDGE('',*,*,#826,.F.); +#826 = EDGE_CURVE('',#217,#485,#827,.T.); +#827 = LINE('',#828,#829); +#828 = CARTESIAN_POINT('',(-11.6927539352,-22.,-48.00951684793)); +#829 = VECTOR('',#830,1.); +#830 = DIRECTION('',(0.,1.,0.)); +#831 = ORIENTED_EDGE('',*,*,#216,.T.); +#832 = ORIENTED_EDGE('',*,*,#826,.T.); +#833 = ORIENTED_EDGE('',*,*,#484,.F.); +#834 = CYLINDRICAL_SURFACE('',#835,4.735522705283); +#835 = AXIS2_PLACEMENT_3D('',#836,#837,#838); +#836 = CARTESIAN_POINT('',(-16.42827664048,-22.,-48.00951684793)); +#837 = DIRECTION('',(0.,1.,0.)); +#838 = DIRECTION('',(1.,0.,0.)); +#839 = ADVANCED_FACE('',(#840),#851,.F.); +#840 = FACE_BOUND('',#841,.F.); +#841 = EDGE_LOOP('',(#842,#848,#849,#850)); +#842 = ORIENTED_EDGE('',*,*,#843,.F.); +#843 = EDGE_CURVE('',#72,#315,#844,.T.); +#844 = LINE('',#845,#846); +#845 = CARTESIAN_POINT('',(21.163799345768,-22.,-48.00951684793)); +#846 = VECTOR('',#847,1.); +#847 = DIRECTION('',(0.,1.,0.)); +#848 = ORIENTED_EDGE('',*,*,#71,.T.); +#849 = ORIENTED_EDGE('',*,*,#843,.T.); +#850 = ORIENTED_EDGE('',*,*,#314,.F.); +#851 = CYLINDRICAL_SURFACE('',#852,4.735522705283); +#852 = AXIS2_PLACEMENT_3D('',#853,#854,#855); +#853 = CARTESIAN_POINT('',(16.428276640485,-22.,-48.00951684793)); +#854 = DIRECTION('',(0.,1.,0.)); +#855 = DIRECTION('',(1.,0.,0.)); +#856 = ADVANCED_FACE('',(#857),#873,.F.); +#857 = FACE_BOUND('',#858,.F.); +#858 = EDGE_LOOP('',(#859,#865,#866,#872)); +#859 = ORIENTED_EDGE('',*,*,#860,.F.); +#860 = EDGE_CURVE('',#24,#265,#861,.T.); +#861 = LINE('',#862,#863); +#862 = CARTESIAN_POINT('',(16.626582997737,-22.,-8.940188245231)); +#863 = VECTOR('',#864,1.); +#864 = DIRECTION('',(0.,1.,0.)); +#865 = ORIENTED_EDGE('',*,*,#63,.T.); +#866 = ORIENTED_EDGE('',*,*,#867,.T.); +#867 = EDGE_CURVE('',#56,#267,#868,.T.); +#868 = LINE('',#869,#870); +#869 = CARTESIAN_POINT('',(-5.329070518201E-15,-22.,-8.903751135252)); +#870 = VECTOR('',#871,1.); +#871 = DIRECTION('',(0.,1.,0.)); +#872 = ORIENTED_EDGE('',*,*,#264,.F.); +#873 = PLANE('',#874); +#874 = AXIS2_PLACEMENT_3D('',#875,#876,#877); +#875 = CARTESIAN_POINT('',(8.237581109188,-22.,-8.921803528809)); +#876 = DIRECTION('',(-2.191520817069E-03,0.,-0.999997598615)); +#877 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03)); +#878 = ADVANCED_FACE('',(#879),#890,.F.); +#879 = FACE_BOUND('',#880,.F.); +#880 = EDGE_LOOP('',(#881,#887,#888,#889)); +#881 = ORIENTED_EDGE('',*,*,#882,.T.); +#882 = EDGE_CURVE('',#48,#299,#883,.T.); +#883 = LINE('',#884,#885); +#884 = CARTESIAN_POINT('',(-21.72552223146,-22.,-8.903751135252)); +#885 = VECTOR('',#886,1.); +#886 = DIRECTION('',(0.,1.,0.)); +#887 = ORIENTED_EDGE('',*,*,#306,.F.); +#888 = ORIENTED_EDGE('',*,*,#867,.F.); +#889 = ORIENTED_EDGE('',*,*,#55,.T.); +#890 = PLANE('',#891); +#891 = AXIS2_PLACEMENT_3D('',#892,#893,#894); +#892 = CARTESIAN_POINT('',(-10.96276111573,-22.,-8.903751135252)); +#893 = DIRECTION('',(0.,0.,-1.)); +#894 = DIRECTION('',(0.,1.,0.)); +#895 = ADVANCED_FACE('',(#896),#907,.F.); +#896 = FACE_BOUND('',#897,.F.); +#897 = EDGE_LOOP('',(#898,#904,#905,#906)); +#898 = ORIENTED_EDGE('',*,*,#899,.T.); +#899 = EDGE_CURVE('',#40,#291,#900,.T.); +#900 = LINE('',#901,#902); +#901 = CARTESIAN_POINT('',(-21.72552223146,-22.,-3.734519760785)); +#902 = VECTOR('',#903,1.); +#903 = DIRECTION('',(0.,1.,0.)); +#904 = ORIENTED_EDGE('',*,*,#298,.F.); +#905 = ORIENTED_EDGE('',*,*,#882,.F.); +#906 = ORIENTED_EDGE('',*,*,#47,.T.); +#907 = PLANE('',#908); +#908 = AXIS2_PLACEMENT_3D('',#909,#910,#911); +#909 = CARTESIAN_POINT('',(-21.72552223146,-22.,-6.319135448019)); +#910 = DIRECTION('',(-1.,0.,0.)); +#911 = DIRECTION('',(0.,1.,0.)); +#912 = ADVANCED_FACE('',(#913),#924,.F.); +#913 = FACE_BOUND('',#914,.F.); +#914 = EDGE_LOOP('',(#915,#921,#922,#923)); +#915 = ORIENTED_EDGE('',*,*,#916,.T.); +#916 = EDGE_CURVE('',#32,#283,#917,.T.); +#917 = LINE('',#918,#919); +#918 = CARTESIAN_POINT('',(22.059435554995,-22.,-3.734519760785)); +#919 = VECTOR('',#920,1.); +#920 = DIRECTION('',(0.,1.,0.)); +#921 = ORIENTED_EDGE('',*,*,#290,.F.); +#922 = ORIENTED_EDGE('',*,*,#899,.F.); +#923 = ORIENTED_EDGE('',*,*,#39,.T.); +#924 = PLANE('',#925); +#925 = AXIS2_PLACEMENT_3D('',#926,#927,#928); +#926 = CARTESIAN_POINT('',(0.19111316645,-22.,-3.734519760785)); +#927 = DIRECTION('',(0.,0.,1.)); +#928 = DIRECTION('',(0.,-1.,0.)); +#929 = ADVANCED_FACE('',(#930),#941,.F.); +#930 = FACE_BOUND('',#931,.F.); +#931 = EDGE_LOOP('',(#932,#938,#939,#940)); +#932 = ORIENTED_EDGE('',*,*,#933,.T.); +#933 = EDGE_CURVE('',#22,#275,#934,.T.); +#934 = LINE('',#935,#936); +#935 = CARTESIAN_POINT('',(19.029295926024,-22.,-17.63009960955)); +#936 = VECTOR('',#937,1.); +#937 = DIRECTION('',(0.,1.,0.)); +#938 = ORIENTED_EDGE('',*,*,#282,.F.); +#939 = ORIENTED_EDGE('',*,*,#916,.F.); +#940 = ORIENTED_EDGE('',*,*,#31,.T.); +#941 = PLANE('',#942); +#942 = AXIS2_PLACEMENT_3D('',#943,#944,#945); +#943 = CARTESIAN_POINT('',(20.484588228021,-22.,-10.95643672209)); +#944 = DIRECTION('',(0.977039526026,0.,-0.213058124893)); +#945 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#946 = ADVANCED_FACE('',(#947),#953,.F.); +#947 = FACE_BOUND('',#948,.F.); +#948 = EDGE_LOOP('',(#949,#950,#951,#952)); +#949 = ORIENTED_EDGE('',*,*,#21,.T.); +#950 = ORIENTED_EDGE('',*,*,#860,.T.); +#951 = ORIENTED_EDGE('',*,*,#274,.F.); +#952 = ORIENTED_EDGE('',*,*,#933,.F.); +#953 = PLANE('',#954); +#954 = AXIS2_PLACEMENT_3D('',#955,#956,#957); +#955 = CARTESIAN_POINT('',(17.956031892536,-22.,-13.7484168618)); +#956 = DIRECTION('',(-0.963836182336,0.,-0.26649542889)); +#957 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#958 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#962)) GLOBAL_UNIT_ASSIGNED_CONTEXT +((#959,#960,#961)) REPRESENTATION_CONTEXT('Context #1', + '3D Context with UNIT and UNCERTAINTY') ); +#959 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#960 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#961 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#962 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#959, + 'distance_accuracy_value','confusion accuracy'); +#963 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/CAD/design/mate-connectors/BearConnector_Female.step b/docs/CAD/design/mate-connectors/BearConnector_Female.step new file mode 100644 index 0000000000..18e31c4b47 --- /dev/null +++ b/docs/CAD/design/mate-connectors/BearConnector_Female.step @@ -0,0 +1,1173 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('FreeCAD Model'),'2;1'); +FILE_NAME('Open CASCADE Shape Model','2026-08-05T10:54:26',('FreeCAD'),( + 'FreeCAD'),'Open CASCADE STEP processor 7.8','FreeCAD','Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('Open CASCADE STEP translator 7.8 1', + 'Open CASCADE STEP translator 7.8 1','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#1118); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#57,#222,#246,#270,#287,#299,#330,#354,#378, + #402,#426,#451,#475,#500,#524,#548,#573,#597,#622,#646,#670,#687, + #817,#848,#872,#896,#920,#944,#961,#986,#1017,#1033,#1050,#1061, + #1086,#1100,#1109)); +#17 = ADVANCED_FACE('',(#18),#52,.F.); +#18 = FACE_BOUND('',#19,.F.); +#19 = EDGE_LOOP('',(#20,#30,#38,#46)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(-45.46495478095,0.,4.2)); +#26 = LINE('',#27,#28); +#27 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#28 = VECTOR('',#29,1.); +#29 = DIRECTION('',(0.,0.,1.)); +#30 = ORIENTED_EDGE('',*,*,#31,.T.); +#31 = EDGE_CURVE('',#22,#32,#34,.T.); +#32 = VERTEX_POINT('',#33); +#33 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,-70.97315781796) + ); +#34 = LINE('',#35,#36); +#35 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#36 = VECTOR('',#37,1.); +#37 = DIRECTION('',(0.,1.,0.)); +#38 = ORIENTED_EDGE('',*,*,#39,.T.); +#39 = EDGE_CURVE('',#32,#40,#42,.T.); +#40 = VERTEX_POINT('',#41); +#41 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,4.2)); +#42 = LINE('',#43,#44); +#43 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,-70.97315781796) + ); +#44 = VECTOR('',#45,1.); +#45 = DIRECTION('',(0.,0.,1.)); +#46 = ORIENTED_EDGE('',*,*,#47,.F.); +#47 = EDGE_CURVE('',#24,#40,#48,.T.); +#48 = LINE('',#49,#50); +#49 = CARTESIAN_POINT('',(-45.46495478095,0.,4.2)); +#50 = VECTOR('',#51,1.); +#51 = DIRECTION('',(0.,1.,0.)); +#52 = PLANE('',#53); +#53 = AXIS2_PLACEMENT_3D('',#54,#55,#56); +#54 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#55 = DIRECTION('',(1.,0.,0.)); +#56 = DIRECTION('',(0.,0.,1.)); +#57 = ADVANCED_FACE('',(#58,#83),#217,.F.); +#58 = FACE_BOUND('',#59,.F.); +#59 = EDGE_LOOP('',(#60,#68,#69,#77)); +#60 = ORIENTED_EDGE('',*,*,#61,.F.); +#61 = EDGE_CURVE('',#22,#62,#64,.T.); +#62 = VERTEX_POINT('',#63); +#63 = CARTESIAN_POINT('',(46.298608189024,0.,-70.97315781796)); +#64 = LINE('',#65,#66); +#65 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#66 = VECTOR('',#67,1.); +#67 = DIRECTION('',(1.,0.,0.)); +#68 = ORIENTED_EDGE('',*,*,#21,.T.); +#69 = ORIENTED_EDGE('',*,*,#70,.T.); +#70 = EDGE_CURVE('',#24,#71,#73,.T.); +#71 = VERTEX_POINT('',#72); +#72 = CARTESIAN_POINT('',(46.298608189024,0.,4.2)); +#73 = LINE('',#74,#75); +#74 = CARTESIAN_POINT('',(-45.46495478095,0.,4.2)); +#75 = VECTOR('',#76,1.); +#76 = DIRECTION('',(1.,0.,0.)); +#77 = ORIENTED_EDGE('',*,*,#78,.F.); +#78 = EDGE_CURVE('',#62,#71,#79,.T.); +#79 = LINE('',#80,#81); +#80 = CARTESIAN_POINT('',(46.298608189024,0.,-70.97315781796)); +#81 = VECTOR('',#82,1.); +#82 = DIRECTION('',(0.,0.,1.)); +#83 = FACE_BOUND('',#84,.F.); +#84 = EDGE_LOOP('',(#85,#95,#103,#111,#119,#128,#136,#145,#153,#161,#170 + ,#178,#187,#195,#203,#211)); +#85 = ORIENTED_EDGE('',*,*,#86,.T.); +#86 = EDGE_CURVE('',#87,#89,#91,.T.); +#87 = VERTEX_POINT('',#88); +#88 = CARTESIAN_POINT('',(32.703857168398,0.,-60.78483712899)); +#89 = VERTEX_POINT('',#90); +#90 = CARTESIAN_POINT('',(26.938753236523,0.,-55.20570756731)); +#91 = LINE('',#92,#93); +#92 = CARTESIAN_POINT('',(13.567306766147,0.,-42.26560567724)); +#93 = VECTOR('',#94,1.); +#94 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#95 = ORIENTED_EDGE('',*,*,#96,.T.); +#96 = EDGE_CURVE('',#89,#97,#99,.T.); +#97 = VERTEX_POINT('',#98); +#98 = CARTESIAN_POINT('',(42.298608189024,0.,-7.015765476549)); +#99 = LINE('',#100,#101); +#100 = CARTESIAN_POINT('',(25.140315874087,-4.440892098501E-16, + -60.84811712245)); +#101 = VECTOR('',#102,1.); +#102 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#103 = ORIENTED_EDGE('',*,*,#104,.T.); +#104 = EDGE_CURVE('',#97,#105,#107,.T.); +#105 = VERTEX_POINT('',#106); +#106 = CARTESIAN_POINT('',(35.082842712475,0.,0.2)); +#107 = LINE('',#108,#109); +#108 = CARTESIAN_POINT('',(34.536239068456,0.,0.746603644019)); +#109 = VECTOR('',#110,1.); +#110 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#111 = ORIENTED_EDGE('',*,*,#112,.F.); +#112 = EDGE_CURVE('',#113,#105,#115,.T.); +#113 = VERTEX_POINT('',#114); +#114 = CARTESIAN_POINT('',(-32.57517518159,0.,0.2)); +#115 = LINE('',#116,#117); +#116 = CARTESIAN_POINT('',(-30.87627118587,0.,0.2)); +#117 = VECTOR('',#118,1.); +#118 = DIRECTION('',(1.,0.,0.)); +#119 = ORIENTED_EDGE('',*,*,#120,.T.); +#120 = EDGE_CURVE('',#113,#121,#123,.T.); +#121 = VERTEX_POINT('',#122); +#122 = CARTESIAN_POINT('',(-36.85603142851,0.,-1.573188716044)); +#123 = CIRCLE('',#124,6.054044962965); +#124 = AXIS2_PLACEMENT_3D('',#125,#126,#127); +#125 = CARTESIAN_POINT('',(-32.57517518159,0.,-5.854044962965)); +#126 = DIRECTION('',(0.,-1.,0.)); +#127 = DIRECTION('',(-1.,0.,0.)); +#128 = ORIENTED_EDGE('',*,*,#129,.F.); +#129 = EDGE_CURVE('',#130,#121,#132,.T.); +#130 = VERTEX_POINT('',#131); +#131 = CARTESIAN_POINT('',(-39.69176606491,0.,-4.408923352436)); +#132 = LINE('',#133,#134); +#133 = CARTESIAN_POINT('',(-57.0671882012,0.,-21.78434548873)); +#134 = VECTOR('',#135,1.); +#135 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#136 = ORIENTED_EDGE('',*,*,#137,.T.); +#137 = EDGE_CURVE('',#130,#138,#140,.T.); +#138 = VERTEX_POINT('',#139); +#139 = CARTESIAN_POINT('',(-41.17904151244,0.,-10.52828909594)); +#140 = CIRCLE('',#141,6.054044962965); +#141 = AXIS2_PLACEMENT_3D('',#142,#143,#144); +#142 = CARTESIAN_POINT('',(-35.41090981799,0.,-8.689779599357)); +#143 = DIRECTION('',(0.,-1.,0.)); +#144 = DIRECTION('',(-1.,0.,0.)); +#145 = ORIENTED_EDGE('',*,*,#146,.F.); +#146 = EDGE_CURVE('',#147,#138,#149,.T.); +#147 = VERTEX_POINT('',#148); +#148 = CARTESIAN_POINT('',(-26.93875323652,0.,-55.20570756731)); +#149 = LINE('',#150,#151); +#150 = CARTESIAN_POINT('',(-29.06259699304,-4.440892098501E-16, + -48.54236940733)); +#151 = VECTOR('',#152,1.); +#152 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#153 = ORIENTED_EDGE('',*,*,#154,.F.); +#154 = EDGE_CURVE('',#155,#147,#157,.T.); +#155 = VERTEX_POINT('',#156); +#156 = CARTESIAN_POINT('',(-28.96712497021,0.,-57.16864680227)); +#157 = LINE('',#158,#159); +#158 = CARTESIAN_POINT('',(-36.14667143517,0.,-64.11659091489)); +#159 = VECTOR('',#160,1.); +#160 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#161 = ORIENTED_EDGE('',*,*,#162,.T.); +#162 = EDGE_CURVE('',#155,#163,#165,.T.); +#163 = VERTEX_POINT('',#164); +#164 = CARTESIAN_POINT('',(-29.08766684168,0.,-64.52156932717)); +#165 = CIRCLE('',#166,5.2); +#166 = AXIS2_PLACEMENT_3D('',#167,#168,#169); +#167 = CARTESIAN_POINT('',(-25.35093464349,0.,-60.90537900045)); +#168 = DIRECTION('',(0.,-1.,0.)); +#169 = DIRECTION('',(-1.,0.,0.)); +#170 = ORIENTED_EDGE('',*,*,#171,.F.); +#171 = EDGE_CURVE('',#172,#163,#174,.T.); +#172 = VERTEX_POINT('',#173); +#173 = CARTESIAN_POINT('',(-27.86158468659,0.,-65.78852163128)); +#174 = LINE('',#175,#176); +#175 = CARTESIAN_POINT('',(-31.12923147938,0.,-62.41195129628)); +#176 = VECTOR('',#177,1.); +#177 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#178 = ORIENTED_EDGE('',*,*,#179,.T.); +#179 = EDGE_CURVE('',#172,#180,#182,.T.); +#180 = VERTEX_POINT('',#181); +#181 = CARTESIAN_POINT('',(-25.53052705686,0.,-65.82673637491)); +#182 = CIRCLE('',#183,1.648528137424); +#183 = AXIS2_PLACEMENT_3D('',#184,#185,#186); +#184 = CARTESIAN_POINT('',(-26.67694849991,0.,-64.64210018823)); +#185 = DIRECTION('',(0.,-1.,0.)); +#186 = DIRECTION('',(-1.,0.,0.)); +#187 = ORIENTED_EDGE('',*,*,#188,.F.); +#188 = EDGE_CURVE('',#189,#180,#191,.T.); +#189 = VERTEX_POINT('',#190); +#190 = CARTESIAN_POINT('',(-20.53201900137,0.,-60.98947335481)); +#191 = LINE('',#192,#193); +#192 = CARTESIAN_POINT('',(-30.69923804215,0.,-70.82871181101)); +#193 = VECTOR('',#194,1.); +#194 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#195 = ORIENTED_EDGE('',*,*,#196,.T.); +#196 = EDGE_CURVE('',#189,#197,#199,.T.); +#197 = VERTEX_POINT('',#198); +#198 = CARTESIAN_POINT('',(20.532019001374,0.,-60.98947335481)); +#199 = LINE('',#200,#201); +#200 = CARTESIAN_POINT('',(-17.57924046663,0.,-60.98947335481)); +#201 = VECTOR('',#202,1.); +#202 = DIRECTION('',(1.,0.,0.)); +#203 = ORIENTED_EDGE('',*,*,#204,.T.); +#204 = EDGE_CURVE('',#197,#205,#207,.T.); +#205 = VERTEX_POINT('',#206); +#206 = CARTESIAN_POINT('',(26.715163243538,0.,-66.97315781796)); +#207 = LINE('',#208,#209); +#208 = CARTESIAN_POINT('',(7.481849370247,-4.440892098501E-16, + -48.36028435244)); +#209 = VECTOR('',#210,1.); +#210 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#211 = ORIENTED_EDGE('',*,*,#212,.T.); +#212 = EDGE_CURVE('',#205,#87,#213,.T.); +#213 = LINE('',#214,#215); +#214 = CARTESIAN_POINT('',(9.75933652063,0.,-84.4941890519)); +#215 = VECTOR('',#216,1.); +#216 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#217 = PLANE('',#218); +#218 = AXIS2_PLACEMENT_3D('',#219,#220,#221); +#219 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#220 = DIRECTION('',(0.,1.,0.)); +#221 = DIRECTION('',(0.,0.,1.)); +#222 = ADVANCED_FACE('',(#223),#241,.T.); +#223 = FACE_BOUND('',#224,.T.); +#224 = EDGE_LOOP('',(#225,#226,#227,#235)); +#225 = ORIENTED_EDGE('',*,*,#47,.F.); +#226 = ORIENTED_EDGE('',*,*,#70,.T.); +#227 = ORIENTED_EDGE('',*,*,#228,.T.); +#228 = EDGE_CURVE('',#71,#229,#231,.T.); +#229 = VERTEX_POINT('',#230); +#230 = CARTESIAN_POINT('',(46.298608189024,20.8572529153,4.2)); +#231 = LINE('',#232,#233); +#232 = CARTESIAN_POINT('',(46.298608189024,0.,4.2)); +#233 = VECTOR('',#234,1.); +#234 = DIRECTION('',(0.,1.,0.)); +#235 = ORIENTED_EDGE('',*,*,#236,.F.); +#236 = EDGE_CURVE('',#40,#229,#237,.T.); +#237 = LINE('',#238,#239); +#238 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,4.2)); +#239 = VECTOR('',#240,1.); +#240 = DIRECTION('',(1.,0.,0.)); +#241 = PLANE('',#242); +#242 = AXIS2_PLACEMENT_3D('',#243,#244,#245); +#243 = CARTESIAN_POINT('',(-45.46495478095,0.,4.2)); +#244 = DIRECTION('',(0.,0.,1.)); +#245 = DIRECTION('',(1.,0.,0.)); +#246 = ADVANCED_FACE('',(#247),#265,.T.); +#247 = FACE_BOUND('',#248,.T.); +#248 = EDGE_LOOP('',(#249,#257,#258,#259)); +#249 = ORIENTED_EDGE('',*,*,#250,.F.); +#250 = EDGE_CURVE('',#32,#251,#253,.T.); +#251 = VERTEX_POINT('',#252); +#252 = CARTESIAN_POINT('',(46.298608189024,20.8572529153,-70.97315781796 + )); +#253 = LINE('',#254,#255); +#254 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,-70.97315781796 + )); +#255 = VECTOR('',#256,1.); +#256 = DIRECTION('',(1.,0.,0.)); +#257 = ORIENTED_EDGE('',*,*,#39,.T.); +#258 = ORIENTED_EDGE('',*,*,#236,.T.); +#259 = ORIENTED_EDGE('',*,*,#260,.F.); +#260 = EDGE_CURVE('',#251,#229,#261,.T.); +#261 = LINE('',#262,#263); +#262 = CARTESIAN_POINT('',(46.298608189024,20.8572529153,-70.97315781796 + )); +#263 = VECTOR('',#264,1.); +#264 = DIRECTION('',(0.,0.,1.)); +#265 = PLANE('',#266); +#266 = AXIS2_PLACEMENT_3D('',#267,#268,#269); +#267 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,-70.97315781796 + )); +#268 = DIRECTION('',(0.,1.,0.)); +#269 = DIRECTION('',(0.,0.,1.)); +#270 = ADVANCED_FACE('',(#271),#282,.F.); +#271 = FACE_BOUND('',#272,.F.); +#272 = EDGE_LOOP('',(#273,#274,#275,#281)); +#273 = ORIENTED_EDGE('',*,*,#31,.F.); +#274 = ORIENTED_EDGE('',*,*,#61,.T.); +#275 = ORIENTED_EDGE('',*,*,#276,.T.); +#276 = EDGE_CURVE('',#62,#251,#277,.T.); +#277 = LINE('',#278,#279); +#278 = CARTESIAN_POINT('',(46.298608189024,0.,-70.97315781796)); +#279 = VECTOR('',#280,1.); +#280 = DIRECTION('',(0.,1.,0.)); +#281 = ORIENTED_EDGE('',*,*,#250,.F.); +#282 = PLANE('',#283); +#283 = AXIS2_PLACEMENT_3D('',#284,#285,#286); +#284 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#285 = DIRECTION('',(0.,0.,1.)); +#286 = DIRECTION('',(1.,0.,0.)); +#287 = ADVANCED_FACE('',(#288),#294,.T.); +#288 = FACE_BOUND('',#289,.T.); +#289 = EDGE_LOOP('',(#290,#291,#292,#293)); +#290 = ORIENTED_EDGE('',*,*,#78,.F.); +#291 = ORIENTED_EDGE('',*,*,#276,.T.); +#292 = ORIENTED_EDGE('',*,*,#260,.T.); +#293 = ORIENTED_EDGE('',*,*,#228,.F.); +#294 = PLANE('',#295); +#295 = AXIS2_PLACEMENT_3D('',#296,#297,#298); +#296 = CARTESIAN_POINT('',(46.298608189024,0.,-70.97315781796)); +#297 = DIRECTION('',(1.,0.,0.)); +#298 = DIRECTION('',(0.,0.,1.)); +#299 = ADVANCED_FACE('',(#300),#325,.T.); +#300 = FACE_BOUND('',#301,.T.); +#301 = EDGE_LOOP('',(#302,#310,#311,#319)); +#302 = ORIENTED_EDGE('',*,*,#303,.F.); +#303 = EDGE_CURVE('',#87,#304,#306,.T.); +#304 = VERTEX_POINT('',#305); +#305 = CARTESIAN_POINT('',(32.703857168398,3.2,-60.78483712899)); +#306 = LINE('',#307,#308); +#307 = CARTESIAN_POINT('',(32.703857168398,3.,-60.78483712899)); +#308 = VECTOR('',#309,1.); +#309 = DIRECTION('',(0.,1.,0.)); +#310 = ORIENTED_EDGE('',*,*,#86,.T.); +#311 = ORIENTED_EDGE('',*,*,#312,.F.); +#312 = EDGE_CURVE('',#313,#89,#315,.T.); +#313 = VERTEX_POINT('',#314); +#314 = CARTESIAN_POINT('',(26.938753236523,3.2,-55.20570756731)); +#315 = LINE('',#316,#317); +#316 = CARTESIAN_POINT('',(26.938753236523,3.,-55.20570756731)); +#317 = VECTOR('',#318,1.); +#318 = DIRECTION('',(0.,-1.,0.)); +#319 = ORIENTED_EDGE('',*,*,#320,.F.); +#320 = EDGE_CURVE('',#304,#313,#321,.T.); +#321 = LINE('',#322,#323); +#322 = CARTESIAN_POINT('',(16.008242280412,3.2,-44.62779994931)); +#323 = VECTOR('',#324,1.); +#324 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#325 = PLANE('',#326); +#326 = AXIS2_PLACEMENT_3D('',#327,#328,#329); +#327 = CARTESIAN_POINT('',(29.704873980143,3.,-57.88259703786)); +#328 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#329 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#330 = ADVANCED_FACE('',(#331),#349,.T.); +#331 = FACE_BOUND('',#332,.T.); +#332 = EDGE_LOOP('',(#333,#341,#342,#343)); +#333 = ORIENTED_EDGE('',*,*,#334,.F.); +#334 = EDGE_CURVE('',#205,#335,#337,.T.); +#335 = VERTEX_POINT('',#336); +#336 = CARTESIAN_POINT('',(26.715163243538,3.2,-66.97315781796)); +#337 = LINE('',#338,#339); +#338 = CARTESIAN_POINT('',(26.715163243538,3.,-66.97315781796)); +#339 = VECTOR('',#340,1.); +#340 = DIRECTION('',(0.,1.,0.)); +#341 = ORIENTED_EDGE('',*,*,#212,.T.); +#342 = ORIENTED_EDGE('',*,*,#303,.T.); +#343 = ORIENTED_EDGE('',*,*,#344,.F.); +#344 = EDGE_CURVE('',#335,#304,#345,.T.); +#345 = LINE('',#346,#347); +#346 = CARTESIAN_POINT('',(30.25240665457,3.2,-63.31800414721)); +#347 = VECTOR('',#348,1.); +#348 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#349 = PLANE('',#350); +#350 = AXIS2_PLACEMENT_3D('',#351,#352,#353); +#351 = CARTESIAN_POINT('',(29.709510205968,3.,-63.87899747347)); +#352 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#353 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#354 = ADVANCED_FACE('',(#355),#373,.T.); +#355 = FACE_BOUND('',#356,.T.); +#356 = EDGE_LOOP('',(#357,#365,#371,#372)); +#357 = ORIENTED_EDGE('',*,*,#358,.F.); +#358 = EDGE_CURVE('',#359,#335,#361,.T.); +#359 = VERTEX_POINT('',#360); +#360 = CARTESIAN_POINT('',(20.532019001374,3.2,-60.98947335481)); +#361 = LINE('',#362,#363); +#362 = CARTESIAN_POINT('',(9.922784884512,3.2,-50.72247862452)); +#363 = VECTOR('',#364,1.); +#364 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#365 = ORIENTED_EDGE('',*,*,#366,.T.); +#366 = EDGE_CURVE('',#359,#197,#367,.T.); +#367 = LINE('',#368,#369); +#368 = CARTESIAN_POINT('',(20.532019001374,3.,-60.98947335481)); +#369 = VECTOR('',#370,1.); +#370 = DIRECTION('',(0.,-1.,0.)); +#371 = ORIENTED_EDGE('',*,*,#204,.T.); +#372 = ORIENTED_EDGE('',*,*,#334,.T.); +#373 = PLANE('',#374); +#374 = AXIS2_PLACEMENT_3D('',#375,#376,#377); +#375 = CARTESIAN_POINT('',(23.522653113203,3.,-63.8836336993)); +#376 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#377 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#378 = ADVANCED_FACE('',(#379),#397,.T.); +#379 = FACE_BOUND('',#380,.T.); +#380 = EDGE_LOOP('',(#381,#389,#390,#391)); +#381 = ORIENTED_EDGE('',*,*,#382,.F.); +#382 = EDGE_CURVE('',#189,#383,#385,.T.); +#383 = VERTEX_POINT('',#384); +#384 = CARTESIAN_POINT('',(-20.53201900137,3.2,-60.98947335481)); +#385 = LINE('',#386,#387); +#386 = CARTESIAN_POINT('',(-20.53201900137,3.,-60.98947335481)); +#387 = VECTOR('',#388,1.); +#388 = DIRECTION('',(0.,1.,0.)); +#389 = ORIENTED_EDGE('',*,*,#196,.T.); +#390 = ORIENTED_EDGE('',*,*,#366,.F.); +#391 = ORIENTED_EDGE('',*,*,#392,.F.); +#392 = EDGE_CURVE('',#383,#359,#393,.T.); +#393 = LINE('',#394,#395); +#394 = CARTESIAN_POINT('',(5.354765181569,3.2,-60.98947335481)); +#395 = VECTOR('',#396,1.); +#396 = DIRECTION('',(1.,0.,0.)); +#397 = PLANE('',#398); +#398 = AXIS2_PLACEMENT_3D('',#399,#400,#401); +#399 = CARTESIAN_POINT('',(10.306473847682,3.,-60.98947335481)); +#400 = DIRECTION('',(0.,0.,1.)); +#401 = DIRECTION('',(0.,-1.,0.)); +#402 = ADVANCED_FACE('',(#403),#421,.F.); +#403 = FACE_BOUND('',#404,.F.); +#404 = EDGE_LOOP('',(#405,#413,#419,#420)); +#405 = ORIENTED_EDGE('',*,*,#406,.F.); +#406 = EDGE_CURVE('',#407,#180,#409,.T.); +#407 = VERTEX_POINT('',#408); +#408 = CARTESIAN_POINT('',(-25.53052705686,3.2,-65.82673637491)); +#409 = LINE('',#410,#411); +#410 = CARTESIAN_POINT('',(-25.53052705686,3.,-65.82673637491)); +#411 = VECTOR('',#412,1.); +#412 = DIRECTION('',(0.,-1.,0.)); +#413 = ORIENTED_EDGE('',*,*,#414,.F.); +#414 = EDGE_CURVE('',#383,#407,#415,.T.); +#415 = LINE('',#416,#417); +#416 = CARTESIAN_POINT('',(-9.454421870603,3.2,-50.26922436104)); +#417 = VECTOR('',#418,1.); +#418 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#419 = ORIENTED_EDGE('',*,*,#382,.F.); +#420 = ORIENTED_EDGE('',*,*,#188,.T.); +#421 = PLANE('',#422); +#422 = AXIS2_PLACEMENT_3D('',#423,#424,#425); +#423 = CARTESIAN_POINT('',(-23.00219525444,3.,-63.37996509944)); +#424 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#425 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#426 = ADVANCED_FACE('',(#427),#446,.F.); +#427 = FACE_BOUND('',#428,.F.); +#428 = EDGE_LOOP('',(#429,#437,#444,#445)); +#429 = ORIENTED_EDGE('',*,*,#430,.F.); +#430 = EDGE_CURVE('',#431,#172,#433,.T.); +#431 = VERTEX_POINT('',#432); +#432 = CARTESIAN_POINT('',(-27.86158468659,3.2,-65.78852163128)); +#433 = LINE('',#434,#435); +#434 = CARTESIAN_POINT('',(-27.86158468659,3.,-65.78852163128)); +#435 = VECTOR('',#436,1.); +#436 = DIRECTION('',(0.,-1.,0.)); +#437 = ORIENTED_EDGE('',*,*,#438,.T.); +#438 = EDGE_CURVE('',#431,#407,#439,.T.); +#439 = CIRCLE('',#440,1.648528137424); +#440 = AXIS2_PLACEMENT_3D('',#441,#442,#443); +#441 = CARTESIAN_POINT('',(-26.67694849991,3.2,-64.64210018823)); +#442 = DIRECTION('',(0.,-1.,0.)); +#443 = DIRECTION('',(-1.,0.,0.)); +#444 = ORIENTED_EDGE('',*,*,#406,.T.); +#445 = ORIENTED_EDGE('',*,*,#179,.F.); +#446 = CYLINDRICAL_SURFACE('',#447,1.648528137424); +#447 = AXIS2_PLACEMENT_3D('',#448,#449,#450); +#448 = CARTESIAN_POINT('',(-26.67694849991,3.,-64.64210018823)); +#449 = DIRECTION('',(0.,-1.,0.)); +#450 = DIRECTION('',(-1.,0.,0.)); +#451 = ADVANCED_FACE('',(#452),#470,.F.); +#452 = FACE_BOUND('',#453,.F.); +#453 = EDGE_LOOP('',(#454,#462,#468,#469)); +#454 = ORIENTED_EDGE('',*,*,#455,.F.); +#455 = EDGE_CURVE('',#456,#163,#458,.T.); +#456 = VERTEX_POINT('',#457); +#457 = CARTESIAN_POINT('',(-29.08766684168,3.2,-64.52156932717)); +#458 = LINE('',#459,#460); +#459 = CARTESIAN_POINT('',(-29.08766684168,3.,-64.52156932717)); +#460 = VECTOR('',#461,1.); +#461 = DIRECTION('',(0.,-1.,0.)); +#462 = ORIENTED_EDGE('',*,*,#463,.F.); +#463 = EDGE_CURVE('',#431,#456,#464,.T.); +#464 = LINE('',#465,#466); +#465 = CARTESIAN_POINT('',(-29.44004200272,3.2,-64.15744811364)); +#466 = VECTOR('',#467,1.); +#467 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#468 = ORIENTED_EDGE('',*,*,#430,.T.); +#469 = ORIENTED_EDGE('',*,*,#171,.T.); +#470 = PLANE('',#471); +#471 = AXIS2_PLACEMENT_3D('',#472,#473,#474); +#472 = CARTESIAN_POINT('',(-28.47462576413,3.,-65.15504547923)); +#473 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#474 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#475 = ADVANCED_FACE('',(#476),#495,.F.); +#476 = FACE_BOUND('',#477,.F.); +#477 = EDGE_LOOP('',(#478,#486,#493,#494)); +#478 = ORIENTED_EDGE('',*,*,#479,.F.); +#479 = EDGE_CURVE('',#480,#155,#482,.T.); +#480 = VERTEX_POINT('',#481); +#481 = CARTESIAN_POINT('',(-28.96712497021,3.2,-57.16864680227)); +#482 = LINE('',#483,#484); +#483 = CARTESIAN_POINT('',(-28.96712497021,3.,-57.16864680227)); +#484 = VECTOR('',#485,1.); +#485 = DIRECTION('',(0.,-1.,0.)); +#486 = ORIENTED_EDGE('',*,*,#487,.T.); +#487 = EDGE_CURVE('',#480,#456,#488,.T.); +#488 = CIRCLE('',#489,5.2); +#489 = AXIS2_PLACEMENT_3D('',#490,#491,#492); +#490 = CARTESIAN_POINT('',(-25.35093464349,3.2,-60.90537900045)); +#491 = DIRECTION('',(0.,-1.,0.)); +#492 = DIRECTION('',(-1.,0.,0.)); +#493 = ORIENTED_EDGE('',*,*,#455,.T.); +#494 = ORIENTED_EDGE('',*,*,#162,.F.); +#495 = CYLINDRICAL_SURFACE('',#496,5.2); +#496 = AXIS2_PLACEMENT_3D('',#497,#498,#499); +#497 = CARTESIAN_POINT('',(-25.35093464349,3.,-60.90537900045)); +#498 = DIRECTION('',(0.,-1.,0.)); +#499 = DIRECTION('',(-1.,0.,0.)); +#500 = ADVANCED_FACE('',(#501),#519,.F.); +#501 = FACE_BOUND('',#502,.F.); +#502 = EDGE_LOOP('',(#503,#511,#517,#518)); +#503 = ORIENTED_EDGE('',*,*,#504,.F.); +#504 = EDGE_CURVE('',#505,#147,#507,.T.); +#505 = VERTEX_POINT('',#506); +#506 = CARTESIAN_POINT('',(-26.93875323652,3.2,-55.20570756731)); +#507 = LINE('',#508,#509); +#508 = CARTESIAN_POINT('',(-26.93875323652,3.,-55.20570756731)); +#509 = VECTOR('',#510,1.); +#510 = DIRECTION('',(0.,-1.,0.)); +#511 = ORIENTED_EDGE('',*,*,#512,.F.); +#512 = EDGE_CURVE('',#480,#505,#513,.T.); +#513 = LINE('',#514,#515); +#514 = CARTESIAN_POINT('',(-14.90185526362,3.2,-43.55710346492)); +#515 = VECTOR('',#516,1.); +#516 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#517 = ORIENTED_EDGE('',*,*,#479,.T.); +#518 = ORIENTED_EDGE('',*,*,#154,.T.); +#519 = PLANE('',#520); +#520 = AXIS2_PLACEMENT_3D('',#521,#522,#523); +#521 = CARTESIAN_POINT('',(-27.90836811563,3.,-56.14404399617)); +#522 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#523 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#524 = ADVANCED_FACE('',(#525),#543,.F.); +#525 = FACE_BOUND('',#526,.F.); +#526 = EDGE_LOOP('',(#527,#535,#541,#542)); +#527 = ORIENTED_EDGE('',*,*,#528,.F.); +#528 = EDGE_CURVE('',#529,#138,#531,.T.); +#529 = VERTEX_POINT('',#530); +#530 = CARTESIAN_POINT('',(-41.17904151244,3.2,-10.52828909594)); +#531 = LINE('',#532,#533); +#532 = CARTESIAN_POINT('',(-41.17904151244,3.,-10.52828909594)); +#533 = VECTOR('',#534,1.); +#534 = DIRECTION('',(0.,-1.,0.)); +#535 = ORIENTED_EDGE('',*,*,#536,.F.); +#536 = EDGE_CURVE('',#505,#529,#537,.T.); +#537 = LINE('',#538,#539); +#538 = CARTESIAN_POINT('',(-32.39120436163,3.2,-38.09921113329)); +#539 = VECTOR('',#540,1.); +#540 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#541 = ORIENTED_EDGE('',*,*,#504,.T.); +#542 = ORIENTED_EDGE('',*,*,#146,.T.); +#543 = PLANE('',#544); +#544 = AXIS2_PLACEMENT_3D('',#545,#546,#547); +#545 = CARTESIAN_POINT('',(-34.04006158346,3.,-32.92609366041)); +#546 = DIRECTION('',(-0.952773183837,0.,-0.30368282823)); +#547 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#548 = ADVANCED_FACE('',(#549),#568,.F.); +#549 = FACE_BOUND('',#550,.F.); +#550 = EDGE_LOOP('',(#551,#559,#566,#567)); +#551 = ORIENTED_EDGE('',*,*,#552,.F.); +#552 = EDGE_CURVE('',#553,#130,#555,.T.); +#553 = VERTEX_POINT('',#554); +#554 = CARTESIAN_POINT('',(-39.69176606491,3.2,-4.408923352436)); +#555 = LINE('',#556,#557); +#556 = CARTESIAN_POINT('',(-39.69176606491,3.,-4.408923352436)); +#557 = VECTOR('',#558,1.); +#558 = DIRECTION('',(0.,-1.,0.)); +#559 = ORIENTED_EDGE('',*,*,#560,.T.); +#560 = EDGE_CURVE('',#553,#529,#561,.T.); +#561 = CIRCLE('',#562,6.054044962965); +#562 = AXIS2_PLACEMENT_3D('',#563,#564,#565); +#563 = CARTESIAN_POINT('',(-35.41090981799,3.2,-8.689779599357)); +#564 = DIRECTION('',(0.,-1.,0.)); +#565 = DIRECTION('',(-1.,0.,0.)); +#566 = ORIENTED_EDGE('',*,*,#528,.T.); +#567 = ORIENTED_EDGE('',*,*,#137,.F.); +#568 = CYLINDRICAL_SURFACE('',#569,6.054044962965); +#569 = AXIS2_PLACEMENT_3D('',#570,#571,#572); +#570 = CARTESIAN_POINT('',(-35.41090981799,3.,-8.689779599357)); +#571 = DIRECTION('',(0.,-1.,0.)); +#572 = DIRECTION('',(-1.,0.,0.)); +#573 = ADVANCED_FACE('',(#574),#592,.F.); +#574 = FACE_BOUND('',#575,.F.); +#575 = EDGE_LOOP('',(#576,#584,#590,#591)); +#576 = ORIENTED_EDGE('',*,*,#577,.F.); +#577 = EDGE_CURVE('',#578,#121,#580,.T.); +#578 = VERTEX_POINT('',#579); +#579 = CARTESIAN_POINT('',(-36.85603142851,3.2,-1.573188716044)); +#580 = LINE('',#581,#582); +#581 = CARTESIAN_POINT('',(-36.85603142851,3.,-1.573188716044)); +#582 = VECTOR('',#583,1.); +#583 = DIRECTION('',(0.,-1.,0.)); +#584 = ORIENTED_EDGE('',*,*,#585,.F.); +#585 = EDGE_CURVE('',#553,#578,#586,.T.); +#586 = LINE('',#587,#588); +#587 = CARTESIAN_POINT('',(-36.19319006079,3.2,-0.910347348321)); +#588 = VECTOR('',#589,1.); +#589 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#590 = ORIENTED_EDGE('',*,*,#552,.T.); +#591 = ORIENTED_EDGE('',*,*,#129,.T.); +#592 = PLANE('',#593); +#593 = AXIS2_PLACEMENT_3D('',#594,#595,#596); +#594 = CARTESIAN_POINT('',(-38.27389874671,3.,-2.99105603424)); +#595 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#596 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#597 = ADVANCED_FACE('',(#598),#617,.F.); +#598 = FACE_BOUND('',#599,.F.); +#599 = EDGE_LOOP('',(#600,#608,#615,#616)); +#600 = ORIENTED_EDGE('',*,*,#601,.F.); +#601 = EDGE_CURVE('',#602,#113,#604,.T.); +#602 = VERTEX_POINT('',#603); +#603 = CARTESIAN_POINT('',(-32.57517518159,3.2,0.2)); +#604 = LINE('',#605,#606); +#605 = CARTESIAN_POINT('',(-32.57517518159,3.,0.2)); +#606 = VECTOR('',#607,1.); +#607 = DIRECTION('',(0.,-1.,0.)); +#608 = ORIENTED_EDGE('',*,*,#609,.T.); +#609 = EDGE_CURVE('',#602,#578,#610,.T.); +#610 = CIRCLE('',#611,6.054044962965); +#611 = AXIS2_PLACEMENT_3D('',#612,#613,#614); +#612 = CARTESIAN_POINT('',(-32.57517518159,3.2,-5.854044962965)); +#613 = DIRECTION('',(0.,-1.,0.)); +#614 = DIRECTION('',(-1.,0.,0.)); +#615 = ORIENTED_EDGE('',*,*,#577,.T.); +#616 = ORIENTED_EDGE('',*,*,#120,.F.); +#617 = CYLINDRICAL_SURFACE('',#618,6.054044962965); +#618 = AXIS2_PLACEMENT_3D('',#619,#620,#621); +#619 = CARTESIAN_POINT('',(-32.57517518159,3.,-5.854044962965)); +#620 = DIRECTION('',(0.,-1.,0.)); +#621 = DIRECTION('',(-1.,0.,0.)); +#622 = ADVANCED_FACE('',(#623),#641,.F.); +#623 = FACE_BOUND('',#624,.F.); +#624 = EDGE_LOOP('',(#625,#633,#639,#640)); +#625 = ORIENTED_EDGE('',*,*,#626,.F.); +#626 = EDGE_CURVE('',#627,#105,#629,.T.); +#627 = VERTEX_POINT('',#628); +#628 = CARTESIAN_POINT('',(35.082842712475,3.2,0.2)); +#629 = LINE('',#630,#631); +#630 = CARTESIAN_POINT('',(35.082842712475,3.,0.2)); +#631 = VECTOR('',#632,1.); +#632 = DIRECTION('',(0.,-1.,0.)); +#633 = ORIENTED_EDGE('',*,*,#634,.F.); +#634 = EDGE_CURVE('',#602,#627,#635,.T.); +#635 = LINE('',#636,#637); +#636 = CARTESIAN_POINT('',(-7.942265537672,3.2,0.2)); +#637 = VECTOR('',#638,1.); +#638 = DIRECTION('',(1.,0.,0.)); +#639 = ORIENTED_EDGE('',*,*,#601,.T.); +#640 = ORIENTED_EDGE('',*,*,#112,.T.); +#641 = PLANE('',#642); +#642 = AXIS2_PLACEMENT_3D('',#643,#644,#645); +#643 = CARTESIAN_POINT('',(-16.28758759079,3.,0.2)); +#644 = DIRECTION('',(0.,0.,1.)); +#645 = DIRECTION('',(0.,-1.,0.)); +#646 = ADVANCED_FACE('',(#647),#665,.T.); +#647 = FACE_BOUND('',#648,.T.); +#648 = EDGE_LOOP('',(#649,#657,#658,#659)); +#649 = ORIENTED_EDGE('',*,*,#650,.T.); +#650 = EDGE_CURVE('',#651,#97,#653,.T.); +#651 = VERTEX_POINT('',#652); +#652 = CARTESIAN_POINT('',(42.298608189024,3.2,-7.015765476549)); +#653 = LINE('',#654,#655); +#654 = CARTESIAN_POINT('',(42.298608189024,3.,-7.015765476549)); +#655 = VECTOR('',#656,1.); +#656 = DIRECTION('',(0.,-1.,0.)); +#657 = ORIENTED_EDGE('',*,*,#104,.T.); +#658 = ORIENTED_EDGE('',*,*,#626,.F.); +#659 = ORIENTED_EDGE('',*,*,#660,.F.); +#660 = EDGE_CURVE('',#651,#627,#661,.T.); +#661 = LINE('',#662,#663); +#662 = CARTESIAN_POINT('',(36.596246576251,3.2,-1.313403863776)); +#663 = VECTOR('',#664,1.); +#664 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#665 = PLANE('',#666); +#666 = AXIS2_PLACEMENT_3D('',#667,#668,#669); +#667 = CARTESIAN_POINT('',(38.67695526217,3.,-3.394112549695)); +#668 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#669 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#670 = ADVANCED_FACE('',(#671),#682,.T.); +#671 = FACE_BOUND('',#672,.T.); +#672 = EDGE_LOOP('',(#673,#679,#680,#681)); +#673 = ORIENTED_EDGE('',*,*,#674,.F.); +#674 = EDGE_CURVE('',#313,#651,#675,.T.); +#675 = LINE('',#676,#677); +#676 = CARTESIAN_POINT('',(32.699020781567,3.2,-37.13346923684)); +#677 = VECTOR('',#678,1.); +#678 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#679 = ORIENTED_EDGE('',*,*,#312,.T.); +#680 = ORIENTED_EDGE('',*,*,#96,.T.); +#681 = ORIENTED_EDGE('',*,*,#650,.F.); +#682 = PLANE('',#683); +#683 = AXIS2_PLACEMENT_3D('',#684,#685,#686); +#684 = CARTESIAN_POINT('',(34.581352051556,3.,-31.22785130119)); +#685 = DIRECTION('',(-0.952773183837,0.,0.30368282823)); +#686 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#687 = ADVANCED_FACE('',(#688,#738,#749,#767,#801),#812,.F.); +#688 = FACE_BOUND('',#689,.F.); +#689 = EDGE_LOOP('',(#690,#700,#708,#716,#724,#732)); +#690 = ORIENTED_EDGE('',*,*,#691,.F.); +#691 = EDGE_CURVE('',#692,#694,#696,.T.); +#692 = VERTEX_POINT('',#693); +#693 = CARTESIAN_POINT('',(16.626582997737,3.2,-8.940188245231)); +#694 = VERTEX_POINT('',#695); +#695 = CARTESIAN_POINT('',(4.383041634064E-04,3.2,-8.903751615529)); +#696 = LINE('',#697,#698); +#697 = CARTESIAN_POINT('',(4.347099726942,3.2,-8.913277437397)); +#698 = VECTOR('',#699,1.); +#699 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03)); +#700 = ORIENTED_EDGE('',*,*,#701,.F.); +#701 = EDGE_CURVE('',#702,#692,#704,.T.); +#702 = VERTEX_POINT('',#703); +#703 = CARTESIAN_POINT('',(19.029295926024,3.2,-17.63009960955)); +#704 = LINE('',#705,#706); +#705 = CARTESIAN_POINT('',(19.849519003668,3.2,-20.59660707692)); +#706 = VECTOR('',#707,1.); +#707 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#708 = ORIENTED_EDGE('',*,*,#709,.F.); +#709 = EDGE_CURVE('',#710,#702,#712,.T.); +#710 = VERTEX_POINT('',#711); +#711 = CARTESIAN_POINT('',(22.059435554995,3.2,-3.734519760785)); +#712 = LINE('',#713,#714); +#713 = CARTESIAN_POINT('',(17.698510515043,3.2,-23.73280021221)); +#714 = VECTOR('',#715,1.); +#715 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#716 = ORIENTED_EDGE('',*,*,#717,.F.); +#717 = EDGE_CURVE('',#718,#710,#720,.T.); +#718 = VERTEX_POINT('',#719); +#719 = CARTESIAN_POINT('',(-21.72552223146,3.2,-3.734519760785)); +#720 = LINE('',#721,#722); +#721 = CARTESIAN_POINT('',(0.297084840953,3.2,-3.734519760785)); +#722 = VECTOR('',#723,1.); +#723 = DIRECTION('',(1.,0.,0.)); +#724 = ORIENTED_EDGE('',*,*,#725,.F.); +#725 = EDGE_CURVE('',#726,#718,#728,.T.); +#726 = VERTEX_POINT('',#727); +#727 = CARTESIAN_POINT('',(-21.72552223146,3.2,-8.903751135252)); +#728 = LINE('',#729,#730); +#729 = CARTESIAN_POINT('',(-21.72552223146,3.2,-19.83215600037)); +#730 = VECTOR('',#731,1.); +#731 = DIRECTION('',(0.,0.,1.)); +#732 = ORIENTED_EDGE('',*,*,#733,.F.); +#733 = EDGE_CURVE('',#694,#726,#734,.T.); +#734 = LINE('',#735,#736); +#735 = CARTESIAN_POINT('',(-5.279852300138,3.2,-8.903751135252)); +#736 = VECTOR('',#737,1.); +#737 = DIRECTION('',(-1.,0.,0.)); +#738 = FACE_BOUND('',#739,.F.); +#739 = EDGE_LOOP('',(#740)); +#740 = ORIENTED_EDGE('',*,*,#741,.F.); +#741 = EDGE_CURVE('',#742,#742,#744,.T.); +#742 = VERTEX_POINT('',#743); +#743 = CARTESIAN_POINT('',(21.163799345768,3.2,-48.00951684793)); +#744 = CIRCLE('',#745,4.735522705283); +#745 = AXIS2_PLACEMENT_3D('',#746,#747,#748); +#746 = CARTESIAN_POINT('',(16.428276640485,3.2,-48.00951684793)); +#747 = DIRECTION('',(-0.,1.,0.)); +#748 = DIRECTION('',(1.,0.,0.)); +#749 = FACE_BOUND('',#750,.F.); +#750 = EDGE_LOOP('',(#751,#752,#753,#754,#755,#756,#757,#758,#759,#760, + #761,#762,#763,#764,#765,#766)); +#751 = ORIENTED_EDGE('',*,*,#536,.T.); +#752 = ORIENTED_EDGE('',*,*,#560,.F.); +#753 = ORIENTED_EDGE('',*,*,#585,.T.); +#754 = ORIENTED_EDGE('',*,*,#609,.F.); +#755 = ORIENTED_EDGE('',*,*,#634,.T.); +#756 = ORIENTED_EDGE('',*,*,#660,.F.); +#757 = ORIENTED_EDGE('',*,*,#674,.F.); +#758 = ORIENTED_EDGE('',*,*,#320,.F.); +#759 = ORIENTED_EDGE('',*,*,#344,.F.); +#760 = ORIENTED_EDGE('',*,*,#358,.F.); +#761 = ORIENTED_EDGE('',*,*,#392,.F.); +#762 = ORIENTED_EDGE('',*,*,#414,.T.); +#763 = ORIENTED_EDGE('',*,*,#438,.F.); +#764 = ORIENTED_EDGE('',*,*,#463,.T.); +#765 = ORIENTED_EDGE('',*,*,#487,.F.); +#766 = ORIENTED_EDGE('',*,*,#512,.T.); +#767 = FACE_BOUND('',#768,.F.); +#768 = EDGE_LOOP('',(#769,#779,#787,#795)); +#769 = ORIENTED_EDGE('',*,*,#770,.F.); +#770 = EDGE_CURVE('',#771,#773,#775,.T.); +#771 = VERTEX_POINT('',#772); +#772 = CARTESIAN_POINT('',(5.809375885494,3.2,-13.06417917474)); +#773 = VERTEX_POINT('',#774); +#774 = CARTESIAN_POINT('',(2.688069798796,3.2,-49.64588621989)); +#775 = LINE('',#776,#777); +#776 = CARTESIAN_POINT('',(4.914157977861,3.2,-23.55613296875)); +#777 = VECTOR('',#778,1.); +#778 = DIRECTION('',(-8.501532861635E-02,0.,-0.996379643459)); +#779 = ORIENTED_EDGE('',*,*,#780,.F.); +#780 = EDGE_CURVE('',#781,#771,#783,.T.); +#781 = VERTEX_POINT('',#782); +#782 = CARTESIAN_POINT('',(-5.809375885494,3.2,-13.06417917474)); +#783 = LINE('',#784,#785); +#784 = CARTESIAN_POINT('',(1.615747408047,3.2,-13.06417917474)); +#785 = VECTOR('',#786,1.); +#786 = DIRECTION('',(1.,0.,-3.066574716487E-16)); +#787 = ORIENTED_EDGE('',*,*,#788,.F.); +#788 = EDGE_CURVE('',#789,#781,#791,.T.); +#789 = VERTEX_POINT('',#790); +#790 = CARTESIAN_POINT('',(-2.688069798796,3.2,-49.64588621989)); +#791 = LINE('',#792,#793); +#792 = CARTESIAN_POINT('',(-4.890802006217,3.2,-23.82986495424)); +#793 = VECTOR('',#794,1.); +#794 = DIRECTION('',(-8.501532861635E-02,0.,0.996379643459)); +#795 = ORIENTED_EDGE('',*,*,#796,.F.); +#796 = EDGE_CURVE('',#773,#789,#797,.T.); +#797 = LINE('',#798,#799); +#798 = CARTESIAN_POINT('',(1.615747408047,3.2,-49.64588621989)); +#799 = VECTOR('',#800,1.); +#800 = DIRECTION('',(-1.,0.,0.)); +#801 = FACE_BOUND('',#802,.F.); +#802 = EDGE_LOOP('',(#803)); +#803 = ORIENTED_EDGE('',*,*,#804,.F.); +#804 = EDGE_CURVE('',#805,#805,#807,.T.); +#805 = VERTEX_POINT('',#806); +#806 = CARTESIAN_POINT('',(-11.6927539352,3.2,-48.00951684793)); +#807 = CIRCLE('',#808,4.735522705283); +#808 = AXIS2_PLACEMENT_3D('',#809,#810,#811); +#809 = CARTESIAN_POINT('',(-16.42827664048,3.2,-48.00951684793)); +#810 = DIRECTION('',(-0.,1.,0.)); +#811 = DIRECTION('',(1.,0.,0.)); +#812 = PLANE('',#813); +#813 = AXIS2_PLACEMENT_3D('',#814,#815,#816); +#814 = CARTESIAN_POINT('',(0.403056515455,3.2,-33.34517655273)); +#815 = DIRECTION('',(0.,1.,0.)); +#816 = DIRECTION('',(1.,0.,0.)); +#817 = ADVANCED_FACE('',(#818),#843,.T.); +#818 = FACE_BOUND('',#819,.T.); +#819 = EDGE_LOOP('',(#820,#828,#836,#842)); +#820 = ORIENTED_EDGE('',*,*,#821,.F.); +#821 = EDGE_CURVE('',#822,#692,#824,.T.); +#822 = VERTEX_POINT('',#823); +#823 = CARTESIAN_POINT('',(16.626582997737,0.,-8.940188245231)); +#824 = LINE('',#825,#826); +#825 = CARTESIAN_POINT('',(16.626582997737,-22.,-8.940188245231)); +#826 = VECTOR('',#827,1.); +#827 = DIRECTION('',(0.,1.,0.)); +#828 = ORIENTED_EDGE('',*,*,#829,.T.); +#829 = EDGE_CURVE('',#822,#830,#832,.T.); +#830 = VERTEX_POINT('',#831); +#831 = CARTESIAN_POINT('',(-5.329070518201E-15,0.,-8.903751135252)); +#832 = LINE('',#833,#834); +#833 = CARTESIAN_POINT('',(-18.54556462154,1.7763568394E-15, + -8.863107566442)); +#834 = VECTOR('',#835,1.); +#835 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03)); +#836 = ORIENTED_EDGE('',*,*,#837,.T.); +#837 = EDGE_CURVE('',#830,#694,#838,.T.); +#838 = LINE('',#839,#840); +#839 = CARTESIAN_POINT('',(-5.329070518201E-15,-22.,-8.903751135252)); +#840 = VECTOR('',#841,1.); +#841 = DIRECTION('',(0.,1.,0.)); +#842 = ORIENTED_EDGE('',*,*,#691,.F.); +#843 = PLANE('',#844); +#844 = AXIS2_PLACEMENT_3D('',#845,#846,#847); +#845 = CARTESIAN_POINT('',(8.237581109188,-22.,-8.921803528809)); +#846 = DIRECTION('',(-2.191520817069E-03,0.,-0.999997598615)); +#847 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03)); +#848 = ADVANCED_FACE('',(#849),#867,.T.); +#849 = FACE_BOUND('',#850,.T.); +#850 = EDGE_LOOP('',(#851,#859,#865,#866)); +#851 = ORIENTED_EDGE('',*,*,#852,.F.); +#852 = EDGE_CURVE('',#853,#702,#855,.T.); +#853 = VERTEX_POINT('',#854); +#854 = CARTESIAN_POINT('',(19.029295926024,0.,-17.63009960955)); +#855 = LINE('',#856,#857); +#856 = CARTESIAN_POINT('',(19.029295926024,-22.,-17.63009960955)); +#857 = VECTOR('',#858,1.); +#858 = DIRECTION('',(0.,1.,0.)); +#859 = ORIENTED_EDGE('',*,*,#860,.T.); +#860 = EDGE_CURVE('',#853,#822,#861,.T.); +#861 = LINE('',#862,#863); +#862 = CARTESIAN_POINT('',(23.053273013696,0.,-32.18365022821)); +#863 = VECTOR('',#864,1.); +#864 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#865 = ORIENTED_EDGE('',*,*,#821,.T.); +#866 = ORIENTED_EDGE('',*,*,#701,.F.); +#867 = PLANE('',#868); +#868 = AXIS2_PLACEMENT_3D('',#869,#870,#871); +#869 = CARTESIAN_POINT('',(17.956031892536,-22.,-13.7484168618)); +#870 = DIRECTION('',(-0.963836182336,0.,-0.26649542889)); +#871 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#872 = ADVANCED_FACE('',(#873),#891,.T.); +#873 = FACE_BOUND('',#874,.T.); +#874 = EDGE_LOOP('',(#875,#883,#884,#885)); +#875 = ORIENTED_EDGE('',*,*,#876,.T.); +#876 = EDGE_CURVE('',#877,#853,#879,.T.); +#877 = VERTEX_POINT('',#878); +#878 = CARTESIAN_POINT('',(22.059435554995,0.,-3.734519760785)); +#879 = LINE('',#880,#881); +#880 = CARTESIAN_POINT('',(12.741012548745,1.7763568394E-15, + -46.46683800444)); +#881 = VECTOR('',#882,1.); +#882 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#883 = ORIENTED_EDGE('',*,*,#852,.T.); +#884 = ORIENTED_EDGE('',*,*,#709,.F.); +#885 = ORIENTED_EDGE('',*,*,#886,.F.); +#886 = EDGE_CURVE('',#877,#710,#887,.T.); +#887 = LINE('',#888,#889); +#888 = CARTESIAN_POINT('',(22.059435554995,-22.,-3.734519760785)); +#889 = VECTOR('',#890,1.); +#890 = DIRECTION('',(0.,1.,0.)); +#891 = PLANE('',#892); +#892 = AXIS2_PLACEMENT_3D('',#893,#894,#895); +#893 = CARTESIAN_POINT('',(20.484588228021,-22.,-10.95643672209)); +#894 = DIRECTION('',(0.977039526026,0.,-0.213058124893)); +#895 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#896 = ADVANCED_FACE('',(#897),#915,.T.); +#897 = FACE_BOUND('',#898,.T.); +#898 = EDGE_LOOP('',(#899,#907,#908,#909)); +#899 = ORIENTED_EDGE('',*,*,#900,.T.); +#900 = EDGE_CURVE('',#901,#877,#903,.T.); +#901 = VERTEX_POINT('',#902); +#902 = CARTESIAN_POINT('',(-21.72552223146,0.,-3.734519760785)); +#903 = LINE('',#904,#905); +#904 = CARTESIAN_POINT('',(-22.63692080725,0.,-3.734519760785)); +#905 = VECTOR('',#906,1.); +#906 = DIRECTION('',(1.,0.,0.)); +#907 = ORIENTED_EDGE('',*,*,#886,.T.); +#908 = ORIENTED_EDGE('',*,*,#717,.F.); +#909 = ORIENTED_EDGE('',*,*,#910,.F.); +#910 = EDGE_CURVE('',#901,#718,#911,.T.); +#911 = LINE('',#912,#913); +#912 = CARTESIAN_POINT('',(-21.72552223146,-22.,-3.734519760785)); +#913 = VECTOR('',#914,1.); +#914 = DIRECTION('',(0.,1.,0.)); +#915 = PLANE('',#916); +#916 = AXIS2_PLACEMENT_3D('',#917,#918,#919); +#917 = CARTESIAN_POINT('',(0.19111316645,-22.,-3.734519760785)); +#918 = DIRECTION('',(0.,0.,1.)); +#919 = DIRECTION('',(0.,-1.,0.)); +#920 = ADVANCED_FACE('',(#921),#939,.T.); +#921 = FACE_BOUND('',#922,.T.); +#922 = EDGE_LOOP('',(#923,#931,#932,#933)); +#923 = ORIENTED_EDGE('',*,*,#924,.T.); +#924 = EDGE_CURVE('',#925,#901,#927,.T.); +#925 = VERTEX_POINT('',#926); +#926 = CARTESIAN_POINT('',(-21.72552223146,0.,-8.903751135252)); +#927 = LINE('',#928,#929); +#928 = CARTESIAN_POINT('',(-21.72552223146,0.,-38.64614663299)); +#929 = VECTOR('',#930,1.); +#930 = DIRECTION('',(0.,0.,1.)); +#931 = ORIENTED_EDGE('',*,*,#910,.T.); +#932 = ORIENTED_EDGE('',*,*,#725,.F.); +#933 = ORIENTED_EDGE('',*,*,#934,.F.); +#934 = EDGE_CURVE('',#925,#726,#935,.T.); +#935 = LINE('',#936,#937); +#936 = CARTESIAN_POINT('',(-21.72552223146,-22.,-8.903751135252)); +#937 = VECTOR('',#938,1.); +#938 = DIRECTION('',(0.,1.,0.)); +#939 = PLANE('',#940); +#940 = AXIS2_PLACEMENT_3D('',#941,#942,#943); +#941 = CARTESIAN_POINT('',(-21.72552223146,-22.,-6.319135448019)); +#942 = DIRECTION('',(-1.,0.,0.)); +#943 = DIRECTION('',(0.,1.,0.)); +#944 = ADVANCED_FACE('',(#945),#956,.T.); +#945 = FACE_BOUND('',#946,.T.); +#946 = EDGE_LOOP('',(#947,#953,#954,#955)); +#947 = ORIENTED_EDGE('',*,*,#948,.T.); +#948 = EDGE_CURVE('',#830,#925,#949,.T.); +#949 = LINE('',#950,#951); +#950 = CARTESIAN_POINT('',(-28.21385794834,0.,-8.903751135252)); +#951 = VECTOR('',#952,1.); +#952 = DIRECTION('',(-1.,0.,0.)); +#953 = ORIENTED_EDGE('',*,*,#934,.T.); +#954 = ORIENTED_EDGE('',*,*,#733,.F.); +#955 = ORIENTED_EDGE('',*,*,#837,.F.); +#956 = PLANE('',#957); +#957 = AXIS2_PLACEMENT_3D('',#958,#959,#960); +#958 = CARTESIAN_POINT('',(-10.96276111573,-22.,-8.903751135252)); +#959 = DIRECTION('',(0.,0.,-1.)); +#960 = DIRECTION('',(0.,1.,0.)); +#961 = ADVANCED_FACE('',(#962),#981,.T.); +#962 = FACE_BOUND('',#963,.T.); +#963 = EDGE_LOOP('',(#964,#973,#979,#980)); +#964 = ORIENTED_EDGE('',*,*,#965,.T.); +#965 = EDGE_CURVE('',#966,#966,#968,.T.); +#966 = VERTEX_POINT('',#967); +#967 = CARTESIAN_POINT('',(21.163799345768,0.,-48.00951684793)); +#968 = CIRCLE('',#969,4.735522705283); +#969 = AXIS2_PLACEMENT_3D('',#970,#971,#972); +#970 = CARTESIAN_POINT('',(16.428276640485,0.,-48.00951684793)); +#971 = DIRECTION('',(-0.,1.,0.)); +#972 = DIRECTION('',(1.,0.,0.)); +#973 = ORIENTED_EDGE('',*,*,#974,.T.); +#974 = EDGE_CURVE('',#966,#742,#975,.T.); +#975 = LINE('',#976,#977); +#976 = CARTESIAN_POINT('',(21.163799345768,-22.,-48.00951684793)); +#977 = VECTOR('',#978,1.); +#978 = DIRECTION('',(0.,1.,0.)); +#979 = ORIENTED_EDGE('',*,*,#741,.F.); +#980 = ORIENTED_EDGE('',*,*,#974,.F.); +#981 = CYLINDRICAL_SURFACE('',#982,4.735522705283); +#982 = AXIS2_PLACEMENT_3D('',#983,#984,#985); +#983 = CARTESIAN_POINT('',(16.428276640485,-22.,-48.00951684793)); +#984 = DIRECTION('',(0.,1.,0.)); +#985 = DIRECTION('',(1.,0.,0.)); +#986 = ADVANCED_FACE('',(#987),#1012,.F.); +#987 = FACE_BOUND('',#988,.F.); +#988 = EDGE_LOOP('',(#989,#999,#1005,#1006)); +#989 = ORIENTED_EDGE('',*,*,#990,.T.); +#990 = EDGE_CURVE('',#991,#993,#995,.T.); +#991 = VERTEX_POINT('',#992); +#992 = CARTESIAN_POINT('',(1.480297366167E-15,11.242400581089, + -46.71869179633)); +#993 = VERTEX_POINT('',#994); +#994 = CARTESIAN_POINT('',(3.256654205567E-15,17.8572529153, + -18.39898295202)); +#995 = LINE('',#996,#997); +#996 = CARTESIAN_POINT('',(2.6645352591E-15,18.133069549222, + -17.21814831317)); +#997 = VECTOR('',#998,1.); +#998 = DIRECTION('',(5.275122655166E-17,0.227455280238,0.97378852709)); +#999 = ORIENTED_EDGE('',*,*,#1000,.F.); +#1000 = EDGE_CURVE('',#771,#993,#1001,.T.); +#1001 = LINE('',#1002,#1003); +#1002 = CARTESIAN_POINT('',(5.649679875255,3.602918464526, + -13.21082950266)); +#1003 = VECTOR('',#1004,1.); +#1004 = DIRECTION('',(-0.349023821871,0.880598971639,-0.320511814002)); +#1005 = ORIENTED_EDGE('',*,*,#770,.T.); +#1006 = ORIENTED_EDGE('',*,*,#1007,.T.); +#1007 = EDGE_CURVE('',#773,#991,#1008,.T.); +#1008 = LINE('',#1009,#1010); +#1009 = CARTESIAN_POINT('',(1.103762571829,7.940067898719, + -47.92064259636)); +#1010 = VECTOR('',#1011,1.); +#1011 = DIRECTION('',(-0.299648208284,0.896513522642,0.326304236859)); +#1012 = PLANE('',#1013); +#1013 = AXIS2_PLACEMENT_3D('',#1014,#1015,#1016); +#1014 = CARTESIAN_POINT('',(5.823691883056,3.068404028665, + -13.45978839622)); +#1015 = DIRECTION('',(0.93629059846,0.342020143326,-7.988827695448E-02) + ); +#1016 = DIRECTION('',(-0.340781908463,0.939692620786,2.907695487824E-02) + ); +#1017 = ADVANCED_FACE('',(#1018),#1028,.F.); +#1018 = FACE_BOUND('',#1019,.F.); +#1019 = EDGE_LOOP('',(#1020,#1021,#1027)); +#1020 = ORIENTED_EDGE('',*,*,#1000,.T.); +#1021 = ORIENTED_EDGE('',*,*,#1022,.T.); +#1022 = EDGE_CURVE('',#993,#781,#1023,.T.); +#1023 = LINE('',#1024,#1025); +#1024 = CARTESIAN_POINT('',(-5.275833888477,4.546144602338, + -13.55413574101)); +#1025 = VECTOR('',#1026,1.); +#1026 = DIRECTION('',(-0.349023821871,-0.880598971639,0.320511814002)); +#1027 = ORIENTED_EDGE('',*,*,#780,.T.); +#1028 = PLANE('',#1029); +#1029 = AXIS2_PLACEMENT_3D('',#1030,#1031,#1032); +#1030 = CARTESIAN_POINT('',(2.828438300639,3.068404028665, + -13.01628215822)); +#1031 = DIRECTION('',(2.881637632171E-16,0.342020143326,0.939692620786) + ); +#1032 = DIRECTION('',(-1.048830324052E-16,0.939692620786,-0.342020143326 + )); +#1033 = ADVANCED_FACE('',(#1034),#1045,.F.); +#1034 = FACE_BOUND('',#1035,.F.); +#1035 = EDGE_LOOP('',(#1036,#1037,#1038,#1039)); +#1036 = ORIENTED_EDGE('',*,*,#788,.T.); +#1037 = ORIENTED_EDGE('',*,*,#1022,.F.); +#1038 = ORIENTED_EDGE('',*,*,#990,.F.); +#1039 = ORIENTED_EDGE('',*,*,#1040,.F.); +#1040 = EDGE_CURVE('',#789,#991,#1041,.T.); +#1041 = LINE('',#1042,#1043); +#1042 = CARTESIAN_POINT('',(-0.871390517001,8.635298785064, + -47.66759924779)); +#1043 = VECTOR('',#1044,1.); +#1044 = DIRECTION('',(0.299648208284,0.896513522642,0.326304236859)); +#1045 = PLANE('',#1046); +#1046 = AXIS2_PLACEMENT_3D('',#1047,#1048,#1049); +#1047 = CARTESIAN_POINT('',(-5.782806207227,3.068404028665, + -13.93896851312)); +#1048 = DIRECTION('',(-0.93629059846,0.342020143326,-7.988827695448E-02) + ); +#1049 = DIRECTION('',(0.340781908463,0.939692620786,2.907695487824E-02) + ); +#1050 = ADVANCED_FACE('',(#1051),#1056,.F.); +#1051 = FACE_BOUND('',#1052,.F.); +#1052 = EDGE_LOOP('',(#1053,#1054,#1055)); +#1053 = ORIENTED_EDGE('',*,*,#1040,.T.); +#1054 = ORIENTED_EDGE('',*,*,#1007,.F.); +#1055 = ORIENTED_EDGE('',*,*,#796,.T.); +#1056 = PLANE('',#1057); +#1057 = AXIS2_PLACEMENT_3D('',#1058,#1059,#1060); +#1058 = CARTESIAN_POINT('',(2.828438300639,3.068404028665, + -49.69378323641)); +#1059 = DIRECTION('',(0.,0.342020143326,-0.939692620786)); +#1060 = DIRECTION('',(0.,0.939692620786,0.342020143326)); +#1061 = ADVANCED_FACE('',(#1062),#1081,.T.); +#1062 = FACE_BOUND('',#1063,.T.); +#1063 = EDGE_LOOP('',(#1064,#1073,#1079,#1080)); +#1064 = ORIENTED_EDGE('',*,*,#1065,.T.); +#1065 = EDGE_CURVE('',#1066,#1066,#1068,.T.); +#1066 = VERTEX_POINT('',#1067); +#1067 = CARTESIAN_POINT('',(-11.6927539352,0.,-48.00951684793)); +#1068 = CIRCLE('',#1069,4.735522705283); +#1069 = AXIS2_PLACEMENT_3D('',#1070,#1071,#1072); +#1070 = CARTESIAN_POINT('',(-16.42827664048,0.,-48.00951684793)); +#1071 = DIRECTION('',(-0.,1.,0.)); +#1072 = DIRECTION('',(1.,0.,0.)); +#1073 = ORIENTED_EDGE('',*,*,#1074,.T.); +#1074 = EDGE_CURVE('',#1066,#805,#1075,.T.); +#1075 = LINE('',#1076,#1077); +#1076 = CARTESIAN_POINT('',(-11.6927539352,-22.,-48.00951684793)); +#1077 = VECTOR('',#1078,1.); +#1078 = DIRECTION('',(0.,1.,0.)); +#1079 = ORIENTED_EDGE('',*,*,#804,.F.); +#1080 = ORIENTED_EDGE('',*,*,#1074,.F.); +#1081 = CYLINDRICAL_SURFACE('',#1082,4.735522705283); +#1082 = AXIS2_PLACEMENT_3D('',#1083,#1084,#1085); +#1083 = CARTESIAN_POINT('',(-16.42827664048,-22.,-48.00951684793)); +#1084 = DIRECTION('',(0.,1.,0.)); +#1085 = DIRECTION('',(1.,0.,0.)); +#1086 = ADVANCED_FACE('',(#1087),#1095,.F.); +#1087 = FACE_BOUND('',#1088,.F.); +#1088 = EDGE_LOOP('',(#1089,#1090,#1091,#1092,#1093,#1094)); +#1089 = ORIENTED_EDGE('',*,*,#860,.T.); +#1090 = ORIENTED_EDGE('',*,*,#829,.T.); +#1091 = ORIENTED_EDGE('',*,*,#948,.T.); +#1092 = ORIENTED_EDGE('',*,*,#924,.T.); +#1093 = ORIENTED_EDGE('',*,*,#900,.T.); +#1094 = ORIENTED_EDGE('',*,*,#876,.T.); +#1095 = PLANE('',#1096); +#1096 = AXIS2_PLACEMENT_3D('',#1097,#1098,#1099); +#1097 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#1098 = DIRECTION('',(0.,1.,0.)); +#1099 = DIRECTION('',(0.,0.,1.)); +#1100 = ADVANCED_FACE('',(#1101),#1104,.F.); +#1101 = FACE_BOUND('',#1102,.F.); +#1102 = EDGE_LOOP('',(#1103)); +#1103 = ORIENTED_EDGE('',*,*,#965,.T.); +#1104 = PLANE('',#1105); +#1105 = AXIS2_PLACEMENT_3D('',#1106,#1107,#1108); +#1106 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#1107 = DIRECTION('',(0.,1.,0.)); +#1108 = DIRECTION('',(0.,0.,1.)); +#1109 = ADVANCED_FACE('',(#1110),#1113,.F.); +#1110 = FACE_BOUND('',#1111,.F.); +#1111 = EDGE_LOOP('',(#1112)); +#1112 = ORIENTED_EDGE('',*,*,#1065,.T.); +#1113 = PLANE('',#1114); +#1114 = AXIS2_PLACEMENT_3D('',#1115,#1116,#1117); +#1115 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#1116 = DIRECTION('',(0.,1.,0.)); +#1117 = DIRECTION('',(0.,0.,1.)); +#1118 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#1122)) +GLOBAL_UNIT_ASSIGNED_CONTEXT((#1119,#1120,#1121)) REPRESENTATION_CONTEXT +('Context #1','3D Context with UNIT and UNCERTAINTY') ); +#1119 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#1120 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#1121 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#1122 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#1119, + 'distance_accuracy_value','confusion accuracy'); +#1123 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed.step b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed.step new file mode 100644 index 0000000000..e6575701e4 --- /dev/null +++ b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed.step @@ -0,0 +1,1048 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('FreeCAD Model'),'2;1'); +FILE_NAME('Open CASCADE Shape Model','2026-08-05T11:06:23',('FreeCAD'),( + 'FreeCAD'),'Open CASCADE STEP processor 7.8','FreeCAD','Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('Open CASCADE STEP translator 7.8 1', + 'Open CASCADE STEP translator 7.8 1','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#976); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#157,#188,#212,#236,#261,#285,#310,#334,#358, + #383,#407,#432,#456,#480,#504,#528,#545,#675,#706,#730,#754,#778, + #802,#819,#850,#866,#882,#894,#919,#944,#958,#967)); +#17 = ADVANCED_FACE('',(#18),#152,.T.); +#18 = FACE_BOUND('',#19,.T.); +#19 = EDGE_LOOP('',(#20,#30,#38,#46,#54,#62,#70,#79,#87,#96,#104,#112, + #121,#129,#138,#146)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(26.824640127118,20.8572529153,-55.23443461327) + ); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(42.184838000445,20.8572529153,-7.043416644207) + ); +#26 = LINE('',#27,#28); +#27 = CARTESIAN_POINT('',(28.89442018934,20.8572529153,-48.74071565254) + ); +#28 = VECTOR('',#29,1.); +#29 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#30 = ORIENTED_EDGE('',*,*,#31,.F.); +#31 = EDGE_CURVE('',#32,#22,#34,.T.); +#32 = VERTEX_POINT('',#33); +#33 = CARTESIAN_POINT('',(32.56245481215,20.8572529153,-60.7871552419)); +#34 = LINE('',#35,#36); +#35 = CARTESIAN_POINT('',(12.092418778801,20.8572529153,-40.97745458223) + ); +#36 = VECTOR('',#37,1.); +#37 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#38 = ORIENTED_EDGE('',*,*,#39,.F.); +#39 = EDGE_CURVE('',#40,#32,#42,.T.); +#40 = VERTEX_POINT('',#41); +#41 = CARTESIAN_POINT('',(26.712845130625,20.8572529153,-66.83175546171) + ); +#42 = LINE('',#43,#44); +#43 = CARTESIAN_POINT('',(11.149878706433,20.8572529153,-82.91349687528) + ); +#44 = VECTOR('',#45,1.); +#45 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#46 = ORIENTED_EDGE('',*,*,#47,.F.); +#47 = EDGE_CURVE('',#48,#40,#50,.T.); +#48 = VERTEX_POINT('',#49); +#49 = CARTESIAN_POINT('',(20.57248334837,20.8572529153,-60.88947335481) + ); +#50 = LINE('',#51,#52); +#51 = CARTESIAN_POINT('',(9.111716439792,20.8572529153,-49.79841511636) + ); +#52 = VECTOR('',#53,1.); +#53 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#54 = ORIENTED_EDGE('',*,*,#55,.F.); +#55 = EDGE_CURVE('',#56,#48,#58,.T.); +#56 = VERTEX_POINT('',#57); +#57 = CARTESIAN_POINT('',(-20.57248334837,20.8572529153,-60.88947335481) + ); +#58 = LINE('',#59,#60); +#59 = CARTESIAN_POINT('',(-12.44623571629,20.8572529153,-60.88947335481) + ); +#60 = VECTOR('',#61,1.); +#61 = DIRECTION('',(1.,0.,0.)); +#62 = ORIENTED_EDGE('',*,*,#63,.T.); +#63 = EDGE_CURVE('',#56,#64,#66,.T.); +#64 = VERTEX_POINT('',#65); +#65 = CARTESIAN_POINT('',(-25.60006917852,20.8572529153,-65.75487614033) + ); +#66 = LINE('',#67,#68); +#67 = CARTESIAN_POINT('',(-32.03294606502,20.8572529153,-71.98023721416) + ); +#68 = VECTOR('',#69,1.); +#69 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#70 = ORIENTED_EDGE('',*,*,#71,.F.); +#71 = EDGE_CURVE('',#72,#64,#74,.T.); +#72 = VERTEX_POINT('',#73); +#73 = CARTESIAN_POINT('',(-27.78972445201,20.8572529153,-65.71897950961) + ); +#74 = CIRCLE('',#75,1.548528137424); +#75 = AXIS2_PLACEMENT_3D('',#76,#77,#78); +#76 = CARTESIAN_POINT('',(-26.67694849991,20.8572529153,-64.64210018823) + ); +#77 = DIRECTION('',(0.,-1.,0.)); +#78 = DIRECTION('',(1.,0.,0.)); +#79 = ORIENTED_EDGE('',*,*,#80,.T.); +#80 = EDGE_CURVE('',#72,#81,#83,.T.); +#81 = VERTEX_POINT('',#82); +#82 = CARTESIAN_POINT('',(-29.01580660709,20.8572529153,-64.4520272055) + ); +#83 = LINE('',#84,#85); +#84 = CARTESIAN_POINT('',(-31.36389178357,20.8572529153,-62.02567109858) + ); +#85 = VECTOR('',#86,1.); +#86 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#87 = ORIENTED_EDGE('',*,*,#88,.F.); +#88 = EDGE_CURVE('',#89,#81,#91,.T.); +#89 = VERTEX_POINT('',#90); +#90 = CARTESIAN_POINT('',(-28.89758284854,20.8572529153,-57.24050703685) + ); +#91 = CIRCLE('',#92,5.1); +#92 = AXIS2_PLACEMENT_3D('',#93,#94,#95); +#93 = CARTESIAN_POINT('',(-25.35093464349,20.8572529153,-60.90537900045) + ); +#94 = DIRECTION('',(0.,-1.,0.)); +#95 = DIRECTION('',(1.,0.,0.)); +#96 = ORIENTED_EDGE('',*,*,#97,.T.); +#97 = EDGE_CURVE('',#89,#98,#100,.T.); +#98 = VERTEX_POINT('',#99); +#99 = CARTESIAN_POINT('',(-26.82464012711,20.8572529153,-55.23443461327) + ); +#100 = LINE('',#101,#102); +#101 = CARTESIAN_POINT('',(-35.57003638008,20.8572529153,-63.69771634073 + )); +#102 = VECTOR('',#103,1.); +#103 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#104 = ORIENTED_EDGE('',*,*,#105,.T.); +#105 = EDGE_CURVE('',#98,#106,#108,.T.); +#106 = VERTEX_POINT('',#107); +#107 = CARTESIAN_POINT('',(-41.08376419406,20.8572529153,-10.49792081311 + )); +#108 = LINE('',#109,#110); +#109 = CARTESIAN_POINT('',(-32.53680963915,20.8572529153,-37.31309884227 + )); +#110 = VECTOR('',#111,1.); +#111 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#112 = ORIENTED_EDGE('',*,*,#113,.F.); +#113 = EDGE_CURVE('',#114,#106,#116,.T.); +#114 = VERTEX_POINT('',#115); +#115 = CARTESIAN_POINT('',(-39.62105538679,20.8572529153,-4.479634030555 + )); +#116 = CIRCLE('',#117,5.954044962965); +#117 = AXIS2_PLACEMENT_3D('',#118,#119,#120); +#118 = CARTESIAN_POINT('',(-35.41090981799,20.8572529153,-8.689779599357 + )); +#119 = DIRECTION('',(0.,-1.,0.)); +#120 = DIRECTION('',(1.,0.,0.)); +#121 = ORIENTED_EDGE('',*,*,#122,.T.); +#122 = EDGE_CURVE('',#114,#123,#125,.T.); +#123 = VERTEX_POINT('',#124); +#124 = CARTESIAN_POINT('',(-36.7853207504,20.8572529153,-1.643899394163) + ); +#125 = LINE('',#126,#127); +#126 = CARTESIAN_POINT('',(-56.28754386398,20.8572529153,-21.14612250775 + )); +#127 = VECTOR('',#128,1.); +#128 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#129 = ORIENTED_EDGE('',*,*,#130,.F.); +#130 = EDGE_CURVE('',#131,#123,#133,.T.); +#131 = VERTEX_POINT('',#132); +#132 = CARTESIAN_POINT('',(-32.57517518159,20.8572529153, + 9.999999999998E-02)); +#133 = CIRCLE('',#134,5.954044962965); +#134 = AXIS2_PLACEMENT_3D('',#135,#136,#137); +#135 = CARTESIAN_POINT('',(-32.57517518159,20.8572529153,-5.854044962965 + )); +#136 = DIRECTION('',(0.,-1.,0.)); +#137 = DIRECTION('',(1.,0.,0.)); +#138 = ORIENTED_EDGE('',*,*,#139,.T.); +#139 = EDGE_CURVE('',#131,#140,#142,.T.); +#140 = VERTEX_POINT('',#141); +#141 = CARTESIAN_POINT('',(35.041421356237,20.8572529153,0.1)); +#142 = LINE('',#143,#144); +#143 = CARTESIAN_POINT('',(-5.211766712356,20.8572529153,0.1)); +#144 = VECTOR('',#145,1.); +#145 = DIRECTION('',(1.,0.,0.)); +#146 = ORIENTED_EDGE('',*,*,#147,.F.); +#147 = EDGE_CURVE('',#24,#140,#148,.T.); +#148 = LINE('',#149,#150); +#149 = CARTESIAN_POINT('',(32.68311677643,20.8572529153,2.458304579807) + ); +#150 = VECTOR('',#151,1.); +#151 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#152 = PLANE('',#153); +#153 = AXIS2_PLACEMENT_3D('',#154,#155,#156); +#154 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153,-70.97315781796 + )); +#155 = DIRECTION('',(0.,1.,0.)); +#156 = DIRECTION('',(0.,0.,1.)); +#157 = ADVANCED_FACE('',(#158),#183,.F.); +#158 = FACE_BOUND('',#159,.F.); +#159 = EDGE_LOOP('',(#160,#170,#176,#177)); +#160 = ORIENTED_EDGE('',*,*,#161,.T.); +#161 = EDGE_CURVE('',#162,#164,#166,.T.); +#162 = VERTEX_POINT('',#163); +#163 = CARTESIAN_POINT('',(26.824640127118,3.2,-55.23443461327)); +#164 = VERTEX_POINT('',#165); +#165 = CARTESIAN_POINT('',(42.184838000445,3.2,-7.043416644207)); +#166 = LINE('',#167,#168); +#167 = CARTESIAN_POINT('',(36.45312509682,3.2,-25.02606776693)); +#168 = VECTOR('',#169,1.); +#169 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#170 = ORIENTED_EDGE('',*,*,#171,.T.); +#171 = EDGE_CURVE('',#164,#24,#172,.T.); +#172 = LINE('',#173,#174); +#173 = CARTESIAN_POINT('',(42.184838000445,-5.,-7.043416644207)); +#174 = VECTOR('',#175,1.); +#175 = DIRECTION('',(0.,1.,0.)); +#176 = ORIENTED_EDGE('',*,*,#21,.F.); +#177 = ORIENTED_EDGE('',*,*,#178,.F.); +#178 = EDGE_CURVE('',#162,#22,#179,.T.); +#179 = LINE('',#180,#181); +#180 = CARTESIAN_POINT('',(26.824640127118,-5.,-55.23443461327)); +#181 = VECTOR('',#182,1.); +#182 = DIRECTION('',(0.,1.,0.)); +#183 = PLANE('',#184); +#184 = AXIS2_PLACEMENT_3D('',#185,#186,#187); +#185 = CARTESIAN_POINT('',(42.184838000445,-5.,-7.043416644207)); +#186 = DIRECTION('',(-0.952773183837,0.,0.30368282823)); +#187 = DIRECTION('',(-0.30368282823,0.,-0.952773183837)); +#188 = ADVANCED_FACE('',(#189),#207,.F.); +#189 = FACE_BOUND('',#190,.F.); +#190 = EDGE_LOOP('',(#191,#199,#205,#206)); +#191 = ORIENTED_EDGE('',*,*,#192,.T.); +#192 = EDGE_CURVE('',#164,#193,#195,.T.); +#193 = VERTEX_POINT('',#194); +#194 = CARTESIAN_POINT('',(35.041421356237,3.2,0.1)); +#195 = LINE('',#196,#197); +#196 = CARTESIAN_POINT('',(34.743124284224,3.2,0.398297072013)); +#197 = VECTOR('',#198,1.); +#198 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#199 = ORIENTED_EDGE('',*,*,#200,.T.); +#200 = EDGE_CURVE('',#193,#140,#201,.T.); +#201 = LINE('',#202,#203); +#202 = CARTESIAN_POINT('',(35.041421356237,-5.,0.1)); +#203 = VECTOR('',#204,1.); +#204 = DIRECTION('',(0.,1.,0.)); +#205 = ORIENTED_EDGE('',*,*,#147,.F.); +#206 = ORIENTED_EDGE('',*,*,#171,.F.); +#207 = PLANE('',#208); +#208 = AXIS2_PLACEMENT_3D('',#209,#210,#211); +#209 = CARTESIAN_POINT('',(35.041421356237,-5.,9.999999999998E-02)); +#210 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#211 = DIRECTION('',(0.707106781187,0.,-0.707106781187)); +#212 = ADVANCED_FACE('',(#213),#231,.T.); +#213 = FACE_BOUND('',#214,.T.); +#214 = EDGE_LOOP('',(#215,#223,#224,#225)); +#215 = ORIENTED_EDGE('',*,*,#216,.T.); +#216 = EDGE_CURVE('',#217,#193,#219,.T.); +#217 = VERTEX_POINT('',#218); +#218 = CARTESIAN_POINT('',(-32.57517518159,3.2,9.999999999998E-02)); +#219 = LINE('',#220,#221); +#220 = CARTESIAN_POINT('',(17.722238935846,3.2,0.1)); +#221 = VECTOR('',#222,1.); +#222 = DIRECTION('',(1.,0.,0.)); +#223 = ORIENTED_EDGE('',*,*,#200,.T.); +#224 = ORIENTED_EDGE('',*,*,#139,.F.); +#225 = ORIENTED_EDGE('',*,*,#226,.F.); +#226 = EDGE_CURVE('',#217,#131,#227,.T.); +#227 = LINE('',#228,#229); +#228 = CARTESIAN_POINT('',(-32.57517518159,-5.,9.999999999998E-02)); +#229 = VECTOR('',#230,1.); +#230 = DIRECTION('',(0.,1.,0.)); +#231 = PLANE('',#232); +#232 = AXIS2_PLACEMENT_3D('',#233,#234,#235); +#233 = CARTESIAN_POINT('',(35.041421356237,-5.,0.1)); +#234 = DIRECTION('',(0.,0.,1.)); +#235 = DIRECTION('',(-1.,0.,0.)); +#236 = ADVANCED_FACE('',(#237),#256,.T.); +#237 = FACE_BOUND('',#238,.F.); +#238 = EDGE_LOOP('',(#239,#248,#254,#255)); +#239 = ORIENTED_EDGE('',*,*,#240,.T.); +#240 = EDGE_CURVE('',#217,#241,#243,.T.); +#241 = VERTEX_POINT('',#242); +#242 = CARTESIAN_POINT('',(-36.7853207504,3.2,-1.643899394163)); +#243 = CIRCLE('',#244,5.954044962965); +#244 = AXIS2_PLACEMENT_3D('',#245,#246,#247); +#245 = CARTESIAN_POINT('',(-32.57517518159,3.2,-5.854044962965)); +#246 = DIRECTION('',(0.,-1.,0.)); +#247 = DIRECTION('',(1.,0.,0.)); +#248 = ORIENTED_EDGE('',*,*,#249,.T.); +#249 = EDGE_CURVE('',#241,#123,#250,.T.); +#250 = LINE('',#251,#252); +#251 = CARTESIAN_POINT('',(-36.7853207504,-5.,-1.643899394163)); +#252 = VECTOR('',#253,1.); +#253 = DIRECTION('',(0.,1.,0.)); +#254 = ORIENTED_EDGE('',*,*,#130,.F.); +#255 = ORIENTED_EDGE('',*,*,#226,.F.); +#256 = CYLINDRICAL_SURFACE('',#257,5.954044962965); +#257 = AXIS2_PLACEMENT_3D('',#258,#259,#260); +#258 = CARTESIAN_POINT('',(-32.57517518159,-5.,-5.854044962965)); +#259 = DIRECTION('',(0.,-1.,0.)); +#260 = DIRECTION('',(1.,0.,0.)); +#261 = ADVANCED_FACE('',(#262),#280,.T.); +#262 = FACE_BOUND('',#263,.T.); +#263 = EDGE_LOOP('',(#264,#272,#273,#274)); +#264 = ORIENTED_EDGE('',*,*,#265,.T.); +#265 = EDGE_CURVE('',#266,#241,#268,.T.); +#266 = VERTEX_POINT('',#267); +#267 = CARTESIAN_POINT('',(-39.62105538679,3.2,-4.479634030555)); +#268 = LINE('',#269,#270); +#269 = CARTESIAN_POINT('',(-35.41354572357,3.2,-0.272124367341)); +#270 = VECTOR('',#271,1.); +#271 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#272 = ORIENTED_EDGE('',*,*,#249,.T.); +#273 = ORIENTED_EDGE('',*,*,#122,.F.); +#274 = ORIENTED_EDGE('',*,*,#275,.F.); +#275 = EDGE_CURVE('',#266,#114,#276,.T.); +#276 = LINE('',#277,#278); +#277 = CARTESIAN_POINT('',(-39.62105538679,-5.,-4.479634030555)); +#278 = VECTOR('',#279,1.); +#279 = DIRECTION('',(0.,1.,0.)); +#280 = PLANE('',#281); +#281 = AXIS2_PLACEMENT_3D('',#282,#283,#284); +#282 = CARTESIAN_POINT('',(-36.7853207504,-5.,-1.643899394163)); +#283 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#284 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#285 = ADVANCED_FACE('',(#286),#305,.T.); +#286 = FACE_BOUND('',#287,.F.); +#287 = EDGE_LOOP('',(#288,#297,#303,#304)); +#288 = ORIENTED_EDGE('',*,*,#289,.T.); +#289 = EDGE_CURVE('',#266,#290,#292,.T.); +#290 = VERTEX_POINT('',#291); +#291 = CARTESIAN_POINT('',(-41.08376419406,3.2,-10.49792081311)); +#292 = CIRCLE('',#293,5.954044962965); +#293 = AXIS2_PLACEMENT_3D('',#294,#295,#296); +#294 = CARTESIAN_POINT('',(-35.41090981799,3.2,-8.689779599357)); +#295 = DIRECTION('',(0.,-1.,0.)); +#296 = DIRECTION('',(1.,0.,0.)); +#297 = ORIENTED_EDGE('',*,*,#298,.T.); +#298 = EDGE_CURVE('',#290,#106,#299,.T.); +#299 = LINE('',#300,#301); +#300 = CARTESIAN_POINT('',(-41.08376419406,-5.,-10.49792081311)); +#301 = VECTOR('',#302,1.); +#302 = DIRECTION('',(0.,1.,0.)); +#303 = ORIENTED_EDGE('',*,*,#113,.F.); +#304 = ORIENTED_EDGE('',*,*,#275,.F.); +#305 = CYLINDRICAL_SURFACE('',#306,5.954044962965); +#306 = AXIS2_PLACEMENT_3D('',#307,#308,#309); +#307 = CARTESIAN_POINT('',(-35.41090981799,-5.,-8.689779599357)); +#308 = DIRECTION('',(0.,-1.,0.)); +#309 = DIRECTION('',(1.,0.,0.)); +#310 = ADVANCED_FACE('',(#311),#329,.T.); +#311 = FACE_BOUND('',#312,.T.); +#312 = EDGE_LOOP('',(#313,#321,#322,#323)); +#313 = ORIENTED_EDGE('',*,*,#314,.T.); +#314 = EDGE_CURVE('',#315,#290,#317,.T.); +#315 = VERTEX_POINT('',#316); +#316 = CARTESIAN_POINT('',(-26.82464012711,3.2,-55.23443461327)); +#317 = LINE('',#318,#319); +#318 = CARTESIAN_POINT('',(-35.86541700774,3.2,-26.86994056823)); +#319 = VECTOR('',#320,1.); +#320 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#321 = ORIENTED_EDGE('',*,*,#298,.T.); +#322 = ORIENTED_EDGE('',*,*,#105,.F.); +#323 = ORIENTED_EDGE('',*,*,#324,.F.); +#324 = EDGE_CURVE('',#315,#98,#325,.T.); +#325 = LINE('',#326,#327); +#326 = CARTESIAN_POINT('',(-26.82464012711,-5.,-55.23443461327)); +#327 = VECTOR('',#328,1.); +#328 = DIRECTION('',(0.,1.,0.)); +#329 = PLANE('',#330); +#330 = AXIS2_PLACEMENT_3D('',#331,#332,#333); +#331 = CARTESIAN_POINT('',(-41.08376419406,-5.,-10.49792081311)); +#332 = DIRECTION('',(-0.952773183837,0.,-0.30368282823)); +#333 = DIRECTION('',(0.30368282823,0.,-0.952773183837)); +#334 = ADVANCED_FACE('',(#335),#353,.T.); +#335 = FACE_BOUND('',#336,.T.); +#336 = EDGE_LOOP('',(#337,#345,#346,#347)); +#337 = ORIENTED_EDGE('',*,*,#338,.T.); +#338 = EDGE_CURVE('',#339,#315,#341,.T.); +#339 = VERTEX_POINT('',#340); +#340 = CARTESIAN_POINT('',(-28.89758284854,3.2,-57.24050703685)); +#341 = LINE('',#342,#343); +#342 = CARTESIAN_POINT('',(-14.32522020853,3.2,-43.13822889076)); +#343 = VECTOR('',#344,1.); +#344 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#345 = ORIENTED_EDGE('',*,*,#324,.T.); +#346 = ORIENTED_EDGE('',*,*,#97,.F.); +#347 = ORIENTED_EDGE('',*,*,#348,.F.); +#348 = EDGE_CURVE('',#339,#89,#349,.T.); +#349 = LINE('',#350,#351); +#350 = CARTESIAN_POINT('',(-28.89758284854,-5.,-57.24050703685)); +#351 = VECTOR('',#352,1.); +#352 = DIRECTION('',(0.,1.,0.)); +#353 = PLANE('',#354); +#354 = AXIS2_PLACEMENT_3D('',#355,#356,#357); +#355 = CARTESIAN_POINT('',(-26.82464012711,-5.,-55.23443461327)); +#356 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#357 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#358 = ADVANCED_FACE('',(#359),#378,.T.); +#359 = FACE_BOUND('',#360,.F.); +#360 = EDGE_LOOP('',(#361,#370,#376,#377)); +#361 = ORIENTED_EDGE('',*,*,#362,.T.); +#362 = EDGE_CURVE('',#339,#363,#365,.T.); +#363 = VERTEX_POINT('',#364); +#364 = CARTESIAN_POINT('',(-29.01580660709,3.2,-64.4520272055)); +#365 = CIRCLE('',#366,5.1); +#366 = AXIS2_PLACEMENT_3D('',#367,#368,#369); +#367 = CARTESIAN_POINT('',(-25.35093464349,3.2,-60.90537900045)); +#368 = DIRECTION('',(0.,-1.,0.)); +#369 = DIRECTION('',(1.,0.,0.)); +#370 = ORIENTED_EDGE('',*,*,#371,.T.); +#371 = EDGE_CURVE('',#363,#81,#372,.T.); +#372 = LINE('',#373,#374); +#373 = CARTESIAN_POINT('',(-29.01580660709,-5.,-64.4520272055)); +#374 = VECTOR('',#375,1.); +#375 = DIRECTION('',(0.,1.,0.)); +#376 = ORIENTED_EDGE('',*,*,#88,.F.); +#377 = ORIENTED_EDGE('',*,*,#348,.F.); +#378 = CYLINDRICAL_SURFACE('',#379,5.1); +#379 = AXIS2_PLACEMENT_3D('',#380,#381,#382); +#380 = CARTESIAN_POINT('',(-25.35093464349,-5.,-60.90537900045)); +#381 = DIRECTION('',(0.,-1.,0.)); +#382 = DIRECTION('',(1.,0.,0.)); +#383 = ADVANCED_FACE('',(#384),#402,.T.); +#384 = FACE_BOUND('',#385,.T.); +#385 = EDGE_LOOP('',(#386,#394,#395,#396)); +#386 = ORIENTED_EDGE('',*,*,#387,.T.); +#387 = EDGE_CURVE('',#388,#363,#390,.T.); +#388 = VERTEX_POINT('',#389); +#389 = CARTESIAN_POINT('',(-27.78972445201,3.2,-65.71897950961)); +#390 = LINE('',#391,#392); +#391 = CARTESIAN_POINT('',(-29.67470230692,3.2,-63.77116791594)); +#392 = VECTOR('',#393,1.); +#393 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#394 = ORIENTED_EDGE('',*,*,#371,.T.); +#395 = ORIENTED_EDGE('',*,*,#80,.F.); +#396 = ORIENTED_EDGE('',*,*,#397,.F.); +#397 = EDGE_CURVE('',#388,#72,#398,.T.); +#398 = LINE('',#399,#400); +#399 = CARTESIAN_POINT('',(-27.78972445201,-5.,-65.71897950961)); +#400 = VECTOR('',#401,1.); +#401 = DIRECTION('',(0.,1.,0.)); +#402 = PLANE('',#403); +#403 = AXIS2_PLACEMENT_3D('',#404,#405,#406); +#404 = CARTESIAN_POINT('',(-29.01580660709,-5.,-64.4520272055)); +#405 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#406 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#407 = ADVANCED_FACE('',(#408),#427,.T.); +#408 = FACE_BOUND('',#409,.F.); +#409 = EDGE_LOOP('',(#410,#419,#425,#426)); +#410 = ORIENTED_EDGE('',*,*,#411,.T.); +#411 = EDGE_CURVE('',#388,#412,#414,.T.); +#412 = VERTEX_POINT('',#413); +#413 = CARTESIAN_POINT('',(-25.60006917852,3.2,-65.75487614033)); +#414 = CIRCLE('',#415,1.548528137424); +#415 = AXIS2_PLACEMENT_3D('',#416,#417,#418); +#416 = CARTESIAN_POINT('',(-26.67694849991,3.2,-64.64210018823)); +#417 = DIRECTION('',(0.,-1.,0.)); +#418 = DIRECTION('',(1.,0.,0.)); +#419 = ORIENTED_EDGE('',*,*,#420,.T.); +#420 = EDGE_CURVE('',#412,#64,#421,.T.); +#421 = LINE('',#422,#423); +#422 = CARTESIAN_POINT('',(-25.60006917852,-5.,-65.75487614033)); +#423 = VECTOR('',#424,1.); +#424 = DIRECTION('',(0.,1.,0.)); +#425 = ORIENTED_EDGE('',*,*,#71,.F.); +#426 = ORIENTED_EDGE('',*,*,#397,.F.); +#427 = CYLINDRICAL_SURFACE('',#428,1.548528137424); +#428 = AXIS2_PLACEMENT_3D('',#429,#430,#431); +#429 = CARTESIAN_POINT('',(-26.67694849991,-5.,-64.64210018823)); +#430 = DIRECTION('',(0.,-1.,0.)); +#431 = DIRECTION('',(1.,0.,0.)); +#432 = ADVANCED_FACE('',(#433),#451,.T.); +#433 = FACE_BOUND('',#434,.T.); +#434 = EDGE_LOOP('',(#435,#443,#444,#445)); +#435 = ORIENTED_EDGE('',*,*,#436,.T.); +#436 = EDGE_CURVE('',#437,#412,#439,.T.); +#437 = VERTEX_POINT('',#438); +#438 = CARTESIAN_POINT('',(-20.57248334837,3.2,-60.88947335481)); +#439 = LINE('',#440,#441); +#440 = CARTESIAN_POINT('',(-10.78812989347,3.2,-51.42074976419)); +#441 = VECTOR('',#442,1.); +#442 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#443 = ORIENTED_EDGE('',*,*,#420,.T.); +#444 = ORIENTED_EDGE('',*,*,#63,.F.); +#445 = ORIENTED_EDGE('',*,*,#446,.F.); +#446 = EDGE_CURVE('',#437,#56,#447,.T.); +#447 = LINE('',#448,#449); +#448 = CARTESIAN_POINT('',(-20.57248334837,-5.,-60.88947335481)); +#449 = VECTOR('',#450,1.); +#450 = DIRECTION('',(0.,1.,0.)); +#451 = PLANE('',#452); +#452 = AXIS2_PLACEMENT_3D('',#453,#454,#455); +#453 = CARTESIAN_POINT('',(-25.60006917852,-5.,-65.75487614033)); +#454 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#455 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#456 = ADVANCED_FACE('',(#457),#475,.F.); +#457 = FACE_BOUND('',#458,.F.); +#458 = EDGE_LOOP('',(#459,#467,#473,#474)); +#459 = ORIENTED_EDGE('',*,*,#460,.T.); +#460 = EDGE_CURVE('',#437,#461,#463,.T.); +#461 = VERTEX_POINT('',#462); +#462 = CARTESIAN_POINT('',(20.57248334837,3.2,-60.88947335481)); +#463 = LINE('',#464,#465); +#464 = CARTESIAN_POINT('',(10.487769931912,3.2,-60.88947335481)); +#465 = VECTOR('',#466,1.); +#466 = DIRECTION('',(1.,0.,0.)); +#467 = ORIENTED_EDGE('',*,*,#468,.T.); +#468 = EDGE_CURVE('',#461,#48,#469,.T.); +#469 = LINE('',#470,#471); +#470 = CARTESIAN_POINT('',(20.57248334837,-5.,-60.88947335481)); +#471 = VECTOR('',#472,1.); +#472 = DIRECTION('',(0.,1.,0.)); +#473 = ORIENTED_EDGE('',*,*,#55,.F.); +#474 = ORIENTED_EDGE('',*,*,#446,.F.); +#475 = PLANE('',#476); +#476 = AXIS2_PLACEMENT_3D('',#477,#478,#479); +#477 = CARTESIAN_POINT('',(20.57248334837,-5.,-60.88947335481)); +#478 = DIRECTION('',(0.,0.,1.)); +#479 = DIRECTION('',(-1.,0.,0.)); +#480 = ADVANCED_FACE('',(#481),#499,.F.); +#481 = FACE_BOUND('',#482,.F.); +#482 = EDGE_LOOP('',(#483,#491,#497,#498)); +#483 = ORIENTED_EDGE('',*,*,#484,.T.); +#484 = EDGE_CURVE('',#461,#485,#487,.T.); +#485 = VERTEX_POINT('',#486); +#486 = CARTESIAN_POINT('',(26.712845130625,3.2,-66.83175546171)); +#487 = LINE('',#488,#489); +#488 = CARTESIAN_POINT('',(11.552651954056,3.2,-52.16060938843)); +#489 = VECTOR('',#490,1.); +#490 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#491 = ORIENTED_EDGE('',*,*,#492,.T.); +#492 = EDGE_CURVE('',#485,#40,#493,.T.); +#493 = LINE('',#494,#495); +#494 = CARTESIAN_POINT('',(26.712845130625,-5.,-66.83175546171)); +#495 = VECTOR('',#496,1.); +#496 = DIRECTION('',(0.,1.,0.)); +#497 = ORIENTED_EDGE('',*,*,#47,.F.); +#498 = ORIENTED_EDGE('',*,*,#468,.F.); +#499 = PLANE('',#500); +#500 = AXIS2_PLACEMENT_3D('',#501,#502,#503); +#501 = CARTESIAN_POINT('',(26.712845130625,-5.,-66.83175546171)); +#502 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#503 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#504 = ADVANCED_FACE('',(#505),#523,.F.); +#505 = FACE_BOUND('',#506,.F.); +#506 = EDGE_LOOP('',(#507,#515,#521,#522)); +#507 = ORIENTED_EDGE('',*,*,#508,.T.); +#508 = EDGE_CURVE('',#485,#509,#511,.T.); +#509 = VERTEX_POINT('',#510); +#510 = CARTESIAN_POINT('',(32.56245481215,3.2,-60.7871552419)); +#511 = LINE('',#512,#513); +#512 = CARTESIAN_POINT('',(31.642948840372,3.2,-61.73731197059)); +#513 = VECTOR('',#514,1.); +#514 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#515 = ORIENTED_EDGE('',*,*,#516,.T.); +#516 = EDGE_CURVE('',#509,#32,#517,.T.); +#517 = LINE('',#518,#519); +#518 = CARTESIAN_POINT('',(32.56245481215,-5.,-60.7871552419)); +#519 = VECTOR('',#520,1.); +#520 = DIRECTION('',(0.,1.,0.)); +#521 = ORIENTED_EDGE('',*,*,#39,.F.); +#522 = ORIENTED_EDGE('',*,*,#492,.F.); +#523 = PLANE('',#524); +#524 = AXIS2_PLACEMENT_3D('',#525,#526,#527); +#525 = CARTESIAN_POINT('',(32.56245481215,-5.,-60.7871552419)); +#526 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#527 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#528 = ADVANCED_FACE('',(#529),#540,.F.); +#529 = FACE_BOUND('',#530,.F.); +#530 = EDGE_LOOP('',(#531,#537,#538,#539)); +#531 = ORIENTED_EDGE('',*,*,#532,.T.); +#532 = EDGE_CURVE('',#509,#162,#533,.T.); +#533 = LINE('',#534,#535); +#534 = CARTESIAN_POINT('',(14.533354293065,3.2,-43.3396488543)); +#535 = VECTOR('',#536,1.); +#536 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#537 = ORIENTED_EDGE('',*,*,#178,.T.); +#538 = ORIENTED_EDGE('',*,*,#31,.F.); +#539 = ORIENTED_EDGE('',*,*,#516,.F.); +#540 = PLANE('',#541); +#541 = AXIS2_PLACEMENT_3D('',#542,#543,#544); +#542 = CARTESIAN_POINT('',(26.824640127118,-5.,-55.23443461327)); +#543 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#544 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#545 = ADVANCED_FACE('',(#546,#564,#614,#648,#659),#670,.F.); +#546 = FACE_BOUND('',#547,.F.); +#547 = EDGE_LOOP('',(#548,#549,#550,#551,#552,#553,#554,#555,#556,#557, + #558,#559,#560,#561,#562,#563)); +#548 = ORIENTED_EDGE('',*,*,#161,.F.); +#549 = ORIENTED_EDGE('',*,*,#532,.F.); +#550 = ORIENTED_EDGE('',*,*,#508,.F.); +#551 = ORIENTED_EDGE('',*,*,#484,.F.); +#552 = ORIENTED_EDGE('',*,*,#460,.F.); +#553 = ORIENTED_EDGE('',*,*,#436,.T.); +#554 = ORIENTED_EDGE('',*,*,#411,.F.); +#555 = ORIENTED_EDGE('',*,*,#387,.T.); +#556 = ORIENTED_EDGE('',*,*,#362,.F.); +#557 = ORIENTED_EDGE('',*,*,#338,.T.); +#558 = ORIENTED_EDGE('',*,*,#314,.T.); +#559 = ORIENTED_EDGE('',*,*,#289,.F.); +#560 = ORIENTED_EDGE('',*,*,#265,.T.); +#561 = ORIENTED_EDGE('',*,*,#240,.F.); +#562 = ORIENTED_EDGE('',*,*,#216,.T.); +#563 = ORIENTED_EDGE('',*,*,#192,.F.); +#564 = FACE_BOUND('',#565,.F.); +#565 = EDGE_LOOP('',(#566,#576,#584,#592,#600,#608)); +#566 = ORIENTED_EDGE('',*,*,#567,.F.); +#567 = EDGE_CURVE('',#568,#570,#572,.T.); +#568 = VERTEX_POINT('',#569); +#569 = CARTESIAN_POINT('',(16.626582997737,3.2,-8.940188245231)); +#570 = VERTEX_POINT('',#571); +#571 = CARTESIAN_POINT('',(4.383041634064E-04,3.2,-8.903751615529)); +#572 = LINE('',#573,#574); +#573 = CARTESIAN_POINT('',(4.347099726942,3.2,-8.913277437397)); +#574 = VECTOR('',#575,1.); +#575 = DIRECTION('',(-0.999997598615,0.,2.19152081707E-03)); +#576 = ORIENTED_EDGE('',*,*,#577,.F.); +#577 = EDGE_CURVE('',#578,#568,#580,.T.); +#578 = VERTEX_POINT('',#579); +#579 = CARTESIAN_POINT('',(19.029295926024,3.2,-17.63009960955)); +#580 = LINE('',#581,#582); +#581 = CARTESIAN_POINT('',(19.849519003668,3.2,-20.59660707692)); +#582 = VECTOR('',#583,1.); +#583 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#584 = ORIENTED_EDGE('',*,*,#585,.F.); +#585 = EDGE_CURVE('',#586,#578,#588,.T.); +#586 = VERTEX_POINT('',#587); +#587 = CARTESIAN_POINT('',(22.059435554995,3.2,-3.734519760785)); +#588 = LINE('',#589,#590); +#589 = CARTESIAN_POINT('',(17.698510515043,3.2,-23.73280021221)); +#590 = VECTOR('',#591,1.); +#591 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#592 = ORIENTED_EDGE('',*,*,#593,.F.); +#593 = EDGE_CURVE('',#594,#586,#596,.T.); +#594 = VERTEX_POINT('',#595); +#595 = CARTESIAN_POINT('',(-21.72552223146,3.2,-3.734519760785)); +#596 = LINE('',#597,#598); +#597 = CARTESIAN_POINT('',(0.297084840953,3.2,-3.734519760785)); +#598 = VECTOR('',#599,1.); +#599 = DIRECTION('',(1.,0.,0.)); +#600 = ORIENTED_EDGE('',*,*,#601,.F.); +#601 = EDGE_CURVE('',#602,#594,#604,.T.); +#602 = VERTEX_POINT('',#603); +#603 = CARTESIAN_POINT('',(-21.72552223146,3.2,-8.903751135252)); +#604 = LINE('',#605,#606); +#605 = CARTESIAN_POINT('',(-21.72552223146,3.2,-19.83215600037)); +#606 = VECTOR('',#607,1.); +#607 = DIRECTION('',(0.,0.,1.)); +#608 = ORIENTED_EDGE('',*,*,#609,.F.); +#609 = EDGE_CURVE('',#570,#602,#610,.T.); +#610 = LINE('',#611,#612); +#611 = CARTESIAN_POINT('',(-5.279852300138,3.2,-8.903751135252)); +#612 = VECTOR('',#613,1.); +#613 = DIRECTION('',(-1.,0.,0.)); +#614 = FACE_BOUND('',#615,.F.); +#615 = EDGE_LOOP('',(#616,#626,#634,#642)); +#616 = ORIENTED_EDGE('',*,*,#617,.F.); +#617 = EDGE_CURVE('',#618,#620,#622,.T.); +#618 = VERTEX_POINT('',#619); +#619 = CARTESIAN_POINT('',(5.809375885494,3.2,-13.06417917474)); +#620 = VERTEX_POINT('',#621); +#621 = CARTESIAN_POINT('',(2.688069798796,3.2,-49.64588621989)); +#622 = LINE('',#623,#624); +#623 = CARTESIAN_POINT('',(4.914157977861,3.2,-23.55613296875)); +#624 = VECTOR('',#625,1.); +#625 = DIRECTION('',(-8.501532861638E-02,0.,-0.996379643459)); +#626 = ORIENTED_EDGE('',*,*,#627,.F.); +#627 = EDGE_CURVE('',#628,#618,#630,.T.); +#628 = VERTEX_POINT('',#629); +#629 = CARTESIAN_POINT('',(-5.809375885494,3.2,-13.06417917474)); +#630 = LINE('',#631,#632); +#631 = CARTESIAN_POINT('',(1.615747408047,3.2,-13.06417917474)); +#632 = VECTOR('',#633,1.); +#633 = DIRECTION('',(1.,0.,-3.066574716487E-16)); +#634 = ORIENTED_EDGE('',*,*,#635,.F.); +#635 = EDGE_CURVE('',#636,#628,#638,.T.); +#636 = VERTEX_POINT('',#637); +#637 = CARTESIAN_POINT('',(-2.688069798796,3.2,-49.64588621989)); +#638 = LINE('',#639,#640); +#639 = CARTESIAN_POINT('',(-4.890802006217,3.2,-23.82986495424)); +#640 = VECTOR('',#641,1.); +#641 = DIRECTION('',(-8.501532861638E-02,0.,0.996379643459)); +#642 = ORIENTED_EDGE('',*,*,#643,.F.); +#643 = EDGE_CURVE('',#620,#636,#644,.T.); +#644 = LINE('',#645,#646); +#645 = CARTESIAN_POINT('',(1.615747408047,3.2,-49.64588621989)); +#646 = VECTOR('',#647,1.); +#647 = DIRECTION('',(-1.,0.,0.)); +#648 = FACE_BOUND('',#649,.F.); +#649 = EDGE_LOOP('',(#650)); +#650 = ORIENTED_EDGE('',*,*,#651,.F.); +#651 = EDGE_CURVE('',#652,#652,#654,.T.); +#652 = VERTEX_POINT('',#653); +#653 = CARTESIAN_POINT('',(-11.6927539352,3.2,-48.00951684793)); +#654 = CIRCLE('',#655,4.735522705283); +#655 = AXIS2_PLACEMENT_3D('',#656,#657,#658); +#656 = CARTESIAN_POINT('',(-16.42827664048,3.2,-48.00951684793)); +#657 = DIRECTION('',(-0.,1.,0.)); +#658 = DIRECTION('',(1.,0.,0.)); +#659 = FACE_BOUND('',#660,.F.); +#660 = EDGE_LOOP('',(#661)); +#661 = ORIENTED_EDGE('',*,*,#662,.F.); +#662 = EDGE_CURVE('',#663,#663,#665,.T.); +#663 = VERTEX_POINT('',#664); +#664 = CARTESIAN_POINT('',(21.163799345768,3.2,-48.00951684793)); +#665 = CIRCLE('',#666,4.735522705283); +#666 = AXIS2_PLACEMENT_3D('',#667,#668,#669); +#667 = CARTESIAN_POINT('',(16.428276640485,3.2,-48.00951684793)); +#668 = DIRECTION('',(-0.,1.,0.)); +#669 = DIRECTION('',(1.,0.,0.)); +#670 = PLANE('',#671); +#671 = AXIS2_PLACEMENT_3D('',#672,#673,#674); +#672 = CARTESIAN_POINT('',(0.403056515455,3.2,-33.34517655273)); +#673 = DIRECTION('',(0.,1.,0.)); +#674 = DIRECTION('',(1.,0.,0.)); +#675 = ADVANCED_FACE('',(#676),#701,.T.); +#676 = FACE_BOUND('',#677,.T.); +#677 = EDGE_LOOP('',(#678,#686,#694,#700)); +#678 = ORIENTED_EDGE('',*,*,#679,.F.); +#679 = EDGE_CURVE('',#680,#568,#682,.T.); +#680 = VERTEX_POINT('',#681); +#681 = CARTESIAN_POINT('',(16.626582997737,0.,-8.940188245231)); +#682 = LINE('',#683,#684); +#683 = CARTESIAN_POINT('',(16.626582997737,-22.,-8.940188245231)); +#684 = VECTOR('',#685,1.); +#685 = DIRECTION('',(0.,1.,0.)); +#686 = ORIENTED_EDGE('',*,*,#687,.T.); +#687 = EDGE_CURVE('',#680,#688,#690,.T.); +#688 = VERTEX_POINT('',#689); +#689 = CARTESIAN_POINT('',(-5.329070518201E-15,0.,-8.903751135252)); +#690 = LINE('',#691,#692); +#691 = CARTESIAN_POINT('',(-18.54556462154,1.7763568394E-15, + -8.863107566442)); +#692 = VECTOR('',#693,1.); +#693 = DIRECTION('',(-0.999997598615,0.,2.19152081707E-03)); +#694 = ORIENTED_EDGE('',*,*,#695,.T.); +#695 = EDGE_CURVE('',#688,#570,#696,.T.); +#696 = LINE('',#697,#698); +#697 = CARTESIAN_POINT('',(-5.329070518201E-15,-22.,-8.903751135252)); +#698 = VECTOR('',#699,1.); +#699 = DIRECTION('',(0.,1.,0.)); +#700 = ORIENTED_EDGE('',*,*,#567,.F.); +#701 = PLANE('',#702); +#702 = AXIS2_PLACEMENT_3D('',#703,#704,#705); +#703 = CARTESIAN_POINT('',(8.237581109188,-22.,-8.921803528809)); +#704 = DIRECTION('',(-2.19152081707E-03,0.,-0.999997598615)); +#705 = DIRECTION('',(-0.999997598615,0.,2.19152081707E-03)); +#706 = ADVANCED_FACE('',(#707),#725,.T.); +#707 = FACE_BOUND('',#708,.T.); +#708 = EDGE_LOOP('',(#709,#717,#723,#724)); +#709 = ORIENTED_EDGE('',*,*,#710,.F.); +#710 = EDGE_CURVE('',#711,#578,#713,.T.); +#711 = VERTEX_POINT('',#712); +#712 = CARTESIAN_POINT('',(19.029295926024,0.,-17.63009960955)); +#713 = LINE('',#714,#715); +#714 = CARTESIAN_POINT('',(19.029295926024,-22.,-17.63009960955)); +#715 = VECTOR('',#716,1.); +#716 = DIRECTION('',(0.,1.,0.)); +#717 = ORIENTED_EDGE('',*,*,#718,.T.); +#718 = EDGE_CURVE('',#711,#680,#719,.T.); +#719 = LINE('',#720,#721); +#720 = CARTESIAN_POINT('',(23.053273013696,0.,-32.18365022821)); +#721 = VECTOR('',#722,1.); +#722 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#723 = ORIENTED_EDGE('',*,*,#679,.T.); +#724 = ORIENTED_EDGE('',*,*,#577,.F.); +#725 = PLANE('',#726); +#726 = AXIS2_PLACEMENT_3D('',#727,#728,#729); +#727 = CARTESIAN_POINT('',(17.956031892536,-22.,-13.7484168618)); +#728 = DIRECTION('',(-0.963836182336,0.,-0.26649542889)); +#729 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#730 = ADVANCED_FACE('',(#731),#749,.T.); +#731 = FACE_BOUND('',#732,.T.); +#732 = EDGE_LOOP('',(#733,#741,#747,#748)); +#733 = ORIENTED_EDGE('',*,*,#734,.T.); +#734 = EDGE_CURVE('',#688,#735,#737,.T.); +#735 = VERTEX_POINT('',#736); +#736 = CARTESIAN_POINT('',(-21.72552223146,0.,-8.903751135252)); +#737 = LINE('',#738,#739); +#738 = CARTESIAN_POINT('',(-28.21385794834,0.,-8.903751135252)); +#739 = VECTOR('',#740,1.); +#740 = DIRECTION('',(-1.,0.,0.)); +#741 = ORIENTED_EDGE('',*,*,#742,.T.); +#742 = EDGE_CURVE('',#735,#602,#743,.T.); +#743 = LINE('',#744,#745); +#744 = CARTESIAN_POINT('',(-21.72552223146,-22.,-8.903751135252)); +#745 = VECTOR('',#746,1.); +#746 = DIRECTION('',(0.,1.,0.)); +#747 = ORIENTED_EDGE('',*,*,#609,.F.); +#748 = ORIENTED_EDGE('',*,*,#695,.F.); +#749 = PLANE('',#750); +#750 = AXIS2_PLACEMENT_3D('',#751,#752,#753); +#751 = CARTESIAN_POINT('',(-10.96276111573,-22.,-8.903751135252)); +#752 = DIRECTION('',(0.,0.,-1.)); +#753 = DIRECTION('',(0.,1.,0.)); +#754 = ADVANCED_FACE('',(#755),#773,.T.); +#755 = FACE_BOUND('',#756,.T.); +#756 = EDGE_LOOP('',(#757,#765,#766,#767)); +#757 = ORIENTED_EDGE('',*,*,#758,.T.); +#758 = EDGE_CURVE('',#759,#711,#761,.T.); +#759 = VERTEX_POINT('',#760); +#760 = CARTESIAN_POINT('',(22.059435554995,0.,-3.734519760785)); +#761 = LINE('',#762,#763); +#762 = CARTESIAN_POINT('',(12.741012548745,1.7763568394E-15, + -46.46683800444)); +#763 = VECTOR('',#764,1.); +#764 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#765 = ORIENTED_EDGE('',*,*,#710,.T.); +#766 = ORIENTED_EDGE('',*,*,#585,.F.); +#767 = ORIENTED_EDGE('',*,*,#768,.F.); +#768 = EDGE_CURVE('',#759,#586,#769,.T.); +#769 = LINE('',#770,#771); +#770 = CARTESIAN_POINT('',(22.059435554995,-22.,-3.734519760785)); +#771 = VECTOR('',#772,1.); +#772 = DIRECTION('',(0.,1.,0.)); +#773 = PLANE('',#774); +#774 = AXIS2_PLACEMENT_3D('',#775,#776,#777); +#775 = CARTESIAN_POINT('',(20.484588228021,-22.,-10.95643672209)); +#776 = DIRECTION('',(0.977039526026,0.,-0.213058124893)); +#777 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#778 = ADVANCED_FACE('',(#779),#797,.T.); +#779 = FACE_BOUND('',#780,.T.); +#780 = EDGE_LOOP('',(#781,#789,#795,#796)); +#781 = ORIENTED_EDGE('',*,*,#782,.T.); +#782 = EDGE_CURVE('',#735,#783,#785,.T.); +#783 = VERTEX_POINT('',#784); +#784 = CARTESIAN_POINT('',(-21.72552223146,0.,-3.734519760785)); +#785 = LINE('',#786,#787); +#786 = CARTESIAN_POINT('',(-21.72552223146,0.,-38.64614663299)); +#787 = VECTOR('',#788,1.); +#788 = DIRECTION('',(0.,0.,1.)); +#789 = ORIENTED_EDGE('',*,*,#790,.T.); +#790 = EDGE_CURVE('',#783,#594,#791,.T.); +#791 = LINE('',#792,#793); +#792 = CARTESIAN_POINT('',(-21.72552223146,-22.,-3.734519760785)); +#793 = VECTOR('',#794,1.); +#794 = DIRECTION('',(0.,1.,0.)); +#795 = ORIENTED_EDGE('',*,*,#601,.F.); +#796 = ORIENTED_EDGE('',*,*,#742,.F.); +#797 = PLANE('',#798); +#798 = AXIS2_PLACEMENT_3D('',#799,#800,#801); +#799 = CARTESIAN_POINT('',(-21.72552223146,-22.,-6.319135448019)); +#800 = DIRECTION('',(-1.,0.,0.)); +#801 = DIRECTION('',(0.,1.,0.)); +#802 = ADVANCED_FACE('',(#803),#814,.T.); +#803 = FACE_BOUND('',#804,.T.); +#804 = EDGE_LOOP('',(#805,#811,#812,#813)); +#805 = ORIENTED_EDGE('',*,*,#806,.T.); +#806 = EDGE_CURVE('',#783,#759,#807,.T.); +#807 = LINE('',#808,#809); +#808 = CARTESIAN_POINT('',(-22.63692080725,0.,-3.734519760785)); +#809 = VECTOR('',#810,1.); +#810 = DIRECTION('',(1.,0.,0.)); +#811 = ORIENTED_EDGE('',*,*,#768,.T.); +#812 = ORIENTED_EDGE('',*,*,#593,.F.); +#813 = ORIENTED_EDGE('',*,*,#790,.F.); +#814 = PLANE('',#815); +#815 = AXIS2_PLACEMENT_3D('',#816,#817,#818); +#816 = CARTESIAN_POINT('',(0.19111316645,-22.,-3.734519760785)); +#817 = DIRECTION('',(0.,0.,1.)); +#818 = DIRECTION('',(0.,-1.,0.)); +#819 = ADVANCED_FACE('',(#820),#845,.F.); +#820 = FACE_BOUND('',#821,.F.); +#821 = EDGE_LOOP('',(#822,#832,#838,#839)); +#822 = ORIENTED_EDGE('',*,*,#823,.T.); +#823 = EDGE_CURVE('',#824,#826,#828,.T.); +#824 = VERTEX_POINT('',#825); +#825 = CARTESIAN_POINT('',(1.480297366167E-15,11.242400581089, + -46.71869179633)); +#826 = VERTEX_POINT('',#827); +#827 = CARTESIAN_POINT('',(3.256654205567E-15,17.8572529153, + -18.39898295202)); +#828 = LINE('',#829,#830); +#829 = CARTESIAN_POINT('',(2.6645352591E-15,18.133069549222, + -17.21814831317)); +#830 = VECTOR('',#831,1.); +#831 = DIRECTION('',(5.275122655165E-17,0.227455280238,0.97378852709)); +#832 = ORIENTED_EDGE('',*,*,#833,.F.); +#833 = EDGE_CURVE('',#618,#826,#834,.T.); +#834 = LINE('',#835,#836); +#835 = CARTESIAN_POINT('',(5.649679875255,3.602918464526,-13.21082950266 + )); +#836 = VECTOR('',#837,1.); +#837 = DIRECTION('',(-0.349023821871,0.880598971639,-0.320511814002)); +#838 = ORIENTED_EDGE('',*,*,#617,.T.); +#839 = ORIENTED_EDGE('',*,*,#840,.T.); +#840 = EDGE_CURVE('',#620,#824,#841,.T.); +#841 = LINE('',#842,#843); +#842 = CARTESIAN_POINT('',(1.103762571829,7.940067898719,-47.92064259636 + )); +#843 = VECTOR('',#844,1.); +#844 = DIRECTION('',(-0.299648208284,0.896513522642,0.326304236859)); +#845 = PLANE('',#846); +#846 = AXIS2_PLACEMENT_3D('',#847,#848,#849); +#847 = CARTESIAN_POINT('',(5.823691883056,3.068404028665,-13.45978839622 + )); +#848 = DIRECTION('',(0.93629059846,0.342020143326,-7.988827695448E-02)); +#849 = DIRECTION('',(-0.340781908463,0.939692620786,2.907695487825E-02) + ); +#850 = ADVANCED_FACE('',(#851),#861,.F.); +#851 = FACE_BOUND('',#852,.F.); +#852 = EDGE_LOOP('',(#853,#854,#860)); +#853 = ORIENTED_EDGE('',*,*,#833,.T.); +#854 = ORIENTED_EDGE('',*,*,#855,.T.); +#855 = EDGE_CURVE('',#826,#628,#856,.T.); +#856 = LINE('',#857,#858); +#857 = CARTESIAN_POINT('',(-5.275833888477,4.546144602338, + -13.55413574101)); +#858 = VECTOR('',#859,1.); +#859 = DIRECTION('',(-0.349023821871,-0.880598971639,0.320511814002)); +#860 = ORIENTED_EDGE('',*,*,#627,.T.); +#861 = PLANE('',#862); +#862 = AXIS2_PLACEMENT_3D('',#863,#864,#865); +#863 = CARTESIAN_POINT('',(2.828438300639,3.068404028665,-13.01628215822 + )); +#864 = DIRECTION('',(2.88163763217E-16,0.342020143326,0.939692620786)); +#865 = DIRECTION('',(-1.048830324052E-16,0.939692620786,-0.342020143326) + ); +#866 = ADVANCED_FACE('',(#867),#877,.F.); +#867 = FACE_BOUND('',#868,.F.); +#868 = EDGE_LOOP('',(#869,#875,#876)); +#869 = ORIENTED_EDGE('',*,*,#870,.T.); +#870 = EDGE_CURVE('',#636,#824,#871,.T.); +#871 = LINE('',#872,#873); +#872 = CARTESIAN_POINT('',(-0.871390517001,8.635298785064, + -47.66759924779)); +#873 = VECTOR('',#874,1.); +#874 = DIRECTION('',(0.299648208284,0.896513522642,0.326304236859)); +#875 = ORIENTED_EDGE('',*,*,#840,.F.); +#876 = ORIENTED_EDGE('',*,*,#643,.T.); +#877 = PLANE('',#878); +#878 = AXIS2_PLACEMENT_3D('',#879,#880,#881); +#879 = CARTESIAN_POINT('',(2.828438300639,3.068404028665,-49.69378323641 + )); +#880 = DIRECTION('',(0.,0.342020143326,-0.939692620786)); +#881 = DIRECTION('',(0.,0.939692620786,0.342020143326)); +#882 = ADVANCED_FACE('',(#883),#889,.F.); +#883 = FACE_BOUND('',#884,.F.); +#884 = EDGE_LOOP('',(#885,#886,#887,#888)); +#885 = ORIENTED_EDGE('',*,*,#635,.T.); +#886 = ORIENTED_EDGE('',*,*,#855,.F.); +#887 = ORIENTED_EDGE('',*,*,#823,.F.); +#888 = ORIENTED_EDGE('',*,*,#870,.F.); +#889 = PLANE('',#890); +#890 = AXIS2_PLACEMENT_3D('',#891,#892,#893); +#891 = CARTESIAN_POINT('',(-5.782806207227,3.068404028665, + -13.93896851312)); +#892 = DIRECTION('',(-0.93629059846,0.342020143326,-7.988827695448E-02) + ); +#893 = DIRECTION('',(0.340781908463,0.939692620786,2.907695487825E-02)); +#894 = ADVANCED_FACE('',(#895),#914,.T.); +#895 = FACE_BOUND('',#896,.T.); +#896 = EDGE_LOOP('',(#897,#906,#912,#913)); +#897 = ORIENTED_EDGE('',*,*,#898,.T.); +#898 = EDGE_CURVE('',#899,#899,#901,.T.); +#899 = VERTEX_POINT('',#900); +#900 = CARTESIAN_POINT('',(-11.6927539352,0.,-48.00951684793)); +#901 = CIRCLE('',#902,4.735522705283); +#902 = AXIS2_PLACEMENT_3D('',#903,#904,#905); +#903 = CARTESIAN_POINT('',(-16.42827664048,0.,-48.00951684793)); +#904 = DIRECTION('',(-0.,1.,0.)); +#905 = DIRECTION('',(1.,0.,0.)); +#906 = ORIENTED_EDGE('',*,*,#907,.T.); +#907 = EDGE_CURVE('',#899,#652,#908,.T.); +#908 = LINE('',#909,#910); +#909 = CARTESIAN_POINT('',(-11.6927539352,-22.,-48.00951684793)); +#910 = VECTOR('',#911,1.); +#911 = DIRECTION('',(0.,1.,0.)); +#912 = ORIENTED_EDGE('',*,*,#651,.F.); +#913 = ORIENTED_EDGE('',*,*,#907,.F.); +#914 = CYLINDRICAL_SURFACE('',#915,4.735522705283); +#915 = AXIS2_PLACEMENT_3D('',#916,#917,#918); +#916 = CARTESIAN_POINT('',(-16.42827664048,-22.,-48.00951684793)); +#917 = DIRECTION('',(0.,1.,0.)); +#918 = DIRECTION('',(1.,0.,0.)); +#919 = ADVANCED_FACE('',(#920),#939,.T.); +#920 = FACE_BOUND('',#921,.T.); +#921 = EDGE_LOOP('',(#922,#931,#937,#938)); +#922 = ORIENTED_EDGE('',*,*,#923,.T.); +#923 = EDGE_CURVE('',#924,#924,#926,.T.); +#924 = VERTEX_POINT('',#925); +#925 = CARTESIAN_POINT('',(21.163799345768,0.,-48.00951684793)); +#926 = CIRCLE('',#927,4.735522705283); +#927 = AXIS2_PLACEMENT_3D('',#928,#929,#930); +#928 = CARTESIAN_POINT('',(16.428276640485,0.,-48.00951684793)); +#929 = DIRECTION('',(-0.,1.,0.)); +#930 = DIRECTION('',(1.,0.,0.)); +#931 = ORIENTED_EDGE('',*,*,#932,.T.); +#932 = EDGE_CURVE('',#924,#663,#933,.T.); +#933 = LINE('',#934,#935); +#934 = CARTESIAN_POINT('',(21.163799345768,-22.,-48.00951684793)); +#935 = VECTOR('',#936,1.); +#936 = DIRECTION('',(0.,1.,0.)); +#937 = ORIENTED_EDGE('',*,*,#662,.F.); +#938 = ORIENTED_EDGE('',*,*,#932,.F.); +#939 = CYLINDRICAL_SURFACE('',#940,4.735522705283); +#940 = AXIS2_PLACEMENT_3D('',#941,#942,#943); +#941 = CARTESIAN_POINT('',(16.428276640485,-22.,-48.00951684793)); +#942 = DIRECTION('',(0.,1.,0.)); +#943 = DIRECTION('',(1.,0.,0.)); +#944 = ADVANCED_FACE('',(#945),#953,.F.); +#945 = FACE_BOUND('',#946,.F.); +#946 = EDGE_LOOP('',(#947,#948,#949,#950,#951,#952)); +#947 = ORIENTED_EDGE('',*,*,#718,.T.); +#948 = ORIENTED_EDGE('',*,*,#687,.T.); +#949 = ORIENTED_EDGE('',*,*,#734,.T.); +#950 = ORIENTED_EDGE('',*,*,#782,.T.); +#951 = ORIENTED_EDGE('',*,*,#806,.T.); +#952 = ORIENTED_EDGE('',*,*,#758,.T.); +#953 = PLANE('',#954); +#954 = AXIS2_PLACEMENT_3D('',#955,#956,#957); +#955 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#956 = DIRECTION('',(0.,1.,0.)); +#957 = DIRECTION('',(0.,0.,1.)); +#958 = ADVANCED_FACE('',(#959),#962,.F.); +#959 = FACE_BOUND('',#960,.F.); +#960 = EDGE_LOOP('',(#961)); +#961 = ORIENTED_EDGE('',*,*,#898,.T.); +#962 = PLANE('',#963); +#963 = AXIS2_PLACEMENT_3D('',#964,#965,#966); +#964 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#965 = DIRECTION('',(0.,1.,0.)); +#966 = DIRECTION('',(0.,0.,1.)); +#967 = ADVANCED_FACE('',(#968),#971,.F.); +#968 = FACE_BOUND('',#969,.F.); +#969 = EDGE_LOOP('',(#970)); +#970 = ORIENTED_EDGE('',*,*,#923,.T.); +#971 = PLANE('',#972); +#972 = AXIS2_PLACEMENT_3D('',#973,#974,#975); +#973 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#974 = DIRECTION('',(0.,1.,0.)); +#975 = DIRECTION('',(0.,0.,1.)); +#976 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#980)) GLOBAL_UNIT_ASSIGNED_CONTEXT +((#977,#978,#979)) REPRESENTATION_CONTEXT('Context #1', + '3D Context with UNIT and UNCERTAINTY') ); +#977 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#978 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#979 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#980 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(5.E-06),#977, + 'distance_accuracy_value','confusion accuracy'); +#981 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed.stl b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed.stl new file mode 100644 index 0000000000..940dab90f7 Binary files /dev/null and b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed.stl differ diff --git a/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed_wall.step b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed_wall.step new file mode 100644 index 0000000000..14e61e2877 --- /dev/null +++ b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed_wall.step @@ -0,0 +1,1604 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('FreeCAD Model'),'2;1'); +FILE_NAME('Open CASCADE Shape Model','2026-08-05T11:06:23',('FreeCAD'),( + 'FreeCAD'),'Open CASCADE STEP processor 7.8','FreeCAD','Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('Open CASCADE STEP translator 7.8 2', + 'Open CASCADE STEP translator 7.8 2','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#1522); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#291,#322,#346,#370,#395,#419,#444,#468,#492, + #517,#541,#566,#590,#614,#638,#662,#679,#710,#734,#758,#782,#806, + #830,#854,#878,#903,#928,#952,#976,#1001,#1026,#1050,#1067,#1091, + #1221,#1252,#1276,#1300,#1324,#1348,#1365,#1390,#1421,#1437,#1454, + #1465,#1490,#1504,#1513)); +#17 = ADVANCED_FACE('',(#18,#152),#286,.F.); +#18 = FACE_BOUND('',#19,.F.); +#19 = EDGE_LOOP('',(#20,#30,#38,#46,#54,#62,#70,#79,#87,#96,#104,#112, + #121,#129,#138,#146)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(28.536336768195,0.,-54.80352892379)); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(43.891390829132,0.,-6.628649129335)); +#26 = LINE('',#27,#28); +#27 = CARTESIAN_POINT('',(30.462276491561,4.440892098501E-16, + -48.76109401628)); +#28 = VECTOR('',#29,1.); +#29 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#30 = ORIENTED_EDGE('',*,*,#31,.F.); +#31 = EDGE_CURVE('',#32,#22,#34,.T.); +#32 = VERTEX_POINT('',#33); +#33 = CARTESIAN_POINT('',(34.683490155872,0.,-60.75238354821)); +#34 = LINE('',#35,#36); +#35 = CARTESIAN_POINT('',(13.469833011847,0.,-40.22304997814)); +#36 = VECTOR('',#37,1.); +#37 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#38 = ORIENTED_EDGE('',*,*,#39,.F.); +#39 = EDGE_CURVE('',#40,#32,#42,.T.); +#40 = VERTEX_POINT('',#41); +#41 = CARTESIAN_POINT('',(26.747616824317,0.,-68.95279080543)); +#42 = LINE('',#43,#44); +#43 = CARTESIAN_POINT('',(12.749348137648,0.,-83.41767694094)); +#44 = VECTOR('',#45,1.); +#45 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#46 = ORIENTED_EDGE('',*,*,#47,.F.); +#47 = EDGE_CURVE('',#48,#40,#50,.T.); +#48 = VERTEX_POINT('',#49); +#49 = CARTESIAN_POINT('',(19.965518143439,0.,-62.38947335481)); +#50 = LINE('',#51,#52); +#51 = CARTESIAN_POINT('',(8.607536374131,0.,-51.39788454757)); +#52 = VECTOR('',#53,1.); +#53 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#54 = ORIENTED_EDGE('',*,*,#55,.F.); +#55 = EDGE_CURVE('',#56,#48,#58,.T.); +#56 = VERTEX_POINT('',#57); +#57 = CARTESIAN_POINT('',(-19.96551814343,0.,-62.38947335481)); +#58 = LINE('',#59,#60); +#59 = CARTESIAN_POINT('',(-12.74971831875,0.,-62.38947335481)); +#60 = VECTOR('',#61,1.); +#61 = DIRECTION('',(1.,0.,0.)); +#62 = ORIENTED_EDGE('',*,*,#63,.T.); +#63 = EDGE_CURVE('',#56,#64,#66,.T.); +#64 = VERTEX_POINT('',#65); +#65 = CARTESIAN_POINT('',(-24.55693735351,0.,-66.83277965903)); +#66 = LINE('',#67,#68); +#67 = CARTESIAN_POINT('',(-30.98981424001,0.,-73.05814073287)); +#68 = VECTOR('',#69,1.); +#69 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#70 = ORIENTED_EDGE('',*,*,#71,.F.); +#71 = EDGE_CURVE('',#72,#64,#74,.T.); +#72 = VERTEX_POINT('',#73); +#73 = CARTESIAN_POINT('',(-28.86762797071,0.,-66.76211133463)); +#74 = CIRCLE('',#75,3.048528137424); +#75 = AXIS2_PLACEMENT_3D('',#76,#77,#78); +#76 = CARTESIAN_POINT('',(-26.67694849991,0.,-64.64210018823)); +#77 = DIRECTION('',(0.,-1.,0.)); +#78 = DIRECTION('',(1.,0.,0.)); +#79 = ORIENTED_EDGE('',*,*,#80,.T.); +#80 = EDGE_CURVE('',#72,#81,#83,.T.); +#81 = VERTEX_POINT('',#82); +#82 = CARTESIAN_POINT('',(-30.0937101258,0.,-65.49515903052)); +#83 = LINE('',#84,#85); +#84 = CARTESIAN_POINT('',(-32.44179530228,0.,-63.0688029236)); +#85 = VECTOR('',#86,1.); +#86 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#87 = ORIENTED_EDGE('',*,*,#88,.F.); +#88 = EDGE_CURVE('',#89,#81,#91,.T.); +#89 = VERTEX_POINT('',#90); +#90 = CARTESIAN_POINT('',(-29.94071467356,0.,-56.16260351814)); +#91 = CIRCLE('',#92,6.6); +#92 = AXIS2_PLACEMENT_3D('',#93,#94,#95); +#93 = CARTESIAN_POINT('',(-25.35093464349,0.,-60.90537900045)); +#94 = DIRECTION('',(0.,-1.,0.)); +#95 = DIRECTION('',(1.,0.,0.)); +#96 = ORIENTED_EDGE('',*,*,#97,.T.); +#97 = EDGE_CURVE('',#89,#98,#100,.T.); +#98 = VERTEX_POINT('',#99); +#99 = CARTESIAN_POINT('',(-28.53633676819,0.,-54.80352892379)); +#100 = LINE('',#101,#102); +#101 = CARTESIAN_POINT('',(-36.94745061313,0.,-62.94331173664)); +#102 = VECTOR('',#103,1.); +#103 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#104 = ORIENTED_EDGE('',*,*,#105,.T.); +#105 = EDGE_CURVE('',#98,#106,#108,.T.); +#106 = VERTEX_POINT('',#107); +#107 = CARTESIAN_POINT('',(-42.51292396981,0.,-10.95344505546)); +#108 = LINE('',#109,#110); +#109 = CARTESIAN_POINT('',(-33.96596941491,4.440892098501E-16, + -37.76862308461)); +#110 = VECTOR('',#111,1.); +#111 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#112 = ORIENTED_EDGE('',*,*,#113,.F.); +#113 = EDGE_CURVE('',#114,#106,#116,.T.); +#114 = VERTEX_POINT('',#115); +#115 = CARTESIAN_POINT('',(-40.68171555857,0.,-3.418973858775)); +#116 = CIRCLE('',#117,7.454044962965); +#117 = AXIS2_PLACEMENT_3D('',#118,#119,#120); +#118 = CARTESIAN_POINT('',(-35.41090981799,0.,-8.689779599357)); +#119 = DIRECTION('',(0.,-1.,0.)); +#120 = DIRECTION('',(1.,0.,0.)); +#121 = ORIENTED_EDGE('',*,*,#122,.T.); +#122 = EDGE_CURVE('',#114,#123,#125,.T.); +#123 = VERTEX_POINT('',#124); +#124 = CARTESIAN_POINT('',(-37.84598092218,0.,-0.583239222383)); +#125 = LINE('',#126,#127); +#126 = CARTESIAN_POINT('',(-57.34820403576,0.,-20.08546233597)); +#127 = VECTOR('',#128,1.); +#128 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#129 = ORIENTED_EDGE('',*,*,#130,.F.); +#130 = EDGE_CURVE('',#131,#123,#133,.T.); +#131 = VERTEX_POINT('',#132); +#132 = CARTESIAN_POINT('',(-32.57517518159,0.,1.6)); +#133 = CIRCLE('',#134,7.454044962965); +#134 = AXIS2_PLACEMENT_3D('',#135,#136,#137); +#135 = CARTESIAN_POINT('',(-32.57517518159,0.,-5.854044962965)); +#136 = DIRECTION('',(0.,-1.,0.)); +#137 = DIRECTION('',(1.,0.,0.)); +#138 = ORIENTED_EDGE('',*,*,#139,.T.); +#139 = EDGE_CURVE('',#131,#140,#142,.T.); +#140 = VERTEX_POINT('',#141); +#141 = CARTESIAN_POINT('',(35.662741699797,0.,1.6)); +#142 = LINE('',#143,#144); +#143 = CARTESIAN_POINT('',(-4.901106540577,0.,1.6)); +#144 = VECTOR('',#145,1.); +#145 = DIRECTION('',(1.,0.,0.)); +#146 = ORIENTED_EDGE('',*,*,#147,.F.); +#147 = EDGE_CURVE('',#24,#140,#148,.T.); +#148 = LINE('',#149,#150); +#149 = CARTESIAN_POINT('',(33.5241070341,0.,3.738634665697)); +#150 = VECTOR('',#151,1.); +#151 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#152 = FACE_BOUND('',#153,.F.); +#153 = EDGE_LOOP('',(#154,#164,#172,#180,#188,#197,#205,#214,#222,#230, + #239,#247,#256,#264,#272,#280)); +#154 = ORIENTED_EDGE('',*,*,#155,.T.); +#155 = EDGE_CURVE('',#156,#158,#160,.T.); +#156 = VERTEX_POINT('',#157); +#157 = CARTESIAN_POINT('',(32.703857168398,0.,-60.78483712899)); +#158 = VERTEX_POINT('',#159); +#159 = CARTESIAN_POINT('',(26.938753236523,0.,-55.20570756731)); +#160 = LINE('',#161,#162); +#161 = CARTESIAN_POINT('',(13.567306766147,0.,-42.26560567724)); +#162 = VECTOR('',#163,1.); +#163 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#164 = ORIENTED_EDGE('',*,*,#165,.T.); +#165 = EDGE_CURVE('',#158,#166,#168,.T.); +#166 = VERTEX_POINT('',#167); +#167 = CARTESIAN_POINT('',(42.298608189024,0.,-7.015765476549)); +#168 = LINE('',#169,#170); +#169 = CARTESIAN_POINT('',(25.140315874087,-4.440892098501E-16, + -60.84811712245)); +#170 = VECTOR('',#171,1.); +#171 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#172 = ORIENTED_EDGE('',*,*,#173,.T.); +#173 = EDGE_CURVE('',#166,#174,#176,.T.); +#174 = VERTEX_POINT('',#175); +#175 = CARTESIAN_POINT('',(35.082842712475,0.,0.2)); +#176 = LINE('',#177,#178); +#177 = CARTESIAN_POINT('',(34.536239068456,0.,0.746603644019)); +#178 = VECTOR('',#179,1.); +#179 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#180 = ORIENTED_EDGE('',*,*,#181,.F.); +#181 = EDGE_CURVE('',#182,#174,#184,.T.); +#182 = VERTEX_POINT('',#183); +#183 = CARTESIAN_POINT('',(-32.57517518159,0.,0.2)); +#184 = LINE('',#185,#186); +#185 = CARTESIAN_POINT('',(-30.87627118587,0.,0.2)); +#186 = VECTOR('',#187,1.); +#187 = DIRECTION('',(1.,0.,0.)); +#188 = ORIENTED_EDGE('',*,*,#189,.T.); +#189 = EDGE_CURVE('',#182,#190,#192,.T.); +#190 = VERTEX_POINT('',#191); +#191 = CARTESIAN_POINT('',(-36.85603142851,0.,-1.573188716044)); +#192 = CIRCLE('',#193,6.054044962965); +#193 = AXIS2_PLACEMENT_3D('',#194,#195,#196); +#194 = CARTESIAN_POINT('',(-32.57517518159,0.,-5.854044962965)); +#195 = DIRECTION('',(0.,-1.,0.)); +#196 = DIRECTION('',(-1.,0.,0.)); +#197 = ORIENTED_EDGE('',*,*,#198,.F.); +#198 = EDGE_CURVE('',#199,#190,#201,.T.); +#199 = VERTEX_POINT('',#200); +#200 = CARTESIAN_POINT('',(-39.69176606491,0.,-4.408923352436)); +#201 = LINE('',#202,#203); +#202 = CARTESIAN_POINT('',(-57.0671882012,0.,-21.78434548873)); +#203 = VECTOR('',#204,1.); +#204 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#205 = ORIENTED_EDGE('',*,*,#206,.T.); +#206 = EDGE_CURVE('',#199,#207,#209,.T.); +#207 = VERTEX_POINT('',#208); +#208 = CARTESIAN_POINT('',(-41.17904151244,0.,-10.52828909594)); +#209 = CIRCLE('',#210,6.054044962965); +#210 = AXIS2_PLACEMENT_3D('',#211,#212,#213); +#211 = CARTESIAN_POINT('',(-35.41090981799,0.,-8.689779599357)); +#212 = DIRECTION('',(0.,-1.,0.)); +#213 = DIRECTION('',(-1.,0.,0.)); +#214 = ORIENTED_EDGE('',*,*,#215,.F.); +#215 = EDGE_CURVE('',#216,#207,#218,.T.); +#216 = VERTEX_POINT('',#217); +#217 = CARTESIAN_POINT('',(-26.93875323652,0.,-55.20570756731)); +#218 = LINE('',#219,#220); +#219 = CARTESIAN_POINT('',(-29.06259699304,-4.440892098501E-16, + -48.54236940733)); +#220 = VECTOR('',#221,1.); +#221 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#222 = ORIENTED_EDGE('',*,*,#223,.F.); +#223 = EDGE_CURVE('',#224,#216,#226,.T.); +#224 = VERTEX_POINT('',#225); +#225 = CARTESIAN_POINT('',(-28.96712497021,0.,-57.16864680227)); +#226 = LINE('',#227,#228); +#227 = CARTESIAN_POINT('',(-36.14667143517,0.,-64.11659091489)); +#228 = VECTOR('',#229,1.); +#229 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#230 = ORIENTED_EDGE('',*,*,#231,.T.); +#231 = EDGE_CURVE('',#224,#232,#234,.T.); +#232 = VERTEX_POINT('',#233); +#233 = CARTESIAN_POINT('',(-29.08766684168,0.,-64.52156932717)); +#234 = CIRCLE('',#235,5.2); +#235 = AXIS2_PLACEMENT_3D('',#236,#237,#238); +#236 = CARTESIAN_POINT('',(-25.35093464349,0.,-60.90537900045)); +#237 = DIRECTION('',(0.,-1.,0.)); +#238 = DIRECTION('',(-1.,0.,0.)); +#239 = ORIENTED_EDGE('',*,*,#240,.F.); +#240 = EDGE_CURVE('',#241,#232,#243,.T.); +#241 = VERTEX_POINT('',#242); +#242 = CARTESIAN_POINT('',(-27.86158468659,0.,-65.78852163128)); +#243 = LINE('',#244,#245); +#244 = CARTESIAN_POINT('',(-31.12923147938,0.,-62.41195129628)); +#245 = VECTOR('',#246,1.); +#246 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#247 = ORIENTED_EDGE('',*,*,#248,.T.); +#248 = EDGE_CURVE('',#241,#249,#251,.T.); +#249 = VERTEX_POINT('',#250); +#250 = CARTESIAN_POINT('',(-25.53052705686,0.,-65.82673637491)); +#251 = CIRCLE('',#252,1.648528137424); +#252 = AXIS2_PLACEMENT_3D('',#253,#254,#255); +#253 = CARTESIAN_POINT('',(-26.67694849991,0.,-64.64210018823)); +#254 = DIRECTION('',(0.,-1.,0.)); +#255 = DIRECTION('',(-1.,0.,0.)); +#256 = ORIENTED_EDGE('',*,*,#257,.F.); +#257 = EDGE_CURVE('',#258,#249,#260,.T.); +#258 = VERTEX_POINT('',#259); +#259 = CARTESIAN_POINT('',(-20.53201900137,0.,-60.98947335481)); +#260 = LINE('',#261,#262); +#261 = CARTESIAN_POINT('',(-30.69923804215,0.,-70.82871181101)); +#262 = VECTOR('',#263,1.); +#263 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#264 = ORIENTED_EDGE('',*,*,#265,.T.); +#265 = EDGE_CURVE('',#258,#266,#268,.T.); +#266 = VERTEX_POINT('',#267); +#267 = CARTESIAN_POINT('',(20.532019001374,0.,-60.98947335481)); +#268 = LINE('',#269,#270); +#269 = CARTESIAN_POINT('',(-17.57924046663,0.,-60.98947335481)); +#270 = VECTOR('',#271,1.); +#271 = DIRECTION('',(1.,0.,0.)); +#272 = ORIENTED_EDGE('',*,*,#273,.T.); +#273 = EDGE_CURVE('',#266,#274,#276,.T.); +#274 = VERTEX_POINT('',#275); +#275 = CARTESIAN_POINT('',(26.715163243538,0.,-66.97315781796)); +#276 = LINE('',#277,#278); +#277 = CARTESIAN_POINT('',(7.481849370247,-4.440892098501E-16, + -48.36028435244)); +#278 = VECTOR('',#279,1.); +#279 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#280 = ORIENTED_EDGE('',*,*,#281,.T.); +#281 = EDGE_CURVE('',#274,#156,#282,.T.); +#282 = LINE('',#283,#284); +#283 = CARTESIAN_POINT('',(9.75933652063,0.,-84.4941890519)); +#284 = VECTOR('',#285,1.); +#285 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#286 = PLANE('',#287); +#287 = AXIS2_PLACEMENT_3D('',#288,#289,#290); +#288 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#289 = DIRECTION('',(0.,1.,0.)); +#290 = DIRECTION('',(0.,0.,1.)); +#291 = ADVANCED_FACE('',(#292),#317,.F.); +#292 = FACE_BOUND('',#293,.F.); +#293 = EDGE_LOOP('',(#294,#295,#303,#311)); +#294 = ORIENTED_EDGE('',*,*,#21,.T.); +#295 = ORIENTED_EDGE('',*,*,#296,.T.); +#296 = EDGE_CURVE('',#24,#297,#299,.T.); +#297 = VERTEX_POINT('',#298); +#298 = CARTESIAN_POINT('',(43.891390829132,20.8572529153,-6.628649129335 + )); +#299 = LINE('',#300,#301); +#300 = CARTESIAN_POINT('',(43.891390829132,-5.,-6.628649129335)); +#301 = VECTOR('',#302,1.); +#302 = DIRECTION('',(0.,1.,0.)); +#303 = ORIENTED_EDGE('',*,*,#304,.F.); +#304 = EDGE_CURVE('',#305,#297,#307,.T.); +#305 = VERTEX_POINT('',#306); +#306 = CARTESIAN_POINT('',(28.536336768195,20.8572529153,-54.80352892379 + )); +#307 = LINE('',#308,#309); +#308 = CARTESIAN_POINT('',(30.462276491561,20.8572529153,-48.76109401628 + )); +#309 = VECTOR('',#310,1.); +#310 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#311 = ORIENTED_EDGE('',*,*,#312,.F.); +#312 = EDGE_CURVE('',#22,#305,#313,.T.); +#313 = LINE('',#314,#315); +#314 = CARTESIAN_POINT('',(28.536336768195,-5.,-54.80352892379)); +#315 = VECTOR('',#316,1.); +#316 = DIRECTION('',(0.,1.,0.)); +#317 = PLANE('',#318); +#318 = AXIS2_PLACEMENT_3D('',#319,#320,#321); +#319 = CARTESIAN_POINT('',(43.891390829132,-5.,-6.628649129335)); +#320 = DIRECTION('',(-0.952773183837,0.,0.30368282823)); +#321 = DIRECTION('',(-0.30368282823,0.,-0.952773183837)); +#322 = ADVANCED_FACE('',(#323),#341,.F.); +#323 = FACE_BOUND('',#324,.F.); +#324 = EDGE_LOOP('',(#325,#326,#334,#340)); +#325 = ORIENTED_EDGE('',*,*,#147,.T.); +#326 = ORIENTED_EDGE('',*,*,#327,.T.); +#327 = EDGE_CURVE('',#140,#328,#330,.T.); +#328 = VERTEX_POINT('',#329); +#329 = CARTESIAN_POINT('',(35.662741699797,20.8572529153,1.6)); +#330 = LINE('',#331,#332); +#331 = CARTESIAN_POINT('',(35.662741699797,-5.,1.6)); +#332 = VECTOR('',#333,1.); +#333 = DIRECTION('',(0.,1.,0.)); +#334 = ORIENTED_EDGE('',*,*,#335,.F.); +#335 = EDGE_CURVE('',#297,#328,#336,.T.); +#336 = LINE('',#337,#338); +#337 = CARTESIAN_POINT('',(33.5241070341,20.8572529153,3.738634665697)); +#338 = VECTOR('',#339,1.); +#339 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#340 = ORIENTED_EDGE('',*,*,#296,.F.); +#341 = PLANE('',#342); +#342 = AXIS2_PLACEMENT_3D('',#343,#344,#345); +#343 = CARTESIAN_POINT('',(35.662741699797,-5.,1.6)); +#344 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#345 = DIRECTION('',(0.707106781187,0.,-0.707106781187)); +#346 = ADVANCED_FACE('',(#347),#365,.T.); +#347 = FACE_BOUND('',#348,.T.); +#348 = EDGE_LOOP('',(#349,#350,#351,#359)); +#349 = ORIENTED_EDGE('',*,*,#139,.T.); +#350 = ORIENTED_EDGE('',*,*,#327,.T.); +#351 = ORIENTED_EDGE('',*,*,#352,.F.); +#352 = EDGE_CURVE('',#353,#328,#355,.T.); +#353 = VERTEX_POINT('',#354); +#354 = CARTESIAN_POINT('',(-32.57517518159,20.8572529153,1.6)); +#355 = LINE('',#356,#357); +#356 = CARTESIAN_POINT('',(-4.901106540577,20.8572529153,1.6)); +#357 = VECTOR('',#358,1.); +#358 = DIRECTION('',(1.,0.,0.)); +#359 = ORIENTED_EDGE('',*,*,#360,.F.); +#360 = EDGE_CURVE('',#131,#353,#361,.T.); +#361 = LINE('',#362,#363); +#362 = CARTESIAN_POINT('',(-32.57517518159,-5.,1.6)); +#363 = VECTOR('',#364,1.); +#364 = DIRECTION('',(0.,1.,0.)); +#365 = PLANE('',#366); +#366 = AXIS2_PLACEMENT_3D('',#367,#368,#369); +#367 = CARTESIAN_POINT('',(35.662741699797,-5.,1.6)); +#368 = DIRECTION('',(0.,0.,1.)); +#369 = DIRECTION('',(-1.,0.,0.)); +#370 = ADVANCED_FACE('',(#371),#390,.T.); +#371 = FACE_BOUND('',#372,.F.); +#372 = EDGE_LOOP('',(#373,#374,#382,#389)); +#373 = ORIENTED_EDGE('',*,*,#130,.T.); +#374 = ORIENTED_EDGE('',*,*,#375,.T.); +#375 = EDGE_CURVE('',#123,#376,#378,.T.); +#376 = VERTEX_POINT('',#377); +#377 = CARTESIAN_POINT('',(-37.84598092218,20.8572529153,-0.583239222383 + )); +#378 = LINE('',#379,#380); +#379 = CARTESIAN_POINT('',(-37.84598092218,-5.,-0.583239222383)); +#380 = VECTOR('',#381,1.); +#381 = DIRECTION('',(0.,1.,0.)); +#382 = ORIENTED_EDGE('',*,*,#383,.F.); +#383 = EDGE_CURVE('',#353,#376,#384,.T.); +#384 = CIRCLE('',#385,7.454044962965); +#385 = AXIS2_PLACEMENT_3D('',#386,#387,#388); +#386 = CARTESIAN_POINT('',(-32.57517518159,20.8572529153,-5.854044962965 + )); +#387 = DIRECTION('',(0.,-1.,0.)); +#388 = DIRECTION('',(1.,0.,0.)); +#389 = ORIENTED_EDGE('',*,*,#360,.F.); +#390 = CYLINDRICAL_SURFACE('',#391,7.454044962965); +#391 = AXIS2_PLACEMENT_3D('',#392,#393,#394); +#392 = CARTESIAN_POINT('',(-32.57517518159,-5.,-5.854044962965)); +#393 = DIRECTION('',(0.,-1.,0.)); +#394 = DIRECTION('',(1.,0.,0.)); +#395 = ADVANCED_FACE('',(#396),#414,.T.); +#396 = FACE_BOUND('',#397,.T.); +#397 = EDGE_LOOP('',(#398,#399,#400,#408)); +#398 = ORIENTED_EDGE('',*,*,#122,.T.); +#399 = ORIENTED_EDGE('',*,*,#375,.T.); +#400 = ORIENTED_EDGE('',*,*,#401,.F.); +#401 = EDGE_CURVE('',#402,#376,#404,.T.); +#402 = VERTEX_POINT('',#403); +#403 = CARTESIAN_POINT('',(-40.68171555857,20.8572529153,-3.418973858775 + )); +#404 = LINE('',#405,#406); +#405 = CARTESIAN_POINT('',(-57.34820403576,20.8572529153,-20.08546233597 + )); +#406 = VECTOR('',#407,1.); +#407 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#408 = ORIENTED_EDGE('',*,*,#409,.F.); +#409 = EDGE_CURVE('',#114,#402,#410,.T.); +#410 = LINE('',#411,#412); +#411 = CARTESIAN_POINT('',(-40.68171555857,-5.,-3.418973858775)); +#412 = VECTOR('',#413,1.); +#413 = DIRECTION('',(0.,1.,0.)); +#414 = PLANE('',#415); +#415 = AXIS2_PLACEMENT_3D('',#416,#417,#418); +#416 = CARTESIAN_POINT('',(-37.84598092218,-5.,-0.583239222383)); +#417 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#418 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#419 = ADVANCED_FACE('',(#420),#439,.T.); +#420 = FACE_BOUND('',#421,.F.); +#421 = EDGE_LOOP('',(#422,#423,#431,#438)); +#422 = ORIENTED_EDGE('',*,*,#113,.T.); +#423 = ORIENTED_EDGE('',*,*,#424,.T.); +#424 = EDGE_CURVE('',#106,#425,#427,.T.); +#425 = VERTEX_POINT('',#426); +#426 = CARTESIAN_POINT('',(-42.51292396981,20.8572529153,-10.95344505546 + )); +#427 = LINE('',#428,#429); +#428 = CARTESIAN_POINT('',(-42.51292396981,-5.,-10.95344505546)); +#429 = VECTOR('',#430,1.); +#430 = DIRECTION('',(0.,1.,0.)); +#431 = ORIENTED_EDGE('',*,*,#432,.F.); +#432 = EDGE_CURVE('',#402,#425,#433,.T.); +#433 = CIRCLE('',#434,7.454044962965); +#434 = AXIS2_PLACEMENT_3D('',#435,#436,#437); +#435 = CARTESIAN_POINT('',(-35.41090981799,20.8572529153,-8.689779599357 + )); +#436 = DIRECTION('',(0.,-1.,0.)); +#437 = DIRECTION('',(1.,0.,0.)); +#438 = ORIENTED_EDGE('',*,*,#409,.F.); +#439 = CYLINDRICAL_SURFACE('',#440,7.454044962965); +#440 = AXIS2_PLACEMENT_3D('',#441,#442,#443); +#441 = CARTESIAN_POINT('',(-35.41090981799,-5.,-8.689779599357)); +#442 = DIRECTION('',(0.,-1.,0.)); +#443 = DIRECTION('',(1.,0.,0.)); +#444 = ADVANCED_FACE('',(#445),#463,.T.); +#445 = FACE_BOUND('',#446,.T.); +#446 = EDGE_LOOP('',(#447,#448,#449,#457)); +#447 = ORIENTED_EDGE('',*,*,#105,.T.); +#448 = ORIENTED_EDGE('',*,*,#424,.T.); +#449 = ORIENTED_EDGE('',*,*,#450,.F.); +#450 = EDGE_CURVE('',#451,#425,#453,.T.); +#451 = VERTEX_POINT('',#452); +#452 = CARTESIAN_POINT('',(-28.53633676819,20.8572529153,-54.80352892379 + )); +#453 = LINE('',#454,#455); +#454 = CARTESIAN_POINT('',(-33.96596941491,20.8572529153,-37.76862308461 + )); +#455 = VECTOR('',#456,1.); +#456 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#457 = ORIENTED_EDGE('',*,*,#458,.F.); +#458 = EDGE_CURVE('',#98,#451,#459,.T.); +#459 = LINE('',#460,#461); +#460 = CARTESIAN_POINT('',(-28.53633676819,-5.,-54.80352892379)); +#461 = VECTOR('',#462,1.); +#462 = DIRECTION('',(0.,1.,0.)); +#463 = PLANE('',#464); +#464 = AXIS2_PLACEMENT_3D('',#465,#466,#467); +#465 = CARTESIAN_POINT('',(-42.51292396981,-5.,-10.95344505546)); +#466 = DIRECTION('',(-0.952773183837,0.,-0.30368282823)); +#467 = DIRECTION('',(0.30368282823,0.,-0.952773183837)); +#468 = ADVANCED_FACE('',(#469),#487,.T.); +#469 = FACE_BOUND('',#470,.T.); +#470 = EDGE_LOOP('',(#471,#472,#473,#481)); +#471 = ORIENTED_EDGE('',*,*,#97,.T.); +#472 = ORIENTED_EDGE('',*,*,#458,.T.); +#473 = ORIENTED_EDGE('',*,*,#474,.F.); +#474 = EDGE_CURVE('',#475,#451,#477,.T.); +#475 = VERTEX_POINT('',#476); +#476 = CARTESIAN_POINT('',(-29.94071467356,20.8572529153,-56.16260351814 + )); +#477 = LINE('',#478,#479); +#478 = CARTESIAN_POINT('',(-36.94745061313,20.8572529153,-62.94331173664 + )); +#479 = VECTOR('',#480,1.); +#480 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#481 = ORIENTED_EDGE('',*,*,#482,.F.); +#482 = EDGE_CURVE('',#89,#475,#483,.T.); +#483 = LINE('',#484,#485); +#484 = CARTESIAN_POINT('',(-29.94071467356,-5.,-56.16260351814)); +#485 = VECTOR('',#486,1.); +#486 = DIRECTION('',(0.,1.,0.)); +#487 = PLANE('',#488); +#488 = AXIS2_PLACEMENT_3D('',#489,#490,#491); +#489 = CARTESIAN_POINT('',(-28.53633676819,-5.,-54.80352892379)); +#490 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#491 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#492 = ADVANCED_FACE('',(#493),#512,.T.); +#493 = FACE_BOUND('',#494,.F.); +#494 = EDGE_LOOP('',(#495,#496,#504,#511)); +#495 = ORIENTED_EDGE('',*,*,#88,.T.); +#496 = ORIENTED_EDGE('',*,*,#497,.T.); +#497 = EDGE_CURVE('',#81,#498,#500,.T.); +#498 = VERTEX_POINT('',#499); +#499 = CARTESIAN_POINT('',(-30.0937101258,20.8572529153,-65.49515903052) + ); +#500 = LINE('',#501,#502); +#501 = CARTESIAN_POINT('',(-30.0937101258,-5.,-65.49515903052)); +#502 = VECTOR('',#503,1.); +#503 = DIRECTION('',(0.,1.,0.)); +#504 = ORIENTED_EDGE('',*,*,#505,.F.); +#505 = EDGE_CURVE('',#475,#498,#506,.T.); +#506 = CIRCLE('',#507,6.6); +#507 = AXIS2_PLACEMENT_3D('',#508,#509,#510); +#508 = CARTESIAN_POINT('',(-25.35093464349,20.8572529153,-60.90537900045 + )); +#509 = DIRECTION('',(0.,-1.,0.)); +#510 = DIRECTION('',(1.,0.,0.)); +#511 = ORIENTED_EDGE('',*,*,#482,.F.); +#512 = CYLINDRICAL_SURFACE('',#513,6.6); +#513 = AXIS2_PLACEMENT_3D('',#514,#515,#516); +#514 = CARTESIAN_POINT('',(-25.35093464349,-5.,-60.90537900045)); +#515 = DIRECTION('',(0.,-1.,0.)); +#516 = DIRECTION('',(1.,0.,0.)); +#517 = ADVANCED_FACE('',(#518),#536,.T.); +#518 = FACE_BOUND('',#519,.T.); +#519 = EDGE_LOOP('',(#520,#521,#522,#530)); +#520 = ORIENTED_EDGE('',*,*,#80,.T.); +#521 = ORIENTED_EDGE('',*,*,#497,.T.); +#522 = ORIENTED_EDGE('',*,*,#523,.F.); +#523 = EDGE_CURVE('',#524,#498,#526,.T.); +#524 = VERTEX_POINT('',#525); +#525 = CARTESIAN_POINT('',(-28.86762797071,20.8572529153,-66.76211133463 + )); +#526 = LINE('',#527,#528); +#527 = CARTESIAN_POINT('',(-32.44179530228,20.8572529153,-63.0688029236) + ); +#528 = VECTOR('',#529,1.); +#529 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#530 = ORIENTED_EDGE('',*,*,#531,.F.); +#531 = EDGE_CURVE('',#72,#524,#532,.T.); +#532 = LINE('',#533,#534); +#533 = CARTESIAN_POINT('',(-28.86762797071,-5.,-66.76211133463)); +#534 = VECTOR('',#535,1.); +#535 = DIRECTION('',(0.,1.,0.)); +#536 = PLANE('',#537); +#537 = AXIS2_PLACEMENT_3D('',#538,#539,#540); +#538 = CARTESIAN_POINT('',(-30.0937101258,-5.,-65.49515903052)); +#539 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#540 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#541 = ADVANCED_FACE('',(#542),#561,.T.); +#542 = FACE_BOUND('',#543,.F.); +#543 = EDGE_LOOP('',(#544,#545,#553,#560)); +#544 = ORIENTED_EDGE('',*,*,#71,.T.); +#545 = ORIENTED_EDGE('',*,*,#546,.T.); +#546 = EDGE_CURVE('',#64,#547,#549,.T.); +#547 = VERTEX_POINT('',#548); +#548 = CARTESIAN_POINT('',(-24.55693735351,20.8572529153,-66.83277965903 + )); +#549 = LINE('',#550,#551); +#550 = CARTESIAN_POINT('',(-24.55693735351,-5.,-66.83277965903)); +#551 = VECTOR('',#552,1.); +#552 = DIRECTION('',(0.,1.,0.)); +#553 = ORIENTED_EDGE('',*,*,#554,.F.); +#554 = EDGE_CURVE('',#524,#547,#555,.T.); +#555 = CIRCLE('',#556,3.048528137424); +#556 = AXIS2_PLACEMENT_3D('',#557,#558,#559); +#557 = CARTESIAN_POINT('',(-26.67694849991,20.8572529153,-64.64210018823 + )); +#558 = DIRECTION('',(0.,-1.,0.)); +#559 = DIRECTION('',(1.,0.,0.)); +#560 = ORIENTED_EDGE('',*,*,#531,.F.); +#561 = CYLINDRICAL_SURFACE('',#562,3.048528137424); +#562 = AXIS2_PLACEMENT_3D('',#563,#564,#565); +#563 = CARTESIAN_POINT('',(-26.67694849991,-5.,-64.64210018823)); +#564 = DIRECTION('',(0.,-1.,0.)); +#565 = DIRECTION('',(1.,0.,0.)); +#566 = ADVANCED_FACE('',(#567),#585,.T.); +#567 = FACE_BOUND('',#568,.T.); +#568 = EDGE_LOOP('',(#569,#570,#571,#579)); +#569 = ORIENTED_EDGE('',*,*,#63,.T.); +#570 = ORIENTED_EDGE('',*,*,#546,.T.); +#571 = ORIENTED_EDGE('',*,*,#572,.F.); +#572 = EDGE_CURVE('',#573,#547,#575,.T.); +#573 = VERTEX_POINT('',#574); +#574 = CARTESIAN_POINT('',(-19.96551814343,20.8572529153,-62.38947335481 + )); +#575 = LINE('',#576,#577); +#576 = CARTESIAN_POINT('',(-30.98981424001,20.8572529153,-73.05814073287 + )); +#577 = VECTOR('',#578,1.); +#578 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#579 = ORIENTED_EDGE('',*,*,#580,.F.); +#580 = EDGE_CURVE('',#56,#573,#581,.T.); +#581 = LINE('',#582,#583); +#582 = CARTESIAN_POINT('',(-19.96551814343,-5.,-62.38947335481)); +#583 = VECTOR('',#584,1.); +#584 = DIRECTION('',(0.,1.,0.)); +#585 = PLANE('',#586); +#586 = AXIS2_PLACEMENT_3D('',#587,#588,#589); +#587 = CARTESIAN_POINT('',(-24.55693735351,-5.,-66.83277965903)); +#588 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#589 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#590 = ADVANCED_FACE('',(#591),#609,.F.); +#591 = FACE_BOUND('',#592,.F.); +#592 = EDGE_LOOP('',(#593,#594,#602,#608)); +#593 = ORIENTED_EDGE('',*,*,#55,.T.); +#594 = ORIENTED_EDGE('',*,*,#595,.T.); +#595 = EDGE_CURVE('',#48,#596,#598,.T.); +#596 = VERTEX_POINT('',#597); +#597 = CARTESIAN_POINT('',(19.965518143439,20.8572529153,-62.38947335481 + )); +#598 = LINE('',#599,#600); +#599 = CARTESIAN_POINT('',(19.965518143439,-5.,-62.38947335481)); +#600 = VECTOR('',#601,1.); +#601 = DIRECTION('',(0.,1.,0.)); +#602 = ORIENTED_EDGE('',*,*,#603,.F.); +#603 = EDGE_CURVE('',#573,#596,#604,.T.); +#604 = LINE('',#605,#606); +#605 = CARTESIAN_POINT('',(-12.74971831875,20.8572529153,-62.38947335481 + )); +#606 = VECTOR('',#607,1.); +#607 = DIRECTION('',(1.,0.,0.)); +#608 = ORIENTED_EDGE('',*,*,#580,.F.); +#609 = PLANE('',#610); +#610 = AXIS2_PLACEMENT_3D('',#611,#612,#613); +#611 = CARTESIAN_POINT('',(19.965518143439,-5.,-62.38947335481)); +#612 = DIRECTION('',(0.,0.,1.)); +#613 = DIRECTION('',(-1.,0.,0.)); +#614 = ADVANCED_FACE('',(#615),#633,.F.); +#615 = FACE_BOUND('',#616,.F.); +#616 = EDGE_LOOP('',(#617,#618,#626,#632)); +#617 = ORIENTED_EDGE('',*,*,#47,.T.); +#618 = ORIENTED_EDGE('',*,*,#619,.T.); +#619 = EDGE_CURVE('',#40,#620,#622,.T.); +#620 = VERTEX_POINT('',#621); +#621 = CARTESIAN_POINT('',(26.747616824317,20.8572529153,-68.95279080543 + )); +#622 = LINE('',#623,#624); +#623 = CARTESIAN_POINT('',(26.747616824317,-5.,-68.95279080543)); +#624 = VECTOR('',#625,1.); +#625 = DIRECTION('',(0.,1.,0.)); +#626 = ORIENTED_EDGE('',*,*,#627,.F.); +#627 = EDGE_CURVE('',#596,#620,#628,.T.); +#628 = LINE('',#629,#630); +#629 = CARTESIAN_POINT('',(8.607536374131,20.8572529153,-51.39788454757) + ); +#630 = VECTOR('',#631,1.); +#631 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#632 = ORIENTED_EDGE('',*,*,#595,.F.); +#633 = PLANE('',#634); +#634 = AXIS2_PLACEMENT_3D('',#635,#636,#637); +#635 = CARTESIAN_POINT('',(26.747616824317,-5.,-68.95279080543)); +#636 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#637 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#638 = ADVANCED_FACE('',(#639),#657,.F.); +#639 = FACE_BOUND('',#640,.F.); +#640 = EDGE_LOOP('',(#641,#642,#650,#656)); +#641 = ORIENTED_EDGE('',*,*,#39,.T.); +#642 = ORIENTED_EDGE('',*,*,#643,.T.); +#643 = EDGE_CURVE('',#32,#644,#646,.T.); +#644 = VERTEX_POINT('',#645); +#645 = CARTESIAN_POINT('',(34.683490155872,20.8572529153,-60.75238354821 + )); +#646 = LINE('',#647,#648); +#647 = CARTESIAN_POINT('',(34.683490155872,-5.,-60.75238354821)); +#648 = VECTOR('',#649,1.); +#649 = DIRECTION('',(0.,1.,0.)); +#650 = ORIENTED_EDGE('',*,*,#651,.F.); +#651 = EDGE_CURVE('',#620,#644,#652,.T.); +#652 = LINE('',#653,#654); +#653 = CARTESIAN_POINT('',(12.749348137648,20.8572529153,-83.41767694094 + )); +#654 = VECTOR('',#655,1.); +#655 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#656 = ORIENTED_EDGE('',*,*,#619,.F.); +#657 = PLANE('',#658); +#658 = AXIS2_PLACEMENT_3D('',#659,#660,#661); +#659 = CARTESIAN_POINT('',(34.683490155872,-5.,-60.75238354821)); +#660 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#661 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#662 = ADVANCED_FACE('',(#663),#674,.F.); +#663 = FACE_BOUND('',#664,.F.); +#664 = EDGE_LOOP('',(#665,#666,#667,#673)); +#665 = ORIENTED_EDGE('',*,*,#31,.T.); +#666 = ORIENTED_EDGE('',*,*,#312,.T.); +#667 = ORIENTED_EDGE('',*,*,#668,.F.); +#668 = EDGE_CURVE('',#644,#305,#669,.T.); +#669 = LINE('',#670,#671); +#670 = CARTESIAN_POINT('',(13.469833011847,20.8572529153,-40.22304997814 + )); +#671 = VECTOR('',#672,1.); +#672 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#673 = ORIENTED_EDGE('',*,*,#643,.F.); +#674 = PLANE('',#675); +#675 = AXIS2_PLACEMENT_3D('',#676,#677,#678); +#676 = CARTESIAN_POINT('',(28.536336768195,-5.,-54.80352892379)); +#677 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#678 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#679 = ADVANCED_FACE('',(#680),#705,.T.); +#680 = FACE_BOUND('',#681,.T.); +#681 = EDGE_LOOP('',(#682,#690,#691,#699)); +#682 = ORIENTED_EDGE('',*,*,#683,.F.); +#683 = EDGE_CURVE('',#156,#684,#686,.T.); +#684 = VERTEX_POINT('',#685); +#685 = CARTESIAN_POINT('',(32.703857168398,3.2,-60.78483712899)); +#686 = LINE('',#687,#688); +#687 = CARTESIAN_POINT('',(32.703857168398,3.,-60.78483712899)); +#688 = VECTOR('',#689,1.); +#689 = DIRECTION('',(0.,1.,0.)); +#690 = ORIENTED_EDGE('',*,*,#155,.T.); +#691 = ORIENTED_EDGE('',*,*,#692,.F.); +#692 = EDGE_CURVE('',#693,#158,#695,.T.); +#693 = VERTEX_POINT('',#694); +#694 = CARTESIAN_POINT('',(26.938753236523,3.2,-55.20570756731)); +#695 = LINE('',#696,#697); +#696 = CARTESIAN_POINT('',(26.938753236523,3.,-55.20570756731)); +#697 = VECTOR('',#698,1.); +#698 = DIRECTION('',(0.,-1.,0.)); +#699 = ORIENTED_EDGE('',*,*,#700,.F.); +#700 = EDGE_CURVE('',#684,#693,#701,.T.); +#701 = LINE('',#702,#703); +#702 = CARTESIAN_POINT('',(16.008242280412,3.2,-44.62779994931)); +#703 = VECTOR('',#704,1.); +#704 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#705 = PLANE('',#706); +#706 = AXIS2_PLACEMENT_3D('',#707,#708,#709); +#707 = CARTESIAN_POINT('',(29.704873980143,3.,-57.88259703786)); +#708 = DIRECTION('',(-0.695421216677,0.,-0.718602345805)); +#709 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#710 = ADVANCED_FACE('',(#711),#729,.T.); +#711 = FACE_BOUND('',#712,.T.); +#712 = EDGE_LOOP('',(#713,#721,#722,#723)); +#713 = ORIENTED_EDGE('',*,*,#714,.F.); +#714 = EDGE_CURVE('',#274,#715,#717,.T.); +#715 = VERTEX_POINT('',#716); +#716 = CARTESIAN_POINT('',(26.715163243538,3.2,-66.97315781796)); +#717 = LINE('',#718,#719); +#718 = CARTESIAN_POINT('',(26.715163243538,3.,-66.97315781796)); +#719 = VECTOR('',#720,1.); +#720 = DIRECTION('',(0.,1.,0.)); +#721 = ORIENTED_EDGE('',*,*,#281,.T.); +#722 = ORIENTED_EDGE('',*,*,#683,.T.); +#723 = ORIENTED_EDGE('',*,*,#724,.F.); +#724 = EDGE_CURVE('',#715,#684,#725,.T.); +#725 = LINE('',#726,#727); +#726 = CARTESIAN_POINT('',(30.25240665457,3.2,-63.31800414721)); +#727 = VECTOR('',#728,1.); +#728 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#729 = PLANE('',#730); +#730 = AXIS2_PLACEMENT_3D('',#731,#732,#733); +#731 = CARTESIAN_POINT('',(29.709510205968,3.,-63.87899747347)); +#732 = DIRECTION('',(-0.718602345805,0.,0.695421216677)); +#733 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#734 = ADVANCED_FACE('',(#735),#753,.T.); +#735 = FACE_BOUND('',#736,.T.); +#736 = EDGE_LOOP('',(#737,#745,#746,#747)); +#737 = ORIENTED_EDGE('',*,*,#738,.F.); +#738 = EDGE_CURVE('',#693,#739,#741,.T.); +#739 = VERTEX_POINT('',#740); +#740 = CARTESIAN_POINT('',(42.298608189024,3.2,-7.015765476549)); +#741 = LINE('',#742,#743); +#742 = CARTESIAN_POINT('',(32.699020781567,3.2,-37.13346923684)); +#743 = VECTOR('',#744,1.); +#744 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#745 = ORIENTED_EDGE('',*,*,#692,.T.); +#746 = ORIENTED_EDGE('',*,*,#165,.T.); +#747 = ORIENTED_EDGE('',*,*,#748,.F.); +#748 = EDGE_CURVE('',#739,#166,#749,.T.); +#749 = LINE('',#750,#751); +#750 = CARTESIAN_POINT('',(42.298608189024,3.,-7.015765476549)); +#751 = VECTOR('',#752,1.); +#752 = DIRECTION('',(0.,-1.,0.)); +#753 = PLANE('',#754); +#754 = AXIS2_PLACEMENT_3D('',#755,#756,#757); +#755 = CARTESIAN_POINT('',(34.581352051556,3.,-31.22785130119)); +#756 = DIRECTION('',(-0.952773183837,0.,0.30368282823)); +#757 = DIRECTION('',(0.30368282823,0.,0.952773183837)); +#758 = ADVANCED_FACE('',(#759),#777,.T.); +#759 = FACE_BOUND('',#760,.T.); +#760 = EDGE_LOOP('',(#761,#769,#775,#776)); +#761 = ORIENTED_EDGE('',*,*,#762,.F.); +#762 = EDGE_CURVE('',#763,#715,#765,.T.); +#763 = VERTEX_POINT('',#764); +#764 = CARTESIAN_POINT('',(20.532019001374,3.2,-60.98947335481)); +#765 = LINE('',#766,#767); +#766 = CARTESIAN_POINT('',(9.922784884512,3.2,-50.72247862452)); +#767 = VECTOR('',#768,1.); +#768 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#769 = ORIENTED_EDGE('',*,*,#770,.T.); +#770 = EDGE_CURVE('',#763,#266,#771,.T.); +#771 = LINE('',#772,#773); +#772 = CARTESIAN_POINT('',(20.532019001374,3.,-60.98947335481)); +#773 = VECTOR('',#774,1.); +#774 = DIRECTION('',(0.,-1.,0.)); +#775 = ORIENTED_EDGE('',*,*,#273,.T.); +#776 = ORIENTED_EDGE('',*,*,#714,.T.); +#777 = PLANE('',#778); +#778 = AXIS2_PLACEMENT_3D('',#779,#780,#781); +#779 = CARTESIAN_POINT('',(23.522653113203,3.,-63.8836336993)); +#780 = DIRECTION('',(0.695421216677,0.,0.718602345805)); +#781 = DIRECTION('',(0.718602345805,0.,-0.695421216677)); +#782 = ADVANCED_FACE('',(#783),#801,.T.); +#783 = FACE_BOUND('',#784,.T.); +#784 = EDGE_LOOP('',(#785,#786,#787,#795)); +#785 = ORIENTED_EDGE('',*,*,#748,.T.); +#786 = ORIENTED_EDGE('',*,*,#173,.T.); +#787 = ORIENTED_EDGE('',*,*,#788,.F.); +#788 = EDGE_CURVE('',#789,#174,#791,.T.); +#789 = VERTEX_POINT('',#790); +#790 = CARTESIAN_POINT('',(35.082842712475,3.2,0.2)); +#791 = LINE('',#792,#793); +#792 = CARTESIAN_POINT('',(35.082842712475,3.,0.2)); +#793 = VECTOR('',#794,1.); +#794 = DIRECTION('',(0.,-1.,0.)); +#795 = ORIENTED_EDGE('',*,*,#796,.F.); +#796 = EDGE_CURVE('',#739,#789,#797,.T.); +#797 = LINE('',#798,#799); +#798 = CARTESIAN_POINT('',(36.596246576251,3.2,-1.313403863776)); +#799 = VECTOR('',#800,1.); +#800 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#801 = PLANE('',#802); +#802 = AXIS2_PLACEMENT_3D('',#803,#804,#805); +#803 = CARTESIAN_POINT('',(38.67695526217,3.,-3.394112549695)); +#804 = DIRECTION('',(-0.707106781187,0.,-0.707106781187)); +#805 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#806 = ADVANCED_FACE('',(#807),#825,.T.); +#807 = FACE_BOUND('',#808,.T.); +#808 = EDGE_LOOP('',(#809,#817,#818,#819)); +#809 = ORIENTED_EDGE('',*,*,#810,.F.); +#810 = EDGE_CURVE('',#258,#811,#813,.T.); +#811 = VERTEX_POINT('',#812); +#812 = CARTESIAN_POINT('',(-20.53201900137,3.2,-60.98947335481)); +#813 = LINE('',#814,#815); +#814 = CARTESIAN_POINT('',(-20.53201900137,3.,-60.98947335481)); +#815 = VECTOR('',#816,1.); +#816 = DIRECTION('',(0.,1.,0.)); +#817 = ORIENTED_EDGE('',*,*,#265,.T.); +#818 = ORIENTED_EDGE('',*,*,#770,.F.); +#819 = ORIENTED_EDGE('',*,*,#820,.F.); +#820 = EDGE_CURVE('',#811,#763,#821,.T.); +#821 = LINE('',#822,#823); +#822 = CARTESIAN_POINT('',(5.354765181569,3.2,-60.98947335481)); +#823 = VECTOR('',#824,1.); +#824 = DIRECTION('',(1.,0.,0.)); +#825 = PLANE('',#826); +#826 = AXIS2_PLACEMENT_3D('',#827,#828,#829); +#827 = CARTESIAN_POINT('',(10.306473847682,3.,-60.98947335481)); +#828 = DIRECTION('',(0.,0.,1.)); +#829 = DIRECTION('',(0.,-1.,0.)); +#830 = ADVANCED_FACE('',(#831),#849,.F.); +#831 = FACE_BOUND('',#832,.F.); +#832 = EDGE_LOOP('',(#833,#834,#842,#848)); +#833 = ORIENTED_EDGE('',*,*,#788,.F.); +#834 = ORIENTED_EDGE('',*,*,#835,.F.); +#835 = EDGE_CURVE('',#836,#789,#838,.T.); +#836 = VERTEX_POINT('',#837); +#837 = CARTESIAN_POINT('',(-32.57517518159,3.2,0.2)); +#838 = LINE('',#839,#840); +#839 = CARTESIAN_POINT('',(-7.942265537672,3.2,0.2)); +#840 = VECTOR('',#841,1.); +#841 = DIRECTION('',(1.,0.,0.)); +#842 = ORIENTED_EDGE('',*,*,#843,.T.); +#843 = EDGE_CURVE('',#836,#182,#844,.T.); +#844 = LINE('',#845,#846); +#845 = CARTESIAN_POINT('',(-32.57517518159,3.,0.2)); +#846 = VECTOR('',#847,1.); +#847 = DIRECTION('',(0.,-1.,0.)); +#848 = ORIENTED_EDGE('',*,*,#181,.T.); +#849 = PLANE('',#850); +#850 = AXIS2_PLACEMENT_3D('',#851,#852,#853); +#851 = CARTESIAN_POINT('',(-16.28758759079,3.,0.2)); +#852 = DIRECTION('',(0.,0.,1.)); +#853 = DIRECTION('',(0.,-1.,0.)); +#854 = ADVANCED_FACE('',(#855),#873,.F.); +#855 = FACE_BOUND('',#856,.F.); +#856 = EDGE_LOOP('',(#857,#865,#871,#872)); +#857 = ORIENTED_EDGE('',*,*,#858,.F.); +#858 = EDGE_CURVE('',#859,#249,#861,.T.); +#859 = VERTEX_POINT('',#860); +#860 = CARTESIAN_POINT('',(-25.53052705686,3.2,-65.82673637491)); +#861 = LINE('',#862,#863); +#862 = CARTESIAN_POINT('',(-25.53052705686,3.,-65.82673637491)); +#863 = VECTOR('',#864,1.); +#864 = DIRECTION('',(0.,-1.,0.)); +#865 = ORIENTED_EDGE('',*,*,#866,.F.); +#866 = EDGE_CURVE('',#811,#859,#867,.T.); +#867 = LINE('',#868,#869); +#868 = CARTESIAN_POINT('',(-9.454421870603,3.2,-50.26922436104)); +#869 = VECTOR('',#870,1.); +#870 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#871 = ORIENTED_EDGE('',*,*,#810,.F.); +#872 = ORIENTED_EDGE('',*,*,#257,.T.); +#873 = PLANE('',#874); +#874 = AXIS2_PLACEMENT_3D('',#875,#876,#877); +#875 = CARTESIAN_POINT('',(-23.00219525444,3.,-63.37996509944)); +#876 = DIRECTION('',(0.695421216677,0.,-0.718602345805)); +#877 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#878 = ADVANCED_FACE('',(#879),#898,.F.); +#879 = FACE_BOUND('',#880,.F.); +#880 = EDGE_LOOP('',(#881,#882,#891,#897)); +#881 = ORIENTED_EDGE('',*,*,#843,.F.); +#882 = ORIENTED_EDGE('',*,*,#883,.T.); +#883 = EDGE_CURVE('',#836,#884,#886,.T.); +#884 = VERTEX_POINT('',#885); +#885 = CARTESIAN_POINT('',(-36.85603142851,3.2,-1.573188716044)); +#886 = CIRCLE('',#887,6.054044962965); +#887 = AXIS2_PLACEMENT_3D('',#888,#889,#890); +#888 = CARTESIAN_POINT('',(-32.57517518159,3.2,-5.854044962965)); +#889 = DIRECTION('',(0.,-1.,0.)); +#890 = DIRECTION('',(-1.,0.,0.)); +#891 = ORIENTED_EDGE('',*,*,#892,.T.); +#892 = EDGE_CURVE('',#884,#190,#893,.T.); +#893 = LINE('',#894,#895); +#894 = CARTESIAN_POINT('',(-36.85603142851,3.,-1.573188716044)); +#895 = VECTOR('',#896,1.); +#896 = DIRECTION('',(0.,-1.,0.)); +#897 = ORIENTED_EDGE('',*,*,#189,.F.); +#898 = CYLINDRICAL_SURFACE('',#899,6.054044962965); +#899 = AXIS2_PLACEMENT_3D('',#900,#901,#902); +#900 = CARTESIAN_POINT('',(-32.57517518159,3.,-5.854044962965)); +#901 = DIRECTION('',(0.,-1.,0.)); +#902 = DIRECTION('',(-1.,0.,0.)); +#903 = ADVANCED_FACE('',(#904),#923,.F.); +#904 = FACE_BOUND('',#905,.F.); +#905 = EDGE_LOOP('',(#906,#914,#921,#922)); +#906 = ORIENTED_EDGE('',*,*,#907,.F.); +#907 = EDGE_CURVE('',#908,#241,#910,.T.); +#908 = VERTEX_POINT('',#909); +#909 = CARTESIAN_POINT('',(-27.86158468659,3.2,-65.78852163128)); +#910 = LINE('',#911,#912); +#911 = CARTESIAN_POINT('',(-27.86158468659,3.,-65.78852163128)); +#912 = VECTOR('',#913,1.); +#913 = DIRECTION('',(0.,-1.,0.)); +#914 = ORIENTED_EDGE('',*,*,#915,.T.); +#915 = EDGE_CURVE('',#908,#859,#916,.T.); +#916 = CIRCLE('',#917,1.648528137424); +#917 = AXIS2_PLACEMENT_3D('',#918,#919,#920); +#918 = CARTESIAN_POINT('',(-26.67694849991,3.2,-64.64210018823)); +#919 = DIRECTION('',(0.,-1.,0.)); +#920 = DIRECTION('',(-1.,0.,0.)); +#921 = ORIENTED_EDGE('',*,*,#858,.T.); +#922 = ORIENTED_EDGE('',*,*,#248,.F.); +#923 = CYLINDRICAL_SURFACE('',#924,1.648528137424); +#924 = AXIS2_PLACEMENT_3D('',#925,#926,#927); +#925 = CARTESIAN_POINT('',(-26.67694849991,3.,-64.64210018823)); +#926 = DIRECTION('',(0.,-1.,0.)); +#927 = DIRECTION('',(-1.,0.,0.)); +#928 = ADVANCED_FACE('',(#929),#947,.F.); +#929 = FACE_BOUND('',#930,.F.); +#930 = EDGE_LOOP('',(#931,#932,#940,#946)); +#931 = ORIENTED_EDGE('',*,*,#892,.F.); +#932 = ORIENTED_EDGE('',*,*,#933,.F.); +#933 = EDGE_CURVE('',#934,#884,#936,.T.); +#934 = VERTEX_POINT('',#935); +#935 = CARTESIAN_POINT('',(-39.69176606491,3.2,-4.408923352436)); +#936 = LINE('',#937,#938); +#937 = CARTESIAN_POINT('',(-36.19319006079,3.2,-0.910347348321)); +#938 = VECTOR('',#939,1.); +#939 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#940 = ORIENTED_EDGE('',*,*,#941,.T.); +#941 = EDGE_CURVE('',#934,#199,#942,.T.); +#942 = LINE('',#943,#944); +#943 = CARTESIAN_POINT('',(-39.69176606491,3.,-4.408923352436)); +#944 = VECTOR('',#945,1.); +#945 = DIRECTION('',(0.,-1.,0.)); +#946 = ORIENTED_EDGE('',*,*,#198,.T.); +#947 = PLANE('',#948); +#948 = AXIS2_PLACEMENT_3D('',#949,#950,#951); +#949 = CARTESIAN_POINT('',(-38.27389874671,3.,-2.99105603424)); +#950 = DIRECTION('',(-0.707106781187,0.,0.707106781187)); +#951 = DIRECTION('',(0.707106781187,0.,0.707106781187)); +#952 = ADVANCED_FACE('',(#953),#971,.F.); +#953 = FACE_BOUND('',#954,.F.); +#954 = EDGE_LOOP('',(#955,#963,#969,#970)); +#955 = ORIENTED_EDGE('',*,*,#956,.F.); +#956 = EDGE_CURVE('',#957,#232,#959,.T.); +#957 = VERTEX_POINT('',#958); +#958 = CARTESIAN_POINT('',(-29.08766684168,3.2,-64.52156932717)); +#959 = LINE('',#960,#961); +#960 = CARTESIAN_POINT('',(-29.08766684168,3.,-64.52156932717)); +#961 = VECTOR('',#962,1.); +#962 = DIRECTION('',(0.,-1.,0.)); +#963 = ORIENTED_EDGE('',*,*,#964,.F.); +#964 = EDGE_CURVE('',#908,#957,#965,.T.); +#965 = LINE('',#966,#967); +#966 = CARTESIAN_POINT('',(-29.44004200272,3.2,-64.15744811364)); +#967 = VECTOR('',#968,1.); +#968 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#969 = ORIENTED_EDGE('',*,*,#907,.T.); +#970 = ORIENTED_EDGE('',*,*,#240,.T.); +#971 = PLANE('',#972); +#972 = AXIS2_PLACEMENT_3D('',#973,#974,#975); +#973 = CARTESIAN_POINT('',(-28.47462576413,3.,-65.15504547923)); +#974 = DIRECTION('',(-0.718602345805,0.,-0.695421216677)); +#975 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#976 = ADVANCED_FACE('',(#977),#996,.F.); +#977 = FACE_BOUND('',#978,.F.); +#978 = EDGE_LOOP('',(#979,#980,#989,#995)); +#979 = ORIENTED_EDGE('',*,*,#941,.F.); +#980 = ORIENTED_EDGE('',*,*,#981,.T.); +#981 = EDGE_CURVE('',#934,#982,#984,.T.); +#982 = VERTEX_POINT('',#983); +#983 = CARTESIAN_POINT('',(-41.17904151244,3.2,-10.52828909594)); +#984 = CIRCLE('',#985,6.054044962965); +#985 = AXIS2_PLACEMENT_3D('',#986,#987,#988); +#986 = CARTESIAN_POINT('',(-35.41090981799,3.2,-8.689779599357)); +#987 = DIRECTION('',(0.,-1.,0.)); +#988 = DIRECTION('',(-1.,0.,0.)); +#989 = ORIENTED_EDGE('',*,*,#990,.T.); +#990 = EDGE_CURVE('',#982,#207,#991,.T.); +#991 = LINE('',#992,#993); +#992 = CARTESIAN_POINT('',(-41.17904151244,3.,-10.52828909594)); +#993 = VECTOR('',#994,1.); +#994 = DIRECTION('',(0.,-1.,0.)); +#995 = ORIENTED_EDGE('',*,*,#206,.F.); +#996 = CYLINDRICAL_SURFACE('',#997,6.054044962965); +#997 = AXIS2_PLACEMENT_3D('',#998,#999,#1000); +#998 = CARTESIAN_POINT('',(-35.41090981799,3.,-8.689779599357)); +#999 = DIRECTION('',(0.,-1.,0.)); +#1000 = DIRECTION('',(-1.,0.,0.)); +#1001 = ADVANCED_FACE('',(#1002),#1021,.F.); +#1002 = FACE_BOUND('',#1003,.F.); +#1003 = EDGE_LOOP('',(#1004,#1012,#1019,#1020)); +#1004 = ORIENTED_EDGE('',*,*,#1005,.F.); +#1005 = EDGE_CURVE('',#1006,#224,#1008,.T.); +#1006 = VERTEX_POINT('',#1007); +#1007 = CARTESIAN_POINT('',(-28.96712497021,3.2,-57.16864680227)); +#1008 = LINE('',#1009,#1010); +#1009 = CARTESIAN_POINT('',(-28.96712497021,3.,-57.16864680227)); +#1010 = VECTOR('',#1011,1.); +#1011 = DIRECTION('',(0.,-1.,0.)); +#1012 = ORIENTED_EDGE('',*,*,#1013,.T.); +#1013 = EDGE_CURVE('',#1006,#957,#1014,.T.); +#1014 = CIRCLE('',#1015,5.2); +#1015 = AXIS2_PLACEMENT_3D('',#1016,#1017,#1018); +#1016 = CARTESIAN_POINT('',(-25.35093464349,3.2,-60.90537900045)); +#1017 = DIRECTION('',(0.,-1.,0.)); +#1018 = DIRECTION('',(-1.,0.,0.)); +#1019 = ORIENTED_EDGE('',*,*,#956,.T.); +#1020 = ORIENTED_EDGE('',*,*,#231,.F.); +#1021 = CYLINDRICAL_SURFACE('',#1022,5.2); +#1022 = AXIS2_PLACEMENT_3D('',#1023,#1024,#1025); +#1023 = CARTESIAN_POINT('',(-25.35093464349,3.,-60.90537900045)); +#1024 = DIRECTION('',(0.,-1.,0.)); +#1025 = DIRECTION('',(-1.,0.,0.)); +#1026 = ADVANCED_FACE('',(#1027),#1045,.F.); +#1027 = FACE_BOUND('',#1028,.F.); +#1028 = EDGE_LOOP('',(#1029,#1030,#1038,#1044)); +#1029 = ORIENTED_EDGE('',*,*,#990,.F.); +#1030 = ORIENTED_EDGE('',*,*,#1031,.F.); +#1031 = EDGE_CURVE('',#1032,#982,#1034,.T.); +#1032 = VERTEX_POINT('',#1033); +#1033 = CARTESIAN_POINT('',(-26.93875323652,3.2,-55.20570756731)); +#1034 = LINE('',#1035,#1036); +#1035 = CARTESIAN_POINT('',(-32.39120436163,3.2,-38.09921113329)); +#1036 = VECTOR('',#1037,1.); +#1037 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#1038 = ORIENTED_EDGE('',*,*,#1039,.T.); +#1039 = EDGE_CURVE('',#1032,#216,#1040,.T.); +#1040 = LINE('',#1041,#1042); +#1041 = CARTESIAN_POINT('',(-26.93875323652,3.,-55.20570756731)); +#1042 = VECTOR('',#1043,1.); +#1043 = DIRECTION('',(0.,-1.,0.)); +#1044 = ORIENTED_EDGE('',*,*,#215,.T.); +#1045 = PLANE('',#1046); +#1046 = AXIS2_PLACEMENT_3D('',#1047,#1048,#1049); +#1047 = CARTESIAN_POINT('',(-34.04006158346,3.,-32.92609366041)); +#1048 = DIRECTION('',(-0.952773183837,0.,-0.30368282823)); +#1049 = DIRECTION('',(-0.30368282823,0.,0.952773183837)); +#1050 = ADVANCED_FACE('',(#1051),#1062,.F.); +#1051 = FACE_BOUND('',#1052,.F.); +#1052 = EDGE_LOOP('',(#1053,#1054,#1060,#1061)); +#1053 = ORIENTED_EDGE('',*,*,#1039,.F.); +#1054 = ORIENTED_EDGE('',*,*,#1055,.F.); +#1055 = EDGE_CURVE('',#1006,#1032,#1056,.T.); +#1056 = LINE('',#1057,#1058); +#1057 = CARTESIAN_POINT('',(-14.90185526362,3.2,-43.55710346492)); +#1058 = VECTOR('',#1059,1.); +#1059 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#1060 = ORIENTED_EDGE('',*,*,#1005,.T.); +#1061 = ORIENTED_EDGE('',*,*,#223,.T.); +#1062 = PLANE('',#1063); +#1063 = AXIS2_PLACEMENT_3D('',#1064,#1065,#1066); +#1064 = CARTESIAN_POINT('',(-27.90836811563,3.,-56.14404399617)); +#1065 = DIRECTION('',(-0.695421216677,0.,0.718602345805)); +#1066 = DIRECTION('',(0.718602345805,0.,0.695421216677)); +#1067 = ADVANCED_FACE('',(#1068),#1086,.T.); +#1068 = FACE_BOUND('',#1069,.T.); +#1069 = EDGE_LOOP('',(#1070,#1071,#1072,#1073,#1074,#1075,#1076,#1077, + #1078,#1079,#1080,#1081,#1082,#1083,#1084,#1085)); +#1070 = ORIENTED_EDGE('',*,*,#304,.F.); +#1071 = ORIENTED_EDGE('',*,*,#668,.F.); +#1072 = ORIENTED_EDGE('',*,*,#651,.F.); +#1073 = ORIENTED_EDGE('',*,*,#627,.F.); +#1074 = ORIENTED_EDGE('',*,*,#603,.F.); +#1075 = ORIENTED_EDGE('',*,*,#572,.T.); +#1076 = ORIENTED_EDGE('',*,*,#554,.F.); +#1077 = ORIENTED_EDGE('',*,*,#523,.T.); +#1078 = ORIENTED_EDGE('',*,*,#505,.F.); +#1079 = ORIENTED_EDGE('',*,*,#474,.T.); +#1080 = ORIENTED_EDGE('',*,*,#450,.T.); +#1081 = ORIENTED_EDGE('',*,*,#432,.F.); +#1082 = ORIENTED_EDGE('',*,*,#401,.T.); +#1083 = ORIENTED_EDGE('',*,*,#383,.F.); +#1084 = ORIENTED_EDGE('',*,*,#352,.T.); +#1085 = ORIENTED_EDGE('',*,*,#335,.F.); +#1086 = PLANE('',#1087); +#1087 = AXIS2_PLACEMENT_3D('',#1088,#1089,#1090); +#1088 = CARTESIAN_POINT('',(-45.46495478095,20.8572529153, + -70.97315781796)); +#1089 = DIRECTION('',(0.,1.,0.)); +#1090 = DIRECTION('',(0.,0.,1.)); +#1091 = ADVANCED_FACE('',(#1092,#1142,#1153,#1171,#1205),#1216,.F.); +#1092 = FACE_BOUND('',#1093,.F.); +#1093 = EDGE_LOOP('',(#1094,#1104,#1112,#1120,#1128,#1136)); +#1094 = ORIENTED_EDGE('',*,*,#1095,.F.); +#1095 = EDGE_CURVE('',#1096,#1098,#1100,.T.); +#1096 = VERTEX_POINT('',#1097); +#1097 = CARTESIAN_POINT('',(16.626582997737,3.2,-8.940188245231)); +#1098 = VERTEX_POINT('',#1099); +#1099 = CARTESIAN_POINT('',(4.383041634064E-04,3.2,-8.903751615529)); +#1100 = LINE('',#1101,#1102); +#1101 = CARTESIAN_POINT('',(4.347099726942,3.2,-8.913277437397)); +#1102 = VECTOR('',#1103,1.); +#1103 = DIRECTION('',(-0.999997598615,0.,2.19152081707E-03)); +#1104 = ORIENTED_EDGE('',*,*,#1105,.F.); +#1105 = EDGE_CURVE('',#1106,#1096,#1108,.T.); +#1106 = VERTEX_POINT('',#1107); +#1107 = CARTESIAN_POINT('',(19.029295926024,3.2,-17.63009960955)); +#1108 = LINE('',#1109,#1110); +#1109 = CARTESIAN_POINT('',(19.849519003668,3.2,-20.59660707692)); +#1110 = VECTOR('',#1111,1.); +#1111 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#1112 = ORIENTED_EDGE('',*,*,#1113,.F.); +#1113 = EDGE_CURVE('',#1114,#1106,#1116,.T.); +#1114 = VERTEX_POINT('',#1115); +#1115 = CARTESIAN_POINT('',(22.059435554995,3.2,-3.734519760785)); +#1116 = LINE('',#1117,#1118); +#1117 = CARTESIAN_POINT('',(17.698510515043,3.2,-23.73280021221)); +#1118 = VECTOR('',#1119,1.); +#1119 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#1120 = ORIENTED_EDGE('',*,*,#1121,.F.); +#1121 = EDGE_CURVE('',#1122,#1114,#1124,.T.); +#1122 = VERTEX_POINT('',#1123); +#1123 = CARTESIAN_POINT('',(-21.72552223146,3.2,-3.734519760785)); +#1124 = LINE('',#1125,#1126); +#1125 = CARTESIAN_POINT('',(0.297084840953,3.2,-3.734519760785)); +#1126 = VECTOR('',#1127,1.); +#1127 = DIRECTION('',(1.,0.,0.)); +#1128 = ORIENTED_EDGE('',*,*,#1129,.F.); +#1129 = EDGE_CURVE('',#1130,#1122,#1132,.T.); +#1130 = VERTEX_POINT('',#1131); +#1131 = CARTESIAN_POINT('',(-21.72552223146,3.2,-8.903751135252)); +#1132 = LINE('',#1133,#1134); +#1133 = CARTESIAN_POINT('',(-21.72552223146,3.2,-19.83215600037)); +#1134 = VECTOR('',#1135,1.); +#1135 = DIRECTION('',(0.,0.,1.)); +#1136 = ORIENTED_EDGE('',*,*,#1137,.F.); +#1137 = EDGE_CURVE('',#1098,#1130,#1138,.T.); +#1138 = LINE('',#1139,#1140); +#1139 = CARTESIAN_POINT('',(-5.279852300138,3.2,-8.903751135252)); +#1140 = VECTOR('',#1141,1.); +#1141 = DIRECTION('',(-1.,0.,0.)); +#1142 = FACE_BOUND('',#1143,.F.); +#1143 = EDGE_LOOP('',(#1144)); +#1144 = ORIENTED_EDGE('',*,*,#1145,.F.); +#1145 = EDGE_CURVE('',#1146,#1146,#1148,.T.); +#1146 = VERTEX_POINT('',#1147); +#1147 = CARTESIAN_POINT('',(21.163799345768,3.2,-48.00951684793)); +#1148 = CIRCLE('',#1149,4.735522705283); +#1149 = AXIS2_PLACEMENT_3D('',#1150,#1151,#1152); +#1150 = CARTESIAN_POINT('',(16.428276640485,3.2,-48.00951684793)); +#1151 = DIRECTION('',(-0.,1.,0.)); +#1152 = DIRECTION('',(1.,0.,0.)); +#1153 = FACE_BOUND('',#1154,.F.); +#1154 = EDGE_LOOP('',(#1155,#1156,#1157,#1158,#1159,#1160,#1161,#1162, + #1163,#1164,#1165,#1166,#1167,#1168,#1169,#1170)); +#1155 = ORIENTED_EDGE('',*,*,#1031,.T.); +#1156 = ORIENTED_EDGE('',*,*,#981,.F.); +#1157 = ORIENTED_EDGE('',*,*,#933,.T.); +#1158 = ORIENTED_EDGE('',*,*,#883,.F.); +#1159 = ORIENTED_EDGE('',*,*,#835,.T.); +#1160 = ORIENTED_EDGE('',*,*,#796,.F.); +#1161 = ORIENTED_EDGE('',*,*,#738,.F.); +#1162 = ORIENTED_EDGE('',*,*,#700,.F.); +#1163 = ORIENTED_EDGE('',*,*,#724,.F.); +#1164 = ORIENTED_EDGE('',*,*,#762,.F.); +#1165 = ORIENTED_EDGE('',*,*,#820,.F.); +#1166 = ORIENTED_EDGE('',*,*,#866,.T.); +#1167 = ORIENTED_EDGE('',*,*,#915,.F.); +#1168 = ORIENTED_EDGE('',*,*,#964,.T.); +#1169 = ORIENTED_EDGE('',*,*,#1013,.F.); +#1170 = ORIENTED_EDGE('',*,*,#1055,.T.); +#1171 = FACE_BOUND('',#1172,.F.); +#1172 = EDGE_LOOP('',(#1173,#1183,#1191,#1199)); +#1173 = ORIENTED_EDGE('',*,*,#1174,.F.); +#1174 = EDGE_CURVE('',#1175,#1177,#1179,.T.); +#1175 = VERTEX_POINT('',#1176); +#1176 = CARTESIAN_POINT('',(5.809375885494,3.2,-13.06417917474)); +#1177 = VERTEX_POINT('',#1178); +#1178 = CARTESIAN_POINT('',(2.688069798796,3.2,-49.64588621989)); +#1179 = LINE('',#1180,#1181); +#1180 = CARTESIAN_POINT('',(4.914157977861,3.2,-23.55613296875)); +#1181 = VECTOR('',#1182,1.); +#1182 = DIRECTION('',(-8.501532861638E-02,0.,-0.996379643459)); +#1183 = ORIENTED_EDGE('',*,*,#1184,.F.); +#1184 = EDGE_CURVE('',#1185,#1175,#1187,.T.); +#1185 = VERTEX_POINT('',#1186); +#1186 = CARTESIAN_POINT('',(-5.809375885494,3.2,-13.06417917474)); +#1187 = LINE('',#1188,#1189); +#1188 = CARTESIAN_POINT('',(1.615747408047,3.2,-13.06417917474)); +#1189 = VECTOR('',#1190,1.); +#1190 = DIRECTION('',(1.,0.,-3.066574716487E-16)); +#1191 = ORIENTED_EDGE('',*,*,#1192,.F.); +#1192 = EDGE_CURVE('',#1193,#1185,#1195,.T.); +#1193 = VERTEX_POINT('',#1194); +#1194 = CARTESIAN_POINT('',(-2.688069798796,3.2,-49.64588621989)); +#1195 = LINE('',#1196,#1197); +#1196 = CARTESIAN_POINT('',(-4.890802006217,3.2,-23.82986495424)); +#1197 = VECTOR('',#1198,1.); +#1198 = DIRECTION('',(-8.501532861638E-02,0.,0.996379643459)); +#1199 = ORIENTED_EDGE('',*,*,#1200,.F.); +#1200 = EDGE_CURVE('',#1177,#1193,#1201,.T.); +#1201 = LINE('',#1202,#1203); +#1202 = CARTESIAN_POINT('',(1.615747408047,3.2,-49.64588621989)); +#1203 = VECTOR('',#1204,1.); +#1204 = DIRECTION('',(-1.,0.,0.)); +#1205 = FACE_BOUND('',#1206,.F.); +#1206 = EDGE_LOOP('',(#1207)); +#1207 = ORIENTED_EDGE('',*,*,#1208,.F.); +#1208 = EDGE_CURVE('',#1209,#1209,#1211,.T.); +#1209 = VERTEX_POINT('',#1210); +#1210 = CARTESIAN_POINT('',(-11.6927539352,3.2,-48.00951684793)); +#1211 = CIRCLE('',#1212,4.735522705283); +#1212 = AXIS2_PLACEMENT_3D('',#1213,#1214,#1215); +#1213 = CARTESIAN_POINT('',(-16.42827664048,3.2,-48.00951684793)); +#1214 = DIRECTION('',(-0.,1.,0.)); +#1215 = DIRECTION('',(1.,0.,0.)); +#1216 = PLANE('',#1217); +#1217 = AXIS2_PLACEMENT_3D('',#1218,#1219,#1220); +#1218 = CARTESIAN_POINT('',(0.403056515455,3.2,-33.34517655273)); +#1219 = DIRECTION('',(0.,1.,0.)); +#1220 = DIRECTION('',(1.,0.,0.)); +#1221 = ADVANCED_FACE('',(#1222),#1247,.T.); +#1222 = FACE_BOUND('',#1223,.T.); +#1223 = EDGE_LOOP('',(#1224,#1232,#1240,#1246)); +#1224 = ORIENTED_EDGE('',*,*,#1225,.F.); +#1225 = EDGE_CURVE('',#1226,#1096,#1228,.T.); +#1226 = VERTEX_POINT('',#1227); +#1227 = CARTESIAN_POINT('',(16.626582997737,0.,-8.940188245231)); +#1228 = LINE('',#1229,#1230); +#1229 = CARTESIAN_POINT('',(16.626582997737,-22.,-8.940188245231)); +#1230 = VECTOR('',#1231,1.); +#1231 = DIRECTION('',(0.,1.,0.)); +#1232 = ORIENTED_EDGE('',*,*,#1233,.T.); +#1233 = EDGE_CURVE('',#1226,#1234,#1236,.T.); +#1234 = VERTEX_POINT('',#1235); +#1235 = CARTESIAN_POINT('',(-5.329070518201E-15,0.,-8.903751135252)); +#1236 = LINE('',#1237,#1238); +#1237 = CARTESIAN_POINT('',(-18.54556462154,1.7763568394E-15, + -8.863107566442)); +#1238 = VECTOR('',#1239,1.); +#1239 = DIRECTION('',(-0.999997598615,0.,2.19152081707E-03)); +#1240 = ORIENTED_EDGE('',*,*,#1241,.T.); +#1241 = EDGE_CURVE('',#1234,#1098,#1242,.T.); +#1242 = LINE('',#1243,#1244); +#1243 = CARTESIAN_POINT('',(-5.329070518201E-15,-22.,-8.903751135252)); +#1244 = VECTOR('',#1245,1.); +#1245 = DIRECTION('',(0.,1.,0.)); +#1246 = ORIENTED_EDGE('',*,*,#1095,.F.); +#1247 = PLANE('',#1248); +#1248 = AXIS2_PLACEMENT_3D('',#1249,#1250,#1251); +#1249 = CARTESIAN_POINT('',(8.237581109188,-22.,-8.921803528809)); +#1250 = DIRECTION('',(-2.19152081707E-03,0.,-0.999997598615)); +#1251 = DIRECTION('',(-0.999997598615,0.,2.19152081707E-03)); +#1252 = ADVANCED_FACE('',(#1253),#1271,.T.); +#1253 = FACE_BOUND('',#1254,.T.); +#1254 = EDGE_LOOP('',(#1255,#1263,#1269,#1270)); +#1255 = ORIENTED_EDGE('',*,*,#1256,.F.); +#1256 = EDGE_CURVE('',#1257,#1106,#1259,.T.); +#1257 = VERTEX_POINT('',#1258); +#1258 = CARTESIAN_POINT('',(19.029295926024,0.,-17.63009960955)); +#1259 = LINE('',#1260,#1261); +#1260 = CARTESIAN_POINT('',(19.029295926024,-22.,-17.63009960955)); +#1261 = VECTOR('',#1262,1.); +#1262 = DIRECTION('',(0.,1.,0.)); +#1263 = ORIENTED_EDGE('',*,*,#1264,.T.); +#1264 = EDGE_CURVE('',#1257,#1226,#1265,.T.); +#1265 = LINE('',#1266,#1267); +#1266 = CARTESIAN_POINT('',(23.053273013696,0.,-32.18365022821)); +#1267 = VECTOR('',#1268,1.); +#1268 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#1269 = ORIENTED_EDGE('',*,*,#1225,.T.); +#1270 = ORIENTED_EDGE('',*,*,#1105,.F.); +#1271 = PLANE('',#1272); +#1272 = AXIS2_PLACEMENT_3D('',#1273,#1274,#1275); +#1273 = CARTESIAN_POINT('',(17.956031892536,-22.,-13.7484168618)); +#1274 = DIRECTION('',(-0.963836182336,0.,-0.26649542889)); +#1275 = DIRECTION('',(-0.26649542889,0.,0.963836182336)); +#1276 = ADVANCED_FACE('',(#1277),#1295,.T.); +#1277 = FACE_BOUND('',#1278,.T.); +#1278 = EDGE_LOOP('',(#1279,#1287,#1288,#1289)); +#1279 = ORIENTED_EDGE('',*,*,#1280,.T.); +#1280 = EDGE_CURVE('',#1281,#1257,#1283,.T.); +#1281 = VERTEX_POINT('',#1282); +#1282 = CARTESIAN_POINT('',(22.059435554995,0.,-3.734519760785)); +#1283 = LINE('',#1284,#1285); +#1284 = CARTESIAN_POINT('',(12.741012548745,1.7763568394E-15, + -46.46683800444)); +#1285 = VECTOR('',#1286,1.); +#1286 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#1287 = ORIENTED_EDGE('',*,*,#1256,.T.); +#1288 = ORIENTED_EDGE('',*,*,#1113,.F.); +#1289 = ORIENTED_EDGE('',*,*,#1290,.F.); +#1290 = EDGE_CURVE('',#1281,#1114,#1291,.T.); +#1291 = LINE('',#1292,#1293); +#1292 = CARTESIAN_POINT('',(22.059435554995,-22.,-3.734519760785)); +#1293 = VECTOR('',#1294,1.); +#1294 = DIRECTION('',(0.,1.,0.)); +#1295 = PLANE('',#1296); +#1296 = AXIS2_PLACEMENT_3D('',#1297,#1298,#1299); +#1297 = CARTESIAN_POINT('',(20.484588228021,-22.,-10.95643672209)); +#1298 = DIRECTION('',(0.977039526026,0.,-0.213058124893)); +#1299 = DIRECTION('',(-0.213058124893,0.,-0.977039526026)); +#1300 = ADVANCED_FACE('',(#1301),#1319,.T.); +#1301 = FACE_BOUND('',#1302,.T.); +#1302 = EDGE_LOOP('',(#1303,#1311,#1312,#1313)); +#1303 = ORIENTED_EDGE('',*,*,#1304,.T.); +#1304 = EDGE_CURVE('',#1305,#1281,#1307,.T.); +#1305 = VERTEX_POINT('',#1306); +#1306 = CARTESIAN_POINT('',(-21.72552223146,0.,-3.734519760785)); +#1307 = LINE('',#1308,#1309); +#1308 = CARTESIAN_POINT('',(-22.63692080725,0.,-3.734519760785)); +#1309 = VECTOR('',#1310,1.); +#1310 = DIRECTION('',(1.,0.,0.)); +#1311 = ORIENTED_EDGE('',*,*,#1290,.T.); +#1312 = ORIENTED_EDGE('',*,*,#1121,.F.); +#1313 = ORIENTED_EDGE('',*,*,#1314,.F.); +#1314 = EDGE_CURVE('',#1305,#1122,#1315,.T.); +#1315 = LINE('',#1316,#1317); +#1316 = CARTESIAN_POINT('',(-21.72552223146,-22.,-3.734519760785)); +#1317 = VECTOR('',#1318,1.); +#1318 = DIRECTION('',(0.,1.,0.)); +#1319 = PLANE('',#1320); +#1320 = AXIS2_PLACEMENT_3D('',#1321,#1322,#1323); +#1321 = CARTESIAN_POINT('',(0.19111316645,-22.,-3.734519760785)); +#1322 = DIRECTION('',(0.,0.,1.)); +#1323 = DIRECTION('',(0.,-1.,0.)); +#1324 = ADVANCED_FACE('',(#1325),#1343,.T.); +#1325 = FACE_BOUND('',#1326,.T.); +#1326 = EDGE_LOOP('',(#1327,#1335,#1336,#1337)); +#1327 = ORIENTED_EDGE('',*,*,#1328,.T.); +#1328 = EDGE_CURVE('',#1329,#1305,#1331,.T.); +#1329 = VERTEX_POINT('',#1330); +#1330 = CARTESIAN_POINT('',(-21.72552223146,0.,-8.903751135252)); +#1331 = LINE('',#1332,#1333); +#1332 = CARTESIAN_POINT('',(-21.72552223146,0.,-38.64614663299)); +#1333 = VECTOR('',#1334,1.); +#1334 = DIRECTION('',(0.,0.,1.)); +#1335 = ORIENTED_EDGE('',*,*,#1314,.T.); +#1336 = ORIENTED_EDGE('',*,*,#1129,.F.); +#1337 = ORIENTED_EDGE('',*,*,#1338,.F.); +#1338 = EDGE_CURVE('',#1329,#1130,#1339,.T.); +#1339 = LINE('',#1340,#1341); +#1340 = CARTESIAN_POINT('',(-21.72552223146,-22.,-8.903751135252)); +#1341 = VECTOR('',#1342,1.); +#1342 = DIRECTION('',(0.,1.,0.)); +#1343 = PLANE('',#1344); +#1344 = AXIS2_PLACEMENT_3D('',#1345,#1346,#1347); +#1345 = CARTESIAN_POINT('',(-21.72552223146,-22.,-6.319135448019)); +#1346 = DIRECTION('',(-1.,0.,0.)); +#1347 = DIRECTION('',(0.,1.,0.)); +#1348 = ADVANCED_FACE('',(#1349),#1360,.T.); +#1349 = FACE_BOUND('',#1350,.T.); +#1350 = EDGE_LOOP('',(#1351,#1357,#1358,#1359)); +#1351 = ORIENTED_EDGE('',*,*,#1352,.T.); +#1352 = EDGE_CURVE('',#1234,#1329,#1353,.T.); +#1353 = LINE('',#1354,#1355); +#1354 = CARTESIAN_POINT('',(-28.21385794834,0.,-8.903751135252)); +#1355 = VECTOR('',#1356,1.); +#1356 = DIRECTION('',(-1.,0.,0.)); +#1357 = ORIENTED_EDGE('',*,*,#1338,.T.); +#1358 = ORIENTED_EDGE('',*,*,#1137,.F.); +#1359 = ORIENTED_EDGE('',*,*,#1241,.F.); +#1360 = PLANE('',#1361); +#1361 = AXIS2_PLACEMENT_3D('',#1362,#1363,#1364); +#1362 = CARTESIAN_POINT('',(-10.96276111573,-22.,-8.903751135252)); +#1363 = DIRECTION('',(0.,0.,-1.)); +#1364 = DIRECTION('',(0.,1.,0.)); +#1365 = ADVANCED_FACE('',(#1366),#1385,.T.); +#1366 = FACE_BOUND('',#1367,.T.); +#1367 = EDGE_LOOP('',(#1368,#1377,#1383,#1384)); +#1368 = ORIENTED_EDGE('',*,*,#1369,.T.); +#1369 = EDGE_CURVE('',#1370,#1370,#1372,.T.); +#1370 = VERTEX_POINT('',#1371); +#1371 = CARTESIAN_POINT('',(21.163799345768,0.,-48.00951684793)); +#1372 = CIRCLE('',#1373,4.735522705283); +#1373 = AXIS2_PLACEMENT_3D('',#1374,#1375,#1376); +#1374 = CARTESIAN_POINT('',(16.428276640485,0.,-48.00951684793)); +#1375 = DIRECTION('',(-0.,1.,0.)); +#1376 = DIRECTION('',(1.,0.,0.)); +#1377 = ORIENTED_EDGE('',*,*,#1378,.T.); +#1378 = EDGE_CURVE('',#1370,#1146,#1379,.T.); +#1379 = LINE('',#1380,#1381); +#1380 = CARTESIAN_POINT('',(21.163799345768,-22.,-48.00951684793)); +#1381 = VECTOR('',#1382,1.); +#1382 = DIRECTION('',(0.,1.,0.)); +#1383 = ORIENTED_EDGE('',*,*,#1145,.F.); +#1384 = ORIENTED_EDGE('',*,*,#1378,.F.); +#1385 = CYLINDRICAL_SURFACE('',#1386,4.735522705283); +#1386 = AXIS2_PLACEMENT_3D('',#1387,#1388,#1389); +#1387 = CARTESIAN_POINT('',(16.428276640485,-22.,-48.00951684793)); +#1388 = DIRECTION('',(0.,1.,0.)); +#1389 = DIRECTION('',(1.,0.,0.)); +#1390 = ADVANCED_FACE('',(#1391),#1416,.F.); +#1391 = FACE_BOUND('',#1392,.F.); +#1392 = EDGE_LOOP('',(#1393,#1403,#1409,#1410)); +#1393 = ORIENTED_EDGE('',*,*,#1394,.T.); +#1394 = EDGE_CURVE('',#1395,#1397,#1399,.T.); +#1395 = VERTEX_POINT('',#1396); +#1396 = CARTESIAN_POINT('',(1.480297366167E-15,11.242400581089, + -46.71869179633)); +#1397 = VERTEX_POINT('',#1398); +#1398 = CARTESIAN_POINT('',(3.256654205567E-15,17.8572529153, + -18.39898295202)); +#1399 = LINE('',#1400,#1401); +#1400 = CARTESIAN_POINT('',(2.6645352591E-15,18.133069549222, + -17.21814831317)); +#1401 = VECTOR('',#1402,1.); +#1402 = DIRECTION('',(5.275122655165E-17,0.227455280238,0.97378852709)); +#1403 = ORIENTED_EDGE('',*,*,#1404,.F.); +#1404 = EDGE_CURVE('',#1175,#1397,#1405,.T.); +#1405 = LINE('',#1406,#1407); +#1406 = CARTESIAN_POINT('',(5.649679875255,3.602918464526, + -13.21082950266)); +#1407 = VECTOR('',#1408,1.); +#1408 = DIRECTION('',(-0.349023821871,0.880598971639,-0.320511814002)); +#1409 = ORIENTED_EDGE('',*,*,#1174,.T.); +#1410 = ORIENTED_EDGE('',*,*,#1411,.T.); +#1411 = EDGE_CURVE('',#1177,#1395,#1412,.T.); +#1412 = LINE('',#1413,#1414); +#1413 = CARTESIAN_POINT('',(1.103762571829,7.940067898719, + -47.92064259636)); +#1414 = VECTOR('',#1415,1.); +#1415 = DIRECTION('',(-0.299648208284,0.896513522642,0.326304236859)); +#1416 = PLANE('',#1417); +#1417 = AXIS2_PLACEMENT_3D('',#1418,#1419,#1420); +#1418 = CARTESIAN_POINT('',(5.823691883056,3.068404028665, + -13.45978839622)); +#1419 = DIRECTION('',(0.93629059846,0.342020143326,-7.988827695448E-02) + ); +#1420 = DIRECTION('',(-0.340781908463,0.939692620786,2.907695487825E-02) + ); +#1421 = ADVANCED_FACE('',(#1422),#1432,.F.); +#1422 = FACE_BOUND('',#1423,.F.); +#1423 = EDGE_LOOP('',(#1424,#1425,#1431)); +#1424 = ORIENTED_EDGE('',*,*,#1404,.T.); +#1425 = ORIENTED_EDGE('',*,*,#1426,.T.); +#1426 = EDGE_CURVE('',#1397,#1185,#1427,.T.); +#1427 = LINE('',#1428,#1429); +#1428 = CARTESIAN_POINT('',(-5.275833888477,4.546144602338, + -13.55413574101)); +#1429 = VECTOR('',#1430,1.); +#1430 = DIRECTION('',(-0.349023821871,-0.880598971639,0.320511814002)); +#1431 = ORIENTED_EDGE('',*,*,#1184,.T.); +#1432 = PLANE('',#1433); +#1433 = AXIS2_PLACEMENT_3D('',#1434,#1435,#1436); +#1434 = CARTESIAN_POINT('',(2.828438300639,3.068404028665, + -13.01628215822)); +#1435 = DIRECTION('',(2.88163763217E-16,0.342020143326,0.939692620786)); +#1436 = DIRECTION('',(-1.048830324052E-16,0.939692620786,-0.342020143326 + )); +#1437 = ADVANCED_FACE('',(#1438),#1449,.F.); +#1438 = FACE_BOUND('',#1439,.F.); +#1439 = EDGE_LOOP('',(#1440,#1441,#1442,#1443)); +#1440 = ORIENTED_EDGE('',*,*,#1192,.T.); +#1441 = ORIENTED_EDGE('',*,*,#1426,.F.); +#1442 = ORIENTED_EDGE('',*,*,#1394,.F.); +#1443 = ORIENTED_EDGE('',*,*,#1444,.F.); +#1444 = EDGE_CURVE('',#1193,#1395,#1445,.T.); +#1445 = LINE('',#1446,#1447); +#1446 = CARTESIAN_POINT('',(-0.871390517001,8.635298785064, + -47.66759924779)); +#1447 = VECTOR('',#1448,1.); +#1448 = DIRECTION('',(0.299648208284,0.896513522642,0.326304236859)); +#1449 = PLANE('',#1450); +#1450 = AXIS2_PLACEMENT_3D('',#1451,#1452,#1453); +#1451 = CARTESIAN_POINT('',(-5.782806207227,3.068404028665, + -13.93896851312)); +#1452 = DIRECTION('',(-0.93629059846,0.342020143326,-7.988827695448E-02) + ); +#1453 = DIRECTION('',(0.340781908463,0.939692620786,2.907695487825E-02) + ); +#1454 = ADVANCED_FACE('',(#1455),#1460,.F.); +#1455 = FACE_BOUND('',#1456,.F.); +#1456 = EDGE_LOOP('',(#1457,#1458,#1459)); +#1457 = ORIENTED_EDGE('',*,*,#1444,.T.); +#1458 = ORIENTED_EDGE('',*,*,#1411,.F.); +#1459 = ORIENTED_EDGE('',*,*,#1200,.T.); +#1460 = PLANE('',#1461); +#1461 = AXIS2_PLACEMENT_3D('',#1462,#1463,#1464); +#1462 = CARTESIAN_POINT('',(2.828438300639,3.068404028665, + -49.69378323641)); +#1463 = DIRECTION('',(0.,0.342020143326,-0.939692620786)); +#1464 = DIRECTION('',(0.,0.939692620786,0.342020143326)); +#1465 = ADVANCED_FACE('',(#1466),#1485,.T.); +#1466 = FACE_BOUND('',#1467,.T.); +#1467 = EDGE_LOOP('',(#1468,#1477,#1483,#1484)); +#1468 = ORIENTED_EDGE('',*,*,#1469,.T.); +#1469 = EDGE_CURVE('',#1470,#1470,#1472,.T.); +#1470 = VERTEX_POINT('',#1471); +#1471 = CARTESIAN_POINT('',(-11.6927539352,0.,-48.00951684793)); +#1472 = CIRCLE('',#1473,4.735522705283); +#1473 = AXIS2_PLACEMENT_3D('',#1474,#1475,#1476); +#1474 = CARTESIAN_POINT('',(-16.42827664048,0.,-48.00951684793)); +#1475 = DIRECTION('',(-0.,1.,0.)); +#1476 = DIRECTION('',(1.,0.,0.)); +#1477 = ORIENTED_EDGE('',*,*,#1478,.T.); +#1478 = EDGE_CURVE('',#1470,#1209,#1479,.T.); +#1479 = LINE('',#1480,#1481); +#1480 = CARTESIAN_POINT('',(-11.6927539352,-22.,-48.00951684793)); +#1481 = VECTOR('',#1482,1.); +#1482 = DIRECTION('',(0.,1.,0.)); +#1483 = ORIENTED_EDGE('',*,*,#1208,.F.); +#1484 = ORIENTED_EDGE('',*,*,#1478,.F.); +#1485 = CYLINDRICAL_SURFACE('',#1486,4.735522705283); +#1486 = AXIS2_PLACEMENT_3D('',#1487,#1488,#1489); +#1487 = CARTESIAN_POINT('',(-16.42827664048,-22.,-48.00951684793)); +#1488 = DIRECTION('',(0.,1.,0.)); +#1489 = DIRECTION('',(1.,0.,0.)); +#1490 = ADVANCED_FACE('',(#1491),#1499,.F.); +#1491 = FACE_BOUND('',#1492,.F.); +#1492 = EDGE_LOOP('',(#1493,#1494,#1495,#1496,#1497,#1498)); +#1493 = ORIENTED_EDGE('',*,*,#1264,.T.); +#1494 = ORIENTED_EDGE('',*,*,#1233,.T.); +#1495 = ORIENTED_EDGE('',*,*,#1352,.T.); +#1496 = ORIENTED_EDGE('',*,*,#1328,.T.); +#1497 = ORIENTED_EDGE('',*,*,#1304,.T.); +#1498 = ORIENTED_EDGE('',*,*,#1280,.T.); +#1499 = PLANE('',#1500); +#1500 = AXIS2_PLACEMENT_3D('',#1501,#1502,#1503); +#1501 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#1502 = DIRECTION('',(0.,1.,0.)); +#1503 = DIRECTION('',(0.,0.,1.)); +#1504 = ADVANCED_FACE('',(#1505),#1508,.F.); +#1505 = FACE_BOUND('',#1506,.F.); +#1506 = EDGE_LOOP('',(#1507)); +#1507 = ORIENTED_EDGE('',*,*,#1369,.T.); +#1508 = PLANE('',#1509); +#1509 = AXIS2_PLACEMENT_3D('',#1510,#1511,#1512); +#1510 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#1511 = DIRECTION('',(0.,1.,0.)); +#1512 = DIRECTION('',(0.,0.,1.)); +#1513 = ADVANCED_FACE('',(#1514),#1517,.F.); +#1514 = FACE_BOUND('',#1515,.F.); +#1515 = EDGE_LOOP('',(#1516)); +#1516 = ORIENTED_EDGE('',*,*,#1469,.T.); +#1517 = PLANE('',#1518); +#1518 = AXIS2_PLACEMENT_3D('',#1519,#1520,#1521); +#1519 = CARTESIAN_POINT('',(-45.46495478095,0.,-70.97315781796)); +#1520 = DIRECTION('',(0.,1.,0.)); +#1521 = DIRECTION('',(0.,0.,1.)); +#1522 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#1526)) +GLOBAL_UNIT_ASSIGNED_CONTEXT((#1523,#1524,#1525)) REPRESENTATION_CONTEXT +('Context #1','3D Context with UNIT and UNCERTAINTY') ); +#1523 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#1524 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#1525 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#1526 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(2.E-06),#1523, + 'distance_accuracy_value','confusion accuracy'); +#1527 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed_wall.stl b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed_wall.stl new file mode 100644 index 0000000000..5c1f741349 Binary files /dev/null and b/docs/CAD/design/mate-connectors/BearConnector_Female_Trimmed_wall.stl differ diff --git a/docs/CAD/design/mate-connectors/DESIGN_MATE_CONNECTORS.md b/docs/CAD/design/mate-connectors/DESIGN_MATE_CONNECTORS.md new file mode 100644 index 0000000000..fe1a7a5fee --- /dev/null +++ b/docs/CAD/design/mate-connectors/DESIGN_MATE_CONNECTORS.md @@ -0,0 +1,922 @@ +# Mate connectors: aligning with the mainstream CAD systems + +Research date: 2026-08-05. Written against `orca_cad` / `Snapmaker` at the M8 state +(`CadDocument.{hpp,cpp}`, `apply_mate`, `datum_frame`, the `Mate` card in `DesignPanel.cpp`). + +**Brief:** align with the mate-connector concept as the main CAD programs actually implement it, +and be simple, unequivocal, unconfusing. Alignment is the organising principle of this document: +every recommendation is labelled either **[INDUSTRY]** — do what they all do — or **[DEVIATION]** — +we would be departing, here is why and what it costs. + +--- + +## 0. The answer in ten lines + +1. Seven systems surveyed. **Five of the seven use the same model**; two are the old world. +2. The model: a joint is defined between **two local coordinate frames**, one rigidly attached to + each part, plus **one type** naming which DOF stay free. +3. The frame is called a mate connector (Onshape), a **joint origin** (Fusion, Inventor), a joint + connector (FreeCAD 1.0). Same object, three names. +4. **Every one of them expresses every DOF about the frame's Z axis.** One axis, one convention. +5. **Five types appear in every frame-based system with identical names and identical DOF**: + Fastened/Rigid, Revolute, Slider, Cylindrical, Planar. Ball is in four of five. +6. That is not fashion — those are the classical **lower kinematic pairs**. The vocabulary converged + because the mechanics converged. +7. Our kernel is already on the right side of the line: frame-based, five types, Z-relative, + superimpose-then-relax. **The architecture needs no revisiting.** +8. Where we are out of step: connectors that are not attached to a body; an origin that can only be + a face centroid; no live preview of the two Z arrows; a mate card of abstract dropdowns. +9. Where we would knowingly deviate: refusing a second mate per body (no vendor does this — it is + forced on us by having no solver) and possibly inverting the default mate direction. +10. Biggest single win for the stated goal, and it costs no kernel work: **draw both frames and + ghost the result before Confirm.** The convention stops needing to be remembered. + +--- + +## 1. The two families + +**Constraint-based ("old CAD").** The user states pairwise *geometric relations* between raw +topology — this face coincident with that face, this axis concentric with that axis, this plane +parallel at 12 mm. Each relation removes some DOF; a numerical solver satisfies all of them at once. +Fully positioning one part typically takes **three or more mates**, and the set can be +over-constrained, under-constrained, or satisfiable in several configurations. + +**Frame-based ("mate connectors").** The user places a *local coordinate system* on each part and +states **one** relation between the two frames. The relation is not "these surfaces touch" but +"these frames coincide, except for the following DOF, which stay free." + +Onshape's help page opens by drawing exactly this line: + +> *"Mates in Onshape are different than mates in old CAD systems. Many assemblies require only one +> Onshape Mate between any two instances, as the movement (degrees of freedom) between those two +> instances is embedded in the Mate."* + +The frame-based model won for three reasons, all of which matter here: + +- **One mate per pair.** No mental arithmetic about which three constraints add up to a hinge. +- **The DOF are declared, not deduced.** A revolute mate *is* one rotation. You do not discover the + remaining freedom by dragging. +- **It needs no simultaneous solver for the common case.** Frame-to-frame alignment is a matrix + composition — precisely what `apply_mate` already does. + +> **Caveat — several vendors ship both, and "align with X" is therefore ambiguous.** **Inventor** +> kept its legacy constraints *and* added frame-based Joints in 2012; many Inventor users still build +> assemblies entirely with the old constraint stack. **Creo** has placement constraints *and* +> Mechanism connections. **FreeCAD** had constraint-based Assembly2/3 add-ons before the frame-based +> Assembly workbench shipped in 1.0. So copying "what Inventor does" means copying **one of two +> coexisting workflows**. **Onshape and Fusion 360 are the only pure frame-based examples**, and they +> are the ones to weight most heavily when the evidence conflicts. + +--- + +## 2. Field survey — seven systems + +| | Onshape | Fusion 360 | Inventor | FreeCAD 1.0 | Creo | Siemens NX | SOLIDWORKS | +|---|---|---|---|---|---|---|---| +| **Family** | Frame | Frame | Frame (+ legacy constraints) | Frame (+ legacy add-ons) | Both | Constraint | Constraint | +| **Frame object** | Mate connector | Joint origin | Joint origin | Joint connector (`Placement1/2`) | CSYS on `Weld`/`6DOF` | — | — (nearest: **mate reference**) | +| **Where it lives** | Part Studio **and** Assembly; in the feature list | Component, inside the joint | Component / inside the joint | Inside the Joint object | Part | — | Part (up to 3 named entities) | +| **Origin placement** | Inferred family on hover; `Shift` locks | Discrete **snap points**; `Ctrl` cycles | Snap points + explicit origins | Inferred, previewed on hover | Picked CSYS | Picked entities | Picked entities | +| **Orientation control** | Primary axis (Z) + secondary axis; flip + 90° reorient | Flip, angle, offsets | Flip, angle, offsets | `Placement1/2` + `Offset1/2` | CSYS + offset | — | — | +| **Type inference** | No — explicit | No — explicit | **Yes — "Automatic"** from picked geometry | No | No | No | Partial (mate reference type) | +| **Solver** | Yes, simultaneous — *"order won't affect a Mate"* | Yes | Yes | Yes (Ondsel) | Yes | Yes | Yes | +| **Reuse across instances** | **Yes** — a Part Studio connector exists on every instance | Weak | Partial | Per-joint | Interfaces | Product Interface | Mate references auto-mate on insert | + +Three observations that shape everything below. + +- **Every frame-based system reduced the type list by an order of magnitude** relative to SOLIDWORKS + (7–13 vs ~25) and lost nothing. That is not simplification-by-omission; it is what happens when the + DOF live in the mate instead of being assembled from constraints. +- **Every one of them defines its types relative to a single axis.** Slider translates along Z, + Revolute rotates about Z, Cylindrical does both, Planar translates in X/Y and rotates about Z. + One axis carries the whole vocabulary. +- **Onshape alone treats the connector as a first-class, reusable, named object** — and that is also + where its worst usability complaints come from (§4). + +--- + +## 3. The type vocabulary — cross-system table + +DOF = degrees of freedom left **free**, stated about/along the connector Z. + +| DOF | Onshape | Fusion 360 | Inventor | FreeCAD 1.0 | Creo | **Ours today** | +|---|---|---|---|---|---|---| +| 0 | Fastened | Rigid | Rigid | Fixed | Rigid / Weld | **Fastened** ✅ | +| 1 — rot Z | Revolute | Revolute | Rotational | Revolute | Pin | **Revolute** ✅ | +| 1 — trans Z | Slider | Slider | Slider | Slider | Slider | **Slider** ✅ | +| 2 — rot + trans Z | Cylindrical | Cylindrical | Cylindrical | Cylindrical | Cylinder | **Cylindrical** ✅ | +| 3 — trans XY + rot Z | Planar | Planar | Planar | *(Parallel+Distance)* | Planar | **Planar** ✅ | +| 3 — rot XYZ | Ball | Ball | Ball | Ball | Ball | — | +| 2 — different axes | Pin slot | Pin-Slot | — | — | Slot / Bearing | — | +| 1 — coupled | Screw | — | — | Screw | — | — | +| 4 | Parallel | — | — | Parallel | — | — | +| other | Tangent, Width, Group | As-built | Automatic | Perpendicular, Angle, Distance, Gears, Belt, RackPinion | General, 6DOF | — | + +**Five types appear in every frame-based system, with the same name and the same DOF.** Those five +are the industry's common denominator, and they are exactly `mate_kind` 0–4 as already implemented. +Ball is in four of five. Everything past that is a long tail no two vendors agree on. + +### Why the convergence is a fact, not a fashion + +A rigid-body placement is an element of SE(3). A mate leaves some set of relative motions free. For +the mate to behave the same throughout its range — for a hinge to be a hinge at every angle — that +free set must be **closed under composition**: two allowed motions must compose to an allowed motion. +A closed set of motions is a **subgroup** of SE(3). + +The subgroups corresponding to physical surface-on-surface contact are the classical **six lower +pairs** (Reuleaux): + +| Pair | Free motion relative to Z | DOF | +|---|---|---| +| Revolute (R) | rotation about Z | 1 | +| Prismatic / slider (P) | translation along Z | 1 | +| Helical / screw (H) | coupled rotation + translation | 1 | +| Cylindrical (C) | rotation about **and** translation along Z | 2 | +| Planar (E/G) | translation in X,Y + rotation about Z | 3 | +| Spherical / ball (S) | rotation about X, Y, Z | 3 | + +Plus the two trivial ends: identity (0 DOF — **fastened**) and all of SE(3) (6 DOF — floating, i.e. +no mate). Hervé's Lie-subgroup analysis of the displacement group is the standard reference for +treating these as the algebraic building blocks of mechanism synthesis. + +**Consequence.** Anything outside this table is either (a) a *composition* needing a solver, or +(b) not a joint at all but a *measurement*: + +- Onshape's **Parallel** (4 DOF), **Tangent**, **Width**, **Pin slot**, and FreeCAD's **Distance / + Angle / Perpendicular** are constraints, not pairs — their free set is not a subgroup, so they only + make sense alongside a simultaneous solver. +- **Gear, Belt, Rack-and-pinion** are *relations between two mates*, a different object entirely. +- **Screw (H)** is a legitimate lower pair but needs a pitch parameter and is rare in printed parts. + +So the vendors' shared five, the lower pairs, and our `mate_kind` 0–4 are the same list arrived at +three ways. **[INDUSTRY] Stop looking for missing types and spend the budget on the connector.** + +--- + +## 4. What they all agree on — adopt verbatim + +Deviating from any of these makes an experienced user's intuition *wrong*, which is the operational +definition of "confusing". + +**A1 [INDUSTRY] — The connector is a full right-handed frame.** +Origin + Z (primary) + X (secondary). Onshape and Fusion expose exactly these two axis controls and +nothing else. A point cannot express spin; an axis cannot express clocking. +*Status: we comply* — `DatumCoordSys` carries origin/x/y and derives Z. + +**A2 [INDUSTRY] — Z is the joint axis; every DOF is about or along Z.** +Revolute rotates about Z. Slider translates along Z. Planar's free plane is normal to Z. Offsets run +along Z. This single rule is what makes the system learnable: **one axis to look at, and its meaning +never changes.** +*Status: we comply* — `mate_offset` along A's z, `mate_angle` about A's z. + +**A3 [INDUSTRY] — Mating superimposes the two frames; the type then relaxes specific DOF.** +FreeCAD states it most plainly: *"the second connector is superimposed on the first connector by +default and may change its position according to the joint type."* Fastened is not a special case — +it is the base case with nothing relaxed. +*Status: we comply* — `T = M_A · Rz · Tz · F · M_B⁻¹`, looser kinds relaxing from there. + +**A4 [INDUSTRY] — The connector belongs to a part and moves with it.** +Onshape: a connector defined in a Part Studio *"is available for reuse on every instance of that part +in every assembly in which it is instanced."* It is part geometry, not assembly geometry. +*Status: **violated**.* `CoordSysType::PointWorld` is a bare world XYZ with `X = world X` and no +`coordsys_body`. Such a connector does not follow its part. See §6 G1. + +**A5 [INDUSTRY] — Selection order is meaningful and must be visible.** +One connector is the reference; the other is driven onto it. Onshape spells out that offsets are +measured *"from the second Mate connector selected to the first"*, and that reversing the order +flips the sign. +*Status: complied with in the data model* (`mate_cs_a` fixed, `mate_cs_b` moves) *but not in the UI* — +two dropdowns labelled A and B do not tell the user which part is about to jump. + +**A6 [INDUSTRY] — Flip and re-clock live in the mate dialog, always.** +Onshape: *"Click the arrow icon to flip the direction of the primary axis. Click the Reorient +secondary axis icon to rotate the secondary axis in 90-degree increments."* +*Status: partial.* We have `mate_flip` (Z reversal). We have `mate_angle` as a free number — strictly +more powerful than 90° steps, and much worse to *use*: the common case is "it came in a quarter turn +out", and typing 90 is a worse gesture than pressing a button. + +**A7 [INDUSTRY] — DOF are shown, not inferred by the user.** +Onshape animates each mate's remaining DOF on demand; Fusion and Inventor name the DOF in the type +list. Our dropdown text already does this in words ("free spin + axial slide"). Keep it. + +**A8 [INDUSTRY] — Free DOF are preserved from the current placement, not zeroed.** +Onshape: a Planar mate aligns the frames *"but they are not restricted to this location with respect +to their degrees of freedom."* +*Status: we comply* — and it must be *said*, because a Planar mate that leaves the part where it was +looks like a mate that did nothing. + +--- + +## 5. Where they diverge — who to copy, and why + +### D1 — Where the connector's origin comes from + +| | Behaviour | +|---|---| +| **Fusion 360** | Discrete **snap points** only: vertex, edge midpoint, face centre, arc centre. `Ctrl` cycles the candidates under the cursor. A circle icon denotes a vertex, a triangle a midpoint. "Between two faces" is a separate explicit option. | +| **Onshape** | Infers a *family* on hover — centroid, every vertex, every edge midpoint, every arc centre, the centroids of interior regions (holes, slots), and the virtual sharps of conical faces. `Shift` locks the current candidate. | +| **Inventor** | Snap points, plus explicit joint origins for awkward cases. | +| **FreeCAD 1.0** | Hovering previews where the connector will land before you commit. | +| **Ours** | Always the **face centroid**. No alternative exists. | + +Onshape's richness has a cost its own documentation admits: *"The suggested locations are based on +the underlying geometry of the part and changing the geometry will change the location of the Mate. +This can be undesirable in certain situations."* On the forum this shows up as connectors that move +or break on edit — the classic topological-naming failure. Fusion's discrete set is poorer and far +more predictable. + +> **[INDUSTRY] Copy Fusion's candidate *set*.** A small, closed, enumerable set — **face centroid, +> vertex, edge midpoint, arc/circle centre** — each drawn before commit, with the card naming which is +> in use ("Origin: edge midpoint"). This is our largest expressiveness gap: a face centroid alone +> cannot place a hinge pin on a corner boss. It is also the one place where copying the *simpler* +> vendor is clearly right. +> +> **Open sub-choice — how the candidate is chosen.** Three options, in increasing order of magic: +> (1) **explicit dropdown** in the card after picking the face — no hover behaviour at all; +> (2) **Fusion's `Ctrl` cycling** through candidates under the cursor; (3) **Onshape's hover +> inference**. Kimi's independent review argued for (1) on the grounds that hover is exactly where +> both vendors' instability complaints originate, and that a dropdown gets ~90% of the expressiveness +> with none of the hover-guess debugging. That is a fair reading and (1) is the cheapest to build and +> the easiest to make unequivocal. **Recommendation: build (1) first; if hover is added later, let it +> *pre-fill the dropdown* rather than silently create an implicit connector** — which also keeps R2 +> (one kind of connector) intact. + +### D2 — Explicit type, or inferred from the geometry? + +Inventor is the only surveyed system that infers: *"Rotational is selected if the two selected +origins are circular. Cylindrical if the two selected origins are points on a cylinder. Ball if +points on a sphere. Rigid for all other origin selections."* Onshape and Fusion require an explicit +choice. + +> **[INDUSTRY, Inventor] Do both, in Inventor's order.** Infer a *default* type from what was picked, +> then show it in an editable control. Inference is what makes the tool feel like it understands the +> geometry; the visible, editable result is what keeps it unequivocal. Pure inference with no visible +> type is the confusing option; a pure dropdown with no default is the tedious one. This also fits +> the Design tab's geometry-first charter exactly: point at a bore, get Revolute offered. + +### D3 — How the Z-direction ambiguity is resolved + +This is the specific failure the brief is aimed at. A former IT trainer stated it precisely on the +Onshape forum: + +> *"There is always the risk that users will build their own conceptual models of how software works +> which may not match the designer's concept. The result is usually a poor user experience and many +> mistakes… for a good (say) Fixed mate to occur do the Z axes of the two mates have to be pointing +> in the same direction… Alternatively, should they be facing each other?"* + +He is asking the right question and **no vendor's documentation answers it.** Onshape's own advice — +*"if the behavior is not what you expected, try flipping the primary and/or secondary axis"* — is +trial and error. This is a gap in the industry, not a convention to copy. + +> **[INDUSTRY, method] Resolve it with live preview, not documentation.** FreeCAD previews the +> connector on hover; Onshape and Fusion both draw the frames. Draw **both** Z arrows the moment the +> second connector is picked, and ghost the resulting placement *before* Confirm. The convention then +> never has to be remembered because it is on screen. +> +> **[DEVIATION, optional] Name the two cases in the user's words** rather than in axis-speak: +> "the two faces come together" vs "the axes run the same way". No surveyed vendor does this — they +> all ship a flip arrow. It is a small, low-risk improvement on the state of the art, and it is +> separable from the default-direction question in §8 D1. + +### D4 — Named, reusable connectors on the part + +Onshape: connectors created in the Part Studio are reused on every instance in every assembly. +SOLIDWORKS' **mate reference** reaches the same end by another route: up to three named entities +(primary/secondary/tertiary) baked into the part so it auto-mates on drag-and-drop — and a *named* +mate reference seeks out a matching name on insertion. That naming trick is how a library of +fasteners assembles itself. + +> **[INDUSTRY] Out of scope now, but do not preclude it.** Give connectors a stable, user-visible +> name at creation. One string today; expensive to add once documents exist in the wild. + +--- + +## 6. Confusion catalogue + +Documented ways real implementations confuse people. Each is a requirement in disguise. + +**C1 — Which way does Z point?** See D3. If a user has to ask once, they will mis-predict a hundred +times. + +**C2 — The roll is unspecified.** Aligning Z leaves one rotation about Z undetermined. Something must +pin it, and if that something is world-derived, the frame does not rotate with its part. **This +codebase shipped exactly this bug** (`en4`): a face-only connector took Z from the face +normal but X from `coordsys_x_hint`, a world constant, so Fastened and Slider claimed to lock an +orientation the frame could not see. Fixed 2026-07-26 by deriving X from the face's own first usable +edge — but note the fix's own caveat: *"replaying an older document whose face-only connector fed a +mate can now place that body differently."* Roll conventions are load-bearing, and changing one is a +document-format change. + +**C3 — The origin drifts.** See D1. + +**C4 — Implicit and explicit connectors are not the same thing.** On the Onshape forum, implicit +connectors are reported to change their query structure when a feature is edited and re-accepted, and +are unusable in places explicit ones work. Two things called by one name that behave differently is a +permanent tax. + +**C5 — Which part moves?** A frame alignment is asymmetric. If the UI does not say which frame is +driven, the user finds out by watching the wrong part jump. + +**C6 — Which direction is a positive offset?** Onshape measures *"from the second Mate connector +selected to the first"* — the sign depends on pick order, and swapping the picks flips it. Documented +behaviour, documented surprise. + +**C7 — One intent, several mates.** The SOLIDWORKS failure: expressing "this shaft is in this hole, +resting on this shoulder" as three constraints, then discovering the solver picked the mirror +configuration. Frame-based systems fix this by construction; the requirement is not to reintroduce it. + +**C8 — Degenerate frames.** A circular face has no usable in-plane edge direction; a cylinder seam +projects to nothing; a picked edge parallel to Z gives a zero cross product. `datum_frame` handles all +three with fallbacks — the requirement is that a fallback be *visible*, because a silent fallback is +C2 wearing a different hat. + +**C9 — Order dependence without a solver.** Onshape can say *"Onshape solves Mates simultaneously so +order won't affect a Mate."* A system that composes transforms in tree order cannot say that. Two +mates driving one body means the second wins and the first is a lie on screen. + +**C10 — Mirrors and patterns.** A mirrored instance has a left-handed frame. Blindly mirroring a +connector gives a frame whose Z still points "out" but whose handedness flipped, so every rotation +runs backwards. Cheap to handle now, miserable to retrofit. + +--- + +## 7. Requirements + +Labelled **[INDUSTRY]** (what the frame-based systems do) or **[DEVIATION]** (we would depart). + +### Definition + +**R1 [INDUSTRY] — A mate connector is a frame attached to exactly one body.** No body, no connector. +*Test:* creating a connector without a body is rejected at creation, not at mate time. +→ **`CoordSysType::PointWorld` violates this.** It is a datum wearing a connector's name. + +**R2 [INDUSTRY] — One kind of connector, not two.** No "implicit" connector that behaves differently +from an explicit one. If hover inference is offered, hovering *creates* an ordinary connector. +*Why:* C4. *Test:* everything that accepts a connector accepts any connector. + +**R3 [INDUSTRY] — A mate names exactly one subgroup of free motion.** Fastened (0), Revolute (1), +Slider (1), Cylindrical (2), Planar (3), optionally Ball (3). *Why:* §3. *Test:* every type's free +set is closed; no type is "A and also B". + +### Orientation + +**R4 [INDUSTRY] — Everything is about Z. Say so once, in the UI.** *Test:* no mate parameter refers +to any other axis. + +**R5 [DEVIATION] — Z is the outward material direction, and mates default to FACING.** +A mate would drive B's Z onto **−A's Z** by default, so picking two faces that should touch makes +them touch with no options changed. *Why:* it is the whole of C1. +**Cost and caveat:** this inverts today's default (`mate_flip=false` currently *aligns*), and I could +not establish from any vendor's documentation what their default actually is — the forum question in +D3 went unanswered precisely because it is undocumented. So this is marked a deviation on the honest +grounds that **I cannot prove the industry agrees with it.** If D3's live preview lands first, the +default matters much less, because the user sees the outcome before committing. See §9 D1. + +**R6 [DEVIATION] — Name the two directions; do not ship a boolean called "flip".** +`Direction: Facing | Aligned`. Every surveyed vendor ships a flip arrow instead. A boolean requires +remembering what unticked means; two named values do not. Low risk, small improvement on the state of +the art. + +**R7 [INDUSTRY] — Roll is picked, or a stored quarter turn. Never world-derived.** +X from a referenced edge or in-plane direction; failing that, a deterministic body-attached seed, with +**Rotate 90°** offered as a stored integer 0–3 on top (this is Onshape's "reorient secondary axis", +A6). *Why:* C2 and the world-constant bug this project already shipped. *Test:* rotate the parent +body by any angle; the connector's X rotates with it — *this test already exists* ("a face-only frame +rotates with its body"). + +**R8 [INDUSTRY] — A degenerate roll is reported, not absorbed.** *Test:* a connector on a full +cylindrical face reports "roll undefined — pick a direction" rather than silently taking a fallback. + +### Placement + +**R9 [INDUSTRY, Fusion] — Origin comes from a small closed set of named candidates.** +**Face centroid, arc/circle centre, edge midpoint, vertex.** Four. Each stored as +`(kind, topological reference)` and resolved at rebuild. *Why:* D1. *Test:* the stored kind is visible +in the card; a rebuild either resolves it or raises an error. + +**R10 [INDUSTRY] — An unresolvable reference is an error, never a silent relocation.** +*Test:* delete the referenced face; the mate reports "connector A: face not found" and the body stays +where it was. + +### Semantics without a solver + +**R11 [DEVIATION] — A body is driven by at most one mate. The second is refused.** +**No surveyed system does this** — they all have solvers and all accept many mates per body. It is +forced on us by tree-order composition: a second mate on the same body silently overrides the first +and the screen shows a configuration satisfying only one stated intent (C9). *Test:* creating a +second mate whose moving body already has one is rejected, naming the existing mate. +This is the single largest departure in this document. See §9 D4. + +> **A tempting misreading, checked and rejected.** It is easy to find the claim that Onshape mandates +> *"exactly one Mate between any two instances"*, which would make R11 an industry agreement rather +> than a deviation. **The Onshape page does not say that.** It says *"**Many assemblies require only** +> one Onshape Mate between any two instances"* and then lists, as an explicit remedy, *"**Use more +> than one Mate if necessary.**"* One mate per pair is Onshape's *typical case*, not its rule. R11 +> remains a deviation and must be justified on our own architecture, not on theirs. + +**R11a [DEVIATION] — The refusal list.** With no solver, these are unsupportable and must be refused +rather than half-done: a second mate on an already-driven body; cycles (A→B, B→A); closed loops +(A→B, A→C, B→C); relations *between* mates (gear, belt, rack-and-pinion, screw coupling); **joint +limits**, which nothing can enforce without a solver; and **dragging a body to exercise a free DOF**, +which requires keeping the body on the allowed manifold. Motion analysis and animation follow from the +same lack. *Requirement:* none of these may appear in the UI as something that half-works. + +**R12 [DEVIATION] — The mate graph is an acyclic forest rooted at fixed bodies.** A body reached by +no mate is fixed; cycles are refused. Same root cause as R11. *Test:* A→B, B→A rejected at creation. + +**R13 [INDUSTRY] — Free DOF are preserved from the current placement, and the user is told.** +Behaviour already matches Onshape (A8); the telling does not. *Test:* the card for any type with +DOF > 0 says which motions remain and that dragging exercises them. + +**R14 [INDUSTRY] — State what mirroring does to a connector.** +*Checked in the code:* `datum_frame` ends with a Gram-Schmidt forcing a right-handed frame +(`ds.x = Y.cross(Z)`), so a connector resolved on a mirrored body comes out **right-handed, not +mirror-imaged**. Z follows the mirrored face's outward normal, X follows a mirrored edge, handedness +is re-imposed. Defensible — a mate on the mirrored part still turns the way its type says — but it +means a mirrored sub-assembly is *not* the mirror image of the original in its rotation sense. +*Requirement:* document it and pin it with a test. *Why:* C10. + +### Feedback — the part that actually removes confusion + +**R15 [INDUSTRY] — Before Confirm, the card answers four questions in words.** Which body moves; +which way Z points on each connector; how many DOF remain; what the offset is measured from. + +**R16 [INDUSTRY] — Draw both frames live, with Z distinguishable, and ghost the result.** +Two triads with Z rendered differently from X/Y (length, arrowhead, colour). *Why:* D3 — the fastest +way to make a convention unequivocal is to show it. *Test:* both Z directions are readable in a +screenshot. + +**R17 [INDUSTRY] — Show the DOF budget per body.** "Body 2: 1 of 6 DOF free (rotation about Z)." +The most educational readout in any assembly system, and free to compute here — the type *is* the DOF +count. *Test:* the number changes when the type changes. + +**R18 [DEVIATION] — Refuse loudly and name the alternative.** Where something is out of scope (a +second mate, a tangency, a gear ratio), say what is unsupported and what to do instead. Vendors do not +need this because their solvers accept the input. *Test:* no refusal message ends without a suggested +next action. + +--- + +## 8. Minimal specification, and gap analysis + +### The connector + +``` +MateConnector + body int required, ≥ 0 (R1) + origin_kind enum FaceCentroid | ArcCentre | EdgeMidpoint | Vertex (R9) + origin_ref topo ref face / edge / vertex index on that body + z_source implied by origin_kind: face normal, arc axis, edge tangent + roll_ref topo ref optional in-plane edge; else deterministic seed (R7) + roll_quarters int 0..3 stored quarter turns on top of the seed (R7, A6) + flip_z bool reverse Z at the connector + name string stable, user-visible (D4) +``` + +`flip_z` is a property of the **connector**, chosen once when it is made — not a per-mate +afterthought. Keeping connector-flip and mate-direction separate is what stops the "which flip do I +tick?" question. + +### The mate + +``` +Mate + kind enum Fastened | Revolute | Slider | Cylindrical | Planar [| Ball] (R3) + fixed connector A — its body does not move + moving connector B — its body is driven (A5, C5) + direction enum Facing | Aligned (R5, R6) + offset mm along A's Z, measured A → B — state this in the label (C6) + angle deg about A's Z (R4) +``` + +Within one field of what exists. + +### Gaps against today + +Source of record: `CadDocument.hpp:26,247-252,298-310`; `CadDocument.cpp:1669` (`datum_frame`), +`:2961` (`apply_mate`), `:1302` (`add_mate`); `DesignPanel.cpp:2671-2709` (the Mate card). + +| # | Gap | Severity | Ref | +|---|---|---|---| +| G1 | `PointWorld` connectors are not attached to a body and their X is a world constant | **High — data model** | A4/R1 | +| G2 | Origin is always the face centroid; no vertex / edge-midpoint / arc-centre snap | **High — expressiveness** | D1/R9 | +| G3 | No live preview of the two Z arrows or of the resulting placement | **High — this is the brief** | D3/R16 | +| G4 | Mate card is two abstract dropdowns; nothing says which body moves | High — charter + A5 | R15 | +| G5 | No joint-type inference from the picked geometry | Medium — feel | D2 | +| G6 | `add_mate` validates nothing — no one-mate-per-body, no cycle check | Medium | R11/R12 | +| G7 | No `Ball` type | Low | §3 | +| G8 | Re-clocking needs a typed angle; no 90° step control | Low, cheap | A6/R7 | +| G9 | Degenerate roll falls back silently | Low | C8/R8 | +| G10 | Connectors have no stable user-facing name | Low now, expensive later | D4 | + +**Already aligned — do not "fix" these:** the five types and their DOF; the frame definition (A1); +Z as the joint axis (A2); superimpose-then-relax (A3); the fixed/moving asymmetry in the data model +(A5); DOF wording in the type list (A7); free-DOF preservation (A8); right-handed frames under mirror +(R14); and `en4`'s fix, which put roll derivation on the body where it belongs (C2). + +**The pattern worth naming: the kernel is in good shape and the concept is under-explained.** Half the +requirements here are wording and drawing, not geometry. The two real engineering items are R9 (origin +candidates) and R11/R12 (the mate-graph rules). + +### Expensive-to-retrofit decisions — get these right in the data model now + +Changing any of these after documents exist in the wild costs a migration, not an edit. + +1. **Topological reference stability.** Storing raw face/edge indices is brittle — editing a body + renumbers faces. Either persistent topology IDs, or store the named origin *kind* plus a + deterministic search that re-finds the same geometric intent on rebuild. The latter is cheaper and + probably sufficient here; it is also what makes R10's "error, never silent relocation" enforceable. +2. **Connector ownership** (R1). Remove `PointWorld` or bind it to a body. Do this first. +3. **Mate direction semantics** (R5/D1). Inverting the default rewrites the meaning of every saved + mate. +4. **Roll representation** (R7). "First usable edge" is better than world-X but still fragile. Store + an explicit roll reference plus quarter turns. +5. **Coordinate convention** — Z = joint axis, X = roll reference. Changing this after release + invalidates every mate. +6. **Units** — offset in mm, angle in degrees. Never change. +7. **Mirror handedness** (R14) — document the decision, do not let it stay an accident. +8. **Flat body index vs. a component tree.** Mates currently reference bodies in a flat vector. If + **sub-assemblies** are ever in scope, mates must reference nodes in a tree instead. Retrofitting + this is painful and it is the one item on this list not already implied elsewhere in the document — + **decide now whether nested assemblies are in scope.** +9. **Serialization field semantics.** Adding fields is easy; redefining `mate_flip` or + `coordsys_x_hint` is not. +10. **The one-mate-per-body rule** (R11). Enforce at creation. Relaxing it later by adding a solver is + straightforward; allowing many mates now and discovering later that they silently conflict is not. + +--- + +## 8b. The visual shape of the connector — polarity and verse + +Researched separately (2026-08-05) by downloading and **looking at** the vendors' own figures, not +by reading their prose. Files kept alongside this document in `doc/design/mate-connectors/`. + +### What the systems actually draw + +**Onshape** — verified from `planarfacemateconnectors.png`, `cylindricalmateconnectors.png`, +`linearedgemateconnectors.png`, `mateconnector-planarpoints.png`, `matepointiconLG.png`: + +> **A small circle with one quadrant filled, plus three short coloured axis arms (X red, Y green, +> Z blue).** + +Three parts, each doing one job: + +| Element | What it says | +|---|---| +| The **circle** | "I am a frame, and this is my XY plane." | +| The **filled quadrant** | **The roll.** The shaded sector is the +X/+Y quadrant. | +| The **coloured arms** | The three axis directions, Z distinguished by colour. | + +The quadrant is the cleverest part of the whole design and it is easy to miss. The figure +`matepointreorientsecondaryaxis.png` shows three connectors side by side with the quadrant in three +different rotations — **it is the live readout of "reorient secondary axis in 90° increments" (A6).** +One glyph element makes the otherwise-invisible clocking visible, and makes the 90° button's effect +legible before you commit. The toolbar icon `matepointiconLG.png` is that same circle-with-a-quadrant, +so the symbol is consistent from toolbar to viewport. + +Candidate snap points, before you choose one, are drawn as **plain small white dots** on the model +(clear in `mateconnector-planarpoints.png`: dots at every corner and edge midpoint). Candidate and +committed are deliberately different weights — dots propose, the circle-and-triad commits. + +**FreeCAD 1.0** — verbatim from the wiki: *"Connectors are local coordinate systems and are marked by +a symbol with three axes (X, Y, Z) and a circle representing the XY-plane."* Same core as Onshape — +circle plus triad — **without** the quadrant. + +**Fusion 360** — the joint origin glyph, plus a documented icon language for *candidates*: *"A circle +denotes a vertex, and a triangle denotes a midpoint."* Shape encodes what kind of point it is. + +**Convergent core:** *circle for the XY plane + coloured triad*. Onshape alone adds the roll quadrant. + +### What none of them draw — and it is exactly what was asked for + +**Nothing in any vendor's glyph says which connector is the reference and which one is about to +move.** Both ends of a mate are drawn identically. That is confusion C5 ("which part moves?") left +unsolved in the visual language, and it is why the honest recommendation earlier was a live ghost — +the ghost compensates for a glyph that does not carry the information. + +So the two things asked for split cleanly, and only one of them is solved upstream: + +- **Verse** (*verso* — which way it points): **solved**. Z has a colour and a direction. +- **Polarity** (which end receives, which end inserts; who is anchored, who travels): **unsolved + everywhere.** This is open ground, and getting it right is a genuine improvement rather than a + deviation to justify. + +### Our starting point + +**We draw nothing.** `resolve_datum_coordsys()` (`CadDocument.cpp:1749`) has exactly one consumer in +the entire tree — `McpControl.cpp:1310`, the agent socket. A mate connector is today visible only to +a program. The glyph is unbuilt, so there is no migration cost to designing it properly now. + +### Proposed glyph: the magnet + +Adopt Onshape's proven core, then add the missing polarity with a metaphor that carries its own +instructions. + +``` + ▲ solid cone on +Z ONLY ← verse + | + ────●──── ← the disc = XY plane, ● = exact origin + ▨ quadrant filled ← roll / clocking, steps 90° +``` + +**Rule 1 — verse: draw +Z and never −Z.** A single stem with a cone head, on the positive side only. +No stem below the disc. A double-headed axis is the one thing that guarantees the question gets asked; +an arrow that exists on one side only cannot be misread. Length is asymmetric on purpose. + +**Rule 2 — roll: keep Onshape's quadrant.** Filled sector = the +X/+Y quadrant. It rotates in 90° +steps with the reorient control (A6/R7). This is aligned *and* it is the only in-glyph answer to +"where is X?", which matters because Fastened and Slider lock the clocking. + +**Rule 3 — polarity: solid cone travels, open collar receives.** +- The **driven** connector (B, on the body that will move) draws a **solid filled cone** — the plug. +- The **fixed** connector (A) draws an **open ring / hollow cone outline** — the socket. + +Same silhouette, so they read as a matched pair; opposite fill, so which one is about to jump is +answerable at a glance and without a legend. Plug-into-socket is the one mechanical metaphor every +user of this tool already has in their hands. + +**Rule 4 — the pair reads as a magnet.** Draw a dashed line joining the two origins the moment both +are picked. Two poles, one field line. And because a magnet's north seeks a south, **"facing" becomes +the self-evident default** — which quietly settles open decision D1 (§9) on visual grounds rather than +on a convention nobody can look up. If the glyph looks like a magnet, nobody has to be told that two +faces which touch have opposed normals. + +**Rule 5 — three states, three weights.** + +| State | Drawing | +|---|---| +| **Candidate** (hover) | small dot only — Onshape's white dots; shape may encode kind, Fusion-style | +| **Picked** | full glyph: disc + quadrant + cone | +| **Degenerate roll** (C8/R8) | the quadrant is drawn **hollow/hatched** — "roll undefined, pick a direction" | + +That last row is worth the trouble: it turns R8 from a message nobody reads into a mark you cannot +miss, and it costs one branch in the renderer. + +**Rule 6 — do not reuse the existing triad.** The bed-centre world triad +(`DesignCanvas.cpp:65`, `set_axes_at_bed_center`) and the move gizmo are already three-coloured arrows. +The connector must not be a fourth set of RGB arrows or the viewport becomes unreadable. The disc and +the quadrant are what distinguish it; keep the arms short, and consider drawing only Z on the +committed glyph, with X/Y implied by the quadrant. + +### Built and judged in the viewport, not in a mock + +The browser mock that first accompanied this section was the wrong instrument and its proportions +were meaningless: **every gizmo in this codebase is sized in SCREEN PIXELS** via `upp = 1/zoom` +(`render_shell_gizmo` uses `15.0 * upp`, `render_hole_gizmo` `9.0 * upp` for its cube). A connector +is a symbol, not a part — it must not shrink with the model. Nothing about that is visible in SVG. + +The glyph was therefore implemented and driven on the rig. Screenshots: `g-0*.png`, left in the workspace `artifacts/shots/` and not moved into the repo. +Five findings, none of which a mock could have produced: + +**F1 — Three axis arms lose to one.** Rendered side by side (`ORCA_CAD_GLYPH=A` vs default), the +Onshape-style RGB trio crowds a 22 px disc: the arrowheads are as large as the disc, they bury the +gold quadrant, and at an oblique angle the three heads pile into a coloured smudge. Worse, **it is +indistinguishable from the move gizmo and the bed triad**, which are already RGB arrow trios in this +viewport. One-sided Z wins on evidence, not taste. (`g-01-zoom.png` vs `g-02-zoom.png`.) + +**F2 — Polarity works, and colour does more of the work than fill.** A filled blue head against an +open grey outline head is readable instantly at 22 px (`g-03-zoom.png`). But the fill difference is +the *second* cue; the colour split carries it. Keep both — fill survives greyscale and colour-blind +palettes, colour survives small size. + +**F3 — Depth off floats, depth on tears.** With `GL_DEPTH_TEST` off, connectors on faces pointing +*away* from the camera still drew their discs over the solid, so the part looked covered in frames +that were really on its back. Turning depth on fixed that and immediately caused **z-fighting**: the +disc is exactly coplanar with its face, and came out as a broken dotted arc. The fix is depth **on** +plus a sub-pixel lift along Z (`0.7 * upp`), scaled by `upp` so it never becomes a visible gap on +zoom-in. Both failure modes are in the images (`g-03` torn, `g-04` clean). + +**F4 — The quadrant is the first thing to die at a grazing angle.** On a face seen nearly edge-on the +disc foreshortens to a sliver and the fan collapses into a blob (`g-01-zoom.png`, lower-right glyph). +The roll is exactly the information that is hardest to read when you most need it. Not yet solved — +see the open item below. + +**F5 — Roll-undefined in red is too loud.** It works, but it makes the *least* important connector +the most eye-catching thing on screen. Amber, or the same grey with a hatched quadrant, is enough. + +Also surfaced while testing, and unrelated to the glyph: `add_mate` accepted a mate between two +connectors **on the same body**, which is meaningless, and duly transformed the body relative to +itself. Concrete instance of gap G6. + +**Still untested:** a true grazing view (the view-cube click missed), a connector on a curved face, +and behaviour when a connector overlaps the move gizmo. F4 is the open design question — the disc may +need to billboard its *quadrant* while keeping the disc in-plane, which is a compromise no surveyed +vendor makes and which should be tried before being adopted. + +### What this costs + +A renderer for `resolve_datum_coordsys()` — which does not exist and has to be written whatever glyph +is chosen — plus one dashed line and three fill states. No kernel work. It is the same piece of work +as G3 (live preview), and doing them together is what makes the mate card honest. + +--- + +## 8c. The "faceted ridge dome" proposal — built, rendered, judged + +A colleague proposed replacing the flat disc with an **asymmetric low-poly solid**: a faceted +prismatic wedge with a dominant longitudinal ridge that **slopes** from a tall steep back to a long +shallow front, plus a male protrusion / female pocket pair with a 0.2 mm clearance. + +It was built rather than discussed. `faceted_ridge_key.scad` (this folder) (6 vertices, 7 faces), +verified as a closed manifold, exported through OpenSCAD, and flat-shaded from five directions with +`render_key.py` / `render_stl.py`. Sheets: `rk-sheet.png`, `cmp-sheet.png`. + +### The verdict: the shape is right, the male/female polarity cue is not + +**It solves F4, decisively.** The grazing view — where the flat disc dies, its quadrant collapsing to +a blob — is the view where this shape is *most* legible: the tall back and long shallow front are +unmistakable in silhouette. At a grazing angle the silhouette IS the information, and this solid's +silhouette is maximally informative there. That is a real, evidence-backed win over what is currently +in the code. + +**Down the mating axis (+Z) it also reads well**, which matters because that is the natural viewing +direction when you are looking at a face you intend to mate. + +**One degenerate view, and it is not the one I predicted.** I expected the ±X views (along the ridge) +to be silhouette-ambiguous, resolved only by shading. Wrong: front and back are clearly *different* — +the front shows several facets, the back is a **single flat featureless triangle**. So they are not +confusable, but the view from directly behind the tall end tells you nothing about roll or slope. +A second blind spot remains untested: from below the base, where the protrusion is hidden behind its +own face. + +**The female half fails, and much harder than expected.** Rendered with flat shading and no outlines — +the honest test, since a viewport draws no black edges — a recessed pocket is *invisible*: iso and +grazing show a plain block with a hairline; straight down the axis shows a **completely blank +rectangle**. The interior faces are lit almost identically to the top face and are occluded by the rim +from most angles. As a polarity cue, male/female therefore works in exactly one direction and returns +nothing in the other. + +> **Conclusion: do not overload shape with all three jobs.** Let the solid carry **verse and roll**, +> where it is excellent, and carry **polarity on a second channel** — colour plus the filled/open head +> that already tested well at 22 px (F2). Drawing the fixed connector as an outline/wireframe of the +> same solid is the variant worth trying; drawing it as a pocket is not. + +### Two premises in the brief are wrong + +**"Avoid curved surfaces to optimise rendering computations / rapid mesh processing."** Not a reason +for a viewport glyph. There are 2–20 connectors on screen, the renderer pushes `GLModel` triangles +directly, and it performs no CSG or mesh processing at all. **The real argument for flat facets is +legibility**: hard normals give distinct value steps between adjacent facets, and the renders confirm +that is exactly what makes the shape readable from an arbitrary angle. Keep the constraint, fix the +justification. (For a *printed* part the original justification is sound for a different reason: flat +facets slice without the stair-stepping a tessellated curve produces.) + +**"0.2 mm clearance for smooth mechanical mating."** Meaningless for a glyph. A symbol mates with +nothing, and every gizmo here is sized in screen pixels via `upp`, so a millimetre tolerance has no +referent. This is the strongest signal that **the brief was written for a physical printed part**, +not for a viewport symbol — as are "scannable" and "mechanical mating". See the open question below. + +### Two defects the build caught that discussion would not have + +1. **The flank quads are not planar.** Written as `[0,3,5,4]` and `[1,4,5,2]` the base edge and the + ridge edge are skew, so the four corners do not share a plane — my own first draft asserted the + opposite in a comment. Left as quads, the tessellator picks the fold direction, the "flat facet" + promise is broken by an unspecified crease, and two exporters can disagree about the shape. Fixed + by triangulating explicitly (7 faces, Euler 6 − 11 + 7 = 2). +2. **The pocket punched through its own plate.** A 4.5 mm key against a 3 mm demo plate gives a + through-hole, not a pocket. Minimum stock = height + clearance + pocket depth + a wall. + +Also worth recording: the first female render was misleading because the debug renderer outlined +*every* triangle, so a flat top face triangulated by CGAL looked like a faceted dome. The instrument +lied before the geometry did. Conclusions were only drawn after outlines were removed. + +### Second opinion, and the one disagreement worth resolving + +Kimi reviewed the proposal independently and **rejected it for the viewport**. It agreed on the two +wrong premises, agreed the female pocket is unreadable, and added the useful framing that a +screen-constant symbol and a model-constant part feature are two different design spaces that cannot +be served by one geometry. It also noted correctly that there is **no single scalar** that removes +ambiguity from every view: you need one asymmetry in the base plane (for top-down roll) and one out +of plane (the ridge slope, for front/back). Our base is scalene, so it has both. + +Its central objection was numeric and testable: *"at 22 px with 6–8 facets each facet is 3–7 px wide, +that is at the aliasing limit … minimum useful size is roughly 32–48 px, which is not compatible with +a 22 px screen-constant symbol."* My own renders were ~300 px, so the claim was unaddressed by my +evidence and would have killed the concept if true. + +**Rendered at 22, 32 and 48 px (`size-test.png`), it is false for this shape.** At 22 px all three +views still read: the grazing view shows the tall back and shallow front unmistakably, and the +down-axis view keeps a strong dark/light split. The reason Kimi's arithmetic does not apply is that +this solid presents only **four or five large facets with high value contrast**, not eight small ones — +the silhouette does most of the work, and silhouettes survive downsampling far better than facet +detail does. + +*Honest limit on that result:* the test renderer has no anti-aliasing, no perspective, one directional +light, and no background. Readable at 22 px against white is not the same as readable at 22 px on top +of a shaded gold part next to the move gizmo. That case still needs the rig. + +**Where I do not follow Kimi:** its recommendation is to **billboard** the existing flat glyph so it +never turns edge-on. That kills F4 by construction, but a billboarded frame cannot show the frame's +orientation *in place* — which is the entire reason the disc is a disc and not a dot — and it is what +no surveyed CAD system does; Onshape, Fusion and FreeCAD all draw the frame in the geometry. Worth +prototyping as an option, not worth adopting on argument. + +### Open question for Tommaso + +**Is this a viewport glyph or a printable alignment feature?** The vertex logic is identical either +way; only the units and the clearance change, and the `.scad` file states both readings. But the +answer decides whether `clr`/`depth` are real millimetres or meaningless, and whether the geometry +scales with the model or stays screen-constant. The brief's own language points at "physical", the +conversation it arrived in points at "glyph". + +--- + +## 9. Decisions for you + +**D1 — Invert the default direction to Facing?** [DEVIATION, R5] +It changes the meaning of every stored document containing a mate. Options: (a) invert and migrate, +writing `direction=Aligned` where `mate_flip` was false; (b) invert only for new mates and store +`direction` explicitly from now on. (b) is safer and costs one field. Note this project has taken one +such semantic hit knowingly before — the `en4` fix — and the golden fixture survived, so the +migration path is a known quantity. **If G3 (live preview) lands first, this matters much less.** + +**D2 — How far to take origin candidates?** [R9] +Four kinds is the Fusion-aligned recommendation. Two (face centroid + arc centre) would cover "sit on +a face" and "go down a hole" — most printed-part assembly — at a third of the work. Where do you want +to stop? + +**D3 — Ball mate: in or out?** +In four of five frame-based systems, so including it is the aligned choice. Out is defensible for +printable mechanical parts. Cheap either way — align origins, leave orientation free. Kimi's review +argued **out**: a true ball joint is hard to print and hard to use without a roll reference, and a +Fastened connector at the ball centre approximates it. + +**D3a — Should Planar be dropped?** [dissent worth recording] +Kimi's independent review recommended **removing Planar** and shipping four types, on the grounds that +"slide on a flat surface" is rarely how printed mechanisms work — you usually want a rail or a hinge — +and that Planar is the type most likely to confuse a user who expected "put this flat on that" and got +a part free to slide. It further ranked the honest minimum as **three**: Fastened, Revolute, Slider, +with Cylindrical useful and decomposable. +**I do not agree, and the reason is alignment.** Planar appears in every frame-based system surveyed, +it is a genuine lower pair, it is already implemented and tested, and removing it is a document-format +change made in exchange for nothing. The confusion Kimi names is real but it is a *feedback* problem — +it is exactly what R17 (show the DOF budget) and R13 (say that free DOF are preserved) exist to fix. +Recorded here because it is a legitimate reading of the same evidence and the call is yours. + +**D4 — Is refusing a second mate per body acceptable?** [DEVIATION, R11 — the big one] +It is the honest consequence of having no solver, and it is what makes the tool predictable. But **no +mainstream system behaves this way**, so it is the point where an experienced user's intuition will +break. It means a part cannot be constrained by two independent relationships — "in this hole *and* +resting on this shoulder" must be expressed by placing one connector correctly rather than by two +mates. If that trade is unacceptable, the answer is a solver, and the scope of this document changes +entirely. + +There is a strong argument that the trade is not merely acceptable but *correct for this product*: +the Design tab lives inside a slicer, and most of its users are positioning parts for printing rather +than building working mechanisms. For layout-and-export, tree-order composition is genuinely enough, +and adding a solver to look like Onshape would buy complexity nobody asked for. The rule to publish is +then simple and defensible: **one mate per moving body, acyclic, no relations between mates** — with +R18's loud refusals carrying the honesty. + +--- + +## Sources + +**Onshape** — [Mate Connector](https://cad.onshape.com/help/Content/PartStudio/mate_connector.htm) · +[Mates](https://cad.onshape.com/help/Content/Assembly/mates.htm) · +[Fastened](https://cad.onshape.com/help/Content/Assembly/fastened_mate.htm) · +[Revolute](https://cad.onshape.com/help/Content/Assembly/revolute_mate.htm) · +[Slider](https://cad.onshape.com/help/Content/Assembly/slider_mate.htm) · +[Cylindrical](https://cad.onshape.com/help/Content/Assembly/cylindrical_mate.htm) · +[Planar](https://cad.onshape.com/help/Content/Assembly/planar_mate.htm) · +[Ball](https://cad.onshape.com/help/Content/Assembly/ball_mate.htm) · +[Parallel](https://cad.onshape.com/help/Content/Assembly/parallel_mate.htm) · +[Tangent](https://cad.onshape.com/help/Content/Assembly/tangent_mate.htm) · +[Pin Slot](https://cad.onshape.com/help/Content/Assembly/pin_slot_mate.htm) · +[5 things you can do with mate connectors in Part Studios](https://www.onshape.com/en/resource-center/tech-tips/tech-tip-5-things-you-can-do-with-mate-connectors-in-onshape-part-studios) + +**Onshape forum** — [The concept behind Mates Z Axes](https://forum.onshape.com/discussion/22828/the-concept-behind-mates-z-axes) (C1/D3) · +[Implicit mate connectors act differently than explicit ones](https://forum.onshape.com/discussion/15736/implicit-mate-connectors-act-differently-than-explicit-ones) (C4) · +[Efficiently set mate connectors](https://forum.onshape.com/discussion/13133/efficiently-set-mate-connectors) + +**Fusion 360** — [Joint types](https://help.autodesk.com/cloudhelp/ENU/Fusion-Assemble/files/GUID-8818AE31-958A-4A59-989B-9875A174C67A.htm) · +[Joint origins](https://help.autodesk.com/view/fusion360/ENU/?guid=ASM-JOINT-ORIGIN) · +[Joints vs. Mates in Fusion](https://www.autodesk.com/products/fusion-360/blog/joints-mates-moving-fusion/) · +[Joint tips — snap points and Ctrl cycling](https://mgfx.co.za/blog/engineering-manufacturing-design/fusion-360-joint-tips/) + +**Inventor** — [Create Joints Reference](https://help.autodesk.com/cloudhelp/2026/ENU/Inventor-Help/files/GUID-6AA68E8F-7C97-4806-8483-3941DE915E70.htm) · +[Use Joint to define and manage relationships](https://knowledge.autodesk.com/support/inventor-products/learn-explore/caas/CloudHelp/cloudhelp/2014/ENU/Inventor/files/GUID-21DC3336-5C51-42C1-90FB-4299CD66E0C6-htm.html) (type inference, D2) + +**FreeCAD 1.0** — [Assembly Workbench](https://wiki.freecad.org/Assembly_Workbench) · +[Fixed Joint properties](https://wiki.freecad.org/Assembly_CreateJointFixed) + +**Creo** — [About Predefined Constraint Sets](https://support.ptc.com/help/creo/creo_pma/r12/usascii/assembly/asm/About_Predefined_Constraint_Sets.html) + +**Siemens NX** — [Assembly constraints](https://learnnx.com/lesson/siemens-nx-assemblies-assembly-constraints/) + +**SOLIDWORKS** — [Mate References](https://help.solidworks.com/2025/English/SolidWorks/sldworks/c_Mate_References_Overview_SWassy.htm) · +[Creating and using mate references](https://blogs.solidworks.com/tech/2019/07/creating-and-using-mate-references.html) + +**Theory** — [Hervé, The Lie group of rigid body displacements, a fundamental tool for mechanism design](https://www.sciencedirect.com/science/article/abs/pii/S0094114X98000512) · +[Joint kinematics — the six lower pairs and their DOF](https://erc-bpgc.github.io/handbook/mechanical/Joint%20Kinematics/) · +[ISO 10303-105 — Kinematics (STEP integrated resource)](https://www.iso.org/standard/78589.html) + +**Internal** — `en4` (closed 2026-07-26, fixes C2 here) · `CadDocument.cpp:1669` +`datum_frame` · `CadDocument.cpp:2961` `apply_mate` · `CadDocument.cpp:1302` `add_mate` + +**Second opinion** — an independent review by Kimi Code (2026-08-05) contributed the +vendors-ship-both caveat (§1), the explicit-dropdown option for origin choice (D1), the expanded +refusal list (R11a), the retrofit list (§8), and the dissents recorded at D3/D3a. One of its claims — +that Onshape mandates *"exactly one Mate between any two instances"* — **was checked against the +source and is wrong**; the correction is recorded at R11 because it is a misreading that would +otherwise turn our largest deviation into a false agreement. diff --git a/docs/CAD/design/mate-connectors/bear-01-view.png b/docs/CAD/design/mate-connectors/bear-01-view.png new file mode 100644 index 0000000000..eb57aaeefe Binary files /dev/null and b/docs/CAD/design/mate-connectors/bear-01-view.png differ diff --git a/docs/CAD/design/mate-connectors/bear-02-view.png b/docs/CAD/design/mate-connectors/bear-02-view.png new file mode 100644 index 0000000000..46b6d834fa Binary files /dev/null and b/docs/CAD/design/mate-connectors/bear-02-view.png differ diff --git a/docs/CAD/design/mate-connectors/bear-02-zoom.png b/docs/CAD/design/mate-connectors/bear-02-zoom.png new file mode 100644 index 0000000000..437adaee9d Binary files /dev/null and b/docs/CAD/design/mate-connectors/bear-02-zoom.png differ diff --git a/docs/CAD/design/mate-connectors/bear-flat.png b/docs/CAD/design/mate-connectors/bear-flat.png new file mode 100644 index 0000000000..16375b9d5c Binary files /dev/null and b/docs/CAD/design/mate-connectors/bear-flat.png differ diff --git a/docs/CAD/design/mate-connectors/bear-sil-cmp.png b/docs/CAD/design/mate-connectors/bear-sil-cmp.png new file mode 100644 index 0000000000..eb2f283f95 Binary files /dev/null and b/docs/CAD/design/mate-connectors/bear-sil-cmp.png differ diff --git a/docs/CAD/design/mate-connectors/bear-sil.png b/docs/CAD/design/mate-connectors/bear-sil.png new file mode 100644 index 0000000000..2b6e51067e Binary files /dev/null and b/docs/CAD/design/mate-connectors/bear-sil.png differ diff --git a/docs/CAD/design/mate-connectors/bear.step b/docs/CAD/design/mate-connectors/bear.step new file mode 100644 index 0000000000..a61f1b22c0 --- /dev/null +++ b/docs/CAD/design/mate-connectors/bear.step @@ -0,0 +1,1020 @@ +ISO-10303-21; +HEADER; +/* Generated by software containing ST-Developer + * from STEP Tools, Inc. (www.steptools.com) + */ +/* OPTION: using custom renumber hook */ + +FILE_DESCRIPTION( +/* description */ ('STEP AP242 Edition 2', +'CAx-IF Rec.Pracs.---Representation and Presentation of Product Manufa +cturing Information (PMI)---4.0---2014-10-13', +'CAx-IF Rec.Pracs.---3D Tessellated Geometry---0.4---2014-09-14','2;1', +'CAx-IF Rec.Pracs.---User Defined Attributes---1.5---2016-08-15'), +/* implementation_level */ '2;1'); + +FILE_NAME( +/* name */ 'Part Studio 1 - Part 1', +/* time_stamp */ '2026-08-05T08:27:36Z', +/* author */ (''), +/* organization */ (''), +/* preprocessor_version */ 'ST-DEVELOPER v20', +/* originating_system */ 'ONSHAPE BY PTC INC, 1.218', +/* authorisation */ ' '); + +FILE_SCHEMA (('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }')); +ENDSEC; + +DATA; +#10=SHAPE_REPRESENTATION_RELATIONSHIP('','',#598,#11); +#11=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#596),#960); +#12=CYLINDRICAL_SURFACE('',#620,0.00144852813742385); +#13=CYLINDRICAL_SURFACE('',#622,0.005); +#14=CYLINDRICAL_SURFACE('',#625,0.00585404496296467); +#15=CYLINDRICAL_SURFACE('',#627,0.00585404496296468); +#16=CYLINDRICAL_SURFACE('',#634,0.00493552270528336); +#17=CYLINDRICAL_SURFACE('',#635,0.00493552270528336); +#18=CIRCLE('',#601,0.00493552270528336); +#19=CIRCLE('',#602,0.00585404496296468); +#20=CIRCLE('',#603,0.00585404496296467); +#21=CIRCLE('',#604,0.005); +#22=CIRCLE('',#605,0.00144852813742385); +#23=CIRCLE('',#606,0.00493552270528336); +#24=CIRCLE('',#608,0.00144852813742385); +#25=CIRCLE('',#609,0.005); +#26=CIRCLE('',#610,0.00585404496296467); +#27=CIRCLE('',#611,0.00585404496296468); +#28=CIRCLE('',#612,0.00493552270528336); +#29=CIRCLE('',#613,0.00493552270528336); +#30=ORIENTED_EDGE('',*,*,#188,.T.); +#31=ORIENTED_EDGE('',*,*,#189,.T.); +#32=ORIENTED_EDGE('',*,*,#190,.T.); +#33=ORIENTED_EDGE('',*,*,#191,.T.); +#34=ORIENTED_EDGE('',*,*,#192,.T.); +#35=ORIENTED_EDGE('',*,*,#193,.T.); +#36=ORIENTED_EDGE('',*,*,#194,.T.); +#37=ORIENTED_EDGE('',*,*,#195,.F.); +#38=ORIENTED_EDGE('',*,*,#196,.F.); +#39=ORIENTED_EDGE('',*,*,#197,.T.); +#40=ORIENTED_EDGE('',*,*,#198,.F.); +#41=ORIENTED_EDGE('',*,*,#199,.T.); +#42=ORIENTED_EDGE('',*,*,#200,.F.); +#43=ORIENTED_EDGE('',*,*,#201,.T.); +#44=ORIENTED_EDGE('',*,*,#202,.T.); +#45=ORIENTED_EDGE('',*,*,#203,.F.); +#46=ORIENTED_EDGE('',*,*,#204,.T.); +#47=ORIENTED_EDGE('',*,*,#205,.F.); +#48=ORIENTED_EDGE('',*,*,#206,.T.); +#49=ORIENTED_EDGE('',*,*,#207,.F.); +#50=ORIENTED_EDGE('',*,*,#208,.F.); +#51=ORIENTED_EDGE('',*,*,#209,.F.); +#52=ORIENTED_EDGE('',*,*,#210,.F.); +#53=ORIENTED_EDGE('',*,*,#211,.F.); +#54=ORIENTED_EDGE('',*,*,#212,.F.); +#55=ORIENTED_EDGE('',*,*,#213,.F.); +#56=ORIENTED_EDGE('',*,*,#214,.F.); +#57=ORIENTED_EDGE('',*,*,#215,.F.); +#58=ORIENTED_EDGE('',*,*,#216,.F.); +#59=ORIENTED_EDGE('',*,*,#217,.F.); +#60=ORIENTED_EDGE('',*,*,#218,.F.); +#61=ORIENTED_EDGE('',*,*,#219,.T.); +#62=ORIENTED_EDGE('',*,*,#220,.T.); +#63=ORIENTED_EDGE('',*,*,#221,.F.); +#64=ORIENTED_EDGE('',*,*,#222,.T.); +#65=ORIENTED_EDGE('',*,*,#223,.T.); +#66=ORIENTED_EDGE('',*,*,#224,.T.); +#67=ORIENTED_EDGE('',*,*,#225,.T.); +#68=ORIENTED_EDGE('',*,*,#226,.T.); +#69=ORIENTED_EDGE('',*,*,#227,.F.); +#70=ORIENTED_EDGE('',*,*,#228,.T.); +#71=ORIENTED_EDGE('',*,*,#229,.F.); +#72=ORIENTED_EDGE('',*,*,#230,.T.); +#73=ORIENTED_EDGE('',*,*,#231,.F.); +#74=ORIENTED_EDGE('',*,*,#232,.F.); +#75=ORIENTED_EDGE('',*,*,#233,.T.); +#76=ORIENTED_EDGE('',*,*,#234,.F.); +#77=ORIENTED_EDGE('',*,*,#235,.T.); +#78=ORIENTED_EDGE('',*,*,#236,.F.); +#79=ORIENTED_EDGE('',*,*,#237,.T.); +#80=ORIENTED_EDGE('',*,*,#238,.F.); +#81=ORIENTED_EDGE('',*,*,#239,.T.); +#82=ORIENTED_EDGE('',*,*,#195,.T.); +#83=ORIENTED_EDGE('',*,*,#240,.F.); +#84=ORIENTED_EDGE('',*,*,#222,.F.); +#85=ORIENTED_EDGE('',*,*,#241,.T.); +#86=ORIENTED_EDGE('',*,*,#210,.T.); +#87=ORIENTED_EDGE('',*,*,#242,.F.); +#88=ORIENTED_EDGE('',*,*,#223,.F.); +#89=ORIENTED_EDGE('',*,*,#240,.T.); +#90=ORIENTED_EDGE('',*,*,#209,.T.); +#91=ORIENTED_EDGE('',*,*,#243,.F.); +#92=ORIENTED_EDGE('',*,*,#224,.F.); +#93=ORIENTED_EDGE('',*,*,#242,.T.); +#94=ORIENTED_EDGE('',*,*,#208,.T.); +#95=ORIENTED_EDGE('',*,*,#244,.F.); +#96=ORIENTED_EDGE('',*,*,#225,.F.); +#97=ORIENTED_EDGE('',*,*,#243,.T.); +#98=ORIENTED_EDGE('',*,*,#207,.T.); +#99=ORIENTED_EDGE('',*,*,#245,.F.); +#100=ORIENTED_EDGE('',*,*,#226,.F.); +#101=ORIENTED_EDGE('',*,*,#244,.T.); +#102=ORIENTED_EDGE('',*,*,#206,.F.); +#103=ORIENTED_EDGE('',*,*,#246,.F.); +#104=ORIENTED_EDGE('',*,*,#227,.T.); +#105=ORIENTED_EDGE('',*,*,#245,.T.); +#106=ORIENTED_EDGE('',*,*,#205,.T.); +#107=ORIENTED_EDGE('',*,*,#247,.F.); +#108=ORIENTED_EDGE('',*,*,#228,.F.); +#109=ORIENTED_EDGE('',*,*,#246,.T.); +#110=ORIENTED_EDGE('',*,*,#204,.F.); +#111=ORIENTED_EDGE('',*,*,#248,.F.); +#112=ORIENTED_EDGE('',*,*,#229,.T.); +#113=ORIENTED_EDGE('',*,*,#247,.T.); +#114=ORIENTED_EDGE('',*,*,#203,.T.); +#115=ORIENTED_EDGE('',*,*,#249,.F.); +#116=ORIENTED_EDGE('',*,*,#230,.F.); +#117=ORIENTED_EDGE('',*,*,#248,.T.); +#118=ORIENTED_EDGE('',*,*,#202,.F.); +#119=ORIENTED_EDGE('',*,*,#250,.F.); +#120=ORIENTED_EDGE('',*,*,#231,.T.); +#121=ORIENTED_EDGE('',*,*,#249,.T.); +#122=ORIENTED_EDGE('',*,*,#201,.F.); +#123=ORIENTED_EDGE('',*,*,#251,.F.); +#124=ORIENTED_EDGE('',*,*,#232,.T.); +#125=ORIENTED_EDGE('',*,*,#250,.T.); +#126=ORIENTED_EDGE('',*,*,#200,.T.); +#127=ORIENTED_EDGE('',*,*,#252,.F.); +#128=ORIENTED_EDGE('',*,*,#233,.F.); +#129=ORIENTED_EDGE('',*,*,#251,.T.); +#130=ORIENTED_EDGE('',*,*,#199,.F.); +#131=ORIENTED_EDGE('',*,*,#253,.F.); +#132=ORIENTED_EDGE('',*,*,#234,.T.); +#133=ORIENTED_EDGE('',*,*,#252,.T.); +#134=ORIENTED_EDGE('',*,*,#198,.T.); +#135=ORIENTED_EDGE('',*,*,#254,.F.); +#136=ORIENTED_EDGE('',*,*,#235,.F.); +#137=ORIENTED_EDGE('',*,*,#253,.T.); +#138=ORIENTED_EDGE('',*,*,#197,.F.); +#139=ORIENTED_EDGE('',*,*,#255,.F.); +#140=ORIENTED_EDGE('',*,*,#236,.T.); +#141=ORIENTED_EDGE('',*,*,#254,.T.); +#142=ORIENTED_EDGE('',*,*,#196,.T.); +#143=ORIENTED_EDGE('',*,*,#241,.F.); +#144=ORIENTED_EDGE('',*,*,#237,.F.); +#145=ORIENTED_EDGE('',*,*,#255,.T.); +#146=ORIENTED_EDGE('',*,*,#219,.F.); +#147=ORIENTED_EDGE('',*,*,#256,.F.); +#148=ORIENTED_EDGE('',*,*,#257,.T.); +#149=ORIENTED_EDGE('',*,*,#258,.F.); +#150=ORIENTED_EDGE('',*,*,#256,.T.); +#151=ORIENTED_EDGE('',*,*,#218,.T.); +#152=ORIENTED_EDGE('',*,*,#259,.F.); +#153=ORIENTED_EDGE('',*,*,#221,.T.); +#154=ORIENTED_EDGE('',*,*,#260,.F.); +#155=ORIENTED_EDGE('',*,*,#259,.T.); +#156=ORIENTED_EDGE('',*,*,#257,.F.); +#157=ORIENTED_EDGE('',*,*,#258,.T.); +#158=ORIENTED_EDGE('',*,*,#260,.T.); +#159=ORIENTED_EDGE('',*,*,#220,.F.); +#160=ORIENTED_EDGE('',*,*,#238,.T.); +#161=ORIENTED_EDGE('',*,*,#194,.F.); +#162=ORIENTED_EDGE('',*,*,#239,.F.); +#163=ORIENTED_EDGE('',*,*,#211,.T.); +#164=ORIENTED_EDGE('',*,*,#261,.T.); +#165=ORIENTED_EDGE('',*,*,#212,.T.); +#166=ORIENTED_EDGE('',*,*,#262,.F.); +#167=ORIENTED_EDGE('',*,*,#189,.F.); +#168=ORIENTED_EDGE('',*,*,#262,.T.); +#169=ORIENTED_EDGE('',*,*,#217,.T.); +#170=ORIENTED_EDGE('',*,*,#263,.F.); +#171=ORIENTED_EDGE('',*,*,#190,.F.); +#172=ORIENTED_EDGE('',*,*,#263,.T.); +#173=ORIENTED_EDGE('',*,*,#216,.T.); +#174=ORIENTED_EDGE('',*,*,#264,.F.); +#175=ORIENTED_EDGE('',*,*,#191,.F.); +#176=ORIENTED_EDGE('',*,*,#264,.T.); +#177=ORIENTED_EDGE('',*,*,#215,.T.); +#178=ORIENTED_EDGE('',*,*,#265,.F.); +#179=ORIENTED_EDGE('',*,*,#192,.F.); +#180=ORIENTED_EDGE('',*,*,#265,.T.); +#181=ORIENTED_EDGE('',*,*,#214,.T.); +#182=ORIENTED_EDGE('',*,*,#266,.F.); +#183=ORIENTED_EDGE('',*,*,#193,.F.); +#184=ORIENTED_EDGE('',*,*,#266,.T.); +#185=ORIENTED_EDGE('',*,*,#213,.T.); +#186=ORIENTED_EDGE('',*,*,#261,.F.); +#187=ORIENTED_EDGE('',*,*,#188,.F.); +#188=EDGE_CURVE('',#267,#268,#321,.T.); +#189=EDGE_CURVE('',#268,#269,#322,.T.); +#190=EDGE_CURVE('',#269,#270,#323,.T.); +#191=EDGE_CURVE('',#270,#271,#324,.T.); +#192=EDGE_CURVE('',#271,#272,#325,.T.); +#193=EDGE_CURVE('',#272,#267,#326,.T.); +#194=EDGE_CURVE('',#273,#273,#18,.T.); +#195=EDGE_CURVE('',#274,#275,#327,.T.); +#196=EDGE_CURVE('',#276,#274,#328,.T.); +#197=EDGE_CURVE('',#276,#277,#329,.T.); +#198=EDGE_CURVE('',#278,#277,#19,.T.); +#199=EDGE_CURVE('',#278,#279,#330,.T.); +#200=EDGE_CURVE('',#280,#279,#20,.T.); +#201=EDGE_CURVE('',#280,#281,#331,.T.); +#202=EDGE_CURVE('',#281,#282,#332,.T.); +#203=EDGE_CURVE('',#283,#282,#21,.T.); +#204=EDGE_CURVE('',#283,#284,#333,.T.); +#205=EDGE_CURVE('',#285,#284,#22,.T.); +#206=EDGE_CURVE('',#285,#286,#334,.T.); +#207=EDGE_CURVE('',#287,#286,#335,.T.); +#208=EDGE_CURVE('',#288,#287,#336,.T.); +#209=EDGE_CURVE('',#289,#288,#337,.T.); +#210=EDGE_CURVE('',#275,#289,#338,.T.); +#211=EDGE_CURVE('',#290,#290,#23,.F.); +#212=EDGE_CURVE('',#291,#292,#339,.T.); +#213=EDGE_CURVE('',#293,#291,#340,.T.); +#214=EDGE_CURVE('',#294,#293,#341,.T.); +#215=EDGE_CURVE('',#295,#294,#342,.T.); +#216=EDGE_CURVE('',#296,#295,#343,.T.); +#217=EDGE_CURVE('',#292,#296,#344,.T.); +#218=EDGE_CURVE('',#297,#298,#345,.F.); +#219=EDGE_CURVE('',#297,#299,#346,.T.); +#220=EDGE_CURVE('',#299,#300,#347,.T.); +#221=EDGE_CURVE('',#298,#300,#348,.F.); +#222=EDGE_CURVE('',#301,#302,#349,.T.); +#223=EDGE_CURVE('',#302,#303,#350,.T.); +#224=EDGE_CURVE('',#303,#304,#351,.T.); +#225=EDGE_CURVE('',#304,#305,#352,.T.); +#226=EDGE_CURVE('',#305,#306,#353,.T.); +#227=EDGE_CURVE('',#307,#306,#354,.T.); +#228=EDGE_CURVE('',#307,#308,#24,.T.); +#229=EDGE_CURVE('',#309,#308,#355,.T.); +#230=EDGE_CURVE('',#309,#310,#25,.T.); +#231=EDGE_CURVE('',#311,#310,#356,.T.); +#232=EDGE_CURVE('',#312,#311,#357,.T.); +#233=EDGE_CURVE('',#312,#313,#26,.T.); +#234=EDGE_CURVE('',#314,#313,#358,.T.); +#235=EDGE_CURVE('',#314,#315,#27,.T.); +#236=EDGE_CURVE('',#316,#315,#359,.T.); +#237=EDGE_CURVE('',#316,#301,#360,.T.); +#238=EDGE_CURVE('',#317,#317,#28,.T.); +#239=EDGE_CURVE('',#318,#318,#29,.T.); +#240=EDGE_CURVE('',#302,#275,#361,.T.); +#241=EDGE_CURVE('',#301,#274,#362,.T.); +#242=EDGE_CURVE('',#303,#289,#363,.T.); +#243=EDGE_CURVE('',#304,#288,#364,.T.); +#244=EDGE_CURVE('',#305,#287,#365,.T.); +#245=EDGE_CURVE('',#306,#286,#366,.T.); +#246=EDGE_CURVE('',#307,#285,#367,.T.); +#247=EDGE_CURVE('',#308,#284,#368,.T.); +#248=EDGE_CURVE('',#309,#283,#369,.T.); +#249=EDGE_CURVE('',#310,#282,#370,.T.); +#250=EDGE_CURVE('',#311,#281,#371,.T.); +#251=EDGE_CURVE('',#312,#280,#372,.T.); +#252=EDGE_CURVE('',#313,#279,#373,.T.); +#253=EDGE_CURVE('',#314,#278,#374,.T.); +#254=EDGE_CURVE('',#315,#277,#375,.T.); +#255=EDGE_CURVE('',#316,#276,#376,.T.); +#256=EDGE_CURVE('',#319,#297,#377,.F.); +#257=EDGE_CURVE('',#319,#299,#378,.T.); +#258=EDGE_CURVE('',#319,#320,#379,.F.); +#259=EDGE_CURVE('',#320,#298,#380,.T.); +#260=EDGE_CURVE('',#320,#300,#381,.F.); +#261=EDGE_CURVE('',#268,#291,#382,.T.); +#262=EDGE_CURVE('',#269,#292,#383,.T.); +#263=EDGE_CURVE('',#270,#296,#384,.T.); +#264=EDGE_CURVE('',#271,#295,#385,.T.); +#265=EDGE_CURVE('',#272,#294,#386,.T.); +#266=EDGE_CURVE('',#267,#293,#387,.T.); +#267=VERTEX_POINT('',#798); +#268=VERTEX_POINT('',#799); +#269=VERTEX_POINT('',#801); +#270=VERTEX_POINT('',#803); +#271=VERTEX_POINT('',#805); +#272=VERTEX_POINT('',#807); +#273=VERTEX_POINT('',#810); +#274=VERTEX_POINT('',#812); +#275=VERTEX_POINT('',#813); +#276=VERTEX_POINT('',#815); +#277=VERTEX_POINT('',#817); +#278=VERTEX_POINT('',#819); +#279=VERTEX_POINT('',#821); +#280=VERTEX_POINT('',#823); +#281=VERTEX_POINT('',#825); +#282=VERTEX_POINT('',#827); +#283=VERTEX_POINT('',#829); +#284=VERTEX_POINT('',#831); +#285=VERTEX_POINT('',#833); +#286=VERTEX_POINT('',#835); +#287=VERTEX_POINT('',#837); +#288=VERTEX_POINT('',#839); +#289=VERTEX_POINT('',#841); +#290=VERTEX_POINT('',#844); +#291=VERTEX_POINT('',#847); +#292=VERTEX_POINT('',#848); +#293=VERTEX_POINT('',#850); +#294=VERTEX_POINT('',#852); +#295=VERTEX_POINT('',#854); +#296=VERTEX_POINT('',#856); +#297=VERTEX_POINT('',#859); +#298=VERTEX_POINT('',#860); +#299=VERTEX_POINT('',#862); +#300=VERTEX_POINT('',#864); +#301=VERTEX_POINT('',#867); +#302=VERTEX_POINT('',#868); +#303=VERTEX_POINT('',#870); +#304=VERTEX_POINT('',#872); +#305=VERTEX_POINT('',#874); +#306=VERTEX_POINT('',#876); +#307=VERTEX_POINT('',#878); +#308=VERTEX_POINT('',#880); +#309=VERTEX_POINT('',#882); +#310=VERTEX_POINT('',#884); +#311=VERTEX_POINT('',#886); +#312=VERTEX_POINT('',#888); +#313=VERTEX_POINT('',#890); +#314=VERTEX_POINT('',#892); +#315=VERTEX_POINT('',#894); +#316=VERTEX_POINT('',#896); +#317=VERTEX_POINT('',#899); +#318=VERTEX_POINT('',#901); +#319=VERTEX_POINT('',#936); +#320=VERTEX_POINT('',#940); +#321=LINE('',#797,#388); +#322=LINE('',#800,#389); +#323=LINE('',#802,#390); +#324=LINE('',#804,#391); +#325=LINE('',#806,#392); +#326=LINE('',#808,#393); +#327=LINE('',#811,#394); +#328=LINE('',#814,#395); +#329=LINE('',#816,#396); +#330=LINE('',#820,#397); +#331=LINE('',#824,#398); +#332=LINE('',#826,#399); +#333=LINE('',#830,#400); +#334=LINE('',#834,#401); +#335=LINE('',#836,#402); +#336=LINE('',#838,#403); +#337=LINE('',#840,#404); +#338=LINE('',#842,#405); +#339=LINE('',#846,#406); +#340=LINE('',#849,#407); +#341=LINE('',#851,#408); +#342=LINE('',#853,#409); +#343=LINE('',#855,#410); +#344=LINE('',#857,#411); +#345=LINE('',#858,#412); +#346=LINE('',#861,#413); +#347=LINE('',#863,#414); +#348=LINE('',#865,#415); +#349=LINE('',#866,#416); +#350=LINE('',#869,#417); +#351=LINE('',#871,#418); +#352=LINE('',#873,#419); +#353=LINE('',#875,#420); +#354=LINE('',#877,#421); +#355=LINE('',#881,#422); +#356=LINE('',#885,#423); +#357=LINE('',#887,#424); +#358=LINE('',#891,#425); +#359=LINE('',#895,#426); +#360=LINE('',#897,#427); +#361=LINE('',#903,#428); +#362=LINE('',#904,#429); +#363=LINE('',#906,#430); +#364=LINE('',#908,#431); +#365=LINE('',#910,#432); +#366=LINE('',#912,#433); +#367=LINE('',#914,#434); +#368=LINE('',#916,#435); +#369=LINE('',#918,#436); +#370=LINE('',#920,#437); +#371=LINE('',#922,#438); +#372=LINE('',#924,#439); +#373=LINE('',#926,#440); +#374=LINE('',#928,#441); +#375=LINE('',#930,#442); +#376=LINE('',#932,#443); +#377=LINE('',#935,#444); +#378=LINE('',#937,#445); +#379=LINE('',#939,#446); +#380=LINE('',#941,#447); +#381=LINE('',#943,#448); +#382=LINE('',#948,#449); +#383=LINE('',#949,#450); +#384=LINE('',#951,#451); +#385=LINE('',#953,#452); +#386=LINE('',#955,#453); +#387=LINE('',#957,#454); +#388=VECTOR('',#646,1.); +#389=VECTOR('',#647,1.); +#390=VECTOR('',#648,1.); +#391=VECTOR('',#649,1.); +#392=VECTOR('',#650,1.); +#393=VECTOR('',#651,1.); +#394=VECTOR('',#654,1.); +#395=VECTOR('',#655,1.); +#396=VECTOR('',#656,1.); +#397=VECTOR('',#659,1.); +#398=VECTOR('',#662,1.); +#399=VECTOR('',#663,1.); +#400=VECTOR('',#666,1.); +#401=VECTOR('',#669,1.); +#402=VECTOR('',#670,1.); +#403=VECTOR('',#671,1.); +#404=VECTOR('',#672,1.); +#405=VECTOR('',#673,1.); +#406=VECTOR('',#678,1.); +#407=VECTOR('',#679,1.); +#408=VECTOR('',#680,1.); +#409=VECTOR('',#681,1.); +#410=VECTOR('',#682,1.); +#411=VECTOR('',#683,1.); +#412=VECTOR('',#684,1.); +#413=VECTOR('',#685,1.); +#414=VECTOR('',#686,1.); +#415=VECTOR('',#687,1.); +#416=VECTOR('',#688,1.); +#417=VECTOR('',#689,1.); +#418=VECTOR('',#690,1.); +#419=VECTOR('',#691,1.); +#420=VECTOR('',#692,1.); +#421=VECTOR('',#693,1.); +#422=VECTOR('',#696,1.); +#423=VECTOR('',#699,1.); +#424=VECTOR('',#700,1.); +#425=VECTOR('',#703,1.); +#426=VECTOR('',#706,1.); +#427=VECTOR('',#707,1.); +#428=VECTOR('',#714,1.); +#429=VECTOR('',#715,1.); +#430=VECTOR('',#718,1.); +#431=VECTOR('',#721,1.); +#432=VECTOR('',#724,1.); +#433=VECTOR('',#727,1.); +#434=VECTOR('',#730,1.); +#435=VECTOR('',#733,1.); +#436=VECTOR('',#736,1.); +#437=VECTOR('',#739,1.); +#438=VECTOR('',#742,1.); +#439=VECTOR('',#745,1.); +#440=VECTOR('',#748,1.); +#441=VECTOR('',#751,1.); +#442=VECTOR('',#754,1.); +#443=VECTOR('',#757,1.); +#444=VECTOR('',#762,1.); +#445=VECTOR('',#763,1.); +#446=VECTOR('',#766,1.); +#447=VECTOR('',#767,1.); +#448=VECTOR('',#770,1.); +#449=VECTOR('',#779,1.); +#450=VECTOR('',#780,1.); +#451=VECTOR('',#783,1.); +#452=VECTOR('',#786,1.); +#453=VECTOR('',#789,1.); +#454=VECTOR('',#792,1.); +#455=EDGE_LOOP('',(#30,#31,#32,#33,#34,#35)); +#456=EDGE_LOOP('',(#36)); +#457=EDGE_LOOP('',(#37,#38,#39,#40,#41,#42,#43,#44,#45,#46,#47,#48,#49, +#50,#51,#52)); +#458=EDGE_LOOP('',(#53)); +#459=EDGE_LOOP('',(#54,#55,#56,#57,#58,#59)); +#460=EDGE_LOOP('',(#60,#61,#62,#63)); +#461=EDGE_LOOP('',(#64,#65,#66,#67,#68,#69,#70,#71,#72,#73,#74,#75,#76, +#77,#78,#79)); +#462=EDGE_LOOP('',(#80)); +#463=EDGE_LOOP('',(#81)); +#464=EDGE_LOOP('',(#82,#83,#84,#85)); +#465=EDGE_LOOP('',(#86,#87,#88,#89)); +#466=EDGE_LOOP('',(#90,#91,#92,#93)); +#467=EDGE_LOOP('',(#94,#95,#96,#97)); +#468=EDGE_LOOP('',(#98,#99,#100,#101)); +#469=EDGE_LOOP('',(#102,#103,#104,#105)); +#470=EDGE_LOOP('',(#106,#107,#108,#109)); +#471=EDGE_LOOP('',(#110,#111,#112,#113)); +#472=EDGE_LOOP('',(#114,#115,#116,#117)); +#473=EDGE_LOOP('',(#118,#119,#120,#121)); +#474=EDGE_LOOP('',(#122,#123,#124,#125)); +#475=EDGE_LOOP('',(#126,#127,#128,#129)); +#476=EDGE_LOOP('',(#130,#131,#132,#133)); +#477=EDGE_LOOP('',(#134,#135,#136,#137)); +#478=EDGE_LOOP('',(#138,#139,#140,#141)); +#479=EDGE_LOOP('',(#142,#143,#144,#145)); +#480=EDGE_LOOP('',(#146,#147,#148)); +#481=EDGE_LOOP('',(#149,#150,#151,#152)); +#482=EDGE_LOOP('',(#153,#154,#155)); +#483=EDGE_LOOP('',(#156,#157,#158,#159)); +#484=EDGE_LOOP('',(#160)); +#485=EDGE_LOOP('',(#161)); +#486=EDGE_LOOP('',(#162)); +#487=EDGE_LOOP('',(#163)); +#488=EDGE_LOOP('',(#164,#165,#166,#167)); +#489=EDGE_LOOP('',(#168,#169,#170,#171)); +#490=EDGE_LOOP('',(#172,#173,#174,#175)); +#491=EDGE_LOOP('',(#176,#177,#178,#179)); +#492=EDGE_LOOP('',(#180,#181,#182,#183)); +#493=EDGE_LOOP('',(#184,#185,#186,#187)); +#494=FACE_BOUND('',#455,.T.); +#495=FACE_BOUND('',#456,.T.); +#496=FACE_BOUND('',#457,.T.); +#497=FACE_BOUND('',#458,.T.); +#498=FACE_BOUND('',#459,.T.); +#499=FACE_BOUND('',#460,.T.); +#500=FACE_BOUND('',#461,.T.); +#501=FACE_BOUND('',#462,.T.); +#502=FACE_BOUND('',#463,.T.); +#503=FACE_BOUND('',#464,.T.); +#504=FACE_BOUND('',#465,.T.); +#505=FACE_BOUND('',#466,.T.); +#506=FACE_BOUND('',#467,.T.); +#507=FACE_BOUND('',#468,.T.); +#508=FACE_BOUND('',#469,.T.); +#509=FACE_BOUND('',#470,.T.); +#510=FACE_BOUND('',#471,.T.); +#511=FACE_BOUND('',#472,.T.); +#512=FACE_BOUND('',#473,.T.); +#513=FACE_BOUND('',#474,.T.); +#514=FACE_BOUND('',#475,.T.); +#515=FACE_BOUND('',#476,.T.); +#516=FACE_BOUND('',#477,.T.); +#517=FACE_BOUND('',#478,.T.); +#518=FACE_BOUND('',#479,.T.); +#519=FACE_BOUND('',#480,.T.); +#520=FACE_BOUND('',#481,.T.); +#521=FACE_BOUND('',#482,.T.); +#522=FACE_BOUND('',#483,.T.); +#523=FACE_BOUND('',#484,.T.); +#524=FACE_BOUND('',#485,.T.); +#525=FACE_BOUND('',#486,.T.); +#526=FACE_BOUND('',#487,.T.); +#527=FACE_BOUND('',#488,.T.); +#528=FACE_BOUND('',#489,.T.); +#529=FACE_BOUND('',#490,.T.); +#530=FACE_BOUND('',#491,.T.); +#531=FACE_BOUND('',#492,.T.); +#532=FACE_BOUND('',#493,.T.); +#533=PLANE('',#600); +#534=PLANE('',#607); +#535=PLANE('',#614); +#536=PLANE('',#615); +#537=PLANE('',#616); +#538=PLANE('',#617); +#539=PLANE('',#618); +#540=PLANE('',#619); +#541=PLANE('',#621); +#542=PLANE('',#623); +#543=PLANE('',#624); +#544=PLANE('',#626); +#545=PLANE('',#628); +#546=PLANE('',#629); +#547=PLANE('',#630); +#548=PLANE('',#631); +#549=PLANE('',#632); +#550=PLANE('',#633); +#551=PLANE('',#636); +#552=PLANE('',#637); +#553=PLANE('',#638); +#554=PLANE('',#639); +#555=PLANE('',#640); +#556=PLANE('',#641); +#557=ADVANCED_FACE('',(#494,#495,#496,#497),#533,.F.); +#558=ADVANCED_FACE('',(#498,#499,#500,#501,#502),#534,.T.); +#559=ADVANCED_FACE('',(#503),#535,.F.); +#560=ADVANCED_FACE('',(#504),#536,.F.); +#561=ADVANCED_FACE('',(#505),#537,.F.); +#562=ADVANCED_FACE('',(#506),#538,.F.); +#563=ADVANCED_FACE('',(#507),#539,.F.); +#564=ADVANCED_FACE('',(#508),#540,.T.); +#565=ADVANCED_FACE('',(#509),#12,.T.); +#566=ADVANCED_FACE('',(#510),#541,.T.); +#567=ADVANCED_FACE('',(#511),#13,.T.); +#568=ADVANCED_FACE('',(#512),#542,.T.); +#569=ADVANCED_FACE('',(#513),#543,.T.); +#570=ADVANCED_FACE('',(#514),#14,.T.); +#571=ADVANCED_FACE('',(#515),#544,.T.); +#572=ADVANCED_FACE('',(#516),#15,.T.); +#573=ADVANCED_FACE('',(#517),#545,.T.); +#574=ADVANCED_FACE('',(#518),#546,.F.); +#575=ADVANCED_FACE('',(#519),#547,.T.); +#576=ADVANCED_FACE('',(#520),#548,.T.); +#577=ADVANCED_FACE('',(#521),#549,.T.); +#578=ADVANCED_FACE('',(#522),#550,.T.); +#579=ADVANCED_FACE('',(#523,#524),#16,.F.); +#580=ADVANCED_FACE('',(#525,#526),#17,.F.); +#581=ADVANCED_FACE('',(#527),#551,.F.); +#582=ADVANCED_FACE('',(#528),#552,.F.); +#583=ADVANCED_FACE('',(#529),#553,.F.); +#584=ADVANCED_FACE('',(#530),#554,.F.); +#585=ADVANCED_FACE('',(#531),#555,.F.); +#586=ADVANCED_FACE('',(#532),#556,.F.); +#587=CLOSED_SHELL('',(#557,#558,#559,#560,#561,#562,#563,#564,#565,#566, +#567,#568,#569,#570,#571,#572,#573,#574,#575,#576,#577,#578,#579,#580,#581, +#582,#583,#584,#585,#586)); +#588=STYLED_ITEM('',(#589),#596); +#589=PRESENTATION_STYLE_ASSIGNMENT((#590)); +#590=SURFACE_STYLE_USAGE(.BOTH.,#591); +#591=SURFACE_SIDE_STYLE('',(#592)); +#592=SURFACE_STYLE_FILL_AREA(#593); +#593=FILL_AREA_STYLE('',(#594)); +#594=FILL_AREA_STYLE_COLOUR('',#595); +#595=COLOUR_RGB('',0.917647058823529,0.917647058823529,0.917647058823529); +#596=MANIFOLD_SOLID_BREP('Part 1',#587); +#597=SHAPE_DEFINITION_REPRESENTATION(#965,#598); +#598=SHAPE_REPRESENTATION('Part 1',(#599),#960); +#599=AXIS2_PLACEMENT_3D('',#795,#642,#643); +#600=AXIS2_PLACEMENT_3D('',#796,#644,#645); +#601=AXIS2_PLACEMENT_3D('',#809,#652,#653); +#602=AXIS2_PLACEMENT_3D('',#818,#657,#658); +#603=AXIS2_PLACEMENT_3D('',#822,#660,#661); +#604=AXIS2_PLACEMENT_3D('',#828,#664,#665); +#605=AXIS2_PLACEMENT_3D('',#832,#667,#668); +#606=AXIS2_PLACEMENT_3D('',#843,#674,#675); +#607=AXIS2_PLACEMENT_3D('',#845,#676,#677); +#608=AXIS2_PLACEMENT_3D('',#879,#694,#695); +#609=AXIS2_PLACEMENT_3D('',#883,#697,#698); +#610=AXIS2_PLACEMENT_3D('',#889,#701,#702); +#611=AXIS2_PLACEMENT_3D('',#893,#704,#705); +#612=AXIS2_PLACEMENT_3D('',#898,#708,#709); +#613=AXIS2_PLACEMENT_3D('',#900,#710,#711); +#614=AXIS2_PLACEMENT_3D('',#902,#712,#713); +#615=AXIS2_PLACEMENT_3D('',#905,#716,#717); +#616=AXIS2_PLACEMENT_3D('',#907,#719,#720); +#617=AXIS2_PLACEMENT_3D('',#909,#722,#723); +#618=AXIS2_PLACEMENT_3D('',#911,#725,#726); +#619=AXIS2_PLACEMENT_3D('',#913,#728,#729); +#620=AXIS2_PLACEMENT_3D('',#915,#731,#732); +#621=AXIS2_PLACEMENT_3D('',#917,#734,#735); +#622=AXIS2_PLACEMENT_3D('',#919,#737,#738); +#623=AXIS2_PLACEMENT_3D('',#921,#740,#741); +#624=AXIS2_PLACEMENT_3D('',#923,#743,#744); +#625=AXIS2_PLACEMENT_3D('',#925,#746,#747); +#626=AXIS2_PLACEMENT_3D('',#927,#749,#750); +#627=AXIS2_PLACEMENT_3D('',#929,#752,#753); +#628=AXIS2_PLACEMENT_3D('',#931,#755,#756); +#629=AXIS2_PLACEMENT_3D('',#933,#758,#759); +#630=AXIS2_PLACEMENT_3D('',#934,#760,#761); +#631=AXIS2_PLACEMENT_3D('',#938,#764,#765); +#632=AXIS2_PLACEMENT_3D('',#942,#768,#769); +#633=AXIS2_PLACEMENT_3D('',#944,#771,#772); +#634=AXIS2_PLACEMENT_3D('',#945,#773,#774); +#635=AXIS2_PLACEMENT_3D('',#946,#775,#776); +#636=AXIS2_PLACEMENT_3D('',#947,#777,#778); +#637=AXIS2_PLACEMENT_3D('',#950,#781,#782); +#638=AXIS2_PLACEMENT_3D('',#952,#784,#785); +#639=AXIS2_PLACEMENT_3D('',#954,#787,#788); +#640=AXIS2_PLACEMENT_3D('',#956,#790,#791); +#641=AXIS2_PLACEMENT_3D('',#958,#793,#794); +#642=DIRECTION('',(0.,0.,1.)); +#643=DIRECTION('',(1.,0.,0.)); +#644=DIRECTION('',(0.,1.,0.)); +#645=DIRECTION('',(1.,0.,0.)); +#646=DIRECTION('',(-0.266495428889882,0.,0.963836182336396)); +#647=DIRECTION('',(-0.999997598615371,0.,0.00219152081706934)); +#648=DIRECTION('',(-1.,0.,0.)); +#649=DIRECTION('',(0.,0.,1.)); +#650=DIRECTION('',(1.,0.,0.)); +#651=DIRECTION('',(-0.213058124893242,0.,-0.977039526025931)); +#652=DIRECTION('',(0.,1.,0.)); +#653=DIRECTION('',(1.,0.,0.)); +#654=DIRECTION('',(-0.303682828230025,0.,-0.952773183836643)); +#655=DIRECTION('',(0.707106781186547,0.,-0.707106781186548)); +#656=DIRECTION('',(-1.,0.,0.)); +#657=DIRECTION('',(0.,1.,0.)); +#658=DIRECTION('',(1.,0.,0.)); +#659=DIRECTION('',(-0.707106781186547,0.,-0.707106781186548)); +#660=DIRECTION('',(0.,1.,0.)); +#661=DIRECTION('',(1.,0.,0.)); +#662=DIRECTION('',(0.303682828230025,0.,-0.952773183836643)); +#663=DIRECTION('',(-0.718602345804687,0.,-0.695421216676628)); +#664=DIRECTION('',(0.,1.,0.)); +#665=DIRECTION('',(1.,0.,0.)); +#666=DIRECTION('',(0.695421216676628,0.,-0.718602345804686)); +#667=DIRECTION('',(0.,1.,0.)); +#668=DIRECTION('',(1.,0.,0.)); +#669=DIRECTION('',(0.718602345804687,0.,0.695421216676628)); +#670=DIRECTION('',(-1.,0.,0.)); +#671=DIRECTION('',(-0.718602345804687,0.,0.695421216676628)); +#672=DIRECTION('',(-0.695421216676628,0.,-0.718602345804686)); +#673=DIRECTION('',(0.718602345804687,0.,-0.695421216676628)); +#674=DIRECTION('',(0.,1.,0.)); +#675=DIRECTION('',(1.,0.,0.)); +#676=DIRECTION('',(0.,1.,0.)); +#677=DIRECTION('',(1.,0.,0.)); +#678=DIRECTION('',(-0.999997598615371,0.,0.00219152081706934)); +#679=DIRECTION('',(-0.266495428889882,0.,0.963836182336396)); +#680=DIRECTION('',(-0.213058124893242,0.,-0.977039526025931)); +#681=DIRECTION('',(1.,0.,0.)); +#682=DIRECTION('',(0.,0.,1.)); +#683=DIRECTION('',(-1.,0.,0.)); +#684=DIRECTION('',(0.085015328616355,0.,0.996379643459386)); +#685=DIRECTION('',(-1.,0.,3.06657471648694E-16)); +#686=DIRECTION('',(0.085015328616355,0.,-0.996379643459386)); +#687=DIRECTION('',(1.,0.,0.)); +#688=DIRECTION('',(-0.303682828230025,0.,-0.952773183836643)); +#689=DIRECTION('',(0.718602345804687,0.,-0.695421216676628)); +#690=DIRECTION('',(-0.695421216676628,0.,-0.718602345804686)); +#691=DIRECTION('',(-0.718602345804687,0.,0.695421216676628)); +#692=DIRECTION('',(-1.,0.,0.)); +#693=DIRECTION('',(0.718602345804687,0.,0.695421216676628)); +#694=DIRECTION('',(0.,1.,0.)); +#695=DIRECTION('',(1.,0.,0.)); +#696=DIRECTION('',(0.695421216676628,0.,-0.718602345804686)); +#697=DIRECTION('',(0.,1.,0.)); +#698=DIRECTION('',(1.,0.,0.)); +#699=DIRECTION('',(-0.718602345804687,0.,-0.695421216676628)); +#700=DIRECTION('',(0.303682828230025,0.,-0.952773183836643)); +#701=DIRECTION('',(0.,1.,0.)); +#702=DIRECTION('',(1.,0.,0.)); +#703=DIRECTION('',(-0.707106781186547,0.,-0.707106781186548)); +#704=DIRECTION('',(0.,1.,0.)); +#705=DIRECTION('',(1.,0.,0.)); +#706=DIRECTION('',(-1.,0.,0.)); +#707=DIRECTION('',(0.707106781186547,0.,-0.707106781186548)); +#708=DIRECTION('',(0.,1.,0.)); +#709=DIRECTION('',(1.,0.,0.)); +#710=DIRECTION('',(0.,-1.,0.)); +#711=DIRECTION('',(1.,0.,0.)); +#712=DIRECTION('',(-0.952773183836643,0.,0.303682828230025)); +#713=DIRECTION('',(0.303682828230025,0.,0.952773183836643)); +#714=DIRECTION('',(0.,-1.,0.)); +#715=DIRECTION('',(0.,-1.,0.)); +#716=DIRECTION('',(-0.695421216676628,0.,-0.718602345804686)); +#717=DIRECTION('',(-0.718602345804687,0.,0.695421216676628)); +#718=DIRECTION('',(0.,-1.,0.)); +#719=DIRECTION('',(-0.718602345804686,0.,0.695421216676628)); +#720=DIRECTION('',(0.695421216676628,0.,0.718602345804686)); +#721=DIRECTION('',(0.,-1.,0.)); +#722=DIRECTION('',(0.695421216676628,0.,0.718602345804687)); +#723=DIRECTION('',(0.718602345804687,0.,-0.695421216676628)); +#724=DIRECTION('',(0.,-1.,0.)); +#725=DIRECTION('',(0.,0.,1.)); +#726=DIRECTION('',(0.,-1.,0.)); +#727=DIRECTION('',(0.,-1.,0.)); +#728=DIRECTION('',(0.695421216676628,0.,-0.718602345804686)); +#729=DIRECTION('',(-0.718602345804687,0.,-0.695421216676628)); +#730=DIRECTION('',(0.,-1.,0.)); +#731=DIRECTION('',(0.,-1.,0.)); +#732=DIRECTION('',(-1.,0.,0.)); +#733=DIRECTION('',(0.,-1.,0.)); +#734=DIRECTION('',(-0.718602345804686,0.,-0.695421216676628)); +#735=DIRECTION('',(-0.695421216676628,0.,0.718602345804686)); +#736=DIRECTION('',(0.,-1.,0.)); +#737=DIRECTION('',(0.,-1.,0.)); +#738=DIRECTION('',(-1.,0.,0.)); +#739=DIRECTION('',(0.,-1.,0.)); +#740=DIRECTION('',(-0.695421216676628,0.,0.718602345804686)); +#741=DIRECTION('',(0.718602345804687,0.,0.695421216676628)); +#742=DIRECTION('',(0.,-1.,0.)); +#743=DIRECTION('',(-0.952773183836643,0.,-0.303682828230025)); +#744=DIRECTION('',(-0.303682828230025,0.,0.952773183836643)); +#745=DIRECTION('',(0.,-1.,0.)); +#746=DIRECTION('',(0.,-1.,0.)); +#747=DIRECTION('',(-1.,0.,0.)); +#748=DIRECTION('',(0.,-1.,0.)); +#749=DIRECTION('',(-0.707106781186548,0.,0.707106781186547)); +#750=DIRECTION('',(0.707106781186547,0.,0.707106781186548)); +#751=DIRECTION('',(0.,-1.,0.)); +#752=DIRECTION('',(0.,-1.,0.)); +#753=DIRECTION('',(-1.,0.,0.)); +#754=DIRECTION('',(0.,-1.,0.)); +#755=DIRECTION('',(0.,0.,1.)); +#756=DIRECTION('',(0.,-1.,0.)); +#757=DIRECTION('',(0.,-1.,0.)); +#758=DIRECTION('',(-0.707106781186548,0.,-0.707106781186547)); +#759=DIRECTION('',(-0.707106781186547,0.,0.707106781186548)); +#760=DIRECTION('',(2.88163763217142E-16,0.342020143325668,0.939692620785908)); +#761=DIRECTION('',(-1.04883032405173E-16,0.939692620785908,-0.342020143325668)); +#762=DIRECTION('',(-0.349023821871155,0.88059897163896,-0.320511814002009)); +#763=DIRECTION('',(-0.349023821871155,-0.88059897163896,0.320511814002009)); +#764=DIRECTION('',(0.93629059846008,0.342020143325668,-0.0798882769544779)); +#765=DIRECTION('',(-0.340781908462758,0.939692620785908,0.0290769548782445)); +#766=DIRECTION('',(5.27512265516559E-18,0.227455280238187,0.973788527089824)); +#767=DIRECTION('',(0.29964820828413,-0.89651352264206,-0.326304236858849)); +#768=DIRECTION('',(0.,0.342020143325668,-0.939692620785908)); +#769=DIRECTION('',(0.,0.939692620785908,0.342020143325668)); +#770=DIRECTION('',(0.29964820828413,0.89651352264206,0.326304236858849)); +#771=DIRECTION('',(-0.93629059846008,0.342020143325668,-0.0798882769544778)); +#772=DIRECTION('',(0.340781908462758,0.939692620785908,0.0290769548782445)); +#773=DIRECTION('',(0.,1.,0.)); +#774=DIRECTION('',(1.,0.,0.)); +#775=DIRECTION('',(0.,1.,0.)); +#776=DIRECTION('',(1.,0.,0.)); +#777=DIRECTION('',(-0.00219152081706934,0.,-0.999997598615371)); +#778=DIRECTION('',(-0.999997598615371,0.,0.00219152081706934)); +#779=DIRECTION('',(0.,1.,0.)); +#780=DIRECTION('',(0.,1.,0.)); +#781=DIRECTION('',(0.,0.,-1.)); +#782=DIRECTION('',(0.,1.,0.)); +#783=DIRECTION('',(0.,1.,0.)); +#784=DIRECTION('',(-1.,0.,0.)); +#785=DIRECTION('',(0.,1.,0.)); +#786=DIRECTION('',(0.,1.,0.)); +#787=DIRECTION('',(0.,0.,1.)); +#788=DIRECTION('',(0.,-1.,0.)); +#789=DIRECTION('',(0.,1.,0.)); +#790=DIRECTION('',(0.977039526025931,0.,-0.213058124893242)); +#791=DIRECTION('',(-0.213058124893242,0.,-0.977039526025931)); +#792=DIRECTION('',(0.,1.,0.)); +#793=DIRECTION('',(-0.963836182336396,0.,-0.266495428889882)); +#794=DIRECTION('',(-0.266495428889882,0.,0.963836182336396)); +#795=CARTESIAN_POINT('',(0.,0.,0.)); +#796=CARTESIAN_POINT('',(0.000403056515455171,0.,-0.0333451765527338)); +#797=CARTESIAN_POINT('',(0.0215502388783335,0.,-0.0274980963778158)); +#798=CARTESIAN_POINT('',(0.0190522437020891,0.,-0.0184635769333528)); +#799=CARTESIAN_POINT('',(0.0164742856100485,0.,-0.00913985496181237)); +#800=CARTESIAN_POINT('',(0.000456180040532926,0.,-0.00910475086570832)); +#801=CARTESIAN_POINT('',(-5.20417042793042E-18,0.,-0.00910375113525243)); +#802=CARTESIAN_POINT('',(0.000403056515455171,0.,-0.00910375113525243)); +#803=CARTESIAN_POINT('',(-0.0219255222314632,0.,-0.00910375113525243)); +#804=CARTESIAN_POINT('',(-0.0219255222314632,0.,-0.0333451765527338)); +#805=CARTESIAN_POINT('',(-0.0219255222314632,0.,-0.00353451976078464)); +#806=CARTESIAN_POINT('',(0.000403056515455171,0.,-0.00353451976078464)); +#807=CARTESIAN_POINT('',(0.0223077485643632,0.,-0.00353451976078463)); +#808=CARTESIAN_POINT('',(0.0151078407072698,0.,-0.0365517753273105)); +#809=CARTESIAN_POINT('',(-0.016428276640485,0.,-0.0480095168479337)); +#810=CARTESIAN_POINT('',(-0.0114927539352016,0.,-0.0480095168479337)); +#811=CARTESIAN_POINT('',(0.0343907974147891,0.,-0.0311671147355531)); +#812=CARTESIAN_POINT('',(0.0420710678118655,0.,-0.00707106781186548)); +#813=CARTESIAN_POINT('',(0.0267105270177126,0.,-0.0552631616592407)); +#814=CARTESIAN_POINT('',(0.0385355339059327,0.,-0.00353553390593274)); +#815=CARTESIAN_POINT('',(0.035,0.,0.)); +#816=CARTESIAN_POINT('',(-0.016287587590799,0.,0.)); +#817=CARTESIAN_POINT('',(-0.0325751751815982,0.,-1.56125112837912E-17)); +#818=CARTESIAN_POINT('',(-0.0325751751815982,0.,-0.0058540449629647)); +#819=CARTESIAN_POINT('',(-0.0367146100722814,0.,-0.00171461007228142)); +#820=CARTESIAN_POINT('',(-0.0381324773904776,0.,-0.00313247739047756)); +#821=CARTESIAN_POINT('',(-0.0395503447086737,0.,-0.00455034470867372)); +#822=CARTESIAN_POINT('',(-0.0354109098179905,0.,-0.008689779599357)); +#823=CARTESIAN_POINT('',(-0.0409884868756772,0.,-0.0104675525302958)); +#824=CARTESIAN_POINT('',(-0.0338495069466949,0.,-0.0328653570947683)); +#825=CARTESIAN_POINT('',(-0.0267105270177126,0.,-0.0552631616592407)); +#826=CARTESIAN_POINT('',(-0.0277692838722956,0.,-0.056287764465337)); +#827=CARTESIAN_POINT('',(-0.0288280407268787,0.,-0.0573123672714333)); +#828=CARTESIAN_POINT('',(-0.0253509346434956,0.,-0.0609053790004567)); +#829=CARTESIAN_POINT('',(-0.0289439463725189,0.,-0.0643824850838399)); +#830=CARTESIAN_POINT('',(-0.0283309052949746,0.,-0.0650159612358953)); +#831=CARTESIAN_POINT('',(-0.0277178642174302,0.,-0.0656494373879507)); +#832=CARTESIAN_POINT('',(-0.0266769484999133,0.,-0.0646421001882331)); +#833=CARTESIAN_POINT('',(-0.0256696113001957,0.,-0.06568301590575)); +#834=CARTESIAN_POINT('',(-0.0231412794977803,0.,-0.0632362446302832)); +#835=CARTESIAN_POINT('',(-0.0206129476953649,0.,-0.0607894733548164)); +#836=CARTESIAN_POINT('',(0.0103064738476824,0.,-0.0607894733548164)); +#837=CARTESIAN_POINT('',(0.0206129476953649,0.,-0.0607894733548164)); +#838=CARTESIAN_POINT('',(0.0236617373565387,0.,-0.063739913230142)); +#839=CARTESIAN_POINT('',(0.0267105270177126,0.,-0.0666903531054676)); +#840=CARTESIAN_POINT('',(0.0295657897368073,0.,-0.063739913230142)); +#841=CARTESIAN_POINT('',(0.0324210524559021,0.,-0.0607894733548164)); +#842=CARTESIAN_POINT('',(0.0295657897368073,0.,-0.0580263175070286)); +#843=CARTESIAN_POINT('',(0.016428276640485,0.,-0.0480095168479337)); +#844=CARTESIAN_POINT('',(0.0213637993457684,0.,-0.0480095168479337)); +#845=CARTESIAN_POINT('',(0.000403056515455171,0.003,-0.0333451765527338)); +#846=CARTESIAN_POINT('',(0.00823714280502423,0.003,-0.0091218030485324)); +#847=CARTESIAN_POINT('',(0.0164742856100485,0.003,-0.00913985496181237)); +#848=CARTESIAN_POINT('',(-5.20417042793042E-18,0.003,-0.00910375113525243)); +#849=CARTESIAN_POINT('',(0.0177632646560688,0.003,-0.0138017159475826)); +#850=CARTESIAN_POINT('',(0.0190522437020891,0.003,-0.0184635769333528)); +#851=CARTESIAN_POINT('',(0.0206799961332261,0.003,-0.0109990483470687)); +#852=CARTESIAN_POINT('',(0.0223077485643632,0.003,-0.00353451976078463)); +#853=CARTESIAN_POINT('',(0.000191113166449982,0.003,-0.00353451976078464)); +#854=CARTESIAN_POINT('',(-0.0219255222314632,0.003,-0.00353451976078464)); +#855=CARTESIAN_POINT('',(-0.0219255222314632,0.003,-0.00631913544801854)); +#856=CARTESIAN_POINT('',(-0.0219255222314632,0.003,-0.00910375113525243)); +#857=CARTESIAN_POINT('',(-0.0109627611157316,0.003,-0.00910375113525243)); +#858=CARTESIAN_POINT('',(0.00563643376336433,0.003,-0.0134438107408346)); +#859=CARTESIAN_POINT('',(0.00565687660127879,0.003,-0.0132042206823826)); +#860=CARTESIAN_POINT('',(0.0025594683829695,0.003,-0.0495058447122574)); +#861=CARTESIAN_POINT('',(0.00282843830063939,0.003,-0.0132042206823826)); +#862=CARTESIAN_POINT('',(-0.00565687660127879,0.003,-0.0132042206823826)); +#863=CARTESIAN_POINT('',(-0.00559554808753541,0.003,-0.0139229908577385)); +#864=CARTESIAN_POINT('',(-0.0025594683829695,0.003,-0.0495058447122574)); +#865=CARTESIAN_POINT('',(0.00282843830063939,0.003,-0.0495058447122574)); +#866=CARTESIAN_POINT('',(0.0343907974147891,0.003,-0.0311671147355531)); +#867=CARTESIAN_POINT('',(0.0420710678118655,0.003,-0.00707106781186548)); +#868=CARTESIAN_POINT('',(0.0267105270177126,0.003,-0.0552631616592407)); +#869=CARTESIAN_POINT('',(0.0295657897368073,0.003,-0.0580263175070286)); +#870=CARTESIAN_POINT('',(0.0324210524559021,0.003,-0.0607894733548164)); +#871=CARTESIAN_POINT('',(0.0295657897368073,0.003,-0.063739913230142)); +#872=CARTESIAN_POINT('',(0.0267105270177126,0.003,-0.0666903531054676)); +#873=CARTESIAN_POINT('',(0.0236617373565387,0.003,-0.063739913230142)); +#874=CARTESIAN_POINT('',(0.0206129476953649,0.003,-0.0607894733548164)); +#875=CARTESIAN_POINT('',(0.0103064738476824,0.003,-0.0607894733548164)); +#876=CARTESIAN_POINT('',(-0.0206129476953649,0.003,-0.0607894733548164)); +#877=CARTESIAN_POINT('',(-0.0231412794977803,0.003,-0.0632362446302832)); +#878=CARTESIAN_POINT('',(-0.0256696113001957,0.003,-0.06568301590575)); +#879=CARTESIAN_POINT('',(-0.0266769484999133,0.003,-0.0646421001882331)); +#880=CARTESIAN_POINT('',(-0.0277178642174302,0.003,-0.0656494373879507)); +#881=CARTESIAN_POINT('',(-0.0283309052949746,0.003,-0.0650159612358953)); +#882=CARTESIAN_POINT('',(-0.0289439463725189,0.003,-0.0643824850838399)); +#883=CARTESIAN_POINT('',(-0.0253509346434956,0.003,-0.0609053790004567)); +#884=CARTESIAN_POINT('',(-0.0288280407268787,0.003,-0.0573123672714333)); +#885=CARTESIAN_POINT('',(-0.0277692838722956,0.003,-0.056287764465337)); +#886=CARTESIAN_POINT('',(-0.0267105270177126,0.003,-0.0552631616592407)); +#887=CARTESIAN_POINT('',(-0.0338495069466949,0.003,-0.0328653570947683)); +#888=CARTESIAN_POINT('',(-0.0409884868756772,0.003,-0.0104675525302958)); +#889=CARTESIAN_POINT('',(-0.0354109098179905,0.003,-0.008689779599357)); +#890=CARTESIAN_POINT('',(-0.0395503447086737,0.003,-0.00455034470867372)); +#891=CARTESIAN_POINT('',(-0.0381324773904776,0.003,-0.00313247739047756)); +#892=CARTESIAN_POINT('',(-0.0367146100722814,0.003,-0.00171461007228142)); +#893=CARTESIAN_POINT('',(-0.0325751751815982,0.003,-0.0058540449629647)); +#894=CARTESIAN_POINT('',(-0.0325751751815982,0.003,-1.56125112837912E-17)); +#895=CARTESIAN_POINT('',(-0.016287587590799,0.003,0.)); +#896=CARTESIAN_POINT('',(0.035,0.003,0.)); +#897=CARTESIAN_POINT('',(0.0385355339059327,0.003,-0.00353553390593274)); +#898=CARTESIAN_POINT('',(-0.016428276640485,0.003,-0.0480095168479337)); +#899=CARTESIAN_POINT('',(-0.0114927539352016,0.003,-0.0480095168479337)); +#900=CARTESIAN_POINT('',(0.016428276640485,0.003,-0.0480095168479337)); +#901=CARTESIAN_POINT('',(0.0213637993457684,0.003,-0.0480095168479337)); +#902=CARTESIAN_POINT('',(0.0343907974147891,0.003,-0.0311671147355531)); +#903=CARTESIAN_POINT('',(0.0267105270177126,0.003,-0.0552631616592407)); +#904=CARTESIAN_POINT('',(0.0420710678118655,0.003,-0.00707106781186548)); +#905=CARTESIAN_POINT('',(0.0295657897368073,0.003,-0.0580263175070286)); +#906=CARTESIAN_POINT('',(0.0324210524559021,0.003,-0.0607894733548164)); +#907=CARTESIAN_POINT('',(0.0295657897368073,0.003,-0.063739913230142)); +#908=CARTESIAN_POINT('',(0.0267105270177126,0.003,-0.0666903531054676)); +#909=CARTESIAN_POINT('',(0.0236617373565387,0.003,-0.063739913230142)); +#910=CARTESIAN_POINT('',(0.0206129476953649,0.003,-0.0607894733548164)); +#911=CARTESIAN_POINT('',(0.0103064738476824,0.003,-0.0607894733548164)); +#912=CARTESIAN_POINT('',(-0.0206129476953649,0.003,-0.0607894733548164)); +#913=CARTESIAN_POINT('',(-0.0231412794977803,0.003,-0.0632362446302832)); +#914=CARTESIAN_POINT('',(-0.0256696113001957,0.003,-0.06568301590575)); +#915=CARTESIAN_POINT('',(-0.0266769484999133,0.003,-0.0646421001882331)); +#916=CARTESIAN_POINT('',(-0.0277178642174302,0.003,-0.0656494373879507)); +#917=CARTESIAN_POINT('',(-0.0283309052949746,0.003,-0.0650159612358953)); +#918=CARTESIAN_POINT('',(-0.0289439463725189,0.003,-0.0643824850838399)); +#919=CARTESIAN_POINT('',(-0.0253509346434956,0.003,-0.0609053790004567)); +#920=CARTESIAN_POINT('',(-0.0288280407268787,0.003,-0.0573123672714333)); +#921=CARTESIAN_POINT('',(-0.0277692838722956,0.003,-0.056287764465337)); +#922=CARTESIAN_POINT('',(-0.0267105270177126,0.003,-0.0552631616592407)); +#923=CARTESIAN_POINT('',(-0.0338495069466949,0.003,-0.0328653570947683)); +#924=CARTESIAN_POINT('',(-0.0409884868756772,0.003,-0.0104675525302958)); +#925=CARTESIAN_POINT('',(-0.0354109098179905,0.003,-0.008689779599357)); +#926=CARTESIAN_POINT('',(-0.0395503447086737,0.003,-0.00455034470867372)); +#927=CARTESIAN_POINT('',(-0.0381324773904776,0.003,-0.00313247739047756)); +#928=CARTESIAN_POINT('',(-0.0367146100722814,0.003,-0.00171461007228142)); +#929=CARTESIAN_POINT('',(-0.0325751751815982,0.003,-0.0058540449629647)); +#930=CARTESIAN_POINT('',(-0.0325751751815982,0.003,-1.56125112837912E-17)); +#931=CARTESIAN_POINT('',(-0.016287587590799,0.003,0.)); +#932=CARTESIAN_POINT('',(0.035,0.003,0.)); +#933=CARTESIAN_POINT('',(0.0385355339059327,0.003,-0.00353553390593274)); +#934=CARTESIAN_POINT('',(0.00282843830063939,0.003,-0.0132042206823826)); +#935=CARTESIAN_POINT('',(0.00531232295588997,0.00386932056435923,-0.0135206274918448)); +#936=CARTESIAN_POINT('',(-1.3010426069826E-18,0.0172724920352672,-0.0183989829520213)); +#937=CARTESIAN_POINT('',(-0.00462321566511233,0.00560796169307769,-0.0141534411107693)); +#938=CARTESIAN_POINT('',(0.00563643376336433,0.003,-0.0134438107408346)); +#939=CARTESIAN_POINT('',(0.,0.0176316294050728,-0.0168614330213291)); +#940=CARTESIAN_POINT('',(-2.16840434497101E-19,0.0106576397010564,-0.0467186917963377)); +#941=CARTESIAN_POINT('',(0.0025836189360135,0.00292774427884215,-0.0495321436440143)); +#942=CARTESIAN_POINT('',(0.00282843830063939,0.003,-0.0495058447122574)); +#943=CARTESIAN_POINT('',(-0.00207569336721363,0.0044474000896754,-0.0489790341625413)); +#944=CARTESIAN_POINT('',(-0.00559554808753541,0.003,-0.0139229908577385)); +#945=CARTESIAN_POINT('',(-0.016428276640485,-0.022,-0.0480095168479337)); +#946=CARTESIAN_POINT('',(0.016428276640485,-0.022,-0.0480095168479337)); +#947=CARTESIAN_POINT('',(0.00823714280502423,-0.022,-0.0091218030485324)); +#948=CARTESIAN_POINT('',(0.0164742856100485,-0.022,-0.00913985496181237)); +#949=CARTESIAN_POINT('',(-5.20417042793042E-18,-0.022,-0.00910375113525243)); +#950=CARTESIAN_POINT('',(-0.0109627611157316,-0.022,-0.00910375113525243)); +#951=CARTESIAN_POINT('',(-0.0219255222314632,-0.022,-0.00910375113525243)); +#952=CARTESIAN_POINT('',(-0.0219255222314632,-0.022,-0.00631913544801854)); +#953=CARTESIAN_POINT('',(-0.0219255222314632,-0.022,-0.00353451976078464)); +#954=CARTESIAN_POINT('',(0.000191113166449982,-0.022,-0.00353451976078464)); +#955=CARTESIAN_POINT('',(0.0223077485643632,-0.022,-0.00353451976078463)); +#956=CARTESIAN_POINT('',(0.0206799961332261,-0.022,-0.0109990483470687)); +#957=CARTESIAN_POINT('',(0.0190522437020891,-0.022,-0.0184635769333528)); +#958=CARTESIAN_POINT('',(0.0177632646560688,-0.022,-0.0138017159475826)); +#959=MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',(#588), +#960); +#960=( +GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#961)) +GLOBAL_UNIT_ASSIGNED_CONTEXT((#964,#963,#962)) +REPRESENTATION_CONTEXT('Part 1','TOP_LEVEL_ASSEMBLY_PART') +); +#961=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-8),#964, +'DISTANCE_ACCURACY_VALUE','Maximum Tolerance applied to model'); +#962=( +NAMED_UNIT(*) +SI_UNIT($,.STERADIAN.) +SOLID_ANGLE_UNIT() +); +#963=( +NAMED_UNIT(*) +PLANE_ANGLE_UNIT() +SI_UNIT($,.RADIAN.) +); +#964=( +LENGTH_UNIT() +NAMED_UNIT(*) +SI_UNIT($,.METRE.) +); +#965=PRODUCT_DEFINITION_SHAPE('','',#966); +#966=PRODUCT_DEFINITION('','',#968,#967); +#967=PRODUCT_DEFINITION_CONTEXT('',#974,'design'); +#968=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#970, + .NOT_KNOWN.); +#969=PRODUCT_RELATED_PRODUCT_CATEGORY('','',(#970)); +#970=PRODUCT('Part 1','Part 1','Part 1',(#972)); +#971=PRODUCT_CATEGORY('',''); +#972=PRODUCT_CONTEXT('',#974,'mechanical'); +#973=APPLICATION_PROTOCOL_DEFINITION('international standard', +'ap242_managed_model_based_3d_engineering',2020,#974); +#974=APPLICATION_CONTEXT('managed model based 3d engineering'); +ENDSEC; +END-ISO-10303-21; diff --git a/docs/CAD/design/mate-connectors/bear_glyph_table.h b/docs/CAD/design/mate-connectors/bear_glyph_table.h new file mode 100644 index 0000000000..c26631e7e0 --- /dev/null +++ b/docs/CAD/design/mate-connectors/bear_glyph_table.h @@ -0,0 +1,30 @@ +// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit. +// Normalised to the part's bounding span and centred: the renderer scales by one radius. +static const Vec2d kBearOutline[] = { // 12 verts, RDP eps 0.030, CCW + {+0.3842, +0.3294}, {+0.3156, +0.4002}, {+0.2424, +0.3294}, + {-0.2524, +0.3294}, {-0.3377, +0.3877}, {-0.3693, +0.3298}, + {-0.3256, +0.2631}, {-0.4893, -0.3337}, {-0.3960, -0.4002}, + {+0.4151, -0.4002}, {+0.5000, -0.3154}, {+0.3156, +0.2631}, +}; +static const Vec2d kBearChin[] = { // the CHIN BAR, flat. The muzzle is relief — see kBearCrest. + {-0.2682, -0.3578}, {+0.2628, -0.3578}, {+0.2237, -0.1786}, +}; +// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (wi3z). +static const Vec3d kBearMarks[] = { + {-0.1997, +0.1760, +0.0590}, + {+0.1947, +0.1760, +0.0590}, + {+0.2797, +0.0760, +0.0380}, +}; +// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets. +// This is the only feature standing along +Z and the only one still legible edge-on. +static const double kBearPlateZ = +0.0360; +static const Vec2d kBearSnoutBase[] = { // CCW from the nose end + {-0.0727, -0.2417}, + {+0.0630, -0.2417}, + {+0.0259, +0.1939}, + {-0.0356, +0.1939}, +}; +static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short) + {-0.0048, -0.1793, +0.2073}, + {-0.0048, +0.1605, +0.1279}, +}; diff --git a/docs/CAD/design/mate-connectors/bear_mesh.json b/docs/CAD/design/mate-connectors/bear_mesh.json new file mode 100644 index 0000000000..4bfb6aa361 --- /dev/null +++ b/docs/CAD/design/mate-connectors/bear_mesh.json @@ -0,0 +1 @@ +{"v": [[-36.7146, 0.0, -1.7146], [-36.0624, 0.0, -1.152], [-36.5065, 0.0, -1.5165], [-36.2889, 0.0, -1.3288], [-39.9409, 0.0, -4.9818], [-40.1196, 0.0, -5.2115], [-39.751, 0.0, -4.7612], [-39.5503, 0.0, -4.5503], [-35.8275, 0.0, -0.9866], [-35.3348, 0.0, -0.6912], [-35.5848, 0.0, -0.8329], [-40.4417, 0.0, -5.6962], [-40.5842, 0.0, -5.95], [-40.2867, 0.0, -5.4499], [-35.0781, 0.0, -0.5621], [-40.714, 0.0, -6.2105], [-34.5473, 0.0, -0.3422], [-34.8154, 0.0, -0.4456], [-40.8307, 0.0, -6.4772], [-40.934, 0.0, -6.7493], [-41.0236, 0.0, -7.0262], [-33.9976, 0.0, -0.1754], [-34.2745, 0.0, -0.2521], [-41.161, 0.0, -7.5917], [-41.2085, 0.0, -7.8789], [-41.0994, 0.0, -7.3072], [-33.149, 0.0, -0.0282], [-33.7172, 0.0, -0.1125], [-33.4341, 0.0, -0.0634], [-32.8624, 0.0, -0.0071], [-32.5752, 0.0, -0.0], [-41.2544, 0.0, -9.0404], [-41.2298, 0.0, -9.3304], [-41.2646, 0.0, -8.7495], [-41.2604, 0.0, -8.4585], [-41.2417, 0.0, -8.168], [-41.1908, 0.0, -9.6189], [-41.1374, 0.0, -9.905], [-41.07, 0.0, -10.1881], [-40.9885, 0.0, -10.4676], [-21.9255, 0.0, -9.1038], [-21.9255, 0.0, -3.5345], [-20.0463, 0.0, -44.6525], [-19.8744, 0.0, -44.4763], [-19.694, 0.0, -44.309], [-20.2091, 0.0, -44.837], [-19.5055, 0.0, -44.1508], [-20.3625, 0.0, -45.0294], [-19.3094, 0.0, -44.0022], [-20.5062, 0.0, -45.2292], [-19.106, 0.0, -43.8635], [-20.6397, 0.0, -45.436], [-18.896, 0.0, -43.7352], [-20.7628, 0.0, -45.6491], [-18.6799, 0.0, -43.6175], [-20.875, 0.0, -45.8681], [-18.4582, 0.0, -43.5108], [-20.9762, 0.0, -46.0924], [-18.2314, 0.0, -43.4152], [-21.0662, 0.0, -46.3215], [-18.0002, 0.0, -43.331], [-17.765, 0.0, -43.2585], [-17.5265, 0.0, -43.1977], [22.3077, 0.0, -3.5345], [-17.2853, 0.0, -43.149], [-17.042, 0.0, -43.1123], [-16.7971, 0.0, -43.0878], [-16.5513, 0.0, -43.0755], [-16.3052, 0.0, -43.0755], [-16.0594, 0.0, -43.0878], [-15.8146, 0.0, -43.1123], [-15.5712, 0.0, -43.149], [-15.33, 0.0, -43.1977], [-0.0, 0.0, -9.1038], [-15.0915, 0.0, -43.2585], [-14.8564, 0.0, -43.331], [-21.2112, 0.0, -49.2274], [-26.7105, 0.0, -55.2632], [-21.1445, 0.0, -49.4643], [-21.2659, 0.0, -48.9875], [-21.3087, 0.0, -48.7451], [-21.3393, 0.0, -48.5009], [-21.3577, 0.0, -48.2555], [-21.3638, 0.0, -48.0095], [-21.3577, 0.0, -47.7635], [-21.3393, 0.0, -47.5181], [-21.3087, 0.0, -47.2739], [-21.2659, 0.0, -47.0316], [-21.2112, 0.0, -46.7916], [-21.1445, 0.0, -46.5547], [-21.0662, 0.0, -49.6976], [-14.6251, 0.0, -43.4152], [-20.9762, 0.0, -49.9266], [-14.3984, 0.0, -43.5108], [-20.875, 0.0, -50.151], [-14.1766, 0.0, -43.6175], [-20.7628, 0.0, -50.37], [-13.9605, 0.0, -43.7352], [-20.6397, 0.0, -50.5831], [-13.7505, 0.0, -43.8635], [-20.5062, 0.0, -50.7898], [-13.5472, 0.0, -44.0022], [-20.3625, 0.0, -50.9896], [-13.351, 0.0, -44.1508], [-20.2091, 0.0, -51.182], [-13.1625, 0.0, -44.309], [-20.0463, 0.0, -51.3665], [-19.8744, 0.0, -51.5427], [-19.694, 0.0, -51.7101], [-19.5055, 0.0, -51.8683], [-19.3094, 0.0, -52.0169], [-19.106, 0.0, -52.1555], [35.0, 0.0, 0.0], [19.0522, 0.0, -18.4636], [16.4743, 0.0, -9.1399], [-27.6672, 0.0, -65.6993], [-27.6141, 0.0, -65.7466], [-27.7179, 0.0, -65.6494], [-27.5588, 0.0, -65.7913], [-27.4419, 0.0, -65.8722], [-27.3806, 0.0, -65.9082], [-27.5013, 0.0, -65.8332], [-27.3177, 0.0, -65.9412], [-27.2531, 0.0, -65.9711], [-27.1872, 0.0, -65.9978], [-27.1201, 0.0, -66.0212], [-27.0519, 0.0, -66.0413], [-26.9828, 0.0, -66.058], [-26.9129, 0.0, -66.0713], [-26.8425, 0.0, -66.0811], [-26.7007, 0.0, -66.0904], [-26.7717, 0.0, -66.0875], [-26.6296, 0.0, -66.0899], [-26.4879, 0.0, -66.0782], [-26.4177, 0.0, -66.0672], [-26.5586, 0.0, -66.0858], [-26.3481, 0.0, -66.0528], [-26.2792, 0.0, -66.035], [-26.2114, 0.0, -66.0138], [-26.1446, 0.0, -65.9893], [-26.0792, 0.0, -65.9615], [-26.0151, 0.0, -65.9306], [-25.9527, 0.0, -65.8966], [-25.892, 0.0, -65.8595], [-25.8332, 0.0, -65.8196], [-25.7765, 0.0, -65.7767], [-25.7219, 0.0, -65.7312], [-25.6696, 0.0, -65.683], [-28.9439, 0.0, -64.3825], [-29.1102, 0.0, -64.202], [-29.2675, 0.0, -64.0136], [-29.4153, 0.0, -63.8176], [-29.5533, 0.0, -63.6147], [-29.6811, 0.0, -63.4052], [-29.7986, 0.0, -63.1898], [-29.9053, 0.0, -62.9688], [-30.0011, 0.0, -62.7428], [-30.0856, 0.0, -62.5124], [-30.1588, 0.0, -62.2782], [-30.2204, 0.0, -62.0406], [-30.2702, 0.0, -61.8003], [-30.3082, 0.0, -61.5579], [-30.3342, 0.0, -61.3138], [-30.3483, 0.0, -61.0688], [-30.3503, 0.0, -60.8234], [-30.3402, 0.0, -60.5782], [-30.3182, 0.0, -60.3338], [-30.2841, 0.0, -60.0908], [-30.2382, 0.0, -59.8497], [-30.1805, 0.0, -59.6111], [-30.1112, 0.0, -59.3757], [-30.0304, 0.0, -59.144], [-29.9383, 0.0, -58.9165], [-29.8352, 0.0, -58.6938], [-29.7213, 0.0, -58.4764], [-29.5969, 0.0, -58.2649], [-13.7505, 0.0, -52.1555], [-20.6129, 0.0, -60.7895], [-13.5472, 0.0, -52.0169], [-13.9605, 0.0, -52.2838], [-14.1766, 0.0, -52.4015], [-14.3984, 0.0, -52.5083], [-14.6251, 0.0, -52.6039], [-14.8564, 0.0, -52.688], [-15.0915, 0.0, -52.7606], [-15.33, 0.0, -52.8213], [-15.5712, 0.0, -52.8701], [-15.8146, 0.0, -52.9067], [-16.0594, 0.0, -52.9312], [-16.3052, 0.0, -52.9435], [-16.5513, 0.0, -52.9435], [-16.7971, 0.0, -52.9312], [-17.042, 0.0, -52.9067], [-17.2853, 0.0, -52.8701], [-17.5265, 0.0, -52.8213], [-17.765, 0.0, -52.7606], [-18.0002, 0.0, -52.688], [-18.2314, 0.0, -52.6039], [-18.4582, 0.0, -52.5083], [-18.6799, 0.0, -52.4015], [-18.896, 0.0, -52.2838], [-29.4622, 0.0, -58.0598], [-29.3176, 0.0, -57.8615], [-29.1635, 0.0, -57.6705], [-29.0002, 0.0, -57.4873], [-28.828, 0.0, -57.3124], [42.0711, 0.0, -7.0711], [-12.9821, 0.0, -44.4763], [12.9821, 0.0, -44.4763], [-12.8103, 0.0, -44.6525], [12.8103, 0.0, -44.6525], [-12.6474, 0.0, -44.837], [13.1625, 0.0, -44.309], [12.6474, 0.0, -44.837], [13.351, 0.0, -44.1508], [-12.494, 0.0, -45.0294], [12.494, 0.0, -45.0294], [13.5472, 0.0, -44.0022], [-12.3504, 0.0, -45.2292], [12.3504, 0.0, -45.2292], [-12.2168, 0.0, -45.436], [13.7505, 0.0, -43.8635], [-12.0938, 0.0, -45.6491], [12.2168, 0.0, -45.436], [13.9605, 0.0, -43.7352], [12.0938, 0.0, -45.6491], [14.1766, 0.0, -43.6175], [-11.9815, 0.0, -45.8681], [11.9815, 0.0, -45.8681], [14.3984, 0.0, -43.5108], [-11.8803, 0.0, -46.0924], [11.8803, 0.0, -46.0924], [14.6251, 0.0, -43.4152], [11.7904, 0.0, -46.3215], [-11.7904, 0.0, -46.3215], [-11.712, 0.0, -46.5547], [14.8564, 0.0, -43.331], [11.712, 0.0, -46.5547], [15.0915, 0.0, -43.2585], [-11.6454, 0.0, -46.7916], [11.6454, 0.0, -46.7916], [15.33, 0.0, -43.1977], [-11.5906, 0.0, -47.0316], [11.5906, 0.0, -47.0316], [15.5712, 0.0, -43.149], [-11.5479, 0.0, -47.2739], [11.5479, 0.0, -47.2739], [15.8146, 0.0, -43.1123], [-11.5173, 0.0, -47.5181], [11.5173, 0.0, -47.5181], [16.0594, 0.0, -43.0878], [11.4989, 0.0, -47.7635], [-11.4989, 0.0, -47.7635], [16.3052, 0.0, -43.0755], [11.4928, 0.0, -48.0095], [-11.4928, 0.0, -48.0095], [16.5513, 0.0, -43.0755], [-11.4989, 0.0, -48.2555], [11.4989, 0.0, -48.2555], [-11.5173, 0.0, -48.5009], [16.7971, 0.0, -43.0878], [11.5173, 0.0, -48.5009], [-11.5479, 0.0, -48.7451], [17.042, 0.0, -43.1123], [11.5479, 0.0, -48.7451], [17.2853, 0.0, -43.149], [11.5906, 0.0, -48.9875], [-11.5906, 0.0, -48.9875], [17.5265, 0.0, -43.1977], [11.6454, 0.0, -49.2274], [-11.6454, 0.0, -49.2274], [17.765, 0.0, -43.2585], [-11.712, 0.0, -49.4643], [11.712, 0.0, -49.4643], [-11.7904, 0.0, -49.6976], [18.0002, 0.0, -43.331], [11.7904, 0.0, -49.6976], [18.2314, 0.0, -43.4152], [-11.8803, 0.0, -49.9266], [11.8803, 0.0, -49.9266], [18.4582, 0.0, -43.5108], [-11.9815, 0.0, -50.151], [11.9815, 0.0, -50.151], [18.6799, 0.0, -43.6175], [-12.0938, 0.0, -50.37], [12.0938, 0.0, -50.37], [18.896, 0.0, -43.7352], [-12.2168, 0.0, -50.5831], [12.2168, 0.0, -50.5831], [-12.3504, 0.0, -50.7898], [19.106, 0.0, -43.8635], [12.3504, 0.0, -50.7898], [19.3094, 0.0, -44.0022], [-12.494, 0.0, -50.9896], [12.494, 0.0, -50.9896], [19.5055, 0.0, -44.1508], [-12.6474, 0.0, -51.182], [12.6474, 0.0, -51.182], [-12.8103, 0.0, -51.3665], [19.694, 0.0, -44.309], [12.8103, 0.0, -51.3665], [-12.9821, 0.0, -51.5427], [19.8744, 0.0, -44.4763], [-13.1625, 0.0, -51.7101], [12.9821, 0.0, -51.5427], [20.0463, 0.0, -44.6525], [13.1625, 0.0, -51.7101], [20.2091, 0.0, -44.837], [13.351, 0.0, -51.8683], [-13.351, 0.0, -51.8683], [20.3625, 0.0, -45.0294], [13.5472, 0.0, -52.0169], [20.5062, 0.0, -45.2292], [20.6397, 0.0, -45.436], [20.7628, 0.0, -45.6491], [20.875, 0.0, -45.8681], [20.9762, 0.0, -46.0924], [20.6129, 0.0, -60.7895], [18.896, 0.0, -52.2838], [19.106, 0.0, -52.1555], [18.6799, 0.0, -52.4015], [18.4582, 0.0, -52.5083], [18.2314, 0.0, -52.6039], [18.0002, 0.0, -52.688], [17.765, 0.0, -52.7606], [17.5265, 0.0, -52.8213], [17.2853, 0.0, -52.8701], [17.042, 0.0, -52.9067], [16.7971, 0.0, -52.9312], [16.5513, 0.0, -52.9435], [16.3052, 0.0, -52.9435], [16.0594, 0.0, -52.9312], [15.8146, 0.0, -52.9067], [15.5712, 0.0, -52.8701], [15.33, 0.0, -52.8213], [15.0915, 0.0, -52.7606], [14.8564, 0.0, -52.688], [14.6251, 0.0, -52.6039], [14.3984, 0.0, -52.5083], [14.1766, 0.0, -52.4015], [13.9605, 0.0, -52.2838], [13.7505, 0.0, -52.1555], [26.7105, 0.0, -55.2632], [21.3577, 0.0, -48.2555], [21.3638, 0.0, -48.0095], [21.3393, 0.0, -48.5009], [21.3087, 0.0, -48.7451], [21.2659, 0.0, -48.9875], [21.2112, 0.0, -49.2274], [21.1445, 0.0, -49.4643], [21.0662, 0.0, -49.6976], [20.9762, 0.0, -49.9266], [20.875, 0.0, -50.151], [20.7628, 0.0, -50.37], [20.6397, 0.0, -50.5831], [20.5062, 0.0, -50.7898], [20.3625, 0.0, -50.9896], [20.2091, 0.0, -51.182], [20.0463, 0.0, -51.3665], [19.8744, 0.0, -51.5427], [19.694, 0.0, -51.7101], [21.0662, 0.0, -46.3215], [19.5055, 0.0, -51.8683], [21.1445, 0.0, -46.5547], [19.3094, 0.0, -52.0169], [21.2112, 0.0, -46.7916], [21.2659, 0.0, -47.0316], [21.3087, 0.0, -47.2739], [21.3393, 0.0, -47.5181], [21.3577, 0.0, -47.7635], [26.7105, 0.0, -66.6904], [32.4211, 0.0, -60.7895], [-40.1196, 3.0, -5.2115], [-39.9409, 3.0, -4.9818], [-39.751, 3.0, -4.7612], [-39.5503, 3.0, -4.5503], [-35.3348, 3.0, -0.6912], [-35.8275, 3.0, -0.9866], [-35.5848, 3.0, -0.8329], [-36.0624, 3.0, -1.152], [-40.714, 3.0, -6.2105], [-40.5842, 3.0, -5.95], [-40.4417, 3.0, -5.6962], [-40.2867, 3.0, -5.4499], [-34.5473, 3.0, -0.3422], [-35.0781, 3.0, -0.5621], [-34.8154, 3.0, -0.4456], [-41.0236, 3.0, -7.0262], [-40.934, 3.0, -6.7493], [-40.8307, 3.0, -6.4772], [-33.9976, 3.0, -0.1754], [-34.2745, 3.0, -0.2521], [-36.5065, 3.0, -1.5165], [-36.2889, 3.0, -1.3288], [-41.2085, 3.0, -7.8789], [-41.161, 3.0, -7.5917], [-41.0994, 3.0, -7.3072], [-33.149, 3.0, -0.0282], [-33.7172, 3.0, -0.1125], [-33.4341, 3.0, -0.0634], [-36.7146, 3.0, -1.7146], [-41.2417, 3.0, -8.168], [-32.8624, 3.0, -0.0071], [-32.5752, 3.0, -0.0], [-41.2298, 3.0, -9.3304], [-41.2544, 3.0, -9.0404], [-41.2646, 3.0, -8.7495], [-41.2604, 3.0, -8.4585], [-41.07, 3.0, -10.1881], [-41.1374, 3.0, -9.905], [-41.1908, 3.0, -9.6189], [-40.9885, 3.0, -10.4676], [-21.9255, 3.0, -9.1038], [-21.9255, 3.0, -3.5345], [5.6569, 3.0, -13.2042], [-5.6569, 3.0, -13.2042], [-0.0, 3.0, -9.1038], [-20.0463, 3.0, -44.6525], [-19.8744, 3.0, -44.4763], [-19.694, 3.0, -44.309], [-20.2091, 3.0, -44.837], [-19.5055, 3.0, -44.1508], [-20.3625, 3.0, -45.0294], [-19.3094, 3.0, -44.0022], [-20.5062, 3.0, -45.2292], [-19.106, 3.0, -43.8635], [-20.6397, 3.0, -45.436], [-18.896, 3.0, -43.7352], [-20.7628, 3.0, -45.6491], [-18.6799, 3.0, -43.6175], [-20.875, 3.0, -45.8681], [-18.4582, 3.0, -43.5108], [-20.9762, 3.0, -46.0924], [-18.2314, 3.0, -43.4152], [-21.0662, 3.0, -46.3215], [-18.0002, 3.0, -43.331], [-17.765, 3.0, -43.2585], [16.4743, 3.0, -9.1399], [-17.5265, 3.0, -43.1977], [22.3077, 3.0, -3.5345], [-17.2853, 3.0, -43.149], [-17.042, 3.0, -43.1123], [-16.7971, 3.0, -43.0878], [-16.5513, 3.0, -43.0755], [-16.3052, 3.0, -43.0755], [-16.0594, 3.0, -43.0878], [-15.8146, 3.0, -43.1123], [-15.5712, 3.0, -43.149], [-15.33, 3.0, -43.1977], [-15.0915, 3.0, -43.2585], [-14.8564, 3.0, -43.331], [-26.7105, 3.0, -55.2632], [-21.2112, 3.0, -49.2274], [-21.1445, 3.0, -49.4643], [-21.2659, 3.0, -48.9875], [-21.3087, 3.0, -48.7451], [-21.3393, 3.0, -48.5009], [-21.3577, 3.0, -48.2555], [-21.3638, 3.0, -48.0095], [-21.3577, 3.0, -47.7635], [-21.3393, 3.0, -47.5181], [-21.3087, 3.0, -47.2739], [-21.2659, 3.0, -47.0316], [-21.2112, 3.0, -46.7916], [-21.1445, 3.0, -46.5547], [-21.0662, 3.0, -49.6976], [-14.6251, 3.0, -43.4152], [-20.9762, 3.0, -49.9266], [-14.3984, 3.0, -43.5108], [-20.875, 3.0, -50.151], [-14.1766, 3.0, -43.6175], [-20.7628, 3.0, -50.37], [-13.9605, 3.0, -43.7352], [-20.6397, 3.0, -50.5831], [-13.7505, 3.0, -43.8635], [-20.5062, 3.0, -50.7898], [-13.5472, 3.0, -44.0022], [-20.3625, 3.0, -50.9896], [-13.351, 3.0, -44.1508], [-20.2091, 3.0, -51.182], [-13.1625, 3.0, -44.309], [-20.0463, 3.0, -51.3665], [-12.9821, 3.0, -44.4763], [-19.8744, 3.0, -51.5427], [-19.694, 3.0, -51.7101], [-19.5055, 3.0, -51.8683], [-19.3094, 3.0, -52.0169], [-19.106, 3.0, -52.1555], [35.0, 3.0, 0.0], [19.0522, 3.0, -18.4636], [-27.6141, 3.0, -65.7466], [-27.6672, 3.0, -65.6993], [-27.7179, 3.0, -65.6494], [-27.5588, 3.0, -65.7913], [-27.3806, 3.0, -65.9082], [-27.4419, 3.0, -65.8722], [-27.5013, 3.0, -65.8332], [-27.2531, 3.0, -65.9711], [-27.3177, 3.0, -65.9412], [-27.1872, 3.0, -65.9978], [-27.0519, 3.0, -66.0413], [-27.1201, 3.0, -66.0212], [-26.9129, 3.0, -66.0713], [-26.9828, 3.0, -66.058], [-26.8425, 3.0, -66.0811], [-26.7007, 3.0, -66.0904], [-26.7717, 3.0, -66.0875], [-26.5586, 3.0, -66.0858], [-26.6296, 3.0, -66.0899], [-26.2792, 3.0, -66.035], [-26.3481, 3.0, -66.0528], [-26.4177, 3.0, -66.0672], [-26.4879, 3.0, -66.0782], [-26.1446, 3.0, -65.9893], [-26.2114, 3.0, -66.0138], [-26.0792, 3.0, -65.9615], [-25.9527, 3.0, -65.8966], [-26.0151, 3.0, -65.9306], [-25.8332, 3.0, -65.8196], [-25.892, 3.0, -65.8595], [-25.7765, 3.0, -65.7767], [-25.6696, 3.0, -65.683], [-25.7219, 3.0, -65.7312], [-28.9439, 3.0, -64.3825], [-29.1102, 3.0, -64.202], [-29.2675, 3.0, -64.0136], [-29.4153, 3.0, -63.8176], [-29.5533, 3.0, -63.6147], [-29.6811, 3.0, -63.4052], [-29.7986, 3.0, -63.1898], [-29.9053, 3.0, -62.9688], [-30.0011, 3.0, -62.7428], [-30.0856, 3.0, -62.5124], [-30.1588, 3.0, -62.2782], [-30.2204, 3.0, -62.0406], [-30.2702, 3.0, -61.8003], [-30.3082, 3.0, -61.5579], [-30.3342, 3.0, -61.3138], [-30.3483, 3.0, -61.0688], [-30.3503, 3.0, -60.8234], [-30.3402, 3.0, -60.5782], [-30.3182, 3.0, -60.3338], [-30.2841, 3.0, -60.0908], [-30.2382, 3.0, -59.8497], [-30.1805, 3.0, -59.6111], [-30.1112, 3.0, -59.3757], [-30.0304, 3.0, -59.144], [-29.9383, 3.0, -58.9165], [-29.8352, 3.0, -58.6938], [-29.7213, 3.0, -58.4764], [-29.5969, 3.0, -58.2649], [-20.6129, 3.0, -60.7895], [-13.9605, 3.0, -52.2838], [-13.7505, 3.0, -52.1555], [-14.1766, 3.0, -52.4015], [-14.3984, 3.0, -52.5083], [-14.6251, 3.0, -52.6039], [-14.8564, 3.0, -52.688], [-15.0915, 3.0, -52.7606], [-15.33, 3.0, -52.8213], [-15.5712, 3.0, -52.8701], [-15.8146, 3.0, -52.9067], [-16.0594, 3.0, -52.9312], [-16.3052, 3.0, -52.9435], [-16.5513, 3.0, -52.9435], [-16.7971, 3.0, -52.9312], [-17.042, 3.0, -52.9067], [-17.2853, 3.0, -52.8701], [-17.5265, 3.0, -52.8213], [-17.765, 3.0, -52.7606], [-18.0002, 3.0, -52.688], [-18.2314, 3.0, -52.6039], [-18.4582, 3.0, -52.5083], [-18.6799, 3.0, -52.4015], [-18.896, 3.0, -52.2838], [-29.4622, 3.0, -58.0598], [-29.3176, 3.0, -57.8615], [-29.1635, 3.0, -57.6705], [-29.0002, 3.0, -57.4873], [-28.828, 3.0, -57.3124], [-2.5595, 3.0, -49.5058], [-11.4989, 3.0, -48.2555], [-11.4928, 3.0, -48.0095], [-11.5173, 3.0, -48.5009], [-11.5479, 3.0, -48.7451], [-11.5906, 3.0, -48.9875], [-11.6454, 3.0, -49.2274], [-11.712, 3.0, -49.4643], [-11.7904, 3.0, -49.6976], [-11.8803, 3.0, -49.9266], [-11.9815, 3.0, -50.151], [-12.8103, 3.0, -44.6525], [-12.0938, 3.0, -50.37], [-12.6474, 3.0, -44.837], [-12.2168, 3.0, -50.5831], [-12.494, 3.0, -45.0294], [-12.3504, 3.0, -50.7898], [-12.3504, 3.0, -45.2292], [-12.494, 3.0, -50.9896], [-12.2168, 3.0, -45.436], [-12.6474, 3.0, -51.182], [-12.0938, 3.0, -45.6491], [-12.8103, 3.0, -51.3665], [-11.9815, 3.0, -45.8681], [-12.9821, 3.0, -51.5427], [-11.8803, 3.0, -46.0924], [-13.1625, 3.0, -51.7101], [-11.7904, 3.0, -46.3215], [-13.351, 3.0, -51.8683], [-11.712, 3.0, -46.5547], [-13.5472, 3.0, -52.0169], [-11.6454, 3.0, -46.7916], [-11.5906, 3.0, -47.0316], [-11.5479, 3.0, -47.2739], [-11.5173, 3.0, -47.5181], [-11.4989, 3.0, -47.7635], [42.0711, 3.0, -7.0711], [2.5595, 3.0, -49.5058], [12.9821, 3.0, -44.4763], [12.8103, 3.0, -44.6525], [13.1625, 3.0, -44.309], [12.6474, 3.0, -44.837], [13.351, 3.0, -44.1508], [12.494, 3.0, -45.0294], [13.5472, 3.0, -44.0022], [12.3504, 3.0, -45.2292], [13.7505, 3.0, -43.8635], [12.2168, 3.0, -45.436], [13.9605, 3.0, -43.7352], [12.0938, 3.0, -45.6491], [14.1766, 3.0, -43.6175], [11.9815, 3.0, -45.8681], [14.3984, 3.0, -43.5108], [11.8803, 3.0, -46.0924], [14.6251, 3.0, -43.4152], [11.7904, 3.0, -46.3215], [14.8564, 3.0, -43.331], [11.712, 3.0, -46.5547], [15.0915, 3.0, -43.2585], [11.6454, 3.0, -46.7916], [15.33, 3.0, -43.1977], [11.5906, 3.0, -47.0316], [15.5712, 3.0, -43.149], [11.5479, 3.0, -47.2739], [15.8146, 3.0, -43.1123], [11.5173, 3.0, -47.5181], [16.0594, 3.0, -43.0878], [11.4989, 3.0, -47.7635], [16.3052, 3.0, -43.0755], [11.4928, 3.0, -48.0095], [16.5513, 3.0, -43.0755], [11.4989, 3.0, -48.2555], [16.7971, 3.0, -43.0878], [11.5173, 3.0, -48.5009], [17.042, 3.0, -43.1123], [11.5479, 3.0, -48.7451], [17.2853, 3.0, -43.149], [11.5906, 3.0, -48.9875], [17.5265, 3.0, -43.1977], [11.6454, 3.0, -49.2274], [17.765, 3.0, -43.2585], [11.712, 3.0, -49.4643], [18.0002, 3.0, -43.331], [11.7904, 3.0, -49.6976], [18.2314, 3.0, -43.4152], [11.8803, 3.0, -49.9266], [18.4582, 3.0, -43.5108], [11.9815, 3.0, -50.151], [18.6799, 3.0, -43.6175], [12.0938, 3.0, -50.37], [18.896, 3.0, -43.7352], [12.2168, 3.0, -50.5831], [19.106, 3.0, -43.8635], [12.3504, 3.0, -50.7898], [19.3094, 3.0, -44.0022], [12.494, 3.0, -50.9896], [19.5055, 3.0, -44.1508], [12.6474, 3.0, -51.182], [19.694, 3.0, -44.309], [12.8103, 3.0, -51.3665], [19.8744, 3.0, -44.4763], [12.9821, 3.0, -51.5427], [20.0463, 3.0, -44.6525], [13.1625, 3.0, -51.7101], [20.2091, 3.0, -44.837], [13.351, 3.0, -51.8683], [20.3625, 3.0, -45.0294], [13.5472, 3.0, -52.0169], [20.5062, 3.0, -45.2292], [13.7505, 3.0, -52.1555], [20.6397, 3.0, -45.436], [20.7628, 3.0, -45.6491], [20.875, 3.0, -45.8681], [20.9762, 3.0, -46.0924], [20.6129, 3.0, -60.7895], [13.9605, 3.0, -52.2838], [14.1766, 3.0, -52.4015], [14.3984, 3.0, -52.5083], [14.6251, 3.0, -52.6039], [14.8564, 3.0, -52.688], [15.0915, 3.0, -52.7606], [15.33, 3.0, -52.8213], [15.5712, 3.0, -52.8701], [15.8146, 3.0, -52.9067], [16.0594, 3.0, -52.9312], [16.3052, 3.0, -52.9435], [16.5513, 3.0, -52.9435], [16.7971, 3.0, -52.9312], [17.042, 3.0, -52.9067], [17.2853, 3.0, -52.8701], [17.5265, 3.0, -52.8213], [17.765, 3.0, -52.7606], [18.0002, 3.0, -52.688], [18.2314, 3.0, -52.6039], [18.4582, 3.0, -52.5083], [18.6799, 3.0, -52.4015], [18.896, 3.0, -52.2838], [19.106, 3.0, -52.1555], [26.7105, 3.0, -55.2632], [21.3638, 3.0, -48.0095], [21.3577, 3.0, -47.7635], [21.3393, 3.0, -47.5181], [21.3087, 3.0, -47.2739], [21.2659, 3.0, -47.0316], [21.2112, 3.0, -46.7916], [21.1445, 3.0, -46.5547], [19.3094, 3.0, -52.0169], [21.0662, 3.0, -46.3215], [19.5055, 3.0, -51.8683], [19.694, 3.0, -51.7101], [19.8744, 3.0, -51.5427], [20.0463, 3.0, -51.3665], [20.2091, 3.0, -51.182], [20.3625, 3.0, -50.9896], [20.5062, 3.0, -50.7898], [20.6397, 3.0, -50.5831], [20.7628, 3.0, -50.37], [20.875, 3.0, -50.151], [20.9762, 3.0, -49.9266], [21.0662, 3.0, -49.6976], [21.1445, 3.0, -49.4643], [21.2112, 3.0, -49.2274], [21.2659, 3.0, -48.9875], [21.3087, 3.0, -48.7451], [21.3393, 3.0, -48.5009], [21.3577, 3.0, -48.2555], [26.7105, 3.0, -66.6904], [32.4211, 3.0, -60.7895], [-0.0, 17.2725, -18.399], [-0.0, 10.6576, -46.7187]], "f": [[0, 1, 2], [2, 1, 3], [4, 5, 6], [6, 5, 7], [8, 9, 10], [11, 12, 13], [13, 12, 5], [8, 14, 9], [1, 14, 8], [12, 15, 5], [14, 16, 17], [18, 19, 15], [19, 20, 15], [5, 20, 7], [15, 20, 5], [16, 21, 22], [14, 21, 16], [1, 21, 14], [23, 24, 25], [25, 24, 20], [21, 26, 27], [27, 26, 28], [1, 26, 21], [0, 26, 1], [0, 29, 26], [0, 30, 29], [31, 32, 33], [33, 32, 34], [34, 32, 35], [36, 37, 32], [24, 38, 20], [32, 38, 35], [37, 38, 32], [35, 38, 24], [20, 39, 7], [38, 39, 20], [7, 39, 0], [0, 40, 30], [39, 40, 0], [30, 40, 41], [39, 42, 43], [43, 44, 39], [39, 45, 42], [44, 46, 39], [39, 47, 45], [46, 48, 39], [39, 49, 47], [39, 50, 40], [48, 50, 39], [39, 51, 49], [50, 52, 40], [39, 53, 51], [52, 54, 40], [39, 55, 53], [54, 56, 40], [39, 57, 55], [56, 58, 40], [39, 59, 57], [58, 60, 40], [60, 61, 40], [61, 62, 40], [41, 63, 30], [62, 64, 40], [64, 65, 40], [65, 66, 40], [66, 67, 40], [67, 68, 40], [68, 69, 40], [69, 70, 40], [70, 71, 40], [71, 72, 40], [40, 72, 73], [72, 74, 73], [74, 75, 73], [76, 77, 78], [79, 77, 76], [80, 77, 79], [81, 77, 80], [82, 77, 81], [83, 77, 82], [84, 77, 83], [85, 77, 84], [86, 77, 85], [87, 77, 86], [88, 77, 87], [89, 77, 88], [59, 77, 89], [39, 77, 59], [77, 90, 78], [75, 91, 73], [77, 92, 90], [91, 93, 73], [77, 94, 92], [93, 95, 73], [77, 96, 94], [95, 97, 73], [77, 98, 96], [97, 99, 73], [77, 100, 98], [99, 101, 73], [77, 102, 100], [101, 103, 73], [77, 104, 102], [103, 105, 73], [77, 106, 104], [77, 107, 106], [77, 108, 107], [77, 109, 108], [77, 110, 109], [77, 111, 110], [63, 112, 30], [73, 113, 114], [115, 116, 117], [116, 118, 117], [119, 120, 121], [121, 120, 118], [122, 123, 120], [118, 123, 117], [120, 123, 118], [123, 124, 117], [125, 126, 124], [124, 126, 117], [127, 128, 126], [128, 129, 126], [129, 130, 126], [131, 130, 129], [130, 132, 126], [133, 134, 135], [135, 134, 132], [132, 136, 126], [134, 136, 132], [137, 138, 136], [138, 139, 136], [139, 140, 136], [140, 141, 136], [142, 143, 141], [141, 143, 136], [143, 144, 136], [136, 145, 126], [144, 145, 136], [146, 147, 145], [145, 147, 126], [117, 147, 148], [148, 147, 149], [149, 147, 150], [150, 147, 151], [151, 147, 152], [152, 147, 153], [153, 147, 154], [154, 147, 155], [155, 147, 156], [156, 147, 157], [157, 147, 158], [158, 147, 159], [159, 147, 160], [160, 147, 161], [161, 147, 162], [162, 147, 163], [163, 147, 164], [164, 147, 165], [165, 147, 166], [166, 147, 167], [167, 147, 168], [168, 147, 169], [169, 147, 170], [170, 147, 171], [171, 147, 172], [172, 147, 173], [173, 147, 174], [174, 147, 175], [126, 147, 117], [176, 177, 178], [179, 177, 176], [180, 177, 179], [181, 177, 180], [182, 177, 181], [183, 177, 182], [184, 177, 183], [185, 177, 184], [186, 177, 185], [187, 177, 186], [188, 177, 187], [189, 177, 188], [190, 177, 189], [191, 177, 190], [192, 177, 191], [193, 177, 192], [194, 177, 193], [195, 177, 194], [77, 177, 111], [196, 177, 195], [197, 177, 196], [198, 177, 197], [199, 177, 198], [200, 177, 199], [111, 177, 200], [175, 177, 201], [201, 177, 202], [202, 177, 203], [203, 177, 204], [204, 177, 205], [205, 177, 77], [147, 177, 175], [113, 206, 63], [63, 206, 112], [207, 208, 105], [209, 210, 207], [211, 210, 209], [207, 210, 208], [105, 212, 73], [208, 212, 105], [211, 213, 210], [212, 214, 73], [215, 216, 211], [211, 216, 213], [73, 217, 113], [214, 217, 73], [218, 219, 215], [220, 219, 218], [215, 219, 216], [217, 221, 113], [222, 223, 220], [220, 223, 219], [221, 224, 113], [222, 225, 223], [224, 226, 113], [227, 228, 222], [222, 228, 225], [226, 229, 113], [230, 231, 227], [227, 231, 228], [229, 232, 113], [230, 233, 231], [234, 233, 230], [235, 233, 234], [232, 236, 113], [235, 237, 233], [236, 238, 113], [239, 240, 235], [235, 240, 237], [238, 241, 113], [242, 243, 239], [239, 243, 240], [241, 244, 113], [245, 246, 242], [242, 246, 243], [244, 247, 113], [248, 249, 245], [245, 249, 246], [247, 250, 113], [248, 251, 249], [252, 251, 248], [250, 253, 113], [252, 254, 251], [255, 254, 252], [253, 256, 113], [257, 258, 255], [259, 258, 257], [255, 258, 254], [256, 260, 113], [259, 261, 258], [262, 261, 259], [260, 263, 113], [262, 264, 261], [263, 265, 113], [262, 266, 264], [267, 266, 262], [265, 268, 113], [267, 269, 266], [270, 269, 267], [268, 271, 113], [272, 273, 270], [274, 273, 272], [270, 273, 269], [271, 275, 113], [274, 276, 273], [275, 277, 113], [278, 279, 274], [274, 279, 276], [277, 280, 113], [281, 282, 278], [278, 282, 279], [280, 283, 113], [284, 285, 281], [281, 285, 282], [283, 286, 113], [287, 288, 284], [289, 288, 287], [284, 288, 285], [286, 290, 113], [289, 291, 288], [290, 292, 113], [293, 294, 289], [289, 294, 291], [292, 295, 113], [296, 297, 293], [298, 297, 296], [293, 297, 294], [295, 299, 113], [298, 300, 297], [301, 300, 298], [299, 302, 113], [303, 304, 301], [301, 304, 300], [302, 305, 113], [303, 306, 304], [305, 307, 113], [303, 308, 306], [309, 308, 303], [178, 308, 309], [113, 310, 206], [307, 310, 113], [178, 311, 308], [310, 312, 206], [312, 313, 206], [313, 314, 206], [314, 315, 206], [315, 316, 206], [177, 317, 178], [318, 317, 319], [320, 317, 318], [321, 317, 320], [322, 317, 321], [323, 317, 322], [324, 317, 323], [325, 317, 324], [326, 317, 325], [327, 317, 326], [178, 317, 311], [328, 317, 327], [329, 317, 328], [330, 317, 329], [331, 317, 330], [332, 317, 331], [333, 317, 332], [334, 317, 333], [335, 317, 334], [336, 317, 335], [337, 317, 336], [338, 317, 337], [339, 317, 338], [340, 317, 339], [341, 317, 340], [311, 317, 341], [316, 342, 206], [343, 342, 344], [345, 342, 343], [346, 342, 345], [347, 342, 346], [348, 342, 347], [349, 342, 348], [350, 342, 349], [351, 342, 350], [352, 342, 351], [353, 342, 352], [354, 342, 353], [355, 342, 354], [356, 342, 355], [357, 342, 356], [317, 342, 319], [358, 342, 357], [359, 342, 358], [360, 342, 359], [361, 342, 316], [362, 342, 360], [363, 342, 361], [364, 342, 362], [365, 342, 363], [319, 342, 364], [366, 342, 365], [367, 342, 366], [368, 342, 367], [369, 342, 368], [344, 342, 369], [342, 370, 371], [317, 370, 342], [372, 373, 374], [372, 374, 375], [376, 377, 378], [376, 379, 377], [380, 381, 382], [380, 382, 383], [380, 383, 372], [384, 385, 386], [387, 380, 372], [387, 388, 389], [387, 389, 380], [387, 372, 375], [390, 379, 376], [390, 376, 385], [390, 384, 391], [390, 385, 384], [390, 392, 393], [390, 393, 379], [394, 395, 396], [394, 396, 387], [397, 390, 398], [397, 398, 399], [397, 392, 390], [397, 400, 392], [401, 387, 375], [401, 394, 387], [402, 400, 397], [403, 400, 402], [404, 405, 406], [404, 406, 407], [404, 407, 401], [408, 401, 375], [408, 404, 401], [408, 409, 410], [408, 410, 404], [411, 408, 375], [411, 375, 400], [412, 411, 400], [412, 400, 403], [412, 403, 413], [414, 415, 416], [417, 411, 418], [419, 418, 411], [420, 411, 417], [421, 419, 411], [422, 411, 420], [423, 421, 411], [424, 411, 422], [425, 411, 412], [425, 423, 411], [426, 411, 424], [427, 425, 412], [428, 411, 426], [429, 427, 412], [430, 411, 428], [431, 429, 412], [432, 411, 430], [433, 431, 412], [434, 411, 432], [435, 433, 412], [436, 435, 412], [437, 414, 416], [438, 436, 412], [439, 413, 403], [440, 438, 412], [441, 440, 412], [442, 441, 412], [443, 442, 412], [443, 412, 415], [444, 443, 415], [445, 444, 415], [446, 445, 415], [447, 446, 415], [448, 447, 415], [449, 448, 415], [450, 449, 415], [451, 411, 434], [451, 452, 453], [451, 454, 452], [451, 455, 454], [451, 456, 455], [451, 457, 456], [451, 458, 457], [451, 459, 458], [451, 460, 459], [451, 461, 460], [451, 462, 461], [451, 463, 462], [451, 464, 463], [451, 434, 464], [465, 451, 453], [466, 450, 415], [467, 451, 465], [468, 466, 415], [469, 451, 467], [470, 468, 415], [471, 451, 469], [472, 470, 415], [473, 451, 471], [474, 472, 415], [475, 451, 473], [476, 474, 415], [477, 451, 475], [478, 476, 415], [479, 451, 477], [480, 478, 415], [481, 451, 479], [482, 480, 415], [483, 451, 481], [484, 451, 483], [485, 451, 484], [486, 451, 485], [487, 451, 486], [488, 439, 403], [489, 414, 437], [490, 491, 492], [493, 490, 492], [494, 495, 496], [494, 496, 493], [497, 493, 492], [497, 498, 494], [497, 494, 493], [499, 497, 492], [500, 501, 499], [500, 499, 492], [502, 503, 500], [504, 502, 500], [505, 506, 504], [505, 504, 500], [507, 508, 505], [509, 510, 511], [509, 511, 512], [509, 512, 507], [513, 514, 509], [515, 507, 505], [515, 509, 507], [515, 513, 509], [516, 517, 515], [516, 515, 505], [518, 519, 516], [518, 516, 505], [520, 505, 500], [520, 518, 505], [521, 522, 520], [521, 500, 492], [521, 492, 523], [521, 520, 500], [521, 523, 524], [521, 524, 525], [521, 525, 526], [521, 526, 527], [521, 527, 528], [521, 528, 529], [521, 529, 530], [521, 530, 531], [521, 531, 532], [521, 532, 533], [521, 533, 534], [521, 534, 535], [521, 535, 536], [521, 536, 537], [521, 537, 538], [521, 538, 539], [521, 539, 540], [521, 540, 541], [521, 541, 542], [521, 542, 543], [521, 543, 544], [521, 544, 545], [521, 545, 546], [521, 546, 547], [521, 547, 548], [521, 548, 549], [521, 549, 550], [551, 552, 553], [551, 554, 552], [551, 555, 554], [551, 556, 555], [551, 557, 556], [551, 558, 557], [551, 559, 558], [551, 560, 559], [551, 561, 560], [551, 562, 561], [551, 563, 562], [551, 564, 563], [551, 451, 487], [551, 565, 564], [551, 566, 565], [551, 567, 566], [551, 568, 567], [551, 569, 568], [551, 570, 569], [551, 571, 570], [551, 572, 571], [551, 573, 572], [551, 574, 573], [551, 487, 574], [551, 550, 575], [551, 575, 576], [551, 576, 577], [551, 577, 578], [551, 578, 579], [551, 579, 451], [551, 521, 550], [580, 482, 415], [580, 581, 582], [580, 583, 581], [580, 584, 583], [580, 585, 584], [580, 586, 585], [580, 587, 586], [580, 588, 587], [580, 589, 588], [580, 590, 589], [580, 591, 482], [580, 592, 590], [580, 593, 591], [580, 594, 592], [580, 595, 593], [580, 596, 594], [580, 597, 595], [580, 598, 596], [580, 599, 597], [580, 600, 598], [580, 601, 599], [580, 602, 600], [580, 603, 601], [580, 604, 602], [580, 605, 603], [580, 606, 604], [580, 607, 605], [580, 608, 606], [580, 609, 607], [580, 610, 608], [580, 611, 609], [580, 553, 610], [580, 612, 611], [580, 613, 612], [580, 614, 613], [580, 615, 614], [580, 582, 615], [580, 551, 553], [616, 439, 488], [616, 489, 439], [617, 551, 580], [618, 617, 414], [619, 617, 618], [620, 618, 414], [621, 617, 619], [622, 620, 414], [623, 617, 621], [624, 622, 414], [625, 617, 623], [626, 624, 414], [627, 617, 625], [628, 626, 414], [629, 617, 627], [630, 414, 489], [630, 628, 414], [631, 617, 629], [632, 630, 489], [633, 617, 631], [634, 632, 489], [635, 617, 633], [636, 634, 489], [637, 617, 635], [638, 636, 489], [639, 617, 637], [640, 638, 489], [641, 617, 639], [642, 640, 489], [643, 617, 641], [644, 642, 489], [645, 617, 643], [646, 644, 489], [647, 617, 645], [648, 646, 489], [649, 617, 647], [650, 648, 489], [651, 617, 649], [652, 650, 489], [653, 617, 651], [654, 652, 489], [655, 617, 653], [656, 654, 489], [657, 617, 655], [658, 656, 489], [659, 617, 657], [660, 658, 489], [661, 617, 659], [662, 660, 489], [663, 617, 661], [664, 662, 489], [665, 617, 663], [666, 664, 489], [667, 617, 665], [668, 666, 489], [669, 617, 667], [670, 668, 489], [671, 617, 669], [672, 670, 489], [673, 617, 671], [674, 672, 489], [675, 617, 673], [676, 674, 489], [677, 617, 675], [678, 676, 489], [679, 617, 677], [680, 678, 489], [681, 617, 679], [682, 680, 489], [683, 617, 681], [684, 682, 489], [685, 617, 683], [686, 489, 616], [686, 684, 489], [687, 617, 685], [688, 686, 616], [689, 617, 687], [690, 688, 616], [691, 690, 616], [692, 691, 616], [693, 692, 616], [694, 617, 689], [694, 689, 695], [694, 695, 696], [694, 696, 697], [694, 697, 698], [694, 698, 699], [694, 699, 700], [694, 700, 701], [694, 701, 702], [694, 702, 703], [694, 703, 704], [694, 704, 705], [694, 705, 706], [694, 706, 707], [694, 707, 708], [694, 708, 709], [694, 709, 710], [694, 710, 711], [694, 711, 712], [694, 712, 713], [694, 713, 714], [694, 714, 715], [694, 715, 716], [694, 716, 717], [694, 551, 617], [718, 693, 616], [718, 694, 717], [718, 719, 720], [718, 720, 721], [718, 721, 722], [718, 722, 723], [718, 723, 724], [718, 724, 725], [718, 717, 726], [718, 725, 727], [718, 726, 728], [718, 727, 693], [718, 728, 729], [718, 729, 730], [718, 730, 731], [718, 731, 732], [718, 732, 733], [718, 733, 734], [718, 734, 735], [718, 735, 736], [718, 736, 737], [718, 737, 738], [718, 738, 739], [718, 739, 740], [718, 740, 741], [718, 741, 742], [718, 742, 743], [718, 743, 744], [718, 744, 745], [718, 745, 719], [746, 718, 747], [746, 694, 718], [412, 416, 415], [718, 616, 342], [342, 616, 206], [747, 718, 371], [371, 718, 342], [746, 747, 370], [370, 747, 371], [694, 746, 317], [317, 746, 370], [694, 317, 551], [551, 317, 177], [147, 551, 177], [521, 551, 147], [115, 117, 492], [115, 492, 491], [116, 491, 490], [116, 115, 491], [118, 490, 493], [118, 116, 490], [121, 493, 496], [121, 118, 493], [119, 496, 495], [119, 121, 496], [120, 495, 494], [120, 119, 495], [122, 494, 498], [122, 120, 494], [123, 122, 498], [123, 498, 497], [124, 123, 497], [124, 499, 501], [124, 497, 499], [125, 124, 501], [126, 125, 501], [126, 501, 500], [127, 126, 500], [127, 503, 502], [127, 500, 503], [128, 127, 502], [129, 128, 502], [129, 502, 504], [131, 129, 504], [131, 504, 506], [130, 131, 506], [130, 506, 505], [132, 130, 505], [132, 505, 508], [135, 132, 508], [135, 507, 512], [135, 508, 507], [133, 512, 511], [133, 135, 512], [134, 133, 511], [136, 510, 509], [136, 511, 510], [136, 134, 511], [137, 509, 514], [137, 136, 509], [138, 137, 514], [139, 514, 513], [139, 138, 514], [140, 513, 515], [140, 139, 513], [141, 515, 517], [141, 140, 515], [142, 516, 519], [142, 517, 516], [142, 141, 517], [143, 142, 519], [144, 519, 518], [144, 143, 519], [145, 518, 520], [145, 144, 518], [146, 520, 522], [146, 145, 520], [147, 522, 521], [147, 146, 522], [492, 117, 148], [523, 492, 148], [204, 205, 579], [204, 579, 578], [203, 578, 577], [203, 204, 578], [202, 577, 576], [202, 203, 577], [201, 576, 575], [201, 202, 576], [175, 575, 550], [175, 201, 575], [174, 550, 549], [174, 175, 550], [173, 549, 548], [173, 174, 549], [172, 173, 548], [172, 548, 547], [171, 172, 547], [171, 547, 546], [170, 171, 546], [170, 546, 545], [169, 170, 545], [169, 545, 544], [168, 169, 544], [168, 544, 543], [167, 168, 543], [167, 542, 541], [167, 543, 542], [166, 167, 541], [165, 166, 541], [165, 541, 540], [164, 165, 540], [164, 540, 539], [163, 164, 539], [163, 539, 538], [162, 163, 538], [162, 538, 537], [161, 537, 536], [161, 162, 537], [160, 536, 535], [160, 161, 536], [159, 160, 535], [159, 535, 534], [158, 533, 532], [158, 534, 533], [158, 159, 534], [157, 158, 532], [156, 532, 531], [156, 157, 532], [155, 531, 530], [155, 156, 531], [154, 529, 528], [154, 530, 529], [154, 155, 530], [153, 154, 528], [152, 528, 527], [152, 153, 528], [151, 527, 526], [151, 152, 527], [150, 526, 525], [150, 151, 526], [149, 525, 524], [149, 150, 525], [148, 524, 523], [148, 149, 524], [451, 205, 77], [451, 579, 205], [411, 77, 39], [411, 451, 77], [6, 7, 375], [6, 375, 374], [4, 374, 373], [4, 6, 374], [5, 372, 383], [5, 373, 372], [5, 4, 373], [13, 5, 383], [11, 383, 382], [11, 13, 383], [12, 382, 381], [12, 11, 382], [15, 381, 380], [15, 12, 381], [18, 389, 388], [18, 380, 389], [18, 15, 380], [19, 18, 388], [20, 388, 387], [20, 19, 388], [25, 387, 396], [25, 20, 387], [23, 396, 395], [23, 25, 396], [24, 23, 395], [24, 394, 401], [24, 395, 394], [35, 24, 401], [34, 35, 401], [34, 401, 407], [33, 34, 407], [33, 407, 406], [31, 33, 406], [31, 405, 404], [31, 406, 405], [32, 31, 404], [36, 404, 410], [36, 32, 404], [37, 36, 410], [37, 410, 409], [38, 409, 408], [38, 37, 409], [39, 408, 411], [39, 38, 408], [400, 7, 0], [400, 375, 7], [29, 30, 403], [29, 403, 402], [26, 402, 397], [26, 29, 402], [28, 397, 399], [28, 26, 397], [27, 398, 390], [27, 399, 398], [27, 28, 399], [21, 27, 390], [22, 390, 391], [22, 21, 390], [16, 391, 384], [16, 22, 391], [17, 384, 386], [17, 16, 384], [14, 386, 385], [14, 17, 386], [9, 385, 376], [9, 14, 385], [10, 376, 378], [10, 9, 376], [8, 377, 379], [8, 378, 377], [8, 10, 378], [1, 8, 379], [3, 379, 393], [3, 1, 379], [2, 393, 392], [2, 3, 393], [0, 392, 400], [0, 2, 392], [112, 488, 403], [112, 403, 30], [616, 112, 206], [616, 488, 112], [748, 415, 414], [414, 617, 749], [748, 414, 749], [749, 617, 580], [749, 415, 748], [749, 580, 415], [582, 581, 255], [255, 581, 257], [257, 583, 259], [259, 583, 262], [581, 583, 257], [583, 584, 262], [262, 585, 267], [267, 585, 270], [584, 585, 262], [270, 586, 272], [585, 586, 270], [586, 587, 272], [272, 588, 274], [587, 588, 272], [274, 589, 278], [278, 589, 281], [588, 589, 274], [589, 590, 281], [281, 592, 284], [590, 592, 281], [592, 594, 284], [284, 594, 287], [594, 596, 287], [287, 596, 289], [596, 598, 289], [289, 598, 293], [598, 600, 293], [293, 600, 296], [600, 602, 296], [296, 602, 298], [602, 604, 298], [298, 604, 301], [604, 606, 301], [301, 606, 303], [606, 608, 303], [303, 608, 309], [608, 610, 309], [309, 610, 178], [610, 553, 178], [178, 553, 176], [176, 553, 179], [553, 552, 179], [552, 554, 179], [179, 554, 180], [180, 554, 181], [554, 555, 181], [181, 556, 182], [182, 556, 183], [555, 556, 181], [556, 557, 183], [183, 558, 184], [557, 558, 183], [184, 559, 185], [185, 559, 186], [558, 559, 184], [186, 560, 187], [559, 560, 186], [560, 561, 187], [187, 562, 188], [188, 562, 189], [561, 562, 187], [562, 563, 189], [189, 564, 190], [190, 564, 191], [563, 564, 189], [564, 565, 191], [191, 566, 192], [192, 566, 193], [565, 566, 191], [566, 567, 193], [567, 568, 193], [193, 568, 194], [568, 569, 194], [194, 569, 195], [569, 570, 195], [195, 570, 196], [570, 571, 196], [196, 571, 197], [571, 572, 197], [197, 572, 198], [198, 572, 199], [572, 573, 199], [573, 574, 199], [199, 574, 200], [574, 487, 200], [200, 487, 111], [487, 486, 111], [111, 486, 110], [486, 485, 110], [110, 485, 109], [485, 484, 109], [109, 484, 108], [484, 483, 108], [108, 483, 107], [483, 481, 107], [107, 481, 106], [481, 479, 106], [106, 479, 104], [479, 477, 104], [104, 477, 102], [477, 475, 102], [102, 475, 100], [475, 473, 100], [100, 473, 98], [473, 471, 98], [98, 471, 96], [471, 469, 96], [96, 469, 94], [469, 467, 94], [94, 467, 92], [467, 465, 92], [92, 465, 90], [465, 453, 90], [90, 453, 78], [78, 452, 76], [453, 452, 78], [76, 454, 79], [452, 454, 76], [79, 455, 80], [454, 455, 79], [80, 456, 81], [455, 456, 80], [456, 457, 81], [81, 457, 82], [457, 458, 82], [82, 458, 83], [458, 459, 83], [83, 459, 84], [459, 460, 84], [84, 460, 85], [460, 461, 85], [85, 461, 86], [461, 462, 86], [86, 462, 87], [462, 463, 87], [87, 463, 88], [463, 464, 88], [88, 464, 89], [464, 434, 89], [89, 434, 59], [434, 432, 59], [59, 432, 57], [432, 430, 57], [57, 430, 55], [430, 428, 55], [55, 428, 53], [428, 426, 53], [53, 426, 51], [51, 424, 49], [426, 424, 51], [49, 422, 47], [424, 422, 49], [47, 420, 45], [422, 420, 47], [45, 417, 42], [420, 417, 45], [42, 418, 43], [417, 418, 42], [43, 419, 44], [418, 419, 43], [44, 421, 46], [419, 421, 44], [46, 423, 48], [421, 423, 46], [48, 425, 50], [423, 425, 48], [50, 427, 52], [425, 427, 50], [52, 429, 54], [427, 429, 52], [54, 431, 56], [429, 431, 54], [56, 433, 58], [431, 433, 56], [433, 435, 58], [58, 435, 60], [435, 436, 60], [60, 436, 61], [436, 438, 61], [61, 438, 62], [438, 440, 62], [62, 440, 64], [64, 441, 65], [440, 441, 64], [65, 442, 66], [66, 442, 67], [441, 442, 65], [442, 443, 67], [67, 444, 68], [443, 444, 67], [68, 445, 69], [444, 445, 68], [69, 446, 70], [445, 446, 69], [70, 447, 71], [446, 447, 70], [71, 448, 72], [72, 448, 74], [447, 448, 71], [448, 449, 74], [74, 450, 75], [449, 450, 74], [75, 466, 91], [450, 466, 75], [91, 468, 93], [93, 468, 95], [466, 468, 91], [468, 470, 95], [95, 472, 97], [470, 472, 95], [97, 474, 99], [472, 474, 97], [99, 476, 101], [101, 476, 103], [474, 476, 99], [476, 478, 103], [103, 480, 105], [105, 480, 207], [478, 480, 103], [480, 482, 207], [207, 591, 209], [482, 591, 207], [591, 593, 209], [209, 593, 211], [593, 595, 211], [211, 595, 215], [595, 597, 215], [215, 597, 218], [597, 599, 218], [218, 599, 220], [599, 601, 220], [220, 601, 222], [601, 603, 222], [222, 603, 227], [603, 605, 227], [227, 605, 230], [605, 607, 230], [230, 607, 234], [607, 609, 234], [234, 609, 235], [609, 611, 235], [235, 611, 239], [611, 612, 239], [239, 612, 242], [612, 613, 242], [242, 613, 245], [245, 614, 248], [613, 614, 245], [614, 615, 248], [248, 615, 252], [252, 582, 255], [615, 582, 252], [719, 745, 344], [344, 745, 343], [343, 744, 345], [745, 744, 343], [345, 743, 346], [744, 743, 345], [346, 742, 347], [743, 742, 346], [347, 741, 348], [742, 741, 347], [348, 740, 349], [741, 740, 348], [349, 739, 350], [740, 739, 349], [350, 738, 351], [739, 738, 350], [351, 737, 352], [738, 737, 351], [352, 736, 353], [737, 736, 352], [736, 735, 353], [353, 735, 354], [735, 734, 354], [354, 734, 355], [734, 733, 355], [355, 733, 356], [733, 732, 356], [356, 732, 357], [732, 731, 357], [357, 731, 358], [731, 730, 358], [358, 730, 359], [730, 729, 359], [359, 729, 360], [729, 728, 360], [360, 728, 362], [728, 726, 362], [362, 726, 364], [726, 717, 364], [364, 717, 319], [717, 716, 319], [319, 716, 318], [716, 715, 318], [318, 715, 320], [320, 714, 321], [715, 714, 320], [321, 713, 322], [714, 713, 321], [322, 712, 323], [713, 712, 322], [323, 711, 324], [712, 711, 323], [324, 710, 325], [711, 710, 324], [325, 709, 326], [710, 709, 325], [326, 708, 327], [709, 708, 326], [327, 707, 328], [708, 707, 327], [328, 706, 329], [707, 706, 328], [329, 705, 330], [706, 705, 329], [330, 704, 331], [705, 704, 330], [331, 703, 332], [704, 703, 331], [332, 702, 333], [703, 702, 332], [333, 701, 334], [702, 701, 333], [701, 700, 334], [334, 700, 335], [700, 699, 335], [335, 699, 336], [699, 698, 336], [336, 698, 337], [698, 697, 337], [337, 697, 338], [697, 696, 338], [338, 696, 339], [696, 695, 339], [339, 695, 340], [695, 689, 340], [340, 689, 341], [689, 687, 341], [341, 687, 311], [687, 685, 311], [311, 685, 308], [685, 683, 308], [308, 683, 306], [683, 681, 306], [306, 681, 304], [681, 679, 304], [304, 679, 300], [679, 677, 300], [300, 677, 297], [677, 675, 297], [297, 675, 294], [675, 673, 294], [294, 673, 291], [673, 671, 291], [291, 671, 288], [671, 669, 288], [288, 669, 285], [669, 667, 285], [285, 667, 282], [667, 665, 282], [282, 665, 279], [665, 663, 279], [279, 663, 276], [663, 661, 276], [276, 661, 273], [273, 659, 269], [661, 659, 273], [269, 657, 266], [659, 657, 269], [266, 655, 264], [657, 655, 266], [264, 653, 261], [655, 653, 264], [653, 651, 261], [261, 651, 258], [651, 649, 258], [258, 649, 254], [649, 647, 254], [254, 647, 251], [647, 645, 251], [251, 645, 249], [645, 643, 249], [249, 643, 246], [643, 641, 246], [246, 641, 243], [641, 639, 243], [243, 639, 240], [639, 637, 240], [240, 637, 237], [637, 635, 237], [237, 635, 233], [635, 633, 233], [233, 633, 231], [633, 631, 231], [231, 631, 228], [631, 629, 228], [228, 629, 225], [223, 627, 219], [629, 627, 225], [225, 627, 223], [627, 625, 219], [219, 623, 216], [625, 623, 219], [216, 621, 213], [623, 621, 216], [213, 619, 210], [210, 619, 208], [621, 619, 213], [619, 618, 208], [208, 620, 212], [618, 620, 208], [212, 622, 214], [214, 622, 217], [620, 622, 212], [622, 624, 217], [217, 626, 221], [624, 626, 217], [221, 628, 224], [626, 628, 221], [224, 630, 226], [628, 630, 224], [226, 632, 229], [229, 632, 232], [630, 632, 226], [632, 634, 232], [634, 636, 232], [232, 636, 236], [236, 636, 238], [636, 638, 238], [238, 638, 241], [638, 640, 241], [241, 642, 244], [640, 642, 241], [244, 644, 247], [642, 644, 244], [247, 646, 250], [644, 646, 247], [250, 648, 253], [646, 648, 250], [253, 650, 256], [648, 650, 253], [256, 652, 260], [260, 652, 263], [650, 652, 256], [652, 654, 263], [263, 656, 265], [654, 656, 263], [265, 658, 268], [268, 658, 271], [656, 658, 265], [658, 660, 271], [271, 662, 275], [660, 662, 271], [275, 664, 277], [662, 664, 275], [277, 666, 280], [664, 666, 277], [280, 668, 283], [666, 668, 280], [283, 670, 286], [668, 670, 283], [286, 672, 290], [670, 672, 286], [290, 674, 292], [672, 674, 290], [292, 676, 295], [674, 676, 292], [295, 678, 299], [299, 678, 302], [676, 678, 295], [678, 680, 302], [302, 682, 305], [305, 682, 307], [680, 682, 302], [682, 684, 307], [684, 686, 307], [307, 686, 310], [686, 688, 310], [310, 688, 312], [688, 690, 312], [312, 690, 313], [690, 691, 313], [313, 691, 314], [691, 692, 314], [314, 692, 315], [315, 692, 316], [692, 693, 316], [693, 727, 316], [316, 727, 361], [727, 725, 361], [361, 725, 363], [725, 724, 363], [363, 724, 365], [724, 723, 365], [365, 723, 366], [723, 722, 366], [366, 722, 367], [722, 721, 367], [367, 721, 368], [368, 720, 369], [721, 720, 368], [369, 719, 344], [720, 719, 369], [437, 73, 114], [437, 416, 73], [40, 73, 412], [73, 416, 412], [40, 412, 41], [41, 412, 413], [439, 63, 413], [413, 63, 41], [63, 489, 113], [439, 489, 63], [113, 437, 114], [489, 437, 113]]} \ No newline at end of file diff --git a/docs/CAD/design/mate-connectors/bear_outline.json b/docs/CAD/design/mate-connectors/bear_outline.json new file mode 100644 index 0000000000..21b420c616 --- /dev/null +++ b/docs/CAD/design/mate-connectors/bear_outline.json @@ -0,0 +1 @@ +{"outer": [[26.711, -55.263], [42.071, -7.071], [35.0, -0.0], [-32.575, 0.0], [-33.717, -0.112], [-34.815, -0.446], [-35.828, -0.987], [-36.715, -1.715], [-39.55, -4.55], [-40.35, -5.547], [-40.914, -6.694], [-41.216, -7.937], [-41.241, -9.215], [-40.988, -10.468], [-26.711, -55.263], [-28.828, -57.312], [-29.64, -58.335], [-30.159, -59.532], [-30.35, -60.823], [-30.201, -62.12], [-29.721, -63.334], [-28.944, -64.382], [-27.718, -65.649], [-27.075, -66.035], [-26.325, -66.047], [-25.67, -65.683], [-20.613, -60.789], [20.613, -60.789], [26.711, -66.69], [32.421, -60.789], [26.711, -55.263]], "holes": [{"pts": [[19.052, -18.464], [16.474, -9.14], [-0.0, -9.104], [-21.926, -9.104], [-21.926, -3.535], [22.308, -3.535], [19.052, -18.464]], "cx": 4.719, "cz": -10.192, "d": 44.234}, {"pts": [[-11.493, -48.01], [-11.676, -49.341], [-12.211, -50.574], [-13.06, -51.617], [-14.158, -52.392], [-15.424, -52.842], [-16.765, -52.934], [-18.081, -52.66], [-19.274, -52.042], [-20.257, -51.124], [-20.955, -49.976], [-21.318, -48.682], [-21.318, -47.337], [-20.955, -46.043], [-20.257, -44.895], [-19.274, -43.977], [-18.081, -43.359], [-16.765, -43.085], [-15.424, -43.177], [-14.158, -43.627], [-13.06, -44.402], [-12.211, -45.445], [-11.676, -46.678], [-11.493, -48.01]], "cx": -16.223, "cz": -48.01, "d": 9.825}, {"pts": [[21.364, -48.01], [21.181, -49.341], [20.645, -50.574], [19.797, -51.617], [18.699, -52.392], [17.432, -52.842], [16.091, -52.934], [14.775, -52.66], [13.582, -52.042], [12.6, -51.124], [11.901, -49.976], [11.539, -48.682], [11.539, -47.337], [11.901, -46.043], [12.6, -44.895], [13.582, -43.977], [14.775, -43.359], [16.091, -43.085], [17.432, -43.177], [18.699, -43.627], [19.797, -44.402], [20.645, -45.445], [21.181, -46.678], [21.364, -48.01]], "cx": 16.634, "cz": -48.01, "d": 9.825}]} \ No newline at end of file diff --git a/docs/CAD/design/mate-connectors/circularmateconnectors.png b/docs/CAD/design/mate-connectors/circularmateconnectors.png new file mode 100644 index 0000000000..3150a5667b Binary files /dev/null and b/docs/CAD/design/mate-connectors/circularmateconnectors.png differ diff --git a/docs/CAD/design/mate-connectors/cmp-sheet.png b/docs/CAD/design/mate-connectors/cmp-sheet.png new file mode 100644 index 0000000000..1d67ae31ed Binary files /dev/null and b/docs/CAD/design/mate-connectors/cmp-sheet.png differ diff --git a/docs/CAD/design/mate-connectors/connector-glyph-proposal.html b/docs/CAD/design/mate-connectors/connector-glyph-proposal.html new file mode 100644 index 0000000000..2a8475ee24 --- /dev/null +++ b/docs/CAD/design/mate-connectors/connector-glyph-proposal.html @@ -0,0 +1,299 @@ + + + + + +Mate connector glyph — polarity and verse + + + +
+ +
+
Orca Design · assembly
+

Mate connector glyph — polarity and verse

+

+ Onshape's core (disc + roll quadrant + Z arrow) is adopted unchanged because it is proven and + aligned. The addition is polarity — which connector is anchored and + which one travels — which no surveyed CAD system encodes in its glyph. +

+
+ +

The three jobs of the glyph

+
+
+
+ + + + +
+
Disc — the XY plane
+

Says “I am a frame, and this is the plane I sit in.” The dot is the exact origin.

+
+ +
+
+ + + + + +
+
Quadrant — the roll
+

+ The filled sector is the +X/+Y quadrant. It steps 90° with the reorient control, so the + clocking that Fastened and Slider lock is visible before you commit. +

+
+ +
+
+ + + + + + + +
+
Arrow — the verse
+

+ Drawn on +Z only. Nothing below the disc. A double-headed axis is what + makes people ask which way it points; a one-sided arrow cannot be misread. +

+
+
+ +

Polarity — the part nobody else draws

+
+
+
+ + + + + + + + +
+
Fixed — the socket
+

+ Hollow head, muted colour. This body does not move. It receives. +

+
+ +
+
+ + + + + + + +
+
Driven — the plug
+

+ Solid head, active colour. This body is the one that jumps. It inserts. +

+
+ +
+
+ + + + + + + + + + + + +
+
Roll undefined
+

+ Hatched quadrant, dashed disc: a circular face or a seam gave no usable direction. Says + “pick a direction” without a dialog. +

+
+
+ +

The pair reads as a magnet

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + fixed · receives + driven · inserts + +
+

+ Two arrows nose to nose. Because a magnet's north seeks a south, “facing” is the + self-evident default — which settles open decision D1 on visual grounds instead of a + convention nobody can look up. Nothing has to be remembered: the picture is the rule. + The dashed line is what makes the two glyphs read as one object. +

+
+ +

States

+
+ + + + + + + + + + + + + + + + + + + + + +
StateDrawingWhy
Candidate (hover)small dot onlyOnshape draws plain white dots at every corner and midpoint. Dots propose; the full glyph commits.
Pickeddisc + quadrant + coneThe committed frame, with roll and verse both readable.
Roll undefinedhatched quadrant, dashed discTurns requirement R8 from a message nobody reads into a mark you cannot miss.
+
+ +

Constraints on the drawing

+
+

+ Do not make it a fourth RGB triad. The bed-centre world triad + (DesignCanvas.cpp:65) and the move gizmo are already three coloured arrows. The disc + and the quadrant are what tell a connector apart from those — keep the arms short, and consider + drawing only Z on the committed glyph, with X and Y implied by the quadrant. +

+
+ roll quadrant + Z / driven + fixed + roll undefined +
+
+ +
+ + diff --git a/docs/CAD/design/mate-connectors/coplanar_test.py b/docs/CAD/design/mate-connectors/coplanar_test.py new file mode 100644 index 0000000000..ea02ed3c80 --- /dev/null +++ b/docs/CAD/design/mate-connectors/coplanar_test.py @@ -0,0 +1,68 @@ +# Does the connector pair let two hosts sit COPLANAR, or does it hold them apart? +# +# The male's flat back is the plane Y=0 and all its relief rises to +Y. So Y=0 is the natural +# mating datum: everything the male adds lives on one side of it. The test below builds two dummy +# host plates that meet on that plane -- one with the male FUSED on, one with the cavity CUT in -- +# and measures whether they touch, interfere, or stand apart. +# +# It also emits the artifact that makes this work in practice: a CUTTER solid (the male grown by +# the clearance) that you subtract from any host. A standalone female block cannot keep two hosts +# coplanar, because its own floor material stands between them; a cavity can. +# +# Run: /snap/bin/freecad.cmd coplanar_test.py + +import os +import FreeCAD as App +import Part +from FreeCAD import Vector + +HERE = os.path.dirname(os.path.abspath(__file__)) +MALE = os.path.join(HERE, "bear.step") +CLEAR = 0.20 + +male = Part.Shape(); male.read(MALE); male = male.Solids[0] +bb = male.BoundBox +print(f"male relief: Y {bb.YMin:.3f} .. {bb.YMax:.3f} -> datum plane Y=0, all relief on +Y") + +# the flat back face, and proof it is the whole silhouette sitting on Y=0 +back = max((f for f in male.Faces + if abs(f.CenterOfMass.y) < 1e-6 and abs(abs(f.normalAt(0, 0).y) - 1) < 1e-6), + key=lambda f: f.Area) +print(f"back face : {back.Area:.1f} mm2 on Y=0 -- this is the contact surface") + +# ---- the cutter: the male grown by the clearance, poking 0.2 mm proud so the boolean is clean +cutter = male.makeOffsetShape(CLEAR, 1e-6, False, False, 0, 2, False).Solids[0] +cb = cutter.BoundBox +print(f"cutter : Y {cb.YMin:.3f} .. {cb.YMax:.3f}, {cutter.Volume/1000:.2f} cm3") + +# ---- two dummy hosts meeting on Y = 0 +W, H = 120.0, 100.0 +hostA = Part.makeBox(W, 10.0, H, Vector(-W/2, -10.0, -15.0)) # occupies Y -10..0 +hostB = Part.makeBox(W, 30.0, H, Vector(-W/2, 0.0, -15.0)) # occupies Y 0..30 + +partA = hostA.fuse(male) # male stands proud of A's face +partB = hostB.cut(cutter) # cavity sunk into B from its face + +print(f"\npart A (host + male) : {partA.Volume/1000:.2f} cm3") +print(f"part B (host - cutter) : {partB.Volume/1000:.2f} cm3") + +# ---- the question ------------------------------------------------------------------ +inter = partA.common(partB) +iv = inter.Volume if inter.Solids else 0.0 +gap = partA.distToShape(partB)[0] +print(f"\nRESULT interference A vs B : {iv:.6f} mm3 (0 = they do not collide)") +print(f"RESULT closest approach : {gap:.4f} mm (0 = the host faces are touching)") + +# are the two host faces actually on the same plane? +fa = [f for f in partA.Faces if abs(f.CenterOfMass.y) < 1e-9 and abs(abs(f.normalAt(0,0).y)-1) < 1e-6] +fb = [f for f in partB.Faces if abs(f.CenterOfMass.y) < 1e-9 and abs(abs(f.normalAt(0,0).y)-1) < 1e-6] +print(f"RESULT A has {len(fa)} face(s) lying exactly on Y=0, total {sum(f.Area for f in fa):.1f} mm2") +print(f"RESULT B has {len(fb)} face(s) lying exactly on Y=0, total {sum(f.Area for f in fb):.1f} mm2") +print("RESULT -> the hosts meet on Y=0: COPLANAR" if fa and fb and iv < 1e-3 + else "RESULT -> NOT coplanar") + +doc = App.newDocument("Cutter") +o = doc.addObject("Part::Feature", "BearConnector_Cutter"); o.Shape = cutter +doc.recompute() +Part.export([o], os.path.join(HERE, "BearConnector_Cutter.step")) +print(f"\nwrote BearConnector_Cutter.step -- subtract this from any host to get the socket") diff --git a/docs/CAD/design/mate-connectors/cylindricalmateconnectors.png b/docs/CAD/design/mate-connectors/cylindricalmateconnectors.png new file mode 100644 index 0000000000..e4c1cebcf9 Binary files /dev/null and b/docs/CAD/design/mate-connectors/cylindricalmateconnectors.png differ diff --git a/docs/CAD/design/mate-connectors/emit_glyph_table.py b/docs/CAD/design/mate-connectors/emit_glyph_table.py new file mode 100644 index 0000000000..6e0fd29e32 --- /dev/null +++ b/docs/CAD/design/mate-connectors/emit_glyph_table.py @@ -0,0 +1,97 @@ +"""Emit the simplified bear as a C++ table for the viewport glyph — wi3z. + +Everything is normalised to the part's own bounding span and centred, so the renderer scales by +one radius R in screen pixels and nothing here carries millimetres. Emitting rather than +hand-authoring keeps the glyph and the printed part from drifting apart: rerun this and the table +follows the STEP. +""" +import json, math, os +HERE = os.path.dirname(os.path.abspath(__file__)) +D = json.load(open(os.path.join(HERE, "bear_outline.json"))) + +def unit_frame(pts_sets): + allp=[p for s in pts_sets for p in s] + xs=[p[0] for p in allp]; ys=[p[1] for p in allp] + cx,cy=(min(xs)+max(xs))/2,(min(ys)+max(ys))/2 + span=max(max(xs)-min(xs), max(ys)-min(ys)) + return cx,cy,span + +outer=[(x,-z) for x,z in D["outer"]] +holes=[[(x,-z) for x,z in h["pts"]] for h in D["holes"]] +CX,CY,SPAN = unit_frame([outer]+holes) +U=lambda pts:[((x-CX)/SPAN,(y-CY)/SPAN) for x,y in pts] +OUT=U(outer) +EYES=[U(h) for h,m in zip(holes,D["holes"]) if m["d"]<20] +MUZ =U([h for h,m in zip(holes,D["holes"]) if m["d"]>=20][0]) + +def rdp(p,eps): + if len(p)<3: return p + ax,ay=p[0]; bx,by=p[-1]; dx,dy=bx-ax,by-ay; n=math.hypot(dx,dy) + best,bi=-1.0,0 + for i in range(1,len(p)-1): + px,py=p[i] + d=abs(dx*(ay-py)-(ax-px)*dy)/n if n>1e-12 else math.hypot(px-ax,py-ay) + if d>best: best,bi=d,i + if best<=eps: return [p[0],p[-1]] + return rdp(p[:bi+1],eps)[:-1]+rdp(p[bi:],eps) +def simp(p,eps): + r=rdp(p+[p[0]],eps); return r[:-1] + +OUT_S = simp(OUT,.030) # 22 verts, the size the study settled on +# wind counter-clockwise so the renderer's normals come out facing +Z +def area2(p): return sum(p[i][0]*p[(i+1)%len(p)][1]-p[(i+1)%len(p)][0]*p[i][1] for i in range(len(p))) +if area2(OUT_S) < 0: OUT_S = OUT_S[::-1] + +def centroid(p): return (sum(q[0] for q in p)/len(p), sum(q[1] for q in p)/len(p)) +E=[] +for e in EYES: + c=centroid(e); r=(max(p[0] for p in e)-min(p[0] for p in e))/2 + E.append((c[0],c[1],r)) +E.sort() + +lo=min(p[1] for p in MUZ); hi=max(p[1] for p in MUZ) +bottom=[p for p in MUZ if p[1] < lo+0.06*(hi-lo)] +apex=max(MUZ,key=lambda p:p[1]) +TRI=[min(bottom),max(bottom),apex] +if area2(TRI)<0: TRI=TRI[::-1] + +# the cheek dot: the handedness mark adopted after the mirror-difference study +DOT=(E[1][0]+0.085, E[1][1]-0.10, 0.038) + +# THE MUZZLE. Six facets lifted straight off the mesh -- every facet touching anything above the +# 3 mm plate. Do NOT recompute the base from height*tan(draft): the first version did and produced +# a needle, because the real base OVERHANGS the crest at both ends (0.062 at the nose, 0.034 at the +# tail) and it is that overhang that makes it a tapered wedge instead of a blade. +PLATE = 0.036 # 3.00 / 83.34 +SNOUT_BASE = ((-0.0727, -0.2417), (+0.0630, -0.2417), # nose end, 0.136 wide + (+0.0259, +0.1939), (-0.0356, +0.1939)) # tail end, 0.062 wide +CREST = ((-0.0048, -0.1793, 0.2073), (-0.0048, +0.1605, 0.1279)) + +def fmt(v): return f"{v:+.4f}" +L=[] +L.append(f"// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit.") +L.append(f"// Normalised to the part's bounding span and centred: the renderer scales by one radius.") +L.append(f"static const Vec2d kBearOutline[] = {{ // {len(OUT_S)} verts, RDP eps 0.030, CCW") +for i in range(0,len(OUT_S),3): + row=", ".join(f"{{{fmt(x)}, {fmt(y)}}}" for x,y in OUT_S[i:i+3]) + L.append(" "+row+",") +L.append("};") +L.append(f"static const Vec2d kBearChin[] = {{ // the CHIN BAR, flat. The muzzle is relief — see kBearCrest.") +L.append(" "+", ".join(f"{{{fmt(x)}, {fmt(y)}}}" for x,y in TRI)+",") +L.append("};") +L.append("// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (wi3z).") +L.append("static const Vec3d kBearMarks[] = {") +for cx,cy,r in E: L.append(f" {{{fmt(cx)}, {fmt(cy)}, {fmt(r)}}},") +L.append(f" {{{fmt(DOT[0])}, {fmt(DOT[1])}, {fmt(DOT[2])}}},") +L.append("};") +L.append("// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets.") +L.append("// This is the only feature standing along +Z and the only one still legible edge-on.") +L.append(f"static const double kBearPlateZ = {PLATE:+.4f};") +L.append("static const Vec2d kBearSnoutBase[] = { // CCW from the nose end") +for x,y in SNOUT_BASE: L.append(f" {{{fmt(x)}, {fmt(y)}}},") +L.append("};") +L.append("static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short)") +for x,y,z in CREST: L.append(f" {{{fmt(x)}, {fmt(y)}, {fmt(z)}}},") +L.append("};") +open(os.path.join(HERE,"bear_glyph_table.h"),"w").write("\n".join(L)+"\n") +print("\n".join(L)) diff --git a/docs/CAD/design/mate-connectors/extract_outline.py b/docs/CAD/design/mate-connectors/extract_outline.py new file mode 100644 index 0000000000..720223c6b1 --- /dev/null +++ b/docs/CAD/design/mate-connectors/extract_outline.py @@ -0,0 +1,65 @@ +# Pull the bear's true silhouette and feature positions out of the supplied male B-rep, so the +# simplification study starts from measured geometry instead of a tracing of the flat drawing. +# +# The part's native frame (make_female.py): flat back on Y=0, relief rising to Y=+17.27, the FACE +# carried by X and Z. So the face plane is XZ and the silhouette is the outline projected along Y. +import os, json +import Part + +HERE = os.path.dirname(os.path.abspath(__file__)) +s = Part.Shape(); s.read(os.path.join(HERE, "bear.step")) +sol = s.Solids[0] +bb = sol.BoundBox +print(f"bbox X {bb.XMin:.2f}..{bb.XMax:.2f} Y {bb.YMin:.2f}..{bb.YMax:.2f} Z {bb.ZMin:.2f}..{bb.ZMax:.2f}") + +# The back plate face: the planar face whose normal is -Y and which sits at Y=YMin. Its outer wire +# IS the silhouette; its inner wires are the eye holes. +best = None +for f in sol.Faces: + if f.Surface.__class__.__name__ != "Plane": + continue + n = f.Surface.Axis + if abs(abs(n.y) - 1.0) > 1e-6: + continue + c = f.CenterOfMass + if best is None or c.y < best[0]: + best = (c.y, f) +y, face = best +print(f"back plate at Y={y:.3f} wires={len(face.Wires)} area={face.Area:.1f} mm2") + +def wire_pts(w, tol=0.05): + # ORDER MATTERS and w.Edges does not carry it: OCC hands the edges back in whatever order the + # face stored them, so concatenating their discretisations gives a scrambled ring. The first + # version of this script did exactly that and emitted an outline with 7 duplicated points and + # twice the perimeter it should have. OrderedEdges walks the wire, and each edge is reversed + # when its own orientation runs against the walk. + pts = [] + for e in w.OrderedEdges: + d = e.discretize(Deflection=tol) + if e.Orientation == "Reversed": + d = list(reversed(d)) + for p in d: + pts.append((round(p.x, 3), round(p.z, 3))) + # drop consecutive duplicates + out = [pts[0]] + for p in pts[1:]: + if abs(p[0]-out[-1][0]) > 1e-4 or abs(p[1]-out[-1][1]) > 1e-4: + out.append(p) + return out + +data = {"outer": None, "holes": []} +outer = face.OuterWire +data["outer"] = wire_pts(outer) +for w in face.Wires: + if w.isSame(outer): + continue + pts = wire_pts(w) + xs = [p[0] for p in pts]; zs = [p[1] for p in pts] + data["holes"].append({"pts": pts, + "cx": round(sum(xs)/len(xs), 3), "cz": round(sum(zs)/len(zs), 3), + "d": round(max(xs)-min(xs), 3)}) + print(f" hole: centre ({data['holes'][-1]['cx']}, {data['holes'][-1]['cz']}) dia {data['holes'][-1]['d']}") + +print(f"outer wire: {len(data['outer'])} points") +json.dump(data, open(os.path.join(HERE, "bear_outline.json"), "w")) +print("WROTE bear_outline.json") diff --git a/docs/CAD/design/mate-connectors/faceted_ridge_key.scad b/docs/CAD/design/mate-connectors/faceted_ridge_key.scad new file mode 100644 index 0000000000..3eb60064fb --- /dev/null +++ b/docs/CAD/design/mate-connectors/faceted_ridge_key.scad @@ -0,0 +1,140 @@ +// Faceted ridge key — asymmetric male/female alignment feature, flat facets only. +// +// 6 vertices, 7 faces, one closed manifold. Euler check: V - E + F = 6 - 11 + 7 = 2. +// No spheres, no cylinders, no splines, no fillets. +// +// THE FLANKS ARE TRIANGULATED EXPLICITLY, and that is not cosmetic. Written as quads +// [0,3,5,4] and [1,4,5,2] they are NOT planar — the base edge and the ridge edge are +// skew, so the four corners do not share a plane. A checker caught this after the first +// draft claimed the opposite. Left as quads, the tessellator picks the fold direction for +// you, which means the "flat facet" promise is broken by an unspecified crease and two +// exporters can disagree about the shape. Splitting them here fixes the crease at +// back-bottom -> front-ridge, which keeps the rear peak's triangle large and clean. +// +// FRAME CONVENTION (matches the CAD mate connector it is derived from): +// +Z the mating axis — the feature protrudes along it +// +X the roll reference — the ridge runs along it, low end forward +// +Y completes the right-handed frame +// +// WHAT BREAKS WHICH SYMMETRY +// rotational about Z ....... the ridge (elongation along X) +// 180 deg about Z .......... the ridge SLOPE: tall steep back, long shallow front +// mirror across XZ ......... deliberately NOT broken. Handedness is fixed by convention, +// so +Y is implied once Z and X are known. Breaking it would +// add a facet and buy nothing. +// +// KNOWN AMBIGUITY, stated rather than hidden: viewed exactly ALONG the ridge (+/-X, +// orthographic), the silhouette is the same isoceles triangle from front and back. Front +// and back are then distinguished by SHADING only — the long shallow front face catches +// light differently from the steep back face. If the target renderer is flat-shaded with a +// single headlight, verify this case before committing to the shape. + +// ---------------------------------------------------------------- parameters +L = 12.0; // overall length along the ridge (X) +W = 4.0; // half-width at the BACK +tf = 0.45; // front taper: front half-width = W * tf +H = 4.5; // peak height at the rear <-- the single dimension controlling asymmetry +pr = 0.22; // rear ridge position, fraction of L from the back +pf = 0.62; // front ridge position, fraction of L from the back +hf = 0.35; // front ridge height, fraction of H + +// Clearance is a PHYSICAL quantity and only means anything if this is a printed part. +// See the note at the bottom: for a viewport glyph it is meaningless. +clr = 0.20; // per-face clearance, mm +depth = 0.40; // extra pocket depth so the male never bottoms out before it seats + +Wf = W * tf; +xr0 = -L/2 + L * pr; +xr1 = -L/2 + L * pf; +Hf = H * hf; + +// ---------------------------------------------------------------- geometry +// Vertex order is fixed and referenced by the face table; do not reorder. +// 0 back-left 1 back-right 2 front-right 3 front-left +// 4 REAR PEAK (tall) 5 front ridge (low) +function ridge_pts(l, w, wf, h, hfr, x0, x1) = [ + [-l/2, -w, 0 ], // 0 + [-l/2, w, 0 ], // 1 + [ l/2, wf, 0 ], // 2 + [ l/2, -wf, 0 ], // 3 + [ x0, 0, h ], // 4 rear peak + [ x1, 0, hfr] // 5 front ridge, low +]; + +// OpenSCAD wants each face wound CLOCKWISE seen from OUTSIDE. The right-hand-rule +// outward-normal (CCW) form is given in the comment for anyone porting to STL/OCC, +// where the opposite convention is the usual one. +RIDGE_FACES = [ + [3, 2, 1, 0], // base (CCW-outward: [0,1,2,3]) planar, all z=0 + [1, 4, 0], // back (CCW-outward: [0,4,1]) steep + [5, 3, 0], // flank -Y a (CCW-outward: [0,3,5]) + [4, 5, 0], // flank -Y b (CCW-outward: [0,5,4]) + [5, 4, 1], // flank +Y a (CCW-outward: [1,4,5]) + [2, 5, 1], // flank +Y b (CCW-outward: [1,5,2]) + [5, 2, 3] // front (CCW-outward: [3,2,5]) long, shallow +]; + +module ridge_key(l = L, w = W, wf = Wf, h = H, hfr = Hf, x0 = xr0, x1 = xr1) { + polyhedron(points = ridge_pts(l, w, wf, h, hfr, x0, x1), + faces = RIDGE_FACES, + convexity = 3); +} + +// MALE: the protrusion, nominal size. +module ridge_key_male() { ridge_key(); } + +// FEMALE: the pocket. Grown by `clr` on every side and sunk `depth` deeper. +// +// HONEST LIMITATION: this grows the key by scaling its defining dimensions, which is NOT a +// true uniform surface offset — on the shallow front face the normal clearance comes out +// smaller than `clr`, because that face is far from perpendicular to every axis it is +// scaled along. A true offset needs minkowski() with a small cube, which is exact and slow, +// or an explicit per-face plane push, which is exact and fiddly. For a keying feature whose +// job is angular registration rather than a press fit, the approximation is the right trade +// — but do not quote this pocket as holding 0.2 mm everywhere, because it does not. +module ridge_key_female() { + translate([0, 0, -depth]) + ridge_key(l = L + 2*clr, + w = W + clr, + wf = Wf + clr, + h = H + clr + depth, + hfr = Hf + clr + depth, + x0 = xr0, + x1 = xr1); +} + +// ---------------------------------------------------------------- demo +// Left: the male key on its plate. Right: the plate with the pocket cut. +PLATE = [30, 18, 3]; + +module plate_with_male() { + translate([-PLATE[0]/2, -PLATE[1]/2, -PLATE[2]]) cube(PLATE); + ridge_key_male(); +} + +module plate_with_female() { + difference() { + translate([-PLATE[0]/2, -PLATE[1]/2, -PLATE[2]]) cube(PLATE); + ridge_key_female(); + } +} + +translate([-20, 0, 0]) plate_with_male(); +translate([ 20, 0, 0]) plate_with_female(); + +// ---------------------------------------------------------------- note on the two readings +// This file is written for the PHYSICAL reading: a printable alignment key, where `clr` and +// `depth` are real millimetres and flat facets genuinely help — they slice without the +// stair-stepping a tessellated curve produces, and they print without support on the +// shallow front face. +// +// If the intent is instead the VIEWPORT GLYPH for a CAD mate connector, then: +// - `clr` and `depth` are meaningless: a symbol does not mate with anything; +// - all dimensions must become SCREEN PIXELS scaled by upp = 1/zoom, because every gizmo +// in that viewport is screen-constant and must not shrink with the model; +// - "low-poly for rendering performance" is not a real reason at ~2-20 glyphs per frame. +// The real reason to keep flat facets there is LEGIBILITY: hard normals give distinct +// value steps between facets, and that is what lets a 22-px solid read as an oriented +// object instead of a grey blob. +// The vertex logic above is identical under both readings. Only the units and the clearance +// change. diff --git a/docs/CAD/design/mate-connectors/fem-0.png b/docs/CAD/design/mate-connectors/fem-0.png new file mode 100644 index 0000000000..391881e72a Binary files /dev/null and b/docs/CAD/design/mate-connectors/fem-0.png differ diff --git a/docs/CAD/design/mate-connectors/fem-1.png b/docs/CAD/design/mate-connectors/fem-1.png new file mode 100644 index 0000000000..c329d20bea Binary files /dev/null and b/docs/CAD/design/mate-connectors/fem-1.png differ diff --git a/docs/CAD/design/mate-connectors/fem-2.png b/docs/CAD/design/mate-connectors/fem-2.png new file mode 100644 index 0000000000..a32f2ffbf1 Binary files /dev/null and b/docs/CAD/design/mate-connectors/fem-2.png differ diff --git a/docs/CAD/design/mate-connectors/fem-3.png b/docs/CAD/design/mate-connectors/fem-3.png new file mode 100644 index 0000000000..f21ae3f98b Binary files /dev/null and b/docs/CAD/design/mate-connectors/fem-3.png differ diff --git a/docs/CAD/design/mate-connectors/fem-sheet.png b/docs/CAD/design/mate-connectors/fem-sheet.png new file mode 100644 index 0000000000..add56590a1 Binary files /dev/null and b/docs/CAD/design/mate-connectors/fem-sheet.png differ diff --git a/docs/CAD/design/mate-connectors/fem-sheet2.png b/docs/CAD/design/mate-connectors/fem-sheet2.png new file mode 100644 index 0000000000..879b4295fb Binary files /dev/null and b/docs/CAD/design/mate-connectors/fem-sheet2.png differ diff --git a/docs/CAD/design/mate-connectors/female.stl b/docs/CAD/design/mate-connectors/female.stl new file mode 100644 index 0000000000..57b0ef6d20 --- /dev/null +++ b/docs/CAD/design/mate-connectors/female.stl @@ -0,0 +1,226 @@ +solid OpenSCAD_Model + facet normal 1 -0 0 + outer loop + vertex 15 -9 0 + vertex 15 9 -8 + vertex 15 9 0 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 15 9 -8 + vertex 15 -9 0 + vertex 15 -9 -8 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 15 9 0 + vertex 5.3246 1.63218 0 + vertex 15 -9 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 15 9 0 + vertex -4.79494 3.42759 0 + vertex 5.3246 1.63218 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 15 9 0 + vertex -5.97725 3.87059 0 + vertex -4.79494 3.42759 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex -5.97725 3.87059 0 + vertex -15 9 0 + vertex -5.97725 -3.87059 0 + endloop + endfacet + facet normal -0 0 1 + outer loop + vertex -15 9 0 + vertex -5.97725 3.87059 0 + vertex 15 9 0 + endloop + endfacet + facet normal -0 0 1 + outer loop + vertex 5.3246 -1.63218 0 + vertex 15 -9 0 + vertex 5.3246 1.63218 0 + endloop + endfacet + facet normal -0 0 1 + outer loop + vertex -4.79494 -3.42759 0 + vertex 15 -9 0 + vertex 5.3246 -1.63218 0 + endloop + endfacet + facet normal -0 0 1 + outer loop + vertex -5.97725 -3.87059 0 + vertex 15 -9 0 + vertex -4.79494 -3.42759 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex -5.97725 -3.87059 0 + vertex -15 -9 0 + vertex 15 -9 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex -15 -9 0 + vertex -5.97725 -3.87059 0 + vertex -15 9 0 + endloop + endfacet + facet normal 0 0 -1 + outer loop + vertex -15 -9 -8 + vertex 15 9 -8 + vertex 15 -9 -8 + endloop + endfacet + facet normal -0 0 -1 + outer loop + vertex 15 9 -8 + vertex -15 -9 -8 + vertex -15 9 -8 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex -15 -9 -8 + vertex -15 9 0 + vertex -15 9 -8 + endloop + endfacet + facet normal -1 -0 0 + outer loop + vertex -15 9 0 + vertex -15 -9 -8 + vertex -15 -9 0 + endloop + endfacet + facet normal 0 1 -0 + outer loop + vertex 15 9 -8 + vertex -15 9 0 + vertex 15 9 0 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex -15 9 0 + vertex 15 9 -8 + vertex -15 9 -8 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex -15 -9 -8 + vertex 15 -9 0 + vertex -15 -9 0 + endloop + endfacet + facet normal 0 -1 -0 + outer loop + vertex 15 -9 0 + vertex -15 -9 -8 + vertex 15 -9 -8 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex -6.2 4.2 -0.4 + vertex 6.2 -2 -0.4 + vertex 6.2 2 -0.4 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 6.2 -2 -0.4 + vertex -6.2 4.2 -0.4 + vertex -6.2 -4.2 -0.4 + endloop + endfacet + facet normal 0.873667 0 -0.486524 + outer loop + vertex -5.97725 -3.87059 0 + vertex -6.2 4.2 -0.4 + vertex -5.97725 3.87059 0 + endloop + endfacet + facet normal 0.873667 0 -0.486524 + outer loop + vertex -6.2 4.2 -0.4 + vertex -5.97725 -3.87059 0 + vertex -6.2 -4.2 -0.4 + endloop + endfacet + facet normal -0.107146 0.603912 -0.789816 + outer loop + vertex 6.2 -2 -0.4 + vertex -4.79494 -3.42759 0 + vertex 5.3246 -1.63218 0 + endloop + endfacet + facet normal -0.107147 0.603918 -0.789812 + outer loop + vertex -4.79494 -3.42759 0 + vertex 6.2 -2 -0.4 + vertex -6.2 -4.2 -0.4 + endloop + endfacet + facet normal -0.304068 0.811519 -0.498978 + outer loop + vertex -4.79494 -3.42759 0 + vertex -6.2 -4.2 -0.4 + vertex -5.97725 -3.87059 0 + endloop + endfacet + facet normal -0.304068 -0.811519 -0.498978 + outer loop + vertex -5.97725 3.87059 0 + vertex -6.2 4.2 -0.4 + vertex -4.79494 3.42759 0 + endloop + endfacet + facet normal -0.107146 -0.603912 -0.789816 + outer loop + vertex -4.79494 3.42759 0 + vertex 6.2 2 -0.4 + vertex 5.3246 1.63218 0 + endloop + endfacet + facet normal -0.107147 -0.603918 -0.789812 + outer loop + vertex 6.2 2 -0.4 + vertex -4.79494 3.42759 0 + vertex -6.2 4.2 -0.4 + endloop + endfacet + facet normal -0.415603 0 -0.909546 + outer loop + vertex 5.3246 -1.63218 0 + vertex 6.2 2 -0.4 + vertex 6.2 -2 -0.4 + endloop + endfacet + facet normal -0.415603 0 -0.909546 + outer loop + vertex 6.2 2 -0.4 + vertex 5.3246 -1.63218 0 + vertex 5.3246 1.63218 0 + endloop + endfacet +endsolid OpenSCAD_Model diff --git a/docs/CAD/design/mate-connectors/female_only.scad b/docs/CAD/design/mate-connectors/female_only.scad new file mode 100644 index 0000000000..89f83b4655 --- /dev/null +++ b/docs/CAD/design/mate-connectors/female_only.scad @@ -0,0 +1,12 @@ +// Female half alone, for the legibility test: is a recessed faceted pocket readable in a +// shaded view, or does a concave feature just read as a dark hole with no orientation? +use + +// The plate must be THICKER than the key is tall, or the "pocket" is a through-hole. The +// first version used 3 mm against a 4.5 mm key and cut straight through — caught only by +// rendering it. Minimum stock = H + clearance + pocket depth + a wall to print against. +PLATE = [30, 18, 8]; +difference() { + translate([-PLATE[0]/2, -PLATE[1]/2, -PLATE[2]]) cube(PLATE); + ridge_key_female(); +} diff --git a/docs/CAD/design/mate-connectors/fit_check.py b/docs/CAD/design/mate-connectors/fit_check.py new file mode 100644 index 0000000000..5a9ee01063 --- /dev/null +++ b/docs/CAD/design/mate-connectors/fit_check.py @@ -0,0 +1,20 @@ +# Measure the assembled fit between the supplied male and the generated female. +# This is the number that matters: the minimum gap in the seated position. +# Run: /snap/bin/freecad.cmd fit_check.py +import os +import Part + +HERE = os.path.dirname(os.path.abspath(__file__)) +male = Part.Shape(); male.read(os.path.join(HERE, "bear.step")) +fem = Part.Shape(); fem.read(os.path.join(HERE, "BearConnector_Female.step")) +male, fem = male.Solids[0], fem.Solids[0] + +d = male.distToShape(fem) +print(f"RESULT minimum gap male<->female, seated: {d[0]:.4f} mm (design clearance 0.20)") + +c = male.common(fem) +print(f"RESULT interference volume: {(c.Volume if c.Solids else 0.0):.6f} mm3") + +p = d[1][0][0] +print(f"RESULT tightest point on the male: ({p.x:.2f}, {p.y:.2f}, {p.z:.2f})") +print(f"RESULT male {male.Volume/1000:.2f} cm3 / female {fem.Volume/1000:.2f} cm3") diff --git a/docs/CAD/design/mate-connectors/glyph-preview.png b/docs/CAD/design/mate-connectors/glyph-preview.png new file mode 100644 index 0000000000..5e62c9a17f Binary files /dev/null and b/docs/CAD/design/mate-connectors/glyph-preview.png differ diff --git a/docs/CAD/design/mate-connectors/glyph_preview.py b/docs/CAD/design/mate-connectors/glyph_preview.py new file mode 100644 index 0000000000..e339e6bdab --- /dev/null +++ b/docs/CAD/design/mate-connectors/glyph_preview.py @@ -0,0 +1,99 @@ +"""Render the SIMPLIFIED glyph exactly as render_mate_face() draws it — x0kd. + +This is the panel the study was missing. simplify_study.py measured a FLAT outline and +relief_sheet.py measured the FULL 1508-facet part; neither showed the simplified glyph WITH its +relief, which is what the code actually draws and the only thing that answers "is the snout still +protruding". Same facet list, same painter order, same camera-fixed lambert as the C++. +""" +import math, os +from PIL import Image, ImageDraw + +HERE = os.path.dirname(os.path.abspath(__file__)) +T = open(os.path.join(HERE, "bear_glyph_table.h")).read() +def grab(name, n): + body = T.split(name + "[] = {")[1].split("};")[0] + body = "\n".join(l.split("//")[0] for l in body.splitlines()) + out = [] + for tok in body.replace("\n", " ").split("},"): + tok = tok.strip().lstrip("{").strip() + if not tok: continue + v = [float(x) for x in tok.replace("{", "").split(",")[:n]] + if len(v) == n: out.append(tuple(v)) + return out +OUT = grab("kBearOutline", 2) +CHIN = grab("kBearChin", 2) # NB: this table entry is the CHIN BAR, not the snout +MARKS = grab("kBearMarks", 3) +CREST = grab("kBearCrest", 3) +SBASE = grab("kBearSnoutBase", 2) +PLATE = float(T.split("kBearPlateZ = ")[1].split(";")[0]) + + +def facets(): + F = [] + n = len(OUT) + for i in range(n): # plate sides -> the grazing silhouette + a, b = OUT[i], OUT[(i+1) % n] + F.append(([(a[0],a[1],0.0),(b[0],b[1],0.0),(b[0],b[1],PLATE),(a[0],a[1],PLATE)], "body", True)) + F.append(([(x,y,PLATE) for x,y in OUT], "body", True)) # plate top + zm = PLATE + 0.004 + for cx,cy,r in MARKS: # eyes + cheek dot + F.append(([(cx+r*math.cos(2*math.pi*i/12), cy+r*math.sin(2*math.pi*i/12), zm) for i in range(12)], "mark", False)) + F.append(([(x,y,zm) for x,y in CHIN], "mark", False)) # chin bar + A, B = CREST # THE MUZZLE: base quad + crest + nl=(SBASE[0][0],SBASE[0][1],PLATE); nr=(SBASE[1][0],SBASE[1][1],PLATE) + tr=(SBASE[2][0],SBASE[2][1],PLATE); tl=(SBASE[3][0],SBASE[3][1],PLATE) + F += [([nl,tl,B,A],"body",True), # left flank + ([nr,A,B,tr],"body",True), # right flank + ([nl,A,nr],"body",True), # nose cap, sloping because the base overhangs the crest + ([tr,B,tl],"body",True)] # tail cap + return F +FACETS = facets() + +BODY=(0.42,0.46,0.52); MARK=(0.126,0.138,0.156) +def render(px, elev_deg, ss=8): + S=px*ss; a=math.radians(elev_deg); ca,sa=math.cos(a),math.sin(a) + # camera orbits down; the connector's +Z (relief) tips toward the horizon + xf=lambda p:(p[0], p[1]*sa + p[2]*ca, -p[1]*ca + p[2]*sa) + light=(-0.70,0.30,0.45) + img=Image.new("RGB",(S,S),(24,27,32)); d=ImageDraw.Draw(img) + tris=[] + for pts,kind,shade in FACETS: + q=[xf(p) for p in pts] + tris.append((sum(v[2] for v in q)/len(q), q, kind, shade)) + tris.sort(key=lambda t:t[0]) # far first + for _,q,kind,shade in tris: + (x0,y0,z0),(x1,y1,z1),(x2,y2,z2)=q[0],q[1],q[2] + ux,uy,uz=x1-x0,y1-y0,z1-z0; vx,vy,vz=x2-x0,y2-y0,z2-z0 + nx,ny,nz=uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx + nn=math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0 + nx,ny,nz=nx/nn,ny/nn,nz/nn + if nz<0: nx,ny,nz=-nx,-ny,-nz + base=BODY if kind=="body" else MARK + k=(0.42+0.58*max(0.0,nx*light[0]+ny*light[1]+nz*light[2])) if shade else 1.0 + col=tuple(min(255,int(255*c*k)) for c in base) + d.polygon([(S/2+p[0]*S*0.92, S/2-p[1]*S*0.92) for p in q], fill=col) + return img.resize((px,px), Image.LANCZOS) + +SIZES=[22,32,48]; ELEVS=[(90,"flat on"),(47,"47"),(16,"16"),(6,"6")] +pad,cell=8,58 +W=pad+len(SIZES)*len(ELEVS)*cell+pad; H=pad+cell+pad +sheet=Image.new("RGB",(W,H),(24,27,32)) +for ci,(e,_) in enumerate(ELEVS): + for si,px in enumerate(SIZES): + g=render(px,e) + sheet.paste(g, (pad+(ci*len(SIZES)+si)*cell+(cell-px)//2, pad+(cell-px)//2)) +sheet.resize((W*2,H*2), Image.NEAREST).save(os.path.join(HERE,"glyph-preview.png")) + +# how much of the glyph is the snout: render with and without the tent and diff +def render_no_tent(px, elev): + global FACETS + keep=FACETS; FACETS=FACETS[:-4] + try: return render(px, elev) + finally: FACETS=keep +print(f"{'elev':>8} {'lit px@32':>10} {'snout px':>9} {'snout share':>12}") +for e,_ in ELEVS: + a=render(32,e); b=render_no_tent(32,e) + la=sum(1 for p in a.get_flattened_data() if p!=(24,27,32)) + diff=sum(1 for p,q in zip(a.get_flattened_data(), b.get_flattened_data()) if p!=q) + print(f"{e:>8} {la:>10} {diff:>9} {100.0*diff/max(1,la):>11.1f}%") +print("WROTE glyph-preview.png") diff --git a/docs/CAD/design/mate-connectors/glyph_probe.py b/docs/CAD/design/mate-connectors/glyph_probe.py new file mode 100644 index 0000000000..f4d48fdd6c --- /dev/null +++ b/docs/CAD/design/mate-connectors/glyph_probe.py @@ -0,0 +1,143 @@ +# Mate-connector glyph probe — built as REAL solids on REAL mechanical geometry, +# so the shape can be judged in a 3D viewport instead of in a browser mock. +# +# Four polarity treatments, side by side on one bracket: +# A Onshape baseline ...... ring + roll quadrant + three short axis arms +# B solid cone ............ ring + quadrant + one-sided Z arrow, filled head (driven) +# C hollow collar ......... ring + quadrant + one-sided Z arrow, shell head (fixed) +# D pin / cup ............. polarity by RELIEF: a raised pin vs a sunk cup +# +# D is the one that only a 3D test can settle: in a shaded viewport, solid-vs-hollow is a +# weak cue that depends on angle and lighting, while convex-vs-concave is a strong one -- +# and male/female is the mechanical language for polarity anyway. +# +# Scale note: in the real viewport gizmos are screen-constant (~15-40 px via upp = 1/zoom). +# At a zoom where a 60 mm part fills ~600 px, 40 px is about 4 mm, so R = 4.5 mm here. + +import FreeCAD as App +import FreeCADGui as Gui +import Part +from FreeCAD import Vector + +DOC = "GlyphProbe" +for d in list(App.listDocuments()): + App.closeDocument(d) +doc = App.newDocument(DOC) + +R = 4.5 # disc radius, the module everything scales from +GOLD = (0.93, 0.66, 0.09) +BLUE = (0.18, 0.44, 0.93) +GREY = (0.42, 0.46, 0.52) +RED = (0.85, 0.29, 0.24) +GREEN = (0.23, 0.65, 0.35) + +def add(name, shape, color, transparency=0): + o = doc.addObject("Part::Feature", name) + o.Shape = shape + o.ViewObject.ShapeColor = color + o.ViewObject.LineColor = color + o.ViewObject.PointColor = color + o.ViewObject.Transparency = transparency + return o + +def frame(origin, zdir, xdir): + """Right-handed placement matrix from origin + Z + X (X orthonormalised against Z).""" + z = Vector(*zdir); z.normalize() + xr = Vector(*xdir) + x = xr.sub(Vector(z).multiply(z.dot(xr))); x.normalize() + y = z.cross(x) + return App.Matrix(x.x, y.x, z.x, origin[0], + x.y, y.y, z.y, origin[1], + x.z, y.z, z.z, origin[2], + 0, 0, 0, 1) + +# ---------------------------------------------------------------- the bracket +plate = Part.makeBox(120, 46, 8) +bore = Part.makeCylinder(7, 40, Vector(96, 23, -6)) # a real bore, curved face +boss = Part.makeCylinder(11, 7, Vector(96, 23, 8)) +part = plate.fuse(boss).cut(bore) +add("Bracket", part, (0.60, 0.63, 0.66)) + +# ---------------------------------------------------------------- glyph pieces +def ring(t=None): + t = t or R * 0.10 + return Part.makeCylinder(R, t).cut(Part.makeCylinder(R * 0.84, t)) + +def quadrant(t=None): + t = t or R * 0.10 + return Part.makeCylinder(R * 0.84, t, Vector(0, 0, 0), Vector(0, 0, 1), 90) + +def stem(L=None, r=None): + return Part.makeCylinder(r or R * 0.09, L or R * 2.3) + +def solid_head(): + return Part.makeCone(R * 0.32, 0, R * 0.80, Vector(0, 0, R * 2.3)) + +def shell_head(): + outer = Part.makeCone(R * 0.32, 0, R * 0.80, Vector(0, 0, R * 2.3)) + inner = Part.makeCone(R * 0.22, 0, R * 0.62, Vector(0, 0, R * 2.3)) + return outer.cut(inner) + +def short_axis(direction, L=None): + L = L or R * 1.15 + return Part.makeCylinder(R * 0.07, L, Vector(0, 0, 0), Vector(*direction)) + +def place(shape, m): + s = shape.copy() + s.transformShape(m) + return s + +# ---------------------------------------------------------------- the variants +def variant_A(tag, origin): # Onshape baseline + m = frame(origin, (0, 0, 1), (1, 0, 0)) + add(tag + "_ring", place(ring(), m), GREY) + add(tag + "_quad", place(quadrant(), m), GOLD) + add(tag + "_x", place(short_axis((1, 0, 0)), m), RED) + add(tag + "_y", place(short_axis((0, 1, 0)), m), GREEN) + add(tag + "_z", place(short_axis((0, 0, 1), R * 1.6), m), BLUE) + +def variant_B(tag, origin, zdir=(0, 0, 1)): # solid cone = driven + m = frame(origin, zdir, (1, 0, 0)) + add(tag + "_ring", place(ring(), m), BLUE) + add(tag + "_quad", place(quadrant(), m), GOLD) + add(tag + "_body", place(stem().fuse(solid_head()), m), BLUE) + +def variant_C(tag, origin, zdir=(0, 0, 1)): # hollow collar = fixed + m = frame(origin, zdir, (1, 0, 0)) + add(tag + "_ring", place(ring(), m), GREY) + add(tag + "_quad", place(quadrant(), m), GOLD) + add(tag + "_body", place(stem().fuse(shell_head()), m), GREY) + +def variant_D_pin(tag, origin, zdir=(0, 0, 1)): # polarity by relief: raised PIN + m = frame(origin, zdir, (1, 0, 0)) + pin = Part.makeCylinder(R * 0.30, R * 1.5).fuse( + Part.makeCone(R * 0.30, 0, R * 0.55, Vector(0, 0, R * 1.5))) + add(tag + "_ring", place(ring(), m), BLUE) + add(tag + "_quad", place(quadrant(), m), GOLD) + add(tag + "_pin", place(pin, m), BLUE) + +def variant_D_cup(tag, origin, zdir=(0, 0, 1)): # polarity by relief: sunk CUP + m = frame(origin, zdir, (1, 0, 0)) + cup = Part.makeCylinder(R * 0.62, R * 0.9).cut( + Part.makeCylinder(R * 0.40, R * 0.9, Vector(0, 0, -0.01))) + add(tag + "_ring", place(ring(), m), GREY) + add(tag + "_quad", place(quadrant(), m), GOLD) + add(tag + "_cup", place(cup, m), GREY) + +# four treatments across the plate, all on the same flat face, same Z +variant_A("A", (14, 30, 8)) +variant_B("B", (40, 30, 8)) +variant_C("C", (64, 30, 8)) +variant_D_pin("Dpin", (14, 10, 8)) +variant_D_cup("Dcup", (40, 10, 8)) + +# the hard cases, which is the whole reason for doing this in 3D: +variant_B("Bore", (96, 23, 15)) # on the boss above a bore +variant_B("Edge", (64, 0, 8), (0, -0.7071, 0.7071)) # tilted, on an edge, oblique Z + +doc.recompute() + +v = Gui.activeDocument().activeView() +v.viewIsometric() +Gui.SendMsgToActiveView("ViewFit") +App.Console.PrintMessage("glyph probe built: %d objects\n" % len(doc.Objects)) diff --git a/docs/CAD/design/mate-connectors/handedness-sheet.png b/docs/CAD/design/mate-connectors/handedness-sheet.png new file mode 100644 index 0000000000..b44c370328 Binary files /dev/null and b/docs/CAD/design/mate-connectors/handedness-sheet.png differ diff --git a/docs/CAD/design/mate-connectors/handedness.py b/docs/CAD/design/mate-connectors/handedness.py new file mode 100644 index 0000000000..68fa07bb55 --- /dev/null +++ b/docs/CAD/design/mate-connectors/handedness.py @@ -0,0 +1,112 @@ +"""Give the bear a handedness mark that survives rasterisation — wi3z, Tommaso's call 2. + +The study showed the left/right cue lives in sub-millimetre corner radii and is therefore invisible +at glyph size: one pixel is 2.6 mm at 32 px. Roll and verse are safe; handedness is not. + +THE MEASURE IS THE QUESTION ITSELF. Render the glyph, render its mirror image, and count how many +pixels differ. If a human is to tell left from right, the two must differ on screen; a candidate +that scores near zero is invisible however elegant it looks in CAD. Reported as a percentage of the +glyph's own lit area, so the sizes are comparable. +""" +import json, math, os +from PIL import Image, ImageDraw, ImageChops + +HERE = os.path.dirname(os.path.abspath(__file__)) +D = json.load(open(os.path.join(HERE, "bear_outline.json"))) +def unit(pts): + p = [(x, -z) for x, z in pts] + return p +outer = unit(D["outer"]); holes = [unit(h["pts"]) for h in D["holes"]] +ALL = outer + [p for h in holes for p in h] +xs=[p[0] for p in ALL]; ys=[p[1] for p in ALL] +CX,CY = (min(xs)+max(xs))/2,(min(ys)+max(ys))/2 +SPAN = max(max(xs)-min(xs), max(ys)-min(ys)) +U = lambda pts: [((x-CX)/SPAN,(y-CY)/SPAN) for x,y in pts] +OUT = U(outer) +EYES = [U(h) for h,m in zip(holes, D["holes"]) if m["d"] < 20] +MUZ = U([h for h,m in zip(holes, D["holes"]) if m["d"] >= 20][0]) + +def rdp(pts, eps): + if len(pts) < 3: return pts + ax,ay=pts[0]; bx,by=pts[-1]; dx,dy=bx-ax,by-ay + n=math.hypot(dx,dy); best,bi=-1.0,0 + for i in range(1,len(pts)-1): + px,py=pts[i] + d=abs(dx*(ay-py)-(ax-px)*dy)/n if n>1e-12 else math.hypot(px-ax,py-ay) + if d>best: best,bi=d,i + if best<=eps: return [pts[0],pts[-1]] + return rdp(pts[:bi+1],eps)[:-1]+rdp(pts[bi:],eps) +def simp(pts,eps): + r=rdp(pts+[pts[0]],eps); return r[:-1] + +BASE = simp(OUT, .030) # the 22-vertex outline the study settled on +def centroid(p): return (sum(q[0] for q in p)/len(p), sum(q[1] for q in p)/len(p)) +def circ(cx,cy,r,n=16): return [(cx+r*math.cos(2*math.pi*i/n), cy+r*math.sin(2*math.pi*i/n)) for i in range(n)] +EYE_D = [] +for e in EYES: + c=centroid(e); r=(max(p[0] for p in e)-min(p[0] for p in e))/2 + EYE_D.append((c[0],c[1],r)) +EYE_D.sort() # [0] = left (x<0), [1] = right + +TOP = max(p[1] for p in BASE) +H = TOP - min(p[1] for p in BASE) +def ear_tip(sign): + cands=[p for p in BASE if p[1] > TOP-0.18*H and (p[0]*sign) > 0] + return max(cands, key=lambda p: p[0]*sign) if cands else None +LT, RT = ear_tip(-1), ear_tip(+1) + +def notch(tip, sign, k=0.085): + """A wedge bitten out of one ear — background-filled, exactly how the eyes are already drawn.""" + x,y = tip + return [(x, y+0.02), (x - sign*k, y - k*0.55), (x + sign*k*0.15, y - k*1.05)] + +CANDS = { + "H0 none": dict(cuts=[], eyes=EYE_D), + "H1 notch R ear": dict(cuts=[notch(RT, +1)], eyes=EYE_D), + "H2 notch both": dict(cuts=[notch(RT, +1), notch(LT, -1, 0.045)], eyes=EYE_D), + "H3 cheek dot": dict(cuts=[circ(EYE_D[1][0]+0.085, EYE_D[1][1]-0.10, 0.038)], eyes=EYE_D), + "H4 uneven eyes": dict(cuts=[], eyes=[EYE_D[0], (EYE_D[1][0], EYE_D[1][1], EYE_D[1][2]*1.55)]), +} + +def render(c, px, ss=8, mirror=False): + S=px*ss; img=Image.new("L",(S,S),0); d=ImageDraw.Draw(img) + m = lambda p: (S/2 + (-p[0] if mirror else p[0])*S*0.92, S/2 - p[1]*S*0.92) + d.polygon([m(p) for p in BASE], fill=255) + d.polygon([m(p) for p in MUZ], fill=0) + for cx,cy,r in c["eyes"]: + a=m((cx-r,cy+r)); b=m((cx+r,cy-r)) + d.ellipse([min(a[0],b[0]), min(a[1],b[1]), max(a[0],b[0]), max(a[1],b[1])], fill=0) + for cut in c["cuts"]: + d.polygon([m(p) for p in cut], fill=0) + return img.resize((px,px), Image.LANCZOS) + +SIZES=[22,32,48] +print(f"{'candidate':16} " + " ".join(f"{s}px" for s in SIZES) + " (pixels differing from own mirror, % of lit area)") +print("-"*84) +scores={} +for name,c in CANDS.items(): + row=[] + for px in SIZES: + a=render(c,px); b=render(c,px,mirror=True) + diff=ImageChops.difference(a,b) + nd=sum(1 for v in diff.getdata() if v>40) + lit=sum(1 for v in a.getdata() if v>40) or 1 + row.append(100.0*nd/lit) + scores[name]=row + print(f"{name:16} " + " ".join(f"{v:5.1f}" for v in row)) + +pad,cell=8,58 +W=pad+len(SIZES)*2*cell+pad; Hh=pad+len(CANDS)*cell+pad +sheet=Image.new("RGB",(W,Hh),(24,27,32)) +for r,(name,c) in enumerate(CANDS.items()): + for mi,mir in enumerate((False,True)): + for si,px in enumerate(SIZES): + g=render(c,px,mirror=mir) + tile=Image.new("RGB",(px,px),(24,27,32)) + tile.paste(Image.new("RGB",(px,px),(237,168,23)),(0,0),g) + x=pad+(mi*len(SIZES)+si)*cell+(cell-px)//2 + y=pad+r*cell+(cell-px)//2 + sheet.paste(tile,(x,y)) +sheet.resize((W*2,Hh*2), Image.NEAREST).save(os.path.join(HERE,"handedness-sheet.png")) +print("\nleft block = as drawn, right block = mirrored. rows: " + ", ".join(CANDS)) +print("WROTE handedness-sheet.png") diff --git a/docs/CAD/design/mate-connectors/linearedgemateconnectors.png b/docs/CAD/design/mate-connectors/linearedgemateconnectors.png new file mode 100644 index 0000000000..fd5e64190d Binary files /dev/null and b/docs/CAD/design/mate-connectors/linearedgemateconnectors.png differ diff --git a/docs/CAD/design/mate-connectors/make_female.py b/docs/CAD/design/mate-connectors/make_female.py new file mode 100644 index 0000000000..ccb38426bf --- /dev/null +++ b/docs/CAD/design/mate-connectors/make_female.py @@ -0,0 +1,135 @@ +# Build the complementary FEMALE for BearConnector.step. +# +# Method: take the supplied male B-rep as-is, grow it by a uniform clearance, and subtract that +# from a block. Working on the real solid rather than re-modelling the bear is the whole point — +# the pocket is then exactly complementary by construction, including every deliberate asymmetry. +# +# The offset uses join=2 (Intersection), which extends the adjacent planes and meets them at a +# sharp corner. For a faceted part that is the correct join: the arc join would round every convex +# edge and blunt the very cues the design depends on. +# +# THE MALE'S NATIVE FRAME: the flat back is the plane Y=0 and the relief rises to Y=+17.27. +# X and Z carry the face (83.34 x 66.69). The frame is kept exactly as supplied so that male and +# female drop into the same assembly without anyone having to re-orient one of them. +# Insertion is therefore along +Y, and the pocket must OPEN on the Y=0 plane. +# +# A first version of this script assumed the relief ran along +Z, built the block around the wrong +# axis, and produced a sealed cavity with no way in. It passed a "male does not intersect female" +# check, because that only tests the seated position and says nothing about whether the part can +# get there. The straight-pull test below is what catches it. +# +# Run: /snap/bin/freecad.cmd make_female.py + +import os, sys, math +import FreeCAD as App +import Part + +HERE = os.path.dirname(os.path.abspath(__file__)) +MALE = os.path.join(HERE, "bear.step") +OUT_STEP = os.path.join(HERE, "BearConnector_Female.step") + +CLEAR = 0.20 # per-face clearance, mm +WALL = 4.0 # material around the pocket, mm +FLOOR = 3.0 # material behind the deepest point of the pocket, mm + +male = Part.Shape(); male.read(MALE) +if len(male.Solids) != 1: + print(f"FAIL: expected 1 solid in the male, found {len(male.Solids)}"); sys.exit(1) +male = male.Solids[0] +bb = male.BoundBox +print(f"male : {bb.XLength:.2f} (X) x {bb.YLength:.2f} (Y) x {bb.ZLength:.2f} (Z) mm, " + f"{len(male.Faces)} faces, {male.Volume/1000:.2f} cm3") +print(f" relief runs Y {bb.YMin:.2f} .. {bb.YMax:.2f} -> insertion along +Y, mouth at Y={bb.YMin:.2f}") + +# ---- 1. can the male even be withdrawn along the insertion axis? ---------------------- +# Ray-cast a grid along +Y through the tessellated male and count crossings. A straight pull is +# possible only if no ray enters the solid more than once; a second entry is an undercut. +verts, facets = male.tessellate(0.15) +V = [(v.x, v.y, v.z) for v in verts] +worst, undercut_pts = 0, 0 +NX = NZ = 90 +for i in range(NX): + x = bb.XMin + (i + 0.5) * bb.XLength / NX + for j in range(NZ): + z = bb.ZMin + (j + 0.5) * bb.ZLength / NZ + hits = 0 + for (ia, ib, ic) in facets: # ray (x, *, z) along +Y vs triangle + ax, ay, az = V[ia]; bx, by, bz = V[ib]; cx, cy, cz = V[ic] + # 2D point-in-triangle in the XZ plane + d = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz) + if abs(d) < 1e-12: continue + u = ((bz - cz) * (x - cx) + (cx - bx) * (z - cz)) / d + v = ((cz - az) * (x - cx) + (ax - cx) * (z - cz)) / d + if u < 0 or v < 0 or u + v > 1: continue + hits += 1 + worst = max(worst, hits) + if hits > 2: undercut_pts += 1 +print(f"pull : max crossings along +Y = {worst}, undercut samples = {undercut_pts}/{NX*NZ}") +if undercut_pts: + print("FAIL: the male has an undercut along +Y; a straight pocket cannot release it") + sys.exit(1) +print(" no undercut -> a straight-pull pocket works") + +# ---- 2. grow the male by the clearance ----------------------------------------------- +grown = None +for join, name in ((2, "Intersection"), (1, "Tangent"), (0, "Arc")): + try: + g = male.makeOffsetShape(CLEAR, 1e-6, False, False, 0, join, False) + if g.isValid() and g.Solids: + grown = g.Solids[0]; print(f"offset: join={name}, {grown.Volume/1000:.2f} cm3"); break + except Exception as e: + print(f"offset: join={name} failed -- {e}") +if grown is None: + print("FAIL: could not offset the male; refusing to emit a zero-clearance pocket"); sys.exit(1) + +# ---- 3. the block: walls in X and Z, depth in +Y, OPEN at the Y=0 mouth --------------- +gb = grown.BoundBox +y_mouth = bb.YMin # the male's flat back plane +depth = gb.YMax - y_mouth +block = Part.makeBox(gb.XLength + 2*WALL, depth + FLOOR, gb.ZLength + 2*WALL, + App.Vector(gb.XMin - WALL, y_mouth, gb.ZMin - WALL)) +print(f"block : {gb.XLength + 2*WALL:.2f} x {depth + FLOOR:.2f} x {gb.ZLength + 2*WALL:.2f} mm, " + f"mouth on the Y={y_mouth:.2f} plane") + +female = block.cut(grown) + +# ---- 4. verify -------------------------------------------------------------------------- +ok = True +if not female.isValid(): print("FAIL: invalid shape"); ok = False +if len(female.Solids) != 1: print(f"FAIL: {len(female.Solids)} solids"); ok = False + +clash = male.common(female) +cv = clash.Volume if clash.Solids else 0.0 +print(f"check : male ∩ female = {cv:.6f} mm3 (seated fit, must be ~0)") +if cv > 1e-3: print("FAIL: male collides with female"); ok = False + +# the mouth must actually be open: the pocket has to reach the Y=y_mouth face of the block +mouth_face_area = 0.0 +for f in female.Faces: + c = f.CenterOfMass + if abs(c.y - y_mouth) < 1e-6: + mouth_face_area += f.Area +solid_mouth = (gb.XLength + 2*WALL) * (gb.ZLength + 2*WALL) +open_area = solid_mouth - mouth_face_area +print(f"check : mouth plane -- material {mouth_face_area:.1f} mm2, opening {open_area:.1f} mm2 " + f"({100*open_area/solid_mouth:.1f}% of the face)") +if open_area < 100: + print("FAIL: the pocket is sealed -- the male cannot be inserted"); ok = False + +cavity = block.Volume - female.Volume +print(f"check : cavity {cavity/1000:.2f} cm3 vs male {male.Volume/1000:.2f} cm3 " + f"-> clearance shell {(cavity-male.Volume)/1000:.2f} cm3") +if cavity < male.Volume: print("FAIL: cavity smaller than the male"); ok = False + +if not ok: + print("\nREFUSING to write the STEP"); sys.exit(1) + +doc = App.newDocument("Female") +obj = doc.addObject("Part::Feature", "BearConnector_Female") +obj.Shape = female +doc.recompute() +Part.export([obj], OUT_STEP) +fb = female.BoundBox +print(f"\nwrote {OUT_STEP}") +print(f"female: {fb.XLength:.2f} x {fb.YLength:.2f} x {fb.ZLength:.2f} mm, " + f"{len(female.Faces)} faces, {female.Volume/1000:.2f} cm3") diff --git a/docs/CAD/design/mate-connectors/mate-connector-virtual-sharp.png b/docs/CAD/design/mate-connectors/mate-connector-virtual-sharp.png new file mode 100644 index 0000000000..f9a80c1e10 Binary files /dev/null and b/docs/CAD/design/mate-connectors/mate-connector-virtual-sharp.png differ diff --git a/docs/CAD/design/mate-connectors/mateconnector-planarcentroid.png b/docs/CAD/design/mate-connectors/mateconnector-planarcentroid.png new file mode 100644 index 0000000000..16ca1c01e6 Binary files /dev/null and b/docs/CAD/design/mate-connectors/mateconnector-planarcentroid.png differ diff --git a/docs/CAD/design/mate-connectors/mateconnector-planarpoints.png b/docs/CAD/design/mate-connectors/mateconnector-planarpoints.png new file mode 100644 index 0000000000..d952ddc1f0 Binary files /dev/null and b/docs/CAD/design/mate-connectors/mateconnector-planarpoints.png differ diff --git a/docs/CAD/design/mate-connectors/matepointiconLG.png b/docs/CAD/design/mate-connectors/matepointiconLG.png new file mode 100644 index 0000000000..2f3022d116 Binary files /dev/null and b/docs/CAD/design/mate-connectors/matepointiconLG.png differ diff --git a/docs/CAD/design/mate-connectors/matepointreorientsecondaryaxis.png b/docs/CAD/design/mate-connectors/matepointreorientsecondaryaxis.png new file mode 100644 index 0000000000..bd65a9f40e Binary files /dev/null and b/docs/CAD/design/mate-connectors/matepointreorientsecondaryaxis.png differ diff --git a/docs/CAD/design/mate-connectors/muzzle_variants.py b/docs/CAD/design/mate-connectors/muzzle_variants.py new file mode 100644 index 0000000000..f4c9ab33b5 --- /dev/null +++ b/docs/CAD/design/mate-connectors/muzzle_variants.py @@ -0,0 +1,71 @@ +"""The muzzle has to READ, not just be present — wi3z. + +Faithfully scaled, the part's ridge is 11.3 mm on an 83 mm face: 13.6 % of the width. At glyph +size that is a scratch. A glyph is a symbol, not a scale model, so the question is how much +emphasis it takes before the only +Z feature actually reads. Variants, all with the same crest +geometry, differing only in width and colour. +""" +import math, os, importlib.util +from PIL import Image, ImageDraw +spec=importlib.util.spec_from_file_location("gp","glyph_preview.py") +gp=importlib.util.module_from_spec(spec); spec.loader.exec_module(gp) + +OUT, CHIN, MARKS, CREST, SBASE, PLATE = gp.OUT, gp.CHIN, gp.MARKS, gp.CREST, gp.SBASE, gp.PLATE +BODY=(0.42,0.46,0.52); MARK=(0.126,0.138,0.156); GOLD=(0.93,0.66,0.09) + +def facets(widen=1.0, muzzle_gold=False): + F=[]; n=len(OUT) + for i in range(n): + a,b=OUT[i],OUT[(i+1)%n] + F.append(([(a[0],a[1],0.0),(b[0],b[1],0.0),(b[0],b[1],PLATE),(a[0],a[1],PLATE)],BODY,True)) + F.append(([(x,y,PLATE) for x,y in OUT],BODY,True)) + zm=PLATE+0.004 + for cx,cy,r in MARKS: + F.append(([(cx+r*math.cos(2*math.pi*i/12),cy+r*math.sin(2*math.pi*i/12),zm) for i in range(12)],MARK,False)) + F.append(([(x,y,zm) for x,y in CHIN],MARK,False)) + A,B=CREST + w=lambda p:(p[0]*widen,p[1],PLATE) + nl,nr,tr,tl=(w(SBASE[0]),w(SBASE[1]),w(SBASE[2]),w(SBASE[3])) + col = GOLD if muzzle_gold else BODY + F+=[([nl,tl,B,A],col,True),([nr,A,B,tr],col,True), + ([nl,A,nr],col,True), ([tr,B,tl],col,True)] + return F + +def render(F, px, elev, ss=8): + S=px*ss; a=math.radians(elev); ca,sa=math.cos(a),math.sin(a) + xf=lambda p:(p[0],p[1]*sa+p[2]*ca,-p[1]*ca+p[2]*sa) + light=(-0.70,0.30,0.45) + img=Image.new("RGB",(S,S),(24,27,32)); d=ImageDraw.Draw(img) + tris=sorted(((sum(v[2] for v in [xf(q) for q in pts])/len(pts),[xf(q) for q in pts],c,sh) + for pts,c,sh in F), key=lambda t:t[0]) + for _,q,base,shade in tris: + (x0,y0,z0),(x1,y1,z1),(x2,y2,z2)=q[0],q[1],q[2] + ux,uy,uz=x1-x0,y1-y0,z1-z0; vx,vy,vz=x2-x0,y2-y0,z2-z0 + nx,ny,nz=uy*vz-uz*vy,uz*vx-ux*vz,ux*vy-uy*vx + L=math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0; nx,ny,nz=nx/L,ny/L,nz/L + if nz<0: nx,ny,nz=-nx,-ny,-nz + k=(0.42+0.58*max(0.0,nx*light[0]+ny*light[1]+nz*light[2])) if shade else 1.0 + d.polygon([(S/2+p[0]*S*0.92,S/2-p[1]*S*0.92) for p in q], + fill=tuple(min(255,int(255*c*k)) for c in base)) + return img.resize((px,px),Image.LANCZOS) + +VAR=[("V1 faithful", 1.0, False), + ("V2 gold muzzle", 1.0, True), + ("V3 gold + 1.8x wide",1.8, True), + ("V4 body + 1.8x wide",1.8, False)] +big=Image.new("RGB",(4*250+30,4*140+30),(24,27,32)) +for r,(name,wd,gold) in enumerate(VAR): + F=facets(wd,gold) + for c,e in enumerate((90,47,16,6)): + big.paste(render(F,120,e),(15+c*250+60,15+r*140+10)) +big.save("/tmp/muzzle-variants.png") +for name,wd,gold in VAR: + F=facets(wd,gold); F0=[f for f in F][:-4] + row=[] + for e in (90,16,6): + a=render(F,32,e); b=render(F0,32,e) + la=sum(1 for p in a.get_flattened_data() if p!=(24,27,32)) + df=sum(1 for p,q in zip(a.get_flattened_data(),b.get_flattened_data()) if p!=q) + row.append(f"{100.0*df/max(1,la):5.1f}%") + print(f"{name:22} muzzle share at 90/16/6 deg: " + " ".join(row)) +print("WROTE /tmp/muzzle-variants.png") diff --git a/docs/CAD/design/mate-connectors/planarfacemateconnectors.png b/docs/CAD/design/mate-connectors/planarfacemateconnectors.png new file mode 100644 index 0000000000..9fea27ab2b Binary files /dev/null and b/docs/CAD/design/mate-connectors/planarfacemateconnectors.png differ diff --git a/docs/CAD/design/mate-connectors/relief-sheet.png b/docs/CAD/design/mate-connectors/relief-sheet.png new file mode 100644 index 0000000000..0c6d0d11f0 Binary files /dev/null and b/docs/CAD/design/mate-connectors/relief-sheet.png differ diff --git a/docs/CAD/design/mate-connectors/relief_sheet.py b/docs/CAD/design/mate-connectors/relief_sheet.py new file mode 100644 index 0000000000..4b8bc6aa74 --- /dev/null +++ b/docs/CAD/design/mate-connectors/relief_sheet.py @@ -0,0 +1,99 @@ +"""Flat glyph vs 3D relief, at the elevations that killed the disc — wi3z. + +The flat study collapsed at 16 deg because anything drawn IN the connector's plane foreshortens by +sin(elevation). This renders the SAME bear as its real relief (1508 facets off the supplied male) +with a simple lambert shade, so the silhouette does the work at a grazing angle. Two rows, same +sizes, same elevations, so the comparison is direct. +""" +import json, math, os +from PIL import Image, ImageDraw + +HERE = os.path.dirname(os.path.abspath(__file__)) +M = json.load(open(os.path.join(HERE, "bear_mesh.json"))) +V, F = M["v"], M["f"] + +# Part frame: face carried by X (right) and Z (down-negative), relief along +Y. +P = [(v[0], -v[2], v[1]) for v in V] # -> (x right, y up, z out of the face) +xs=[p[0] for p in P]; ys=[p[1] for p in P]; zs=[p[2] for p in P] +CX,CY,CZ = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2, (min(zs)+max(zs))/2 +SPAN = max(max(xs)-min(xs), max(ys)-min(ys)) +P = [((x-CX)/SPAN, (y-CY)/SPAN, (z-CZ)/SPAN) for x,y,z in P] + +def shade(px, elev_deg, supersample=8): + """Camera orbits down from straight-on (90) to grazing (small). Rotate about the screen x-axis.""" + S = px*supersample + a = math.radians(elev_deg) + ca, sa = math.cos(a), math.sin(a) + # view: rotate the model so the face normal tips away from the camera + def xf(p): + x,y,z = p + return (x, y*sa + z*ca, -y*ca + z*sa) # third component = depth toward camera + Q = [xf(p) for p in P] + img = Image.new("L", (S,S), 0) + d = ImageDraw.Draw(img) + order = [] + for tri in F: + a3 = [Q[i] for i in tri] + order.append((sum(v[2] for v in a3)/3.0, tri, a3)) + order.sort(key=lambda t: t[0]) # painter: far first + light = (-0.35, 0.55, 0.76) + for _, tri, a3 in order: + (x0,y0,z0),(x1,y1,z1),(x2,y2,z2) = a3 + ux,uy,uz = x1-x0, y1-y0, z1-z0 + vx,vy,vz = x2-x0, y2-y0, z2-z0 + nx,ny,nz = uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx + n = math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0 + nx,ny,nz = nx/n, ny/n, nz/n + if nz < 0: nx,ny,nz = -nx,-ny,-nz # face the camera + lam = max(0.0, nx*light[0] + ny*light[1] + nz*light[2]) + val = int(70 + 185*lam) + pts = [(S/2 + x*S*0.92, S/2 - y*S*0.92) for x,y,_ in a3] + d.polygon(pts, fill=val) + return img.resize((px,px), Image.LANCZOS) + +# flat outline, for the side-by-side +D = json.load(open(os.path.join(HERE, "bear_outline.json"))) +def unit(pts): + p=[(x,-z) for x,z in pts] + return [((x-CX)/SPAN,(y-CY)/SPAN) for x,y in p] +OUT = unit(D["outer"]) +HOLES = [unit(h["pts"]) for h in D["holes"]] + +def flat(px, elev_deg, supersample=8): + S=px*supersample + img=Image.new("L",(S,S),0); d=ImageDraw.Draw(img) + k=math.sin(math.radians(elev_deg)) + m=lambda p:(S/2+p[0]*S*0.92, S/2-p[1]*S*0.92*k) + d.polygon([m(p) for p in OUT], fill=255) + for h in HOLES: d.polygon([m(p) for p in h], fill=0) + return img.resize((px,px), Image.LANCZOS) + +SIZES=[22,32,48]; ELEVS=[(90,"flat on"),(47,"47"),(16,"16"),(6,"6")] +pad,cell=8,58 +W=pad+len(SIZES)*len(ELEVS)*cell+pad; H=pad+2*cell+pad +sheet=Image.new("RGB",(W,H),(24,27,32)) +for r,fn in enumerate((flat, shade)): + for ci,(elev,_) in enumerate(ELEVS): + for si,px in enumerate(SIZES): + g=fn(px,elev) + tile=Image.new("RGB",(px,px),(24,27,32)) + if fn is flat: + tile.paste(Image.new("RGB",(px,px),(237,168,23)),(0,0),g) + else: + gg=g.convert("L") + tile=Image.merge("RGB",(gg.point(lambda v:min(255,int(v*1.00))), + gg.point(lambda v:int(v*0.71)), + gg.point(lambda v:int(v*0.16)))) + x=pad+(ci*len(SIZES)+si)*cell+(cell-px)//2 + y=pad+r*cell+(cell-px)//2 + sheet.paste(tile,(x,y)) +sheet.resize((W*2,H*2), Image.NEAREST).save(os.path.join(HERE,"relief-sheet.png")) + +# how much ink survives — the same measure used on the disc glyph +print(f"{'elev':>6} {'flat px@32':>11} {'relief px@32':>13}") +for elev,_ in ELEVS: + f32=flat(32,elev); s32=shade(32,elev) + fi=sum(1 for v in f32.getdata() if v>40) + si=sum(1 for v in s32.getdata() if v>40) + print(f"{elev:>6} {fi:>11} {si:>13}") +print("WROTE relief-sheet.png") diff --git a/docs/CAD/design/mate-connectors/relief_test.py b/docs/CAD/design/mate-connectors/relief_test.py new file mode 100644 index 0000000000..3fb83b2a8e --- /dev/null +++ b/docs/CAD/design/mate-connectors/relief_test.py @@ -0,0 +1,9 @@ +# Export the real male's relief as a triangle mesh, so the grazing test uses the actual geometry. +import os, json +import Part +HERE = os.path.dirname(os.path.abspath(__file__)) +s = Part.Shape(); s.read(os.path.join(HERE, "bear.step")) +verts, facets = s.Solids[0].tessellate(0.25) +V = [[round(p.x,4), round(p.y,4), round(p.z,4)] for p in verts] +json.dump({"v": V, "f": facets}, open(os.path.join(HERE, "bear_mesh.json"), "w")) +print(f"verts {len(V)} facets {len(facets)}") diff --git a/docs/CAD/design/mate-connectors/render_key.py b/docs/CAD/design/mate-connectors/render_key.py new file mode 100644 index 0000000000..eb4bcdbd99 --- /dev/null +++ b/docs/CAD/design/mate-connectors/render_key.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Flat-shade the faceted ridge key from several camera directions. + +The point is not a pretty picture. It is one question: does a low-poly solid, flat-shaded, +let a human read its orientation from an arbitrary viewpoint -- and specifically, is the +view ALONG the ridge ambiguous between front and back, as the geometry suggests it must be +in silhouette? + +Flat shading (one normal per facet, no smoothing) is deliberate: it is what the concept +claims to rely on, and it is what a CAD viewport with hard normals actually produces. +""" +import numpy as np +from PIL import Image, ImageDraw + +# ---- the key, same numbers as faceted_ridge_key.scad +L, W, tf, H, pr, pf, hf = 12.0, 4.0, 0.45, 4.5, 0.22, 0.62, 0.35 +Wf, xr0, xr1, Hf = W * tf, -L / 2 + L * pr, -L / 2 + L * pf, H * hf + +V = np.array([(-L/2, -W, 0), (-L/2, W, 0), (L/2, Wf, 0), (L/2, -Wf, 0), + (xr0, 0, H), (xr1, 0, Hf)], dtype=float) +F = [[0, 1, 2, 3], [0, 4, 1], [0, 3, 5], [0, 5, 4], [1, 4, 5], [1, 5, 2], [3, 2, 5]] + +LIGHT = np.array([0.35, -0.5, 0.78]) # a headlight-ish key light +LIGHT /= np.linalg.norm(LIGHT) + + +def look_at(eye, target, up=(0, 0, 1)): + f = np.array(target, float) - np.array(eye, float) + f /= np.linalg.norm(f) + up = np.array(up, float) + if abs(np.dot(f, up)) > 0.999: + up = np.array([0, 1, 0], float) + r = np.cross(f, up); r /= np.linalg.norm(r) + u = np.cross(r, f) + return r, u, f + + +def render(eye, target, path, size=(620, 460), scale=26.0, label=""): + r, u, f = look_at(eye, target) + eye = np.array(eye, float) + cam = np.stack([r, u, f]) # world -> camera rows + P = (V - eye) @ cam.T # orthographic: x,y screen, z depth + + w, h = size + img = Image.new("RGB", size, (238, 240, 243)) + d = ImageDraw.Draw(img) + + def to_px(p): + return (w / 2 + p[0] * scale, h / 2 - p[1] * scale) + + faces = [] + for face in F: + pts = V[face] + n = np.cross(pts[1] - pts[0], pts[2] - pts[0]) + n /= np.linalg.norm(n) + centre = pts.mean(axis=0) + if np.dot(n, centre - eye) > 0: # back-face cull + continue + depth = P[face][:, 2].mean() + lam = max(0.0, float(np.dot(n, LIGHT))) + shade = 0.22 + 0.78 * lam # flat: ONE value for the whole facet + col = tuple(int(255 * shade * c) for c in (0.86, 0.72, 0.35)) + faces.append((depth, [to_px(P[i]) for i in face], col)) + + for _, poly, col in sorted(faces, key=lambda t: -t[0]): # painter's algorithm + d.polygon(poly, fill=col) + + if label: + d.rectangle([8, 8, 8 + 9 * len(label), 30], fill=(255, 255, 255)) + d.text((14, 14), label, fill=(20, 20, 20)) + img.save(path) + return path + + +if __name__ == "__main__": + t = (0, 0, H * 0.35) + views = [ + ((26, -22, 20), "iso: the reference view"), + ((30, 0, 6), "ALONG +X (from the FRONT, low end)"), + ((-30, 0, 6), "ALONG -X (from the BACK, tall end)"), + ((0, 0, 34), "ALONG +Z (straight down the mating axis)"), + ((2, -32, 5), "ALONG -Y (broadside, grazing)"), + ] + for i, (eye, lab) in enumerate(views): + print(render(eye, t, f"rk-{i}.png", label=lab)) diff --git a/docs/CAD/design/mate-connectors/render_stl.py b/docs/CAD/design/mate-connectors/render_stl.py new file mode 100644 index 0000000000..16ad0e92a9 --- /dev/null +++ b/docs/CAD/design/mate-connectors/render_stl.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Flat-shade an ASCII/binary STL from several directions. + +Used to answer one question with a picture instead of an argument: does a RECESSED faceted +pocket read as an oriented feature, or does a concave feature collapse into a dark hole? +""" +import struct +import sys +import numpy as np +from PIL import Image, ImageDraw + +LIGHT = np.array([0.35, -0.5, 0.78]); LIGHT /= np.linalg.norm(LIGHT) + + +def load_stl(path): + data = open(path, "rb").read() + if data[:5] == b"solid" and b"facet" in data[:2000]: + tris, cur = [], [] + for line in data.decode("ascii", "ignore").splitlines(): + s = line.split() + if s and s[0] == "vertex": + cur.append([float(x) for x in s[1:4]]) + if len(cur) == 3: + tris.append(cur); cur = [] + return np.array(tris, dtype=float) + n = struct.unpack(" 0.999: up = np.array([0, 1.0, 0]) + r = np.cross(f, up); r /= np.linalg.norm(r) + u = np.cross(r, f) + cam = np.stack([r, u, f]) + + w, h = size + img = Image.new("RGB", size, (238, 240, 243)); d = ImageDraw.Draw(img) + faces = [] + for t in tris: + n = np.cross(t[1] - t[0], t[2] - t[0]) + ln = np.linalg.norm(n) + if ln < 1e-12: continue + n /= ln + c = t.mean(axis=0) + if np.dot(n, c - eye) > 0: continue # cull back faces + P = (t - eye) @ cam.T + lam = max(0.0, float(np.dot(n, LIGHT))) + shade = 0.20 + 0.80 * lam + col = tuple(int(255 * shade * ch) for ch in (0.86, 0.72, 0.35)) + poly = [(w / 2 + p[0] * scale, h / 2 - p[1] * scale) for p in P] + faces.append((P[:, 2].mean(), poly, col)) + for _, poly, col in sorted(faces, key=lambda x: -x[0]): + d.polygon(poly, fill=col) + if label: + d.rectangle([8, 8, 8 + 9 * len(label), 30], fill=(255, 255, 255)) + d.text((14, 14), label, fill=(20, 20, 20)) + img.save(path) + + +if __name__ == "__main__": + tris = load_stl(sys.argv[1]) + print("triangles:", len(tris)) + views = [((26, -22, 20), "iso"), ((0, 0, 34), "straight down +Z"), + ((4, -30, 9), "grazing"), ((-28, -10, 12), "from the tall end")] + for i, (eye, lab) in enumerate(views): + render(tris, eye, (0, 0, 0), f"fem-{i}.png", label=f"FEMALE POCKET — {lab}") + print(f"fem-{i}.png") diff --git a/docs/CAD/design/mate-connectors/rk-0.png b/docs/CAD/design/mate-connectors/rk-0.png new file mode 100644 index 0000000000..1d7208238a Binary files /dev/null and b/docs/CAD/design/mate-connectors/rk-0.png differ diff --git a/docs/CAD/design/mate-connectors/rk-1.png b/docs/CAD/design/mate-connectors/rk-1.png new file mode 100644 index 0000000000..282bd0a2fe Binary files /dev/null and b/docs/CAD/design/mate-connectors/rk-1.png differ diff --git a/docs/CAD/design/mate-connectors/rk-2.png b/docs/CAD/design/mate-connectors/rk-2.png new file mode 100644 index 0000000000..75d83e1402 Binary files /dev/null and b/docs/CAD/design/mate-connectors/rk-2.png differ diff --git a/docs/CAD/design/mate-connectors/rk-3.png b/docs/CAD/design/mate-connectors/rk-3.png new file mode 100644 index 0000000000..eccdf94807 Binary files /dev/null and b/docs/CAD/design/mate-connectors/rk-3.png differ diff --git a/docs/CAD/design/mate-connectors/rk-4.png b/docs/CAD/design/mate-connectors/rk-4.png new file mode 100644 index 0000000000..794fd4bd97 Binary files /dev/null and b/docs/CAD/design/mate-connectors/rk-4.png differ diff --git a/docs/CAD/design/mate-connectors/rk-back.png b/docs/CAD/design/mate-connectors/rk-back.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/CAD/design/mate-connectors/rk-front.png b/docs/CAD/design/mate-connectors/rk-front.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/CAD/design/mate-connectors/rk-iso.png b/docs/CAD/design/mate-connectors/rk-iso.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/CAD/design/mate-connectors/rk-sheet.png b/docs/CAD/design/mate-connectors/rk-sheet.png new file mode 100644 index 0000000000..e785a02839 Binary files /dev/null and b/docs/CAD/design/mate-connectors/rk-sheet.png differ diff --git a/docs/CAD/design/mate-connectors/simplify-sheet.png b/docs/CAD/design/mate-connectors/simplify-sheet.png new file mode 100644 index 0000000000..9c05180c59 Binary files /dev/null and b/docs/CAD/design/mate-connectors/simplify-sheet.png differ diff --git a/docs/CAD/design/mate-connectors/simplify_study.py b/docs/CAD/design/mate-connectors/simplify_study.py new file mode 100644 index 0000000000..c0bbcf63ab --- /dev/null +++ b/docs/CAD/design/mate-connectors/simplify_study.py @@ -0,0 +1,142 @@ +"""Reduce the bear face to the fewest marks that still read at glyph size — wi3z. + +Geometry comes from bear_outline.json, which extract_outline.py pulled off the supplied male +B-rep's back plate: the outer wire IS the silhouette, the inner wires are the two eyes and the +muzzle opening. Nothing here is traced by eye. + +The glyph is drawn IN the connector's plane, so a grazing view foreshortens it along one axis by +sin(elevation) — exactly what collapsed the disc's roll quadrant to 3 pixels at 10 deg. Every +candidate is therefore rendered at three elevations as well as three pixel sizes. +""" +import json, math, os +from PIL import Image, ImageDraw + +HERE = os.path.dirname(os.path.abspath(__file__)) +D = json.load(open(os.path.join(HERE, "bear_outline.json"))) + +def norm(pts): + """Part frame (X right, Z down-negative) -> glyph frame (x right, y up), centred, unit height.""" + p = [(x, -z) for x, z in pts] + return p + +outer = norm(D["outer"]) +holes = [norm(h["pts"]) for h in D["holes"]] +# the two Ø9.8 wires are the eyes; the wide one is the muzzle +eyes = [h for h, meta in zip(holes, D["holes"]) if meta["d"] < 20] +muzzle = [h for h, meta in zip(holes, D["holes"]) if meta["d"] >= 20] + +ALL = outer + [p for h in holes for p in h] +xs = [p[0] for p in ALL]; ys = [p[1] for p in ALL] +CX, CY = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2 +SPAN = max(max(xs)-min(xs), max(ys)-min(ys)) +def to_unit(pts): return [((x-CX)/SPAN, (y-CY)/SPAN) for x, y in pts] + +def rdp(pts, eps): + """Douglas-Peucker. Vertex count is the honest measure of 'how simplified'.""" + if len(pts) < 3: return pts + ax, ay = pts[0]; bx, by = pts[-1] + dx, dy = bx-ax, by-ay + n = math.hypot(dx, dy) + best, bi = -1.0, 0 + for i in range(1, len(pts)-1): + px, py = pts[i] + d = abs(dx*(ay-py) - (ax-px)*dy)/n if n > 1e-12 else math.hypot(px-ax, py-ay) + if d > best: best, bi = d, i + if best <= eps: + return [pts[0], pts[-1]] + return rdp(pts[:bi+1], eps)[:-1] + rdp(pts[bi:], eps) + +def simp_closed(pts, eps): + r = rdp(pts + [pts[0]], eps) + return r[:-1] + +def centroid(pts): + return (sum(p[0] for p in pts)/len(pts), sum(p[1] for p in pts)/len(pts)) + +U_OUT = to_unit(outer) +U_EYE = [to_unit(e) for e in eyes] +U_MUZ = [to_unit(m) for m in muzzle] + +def eye_dots(scale=1.0): + out = [] + for e in U_EYE: + cx, cy = centroid(e) + r = max(max(p[0] for p in e)-min(p[0] for p in e), + max(p[1] for p in e)-min(p[1] for p in e))/2*scale + out.append((cx, cy, r)) + return out + +def muzzle_tri(): + """The muzzle reduced to one filled triangle: its two lower corners and its apex.""" + m = U_MUZ[0] + lo = min(p[1] for p in m); hi = max(p[1] for p in m) + bottom = [p for p in m if p[1] < lo + 0.06*(hi-lo)] + apex = max(m, key=lambda p: p[1]) + return [min(bottom), max(bottom), apex] + +CANDIDATES = { + "C0 full": dict(out=U_OUT, eyes=eye_dots(), muz=U_MUZ[0]), + "C1 eps .004": dict(out=simp_closed(U_OUT, .004), eyes=eye_dots(), muz=simp_closed(U_MUZ[0], .004)), + "C2 eps .012": dict(out=simp_closed(U_OUT, .012), eyes=eye_dots(), muz=muzzle_tri()), + "C3 eps .030": dict(out=simp_closed(U_OUT, .030), eyes=eye_dots(1.15), muz=muzzle_tri()), + "C4 no eyes": dict(out=simp_closed(U_OUT, .012), eyes=[], muz=muzzle_tri()), +} + +def sym_report(pts, tol=0.02): + """Trivial symmetry group is the property doing the work. If a simplification restores a + mirror or a 180 deg rotation, that simplification is wrong.""" + def match(tf): + t = [tf(p) for p in pts] + hit = 0 + for q in t: + if min(math.hypot(q[0]-p[0], q[1]-p[1]) for p in pts) <= tol: hit += 1 + return hit, len(pts) + return { + "mirror-x": match(lambda p: (-p[0], p[1])), + "mirror-y": match(lambda p: ( p[0], -p[1])), + "rot-180": match(lambda p: (-p[0], -p[1])), + } + +def render(c, px, elev_deg, supersample=8): + S = px*supersample + img = Image.new("L", (S, S), 0) + d = ImageDraw.Draw(img) + k = math.sin(math.radians(elev_deg)) + def m(p): + return (S/2 + p[0]*S*0.92, S/2 - p[1]*S*0.92*k) + d.polygon([m(p) for p in c["out"]], fill=255) + if c["muz"]: d.polygon([m(p) for p in c["muz"]], fill=0) + for cx, cy, r in c["eyes"]: + a = m((cx-r, cy+r)); b = m((cx+r, cy-r)) + d.ellipse([a[0], a[1], b[0], b[1]], fill=0) + return img.resize((px, px), Image.LANCZOS) + +print(f"{'candidate':14} {'verts':>6} {'marks':>6} symmetry (matched/total, lower is better)") +print("-"*78) +for name, c in CANDIDATES.items(): + s = sym_report(c["out"]) + marks = 1 + (1 if c["muz"] else 0) + len(c["eyes"]) + sym = " ".join(f"{k} {v[0]}/{v[1]}" for k, v in s.items()) + print(f"{name:14} {len(c['out']):6} {marks:6} {sym}") + +SIZES = [22, 32, 48] +ELEVS = [(90, "flat on"), (47, "47 deg"), (16, "16 deg"), (6, "6 deg")] +pad, cell = 8, 56 +W = pad + len(SIZES)*len(ELEVS)*cell + pad +H = pad + len(CANDIDATES)*cell + pad +sheet = Image.new("RGB", (W, H), (24, 27, 32)) +for r, (name, c) in enumerate(CANDIDATES.items()): + for ci, (elev, _) in enumerate(ELEVS): + for si, px in enumerate(SIZES): + g = render(c, px, elev) + tile = Image.new("RGB", (px, px), (24, 27, 32)) + gold = Image.new("RGB", (px, px), (237, 168, 23)) + tile.paste(gold, (0, 0), g) + x = pad + (ci*len(SIZES)+si)*cell + (cell-px)//2 + y = pad + r*cell + (cell-px)//2 + sheet.paste(tile, (x, y)) +sheet = sheet.resize((W*2, H*2), Image.NEAREST) +sheet.save(os.path.join(HERE, "simplify-sheet.png")) +print("\ncolumns: " + " | ".join(f"{e[1]} @ 22/32/48px" for e in ELEVS)) +print("rows: " + ", ".join(CANDIDATES)) +print("WROTE simplify-sheet.png") diff --git a/docs/CAD/design/mate-connectors/size-test.png b/docs/CAD/design/mate-connectors/size-test.png new file mode 100644 index 0000000000..941f76ffb8 Binary files /dev/null and b/docs/CAD/design/mate-connectors/size-test.png differ diff --git a/docs/CAD/design/mate-connectors/sz-22-down+Z.png b/docs/CAD/design/mate-connectors/sz-22-down+Z.png new file mode 100644 index 0000000000..85cbf9ba4e Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-22-down+Z.png differ diff --git a/docs/CAD/design/mate-connectors/sz-22-grazing.png b/docs/CAD/design/mate-connectors/sz-22-grazing.png new file mode 100644 index 0000000000..047f2c6e06 Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-22-grazing.png differ diff --git a/docs/CAD/design/mate-connectors/sz-22-iso.png b/docs/CAD/design/mate-connectors/sz-22-iso.png new file mode 100644 index 0000000000..55b8a13eed Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-22-iso.png differ diff --git a/docs/CAD/design/mate-connectors/sz-32-down+Z.png b/docs/CAD/design/mate-connectors/sz-32-down+Z.png new file mode 100644 index 0000000000..683fc3d0aa Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-32-down+Z.png differ diff --git a/docs/CAD/design/mate-connectors/sz-32-grazing.png b/docs/CAD/design/mate-connectors/sz-32-grazing.png new file mode 100644 index 0000000000..92e17ca09a Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-32-grazing.png differ diff --git a/docs/CAD/design/mate-connectors/sz-32-iso.png b/docs/CAD/design/mate-connectors/sz-32-iso.png new file mode 100644 index 0000000000..1fe0ccb091 Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-32-iso.png differ diff --git a/docs/CAD/design/mate-connectors/sz-48-down+Z.png b/docs/CAD/design/mate-connectors/sz-48-down+Z.png new file mode 100644 index 0000000000..ec451714ff Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-48-down+Z.png differ diff --git a/docs/CAD/design/mate-connectors/sz-48-grazing.png b/docs/CAD/design/mate-connectors/sz-48-grazing.png new file mode 100644 index 0000000000..524c8e5343 Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-48-grazing.png differ diff --git a/docs/CAD/design/mate-connectors/sz-48-iso.png b/docs/CAD/design/mate-connectors/sz-48-iso.png new file mode 100644 index 0000000000..e0ed208b64 Binary files /dev/null and b/docs/CAD/design/mate-connectors/sz-48-iso.png differ diff --git a/docs/CAD/design/mate-connectors/t-+0.1-0.png b/docs/CAD/design/mate-connectors/t-+0.1-0.png new file mode 100644 index 0000000000..b0c09ef253 Binary files /dev/null and b/docs/CAD/design/mate-connectors/t-+0.1-0.png differ diff --git a/docs/CAD/design/mate-connectors/t-+0.1-1.png b/docs/CAD/design/mate-connectors/t-+0.1-1.png new file mode 100644 index 0000000000..782cd16c0d Binary files /dev/null and b/docs/CAD/design/mate-connectors/t-+0.1-1.png differ diff --git a/docs/CAD/design/mate-connectors/t-+1.6-0.png b/docs/CAD/design/mate-connectors/t-+1.6-0.png new file mode 100644 index 0000000000..f3b37f6697 Binary files /dev/null and b/docs/CAD/design/mate-connectors/t-+1.6-0.png differ diff --git a/docs/CAD/design/mate-connectors/t-+1.6-1.png b/docs/CAD/design/mate-connectors/t-+1.6-1.png new file mode 100644 index 0000000000..731e092769 Binary files /dev/null and b/docs/CAD/design/mate-connectors/t-+1.6-1.png differ diff --git a/docs/CAD/design/mate-connectors/trim-cmp.png b/docs/CAD/design/mate-connectors/trim-cmp.png new file mode 100644 index 0000000000..8dcc0c973f Binary files /dev/null and b/docs/CAD/design/mate-connectors/trim-cmp.png differ diff --git a/docs/CAD/design/mate-connectors/trim_female.py b/docs/CAD/design/mate-connectors/trim_female.py new file mode 100644 index 0000000000..2753e4a3a8 --- /dev/null +++ b/docs/CAD/design/mate-connectors/trim_female.py @@ -0,0 +1,74 @@ +# Trim the boxy frame off the female so its outer shape is the bear face itself. +# +# Method: take the male's flat back face (the plane Y=0 -- that face IS the bear silhouette), +# offset its OUTER wire outward in 2D, extrude the result along the insertion axis, and keep only +# the part of the female inside it. Everything outside is the block frame and goes away. +# +# NOTE ON THE NUMBER. The pocket's side walls stand at +0.20 mm from the male outline, because that +# is the clearance. A trim boundary at +0.10 mm therefore falls INSIDE them by 0.10 mm and removes +# the side wall entirely rather than leaving a thin one. The script runs the requested value and +# then measures what is actually left, so the outcome is a number rather than an opinion; it also +# emits a second variant at an offset that leaves a printable wall, for comparison. +# +# Run: /snap/bin/freecad.cmd trim_female.py + +import os, sys +import FreeCAD as App +import Part +from FreeCAD import Vector + +HERE = os.path.dirname(os.path.abspath(__file__)) +MALE = os.path.join(HERE, "bear.step") +FEMALE = os.path.join(HERE, "BearConnector_Female.step") + +REQUESTED = 0.10 # as asked +CLEARANCE = 0.20 # what the pocket was built with +SAFE_WALL = 1.60 # a wall that survives an FDM nozzle: clearance + ~1.4 mm + +male = Part.Shape(); male.read(MALE); male = male.Solids[0] +fem = Part.Shape(); fem.read(FEMALE); fem = fem.Solids[0] +print(f"female in : {fem.Volume/1000:.2f} cm3, {len(fem.Faces)} faces") + +# --- the bear silhouette: the male's flat back face at Y = 0 +back = None +for f in male.Faces: + n = f.normalAt(0, 0) + if abs(f.CenterOfMass.y) < 1e-6 and abs(abs(n.y) - 1.0) < 1e-6: + if back is None or f.Area > back.Area: + back = f +if back is None: + print("FAIL: could not find the flat back face at Y=0"); sys.exit(1) +print(f"silhouette: back face area {back.Area:.1f} mm2, {len(back.Wires)} wires " + f"(outer + {len(back.Wires)-1} holes: eyes and mouth)") + +fb = fem.BoundBox +y0, y1 = fb.YMin - 5.0, fb.YMax + 5.0 + +def trimmed(offset): + """keep only the part of the female inside the silhouette grown by `offset`""" + wire = back.OuterWire + grown = wire.makeOffset2D(offset, join=2, fill=False, openResult=False, intersection=True) + face = Part.Face(Part.Wire(grown.Edges)) + prism = face.extrude(Vector(0, y1 - y0, 0)) + prism.translate(Vector(0, y0 - face.CenterOfMass.y, 0)) + return fem.common(prism) + +for tag, off, out in (("requested", REQUESTED, "BearConnector_Female_Trimmed.step"), + ("safe wall", SAFE_WALL, "BearConnector_Female_Trimmed_wall.step")): + r = trimmed(off) + if not r.Solids: + print(f"\n{tag} (+{off:.2f} mm): NOTHING LEFT"); continue + wall = off - CLEARANCE + # is there any material left at the level of the pocket's side wall? + sec = r.section(Part.makePlane(400, 400, Vector(-200, 1.5, -200), Vector(0, 1, 0))) + perim = sum(e.Length for e in sec.Edges) + print(f"\n{tag} (+{off:.2f} mm) wall = {wall:+.2f} mm") + print(f" volume {r.Volume/1000:.2f} cm3, {len(r.Solids)} solid(s), {len(r.Faces)} faces") + print(f" section through the pocket wall at Y=1.5: {perim:.1f} mm of edge") + if wall <= 0: + print(f" -> the trim cuts {abs(wall):.2f} mm INSIDE the pocket wall: no side wall remains") + doc = App.newDocument(tag.replace(" ", "_")) + o = doc.addObject("Part::Feature", "Female") + o.Shape = r; doc.recompute() + Part.export([o], os.path.join(HERE, out)) + print(f" wrote {out}") diff --git a/docs/CAD/design/mate-connectors/verify_trimmed.py b/docs/CAD/design/mate-connectors/verify_trimmed.py new file mode 100644 index 0000000000..81f6f3fea1 --- /dev/null +++ b/docs/CAD/design/mate-connectors/verify_trimmed.py @@ -0,0 +1,25 @@ +# Check both trimmed females still fit the male, and export STLs for a visual comparison. +# Run: /snap/bin/freecad.cmd verify_trimmed.py +import os +import Mesh, Part + +HERE = os.path.dirname(os.path.abspath(__file__)) +male = Part.Shape(); male.read(os.path.join(HERE, "bear.step")); male = male.Solids[0] + +for name in ("BearConnector_Female_Trimmed", "BearConnector_Female_Trimmed_wall"): + p = os.path.join(HERE, name + ".step") + s = Part.Shape(); s.read(p); s = s.Solids[0] + d = male.distToShape(s)[0] + c = male.common(s) + cv = c.Volume if c.Solids else 0.0 + bb = s.BoundBox + print(f"{name}") + print(f" {bb.XLength:.2f} x {bb.YLength:.2f} x {bb.ZLength:.2f} mm, {s.Volume/1000:.2f} cm3, " + f"{len(s.Faces)} faces, valid={s.isValid()}") + print(f" gap to male {d:.4f} mm, interference {cv:.6f} mm3") + m = Mesh.Mesh(); m.addFacets([tuple(t) for t in s.tessellate(0.12)[1]] and + [(s.tessellate(0.12)[0][a], s.tessellate(0.12)[0][b], + s.tessellate(0.12)[0][c2]) + for a, b, c2 in s.tessellate(0.12)[1]]) + m.write(os.path.join(HERE, name + ".stl")) + print(f" wrote {name}.stl ({m.CountFacets} facets)") diff --git a/docs/CAD/design_tab.md b/docs/CAD/design_tab.md new file mode 100644 index 0000000000..1be0a2ea33 --- /dev/null +++ b/docs/CAD/design_tab.md @@ -0,0 +1,333 @@ +# The Design tab + +Object-driven parametric CAD inside the slicer. Point at geometry; the geometry offers the +verbs that apply to it. Selection comes first and the tool consumes it. Draw a sketch, +constrain it, turn it into a solid, refine it, and send it straight to Prepare — without +leaving for another application and coming back through an STL. + +The model is a **recipe**, not a mesh. Every action becomes a feature in a tree that is +replayed from the start whenever anything changes, so editing a dimension you set twenty +steps ago rebuilds everything downstream. The geometry kernel is OCCT, which the slicer +already ships for STEP import. + +--- + +## Getting started + +1. Open the **Design** tab. +2. Click a face or a reference plane in the viewport, then press `Shift+S` (Sketch). The offer + opens with the sketch tools on it. +3. Draw a closed profile, then press **✓ Confirm** in the floating action bar. +4. With the sketch selected, press `Shift+E` (Extrude). +5. Press **Commit to Plate** to hand the solid to Prepare. + +The status line under the toolbar is the thing to watch: it says what the current tool is +waiting for. When no plane is picked it reads *"Click a face or a reference plane in the +viewport, then a sketch tool"*; once one is picked it reads *"Sketching on — pick a +tool"*. It is also where a refusal explains itself. + +--- + +## Selecting + +- One left-click selects what is under the cursor. There is no click-cycling through + face → edge → body. +- A click near a corner takes the corner, not the face behind it. +- Left-drag sweeps a rubber band, and a rubber band takes the whole body. +- An open sketch line can be clicked, even where it bounds a region. +- Double-click a sketch stroke to edit it — the gesture belongs on the geometry. +- Editing a dimension's value **updates** that dimension instead of adding a second one next + to it. +- The floating chrome that belongs to a sketch leaves with the sketch when it ends. +- Sketching happens on the face you clicked, first click. +- A sketch whose entities form no wire **fails** instead of extruding a default box. A + subtraction that removes nothing is reported as an error instead of a silent success. + +--- + +## The offer + +Right-click on the geometry, released without moving the mouse (an 8 px budget — a +right-drag that orbits the camera does not open it). Left-click still only selects, so +pointing at things stays quiet. + +The offer also opens by itself the moment you press Sketch on a face or plane, showing the +sketch tools — the app hands you the tools directly. + +**Eight families, always in this fixed order:** Create, Add material, Remove, Dress-up, +Repeat, Transform, Reference, Modify. + +- A family with at least one applicable verb shows it. Several applicable verbs collapse + into a submenu under the family name. +- A family with nothing applicable is **shown greyed in place, with the reason** — e.g. + *"Create — Click a face or a reference plane in the viewport, then a sketch tool"*. It is + not hidden. A control that cannot be used still says what it is and what you would have to + do first. +- Inside a sketch the offer shows the sketch verbs; outside it shows the feature verbs. + +**Document-level actions never enter the offer**, because they act on the document and not +on a selection: Import STEP, Import mesh, Text, SVG, Export STEP, Commit to Plate, Undo, +Redo, Variables, Section view, Origin planes, World axes. They live in the toolbar. + +--- + +## Keyboard + +Single letters drive sketch tools **while a sketch is open**; Shift+letter drives feature +tools and single letters drive view toggles **when no sketch is open**. The two maps are +selected by the mode, not by whether a sketch session is running. + +### Sketch (while a sketch is open) + +| Key | Tool | +|---|---| +| `L` | Line — click start, then end | +| `R` | Rectangle — click two opposite corners | +| `C` | Circle — click centre, then radius | +| `A` | Arc — click start, end, then a point | +| `S` | Slot — two centreline ends, then width | +| `E` | Ellipse — centre, major end, minor point | +| `B` | Spline — click control points | +| `P` | Point — click to place | +| `G` | Polygon — click centre, then a vertex | +| `D` | Dimension — click 2 points or an entity | +| `T` | Trim — click a segment to trim it | +| `X` | Extend — click a line/arc to extend it | +| `O` | Offset — pick an entity, drag the distance | +| `M` | Mirror — pick axis, then entities | +| `F` | Fillet — pick two lines, set the radius | +| `H` | Chamfer — pick two lines, set the distance | +| `K` | Constrain — finish the live sketch and enter constrain | +| `Q` | Construction toggle — draw the next entity as construction geometry | +| `Del` | Delete the selected sketch entity | +| `Esc` | Cancel the live tool | + +### Feature (when no sketch is open) + +| Key | Tool | +|---|---| +| `Shift+S` | Sketch | +| `Shift+E` | Extrude — extrude a profile, or push/pull a picked face | +| `Shift+R` | Revolve | +| `Shift+W` | Sweep | +| `Shift+L` | Loft | +| `Shift+N` | Pattern | +| `Shift+G` | Surface Extrude | +| `Shift+J` | Surface Revolve | +| `Shift+O` | Surface Loft | +| `Shift+Q` | Surface Fill | +| `Shift+U` | Surface Offset | +| `Shift+V` | Thicken Surface | +| `Shift+P` | Plane | +| `Shift+A` | Axis | +| `Shift+C` | Coord Sys | +| `Shift+Y` | Transform | +| `Shift+Z` | Mirror | +| `Shift+B` | Boolean | +| `Shift+X` | Cut | +| `Shift+F` | Fillet / Chamfer | +| `Shift+D` | Draft | +| `Shift+K` | Shell | +| `Shift+H` | Hole | +| `Shift+T` | Thread | +| `Shift+I` | Import STEP | +| `Shift+M` | Import mesh | + +### View toggles (single letters, when no sketch is open) + +| Key | Action | +|---|---| +| `Home` | Axonometric view, fitted to the model | +| `P` | Origin planes on/off | +| `A` | World axes on/off | +| `X` | Section view on/off | + +While the section is on: `PageUp` / `PageDown` move the cut plane, `F` flips which half is +kept. With no section on, `F` is Place on Face — lay the picked face flat on the bed. + +--- + +## Sketching + +A sketch is a closed (or open) 2D profile on a plane or on a flat face of an existing body. +Press `Shift+S`, click the face or plane you want to sketch on, and draw. The toolbar and +the offer both carry the sketch tools. + +**Entities:** line, polyline, rectangle (corner / centre / oblique / rounded), circle +(centre-radius / 2-point / 3-point), arc (centre-point / 3-point / tangent), ellipse and +elliptical arc, polygon (inscribed / circumscribed), slot (straight / arc), spline, point, +and text. + +**Editing:** move, rotate, scale, trim, extend, offset, mirror, and linear or polar arrays. + +**Constraints:** coincident, horizontal, vertical, parallel, perpendicular, tangent, equal, +concentric, midpoint, symmetric, fix, plus dimensional radius, diameter, distance and angle. +The solver reports the remaining degrees of freedom and tells you when a sketch is fully +constrained — or when a constraint conflicts with one already there. + +Sketches stay editable. Selecting one in the feature tree reopens it with its dimensions +live. + +--- + +## Building solids + +Grouped in the toolbar by what they do, one concept per drawer. + +### Add material +| Tool | Shortcut | What it does | +|---|---|---| +| Extrude | `Shift+E` | Extrude a profile, or push/pull a face already on a body | +| Revolve | `Shift+R` | Revolve a profile about an axis | +| Sweep | `Shift+W` | Sweep a profile along a path — including a helix, for springs and augers | +| Loft | `Shift+L` | Skin between two or more profiles | +| Thicken | — | Offset a solid face into a thin plate as a new body | +| Rib | — | Grow a stiffening wall from an open sketch line, fused to a body | + +Extrude offers blind, symmetric, two-sided, through-all and up-to-face end conditions, plus +a draft angle on the side wall, and can add, subtract, intersect or start a new body. + +### Surface +Sheet bodies — surfaces with no thickness — for shapes that are easier to build as skins and +solidify afterwards. + +| Tool | Shortcut | +|---|---| +| Surface Extrude | `Shift+G` | +| Surface Revolve | `Shift+J` | +| Surface Loft | `Shift+O` | +| Surface Fill | `Shift+Q` | +| Surface Offset | `Shift+U` | +| Thicken Surface | `Shift+V` | + +Thicken Surface is how a sheet becomes a printable solid. + +### Dress-up +| Tool | Shortcut | +|---|---| +| Fillet / Chamfer | `Shift+F` | +| Draft (taper a face) | `Shift+D` | +| Shell | `Shift+K` | +| Delete Face | — | + +Delete Face removes faces and heals the solid — useful for stripping a feature off an +imported part. + +### Holes +**Hole** (`Shift+H`) drills simple, counterbored or countersunk holes, with an ISO/ANSI +standards table so you can ask for an M6 clearance hole instead of computing a diameter. +**Thread** (`Shift+T`) cuts a real helical thread into a bore or onto a shaft. + +### Placement +Operations that move a body without changing its shape: **Transform** (`Shift+Y`), +**Mirror** (`Shift+Z`), and **Mate** for assemblies. + +### Combining +**Boolean** (`Shift+B`) unions, subtracts or intersects two bodies. **Cut** (`Shift+X`) +splits a body with a plane. **Pattern** (`Shift+N`) repeats a body linearly, in a circle, or +along a curve. + +--- + +## Reference geometry + +Datum features carry no material; they exist to give later features something to attach to. + +- **Plane** (`Shift+P`) — offset, tilted, midplane, tangent, through two edges, or coincident +- **Axis** (`Shift+A`) — two points, a face normal, a cylinder centreline, the intersection of + two planes, or along an edge +- **Coord Sys** (`Shift+C`) — a full frame, from a world point or from a face plus a + direction edge +- **Helix** — a helical curve to sweep along +- **Project** — project a body's edges onto a plane as sketch geometry + +On the Coord Sys tool, picking a direction **edge** is worth the extra click: without one the +frame takes its X from the face's first edge, which is deterministic but not necessarily the +direction you meant. + +--- + +## Assemblies + +**Mate** aligns two coordinate systems and moves one body onto the other. Five kinds: + +| Kind | Leaves free | +|---|---| +| Fastened | nothing — 6 DOF locked | +| Planar | sliding in the plane | +| Revolute | rotation about the axis | +| Slider | sliding along the axis | +| Cylindrical | rotation *and* sliding | + +**Check interference** reports every overlapping pair of solids with the overlapping volume, +so a clash is a number rather than an impression. Bodies that merely touch enclose no volume +and are not reported. + +--- + +## Variables and expressions + +Define named variables and drive dimensions from them. Any numeric field accepts an +expression — `width/2`, `wall*3` — and everything re-evaluates on recompute. Change one +variable and the whole model follows. + +--- + +## Import and export + +**Import STEP** brings in a real B-rep solid, not a mesh: its faces and edges can be filleted, +shelled and cut like anything modelled here. + +**Import mesh** (STL/OBJ) converts triangles to a B-rep body and tells you honestly what it +got — whether the result is a closed solid or an open shell, with the boundary and +non-manifold edge counts. A large mesh becomes a large number of faces, which is slow to +edit; the importer warns before you commit to it. + +**Export STEP** writes the model out for another CAD tool. + +**Commit to Plate** sends the solid to Prepare for slicing. The whole feature recipe is saved +inside the 3MF, so reopening the project restores the editable model rather than a frozen +mesh. + +--- + +## View controls + +**Section view** (`X`) hides half the model so you can see inside — `PageUp`/`PageDown` move +the plane, `F` flips which half is kept. **Place on Face** (`F`, when section is off) lays a +picked face flat on the bed. Origin planes (`P`) and world axes (`A`) can be toggled on while +you orient yourself. + +--- + +## Known limitations + +Being straight about the edges, so nobody discovers them the hard way: + +- **Rib** needs a sketch containing an explicit open line. A parametric rectangle sketch + carries no individual entities, so Rib cannot use one. +- **Surface Loft** and **Surface Fill** have kernel tests but have not been exercised by hand. +- Card wiring for 9 of the 16 late-wired tools has never been click-tested. +- Mate resolves by composing transforms directly. There is no 3D assembly solver, so mates + are applied in order rather than solved simultaneously, and mate limits are not implemented. +- Move-face and replace-face are not implemented — OCCT offers no clean primitive for them. +- There is no automated GUI test in CI. Every behaviour above is traced to code and to a + hand pass, not to a synthetic click. + +--- + +## Where the code lives + +| Path | Role | +|---|---| +| `src/libslic3r/CAD/CadDocument.*` | the feature recipe and its replay | +| `src/libslic3r/CAD/GeometryEngine.*` | OCCT wrapper — faces, edges, booleans, healing | +| `src/libslic3r/CAD/SketchEngine.*` | profile → wire → solid | +| `src/libslic3r/CAD/SketchSolver.*` | constraint solving, over the vendored solver | +| `src/libslic3r/slvs/` | vendored 2D constraint solver (GPLv3) | +| `src/slic3r/GUI/CAD/DesignPanel.*` | the tab: toolbar, cards, feature tree | +| `src/slic3r/GUI/CAD/DesignCanvas.*` | viewport integration | +| `src/slic3r/GUI/CAD/DesignSketchTool.*` | in-canvas sketching | + +Build with `-DSLIC3R_CAD=ON` (the default). With it OFF the tab is not compiled and the deps +prefix matches upstream exactly — see [cad_dependency_weight.md](cad_dependency_weight.md). diff --git a/docs/CAD/design_tab_pr_description.md b/docs/CAD/design_tab_pr_description.md new file mode 100644 index 0000000000..a6d67f0e7c --- /dev/null +++ b/docs/CAD/design_tab_pr_description.md @@ -0,0 +1,73 @@ +# Design (CAD) tab — upstream pull request + +## What this adds + +A sketch-first parametric CAD tab inside the slicer. The workflow is direct: +sketch → constrain → solid features → commit to plate. The whole feature recipe is +persisted inside the 3MF, so reopening restores an editable model rather than a frozen mesh. + +- Kernel: OCCT, which upstream already links for STEP import — see + [cad_dependency_weight.md](docs/cad_dependency_weight.md) +- Constraint solver: vendored SolveSpace `libslvs` subset +- Interaction model: object-driven — point at geometry, the geometry offers the verbs that + apply to it; see [cad_ux_guidelines.md](docs/cad_ux_guidelines.md) +- Full user-facing documentation: [design_tab.md](docs/design_tab.md) + +## Why it belongs in the slicer + +Every round trip through an external CAD tool costs a file export, a re-import, and the +design intent that both steps discard. A part modified after slicing should return to its +feature history, not to a mesh. Keeping the CAD model inside the slicer preserves that +loop — the nozzle diameter, the build volume and the material are known at design time. + +For the integration case in full: [design_tab_upstream_portability.md](docs/design_tab_upstream_portability.md). + +## How it is built + +The `SLIC3R_CAD` CMake flag (default ON) gates the entire tab. With it OFF the tab is not +compiled and the deps prefix matches upstream exactly — the dependency diff is one line in +OCCT's CMake: `BUILD_MODULE_ModelingAlgorithms=OFF → ON`. + +Measured cost table: [cad_dependency_weight.md](docs/cad_dependency_weight.md). + +## Diff shape + + + +Against merge-base `d6cb667b894f`: + + 306 files changed, 83032 insertions(+), 777 deletions(-) + +350 commits, of which 284 are new files and 37 modify upstream files. 99.3 % of the diff +is new code. The negotiable surface is the 37 modified files. + +## Tests + +205 `TEST_CASE` blocks across 6 new test source files. This counts assertions written, not +assertions passed — a run needs a build. + +`scripts/CAD/run-kernel-tests.sh` is the headless verification contract: it builds only +`libslic3r_tests` (not the GUI app), needs no display, and exit 0 means the CAD suite +passed. It now runs with **no exclusions** — both cases that used to be quarantined (the +circle-line tangency solver abort and the internal-thread reference) are fixed. + +## Licensing + +The vendored solver in `src/libslic3r/slvs/` is **GPL-3.0** (see `src/libslic3r/slvs/LICENSE`), +not LGPL. The combined work is distributable under AGPL-3.0. See the Licensing section of +[design_tab_upstream_portability.md](docs/design_tab_upstream_portability.md) for the +AGPLv3/GPLv3 compatibility argument; this point should be confirmed with upstream explicitly. + +## Not verified + +- Card wiring for 9 of the 16 late-wired tools was never click-tested. +- There is no automated GUI test in CI. A green kernel run says nothing about the GUI — + synthetic clicks never drift, so the test suite and the viewport are two separate realities. +- The click-test defect rate has **not converged**: a second pass found no new defects, but + four further days of work found five more. The earlier pass is not evidence of stability. + +## Reviewer's map + +See the [Where the code lives](docs/design_tab.md#where-the-code-lives) table in the user +doc for the file-to-role mapping, and [docs/ux/tool_atlas.json](docs/ux/tool_atlas.json) as +the generated-from source of `src/slic3r/GUI/CAD/DesignOffer.hpp`. diff --git a/docs/CAD/design_tab_upstream_portability.md b/docs/CAD/design_tab_upstream_portability.md new file mode 100644 index 0000000000..a7e9cd84ed --- /dev/null +++ b/docs/CAD/design_tab_upstream_portability.md @@ -0,0 +1,162 @@ +# Design (CAD) tab — upstream integration brief + +**Question:** can the Design tab (sketch-first parametric CAD: sketch → constrain → +extrude/revolve/fillet/hole/thread/shell, multi-body, undo, 3MF persistence) land in +mainline OrcaSlicer? + +**Answer: yes, and the ask is far smaller than previously believed.** OCCT is *already* +an OrcaSlicer dependency. We are not asking upstream to adopt a new library; we are +asking it to widen one it already builds, at a measured cost of **3.77 MiB on Windows**. + +> ### Corrections to the 2026-06-21 assessment +> That revision was written before the persistence work landed and got two load-bearing +> facts wrong. Both are corrected here from direct measurement of the branch: +> +> 1. **"The real blocker: OCCT … a dependency mainline OrcaSlicer has never carried."** +> **False.** `deps/OCCT/` exists at the merge-base and upstream links it from +> `Format/STEP.cpp`, `Format/svg.cpp`, and `Shape/TextShape.cpp`. Our entire +> dependency diff is **one line**: `BUILD_MODULE_ModelingAlgorithms=OFF → ON`. +> 2. **"vendored SolveSpace solver … LGPL."** **False.** `src/libslic3r/slvs/LICENSE` is +> **GPL-3.0**, not LGPL. This is fine (see Licensing) but must not be misstated. +> +> It also claimed "no changes to Model" — no longer true; 3MF recipe persistence adds one +> `std::string` to `Model`. + +## Measured shape of the change + +Against merge-base `449a4cf9fc` (34 commits ahead): + +| | files | lines | +|---|---:|---:| +| **New files** | 138 | +61,720 | +| **Modified upstream files** | 23 | +457 / −75 | +| **Deleted upstream files** | 0 | — | + +The 62 kLOC headline is inflated by localization. The feature itself: + +| area | LOC | files | +|---|---:|---:| +| kernel (`src/libslic3r/`) | 15,828 | 37 | +| GUI (`src/slic3r/`) | 19,544 | 14 | +| tests (Catch2) | 2,567 | 6 | +| i18n (unrelated; strip from the CAD PR) | 23,438 | 77 | + +**99.3 % of the diff is new files.** The negotiable surface is 457 added lines across 23 +files, and nothing upstream is deleted. The largest single hook is `GLCanvas3D.cpp` +(+110/−2): an `m_design_sketch_tool` member plus render/mouse/key hooks, **every one +already null-guarded** — which is why the compile-time gate below is cheap. + +No changes to the slicing pipeline (Print/PrintObject/Layer/GCode), Tab, or the +printer-profile/config system. + +## The dependency ask, precisely + +Not "adopt OCCT" — **widen the existing OCCT build**: + +```diff +- -DBUILD_MODULE_ModelingAlgorithms=OFF ++ -DBUILD_MODULE_ModelingAlgorithms=ON +``` + +Cost, measured from the shipped Windows artifact (42 OCCT DLLs, 45.43 MiB total): + +| toolkit | size | note | +|---|---:|---| +| `TKFillet.dll` | 2.02 MiB | only exists with the flag ON | +| `TKOffset.dll` | 1.75 MiB | only exists with the flag ON | +| **delta** | **3.77 MiB** | Windows only (OCCT is Shared on Win, Static elsewhere) | + +`TKBool` is *not* part of the delta — upstream's `DataExchange` already pulls it in +transitively. On macOS/Linux OCCT links statically, so the cost is only the code actually +referenced, not a 3.77 MiB floor. + +**Unmeasured, and we should measure before the call:** clean-deps build-time delta with +the flag ON vs OFF, and the resulting CI runner-minute cost. Do not guess these at him. + +## Licensing + +- Vendored solver `src/libslic3r/slvs/` — **GPL-3.0**, 9,339 LOC, © Jonathan Westhues, + a self-contained subset of SolveSpace (`libslvs`). No external dependencies. +- OrcaSlicer — **AGPL-3.0** (`LICENSE.txt`). + +GPLv3 §13 expressly permits combining a GPLv3 work with an AGPLv3 work; AGPLv3 §13 grants +the converse. The combined work is distributable under AGPL-3.0 with the solver's GPLv3 +terms preserved. This is a favourable direction (GPLv3 → into an AGPLv3 project), but it +is a point to **confirm explicitly with upstream**, not to assert unilaterally. + +Open question for SoftFever: keep the solver **vendored** (current: pinned, no submodule, +no external build) or move it to `deps/` as a fetched external? Vendoring costs us +upstream-sync burden; `deps/` costs build complexity. + +## The one irreversible decision: the 3MF format + +Persistence adds an **optional** archive entry and one field: + +```cpp +// Model.hpp +std::string cad_recipe; // empty for non-CAD projects +``` + +``` +Metadata/orca_cad.bin // written only when cad_recipe is non-empty +``` + +Readers that do not know the entry ignore it; writers skip it entirely when empty. So +existing projects are bit-identical and old readers are unaffected. Good. + +**But the moment upstream ships this, it owns forward-compatibility forever.** Three +things should be settled *before* the first release, because none can be changed after: + +1. **Name.** Renamed to `Metadata/orca_cad.bin`. +2. **Encoding.** The recipe is an opaque **cereal `PortableBinaryArchive`** blob whose + layout is the field order of `CadFeature::serialize`. Portable across endianness and + word size — *not* across a field reorder. Append-only is currently a convention held by + discipline, not by any check. +3. **Embedded BRep.** `Import` features embed OCCT's ASCII BRep for the imported solid, + which couples saved project files to an OCCT BRep revision. Alternative: re-import from + the source STEP and store only a reference. Worth deciding deliberately. + +**Concrete gap we should close before the call.** `test_caddocument.cpp` covers the +in-memory round-trip and correctly refuses a version-999 blob — but there is **no +checked-in v1 fixture on disk**. A reordered field in `CadFeature::serialize` would pass +the entire suite while silently breaking every previously-saved project. Ship a golden +`.bin` fixture generated today plus a test that loads it; that is the only thing that will +hold the format still once real users have files. + +## Proposed PR decomposition + +35 kLOC in one PR is not reviewable. Behind the flag, slices 1–4 are behaviour-neutral for +existing users: + +1. **Build gate + OCCT flag + Windows packaging guard.** `-DSLIC3R_CAD=ON/OFF`, default + **OFF**. Flips `ModelingAlgorithms=ON`. Includes the guard that asserts every linked + OCCT toolkit has a shipped DLL (already on both forks: `546cef5f42`). ← *this is what + makes SoftFever's "parallel build" a one-line CI matrix entry.* +2. **Vendored `slvs` solver** + its Catch2 tests. No GUI, no OCCT. +3. **CAD kernel** (`CadDocument`, `SketchEngine`, `GeometryEngine`, `Sketch*`) + kernel + tests. Headless, no GUI. +4. **3MF recipe persistence** + golden-fixture regression test. +5. **GUI Design tab** (`DesignPanel`, `DesignCanvas`, `DesignSketchTool`, `GLGizmoSketch`) + + the 23 upstream hooks. + +## Agenda for the call + +Questions only SoftFever can answer: + +- Does OrcaSlicer *want* to be a CAD-integrated slicer? (Strategic; everything else is mechanical.) +- Default of `SLIC3R_CAD` at merge time, and when it flips ON. +- Vendored solver vs `deps/` external; and confirmation of the GPLv3/AGPLv3 combination. +- Project-file format: neutral name, encoding, embedded-BRep policy, and who owns v1 forward-compat. +- Undo/redo: the Design tab has its own stack; integrate with Orca's snapshot system or keep separate? +- Does he want the i18n work (Romanian, +23 kLOC) as a wholly separate PR? (Yes, almost certainly.) + +## Verdict + +Portability **high**. The prior "does upstream want OCCT" framing was wrong — OCCT is +already there. What remains is a 3.77 MiB dependency widening, a compile-time gate that +the existing null-guards make cheap, and one file-format decision that must be made before +the first release rather than after. + +--- +*Revised 2026-07-10 from direct measurement of `cad-mainline` @ `546cef5f42` vs upstream +merge-base `449a4cf9fc`. Supersedes the 2026-06-21 read-only assessment.* diff --git a/docs/CAD/rig_build_traps.md b/docs/CAD/rig_build_traps.md new file mode 100644 index 0000000000..b620b244d9 --- /dev/null +++ b/docs/CAD/rig_build_traps.md @@ -0,0 +1,151 @@ +# Rig build traps + +The build rig is two long-lived containers, `snapmaker-gui` and `orcacad-gui`, one per fork. Each +mounts only its fork's build volume (`snapmaker_buildcache` / `orcacad_buildcache`) at +`/OrcaSlicer/build`, its fork's `resources/`, and a shots directory — nothing else. They run the +binary; they do not build it. Rebuild with `scripts/CAD/build-gui.sh`. + +| fork repo | project() | deps image | build volume | GUI container | binary | +|---|---|---|---|---|---| +| `Snapmaker` | `Snapmaker_Orca` | `snapmaker-deps` | `snapmaker_buildcache` | `snapmaker-gui` | `snapmaker-orca` | +| `orca_cad` | `OrcaSlicer` | `orcacad-deps` | `orcacad_buildcache` | `orcacad-gui` | `orca-slicer` | + +`scripts/CAD/build-gui.sh` exists alongside `scripts/CAD/build-gui-incremental.sh` for one reason: it does a +target-only `ninja` into the volume the GUI rig launches from, so a session can test a single +change without a full repackage, whereas `build-gui-incremental.sh` runs the full packaged build. +Both start a throwaway container from the deps image with the live repo mounted over the baked +tree — never build inside the GUI container (Trap 1). + +Every trap below has already cost about a session to re-derive, once each. They are recorded now +so no fresh session pays them again. Symptoms, causes, and exact recovery commands follow. + +--- + +## Trap 1 — never configure inside the GUI container + +**Symptom.** After building inside the GUI container, the fork's targets no longer exist; ninja +reports an unknown target, and `orca-slicer` / `OrcaSlicer` have been replaced by +`snapmaker-orca` / `Snapmaker_Orca`. + +**Cause.** The GUI image's baked `/OrcaSlicer` tree is the Jun-13 Snapmaker-derived source +(`project(Snapmaker_Orca)`, executable `snapmaker-orca`). `orcacad-deps` is layered on +`snapmaker-deps`, so even on the mainline fork the baked tree is the other fork's. A `cmake .` +there reconfigures the shared build dir under the wrong project name. + +**Fix.** Build only via `scripts/CAD/build-gui.sh`, which starts a throwaway container from the deps +image with the live repo mounted over the baked tree — `src`, `resources`, `cmake`, `deps_src`, +`localization`, `CMakeLists.txt`, `version.inc` — and writes into the same volume the rig +launches from. + +--- + +## Trap 2 — stale `NLopt_DIR` in CMakeCache + +**Symptom.** Configure fails with `Cannot find NLopt library 'nlopt_cxx' in '/lib'`. + +**Cause.** `cmake/modules/FindNLopt.cmake:26` is `set(NLopt_DIR $ENV{NLOPT})`. With `NLOPT` +unset that expands to `set(NLopt_DIR)` — zero arguments — which *unsets the normal variable* and +lets a leftover CACHE entry of the same name (e.g. `/lib/cmake/nlopt`) show through the +following `if(NOT NLopt_DIR)`. The `else()` branch then searches for `nlopt_cxx` under +`${NLopt_DIR}/lib` with `NO_DEFAULT_PATH`, while the deps prefix ships plain `nlopt`. + +**Fix.** From inside the build dir: + + cmake -U NLopt_DIR -U NLopt_LIBS . + +Do **not** `sed` the entry out of `CMakeCache.txt` — deleting a line breaks the cache parser. + +--- + +## Trap 3 — the image lacks `deps_src/pybind11` + +**Symptom.** Configure aborts with `pybind11 headers not found in /OrcaSlicer/deps_src/pybind11. +Did you initialize submodules?` (the `FATAL_ERROR` guarding `PYBIND11_SOURCE_DIR` in the mainline +fork's root `CMakeLists.txt`, near line 948). + +**Cause.** The deps image predates that requirement. Only the mainline (`orca_cad`) fork has +`deps_src/pybind11` and the requirement; Snapmaker has neither. + +**Fix.** Mount `deps_src` over the baked tree — `scripts/CAD/build-gui.sh` does. Corollary: mounting a +Snapmaker tree into an `orcacad-deps` build reproduces this error exactly. + +--- + +## Trap 4 — `OCCT_LIBS` lags one configure + +**Symptom.** A wall of undefined references to `TopOpeBRepBuild` symbols. It reads as a broken +OCCT installation. It is not. + +**Cause.** `src/libslic3r/CMakeLists.txt:603` does +`set(OCCT_LIBS "${OCCT_LIBS}" CACHE INTERNAL "OCCT toolkits linked by libslic3r")` at the END of +its own configure, while the consumer in the root `CMakeLists.txt` (`if (NOT OCCT_LIBS)` … +`foreach (_tk IN LISTS OCCT_LIBS)`) reads whatever is already in the cache. The first reconfigure +after the `TKFillet TKOffset` prepend (`src/libslic3r/CMakeLists.txt:599`) therefore links the +previous list and drops `TKBool`/`TKOffset`. + +**Fix.** Configure twice. `scripts/CAD/build-gui.sh` runs `cmake .` twice for exactly this reason; if +you ever configure by hand, run it twice. + +--- + +## Trap 5 — `SLIC3R_CAD=ON` in the cache, macro never defined + +**Symptom.** The build succeeds and links, but the Design tab is simply absent — or it fails with +`class GLCanvas3D has no member named set_design_sketch_tool`. + +**Cause.** The cache carries `SLIC3R_CAD=ON`, but the root `CMakeLists.txt` actually configured is +a stale baked copy that predates the gate and never runs `add_definitions(-DSLIC3R_CAD)` (the +gate is `if (SLIC3R_CAD)` / `add_definitions(-DSLIC3R_CAD)` in the root list — line 179/180 in +Snapmaker, 319/320 in orca_cad). Every `#ifdef SLIC3R_CAD` block therefore compiles out while the +option still reads ON. + +**Fix.** Always mount the live `CMakeLists.txt` and `cmake/` — never inherit them from the image. +This is why `scripts/CAD/build-gui-incremental.sh`, `scripts/CAD/run-kernel-tests.sh` and `scripts/CAD/build-gui.sh` +all mount both. + +--- + +## The binary the rig actually launches + +`ninja ` writes `/OrcaSlicer/build/src/Release/`; only `build_linux.sh` +additionally packages to `/OrcaSlicer/build/package/bin/`. `orca_cad`'s +`scripts/CAD/start-headless-gui.sh` defaults `BIN` to `src/Release/orca-slicer`, but Snapmaker's defaults to +`package/bin/snapmaker-orca`. So after a target-only rebuild on Snapmaker, launching +`start-headless-gui.sh` with its default runs the **stale packaged** binary — the change under test is +invisible and the session hunts a phantom. Pass `BIN` explicitly: + + docker exec -e BIN=/OrcaSlicer/build/src/Release/snapmaker-orca snapmaker-gui /OrcaSlicer/scripts/CAD/start-headless-gui.sh + +`scripts/CAD/build-gui.sh` prints the correct line for the current fork when it finishes. + +Note also that the GUI containers do **not** mount `scripts/`: `/OrcaSlicer/scripts` inside them +is the baked copy, so a local edit to `start-headless-gui.sh` has no effect until you +`docker cp scripts/CAD/start-headless-gui.sh :/OrcaSlicer/scripts/`. + +--- + +## Trap 6 — `src/Release/` resolves resources to `build/resources`, which may not exist + +The binary derives `resources_dir()` from its own location, so the `src/Release/` one looks in +`/OrcaSlicer/build/resources` while the packaged one looks inside `build/package/`. Only the +packaging step creates the latter; nothing creates the former. Without it the app fails every +`Failed to add custom font ".../build/resources/fonts/…"`, logs `Health check is not running`, +and **exits 255 with nothing on stdout** — which reads exactly like a crash in whatever you just +changed. Measured 2026-08-02: an hour was nearly spent bisecting a GUI change that was fine. + +`build/` is the shared cache volume, so one symlink fixes it permanently, and pointing it at the +bind-mounted repo tree means the rig also picks up new `resources/images/*.svg` without a rebuild: + + docker exec -gui ln -sfn /OrcaSlicer/resources /OrcaSlicer/build/resources + +Tell the two apart before debugging: a resource failure dies in the first second with no window; +a real fault in your code gets past the version banner. Compare +`~/.config//log/.log.0` against a known-good run — 47 lines versus 340 is the tell. + +## Trap 7 — a single-instance app plus a path-matched `pkill` + +`start-headless-gui.sh` used to kill by `"$BIN"`, while its own `app_pid()` matched by BASENAME. Launch +with a `BIN` that differs from the running instance's path and the old process survives, keeps +the single-instance lock, and the new one exits seconds after loading fonts — then `status` +reports the *stale* pid as a healthy session. Fixed by killing on the basename; `status` now also +prints `binary : $(readlink -f /proc//exe)`. **Read that line before trusting a screenshot.** diff --git a/docs/CAD/ux/interaction-model.md b/docs/CAD/ux/interaction-model.md new file mode 100644 index 0000000000..2041978ea0 --- /dev/null +++ b/docs/CAD/ux/interaction-model.md @@ -0,0 +1,144 @@ +# Design tab — interaction model + +The contract for Esc, the right mouse button, and the states between them. Code that changes any +of the three changes this file in the same commit. + +## 1. The state machine + +`src/slic3r/GUI/CAD/DesignInteraction.hpp` — a four-level LIFO stack. The enum value *is* the +depth, so "which level does this press belong to" is a comparison rather than a chain of +special cases spread over three files. + +```cpp +enum class CadLevel : int { + Idle = 0, // nothing transient is up: Esc clears the selection + Tool = 1, // a feature card / armed sketch tool / constrain session: Esc exits it + Gesture = 2, // an uncommitted delta (entity being drawn, body being dragged): Esc reverts it + Transient = 3, // a value field or a popup menu: Esc closes just that +}; + +struct CadInteractionState { // the four bits routing actually needs + bool value_field_open{false}; + bool gesture_active{false}; + bool tool_armed{false}; + bool has_selection{false}; +}; + +constexpr CadLevel cad_escape_level(const CadInteractionState& s) +{ + if (s.value_field_open) return CadLevel::Transient; + if (s.gesture_active) return CadLevel::Gesture; + if (s.tool_armed) return CadLevel::Tool; + return CadLevel::Idle; +} +``` + +The rule is a `constexpr` free function over a POD, not a method on the panel, so the ordering +that is the entire contract is checkable without a window, a GL context or an event loop. Five +`static_assert`s in the header do exactly that, at compile time. + +**Strict invariant.** No level of Esc deletes a feature, discards a sketch that holds geometry, +or rolls history back. Destroying work needs a gesture that says so: + +| To destroy | Gesture | +|---|---| +| a feature | Delete / Backspace on an explicit selection | +| a drawn sketch | the ribbon's ✗ Cancel, which asks first | +| the last committed change | Ctrl+Z | + +## 2. Event routing + +**`OnKeyDown(WXK_ESCAPE)`** — `DesignPanel`'s `wxEVT_CHAR_HOOK`, one line: + +```cpp +if (key == WXK_ESCAPE) { escape(); return; } +``` + +Every Esc in the tab goes through it, whatever holds focus. `DesignPanel::escape_level()` answers +the four questions of `CadInteractionState` about this panel; `DesignPanel::escape()` acts on the +one level that answer names, and on no other: + +| Level | What one press does | What it must not touch | +|---|---|---| +| `Transient` | close the value field (`cancel_value` / `inline_cancel`) | the tool, which stays armed | +| `Gesture` | drop the clicks of the entity being drawn, or put a moved body back at the pose it had when the gizmo appeared | everything already committed | +| `Tool` | discard a feature card's *candidate*; drop an armed sketch tool to Select; end Constrain | committed features; entities already drawn | +| `Idle` | clear the selection (model and sketch); leave a sketch session **only if it is empty** | a sketch holding geometry — it is left through Finish or Cancel | + +A sketch *session* is deliberately not a `Tool`. It is the environment the Idle level lives in, +which is what makes the destructive path unrepresentable rather than merely unlikely. + +**`OnRightDown` / `OnRightUp`** — `DesignCanvas::set_on_context_menu`, bound after `GLCanvas3D`'s +own handlers so it can consume the event before them: + +```cpp +RIGHT_DOWN: remember the press position and the clock, then Skip() // the canvas still seeds the orbit + +RIGHT_UP: terminated = sketch_tool.take_right_consumed(); // read-and-clear, always + is_click = drift <= 3 px && dt <= 200 ms; // both budgets, or it was navigation + if (callback && !terminated && !inline_busy && is_click) { + select_at_screen(press.x, press.y); // raycast at the PRESS, not the release + on_context_menu(ClientToScreen(press)); + return; // consumed + } + Skip(); // orbit / pan / the handlers underneath +``` + +Two independent budgets because the two failure modes are independent: drift alone still popped a +menu at the end of a slow, careful orbit. `take_right_consumed()` is how a right-click that +already meant something to the armed sketch tool (terminate a chain, drop an edit-op) declines to +also mean "open a menu". + +## 3. Transition table + +`sel` = something is picked. Blank = the input does nothing at that state. + +| State | Left-click | Right-click | Esc | Enter | +|---|---|---|---|---| +| **Idle — model view** | pick / escalate the pick | offer menu for what is under the cursor | clear the selection | — | +| **Idle — sketch, empty** | pick | sketch offer menu | leave the session (nothing to lose) | Finish sketch | +| **Idle — sketch, drawn** | pick | sketch offer menu | clear the selection; status says the sketch is kept | Finish sketch | +| **Tool — feature card** | pick the card's next reference | offer menu | discard the candidate, close the card | commit the feature | +| **Tool — sketch tool armed** | place the first point | drop the tool to Select | drop the tool to Select | — | +| **Tool — constrain** | pick an entity | offer menu | end the session | apply | +| **Gesture — drawing** | place the next point | terminate the chain (keep what is drawn) | drop the in-progress entity, tool stays armed | commit the entity as drawn | +| **Gesture — moving a body** | drop the body here | end the move | revert to the pose at move-start | keep the placement | +| **Transient — value field** | — | — | close the field, tool stays armed | commit the value, advance the chain | +| **Transient — popup menu** | run the entry | — | close the menu | run the highlighted entry | +| **any** | — | — | *never* deletes, discards or rolls back | — | + +Right-hold-and-drag is not in the table on purpose: past 3 px or 200 ms it is navigation, and +navigation does not transition the state machine. + +## 4. Visual scaffolding + +Entering a sketch changes three things at once, so the state is legible from across the room: + +- **Banner.** A teal strip across the top of the viewport: `Editing: Sketch N · N = look normal to + the plane · Finish or Cancel in the toolbar`. Indicator only — Confirm and Cancel stay on the one + ribbon action bar, per the Design UX contract. It is a sibling above the canvas, not a floating + child over it: a child window over a `wxGLCanvas` is a native window on GTK and does not reliably + stack over GL, and this banner's job is to be unmissable rather than clever. +- **The printer bed is muted.** A plate grid and a sketch grid are the same visual language, and + reading one as the other is how a sketch gets drawn against the wrong reference. The view + checkbox remains the stored preference and is restored on the way out; ticking it mid-sketch + still shows the bed, because that is a deliberate act and this is only a default. +- **`N` looks normal to the plane**, keeping the current zoom, with the plane's own y axis as up. + Sketch key map only — in Feature mode the navigator orb owns orientation. + +## 5. Context menu content + +The offer is generated from `docs/CAD/ux/tool_atlas.json`; its 8-row shape and permanent row +indices are ratified and are not changed here. Checked against the per-context vocabularies asked +for in the 2026-09-05 interaction brief, the atlas already carries all of them except two, both on +a planar face: + +| Asked for | Status | +|---|---| +| Revolve on a planar face | **not offered, and should not be**: `revolve` accepts `sk_loop` only, because the kernel takes a sketch profile — a face is not one | +| Offset Face | offered as **Thicken** (`thicken`, accepts `face_planar`); `surf_offset` is the sheet-body verb and accepts `body_sheet` | + +View and document actions — Zoom to Fit, View Isometric, Clear Selection, Finish Sketch, Normal to +Sketch — stay in chrome by the atlas's own rule: the offer describes verbs that consume a +*selection*, and these act on the document or the camera. Esc covers Clear Selection, `N` covers +Normal to Sketch, and the ribbon covers Finish. diff --git a/docs/CAD/ux/mockups/cmp_12__edge_str.svg b/docs/CAD/ux/mockups/cmp_12__edge_str.svg new file mode 100644 index 0000000000..3e641e90ec --- /dev/null +++ b/docs/CAD/ux/mockups/cmp_12__edge_str.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Straight edge selected — pick what to do with itWorking document · 12 slotsDress-up2Pattern on CurveReference3Straight edge \ No newline at end of file diff --git a/docs/CAD/ux/mockups/cmp_12__face_planar.svg b/docs/CAD/ux/mockups/cmp_12__face_planar.svg new file mode 100644 index 0000000000..85809d5bf6 --- /dev/null +++ b/docs/CAD/ux/mockups/cmp_12__face_planar.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 12 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/cmp_12__sk_line.svg b/docs/CAD/ux/mockups/cmp_12__sk_line.svg new file mode 100644 index 0000000000..6535c9fe77 --- /dev/null +++ b/docs/CAD/ux/mockups/cmp_12__sk_line.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 12 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line \ No newline at end of file diff --git a/docs/CAD/ux/mockups/cmp_8__edge_str.svg b/docs/CAD/ux/mockups/cmp_8__edge_str.svg new file mode 100644 index 0000000000..2c41ce74fc --- /dev/null +++ b/docs/CAD/ux/mockups/cmp_8__edge_str.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Straight edge selected — pick what to do with itWorking document · 8 slotsDress-up2Pattern on CurveReference3Straight edge \ No newline at end of file diff --git a/docs/CAD/ux/mockups/cmp_8__face_planar.svg b/docs/CAD/ux/mockups/cmp_8__face_planar.svg new file mode 100644 index 0000000000..5a58e2fb72 --- /dev/null +++ b/docs/CAD/ux/mockups/cmp_8__face_planar.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 8 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/cmp_8__sk_line.svg b/docs/CAD/ux/mockups/cmp_8__sk_line.svg new file mode 100644 index 0000000000..4ca4000d59 --- /dev/null +++ b/docs/CAD/ux/mockups/cmp_8__sk_line.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__art.svg b/docs/CAD/ux/mockups/fresh__art.svg new file mode 100644 index 0000000000..a54dd49b43 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__art.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetText / imported art selected — pick what to do with itFresh document · 8 slotsABCModify2Text / imported art \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__art__modify.svg b/docs/CAD/ux/mockups/fresh__art__modify.svg new file mode 100644 index 0000000000..184611c682 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__art__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsABCDeleteDelEditText / imported art · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__bodies_2.svg b/docs/CAD/ux/mockups/fresh__bodies_2.svg new file mode 100644 index 0000000000..f282bef03a --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__bodies_2.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetTwo bodies selected — pick what to do with itFresh document · 8 slotsMeasureDeleteDelTwo bodies \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__body_sheet.svg b/docs/CAD/ux/mockups/fresh__body_sheet.svg new file mode 100644 index 0000000000..635b2f6aa6 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__body_sheet.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetSheet body selected — pick what to do with itFresh document · 8 slotsMeasureModify2Sheet body \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__body_sheet__modify.svg b/docs/CAD/ux/mockups/fresh__body_sheet__modify.svg new file mode 100644 index 0000000000..0cd330046d --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__body_sheet__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelEditSheet body · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__body_solid.svg b/docs/CAD/ux/mockups/fresh__body_solid.svg new file mode 100644 index 0000000000..ef21e2b8e2 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__body_solid.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetSolid body selected — pick what to do with itFresh document · 8 slotsMeasureModify2Solid body \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__body_solid__modify.svg b/docs/CAD/ux/mockups/fresh__body_solid__modify.svg new file mode 100644 index 0000000000..8b5d1485a0 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__body_solid__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelEditSolid body · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__coordsys.svg b/docs/CAD/ux/mockups/fresh__coordsys.svg new file mode 100644 index 0000000000..e20d08d000 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__coordsys.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCoordinate system selected — pick what to do with itFresh document · 8 slotsModify2Coordinate system \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__coordsys__modify.svg b/docs/CAD/ux/mockups/fresh__coordsys__modify.svg new file mode 100644 index 0000000000..b6b4871d16 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__coordsys__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelEditCoordinate system · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__datum_axis.svg b/docs/CAD/ux/mockups/fresh__datum_axis.svg new file mode 100644 index 0000000000..8174807ceb --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__datum_axis.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetDatum axis selected — pick what to do with itFresh document · 8 slotsModify2Datum axis \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__datum_axis__modify.svg b/docs/CAD/ux/mockups/fresh__datum_axis__modify.svg new file mode 100644 index 0000000000..cba7005358 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__datum_axis__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelEditDatum axis · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__datum_plane.svg b/docs/CAD/ux/mockups/fresh__datum_plane.svg new file mode 100644 index 0000000000..ffabd59969 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__datum_plane.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetDatum plane selected — pick what to do with itFresh document · 8 slotsSketchShift+SReference2Modify2Datum plane \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__datum_plane__modify.svg b/docs/CAD/ux/mockups/fresh__datum_plane__modify.svg new file mode 100644 index 0000000000..9ac26e4ce1 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__datum_plane__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelEditDatum plane · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__datum_plane__reference.svg b/docs/CAD/ux/mockups/fresh__datum_plane__reference.svg new file mode 100644 index 0000000000..b42479441a --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__datum_plane__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsPlaneShift+PHelixDatum plane · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__edge_circ.svg b/docs/CAD/ux/mockups/fresh__edge_circ.svg new file mode 100644 index 0000000000..3e72544c04 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__edge_circ.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCircular edge selected — pick what to do with itFresh document · 8 slotsMeasureCircular edge \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__edge_str.svg b/docs/CAD/ux/mockups/fresh__edge_str.svg new file mode 100644 index 0000000000..c4e54d5335 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__edge_str.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetStraight edge selected — pick what to do with itFresh document · 8 slotsReference3Straight edge \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__edge_str__reference.svg b/docs/CAD/ux/mockups/fresh__edge_str__reference.svg new file mode 100644 index 0000000000..80dd7a877a --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__edge_str__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsMeasurePlaneShift+PAxisShift+AStraight edge · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__face_cyl.svg b/docs/CAD/ux/mockups/fresh__face_cyl.svg new file mode 100644 index 0000000000..d2335e4177 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__face_cyl.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCylindrical face selected — pick what to do with itFresh document · 8 slotsReference3EditCylindrical face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__face_cyl__reference.svg b/docs/CAD/ux/mockups/fresh__face_cyl__reference.svg new file mode 100644 index 0000000000..7cb83afb56 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__face_cyl__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsMeasureAxisShift+AHelixCylindrical face · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__face_other.svg b/docs/CAD/ux/mockups/fresh__face_other.svg new file mode 100644 index 0000000000..d8dcfb96ab --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__face_other.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCurved face selected — pick what to do with itFresh document · 8 slotsMeasureEditCurved face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__face_planar.svg b/docs/CAD/ux/mockups/fresh__face_planar.svg new file mode 100644 index 0000000000..a66268fc9a --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__face_planar.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetPlanar face selected — pick what to do with itFresh document · 8 slotsSketchShift+SExtrudeShift+EReference4EditPlanar face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__face_planar__reference.svg b/docs/CAD/ux/mockups/fresh__face_planar__reference.svg new file mode 100644 index 0000000000..9aa608a5e7 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__face_planar__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsCoord SysShift+CMeasurePlaneShift+PAxisShift+APlanar face · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__none.svg b/docs/CAD/ux/mockups/fresh__none.svg new file mode 100644 index 0000000000..56ac028b74 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__none.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetClick a face or a reference plane, then a toolFresh document · 8 slotsSketchShift+SReference4Nothing selected \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__none__reference.svg b/docs/CAD/ux/mockups/fresh__none__reference.svg new file mode 100644 index 0000000000..0492aa6de0 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__none__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsCoord SysShift+CHelixPlaneShift+PAxisShift+ANothing selected · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_2ent.svg b/docs/CAD/ux/mockups/fresh__sk_2ent.svg new file mode 100644 index 0000000000..dd02122149 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_2ent.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetTwo sketch entities selected — pick what to do with itFresh document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Two sketch entities \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_2ent__create.svg b/docs/CAD/ux/mockups/fresh__sk_2ent__create.svg new file mode 100644 index 0000000000..98e984bff2 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_2ent__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCreate — pick oneFresh document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Two sketch entities · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_2ent__dressup.svg b/docs/CAD/ux/mockups/fresh__sk_2ent__dressup.svg new file mode 100644 index 0000000000..926594bb86 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_2ent__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetDress-up — pick oneFresh document · 8 slotsFilletFChamferHTwo sketch entities · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_2ent__modify.svg b/docs/CAD/ux/mockups/fresh__sk_2ent__modify.svg new file mode 100644 index 0000000000..242332967e --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_2ent__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelExtendXTwo sketch entities · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_2ent__reference.svg b/docs/CAD/ux/mockups/fresh__sk_2ent__reference.svg new file mode 100644 index 0000000000..aa105c3a9e --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_2ent__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsConstructionQDimensionDConstrainKTwo sketch entities · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_arc.svg b/docs/CAD/ux/mockups/fresh__sk_arc.svg new file mode 100644 index 0000000000..0712508411 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_arc.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetSketch arc or circle selected — pick what to do with itFresh document · 8 slotsCreate9OffsetOTrimTMirrorMMoveReference3Modify2Sketch arc or circle \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_arc__create.svg b/docs/CAD/ux/mockups/fresh__sk_arc__create.svg new file mode 100644 index 0000000000..d22d916e60 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_arc__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCreate — pick oneFresh document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch arc or circle · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_arc__modify.svg b/docs/CAD/ux/mockups/fresh__sk_arc__modify.svg new file mode 100644 index 0000000000..cf3fc2450f --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_arc__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelExtendXSketch arc or circle · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_arc__reference.svg b/docs/CAD/ux/mockups/fresh__sk_arc__reference.svg new file mode 100644 index 0000000000..cfbe0a1042 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_arc__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsConstructionQDimensionDConstrainKSketch arc or circle · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_line.svg b/docs/CAD/ux/mockups/fresh__sk_line.svg new file mode 100644 index 0000000000..e40d823e13 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_line.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetSketch line selected — pick what to do with itFresh document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_line__create.svg b/docs/CAD/ux/mockups/fresh__sk_line__create.svg new file mode 100644 index 0000000000..1a6f7b561c --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_line__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCreate — pick oneFresh document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch line · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_line__dressup.svg b/docs/CAD/ux/mockups/fresh__sk_line__dressup.svg new file mode 100644 index 0000000000..3fce662ba1 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_line__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetDress-up — pick oneFresh document · 8 slotsFilletFChamferHSketch line · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_line__modify.svg b/docs/CAD/ux/mockups/fresh__sk_line__modify.svg new file mode 100644 index 0000000000..26318535ef --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_line__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelExtendXSketch line · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_line__reference.svg b/docs/CAD/ux/mockups/fresh__sk_line__reference.svg new file mode 100644 index 0000000000..2abc91fc99 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_line__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsConstructionQDimensionDConstrainKSketch line · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_loop.svg b/docs/CAD/ux/mockups/fresh__sk_loop.svg new file mode 100644 index 0000000000..58eaa9e4fb --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_loop.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetClosed sketch loop selected — pick what to do with itFresh document · 8 slotsAdd material5Modify2Closed sketch loop \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_loop__add.svg b/docs/CAD/ux/mockups/fresh__sk_loop__add.svg new file mode 100644 index 0000000000..c6e6c90701 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_loop__add.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetAdd material — pick oneFresh document · 8 slotsExtrudeShift+ERevolveShift+RSurface ExtrudeShift+GSurface RevolveSurface FillClosed sketch loop · Add material \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_loop__modify.svg b/docs/CAD/ux/mockups/fresh__sk_loop__modify.svg new file mode 100644 index 0000000000..416e62d7cd --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_loop__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetModify — pick oneFresh document · 8 slotsDeleteDelEditClosed sketch loop · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_none.svg b/docs/CAD/ux/mockups/fresh__sk_none.svg new file mode 100644 index 0000000000..ce7604c47d --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_none.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetSketch, nothing picked selected — pick what to do with itFresh document · 8 slotsCreate9Reference2Sketch, nothing picked \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_none__create.svg b/docs/CAD/ux/mockups/fresh__sk_none__create.svg new file mode 100644 index 0000000000..06994f9517 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_none__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCreate — pick oneFresh document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch, nothing picked · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_none__reference.svg b/docs/CAD/ux/mockups/fresh__sk_none__reference.svg new file mode 100644 index 0000000000..e4e558bbd9 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_none__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsDimensionDConstructionQSketch, nothing picked · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_point.svg b/docs/CAD/ux/mockups/fresh__sk_point.svg new file mode 100644 index 0000000000..65acf94ac7 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_point.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetSketch point selected — pick what to do with itFresh document · 8 slotsCreate9MoveReference2DeleteDelSketch point \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_point__create.svg b/docs/CAD/ux/mockups/fresh__sk_point__create.svg new file mode 100644 index 0000000000..fbe094d147 --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_point__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetCreate — pick oneFresh document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch point · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__sk_point__reference.svg b/docs/CAD/ux/mockups/fresh__sk_point__reference.svg new file mode 100644 index 0000000000..8b02eba29f --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__sk_point__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsDimensionDConstrainKSketch point · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__vertex.svg b/docs/CAD/ux/mockups/fresh__vertex.svg new file mode 100644 index 0000000000..1e1661d6aa --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__vertex.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetVertex selected — pick what to do with itFresh document · 8 slotsReference4Vertex \ No newline at end of file diff --git a/docs/CAD/ux/mockups/fresh__vertex__reference.svg b/docs/CAD/ux/mockups/fresh__vertex__reference.svg new file mode 100644 index 0000000000..aad3d46b6e --- /dev/null +++ b/docs/CAD/ux/mockups/fresh__vertex__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetReference — pick oneFresh document · 8 slotsCoord SysShift+CMeasurePlaneShift+PAxisShift+AVertex · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/gen_offer_mockups.py b/docs/CAD/ux/mockups/gen_offer_mockups.py new file mode 100644 index 0000000000..6ddd4caae4 --- /dev/null +++ b/docs/CAD/ux/mockups/gen_offer_mockups.py @@ -0,0 +1,884 @@ +#!/usr/bin/env python3 +"""Render the object-driven tool offer from docs/ux/tool_atlas.json. + +Fork-neutral on purpose: this file and everything it emits name no product, so the +two forks carry byte-identical copies (the charter itself is the only doc that +substitutes the product name). + +Every mockup in docs/ux/mockups/ and the review page docs/ux/offer_atlas.html are +generated by this script. Nothing is hand-drawn: the point of the offer is that a +tool's address never changes, and a human drawing 40-odd states by hand is exactly +how an address quietly changes. + + python3 docs/ux/mockups/gen_offer_mockups.py + +It fails loudly rather than rendering a broken map: an unknown slot, an unknown +selection id in `accepts`, or one address holding two different verbs for the same +selection is an error, not a warning. + +SVG on purpose. There is no rsvg/inkscape/cairosvg on the build box and +ImageMagick would rasterise through its own weak internal renderer; SVG renders +exactly in a browser at any zoom and stays diffable in git. Rasterise later if +slides need it. +""" + +import json +import re +import math +import os +import sys +from collections import defaultdict + +HERE = os.path.dirname(os.path.abspath(__file__)) +UX = os.path.dirname(HERE) +ATLAS = os.path.join(UX, "tool_atlas.json") + +# The reach target from the charter (L11 / 6.1): if the ring plus a real part do not +# fit here, the design has failed before it is built. +W, H = 1366, 768 + +C = { + "app": "#1a1d21", "chrome": "#23272d", "panel": "#20242a", "line": "#31363d", + "vp0": "#2f353d", "vp1": "#242930", "grid": "#333a43", + "text": "#e7ecf1", "muted": "#8e9aa7", "dim": "#5f6a76", + "top": "#93a1b1", "left": "#6f7b89", "right": "#5a6472", "edge": "#39414b", + "hi": "#f5a623", "hi_soft": "#f5a62333", "sheet": "#7fa8c9", + "ring": "#171a1e", "chip": "#2e343c", "chip_line": "#3c444e", + "key": "#454f5b", "accent": "#4f9bd9", "empty": "#2a2f36", +} + + +# ---------------------------------------------------------------- glyphs +# Each glyph draws inside a 24x24 box centred on (0,0) — i.e. -12..12. +def _g(body): + return body + + +GLYPHS = { + # model + "sketch": '', + "extrude": '', + "revolve": '', + "sweep": '', + "loft": '', + "thicken": '', + "rib": '', + "boolean": '', + "hole": '', + "thread": '', + "shell": '', + "cut": '', + "split": '', + "fillet": '', + "chamfer": '', + "draft": '', + "surf_off": '', + "pattern": '', + "mirror": '', + "pat_curve": '', + "move": '', + "mate": '', + "align": '', + "plane": '', + "axis": '', + "csys": '', + "helix": '', + "project": '', + "measure": '', + "mass": '', + "interfere": '', + "edit": '', + "del_face": '', + "colour": '', + "delete": '', + # sketch primitives + "sk_line": '', + "sk_rect": '', + "sk_circle": '', + "sk_arc": '', + "sk_slot": '', + "sk_ell": '', + "sk_spline": '', + "sk_poly": '', + "sk_point": '', + "sk_offset": '', + "sk_trim": '', + "sk_ext": '', + "sk_mir": '', + "sk_move": '', + "sk_dim": '', + "sk_lock": '', + "sk_constr": '', + # families (used when a slot holds more than one verb) + "fam_create": '', + "fam_add": '', + "fam_remove": '', + "fam_dressup": '', + "fam_repeat": '', + "fam_transform": '', + "fam_reference": '', + # Deliberately NOT the bare pencil: create/sketch already owns that shape and the two slots + # are adjacent (N and NW). A pencil over a solid reads as "change the thing that exists". + "fam_modify": '' + '', +} + +VERB_GLYPH = { + "sketch": "sketch", "extrude": "extrude", "revolve": "revolve", "sweep": "sweep", + "loft": "loft", "thicken": "thicken", "rib": "rib", "boolean": "boolean", + "surf_extrude": "extrude", "surf_revolve": "revolve", "surf_loft": "loft", + "surf_fill": "surf_off", "thicken_surf": "thicken", "hole": "hole", "thread": "thread", + "shell": "shell", "cut": "cut", "split": "split", "fillet": "fillet", + "chamfer": "chamfer", "draft": "draft", "surf_offset": "surf_off", + "pattern": "pattern", "mirror": "mirror", "pat_curve": "pat_curve", + "transform": "move", "mate": "mate", "align": "align", "plane": "plane", + "axis": "axis", "coordsys_v": "csys", "helix": "helix", "project": "project", + "measure": "measure", "mass_props": "mass", "interference": "interfere", + "edit_feature": "edit", "delete_face": "del_face", "colour": "colour", "delete": "delete", + "sk_line_t": "sk_line", "sk_rect": "sk_rect", "sk_circle": "sk_circle", + "sk_arc_t": "sk_arc", "sk_slot": "sk_slot", "sk_ellipse": "sk_ell", + "sk_spline": "sk_spline", "sk_polygon": "sk_poly", "sk_point_t": "sk_point", + "sk_offset": "sk_offset", "sk_trim": "sk_trim", "sk_fillet": "fillet", + "sk_chamfer": "chamfer", "sk_mirror": "sk_mir", "sk_move": "sk_move", + "sk_dimension": "sk_dim", "sk_constrain": "sk_lock", "sk_construct": "sk_constr", + "sk_extend": "sk_ext", "sk_delete": "delete", +} + + +def glyph(name, cx, cy, colour, scale=1.0, sw=1.6): + body = GLYPHS.get(name, '') + return (f'{body}') + + +# ---------------------------------------------------------------- iso scene +COS30, SIN30 = math.cos(math.radians(30)), math.sin(math.radians(30)) + + +def iso(x, y, z, ox, oy, s=1.0): + return (ox + (x - y) * COS30 * s, oy + (x + y) * SIN30 * s - z * s) + + +def poly(pts, fill, stroke=None, extra=""): + d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) + # Only emit our own stroke-width when the caller has not supplied one: a duplicate + # attribute is last-one-wins in a browser but invalid markup, and it is exactly the + # kind of thing a stricter renderer refuses outright. + if stroke: + st = f' stroke="{stroke}"' + ("" if "stroke-width" in extra else ' stroke-width="1"') + else: + st = ' stroke="none"' + return f'' + + +def box(ox, oy, w=150, d=105, h=52, s=1.0, hi=None): + """Three visible faces of a box. `hi` names the face/edge to highlight.""" + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + top = [P(0, 0, h), P(w, 0, h), P(w, d, h), P(0, d, h)] + left = [P(0, d, h), P(w, d, h), P(w, d, 0), P(0, d, 0)] + right = [P(w, 0, h), P(w, d, h), P(w, d, 0), P(w, 0, 0)] + out = [ + poly(left, C["left"], C["edge"]), + poly(right, C["right"], C["edge"]), + poly(top, C["hi"] if hi == "top" else C["top"], C["edge"]), + ] + if hi == "top": + out.append(poly(top, "none", "#ffd07a", ' stroke-width="2.5"')) + if hi == "front_edge": + a, b = P(0, d, h), P(w, d, h) + out.append(f'') + if hi == "vertex": + v = P(w, d, h) + out.append(f'') + if hi == "whole": + out.append(poly(top, "none", "#ffd07a", ' stroke-width="2.5"')) + out.append(poly(left, "none", "#ffd07a", ' stroke-width="2.5"')) + out.append(poly(right, "none", "#ffd07a", ' stroke-width="2.5"')) + return "".join(out), P + + +def bore(P, cx, cy, h, r=22, s=1.0, hi=False): + """A cylindrical bore through the top face, drawn as ellipse + wall.""" + c = P(cx, cy, h) + rx, ry = r * COS30 * 2 * s, r * SIN30 * 2 * s + col = C["hi"] if hi else "#20252b" + wall = C["hi"] if hi == "wall" else "#3b434d" + return (f'' + f'' + + (f'' if hi else "")), c + + +def scene(kind, ox, oy): + """Return (svg, anchor) for a selection kind. Anchor = where the ring centres.""" + s = 1.0 + if kind == "origin": + # A document with nothing in it: the three origin planes are all there is to click. + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + g = poly([P(-70, -70, 0), P(70, -70, 0), P(70, 70, 0), P(-70, 70, 0)], + "#4f9bd91f", "#4f9bd977", ' stroke-width="1.5" stroke-dasharray="6 4"') + g += poly([P(-70, 0, -70), P(70, 0, -70), P(70, 0, 70), P(-70, 0, 70)], + "#e05c5c14", "#e05c5c66", ' stroke-width="1.5" stroke-dasharray="6 4"') + g += poly([P(0, -70, -70), P(0, 70, -70), P(0, 70, 70), P(0, -70, 70)], + "#5ce07a14", "#5ce07a66", ' stroke-width="1.5" stroke-dasharray="6 4"') + return g, P(0, 0, 0) + if kind == "empty": + g, P = box(ox, oy, s=s) + return g, P(75, 52, 26) + if kind == "box_top": + g, P = box(ox, oy, s=s, hi="top") + return g, P(75, 52, 52) + if kind == "box_whole": + g, P = box(ox, oy, s=s, hi="whole") + return g, P(75, 52, 26) + if kind == "box_edge": + g, P = box(ox, oy, s=s, hi="front_edge") + return g, P(75, 105, 52) + if kind == "box_vertex": + g, P = box(ox, oy, s=s, hi="vertex") + return g, P(150, 105, 52) + if kind in ("box_bore", "box_bore_rim"): + g, P = box(ox, oy, s=s) + b, c = bore(P, 75, 52, 52, hi=("wall" if kind == "box_bore" else True)) + return g + b, c + if kind == "box_fillet_face": + g, P = box(ox, oy, s=s) + a, b = P(0, 0, 52), P(0, 105, 52) + g += (f'') + return g, P(0, 52, 52) + if kind == "sheet": + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + pts = [P(0, 0, 40), P(150, 0, 55), P(150, 105, 30), P(0, 105, 18)] + return (poly(pts, C["sheet"], "#ffd07a", ' stroke-width="2.5" opacity="0.85"'), + P(75, 52, 36)) + if kind == "two_boxes": + g1, P1 = box(ox - 60, oy - 10, w=110, d=80, h=44, hi="whole") + g2, P2 = box(ox + 70, oy + 26, w=95, d=70, h=60, hi="whole") + return g1 + g2, (ox + 42, oy + 6) + if kind == "datum": + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + g, _ = box(ox, oy, s=s) + pts = [P(-25, -25, 70), P(175, -25, 70), P(175, 130, 70), P(-25, 130, 70)] + g += poly(pts, "#4f9bd933", C["hi"], ' stroke-width="2.5" stroke-dasharray="6 4"') + return g, P(75, 52, 70) + if kind == "axis": + g, P = box(ox, oy, s=s) + a, b = P(-30, 52, 52), P(180, 52, 52) + g += (f'') + return g, P(75, 52, 52) + if kind == "csys": + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + g, _ = box(ox, oy, s=s) + o = P(0, 0, 52) + for tgt, col in ((P(60, 0, 52), "#e05c5c"), (P(0, 60, 52), "#5ce07a"), (P(0, 0, 112), "#5c9ce0")): + g += (f'') + g += f'' + return g, o + if kind == "art": + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + g, _ = box(ox, oy, s=s) + c = P(75, 52, 52) + g += (f'' + f'ABC') + return g, c + if kind == "loop": + P = lambda x, y, z: iso(x, y, z, ox, oy, s) + g, _ = box(ox, oy, s=s) + pts = [P(28, 22, 52), P(122, 22, 52), P(122, 83, 52), P(28, 83, 52)] + g += poly(pts, C["hi_soft"], C["hi"], ' stroke-width="3"') + return g, P(75, 52, 52) + # --- sketch mode: flat, camera normal to the plane + gx, gy = ox - 30, oy - 10 + grid = "".join( + f'' + for i in range(11)) + "".join( + f'' + for i in range(16)) + if kind == "sk_empty": + return grid, (gx, gy) + if kind == "sk_line": + return (grid + f'' + f'' + f'', (gx, gy + 10)) + if kind == "sk_arc": + return (grid + f'', (gx, gy)) + if kind == "sk_point": + return (grid + f'', (gx, gy)) + if kind == "sk_two": + return (grid + f'' + f'', (gx, gy + 5)) + return grid, (gx, gy) + + +# ---------------------------------------------------------------- app chrome +def chrome(title, status, mode="model", empty_doc=False): + tabs = ["Prepare", "Preview", "Design"] + g = [f'', + f'' + f'' + f'', + f'', + f''] + x = 24 + for t in tabs: + on = (t == "Design") + g.append(f'{t}') + if on: + g.append(f'') + x += len(t) * 9 + 34 + # toolbar + g.append(f'' + f'') + fams = (["sketch", "extrude", "hole", "fillet", "pattern", "boolean", "plane", "move", "measure"] + if mode == "model" else + ["sk_line", "sk_rect", "sk_circle", "sk_arc", "sk_slot", "sk_spline", "sk_dim", "sk_trim", "sk_lock"]) + for i, gl in enumerate(fams): + cx = 40 + i * 46 + g.append(f'') + g.append(glyph(gl, cx, 68, C["muted"], 0.62, 1.7)) + # left rail + g.append(f'' + f'') + g.append(f'FEATURES') + # A "fresh document" mockup that shows six existing features is a lie, and it is the one + # state the group will look at hardest — it is the first-run picture (B5). + tree = ([] if empty_doc else + [("Sketch1", 0), ("Extrude1", 0), ("Hole1", 1), ("Fillet1", 1), ("Sketch2", 0), ("Extrude2", 0)]) + if empty_doc: + g.append(f'nothing yet') + for i, (n, ind) in enumerate(tree): + yy = 150 + i * 26 + g.append(glyph("sketch" if n.startswith("Sketch") else "extrude", 30 + ind * 14, yy - 4, C["dim"], 0.42, 1.8)) + g.append(f'{n}') + # status bar + g.append(f'' + f'{status}') + g.append(f'{title}') + return "".join(g) + + +# ---------------------------------------------------------------- the ring +def ring(cx, cy, items, capacity, sel_name, radius=134): + """items: dict angle_index -> (glyph, label, key, count) or None for an empty slot.""" + # The scrim is deliberately faint. §4.1: the offer "never blocks the view of what it acts + # on" — at 0.55 the disc swallowed the very face the ring was opened on, which is the rule + # failing in its own mockup. + g = [f'', + f'', + f''] + step = 360.0 / capacity + for i in range(capacity): + ang = math.radians(-90 + i * step) + px, py = cx + radius * math.cos(ang), cy + radius * math.sin(ang) + it = items.get(i) + if not it: + g.append(f'') + continue + gl, label, key, count = it + g.append(f'') + g.append(glyph(gl, px, py - 2, C["text"], 0.92, 1.7)) + g.append(f'{label}') + if key: + kw = 13 + len(key) * 6.6 + g.append(f'' + f'{key}') + if count and count > 1: + g.append(f'' + f'{count}') + # The selection name sits on the LOWER rim of the centre hole, not above it: above, it + # landed on top of the north slot's shortcut chip and hid it. Below, the pick stays + # visible through the hole and the pill is clear of the south chip at cy+104. + w = 13 + len(sel_name) * 6.9 + g.append(f'' + f'{sel_name}') + return "".join(g) + + +# ---------------------------------------------------------------- enumeration +def load(): + with open(ATLAS, encoding="utf-8") as f: + return json.load(f) + + +def validate(A): + slot_ids = {s["id"] for s in A["slots"]} + sel_ids = {s["id"] for s in A["selections"]} + errs = [] + seen = {} + for v in A["verbs"]: + if v["slot"] not in slot_ids: + errs.append(f'{v["id"]}: unknown slot {v["slot"]!r}') + for a in v["accepts"]: + if a not in sel_ids: + errs.append(f'{v["id"]}: accepts unknown selection {a!r}') + if v["id"] in seen: + errs.append(f'{v["id"]}: duplicate verb id') + seen[v["id"]] = v["slot"] + if errs: + sys.exit("tool_atlas.json is inconsistent:\n " + "\n ".join(errs)) + return slot_ids, sel_ids + + +def eligible(A, sel_id, doc): + sel = next(s for s in A["selections"] if s["id"] == sel_id) + out = [] + for v in A["verbs"]: + if v.get("mode", "model") != sel["mode"]: + continue + if sel_id not in v["accepts"]: + continue + n = v.get("needs") or {} + if n.get("bodies", 0) > doc["bodies"]: + continue + if n.get("sketches", 0) > doc["sketches"]: + continue + if n.get("sheet") and not doc["sheet"]: + continue + out.append(v) + return out + + +def by_slot(A, verbs): + order = [s["id"] for s in A["slots"]] + d = defaultdict(list) + for v in verbs: + d[v["slot"]].append(v) + return {k: d[k] for k in order if d[k]} + + +def ring_items(A, grouped): + order = [s["id"] for s in A["slots"]] + slot_label = {s["id"]: s["label"] for s in A["slots"]} + items = {} + for i, sid in enumerate(order): + vs = grouped.get(sid) + if not vs: + continue + if len(vs) == 1: + v = vs[0] + items[i] = (VERB_GLYPH.get(v["id"], "fam_" + sid), v["name"], v.get("key"), 1) + else: + items[i] = ("fam_" + sid, slot_label[sid], None, len(vs)) + return items + + +# ---------------------------------------------------------------- rendering +def menu(cx, cy, header, rows, submenu=None, sub_at=None): + """Vertical list form of the offer. rows: (glyph, name, key, count, enabled, reason). + + The invariant is unchanged — a verb has one permanent row index, and rows that do not + apply are DISABLED IN PLACE, never removed. What changes against the ring is what an + unavailable slot can say: an empty circle says nothing, a greyed row says its own name + and the reason it is grey, in the words the product already ships. + """ + RW, RH, HD = 324, 34, 38 + # The reason line is the whole point of a disabled row, so it must FIT: at 10px italic a + # glyph is ~4.9px, and anything past the box edge is a promise the layout does not keep. + fit = int((RW - 62) / 4.9) + x, y = cx + 26, cy - 30 + h = HD + len(rows) * RH + 10 + if y + h > H - 44: + y = max(100, H - 44 - h) + g = [f'', + f'', + f'{header.upper()}', + f''] + # a leader from the pick point to the menu, so the list is visibly ABOUT that geometry + g.insert(0, f'') + g.insert(0, f'') + for i, (gl, name, key, count, on, reason) in enumerate(rows): + ry = y + HD + i * RH + op = "1" if on else "0.34" + if on and i == 0: + g.append(f'') + g.append(f'') + g.append(glyph(gl, x + 26, ry + RH / 2, C["text"], 0.72, 1.7)) + g.append(f'{name}') + if key: + kw = 13 + len(key) * 6.6 + g.append(f'' + f'{key}') + elif count and count > 1: + g.append(f'') + g.append(f'{count}') + g.append('') + if not on and reason: + r = reason if len(reason) <= fit else reason[:fit - 1].rstrip(" ,—-") + "…" + g.append(f'{r}') + if submenu: + sy = y + HD + (sub_at or 0) * RH - 6 + sh = 12 + len(submenu) * RH + sx = x + RW + 8 + g.append(f'') + g.append(f'') + for i, (gl, name, key, _c, on, _r) in enumerate(submenu): + ry = sy + 6 + i * RH + g.append(f'') + g.append(glyph(gl, sx + 24, ry + RH / 2, C["text"], 0.72, 1.7)) + g.append(f'{name}') + if key: + kw = 13 + len(key) * 6.6 + g.append(f'' + f'{key}') + g.append('') + return "".join(g) + + +OVERFLOWS = [] # (selection, doc state, family, verbs that did not fit the sub-ring) + + +def svg_doc(inner): + return (f'{inner}') + + +def render_list_state(A, sel, doc, expand=None): + """The vertical-list form: every family row always present, in the same order, with the + ones that do not apply disabled and carrying their reason.""" + verbs = eligible(A, sel["id"], doc) + grouped = by_slot(A, verbs) + fresh = doc["bodies"] == 0 and doc["sketches"] == 0 + shape = "origin" if (fresh and sel["shape"] == "empty") else sel["shape"] + art, (ax, ay) = scene(shape, 620, 360) + rows, sub, sub_at = [], None, None + for idx, s in enumerate(A["slots"]): + vs = grouped.get(s["id"], []) + if len(vs) == 1: + v = vs[0] + rows.append((VERB_GLYPH.get(v["id"], "fam_" + s["id"]), v["name"], v.get("key"), 1, True, None)) + elif len(vs) > 1: + rows.append(("fam_" + s["id"], s["label"], None, len(vs), True, None)) + if expand == s["id"]: + sub_at = idx + sub = [(VERB_GLYPH.get(v["id"], "fam_" + s["id"]), v["name"], v.get("key"), 1, True, None) + for v in vs] + else: + # Disabled in place, with the product's own refusal text — the thing an empty + # slot in the ring could never say. + cands = [v for v in A["verbs"] + if v["slot"] == s["id"] and v.get("mode", "model") == sel["mode"]] + why = next((v["refusal"] for v in cands if v.get("refusal")), None) + rows.append(("fam_" + s["id"], s["label"], None, 0, False, why)) + status = ("Right-click the geometry to see what you can do with it" if not expand + else f'{sel["name"]} — pick one') + inner = chrome(f'{doc["name"]} · vertical list', status, sel["mode"], empty_doc=fresh) + inner += art + inner += menu(ax, ay, sel["name"], rows, sub, sub_at) + return svg_doc(inner) + + +def render_state(A, sel, doc, capacity=8, secondary=None): + verbs = eligible(A, sel["id"], doc) + grouped = by_slot(A, verbs) + anchor_x, anchor_y = 810, 400 + fresh = doc["bodies"] == 0 and doc["sketches"] == 0 + shape = "origin" if (fresh and sel["shape"] == "empty") else sel["shape"] + art, (ax, ay) = scene(shape, anchor_x - 90, anchor_y - 40) + if secondary: + vs = grouped[secondary] + # A sub-ring ANCHORS ON ITS PARENT'S DIRECTION. Fanning from north instead put Extrude + # at N — Create's address in the primary map — so the second level contradicted the + # first. Anchored, an address is two consistent strokes: Add material is NE, and the + # first verb of Add material is NE again. + base = [s["id"] for s in A["slots"]].index(secondary) + overflow = [] + if len(vs) > capacity: + # Do NOT wrap: (base+i) % capacity would quietly put verb 9 on top of verb 1, which + # is the invariant failing silently — the one outcome worse than an ugly ring. Show + # what fits, mark the rest, and report it. + overflow = vs[capacity - 1:] + vs = vs[:capacity - 1] + items = {(base + i) % capacity: + (VERB_GLYPH.get(v["id"], "fam_" + secondary), v["name"], v.get("key"), 1) + for i, v in enumerate(vs)} + if overflow: + items[(base + capacity - 1) % capacity] = ("fam_" + secondary, "More", None, len(overflow)) + OVERFLOWS.append((sel["name"], doc["id"], secondary, [v["name"] for v in overflow])) + label = next(s["label"] for s in A["slots"] if s["id"] == secondary) + sel_name = f'{sel["name"]} · {label}' + status = f'{label} — pick one' + else: + items = ring_items(A, grouped) + sel_name = sel["name"] + status = ("Click a face or a reference plane, then a tool" + if sel["id"] == "none" else f'{sel["name"]} selected — pick what to do with it') + inner = chrome(f'{doc["name"]} · {capacity} slots', status, sel["mode"], empty_doc=fresh) + inner += art + inner += ring(ax, ay, items, capacity, sel_name) + return svg_doc(inner), verbs, grouped + + +def main(): + A = load() + validate(A) + docs = {d["id"]: d for d in A["doc_states"]} + outdir = HERE + rows, files = [], [] + total_primary = total_secondary = 0 + fill_sum = fill_n = 0 + + for d in A["doc_states"]: + for sel in A["selections"]: + svg, verbs, grouped = render_state(A, sel, d) + name = f'{d["id"]}__{sel["id"]}.svg' + with open(os.path.join(outdir, name), "w", encoding="utf-8") as f: + f.write(svg) + files.append((name, f'{sel["name"]} — {d["name"]}')) + total_primary += 1 + fill_sum += len(grouped) + fill_n += 1 + secondaries = [] + for sid, vs in grouped.items(): + if len(vs) > 1: + s2, _, _ = render_state(A, sel, d, secondary=sid) + n2 = f'{d["id"]}__{sel["id"]}__{sid}.svg' + with open(os.path.join(outdir, n2), "w", encoding="utf-8") as f: + f.write(s2) + label = next(s["label"] for s in A["slots"] if s["id"] == sid) + files.append((n2, f'{sel["name"]} — {label} ring')) + secondaries.append(sid) + total_secondary += 1 + rows.append({ + "doc": d["name"], "sel": sel["name"], "mode": sel["mode"], + "verbs": len(verbs), "slots": len(grouped), + "second": len(secondaries), + "detail": {sid: [v["name"] for v in vs] for sid, vs in grouped.items()}, + }) + + # Form-factor comparison: the SAME state as a ring and as a vertical list. + for sid in ("face_planar", "edge_str", "body_solid", "sk_line", "none"): + sel = next(s for s in A["selections"] if s["id"] == sid) + d = docs["fresh"] if sid == "none" else docs["rich"] + with open(os.path.join(outdir, f"list__{sid}.svg"), "w", encoding="utf-8") as f: + f.write(render_list_state(A, sel, d)) + for sid, fam in (("face_planar", "add"), ("sk_none", "create")): + sel = next(s for s in A["selections"] if s["id"] == sid) + with open(os.path.join(outdir, f"list__{sid}__{fam}.svg"), "w", encoding="utf-8") as f: + f.write(render_list_state(A, sel, docs["rich"], expand=fam)) + + # comparison sheet: 8 vs 12 slots on the same three selections + for cap in (8, 12): + for sid in ("face_planar", "edge_str", "sk_line"): + sel = next(s for s in A["selections"] if s["id"] == sid) + svg, _, _ = render_state(A, sel, docs["rich"], capacity=cap) + n = f'cmp_{cap}__{sid}.svg' + with open(os.path.join(outdir, n), "w", encoding="utf-8") as f: + f.write(svg) + + write_atlas(A, rows, files, total_primary, total_secondary, + fill_sum / max(1, fill_n)) + print(f"{total_primary} primary + {total_secondary} secondary = " + f"{total_primary+total_secondary} offer states rendered") + print(f"mean populated slots per primary ring: {fill_sum/max(1,fill_n):.2f} of 8") + if OVERFLOWS: + seen = {(f, tuple(v)) for _, _, f, v in OVERFLOWS} + print(f"OVERFLOW: {len(OVERFLOWS)} sub-rings did not fit 8 slots — " + f"{len(seen)} distinct case(s):") + for f, v in sorted(seen): + print(f" {f}: {', '.join(v)} pushed behind 'More'") + + +def write_atlas(A, rows, files, n_prim, n_sec, mean_fill): + gui_missing = [v["name"] for v in A["verbs"] if not v.get("gui", True)] + esc = lambda s: (s.replace("&", "&").replace("<", "<").replace(">", ">")) + cards = "".join( + f'
{esc(t)}
{esc(t)}
' + for n, t in files) + cmp8 = "".join(f'
' + f'
8 slots — {s}
' + for s in ("face_planar", "edge_str", "sk_line")) + cmp12 = "".join(f'
' + f'
12 slots — {s}
' + for s in ("face_planar", "edge_str", "sk_line")) + if OVERFLOWS: + cases = sorted({(f, tuple(v)) for _, _, f, v in OVERFLOWS}) + over_html = ("Measured, not predicted: " + "; ".join( + f'the {esc(f)} sub-ring needs {8 + len(v)} addresses, so ' + f'{esc(", ".join(v))} {"is" if len(v) == 1 else "are"} pushed behind a “More” slot' + for f, v in cases) + + ". This is the decision the ring size actually turns on — either a verb moves to " + "another family, or the tail goes to a third level, or the ring is not eight. " + "It affects sketch mode only; every model-mode family fits.") + else: + over_html = "No sub-ring exceeds the ring capacity. Eight slots is sufficient everywhere." + trs = "".join( + f'{esc(r["sel"])}{esc(r["doc"])}{r["verbs"]}' + f'{r["slots"]}/8{r["second"]}' + f'{esc(" · ".join(k + ": " + ", ".join(v) for k, v in r["detail"].items()))}' + for r in rows) + html = f"""Design tab — offer atlas + +
+

The offer — atlas of every state

+

Every state of the object-driven tool offer, generated from + docs/ux/tool_atlas.json. The invariant under test: a verb has one address, that + address is the same in every selection where it appears, and slots that do not apply are drawn + empty rather than compacted.

+
+
{len(A["verbs"])}verbs mapped
+
{len(A["selections"])}selection kinds
+
{n_prim}primary rings
+
{n_sec}secondary rings
+
{n_prim+n_sec}states total
+
{mean_fill:.1f}/8mean slots filled
+
{len(gui_missing)}verbs with no GUI yet
+
+
+
+

Decision 0 — ring or vertical list

+

The live question. Both forms carry the SAME map and the same invariant — fixed order, never + re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the + offer. What the list buys: a disabled row can state its own reason in the words the product + already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an + overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable + by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick + gesture, and travel to the last row is longer than to the nearest direction. Pairs below — + list first, the same state as a ring second.

+ +

Decision 1 — ring capacity

+

The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is + the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses + for more verbs at 30° spacing and positions that stop being nameable.

+
{cmp8}
+
{cmp12}
+ +

Decision 2 — one map or one per mode

+

These renders use ONE shared map: sketch verbs occupy the same eight families as model verbs, + so dress-up is south-east whether you picked a solid edge or a sketch line. Compare the sketch-mode + states below against the model-mode ones — if a verb that exists in both modes ever appears at two + addresses, the shared map has failed and the split map is the answer.

+ +

The matrix — every selection, every document state

+
+ + {trs} +
SelectionDocumentVerbsSlots2nd ringsWhat lands where
+ +

Overflow — the one place eight slots is not enough

+

{over_html}

+ +

Not in the offer

+

{esc(", ".join(A["chrome_only"]["items"]))} — these act on the document, not on a selection, + so they stay in chrome. Verbs with kernel support but no GUI today, which still hold an address: + {esc(", ".join(gui_missing))}.

+ +

All states

+
{cards}
+
+""" + with open(os.path.join(UX, "offer_atlas.html"), "w", encoding="utf-8") as f: + f.write(html) + + # A second, SELF-CONTAINED copy for review outside the repo: the artifact host blocks + # every external request, so relative would silently show nothing. Curated + # rather than all 113 states — the whole set inlined is 1.5 MB, which is a poor thing to + # send to the low-end machine this design is meant to serve (6.1). + def inline(name): + p = os.path.join(HERE, name) + if not os.path.exists(p): + return "" + s = open(p, encoding="utf-8").read() + return s.replace("{inline(n)}
{esc(t)}
' + for n, t in picks if inline(n)) + head, _, tail = html.partition('

All states

') + # The comparison grids in the head use , which resolves to nothing + # once the page is served on its own. Inline those too rather than shipping empty frames. + head = re.sub(r']*>', + lambda m: inline(m.group(1)), head) + inline_html = head + '

The states

\n
' + figs + '
\n' + with open(os.path.join(UX, "offer_atlas_inline.html"), "w", encoding="utf-8") as f: + f.write(inline_html) + + +if __name__ == "__main__": + main() diff --git a/docs/CAD/ux/mockups/gen_offer_table.py b/docs/CAD/ux/mockups/gen_offer_table.py new file mode 100644 index 0000000000..9744f9288b --- /dev/null +++ b/docs/CAD/ux/mockups/gen_offer_table.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Emit the C++ offer table from docs/ux/tool_atlas.json. + + python3 docs/ux/mockups/gen_offer_table.py + +The map exists ONCE. The mockups and the shipping menu read the same rows in the same +order from the same file, so a drawing and the product cannot drift apart — which is the +only way row constancy (charter 4.1) survives contact with a codebase. + +Output: src/slic3r/GUI/CAD/DesignOffer.hpp, checked in and never hand-edited. +""" + +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +UX = os.path.dirname(HERE) +# One dirname more than you would expect: this generator lives at docs/CAD/ux/mockups/, +# not docs/ux/mockups/, since the design docs moved into the CAD subfolder (bbd1989e1e). +# With the old count REPO resolved to docs/, so OUT pointed at docs/src/.../DesignOffer.hpp, +# which does not exist -- and --check then diffed the real generated table against an empty +# file and reported the whole 189-line header as a difference. +REPO = os.path.dirname(os.path.dirname(os.path.dirname(UX))) +ATLAS = os.path.join(UX, "tool_atlas.json") +OUT = os.path.join(REPO, "src", "slic3r", "GUI", "CAD", "DesignOffer.hpp") + +# selection id -> C++ enumerator +ENUM = { + "none": "None", "face_planar": "FacePlanar", "face_cyl": "FaceCyl", + "face_other": "FaceOther", "edge_str": "EdgeStr", "edge_circ": "EdgeCirc", + "vertex": "Vertex", "body_solid": "BodySolid", "body_sheet": "BodySheet", + "bodies_2": "Bodies2", "datum_plane": "DatumPlane", "datum_axis": "DatumAxis", + "coordsys": "CoordSys", "art": "Art", "sk_loop": "SkLoop", "sk_none": "SkNone", + "sk_line": "SkLine", "sk_arc": "SkArc", "sk_point": "SkPoint", "sk_2ent": "Sk2Ent", +} + + +def cstr(s): + if s is None: + return "nullptr" + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def main(): + A = json.load(open(ATLAS, encoding="utf-8")) + sels = [s["id"] for s in A["selections"]] + assert all(s in ENUM for s in sels), [s for s in sels if s not in ENUM] + slots = [s["id"] for s in A["slots"]] + + lines = [ + "// GENERATED FILE — DO NOT EDIT.", + "// Source: docs/ux/tool_atlas.json Generator: docs/ux/mockups/gen_offer_table.py", + "//", + "// The object-driven tool offer (charter 4.1): every verb has ONE row index, that index", + "// is the same in every selection it appears in, and verbs that do not apply are shown", + "// disabled in place with their reason rather than removed. Row order was ratified", + "// 2026-07-31; changing an index is a breaking change to every user's muscle memory.", + "#ifndef slic3r_GUI_DesignOffer_hpp_", + "#define slic3r_GUI_DesignOffer_hpp_", + "", + "#include ", + "", + "namespace Slic3r { namespace GUI {", + "", + "// What the viewport has selected. Ordered as in tool_atlas.json; the bitmask in", + "// OfferVerb::accepts indexes these.", + "enum class OfferSel : int {", + ] + for i, s in enumerate(sels): + lines.append(f" {ENUM[s]} = {i},") + lines += [ + f" Count = {len(sels)}", + "};", + "", + "inline uint32_t offer_bit(OfferSel s) { return 1u << int(s); }", + "", + "// One row of the offer. `action` routes to the code that already implements the verb:", + '// "key:S+E" -> m_keys_feature[SHIFT(\'E\')]', + '// "key:L" -> m_keys_sketch[\'L\']', + '// "fly:material#4" -> row 4 of the "material" feature flyout', + '// "btn:delete" -> a standalone toolbar button', + "// nullptr -> kernel support exists, no GUI path yet (row shows disabled)", + "struct OfferVerb {", + " const char* id;", + " const char* name; // drawing-office word (L10); translated at use with wxGetTranslation", + " int row; // 0..7, the ratified index — NEVER reorder", + " const char* key; // shortcut shown in the row, or nullptr", + " const char* action;", + " const char* refusal; // why this row is greyed, in the product's own words", + " uint32_t accepts; // bitmask over OfferSel", + " int need_bodies;", + " int need_sketches;", + " bool need_sheet;", + " bool sketch_mode; // belongs to the sketch-mode vocabulary, not the model one", + " // Second level INSIDE a row, for tools that come in variants: \"Rectangle\" holds corner,", + " // centre, oblique and rounded. nullptr = sits directly in the row. Keeps the row's own", + " // address fixed (L4.1) while the variants hang one level below it, mirroring the toolbar's", + " // grouping instead of flattening 19 create tools into one wall.", + " const char* family;", + " const char* icon; // resources/images name, or nullptr — the offer draws it beside the row", + " const char* hint; // what the verb does / what to click; shown on hover", + "};", + "", + "// Row labels, in ratified order.", + "static const char* const kOfferRowNames[] = {", + ] + for s in A["slots"]: + lines.append(f' "{s["label"]}",') + lines += [ + "};", + f"static const int kOfferRowCount = {len(slots)};", + "", + "static const OfferVerb kOfferVerbs[] = {", + ] + # A verb the user can PICK must say what it does. Fail loudly rather than ship a + # bare name — the offer is now the only door to these tools. + blind = [v["id"] for v in A["verbs"] if v.get("action") and not v.get("hint")] + assert not blind, f"wired verbs with no hint: {blind}" + for v in A["verbs"]: + # A verb may carry a NOTE: the reason it exists, emitted as a C++ comment above its row. + # Without somewhere to put it, a rationale written into the generated header is deleted by + # the next regeneration — which is how the model-mode "Constrain sketch" row came to exist + # in the header and not in the atlas at all (ziam). The map exists once; so does + # the explanation. + for ln in ([v["note"]] if isinstance(v.get("note"), str) else v.get("note") or []): + lines.append(f" // {ln}") + mask = 0 + for a in v["accepts"]: + mask |= 1 << sels.index(a) + n = v.get("needs") or {} + lines.append( + " {%s, %s, %d, %s, %s, %s, 0x%08xu, %d, %d, %s, %s, %s, %s, %s}," % ( + cstr(v["id"]), cstr(v["name"]), slots.index(v["slot"]), + cstr(v.get("key")), cstr(v.get("action")), cstr(v.get("refusal")), + mask, n.get("bodies", 0), n.get("sketches", 0), + "true" if n.get("sheet") else "false", + "true" if v.get("mode") == "sketch" else "false", + cstr(v.get("family")), cstr(v.get("icon")), cstr(v.get("hint")))) + lines += [ + "};", + f"static const int kOfferVerbCount = {len(A['verbs'])};", + "", + "}} // namespace Slic3r::GUI", + "", + "#endif // slic3r_GUI_DesignOffer_hpp_", + "", + ] + text = "\n".join(lines) + # --check: prove the checked-in header IS what this generator produces, and change nothing. + # The header calls itself GENERATED and was hand-edited anyway; a claim like that is only + # worth having if something enforces it, so run-all-checks.sh runs this on every gate. + if "--check" in sys.argv: + have = open(OUT, encoding="utf-8").read() if os.path.exists(OUT) else "" + if have == text: + print(f"{os.path.relpath(OUT, REPO)} matches tool_atlas.json") + return 0 + import difflib + d = list(difflib.unified_diff(have.splitlines(), text.splitlines(), + "checked-in", "generated", lineterm="", n=1)) + print(f"{os.path.relpath(OUT, REPO)} DIFFERS from tool_atlas.json:") + print("\n".join(d[:60])) + return 1 + with open(OUT, "w", encoding="utf-8") as f: + f.write(text) + wired = sum(1 for v in A["verbs"] if v.get("action")) + print(f"wrote {os.path.relpath(OUT, REPO)}: {len(A['verbs'])} verbs, " + f"{len(slots)} rows, {wired} wired to existing actions") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/CAD/ux/mockups/list__body_solid.svg b/docs/CAD/ux/mockups/list__body_solid.svg new file mode 100644 index 0000000000..ec3b3a84f4 --- /dev/null +++ b/docs/CAD/ux/mockups/list__body_solid.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listSOLID BODYCreateClick a face or a reference plane in the viewport, t…Add materialCreate a sketch, or pick a solid face, firstRemove3Dress-up2Repeat3MoveShift+YReference3Modify3 \ No newline at end of file diff --git a/docs/CAD/ux/mockups/list__edge_str.svg b/docs/CAD/ux/mockups/list__edge_str.svg new file mode 100644 index 0000000000..3942000d25 --- /dev/null +++ b/docs/CAD/ux/mockups/list__edge_str.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listSTRAIGHT EDGECreateClick a face or a reference plane in the viewport, t…Add materialCreate a sketch, or pick a solid face, firstRemovePick a face or a plane to drill intoDress-up2Pattern on CurveTransformTransform needs a body — add or import one firstReference3ModifyDelete Face needs a body — add or import one first \ No newline at end of file diff --git a/docs/CAD/ux/mockups/list__face_planar.svg b/docs/CAD/ux/mockups/list__face_planar.svg new file mode 100644 index 0000000000..5ff6892902 --- /dev/null +++ b/docs/CAD/ux/mockups/list__face_planar.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listPLANAR FACESketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2 \ No newline at end of file diff --git a/docs/CAD/ux/mockups/list__face_planar__add.svg b/docs/CAD/ux/mockups/list__face_planar__add.svg new file mode 100644 index 0000000000..fb80f2a5c7 --- /dev/null +++ b/docs/CAD/ux/mockups/list__face_planar__add.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face — pick oneWorking document · vertical listPLANAR FACESketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2ExtrudeShift+EThicken \ No newline at end of file diff --git a/docs/CAD/ux/mockups/list__none.svg b/docs/CAD/ux/mockups/list__none.svg new file mode 100644 index 0000000000..1e68606383 --- /dev/null +++ b/docs/CAD/ux/mockups/list__none.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESnothing yetRight-click the geometry to see what you can do with itFresh document · vertical listNOTHING SELECTEDSketchShift+SAdd materialCreate a sketch, or pick a solid face, firstRemovePick a face or a plane to drill intoDress-upPick an edge to roundRepeatCreate a solid body to pattern firstTransformTransform needs a body — add or import one firstReference4ModifyDelete Face needs a body — add or import one first \ No newline at end of file diff --git a/docs/CAD/ux/mockups/list__sk_line.svg b/docs/CAD/ux/mockups/list__sk_line.svg new file mode 100644 index 0000000000..1b1c2e9cd2 --- /dev/null +++ b/docs/CAD/ux/mockups/list__sk_line.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listSKETCH LINECreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2 \ No newline at end of file diff --git a/docs/CAD/ux/mockups/list__sk_none__create.svg b/docs/CAD/ux/mockups/list__sk_none__create.svg new file mode 100644 index 0000000000..0b41268fa6 --- /dev/null +++ b/docs/CAD/ux/mockups/list__sk_none__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch, nothing picked — pick oneWorking document · vertical listSKETCH, NOTHING PICKEDCreate9Add materialRemoveDress-upRepeatTransformReference2ModifyLineLRectangleRCircleCArcASlotSEllipseESplineBPolygonGPointP \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__art.svg b/docs/CAD/ux/mockups/rich__art.svg new file mode 100644 index 0000000000..e835ed1ce0 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__art.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Text / imported art selected — pick what to do with itWorking document · 8 slotsABCPatternShift+NMoveShift+YModify2Text / imported art \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__art__modify.svg b/docs/CAD/ux/mockups/rich__art__modify.svg new file mode 100644 index 0000000000..4c8cdef2b5 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__art__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsABCDeleteDelEditText / imported art · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__bodies_2.svg b/docs/CAD/ux/mockups/rich__bodies_2.svg new file mode 100644 index 0000000000..a977b7f1de --- /dev/null +++ b/docs/CAD/ux/mockups/rich__bodies_2.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Two bodies selected — pick what to do with itWorking document · 8 slotsCombineShift+BMateReference2DeleteDelTwo bodies \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__bodies_2__reference.svg b/docs/CAD/ux/mockups/rich__bodies_2__reference.svg new file mode 100644 index 0000000000..1bfbac0db0 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__bodies_2__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsMeasureInterferenceTwo bodies · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_sheet.svg b/docs/CAD/ux/mockups/rich__body_sheet.svg new file mode 100644 index 0000000000..a1cc27808d --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_sheet.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sheet body selected — pick what to do with itWorking document · 8 slotsThicken SurfaceSurface OffsetMoveShift+YMeasureModify3Sheet body \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_sheet__modify.svg b/docs/CAD/ux/mockups/rich__body_sheet__modify.svg new file mode 100644 index 0000000000..7ce8dbee54 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_sheet__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsColourDeleteDelEditSheet body · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_solid.svg b/docs/CAD/ux/mockups/rich__body_solid.svg new file mode 100644 index 0000000000..e958532347 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_solid.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Solid body selected — pick what to do with itWorking document · 8 slotsRemove3Dress-up2Repeat3MoveShift+YReference3Modify3Solid body \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_solid__dressup.svg b/docs/CAD/ux/mockups/rich__body_solid__dressup.svg new file mode 100644 index 0000000000..7a9ceed334 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_solid__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Dress-up — pick oneWorking document · 8 slotsFilletShift+FChamferSolid body · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_solid__modify.svg b/docs/CAD/ux/mockups/rich__body_solid__modify.svg new file mode 100644 index 0000000000..7a6340d40f --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_solid__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsColourDeleteDelEditSolid body · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_solid__reference.svg b/docs/CAD/ux/mockups/rich__body_solid__reference.svg new file mode 100644 index 0000000000..27639836c6 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_solid__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsMassProjectMeasureSolid body · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_solid__remove.svg b/docs/CAD/ux/mockups/rich__body_solid__remove.svg new file mode 100644 index 0000000000..edbddb7115 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_solid__remove.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Remove — pick oneWorking document · 8 slotsShellShift+KCutShift+XSplitSolid body · Remove \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__body_solid__repeat.svg b/docs/CAD/ux/mockups/rich__body_solid__repeat.svg new file mode 100644 index 0000000000..74dd67c7de --- /dev/null +++ b/docs/CAD/ux/mockups/rich__body_solid__repeat.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Repeat — pick oneWorking document · 8 slotsPatternShift+NMirrorShift+ZPattern on CurveSolid body · Repeat \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__coordsys.svg b/docs/CAD/ux/mockups/rich__coordsys.svg new file mode 100644 index 0000000000..8cef106106 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__coordsys.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Coordinate system selected — pick what to do with itWorking document · 8 slotsMateModify2Coordinate system \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__coordsys__modify.svg b/docs/CAD/ux/mockups/rich__coordsys__modify.svg new file mode 100644 index 0000000000..92eb28d3dc --- /dev/null +++ b/docs/CAD/ux/mockups/rich__coordsys__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelEditCoordinate system · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__datum_axis.svg b/docs/CAD/ux/mockups/rich__datum_axis.svg new file mode 100644 index 0000000000..c9da648ab7 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__datum_axis.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Datum axis selected — pick what to do with itWorking document · 8 slotsModify2Datum axis \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__datum_axis__modify.svg b/docs/CAD/ux/mockups/rich__datum_axis__modify.svg new file mode 100644 index 0000000000..fd0ae80c52 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__datum_axis__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelEditDatum axis · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__datum_plane.svg b/docs/CAD/ux/mockups/rich__datum_plane.svg new file mode 100644 index 0000000000..8ef88bd9a3 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__datum_plane.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Datum plane selected — pick what to do with itWorking document · 8 slotsSketchShift+SRemove2MirrorShift+ZReference3Modify2Datum plane \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__datum_plane__modify.svg b/docs/CAD/ux/mockups/rich__datum_plane__modify.svg new file mode 100644 index 0000000000..39e5cad41e --- /dev/null +++ b/docs/CAD/ux/mockups/rich__datum_plane__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelEditDatum plane · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__datum_plane__reference.svg b/docs/CAD/ux/mockups/rich__datum_plane__reference.svg new file mode 100644 index 0000000000..bae3119b6b --- /dev/null +++ b/docs/CAD/ux/mockups/rich__datum_plane__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsProjectPlaneShift+PHelixDatum plane · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__datum_plane__remove.svg b/docs/CAD/ux/mockups/rich__datum_plane__remove.svg new file mode 100644 index 0000000000..c98b33ad31 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__datum_plane__remove.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Remove — pick oneWorking document · 8 slotsHoleShift+HCutShift+XDatum plane · Remove \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__edge_circ.svg b/docs/CAD/ux/mockups/rich__edge_circ.svg new file mode 100644 index 0000000000..1d07291623 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__edge_circ.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Circular edge selected — pick what to do with itWorking document · 8 slotsThreadShift+TDress-up2MeasureCircular edge \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__edge_circ__dressup.svg b/docs/CAD/ux/mockups/rich__edge_circ__dressup.svg new file mode 100644 index 0000000000..b315fc2e22 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__edge_circ__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Dress-up — pick oneWorking document · 8 slotsFilletShift+FChamferCircular edge · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__edge_str.svg b/docs/CAD/ux/mockups/rich__edge_str.svg new file mode 100644 index 0000000000..2c41ce74fc --- /dev/null +++ b/docs/CAD/ux/mockups/rich__edge_str.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Straight edge selected — pick what to do with itWorking document · 8 slotsDress-up2Pattern on CurveReference3Straight edge \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__edge_str__dressup.svg b/docs/CAD/ux/mockups/rich__edge_str__dressup.svg new file mode 100644 index 0000000000..8e674c044f --- /dev/null +++ b/docs/CAD/ux/mockups/rich__edge_str__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Dress-up — pick oneWorking document · 8 slotsFilletShift+FChamferStraight edge · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__edge_str__reference.svg b/docs/CAD/ux/mockups/rich__edge_str__reference.svg new file mode 100644 index 0000000000..bc99ca6a13 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__edge_str__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsMeasurePlaneShift+PAxisShift+AStraight edge · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_cyl.svg b/docs/CAD/ux/mockups/rich__face_cyl.svg new file mode 100644 index 0000000000..3c2860373f --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_cyl.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Cylindrical face selected — pick what to do with itWorking document · 8 slotsThreadShift+TReference3Modify2Cylindrical face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_cyl__modify.svg b/docs/CAD/ux/mockups/rich__face_cyl__modify.svg new file mode 100644 index 0000000000..9f573682ad --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_cyl__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDelete FaceEditCylindrical face · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_cyl__reference.svg b/docs/CAD/ux/mockups/rich__face_cyl__reference.svg new file mode 100644 index 0000000000..a62be81de8 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_cyl__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsMeasureAxisShift+AHelixCylindrical face · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_other.svg b/docs/CAD/ux/mockups/rich__face_other.svg new file mode 100644 index 0000000000..e5cff4eee5 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_other.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Curved face selected — pick what to do with itWorking document · 8 slotsThickenDraftShift+DMeasureModify2Curved face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_other__modify.svg b/docs/CAD/ux/mockups/rich__face_other__modify.svg new file mode 100644 index 0000000000..8b5fcfd63f --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_other__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDelete FaceEditCurved face · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar.svg b/docs/CAD/ux/mockups/rich__face_planar.svg new file mode 100644 index 0000000000..5a58e2fb72 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 8 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar__add.svg b/docs/CAD/ux/mockups/rich__face_planar__add.svg new file mode 100644 index 0000000000..82acc85038 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar__add.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Add material — pick oneWorking document · 8 slotsExtrudeShift+EThickenPlanar face · Add material \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar__dressup.svg b/docs/CAD/ux/mockups/rich__face_planar__dressup.svg new file mode 100644 index 0000000000..8d32b9f212 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Dress-up — pick oneWorking document · 8 slotsFilletShift+FChamferDraftShift+DPlanar face · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar__modify.svg b/docs/CAD/ux/mockups/rich__face_planar__modify.svg new file mode 100644 index 0000000000..bffd218131 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDelete FaceEditPlanar face · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar__reference.svg b/docs/CAD/ux/mockups/rich__face_planar__reference.svg new file mode 100644 index 0000000000..4682f1e7b1 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsCoord SysShift+CProjectMeasurePlaneShift+PAxisShift+APlanar face · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar__remove.svg b/docs/CAD/ux/mockups/rich__face_planar__remove.svg new file mode 100644 index 0000000000..b77a5a0c50 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar__remove.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Remove — pick oneWorking document · 8 slotsHoleShift+HShellShift+KPlanar face · Remove \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__face_planar__transform.svg b/docs/CAD/ux/mockups/rich__face_planar__transform.svg new file mode 100644 index 0000000000..2e27983c8b --- /dev/null +++ b/docs/CAD/ux/mockups/rich__face_planar__transform.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Transform — pick oneWorking document · 8 slotsMateAlign toPlanar face · Transform \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__none.svg b/docs/CAD/ux/mockups/rich__none.svg new file mode 100644 index 0000000000..4e8a9dc640 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__none.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Click a face or a reference plane, then a toolWorking document · 8 slotsSketchShift+SReference4Nothing selected \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__none__reference.svg b/docs/CAD/ux/mockups/rich__none__reference.svg new file mode 100644 index 0000000000..14c47c4b27 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__none__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsCoord SysShift+CHelixPlaneShift+PAxisShift+ANothing selected · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_2ent.svg b/docs/CAD/ux/mockups/rich__sk_2ent.svg new file mode 100644 index 0000000000..738b01ecf2 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_2ent.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Two sketch entities selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Two sketch entities \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_2ent__create.svg b/docs/CAD/ux/mockups/rich__sk_2ent__create.svg new file mode 100644 index 0000000000..6d27ffa34a --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_2ent__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Two sketch entities · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_2ent__dressup.svg b/docs/CAD/ux/mockups/rich__sk_2ent__dressup.svg new file mode 100644 index 0000000000..929520b8d2 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_2ent__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Dress-up — pick oneWorking document · 8 slotsFilletFChamferHTwo sketch entities · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_2ent__modify.svg b/docs/CAD/ux/mockups/rich__sk_2ent__modify.svg new file mode 100644 index 0000000000..d8be7c2d4f --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_2ent__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelExtendXTwo sketch entities · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_2ent__reference.svg b/docs/CAD/ux/mockups/rich__sk_2ent__reference.svg new file mode 100644 index 0000000000..9ffc2592a8 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_2ent__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsConstructionQDimensionDConstrainKTwo sketch entities · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_arc.svg b/docs/CAD/ux/mockups/rich__sk_arc.svg new file mode 100644 index 0000000000..d470369f1a --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_arc.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch arc or circle selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTMirrorMMoveReference3Modify2Sketch arc or circle \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_arc__create.svg b/docs/CAD/ux/mockups/rich__sk_arc__create.svg new file mode 100644 index 0000000000..1f901596ec --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_arc__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch arc or circle · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_arc__modify.svg b/docs/CAD/ux/mockups/rich__sk_arc__modify.svg new file mode 100644 index 0000000000..52f9d7d3e5 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_arc__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelExtendXSketch arc or circle · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_arc__reference.svg b/docs/CAD/ux/mockups/rich__sk_arc__reference.svg new file mode 100644 index 0000000000..53e949c184 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_arc__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsConstructionQDimensionDConstrainKSketch arc or circle · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_line.svg b/docs/CAD/ux/mockups/rich__sk_line.svg new file mode 100644 index 0000000000..4ca4000d59 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_line.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_line__create.svg b/docs/CAD/ux/mockups/rich__sk_line__create.svg new file mode 100644 index 0000000000..c37f2d0e45 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_line__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch line · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_line__dressup.svg b/docs/CAD/ux/mockups/rich__sk_line__dressup.svg new file mode 100644 index 0000000000..29e86762d2 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_line__dressup.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Dress-up — pick oneWorking document · 8 slotsFilletFChamferHSketch line · Dress-up \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_line__modify.svg b/docs/CAD/ux/mockups/rich__sk_line__modify.svg new file mode 100644 index 0000000000..b9775b7d94 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_line__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelExtendXSketch line · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_line__reference.svg b/docs/CAD/ux/mockups/rich__sk_line__reference.svg new file mode 100644 index 0000000000..6eb1ef621f --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_line__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsConstructionQDimensionDConstrainKSketch line · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_loop.svg b/docs/CAD/ux/mockups/rich__sk_loop.svg new file mode 100644 index 0000000000..c07d1c09c3 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_loop.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Closed sketch loop selected — pick what to do with itWorking document · 8 slotsAdd material8PatternShift+NModify2Closed sketch loop \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_loop__add.svg b/docs/CAD/ux/mockups/rich__sk_loop__add.svg new file mode 100644 index 0000000000..bf9f429b81 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_loop__add.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Add material — pick oneWorking document · 8 slotsSurface FillExtrudeShift+ERevolveShift+RSweepShift+WLoftShift+LSurface ExtrudeShift+GSurface RevolveSurface LoftClosed sketch loop · Add material \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_loop__modify.svg b/docs/CAD/ux/mockups/rich__sk_loop__modify.svg new file mode 100644 index 0000000000..c466379c9e --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_loop__modify.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Modify — pick oneWorking document · 8 slotsDeleteDelEditClosed sketch loop · Modify \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_none.svg b/docs/CAD/ux/mockups/rich__sk_none.svg new file mode 100644 index 0000000000..ffe49ab9a0 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_none.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch, nothing picked selected — pick what to do with itWorking document · 8 slotsCreate9Reference2Sketch, nothing picked \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_none__create.svg b/docs/CAD/ux/mockups/rich__sk_none__create.svg new file mode 100644 index 0000000000..c9a2bc698d --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_none__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch, nothing picked · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_none__reference.svg b/docs/CAD/ux/mockups/rich__sk_none__reference.svg new file mode 100644 index 0000000000..cc9d465fa4 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_none__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsDimensionDConstructionQSketch, nothing picked · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_point.svg b/docs/CAD/ux/mockups/rich__sk_point.svg new file mode 100644 index 0000000000..f089787bca --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_point.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch point selected — pick what to do with itWorking document · 8 slotsCreate9MoveReference2DeleteDelSketch point \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_point__create.svg b/docs/CAD/ux/mockups/rich__sk_point__create.svg new file mode 100644 index 0000000000..5d2070a6c5 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_point__create.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch point · Create \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__sk_point__reference.svg b/docs/CAD/ux/mockups/rich__sk_point__reference.svg new file mode 100644 index 0000000000..e090382a24 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__sk_point__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsDimensionDConstrainKSketch point · Reference \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__vertex.svg b/docs/CAD/ux/mockups/rich__vertex.svg new file mode 100644 index 0000000000..f37bbd7d47 --- /dev/null +++ b/docs/CAD/ux/mockups/rich__vertex.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Vertex selected — pick what to do with itWorking document · 8 slotsReference4Vertex \ No newline at end of file diff --git a/docs/CAD/ux/mockups/rich__vertex__reference.svg b/docs/CAD/ux/mockups/rich__vertex__reference.svg new file mode 100644 index 0000000000..ceabba032e --- /dev/null +++ b/docs/CAD/ux/mockups/rich__vertex__reference.svg @@ -0,0 +1 @@ +PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsCoord SysShift+CMeasurePlaneShift+PAxisShift+AVertex · Reference \ No newline at end of file diff --git a/docs/CAD/ux/offer_atlas.html b/docs/CAD/ux/offer_atlas.html new file mode 100644 index 0000000000..35c4f5860c --- /dev/null +++ b/docs/CAD/ux/offer_atlas.html @@ -0,0 +1,78 @@ +Design tab — offer atlas + +
+

The offer — atlas of every state

+

Every state of the object-driven tool offer, generated from + docs/ux/tool_atlas.json. The invariant under test: a verb has one address, that + address is the same in every selection where it appears, and slots that do not apply are drawn + empty rather than compacted.

+
+
60verbs mapped
+
20selection kinds
+
40primary rings
+
73secondary rings
+
113states total
+
3.5/8mean slots filled
+
5verbs with no GUI yet
+
+
+
+

Decision 0 — ring or vertical list

+

The live question. Both forms carry the SAME map and the same invariant — fixed order, never + re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the + offer. What the list buys: a disabled row can state its own reason in the words the product + already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an + overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable + by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick + gesture, and travel to the last row is longer than to the nearest direction. Pairs below — + list first, the same state as a ring second.

+ +

Decision 1 — ring capacity

+

The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is + the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses + for more verbs at 30° spacing and positions that stop being nameable.

+
8 slots — face_planar
8 slots — edge_str
8 slots — sk_line
+
12 slots — face_planar
12 slots — edge_str
12 slots — sk_line
+ +

Decision 2 — one map or one per mode

+

These renders use ONE shared map: sketch verbs occupy the same eight families as model verbs, + so dress-up is south-east whether you picked a solid edge or a sketch line. Compare the sketch-mode + states below against the model-mode ones — if a verb that exists in both modes ever appears at two + addresses, the shared map has failed and the split map is the answer.

+ +

The matrix — every selection, every document state

+
+ + +
SelectionDocumentVerbsSlots2nd ringsWhat lands where
Nothing selectedWorking document52/81create: Sketch · reference: Plane, Axis, Coord Sys, Helix
Planar faceWorking document188/86create: Sketch · add: Extrude, Thicken · remove: Hole, Shell · dressup: Fillet, Chamfer, Draft · repeat: Pattern · transform: Mate, Align to · reference: Plane, Axis, Coord Sys, Project, Measure · modify: Edit, Delete Face
Cylindrical faceWorking document63/82remove: Thread · reference: Axis, Helix, Measure · modify: Edit, Delete Face
Curved faceWorking document54/81add: Thicken · dressup: Draft · reference: Measure · modify: Edit, Delete Face
Straight edgeWorking document63/82dressup: Fillet, Chamfer · repeat: Pattern on Curve · reference: Plane, Axis, Measure
Circular edgeWorking document43/81remove: Thread · dressup: Fillet, Chamfer · reference: Measure
VertexWorking document41/81reference: Plane, Axis, Coord Sys, Measure
Solid bodyWorking document156/85remove: Shell, Cut, Split · dressup: Fillet, Chamfer · repeat: Pattern, Mirror, Pattern on Curve · transform: Move · reference: Project, Measure, Mass · modify: Edit, Colour, Delete
Sheet bodyWorking document75/81add: Thicken Surface · dressup: Surface Offset · transform: Move · reference: Measure · modify: Edit, Colour, Delete
Two bodiesWorking document54/81add: Combine · transform: Mate · reference: Measure, Interference · modify: Delete
Datum planeWorking document95/83create: Sketch · remove: Hole, Cut · repeat: Mirror · reference: Plane, Helix, Project · modify: Edit, Delete
Datum axisWorking document21/81modify: Edit, Delete
Coordinate systemWorking document32/81transform: Mate · modify: Edit, Delete
Text / imported artWorking document43/81repeat: Pattern · transform: Move · modify: Edit, Delete
Closed sketch loopWorking document113/82add: Extrude, Revolve, Sweep, Loft, Surface Extrude, Surface Revolve, Surface Loft, Surface Fill · repeat: Pattern · modify: Edit, Delete
Sketch, nothing pickedWorking document112/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · reference: Dimension, Construction
Sketch lineWorking document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch arc or circleWorking document187/83create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch pointWorking document134/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · transform: Move · reference: Dimension, Constrain · modify: Delete
Two sketch entitiesWorking document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Nothing selectedFresh document52/81create: Sketch · reference: Plane, Axis, Coord Sys, Helix
Planar faceFresh document74/81create: Sketch · add: Extrude · reference: Plane, Axis, Coord Sys, Measure · modify: Edit
Cylindrical faceFresh document42/81reference: Axis, Helix, Measure · modify: Edit
Curved faceFresh document22/80reference: Measure · modify: Edit
Straight edgeFresh document31/81reference: Plane, Axis, Measure
Circular edgeFresh document11/80reference: Measure
VertexFresh document41/81reference: Plane, Axis, Coord Sys, Measure
Solid bodyFresh document32/81reference: Measure · modify: Edit, Delete
Sheet bodyFresh document32/81reference: Measure · modify: Edit, Delete
Two bodiesFresh document22/80reference: Measure · modify: Delete
Datum planeFresh document53/82create: Sketch · reference: Plane, Helix · modify: Edit, Delete
Datum axisFresh document21/81modify: Edit, Delete
Coordinate systemFresh document21/81modify: Edit, Delete
Text / imported artFresh document21/81modify: Edit, Delete
Closed sketch loopFresh document72/82add: Extrude, Revolve, Surface Extrude, Surface Revolve, Surface Fill · modify: Edit, Delete
Sketch, nothing pickedFresh document112/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · reference: Dimension, Construction
Sketch lineFresh document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch arc or circleFresh document187/83create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch pointFresh document134/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · transform: Move · reference: Dimension, Constrain · modify: Delete
Two sketch entitiesFresh document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
+ +

Overflow — the one place eight slots is not enough

+

Measured, not predicted: the create sub-ring needs 10 addresses, so Polygon, Point are pushed behind a “More” slot. This is the decision the ring size actually turns on — either a verb moves to another family, or the tail goes to a third level, or the ring is not eight. It affects sketch mode only; every model-mode family fits.

+ +

Not in the offer

+

Import STEP, Import mesh, Text, SVG, Export STEP, Commit to Plate, Undo, Redo, Variables, Section view, Origin planes, World axes — these act on the document, not on a selection, + so they stay in chrome. Verbs with kernel support but no GUI today, which still hold an address: + Split, Pattern on Curve, Align to, Measure, Interference.

+ +

All states

+
Nothing selected — Working document
Nothing selected — Working document
Nothing selected — Reference ring
Nothing selected — Reference ring
Planar face — Working document
Planar face — Working document
Planar face — Add material ring
Planar face — Add material ring
Planar face — Remove ring
Planar face — Remove ring
Planar face — Dress-up ring
Planar face — Dress-up ring
Planar face — Transform ring
Planar face — Transform ring
Planar face — Reference ring
Planar face — Reference ring
Planar face — Modify ring
Planar face — Modify ring
Cylindrical face — Working document
Cylindrical face — Working document
Cylindrical face — Reference ring
Cylindrical face — Reference ring
Cylindrical face — Modify ring
Cylindrical face — Modify ring
Curved face — Working document
Curved face — Working document
Curved face — Modify ring
Curved face — Modify ring
Straight edge — Working document
Straight edge — Working document
Straight edge — Dress-up ring
Straight edge — Dress-up ring
Straight edge — Reference ring
Straight edge — Reference ring
Circular edge — Working document
Circular edge — Working document
Circular edge — Dress-up ring
Circular edge — Dress-up ring
Vertex — Working document
Vertex — Working document
Vertex — Reference ring
Vertex — Reference ring
Solid body — Working document
Solid body — Working document
Solid body — Remove ring
Solid body — Remove ring
Solid body — Dress-up ring
Solid body — Dress-up ring
Solid body — Repeat ring
Solid body — Repeat ring
Solid body — Reference ring
Solid body — Reference ring
Solid body — Modify ring
Solid body — Modify ring
Sheet body — Working document
Sheet body — Working document
Sheet body — Modify ring
Sheet body — Modify ring
Two bodies — Working document
Two bodies — Working document
Two bodies — Reference ring
Two bodies — Reference ring
Datum plane — Working document
Datum plane — Working document
Datum plane — Remove ring
Datum plane — Remove ring
Datum plane — Reference ring
Datum plane — Reference ring
Datum plane — Modify ring
Datum plane — Modify ring
Datum axis — Working document
Datum axis — Working document
Datum axis — Modify ring
Datum axis — Modify ring
Coordinate system — Working document
Coordinate system — Working document
Coordinate system — Modify ring
Coordinate system — Modify ring
Text / imported art — Working document
Text / imported art — Working document
Text / imported art — Modify ring
Text / imported art — Modify ring
Closed sketch loop — Working document
Closed sketch loop — Working document
Closed sketch loop — Add material ring
Closed sketch loop — Add material ring
Closed sketch loop — Modify ring
Closed sketch loop — Modify ring
Sketch, nothing picked — Working document
Sketch, nothing picked — Working document
Sketch, nothing picked — Create ring
Sketch, nothing picked — Create ring
Sketch, nothing picked — Reference ring
Sketch, nothing picked — Reference ring
Sketch line — Working document
Sketch line — Working document
Sketch line — Create ring
Sketch line — Create ring
Sketch line — Dress-up ring
Sketch line — Dress-up ring
Sketch line — Reference ring
Sketch line — Reference ring
Sketch line — Modify ring
Sketch line — Modify ring
Sketch arc or circle — Working document
Sketch arc or circle — Working document
Sketch arc or circle — Create ring
Sketch arc or circle — Create ring
Sketch arc or circle — Reference ring
Sketch arc or circle — Reference ring
Sketch arc or circle — Modify ring
Sketch arc or circle — Modify ring
Sketch point — Working document
Sketch point — Working document
Sketch point — Create ring
Sketch point — Create ring
Sketch point — Reference ring
Sketch point — Reference ring
Two sketch entities — Working document
Two sketch entities — Working document
Two sketch entities — Create ring
Two sketch entities — Create ring
Two sketch entities — Dress-up ring
Two sketch entities — Dress-up ring
Two sketch entities — Reference ring
Two sketch entities — Reference ring
Two sketch entities — Modify ring
Two sketch entities — Modify ring
Nothing selected — Fresh document
Nothing selected — Fresh document
Nothing selected — Reference ring
Nothing selected — Reference ring
Planar face — Fresh document
Planar face — Fresh document
Planar face — Reference ring
Planar face — Reference ring
Cylindrical face — Fresh document
Cylindrical face — Fresh document
Cylindrical face — Reference ring
Cylindrical face — Reference ring
Curved face — Fresh document
Curved face — Fresh document
Straight edge — Fresh document
Straight edge — Fresh document
Straight edge — Reference ring
Straight edge — Reference ring
Circular edge — Fresh document
Circular edge — Fresh document
Vertex — Fresh document
Vertex — Fresh document
Vertex — Reference ring
Vertex — Reference ring
Solid body — Fresh document
Solid body — Fresh document
Solid body — Modify ring
Solid body — Modify ring
Sheet body — Fresh document
Sheet body — Fresh document
Sheet body — Modify ring
Sheet body — Modify ring
Two bodies — Fresh document
Two bodies — Fresh document
Datum plane — Fresh document
Datum plane — Fresh document
Datum plane — Reference ring
Datum plane — Reference ring
Datum plane — Modify ring
Datum plane — Modify ring
Datum axis — Fresh document
Datum axis — Fresh document
Datum axis — Modify ring
Datum axis — Modify ring
Coordinate system — Fresh document
Coordinate system — Fresh document
Coordinate system — Modify ring
Coordinate system — Modify ring
Text / imported art — Fresh document
Text / imported art — Fresh document
Text / imported art — Modify ring
Text / imported art — Modify ring
Closed sketch loop — Fresh document
Closed sketch loop — Fresh document
Closed sketch loop — Add material ring
Closed sketch loop — Add material ring
Closed sketch loop — Modify ring
Closed sketch loop — Modify ring
Sketch, nothing picked — Fresh document
Sketch, nothing picked — Fresh document
Sketch, nothing picked — Create ring
Sketch, nothing picked — Create ring
Sketch, nothing picked — Reference ring
Sketch, nothing picked — Reference ring
Sketch line — Fresh document
Sketch line — Fresh document
Sketch line — Create ring
Sketch line — Create ring
Sketch line — Dress-up ring
Sketch line — Dress-up ring
Sketch line — Reference ring
Sketch line — Reference ring
Sketch line — Modify ring
Sketch line — Modify ring
Sketch arc or circle — Fresh document
Sketch arc or circle — Fresh document
Sketch arc or circle — Create ring
Sketch arc or circle — Create ring
Sketch arc or circle — Reference ring
Sketch arc or circle — Reference ring
Sketch arc or circle — Modify ring
Sketch arc or circle — Modify ring
Sketch point — Fresh document
Sketch point — Fresh document
Sketch point — Create ring
Sketch point — Create ring
Sketch point — Reference ring
Sketch point — Reference ring
Two sketch entities — Fresh document
Two sketch entities — Fresh document
Two sketch entities — Create ring
Two sketch entities — Create ring
Two sketch entities — Dress-up ring
Two sketch entities — Dress-up ring
Two sketch entities — Reference ring
Two sketch entities — Reference ring
Two sketch entities — Modify ring
Two sketch entities — Modify ring
+
diff --git a/docs/CAD/ux/offer_atlas_inline.html b/docs/CAD/ux/offer_atlas_inline.html new file mode 100644 index 0000000000..3bbfb1b303 --- /dev/null +++ b/docs/CAD/ux/offer_atlas_inline.html @@ -0,0 +1,77 @@ +Design tab — offer atlas + +
+

The offer — atlas of every state

+

Every state of the object-driven tool offer, generated from + docs/ux/tool_atlas.json. The invariant under test: a verb has one address, that + address is the same in every selection where it appears, and slots that do not apply are drawn + empty rather than compacted.

+
+
60verbs mapped
+
20selection kinds
+
40primary rings
+
73secondary rings
+
113states total
+
3.5/8mean slots filled
+
5verbs with no GUI yet
+
+
+
+

Decision 0 — ring or vertical list

+

The live question. Both forms carry the SAME map and the same invariant — fixed order, never + re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the + offer. What the list buys: a disabled row can state its own reason in the words the product + already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an + overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable + by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick + gesture, and travel to the last row is longer than to the nearest direction. Pairs below — + list first, the same state as a ring second.

+ +

Decision 1 — ring capacity

+

The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is + the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses + for more verbs at 30° spacing and positions that stop being nameable.

+
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 8 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face
8 slots — face_planar
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Straight edge selected — pick what to do with itWorking document · 8 slotsDress-up2Pattern on CurveReference3Straight edge
8 slots — edge_str
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line
8 slots — sk_line
+
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 12 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face
12 slots — face_planar
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Straight edge selected — pick what to do with itWorking document · 12 slotsDress-up2Pattern on CurveReference3Straight edge
12 slots — edge_str
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 12 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line
12 slots — sk_line
+ +

Decision 2 — one map or one per mode

+

These renders use ONE shared map: sketch verbs occupy the same eight families as model verbs, + so dress-up is south-east whether you picked a solid edge or a sketch line. Compare the sketch-mode + states below against the model-mode ones — if a verb that exists in both modes ever appears at two + addresses, the shared map has failed and the split map is the answer.

+ +

The matrix — every selection, every document state

+
+ + +
SelectionDocumentVerbsSlots2nd ringsWhat lands where
Nothing selectedWorking document52/81create: Sketch · reference: Plane, Axis, Coord Sys, Helix
Planar faceWorking document188/86create: Sketch · add: Extrude, Thicken · remove: Hole, Shell · dressup: Fillet, Chamfer, Draft · repeat: Pattern · transform: Mate, Align to · reference: Plane, Axis, Coord Sys, Project, Measure · modify: Edit, Delete Face
Cylindrical faceWorking document63/82remove: Thread · reference: Axis, Helix, Measure · modify: Edit, Delete Face
Curved faceWorking document54/81add: Thicken · dressup: Draft · reference: Measure · modify: Edit, Delete Face
Straight edgeWorking document63/82dressup: Fillet, Chamfer · repeat: Pattern on Curve · reference: Plane, Axis, Measure
Circular edgeWorking document43/81remove: Thread · dressup: Fillet, Chamfer · reference: Measure
VertexWorking document41/81reference: Plane, Axis, Coord Sys, Measure
Solid bodyWorking document156/85remove: Shell, Cut, Split · dressup: Fillet, Chamfer · repeat: Pattern, Mirror, Pattern on Curve · transform: Move · reference: Project, Measure, Mass · modify: Edit, Colour, Delete
Sheet bodyWorking document75/81add: Thicken Surface · dressup: Surface Offset · transform: Move · reference: Measure · modify: Edit, Colour, Delete
Two bodiesWorking document54/81add: Combine · transform: Mate · reference: Measure, Interference · modify: Delete
Datum planeWorking document95/83create: Sketch · remove: Hole, Cut · repeat: Mirror · reference: Plane, Helix, Project · modify: Edit, Delete
Datum axisWorking document21/81modify: Edit, Delete
Coordinate systemWorking document32/81transform: Mate · modify: Edit, Delete
Text / imported artWorking document43/81repeat: Pattern · transform: Move · modify: Edit, Delete
Closed sketch loopWorking document113/82add: Extrude, Revolve, Sweep, Loft, Surface Extrude, Surface Revolve, Surface Loft, Surface Fill · repeat: Pattern · modify: Edit, Delete
Sketch, nothing pickedWorking document112/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · reference: Dimension, Construction
Sketch lineWorking document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch arc or circleWorking document187/83create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch pointWorking document134/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · transform: Move · reference: Dimension, Constrain · modify: Delete
Two sketch entitiesWorking document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Nothing selectedFresh document52/81create: Sketch · reference: Plane, Axis, Coord Sys, Helix
Planar faceFresh document74/81create: Sketch · add: Extrude · reference: Plane, Axis, Coord Sys, Measure · modify: Edit
Cylindrical faceFresh document42/81reference: Axis, Helix, Measure · modify: Edit
Curved faceFresh document22/80reference: Measure · modify: Edit
Straight edgeFresh document31/81reference: Plane, Axis, Measure
Circular edgeFresh document11/80reference: Measure
VertexFresh document41/81reference: Plane, Axis, Coord Sys, Measure
Solid bodyFresh document32/81reference: Measure · modify: Edit, Delete
Sheet bodyFresh document32/81reference: Measure · modify: Edit, Delete
Two bodiesFresh document22/80reference: Measure · modify: Delete
Datum planeFresh document53/82create: Sketch · reference: Plane, Helix · modify: Edit, Delete
Datum axisFresh document21/81modify: Edit, Delete
Coordinate systemFresh document21/81modify: Edit, Delete
Text / imported artFresh document21/81modify: Edit, Delete
Closed sketch loopFresh document72/82add: Extrude, Revolve, Surface Extrude, Surface Revolve, Surface Fill · modify: Edit, Delete
Sketch, nothing pickedFresh document112/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · reference: Dimension, Construction
Sketch lineFresh document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch arc or circleFresh document187/83create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
Sketch pointFresh document134/82create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · transform: Move · reference: Dimension, Constrain · modify: Delete
Two sketch entitiesFresh document208/84create: Line, Rectangle, Circle, Arc, Slot, Ellipse, Spline, Polygon, Point · add: Offset · remove: Trim · dressup: Fillet, Chamfer · repeat: Mirror · transform: Move · reference: Dimension, Constrain, Construction · modify: Extend, Delete
+ +

Overflow — the one place eight slots is not enough

+

Measured, not predicted: the create sub-ring needs 10 addresses, so Polygon, Point are pushed behind a “More” slot. This is the decision the ring size actually turns on — either a verb moves to another family, or the tail goes to a third level, or the ring is not eight. It affects sketch mode only; every model-mode family fits.

+ +

Not in the offer

+

Import STEP, Import mesh, Text, SVG, Export STEP, Commit to Plate, Undo, Redo, Variables, Section view, Origin planes, World axes — these act on the document, not on a selection, + so they stay in chrome. Verbs with kernel support but no GUI today, which still hold an address: + Split, Pattern on Curve, Align to, Measure, Interference.

+ +

The states

+
PreparePreviewDesignFEATURESnothing yetClick a face or a reference plane, then a toolFresh document · 8 slotsSketchShift+SReference4Nothing selected
Fresh document, nothing selected — the first-run picture
PreparePreviewDesignFEATURESnothing yetRight-click the geometry to see what you can do with itFresh document · vertical listNOTHING SELECTEDSketchShift+SAdd materialCreate a sketch, or pick a solid face, firstRemovePick a face or a plane to drill intoDress-upPick an edge to roundRepeatCreate a solid body to pattern firstTransformTransform needs a body — add or import one firstReference4ModifyDelete Face needs a body — add or import one first
LIST · fresh document — every family present, the unavailable ones say why
PreparePreviewDesignFEATURESnothing yetClick a face or a reference plane, then a toolFresh document · 8 slotsSketchShift+SReference4Nothing selected
RING · the same state — an empty slot cannot say anything
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listPLANAR FACESketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2
LIST · planar face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 8 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face
RING · planar face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch, nothing picked — pick oneWorking document · vertical listSKETCH, NOTHING PICKEDCreate9Add materialRemoveDress-upRepeatTransformReference2ModifyLineLRectangleRCircleCArcASlotSEllipseESplineBPolygonGPointP
LIST · sketch Create submenu — all 9 primitives fit, no overflow
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch, nothing picked · Create
RING · the same submenu — 2 verbs pushed behind “More”
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face — pick oneWorking document · vertical listPLANAR FACESketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2ExtrudeShift+EThicken
LIST · planar face, Add material submenu
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Add material — pick oneWorking document · 8 slotsExtrudeShift+EThickenPlanar face · Add material
RING · planar face, Add material sub-ring
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listSOLID BODYCreateClick a face or a reference plane in the viewport, t…Add materialCreate a sketch, or pick a solid face, firstRemove3Dress-up2Repeat3MoveShift+YReference3Modify3
LIST · solid body
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listSTRAIGHT EDGECreateClick a face or a reference plane in the viewport, t…Add materialCreate a sketch, or pick a solid face, firstRemovePick a face or a plane to drill intoDress-up2Pattern on CurveTransformTransform needs a body — add or import one firstReference3ModifyDelete Face needs a body — add or import one first
LIST · straight edge
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Right-click the geometry to see what you can do with itWorking document · vertical listSKETCH LINECreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2
LIST · sketch line
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Click a face or a reference plane, then a toolWorking document · 8 slotsSketchShift+SReference4Nothing selected
RING · Nothing selected
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 8 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face
RING · Planar face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Cylindrical face selected — pick what to do with itWorking document · 8 slotsThreadShift+TReference3Modify2Cylindrical face
RING · Cylindrical face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Curved face selected — pick what to do with itWorking document · 8 slotsThickenDraftShift+DMeasureModify2Curved face
RING · Curved face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Straight edge selected — pick what to do with itWorking document · 8 slotsDress-up2Pattern on CurveReference3Straight edge
RING · Straight edge
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Circular edge selected — pick what to do with itWorking document · 8 slotsThreadShift+TDress-up2MeasureCircular edge
RING · Circular edge
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Vertex selected — pick what to do with itWorking document · 8 slotsReference4Vertex
RING · Vertex
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Solid body selected — pick what to do with itWorking document · 8 slotsRemove3Dress-up2Repeat3MoveShift+YReference3Modify3Solid body
RING · Solid body
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sheet body selected — pick what to do with itWorking document · 8 slotsThicken SurfaceSurface OffsetMoveShift+YMeasureModify3Sheet body
RING · Sheet body
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Two bodies selected — pick what to do with itWorking document · 8 slotsCombineShift+BMateReference2DeleteDelTwo bodies
RING · Two bodies
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Datum plane selected — pick what to do with itWorking document · 8 slotsSketchShift+SRemove2MirrorShift+ZReference3Modify2Datum plane
RING · Datum plane
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Datum axis selected — pick what to do with itWorking document · 8 slotsModify2Datum axis
RING · Datum axis
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Coordinate system selected — pick what to do with itWorking document · 8 slotsMateModify2Coordinate system
RING · Coordinate system
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Text / imported art selected — pick what to do with itWorking document · 8 slotsABCPatternShift+NMoveShift+YModify2Text / imported art
RING · Text / imported art
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Closed sketch loop selected — pick what to do with itWorking document · 8 slotsAdd material8PatternShift+NModify2Closed sketch loop
RING · Closed sketch loop
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch, nothing picked selected — pick what to do with itWorking document · 8 slotsCreate9Reference2Sketch, nothing picked
RING · Sketch, nothing picked
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line
RING · Sketch line
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch arc or circle selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTMirrorMMoveReference3Modify2Sketch arc or circle
RING · Sketch arc or circle
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch point selected — pick what to do with itWorking document · 8 slotsCreate9MoveReference2DeleteDelSketch point
RING · Sketch point
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Two sketch entities selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Two sketch entities
RING · Two sketch entities
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Add material — pick oneWorking document · 8 slotsExtrudeShift+EThickenPlanar face · Add material
Planar face · Add material sub-ring
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Reference — pick oneWorking document · 8 slotsCoord SysShift+CProjectMeasurePlaneShift+PAxisShift+APlanar face · Reference
Planar face · Reference sub-ring
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Create — pick oneWorking document · 8 slotsLineLRectangleRCircleCArcASlotSEllipseESplineBMore2Sketch, nothing picked · Create
Sketch · Create sub-ring (the overflow case)
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Remove — pick oneWorking document · 8 slotsShellShift+KCutShift+XSplitSolid body · Remove
Solid body · Remove sub-ring
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 8 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face
8 slots — planar face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Planar face selected — pick what to do with itWorking document · 12 slotsSketchShift+SAdd material2Remove2Dress-up3PatternShift+NTransform2Reference5Modify2Planar face
12 slots — planar face
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 8 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line
8 slots — sketch line
PreparePreviewDesignFEATURESSketch1Extrude1Hole1Fillet1Sketch2Extrude2Sketch line selected — pick what to do with itWorking document · 12 slotsCreate9OffsetOTrimTDress-up2MirrorMMoveReference3Modify2Sketch line
12 slots — sketch line
diff --git a/docs/CAD/ux/tool_atlas.json b/docs/CAD/ux/tool_atlas.json new file mode 100644 index 0000000000..94709f2057 --- /dev/null +++ b/docs/CAD/ux/tool_atlas.json @@ -0,0 +1,2213 @@ +{ + "_comment": [ + "Source of truth for the object-driven tool offer (see docs/cad_ux_guidelines.md 4.1).", + "Every verb, the selections it accepts, the document state it needs, and its fixed", + "compass address. Extracted from the code, not from memory:", + " verbs + shortcuts + refusal messages -> src/slic3r/GUI/CAD/DesignPanel.cpp (feat_dropdown", + " call sites at 524/662/734/790/874/904, sk_key table at 369-400, m_keys_feature)", + " kernel coverage -> src/libslic3r/CAD/CadDocument.hpp (enum CadFeatureType)", + " headless coverage -> src/slic3r/GUI/CAD/McpControl.cpp (method == \"...\")", + " selection kinds -> src/slic3r/GUI/CAD/DesignCanvas.hpp (the on_*_selected callbacks)", + "gen_offer_mockups.py reads this file; the eventual C++ offer table is generated from it", + "too, so the map exists once." + ], + "slots": [ + { + "id": "create", + "pos": "N", + "angle": 270, + "label": "Create", + "why": "makes new geometry from nothing you have yet" + }, + { + "id": "add", + "pos": "NE", + "angle": 315, + "label": "Add material", + "why": "grows solid or sheet material" + }, + { + "id": "remove", + "pos": "E", + "angle": 0, + "label": "Remove", + "why": "takes material away Shell lives here by decision of 2026-08-01, not by oversight." + }, + { + "id": "dressup", + "pos": "SE", + "angle": 45, + "label": "Fillet / chamfer / draft", + "why": "finishes faces and edges without changing the shape's intent; named for the tools it holds, not for the trade word. RATIFIED 2026-08-01: Shell stays in Remove and Delete Face in Modify — this row is deliberately NOT the same set as the toolbar's dressup dropdown, which groups all five. Shell hollows a solid, so it belongs with the verbs that take material away. Do not re-open: a verb's row is its address and reordering breaks learned reach." + }, + { + "id": "repeat", + "pos": "S", + "angle": 90, + "label": "Repeat", + "why": "copies what exists" + }, + { + "id": "transform", + "pos": "SW", + "angle": 135, + "label": "Transform", + "why": "moves without changing shape" + }, + { + "id": "reference", + "pos": "W", + "angle": 180, + "label": "Reference", + "why": "makes datums, curves and measurements" + }, + { + "id": "modify", + "pos": "NW", + "angle": 225, + "label": "Modify", + "why": "edits or removes what is already there" + } + ], + "selections": [ + { + "id": "none", + "name": "Nothing selected", + "mode": "model", + "shape": "empty" + }, + { + "id": "face_planar", + "name": "Planar face", + "mode": "model", + "shape": "box_top" + }, + { + "id": "face_cyl", + "name": "Cylindrical face", + "mode": "model", + "shape": "box_bore" + }, + { + "id": "face_other", + "name": "Curved face", + "mode": "model", + "shape": "box_fillet_face" + }, + { + "id": "edge_str", + "name": "Straight edge", + "mode": "model", + "shape": "box_edge" + }, + { + "id": "edge_circ", + "name": "Circular edge", + "mode": "model", + "shape": "box_bore_rim" + }, + { + "id": "vertex", + "name": "Vertex", + "mode": "model", + "shape": "box_vertex" + }, + { + "id": "body_solid", + "name": "Solid body", + "mode": "model", + "shape": "box_whole" + }, + { + "id": "body_sheet", + "name": "Sheet body", + "mode": "model", + "shape": "sheet" + }, + { + "id": "bodies_2", + "name": "Two bodies", + "mode": "model", + "shape": "two_boxes" + }, + { + "id": "datum_plane", + "name": "Datum plane", + "mode": "model", + "shape": "datum" + }, + { + "id": "datum_axis", + "name": "Datum axis", + "mode": "model", + "shape": "axis" + }, + { + "id": "coordsys", + "name": "Coordinate system", + "mode": "model", + "shape": "csys" + }, + { + "id": "art", + "name": "Text / imported art", + "mode": "model", + "shape": "art" + }, + { + "id": "sk_loop", + "name": "Closed sketch loop", + "mode": "model", + "shape": "loop" + }, + { + "id": "sk_none", + "name": "Sketch, nothing picked", + "mode": "sketch", + "shape": "sk_empty" + }, + { + "id": "sk_line", + "name": "Sketch line", + "mode": "sketch", + "shape": "sk_line" + }, + { + "id": "sk_arc", + "name": "Sketch arc or circle", + "mode": "sketch", + "shape": "sk_arc" + }, + { + "id": "sk_point", + "name": "Sketch point", + "mode": "sketch", + "shape": "sk_point" + }, + { + "id": "sk_2ent", + "name": "Two sketch entities", + "mode": "sketch", + "shape": "sk_two" + } + ], + "doc_states": [ + { + "id": "rich", + "name": "Working document", + "bodies": 2, + "sketches": 2, + "sheet": true, + "note": "two solids, two sketches, one sheet body — everything doc-gated is legal" + }, + { + "id": "fresh", + "name": "Fresh document", + "bodies": 0, + "sketches": 0, + "sheet": false, + "note": "nothing built yet — shows how much of the ring is empty on first open" + } + ], + "verbs": [ + { + "id": "sketch", + "name": "Sketch", + "slot": "create", + "key": "Shift+S", + "feature": "Sketch", + "mcp": null, + "accepts": [ + "face_planar", + "datum_plane", + "none" + ], + "needs": {}, + "refusal": "Click a face or a reference plane in the viewport, then a sketch tool", + "gui": true, + "action": "key:S+S", + "icon": "design_sketch", + "hint": "Click a face or a reference plane, then pick a drawing tool" + }, + { + "id": "extrude", + "name": "Extrude", + "slot": "add", + "key": "Shift+E", + "feature": "Extrude", + "mcp": "extrude", + "accepts": [ + "sk_loop", + "face_planar" + ], + "needs": {}, + "refusal": "Create a sketch, or pick a solid face, first", + "gui": true, + "action": "key:S+E", + "icon": "design_extrude", + "hint": "Extrude a sketch profile, or push/pull a picked face" + }, + { + "id": "revolve", + "name": "Revolve", + "slot": "add", + "key": "Shift+R", + "feature": "Revolve", + "mcp": "revolve", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a sketch profile to revolve first", + "gui": true, + "action": "key:S+R", + "icon": "design_revolve", + "hint": "Revolve a profile about an axis" + }, + { + "id": "sweep", + "name": "Sweep", + "slot": "add", + "key": "Shift+W", + "feature": "Sweep", + "mcp": null, + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 2 + }, + "refusal": "Create a profile sketch to sweep first", + "gui": true, + "action": "key:S+W", + "icon": "design_sweep", + "hint": "Sweep a profile along a path" + }, + { + "id": "loft", + "name": "Loft", + "slot": "add", + "key": "Shift+L", + "feature": "Loft", + "mcp": null, + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 2 + }, + "refusal": "Create at least two profile sketches to loft", + "gui": true, + "action": "key:S+L", + "icon": "design_loft", + "hint": "Loft (skin) between two or more profiles" + }, + { + "id": "thicken", + "name": "Thicken", + "slot": "add", + "key": null, + "feature": "Thicken", + "mcp": "thicken", + "accepts": [ + "face_planar", + "face_other" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Thicken needs a solid body — add or import one first", + "gui": true, + "action": "fly:material#4", + "icon": "design_thicken", + "hint": "Offset a solid face into a thin plate (new body)" + }, + { + "id": "rib", + "name": "Rib", + "slot": "add", + "key": null, + "feature": "Rib", + "mcp": "rib", + "accepts": [ + "sk_line" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Rib needs a solid body — add or import one first", + "gui": true, + "action": "fly:material#5", + "icon": "design_rib", + "hint": "Grow a thin wall from an open sketch line, fused to a body" + }, + { + "id": "boolean", + "name": "Union", + "slot": "add", + "key": "Shift+B", + "feature": "Boolean", + "mcp": "boolean", + "accepts": [ + "bodies_2" + ], + "needs": { + "bodies": 2 + }, + "refusal": "Boolean needs two bodies — create or import a second solid", + "gui": true, + "action": "btn:bool#0", + "icon": "design_boolean", + "hint": "Fuse the tool body into the target — one solid, no seam" + }, + { + "id": "bool_subtract", + "name": "Subtract", + "slot": "add", + "key": null, + "feature": "Boolean", + "mcp": null, + "accepts": [ + "bodies_2" + ], + "needs": { + "bodies": 2 + }, + "refusal": "Boolean needs two bodies — create or import a second solid", + "gui": true, + "action": "btn:bool#1", + "mode": null, + "icon": "design_boolean", + "hint": "Cut the tool body out of the target" + }, + { + "id": "bool_intersect", + "name": "Intersect", + "slot": "add", + "key": null, + "feature": "Boolean", + "mcp": null, + "accepts": [ + "bodies_2" + ], + "needs": { + "bodies": 2 + }, + "refusal": "Boolean needs two bodies — create or import a second solid", + "gui": true, + "action": "btn:bool#2", + "mode": null, + "icon": "design_boolean", + "hint": "Keep only where the two bodies overlap" + }, + { + "id": "surf_extrude", + "name": "Surface Extrude", + "slot": "add", + "key": "Shift+G", + "feature": "SurfaceExtrude", + "mcp": "surface_extrude", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a sketch first", + "gui": true, + "action": "key:S+G", + "icon": "design_extrude", + "hint": "Extrude a sketch into a sheet body (no end caps)" + }, + { + "id": "surf_revolve", + "name": "Surface Revolve", + "slot": "add", + "key": null, + "feature": "SurfaceRevolve", + "mcp": "surface_revolve", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a sketch profile to revolve first", + "gui": true, + "action": "fly:surface#1", + "icon": "design_revolve", + "hint": "Revolve a sketch profile into a sheet body" + }, + { + "id": "surf_loft", + "name": "Surface Loft", + "slot": "add", + "key": null, + "feature": "SurfaceLoft", + "mcp": "surface_loft", + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 2 + }, + "refusal": "Create at least two profile sketches to loft", + "gui": true, + "action": "fly:surface#2", + "icon": "design_loft", + "hint": "Loft (skin) between 2+ profiles, open (no end caps)" + }, + { + "id": "surf_fill", + "name": "Surface Fill", + "slot": "add", + "key": null, + "feature": "SurfaceFill", + "mcp": "surface_fill", + "accepts": [ + "sk_loop" + ], + "needs": {}, + "refusal": "Create a closed sketch first", + "gui": true, + "action": "fly:surface#3", + "icon": "design_surface", + "hint": "Fill a sketch boundary with a smooth face" + }, + { + "id": "thicken_surf", + "name": "Thicken Surface", + "slot": "add", + "key": null, + "feature": "ThickenSurface", + "mcp": "thicken_surface", + "accepts": [ + "body_sheet" + ], + "needs": { + "sheet": true + }, + "refusal": "target is not a sheet body", + "gui": true, + "action": "fly:surface#5", + "icon": "design_thicken", + "hint": "Thicken a sheet body into a solid" + }, + { + "id": "hole", + "name": "Hole", + "slot": "remove", + "key": "Shift+H", + "feature": "Hole", + "mcp": "hole", + "accepts": [ + "face_planar", + "datum_plane" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick a face or a plane to drill into", + "gui": true, + "action": "key:S+H", + "icon": "design_hole", + "hint": "Drill a hole, centred on a picked face or placed on a plane" + }, + { + "id": "thread", + "name": "Thread", + "slot": "remove", + "key": "Shift+T", + "feature": "Thread", + "mcp": null, + "accepts": [ + "face_cyl", + "edge_circ" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", + "gui": true, + "action": "key:S+T", + "icon": "design_thread", + "hint": "Thread a cylindrical surface (inner bore / outer) or a circular edge" + }, + { + "id": "shell", + "name": "Shell", + "slot": "remove", + "key": "Shift+K", + "feature": "Shell", + "mcp": "shell", + "accepts": [ + "face_planar", + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Shell needs a solid body", + "gui": true, + "action": "key:S+K", + "icon": "design_shell", + "hint": "Hollow the body to a wall thickness, opening a picked face" + }, + { + "id": "cut", + "name": "Cut", + "slot": "remove", + "key": "Shift+X", + "feature": "Cut", + "mcp": null, + "accepts": [ + "body_solid", + "datum_plane", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Create a solid body to cut first", + "gui": true, + "action": "key:S+X", + "icon": "design_cut", + "hint": "Trim the body with a plane — drag the offset arrow; keep one half or both" + }, + { + "id": "split", + "name": "Split", + "slot": "remove", + "key": null, + "feature": "Cut", + "mcp": "split", + "accepts": [ + "body_solid", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Split needs a solid body", + "gui": false, + "action": null, + "icon": null, + "hint": "Split the body along a picked face into two solids" + }, + { + "id": "fillet", + "name": "Fillet", + "slot": "dressup", + "key": "Shift+F", + "feature": "Fillet", + "mcp": "fillet", + "accepts": [ + "edge_str", + "edge_circ", + "face_planar", + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick an edge to round", + "gui": true, + "action": "btn:dress#0", + "icon": "design_filletedge", + "hint": "Pick an edge, then drag the radius arrow or type it" + }, + { + "id": "chamfer", + "name": "Chamfer", + "slot": "dressup", + "key": null, + "feature": "Chamfer", + "mcp": "chamfer", + "accepts": [ + "edge_str", + "edge_circ", + "face_planar", + "body_solid" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick an edge to bevel", + "gui": true, + "action": "btn:dress#1", + "icon": "design_chamfer", + "hint": "Pick an edge, then drag the distance arrow or type it" + }, + { + "id": "draft", + "name": "Draft", + "slot": "dressup", + "key": "Shift+D", + "feature": "Draft", + "mcp": "draft", + "accepts": [ + "face_planar", + "face_other" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pick a face to taper", + "gui": true, + "action": "key:S+D", + "icon": "design_draft", + "hint": "Tilt a picked face by a draft angle" + }, + { + "id": "surf_offset", + "name": "Surface Offset", + "slot": "dressup", + "key": null, + "feature": "SurfaceOffset", + "mcp": "surface_offset", + "accepts": [ + "body_sheet" + ], + "needs": { + "sheet": true + }, + "refusal": "target is not a sheet body", + "gui": true, + "action": "fly:surface#4", + "icon": "design_offset", + "hint": "Offset a sheet body's shell by a signed distance" + }, + { + "id": "pattern", + "name": "Linear pattern", + "slot": "repeat", + "key": "Shift+N", + "feature": "Pattern", + "mcp": "pattern", + "accepts": [ + "body_solid", + "face_planar", + "sk_loop", + "art" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Create a solid body to pattern first", + "gui": true, + "action": "btn:pat#0", + "icon": "design_array", + "hint": "Repeat the body along a direction — drag the spacing, set the count" + }, + { + "id": "pattern_circular", + "name": "Circular pattern", + "slot": "repeat", + "key": null, + "feature": "Pattern", + "mcp": null, + "accepts": [ + "body_solid", + "face_planar", + "sk_loop", + "art" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Create a solid body to pattern first", + "gui": true, + "action": "btn:pat#1", + "mode": null, + "icon": "design_polararray", + "hint": "Repeat the body around an axis — set the count and sweep" + }, + { + "id": "mirror", + "name": "Mirror", + "slot": "repeat", + "key": "Shift+Z", + "feature": "Mirror", + "mcp": "mirror", + "accepts": [ + "body_solid", + "datum_plane", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Mirror needs a body — add or import one first", + "gui": true, + "action": "key:S+Z", + "icon": "design_mirror", + "hint": "Reflect a body about a plane" + }, + { + "id": "pat_curve", + "name": "Pattern on Curve", + "slot": "repeat", + "key": null, + "feature": "Pattern", + "mcp": "pattern_on_curve", + "accepts": [ + "body_solid", + "edge_str" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Pattern on curve needs a body and a curve", + "gui": false, + "action": null, + "icon": null, + "hint": "Repeat the body along a picked curve" + }, + { + "id": "transform", + "name": "Move", + "slot": "transform", + "key": "Shift+Y", + "feature": "Transform", + "mcp": "transform", + "accepts": [ + "body_solid", + "body_sheet", + "art", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Transform needs a body — add or import one first", + "gui": true, + "action": "key:S+Y", + "icon": "design_move", + "hint": "Move and/or rotate an existing body" + }, + { + "id": "mate", + "name": "Mate", + "slot": "transform", + "key": null, + "feature": "Mate", + "mcp": "mate", + "accepts": [ + "coordsys", + "bodies_2", + "face_planar" + ], + "needs": { + "bodies": 2 + }, + "refusal": "A mate needs two coordinate systems", + "gui": true, + "action": "fly:placement#2", + "icon": "design_c_coincident", + "hint": "Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)" + }, + { + "id": "align", + "name": "Align to", + "slot": "transform", + "key": null, + "feature": "Transform", + "mcp": "transform", + "accepts": [ + "face_planar" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Align needs a body", + "gui": false, + "action": null, + "icon": null, + "hint": "Align the body to a picked face or plane" + }, + { + "id": "plane", + "name": "Plane", + "slot": "reference", + "key": "Shift+P", + "feature": "Plane", + "mcp": null, + "accepts": [ + "none", + "face_planar", + "edge_str", + "datum_plane", + "vertex" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S+P", + "icon": "design_plane", + "hint": "Reference plane (offset / tilt / midplane / tangent / two edges / coincident)" + }, + { + "id": "axis", + "name": "Axis", + "slot": "reference", + "key": "Shift+A", + "feature": "Axis", + "mcp": "axis", + "accepts": [ + "none", + "face_planar", + "face_cyl", + "edge_str", + "vertex" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S+A", + "icon": "design_line", + "hint": "Datum axis (two points, face normal, cylinder centerline, two planes, along edge)" + }, + { + "id": "coordsys_v", + "name": "Coord Sys", + "slot": "reference", + "key": "Shift+C", + "feature": "CoordSys", + "mcp": "coordsys", + "accepts": [ + "none", + "face_planar", + "vertex" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S+C", + "icon": "design_point", + "hint": "Datum coordinate system (world point, or face + direction edge)" + }, + { + "id": "helix", + "name": "Helix", + "slot": "reference", + "key": null, + "feature": "Helix", + "mcp": "helix", + "accepts": [ + "none", + "datum_plane", + "face_cyl" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:plane#3", + "icon": "design_thread", + "hint": "Helical curve (spring path) — use as a sweep path for coils / springs / augers" + }, + { + "id": "project", + "name": "Project", + "slot": "reference", + "key": null, + "feature": "Project", + "mcp": "project", + "accepts": [ + "body_solid", + "face_planar", + "datum_plane" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Project needs a body — add or import one first", + "gui": true, + "action": "fly:plane#4", + "icon": "design_sketch", + "hint": "Project body edges onto a plane as sketch entities" + }, + { + "id": "measure", + "name": "Measure", + "slot": "reference", + "key": null, + "feature": null, + "mcp": "measure", + "accepts": [ + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex", + "body_solid", + "body_sheet", + "bodies_2", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": false, + "action": null, + "icon": null, + "hint": "Measure between the picked points, edges or faces" + }, + { + "id": "mass_props", + "name": "Mass", + "slot": "reference", + "key": null, + "feature": null, + "mcp": "mass_properties", + "accepts": [ + "body_solid", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "refusal": null, + "gui": true, + "action": "btn:mass", + "icon": "info", + "hint": "Report the volume and surface area of the selected body" + }, + { + "id": "interference", + "name": "Interference", + "slot": "reference", + "key": null, + "feature": null, + "mcp": "check_interference", + "accepts": [ + "bodies_2" + ], + "needs": { + "bodies": 2 + }, + "refusal": null, + "gui": false, + "action": null, + "icon": null, + "hint": "Check whether two bodies overlap — reports, changes nothing" + }, + { + "id": "edit_feature", + "name": "Edit", + "slot": "modify", + "key": null, + "feature": null, + "mcp": "set_feature_expr", + "accepts": [ + "body_solid", + "face_planar", + "face_cyl", + "face_other", + "sk_loop", + "art", + "datum_plane", + "datum_axis", + "coordsys", + "body_sheet" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:edit", + "icon": "design_edit", + "hint": "Reopen the selected feature to change what it was made from" + }, + { + "id": "rename", + "name": "Rename…", + "slot": "modify", + "key": "F2", + "feature": "Tree", + "mcp": null, + "mode": "model", + "accepts": [ + "sk_loop", + "body_solid" + ], + "needs": {}, + "refusal": "Select a feature, or a body, to rename it", + "gui": true, + "action": "btn:rename", + "icon": null, + "hint": "Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)" + }, + { + "id": "delete_face", + "name": "Delete Face", + "slot": "modify", + "key": null, + "feature": "DeleteFace", + "mcp": "delete_face", + "accepts": [ + "face_planar", + "face_cyl", + "face_other" + ], + "needs": { + "bodies": 1 + }, + "refusal": "Delete Face needs a body — add or import one first", + "gui": true, + "action": "fly:dressup#3", + "icon": "design_delete", + "hint": "Remove faces from a body and heal the solid" + }, + { + "id": "colour", + "name": "Colour", + "slot": "modify", + "key": null, + "feature": null, + "mcp": null, + "accepts": [ + "body_solid", + "body_sheet", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "refusal": null, + "gui": true, + "action": "btn:colour", + "icon": "color_palette", + "hint": "Set the selected body's display colour" + }, + { + "id": "delete", + "name": "Delete", + "slot": "modify", + "key": "Del", + "feature": null, + "mcp": null, + "accepts": [ + "sk_loop", + "art", + "datum_plane", + "datum_axis", + "coordsys", + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:delete", + "icon": "design_delete", + "hint": "Delete what is selected" + }, + { + "id": "delete_body", + "name": "Delete Body", + "slot": "modify", + "key": null, + "action": "btn:delete_body", + "refusal": null, + "accepts": [ + "body_solid", + "body_sheet", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex" + ], + "needs": { + "bodies": 1 + }, + "mode": "model", + "family": null, + "icon": "design_delete", + "hint": "Delete this whole body — removes the feature it was made from" + }, + { + "id": "sk_line_t", + "name": "Line", + "slot": "create", + "key": "L", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:L", + "family": "Line", + "icon": "design_line", + "hint": "Line — click start, then end" + }, + { + "id": "sk_polyline", + "name": "Polyline", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_line#1", + "mode": "sketch", + "family": "Line", + "icon": "design_polyline", + "hint": "Click points; click the first point to close the loop, right-click to end it open" + }, + { + "id": "sk_rect", + "name": "Corner rectangle", + "slot": "create", + "key": "R", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:R", + "family": "Rectangle", + "icon": "design_rect", + "hint": "Rectangle — click two opposite corners" + }, + { + "id": "sk_rect_center", + "name": "Centre rectangle", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_rect#1", + "mode": "sketch", + "family": "Rectangle", + "icon": "design_crect", + "hint": "Click center, then a corner" + }, + { + "id": "sk_rect_oblique", + "name": "Oblique rectangle", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_rect#2", + "mode": "sketch", + "family": "Rectangle", + "icon": "design_rect_oblique", + "hint": "Click two corners of one edge, then a point for the width" + }, + { + "id": "sk_rect_rounded", + "name": "Rounded rectangle", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_rect#3", + "mode": "sketch", + "family": "Rectangle", + "icon": "design_rect_rounded", + "hint": "Click two opposite corners, then a point for the corner radius" + }, + { + "id": "sk_circle", + "name": "Centre circle", + "slot": "create", + "key": "C", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:C", + "family": "Circle", + "icon": "design_circle", + "hint": "Circle — click center, then radius" + }, + { + "id": "sk_circle_2pt", + "name": "2-point circle", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_circle#1", + "mode": "sketch", + "family": "Circle", + "icon": "design_circle2pt", + "hint": "Click two ends of the diameter" + }, + { + "id": "sk_circle_3pt", + "name": "3-point circle", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_circle#2", + "mode": "sketch", + "family": "Circle", + "icon": "design_circle3pt", + "hint": "Click three points on the circle" + }, + { + "id": "sk_arc_t", + "name": "3-point arc", + "slot": "create", + "key": "A", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:A", + "family": "Arc", + "icon": "design_arc3pt", + "hint": "Arc — click start, end, then a point" + }, + { + "id": "sk_arc_tangent", + "name": "Tangent arc", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_arc3pt#1", + "mode": "sketch", + "family": "Arc", + "icon": "design_tangentarc", + "hint": "Click start (on the last entity) then end" + }, + { + "id": "sk_arc_center", + "name": "Centre-point arc", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_arc3pt#2", + "mode": "sketch", + "family": "Arc", + "icon": "design_arc_center", + "hint": "Click center, then start, then a point for the end angle" + }, + { + "id": "sk_slot", + "name": "Slot", + "slot": "create", + "key": "S", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:S", + "family": "Slot", + "icon": "design_slot", + "hint": "Slot — two centerline ends, then end radius" + }, + { + "id": "sk_slot_arc", + "name": "Arc slot", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_slot#1", + "mode": "sketch", + "family": "Slot", + "icon": "design_slot_arc", + "hint": "Click center, start, end, then a point for the width" + }, + { + "id": "sk_ellipse", + "name": "Ellipse", + "slot": "create", + "key": "E", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:E", + "family": "Ellipse", + "icon": "design_ellipse", + "hint": "Ellipse — center, major end, minor point" + }, + { + "id": "sk_ellipse_arc", + "name": "Elliptical arc", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_ellipse#1", + "mode": "sketch", + "family": "Ellipse", + "icon": "design_ellipse_arc", + "hint": "Click center, major-axis end, minor point, then arc start and end" + }, + { + "id": "sk_spline", + "name": "Spline", + "slot": "create", + "key": "B", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:B", + "icon": "design_bspline", + "hint": "Spline — click control points" + }, + { + "id": "sk_poly_3", + "name": "Triangle", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:poly#3", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Triangle — click centre, then a vertex" + }, + { + "id": "sk_poly_4", + "name": "Square", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:poly#4", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Square — click centre, then a vertex" + }, + { + "id": "sk_poly_5", + "name": "Pentagon", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:poly#5", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Pentagon — click centre, then a vertex" + }, + { + "id": "sk_polygon", + "name": "Hexagon", + "slot": "create", + "key": "G", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:poly#6", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Hexagon — click centre, then a vertex" + }, + { + "id": "sk_poly_8", + "name": "Octagon", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:poly#8", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Octagon — click centre, then a vertex" + }, + { + "id": "sk_poly_12", + "name": "Dodecagon", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:poly#12", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Dodecagon — click centre, then a vertex" + }, + { + "id": "sk_poly_inscribed", + "name": "Inscribed", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:polyfit#0", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Measure the polygon to its corners (inscribed)" + }, + { + "id": "sk_poly_circumscribed", + "name": "Circumscribed", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:polyfit#1", + "mode": "sketch", + "family": "Polygon", + "icon": "design_polygon", + "hint": "Measure the polygon to its flats (circumscribed)" + }, + { + "id": "sk_point_t", + "name": "Point", + "slot": "create", + "key": "P", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:P", + "icon": "design_point", + "hint": "Point — click to place" + }, + { + "id": "sk_text", + "name": "Text", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:text", + "mode": "sketch", + "icon": "design_text", + "hint": "Type text; its outline is added to this sketch as editable lines" + }, + { + "id": "sk_svg", + "name": "SVG", + "slot": "create", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_none", + "sk_point", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:svg", + "mode": "sketch", + "icon": "design_svg", + "hint": "Import an SVG outline into this sketch as editable lines" + }, + { + "id": "sk_offset", + "name": "Offset", + "slot": "add", + "key": "O", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:O", + "icon": "design_offset", + "hint": "Offset — pick an entity, drag the distance" + }, + { + "id": "sk_trim", + "name": "Trim", + "slot": "remove", + "key": "T", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:T", + "icon": "design_trim", + "hint": "Trim — click a segment to trim it" + }, + { + "id": "sk_fillet", + "name": "Fillet", + "slot": "dressup", + "key": "F", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:F", + "icon": "design_filletedge", + "hint": "Fillet — pick two lines, set the radius" + }, + { + "id": "sk_chamfer", + "name": "Chamfer", + "slot": "dressup", + "key": "H", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:H", + "icon": "design_chamfer", + "hint": "Chamfer — pick two lines, set the distance" + }, + { + "id": "sk_array", + "name": "Linear array", + "slot": "repeat", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_array#0", + "mode": "sketch", + "family": "Array", + "icon": "design_array", + "hint": "Pick entities, drag the spacing handle, click the count; click empty to apply" + }, + { + "id": "sk_array_polar", + "name": "Polar array", + "slot": "repeat", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_array#1", + "mode": "sketch", + "family": "Array", + "icon": "design_polararray", + "hint": "Pick entities, drag the sweep handle, click the count; click empty to apply" + }, + { + "id": "sk_mirror", + "name": "Mirror", + "slot": "repeat", + "key": "M", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:M", + "icon": "design_mirror", + "hint": "Mirror — pick axis, then entities" + }, + { + "id": "sk_move", + "name": "Move", + "slot": "transform", + "key": null, + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_move#0", + "family": "Move", + "icon": "design_move", + "hint": "Pick entities, then drag the handle or click the distance; click empty to apply" + }, + { + "id": "sk_rotate", + "name": "Rotate", + "slot": "transform", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_move#1", + "mode": "sketch", + "family": "Move", + "icon": "design_rotate", + "hint": "Pick entities, then drag around the pivot or click the angle; click empty to apply" + }, + { + "id": "sk_scale", + "name": "Scale", + "slot": "transform", + "key": null, + "feature": "Sketch", + "mcp": null, + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "fly:design_move#2", + "mode": "sketch", + "family": "Move", + "icon": "design_scale", + "hint": "Pick entities, then drag the handle or click the factor; click empty to apply" + }, + { + "id": "sk_dimension", + "name": "Dimension", + "slot": "reference", + "key": "D", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:D", + "icon": "design_dimension", + "hint": "Dimension — click 2 points or an entity" + }, + { + "id": "sk_constrain", + "name": "Constrain", + "slot": "reference", + "key": "K", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:K", + "icon": "design_constrain", + "hint": "Constrain the selected sketch entities to each other" + }, + { + "id": "constrain", + "name": "Constrain sketch", + "slot": "modify", + "key": null, + "feature": "Sketch", + "mcp": null, + "mode": "model", + "accepts": [ + "sk_loop" + ], + "needs": { + "sketches": 1 + }, + "refusal": "Select a sketch to constrain it", + "gui": true, + "action": "btn:constrain", + "icon": "design_constrain", + "hint": "Add dimensions and relations (coincident, tangent, parallel...) to the selected sketch", + "note": [ + "Same verb, model-mode vocabulary: offered when a SKETCH is selected (bit 14, SkLoop), the", + "state a user is in right after finishing one. Without this row the only way in was the", + "toolbar icon, and constraints read as absent — see the Onshape-comparison report." + ] + }, + { + "id": "sk_construct", + "name": "Construction", + "slot": "reference", + "key": "Q", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_none", + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:Q", + "icon": null, + "hint": "Toggle construction: geometry that guides but is never built" + }, + { + "id": "sk_extend", + "name": "Extend", + "slot": "modify", + "key": "X", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:X", + "icon": "design_extend", + "hint": "Extend — click a line/arc to extend it" + }, + { + "id": "sk_delete", + "name": "Delete", + "slot": "modify", + "key": "Del", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line", + "sk_arc", + "sk_point", + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:sk_delete", + "icon": "design_delete", + "hint": "Delete the selected sketch entities" + }, + { + "id": "sk_length", + "name": "Length…", + "slot": "modify", + "key": "V", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_line" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:V", + "icon": "design_dimension", + "hint": "Type the length of this line", + "note": [ + "Typing the defining number of the element you pointed at. Three rows rather than one so", + "each names the quantity in the drawing-office word for THAT element; all three land on", + "the same handler, because dimension_kind() already resolves the quantity from the", + "selection. Without these, an element's own numbers were reachable only by arming the", + "Dimension tool and re-picking geometry that was already selected." + ] + }, + { + "id": "sk_radius", + "name": "Radius / diameter…", + "slot": "modify", + "key": "V", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_arc" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:V", + "icon": "design_dimension", + "hint": "Type the radius of this arc, or the diameter of this circle" + }, + { + "id": "sk_angdist", + "name": "Angle / distance…", + "slot": "modify", + "key": "V", + "feature": "Sketch", + "mcp": null, + "mode": "sketch", + "accepts": [ + "sk_2ent" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "key:V", + "icon": "design_dimension", + "hint": "Type the angle between two lines, or the distance between the two picks" + } + ], + "chrome_only": { + "_why": "document-level actions act on the DOCUMENT, not on a selection, so they stay in chrome and never enter the offer (see 4.1) Text and SVG were removed from this list 2026-08-01: they create a Sketch feature on the picked face, so they consume a selection like any other Create verb.", + "items": [ + "Import STEP", + "Import mesh", + "Export STEP", + "Commit to Plate", + "Undo", + "Redo", + "Variables", + "Section view", + "Origin planes", + "World axes" + ] + }, + "_ratified": "Row order ratified 2026-07-31 by Tommaso. Changing an index is a breaking change (charter 4.1)." +} diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index a36ecd5cab..ae33c5fc85 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -291,3 +291,6 @@ src/slic3r/GUI/PrinterWebViewHandler.cpp src/slic3r/GUI/AMSDryControl.cpp src/slic3r/GUI/AMSDryControl.hpp src/libslic3r/PresetBundle.cpp +src/slic3r/GUI/CAD/DesignPanel.cpp +src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp +src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp diff --git a/resources/images/color_palette.svg b/resources/images/color_palette.svg new file mode 100644 index 0000000000..8448a364aa --- /dev/null +++ b/resources/images/color_palette.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/resources/images/design_arc3pt.svg b/resources/images/design_arc3pt.svg new file mode 100644 index 0000000000..1b40569b4f --- /dev/null +++ b/resources/images/design_arc3pt.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_arc_center.svg b/resources/images/design_arc_center.svg new file mode 100644 index 0000000000..e4c85febfc --- /dev/null +++ b/resources/images/design_arc_center.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_array.svg b/resources/images/design_array.svg new file mode 100644 index 0000000000..d29da47bd7 --- /dev/null +++ b/resources/images/design_array.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_boolean.svg b/resources/images/design_boolean.svg new file mode 100644 index 0000000000..97813cf2eb --- /dev/null +++ b/resources/images/design_boolean.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/images/design_bspline.svg b/resources/images/design_bspline.svg new file mode 100644 index 0000000000..16a5dc9b31 --- /dev/null +++ b/resources/images/design_bspline.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_angle.svg b/resources/images/design_c_angle.svg new file mode 100644 index 0000000000..2359b23b6a --- /dev/null +++ b/resources/images/design_c_angle.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_coincident.svg b/resources/images/design_c_coincident.svg new file mode 100644 index 0000000000..83ab602eca --- /dev/null +++ b/resources/images/design_c_coincident.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_collinear.svg b/resources/images/design_c_collinear.svg new file mode 100644 index 0000000000..28b8b6a8cd --- /dev/null +++ b/resources/images/design_c_collinear.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_concentric.svg b/resources/images/design_c_concentric.svg new file mode 100644 index 0000000000..9861381b58 --- /dev/null +++ b/resources/images/design_c_concentric.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_diameter.svg b/resources/images/design_c_diameter.svg new file mode 100644 index 0000000000..8e3766c4dc --- /dev/null +++ b/resources/images/design_c_diameter.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_dist_x.svg b/resources/images/design_c_dist_x.svg new file mode 100644 index 0000000000..e811a58df6 --- /dev/null +++ b/resources/images/design_c_dist_x.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_dist_y.svg b/resources/images/design_c_dist_y.svg new file mode 100644 index 0000000000..5aefec85ee --- /dev/null +++ b/resources/images/design_c_dist_y.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_equal.svg b/resources/images/design_c_equal.svg new file mode 100644 index 0000000000..f0657b74b0 --- /dev/null +++ b/resources/images/design_c_equal.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_equal_radius.svg b/resources/images/design_c_equal_radius.svg new file mode 100644 index 0000000000..4d7ccf2c5c --- /dev/null +++ b/resources/images/design_c_equal_radius.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_fix.svg b/resources/images/design_c_fix.svg new file mode 100644 index 0000000000..b64024286e --- /dev/null +++ b/resources/images/design_c_fix.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_horizontal.svg b/resources/images/design_c_horizontal.svg new file mode 100644 index 0000000000..9804a8ad82 --- /dev/null +++ b/resources/images/design_c_horizontal.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_midpoint.svg b/resources/images/design_c_midpoint.svg new file mode 100644 index 0000000000..6646a6cc1e --- /dev/null +++ b/resources/images/design_c_midpoint.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_parallel.svg b/resources/images/design_c_parallel.svg new file mode 100644 index 0000000000..cc78f9c452 --- /dev/null +++ b/resources/images/design_c_parallel.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_perpendicular.svg b/resources/images/design_c_perpendicular.svg new file mode 100644 index 0000000000..9a67cebb2b --- /dev/null +++ b/resources/images/design_c_perpendicular.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_radius.svg b/resources/images/design_c_radius.svg new file mode 100644 index 0000000000..5e1f1cdadf --- /dev/null +++ b/resources/images/design_c_radius.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_sym_h.svg b/resources/images/design_c_sym_h.svg new file mode 100644 index 0000000000..4e152787b1 --- /dev/null +++ b/resources/images/design_c_sym_h.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_sym_v.svg b/resources/images/design_c_sym_v.svg new file mode 100644 index 0000000000..f4cd99371a --- /dev/null +++ b/resources/images/design_c_sym_v.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_symmetric.svg b/resources/images/design_c_symmetric.svg new file mode 100644 index 0000000000..7a539be9a3 --- /dev/null +++ b/resources/images/design_c_symmetric.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_tangent.svg b/resources/images/design_c_tangent.svg new file mode 100644 index 0000000000..e82d23240d --- /dev/null +++ b/resources/images/design_c_tangent.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_c_vertical.svg b/resources/images/design_c_vertical.svg new file mode 100644 index 0000000000..98526e93d7 --- /dev/null +++ b/resources/images/design_c_vertical.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_chamfer.svg b/resources/images/design_chamfer.svg new file mode 100644 index 0000000000..861f7b819a --- /dev/null +++ b/resources/images/design_chamfer.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_check.svg b/resources/images/design_check.svg new file mode 100644 index 0000000000..82d5edd48e --- /dev/null +++ b/resources/images/design_check.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_circle.svg b/resources/images/design_circle.svg new file mode 100644 index 0000000000..edc74b13ea --- /dev/null +++ b/resources/images/design_circle.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_circle2pt.svg b/resources/images/design_circle2pt.svg new file mode 100644 index 0000000000..844b0797eb --- /dev/null +++ b/resources/images/design_circle2pt.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_circle3pt.svg b/resources/images/design_circle3pt.svg new file mode 100644 index 0000000000..6fdbab2474 --- /dev/null +++ b/resources/images/design_circle3pt.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_constrain.svg b/resources/images/design_constrain.svg new file mode 100644 index 0000000000..cc9be6ca9b --- /dev/null +++ b/resources/images/design_constrain.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_construction.svg b/resources/images/design_construction.svg new file mode 100644 index 0000000000..89d10f424d --- /dev/null +++ b/resources/images/design_construction.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_crect.svg b/resources/images/design_crect.svg new file mode 100644 index 0000000000..9e2ece0c24 --- /dev/null +++ b/resources/images/design_crect.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_cut.svg b/resources/images/design_cut.svg new file mode 100644 index 0000000000..3f68a7002e --- /dev/null +++ b/resources/images/design_cut.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/images/design_delete.svg b/resources/images/design_delete.svg new file mode 100644 index 0000000000..f5d5c6a1c7 --- /dev/null +++ b/resources/images/design_delete.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_dimension.svg b/resources/images/design_dimension.svg new file mode 100644 index 0000000000..f456abb6fc --- /dev/null +++ b/resources/images/design_dimension.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_draft.svg b/resources/images/design_draft.svg new file mode 100644 index 0000000000..2957584cb8 --- /dev/null +++ b/resources/images/design_draft.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_dressup.svg b/resources/images/design_dressup.svg new file mode 100644 index 0000000000..cc2a09250e --- /dev/null +++ b/resources/images/design_dressup.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_edit.svg b/resources/images/design_edit.svg new file mode 100644 index 0000000000..4de84fc390 --- /dev/null +++ b/resources/images/design_edit.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_ellipse.svg b/resources/images/design_ellipse.svg new file mode 100644 index 0000000000..1f946eb5fa --- /dev/null +++ b/resources/images/design_ellipse.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_ellipse_arc.svg b/resources/images/design_ellipse_arc.svg new file mode 100644 index 0000000000..e9cd4aa66a --- /dev/null +++ b/resources/images/design_ellipse_arc.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_extend.svg b/resources/images/design_extend.svg new file mode 100644 index 0000000000..9d4934fada --- /dev/null +++ b/resources/images/design_extend.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_extrude.svg b/resources/images/design_extrude.svg new file mode 100644 index 0000000000..8137336fb2 --- /dev/null +++ b/resources/images/design_extrude.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_eye.svg b/resources/images/design_eye.svg new file mode 100644 index 0000000000..97296c7465 --- /dev/null +++ b/resources/images/design_eye.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_filletedge.svg b/resources/images/design_filletedge.svg new file mode 100644 index 0000000000..435d7c2cb9 --- /dev/null +++ b/resources/images/design_filletedge.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_hole.svg b/resources/images/design_hole.svg new file mode 100644 index 0000000000..35128ef51e --- /dev/null +++ b/resources/images/design_hole.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_line.svg b/resources/images/design_line.svg new file mode 100644 index 0000000000..afb644ba3f --- /dev/null +++ b/resources/images/design_line.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_loft.svg b/resources/images/design_loft.svg new file mode 100644 index 0000000000..4529b52554 --- /dev/null +++ b/resources/images/design_loft.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_mirror.svg b/resources/images/design_mirror.svg new file mode 100644 index 0000000000..0a9642f8f3 --- /dev/null +++ b/resources/images/design_mirror.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_move.svg b/resources/images/design_move.svg new file mode 100644 index 0000000000..dba4f4f4c2 --- /dev/null +++ b/resources/images/design_move.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_movedown.svg b/resources/images/design_movedown.svg new file mode 100644 index 0000000000..89c442dc6e --- /dev/null +++ b/resources/images/design_movedown.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_moveup.svg b/resources/images/design_moveup.svg new file mode 100644 index 0000000000..b2fe5ec127 --- /dev/null +++ b/resources/images/design_moveup.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_offset.svg b/resources/images/design_offset.svg new file mode 100644 index 0000000000..74758ad0ba --- /dev/null +++ b/resources/images/design_offset.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_pattern.svg b/resources/images/design_pattern.svg new file mode 100644 index 0000000000..c07708509b --- /dev/null +++ b/resources/images/design_pattern.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_plane.svg b/resources/images/design_plane.svg new file mode 100644 index 0000000000..8a3886158f --- /dev/null +++ b/resources/images/design_plane.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_point.svg b/resources/images/design_point.svg new file mode 100644 index 0000000000..dd5d750545 --- /dev/null +++ b/resources/images/design_point.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_polararray.svg b/resources/images/design_polararray.svg new file mode 100644 index 0000000000..5e91d49f73 --- /dev/null +++ b/resources/images/design_polararray.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_polygon.svg b/resources/images/design_polygon.svg new file mode 100644 index 0000000000..de5cfd6f20 --- /dev/null +++ b/resources/images/design_polygon.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_polyline.svg b/resources/images/design_polyline.svg new file mode 100644 index 0000000000..96538abea7 --- /dev/null +++ b/resources/images/design_polyline.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_rect.svg b/resources/images/design_rect.svg new file mode 100644 index 0000000000..67b70cc6a1 --- /dev/null +++ b/resources/images/design_rect.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_rect_oblique.svg b/resources/images/design_rect_oblique.svg new file mode 100644 index 0000000000..5d252ca677 --- /dev/null +++ b/resources/images/design_rect_oblique.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_rect_rounded.svg b/resources/images/design_rect_rounded.svg new file mode 100644 index 0000000000..a8c7022c87 --- /dev/null +++ b/resources/images/design_rect_rounded.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_revolve.svg b/resources/images/design_revolve.svg new file mode 100644 index 0000000000..3a0e87f6f4 --- /dev/null +++ b/resources/images/design_revolve.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_rib.svg b/resources/images/design_rib.svg new file mode 100644 index 0000000000..b481e38d23 --- /dev/null +++ b/resources/images/design_rib.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_rotate.svg b/resources/images/design_rotate.svg new file mode 100644 index 0000000000..5a9b48ebfe --- /dev/null +++ b/resources/images/design_rotate.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_scale.svg b/resources/images/design_scale.svg new file mode 100644 index 0000000000..5a53562ba3 --- /dev/null +++ b/resources/images/design_scale.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_select.svg b/resources/images/design_select.svg new file mode 100644 index 0000000000..6f2e4f4ae9 --- /dev/null +++ b/resources/images/design_select.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_shell.svg b/resources/images/design_shell.svg new file mode 100644 index 0000000000..aa11b572e6 --- /dev/null +++ b/resources/images/design_shell.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_sketch.svg b/resources/images/design_sketch.svg new file mode 100644 index 0000000000..102a9fe493 --- /dev/null +++ b/resources/images/design_sketch.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_slot.svg b/resources/images/design_slot.svg new file mode 100644 index 0000000000..f1c72f8c54 --- /dev/null +++ b/resources/images/design_slot.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_slot_arc.svg b/resources/images/design_slot_arc.svg new file mode 100644 index 0000000000..f17ab89144 --- /dev/null +++ b/resources/images/design_slot_arc.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_step.svg b/resources/images/design_step.svg new file mode 100644 index 0000000000..4bdf7811ff --- /dev/null +++ b/resources/images/design_step.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_surface.svg b/resources/images/design_surface.svg new file mode 100644 index 0000000000..1299dd1a97 --- /dev/null +++ b/resources/images/design_surface.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_svg.svg b/resources/images/design_svg.svg new file mode 100644 index 0000000000..34a0685153 --- /dev/null +++ b/resources/images/design_svg.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_sweep.svg b/resources/images/design_sweep.svg new file mode 100644 index 0000000000..f1cc301b1e --- /dev/null +++ b/resources/images/design_sweep.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_tangentarc.svg b/resources/images/design_tangentarc.svg new file mode 100644 index 0000000000..9e53da5c8e --- /dev/null +++ b/resources/images/design_tangentarc.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_text.svg b/resources/images/design_text.svg new file mode 100644 index 0000000000..78da223f62 --- /dev/null +++ b/resources/images/design_text.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_thicken.svg b/resources/images/design_thicken.svg new file mode 100644 index 0000000000..bfcd356cfa --- /dev/null +++ b/resources/images/design_thicken.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_thread.svg b/resources/images/design_thread.svg new file mode 100644 index 0000000000..9bff4d778b --- /dev/null +++ b/resources/images/design_thread.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_trim.svg b/resources/images/design_trim.svg new file mode 100644 index 0000000000..405e9fe3c4 --- /dev/null +++ b/resources/images/design_trim.svg @@ -0,0 +1 @@ + diff --git a/resources/images/tab_design_active.svg b/resources/images/tab_design_active.svg new file mode 100644 index 0000000000..36ffc87ee5 --- /dev/null +++ b/resources/images/tab_design_active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/images/toolbar_modifier_cube_dark.svg b/resources/images/toolbar_modifier_cube_dark.svg new file mode 100644 index 0000000000..7e20cd0dd5 --- /dev/null +++ b/resources/images/toolbar_modifier_cube_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/images/toolbar_sketch.svg b/resources/images/toolbar_sketch.svg new file mode 100644 index 0000000000..351b3e7924 --- /dev/null +++ b/resources/images/toolbar_sketch.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/resources/images/toolbar_sketch_dark.svg b/resources/images/toolbar_sketch_dark.svg new file mode 100644 index 0000000000..e5cf8d6859 --- /dev/null +++ b/resources/images/toolbar_sketch_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/scripts/CAD/README.md b/scripts/CAD/README.md new file mode 100644 index 0000000000..861b77a976 --- /dev/null +++ b/scripts/CAD/README.md @@ -0,0 +1,53 @@ +# Design-tab scripts + +Everything here supports the parametric Design tab (`src/libslic3r/CAD/`, +`src/slic3r/GUI/CAD/`). Nothing here is needed to build or run OrcaSlicer — these +are the development and verification tools for that one feature. + +The verb in the name is the role: + +| | | +|---|---| +| `build-…` | produce a binary | +| `start-…` | bring something up and leave it running | +| `run-…` | run a suite and report pass/fail | +| `check-…` | one specific assertion, usually driving a live app | + +## Verification + +| Script | What it proves | Needs | +|---|---|---| +| `run-kernel-tests.sh` | The CAD kernel builds and the Catch2 `[CadDocument]` tags pass — every case builds a document, recomputes it and asserts on real geometry. **Exit 0 is the verification contract.** | Docker only. No display. | +| `run-all-checks.sh` | Every check below, in one command. The gate before pushing a Design-tab change. | Docker + the GUI container | +| `check-sketch-engine.py` | A ladder of 2D sketches of increasing complexity, judged on loop count, closure and void attribution rather than on area. | Kernel only | +| `check-sketch-engine-corpus.py` | The same ladder graded against a systematic sample of real drawings instead of shapes we chose. | Kernel + corpus | +| `check-gui-sketching.py` | The same profiles drawn the way a person draws them — synthetic mouse gestures and typed values. | Headless GUI | +| `check-gui-context-menu.py` | That right-click is the pivot of the design gesture, and adapts to what was clicked. | Headless GUI | +| `check-mcp-sketch.py` | The sketch layer driven over the MCP socket, asserting what decides whether a profile is buildable. | Headless GUI + `ORCA_CAD_MCP` | + +**`run-kernel-tests.sh` is the only one CI can run.** The rest need a live +application with an OpenGL canvas and synthetic input, which hosted runners do not +have. The kernel suite itself is already in CI by an ordinary route: the cases are +registered in `tests/libslic3r/CMakeLists.txt` under `if (SLIC3R_CAD)`, so they are +part of `libslic3r_tests` and run under `ctest` on every platform like any other +unit test. This script exists for the local loop, where it is a two-minute round +trip instead of a full application build. + +## Build and run + +| Script | Purpose | +|---|---| +| `build-gui.sh` | Build the GUI binary in a throwaway container, writing into the build-cache volume the long-lived GUI container reads. | +| `build-gui-incremental.sh` | Incremental build against the deps-baked image, for a fast edit/compile loop. | +| `start-headless-gui.sh` | Bring the app up on a headless X display (Xvfb + a window manager), ready to drive or attach to over VNC. | + +Two constraints that are not obvious and have each cost a session: + +- **Never build inside the GUI container.** Its baked source tree silently + reconfigures the shared build directory and this fork's targets vanish. +- **A window manager is required.** Without one, windows are never focused, and an + unfocused GTK app ignores synthetic keys — which looks exactly like a code bug. + +`docs/rig_build_traps.md` documents these and three more, with symptoms and exact +recovery commands. Read it before debugging a configure or link failure one of +these scripts reports. diff --git a/scripts/CAD/build-gui-incremental.sh b/scripts/CAD/build-gui-incremental.sh new file mode 100755 index 0000000000..539a21c973 --- /dev/null +++ b/scripts/CAD/build-gui-incremental.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Incremental slicer build against the orcacad-deps base image. +# +# The deps-baked image (built from scripts/Dockerfile.deps) carries the pinned +# dependencies at /OrcaSlicer/deps/build/destdir. This script mounts the LIVE source +# tree and resources over the baked copy so code/CMake edits apply immediately, and +# persists /OrcaSlicer/build in a named volume so ninja recompiles only what changed. +# +# Result: edit -> rebuild in seconds-to-minutes instead of a full Docker rebuild. +# +# Usage (run on the build host, e.g. behemoth, from anywhere): +# scripts/CAD/build-gui-incremental.sh +# IMAGE=orcacad-deps scripts/CAD/build-gui-incremental.sh +# +# On success the binary is inside the persistent volume at +# /OrcaSlicer/build/package/bin/orca-slicer (copy it out with a follow-up +# `docker run --rm -v orcacad_buildcache:/b alpine cp ...` or via this script's tail). +# Rig build traps already paid for once each (stale project, NLopt cache, pybind11, OCCT_LIBS, SLIC3R_CAD gate): docs/rig_build_traps.md +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# orcacad-deps, NOT snapmaker-deps: see the note in run-kernel-tests.sh — the wrong image +# fails at CMake configure, not at link time. +IMAGE="${IMAGE:-orcacad-deps}" +BUILD_VOL="${BUILD_VOL:-orcacad_buildcache}" + +echo "REPO=$REPO IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL" + +# The root CMakeLists.txt and cmake/ must be mounted too, not taken from the baked image: +# they carry the build-time gates (e.g. SLIC3R_CAD -> add_definitions(-DSLIC3R_CAD)) that the +# mounted headers are compiled against. With a stale baked copy the gate silently stays off and +# the build fails with "class GLCanvas3D has no member named set_design_sketch_tool". +# +# build_linux.sh must be mounted for the same reason, and here the stale copy is guaranteed +# wrong rather than merely risky: orcacad-deps is layered on snapmaker-deps, so the baked script +# is the OTHER fork's and builds `--target Snapmaker_Orca`. This fork's target is `OrcaSlicer`, +# so without this mount configure succeeds and then ninja dies on "unknown target". +# scripts/ likewise: build_linux.sh's packaging step sources scripts/appimage_lib_policy.sh, +# which the baked Snapmaker tree does not have, so a fully successful link still exited +# non-zero with "missing AppImage helper" and the binary check never ran. + +# ---- OOM guard (2026-08-21) ------------------------------------------------------------- +# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus +# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable, +# lightdm destroyed. Neither build produced a single object. scripts/CAD/build-gui.sh grew the +# bounds first; every script that starts a compile needs the same three, or the guard is only +# as strong as the script you happened not to use. +# flock — the lock path is SHARED with build-gui.sh and the other fork on purpose, so +# concurrent builds serialise instead of summing. +# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average. +# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking +# the host down. --memory-swap equal to --memory forbids swap, which is what made +# ssh hang. +JOBS="${JOBS:-12}" +MEM="${MEM:-40g}" +LOCK=/tmp/orca-rig-build.lock + +exec 9>"$LOCK" +if ! flock -n 9; then + echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)" + flock 9 +fi + +docker run --rm \ + --memory="$MEM" --memory-swap="$MEM" \ + -v "$REPO/src":/OrcaSlicer/src \ + -v "$REPO/resources":/OrcaSlicer/resources \ + -v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \ + -v "$REPO/cmake":/OrcaSlicer/cmake \ + -v "$REPO/deps_src":/OrcaSlicer/deps_src \ + -v "$REPO/build_linux.sh":/OrcaSlicer/build_linux.sh \ + -v "$REPO/scripts":/OrcaSlicer/scripts \ + -v "$BUILD_VOL":/OrcaSlicer/build \ + "$IMAGE" \ + bash -lc "cd /OrcaSlicer && ./build_linux.sh -sr -j $JOBS" + +# src/CMakeLists.txt:151 renames the OrcaSlicer target's output to "orca-slicer" — not +# "snapmaker-orca", which is the other fork's binary name. +echo "=== build finished; checking for binary ===" +docker run --rm -v "$BUILD_VOL":/b "$IMAGE" \ + bash -lc 'ls -lh /b/package/bin/orca-slicer 2>/dev/null && file /b/package/bin/orca-slicer || echo "NO BINARY"' diff --git a/scripts/CAD/build-gui.sh b/scripts/CAD/build-gui.sh new file mode 100755 index 0000000000..0a1a86dd5c --- /dev/null +++ b/scripts/CAD/build-gui.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Rebuild the GUI binary the design rig launches — in a THROWAWAY container, writing into the +# same build-cache volume the rig's long-lived GUI container reads from. +# +# NEVER build inside the GUI container (snapmaker-gui / orcacad-gui). Its baked /OrcaSlicer tree +# is the Jun-13 Snapmaker-derived source, so a `cmake .` in there silently reconfigures the +# shared build dir as project(Snapmaker_Orca) and this fork's targets vanish. That is Trap 1 of +# five; all of them, with symptoms and exact recovery commands, are in docs/rig_build_traps.md. +# Read that file before debugging a configure or link failure this script reports. +# +# Usage: +# scripts/CAD/build-gui.sh # configure + build the fork's GUI target +# DRY_RUN=1 scripts/CAD/build-gui.sh # print the resolved fork identity and exit, no container +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Fork identity is DERIVED from the repo, never hardcoded, so this file is byte-identical in +# both forks and cannot be mirrored into the wrong one. Pointing a fork at the other fork's +# image or volume is not a slow failure: with the wrong image CMake dies at configure, and with +# the wrong volume the two forks silently trade build artefacts. +PROJECT="$(sed -n 's/^project(\([A-Za-z_0-9]*\)).*/\1/p' "$REPO/CMakeLists.txt" | head -1)" +case "$PROJECT" in + Snapmaker_Orca) PREFIX=snapmaker; BIN=snapmaker-orca ;; + OrcaSlicer) PREFIX=orcacad; BIN=orca-slicer ;; + *) echo "FATAL: unrecognised project($PROJECT) in $REPO/CMakeLists.txt" >&2; exit 2 ;; +esac +TARGET="$PROJECT" +IMAGE="${PREFIX}-deps" +BUILD_VOL="${PREFIX}_buildcache" + +echo "REPO=$REPO PROJECT=$PROJECT IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL TARGET=$TARGET BIN=$BIN" + +if [ -n "${DRY_RUN:-}" ]; then + echo "DRY_RUN: resolution only, no container started" + exit 0 +fi + +# Three memory bounds. On 2026-08-21 both forks ran this script at the same time, each with +# ninja -j$(nproc)=16: ~36 cc1plus holding 42 GB of a 62 GB box -> global OOM at 21:05, a +# 2h28m kill storm, ssh unreachable, lightdm destroyed, 2946 session kill events. Neither +# build produced a single object. The bounds, weakest to strongest: +# flock — the lock path is shared by both forks on purpose, so they SERIALISE instead of +# summing. Peak is one build's worth no matter who else starts one. +# -j12 — measured 1.17 GB average per cc1plus in the incident dump, so 12 in flight +# is ~14 GB typical and leaves the box usable. JOBS=n overrides. +# --memory — the actual guarantee. A runaway build hits its own cgroup limit and dies alone; +# the host never reaches global OOM again, whatever -j or flock do. +# --memory-swap equal to --memory forbids swap, which is what made ssh hang. +JOBS="${JOBS:-12}" +MEM="${MEM:-40g}" +LOCK=/tmp/orca-rig-build.lock + +exec 9>"$LOCK" +if ! flock -n 9; then + echo "another fork's build-gui holds $LOCK — waiting (this is the OOM guard, not a hang)" + flock 9 +fi + +# Every one of these mounts covers a trap, none is decorative: +# CMakeLists.txt + cmake/ carry the SLIC3R_CAD gate — inherit the baked copies and the cache +# says SLIC3R_CAD=ON while -DSLIC3R_CAD is never defined, so every #ifdef block compiles out. +# deps_src/ carries pybind11, which the image predates. +# src/, resources/, localization/, version.inc are the code under test. +rc=0 +docker run --rm \ + --memory="$MEM" --memory-swap="$MEM" \ + -v "$REPO/src":/OrcaSlicer/src \ + -v "$REPO/resources":/OrcaSlicer/resources \ + -v "$REPO/cmake":/OrcaSlicer/cmake \ + -v "$REPO/deps_src":/OrcaSlicer/deps_src \ + -v "$REPO/localization":/OrcaSlicer/localization \ + -v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \ + -v "$REPO/version.inc":/OrcaSlicer/version.inc \ + -v "$BUILD_VOL":/OrcaSlicer/build \ + "$IMAGE" bash -lc " + cd /OrcaSlicer/build || exit 1 + cmake . > /tmp/cfg.log 2>&1 || { echo 'CONFIGURE FAILED'; tail -25 /tmp/cfg.log; exit 1; } + # Twice, deliberately. src/libslic3r/CMakeLists.txt publishes OCCT_LIBS as CACHE INTERNAL + # at the END of its own configure, so a first pass after that list changes links the + # PREVIOUS one and drops TKBool/TKOffset — a wall of TopOpeBRepBuild undefined references + # that reads as a broken OCCT install and is not. Trap 4. + cmake . > /tmp/cfg2.log 2>&1 || { echo 'RECONFIGURE FAILED'; tail -25 /tmp/cfg2.log; exit 1; } + ninja -f build-Release.ninja -j$JOBS $TARGET > /tmp/bld.log 2>&1 + rc=\$? + echo \"EXIT=\$rc\" + grep -n 'error:' /tmp/bld.log | head -20 + tail -4 /tmp/bld.log + ls -la /OrcaSlicer/build/src/Release/$BIN 2>/dev/null + exit \$rc + " || rc=$? + +# A target-only build writes src/Release/, but this fork's start-headless-gui.sh may default BIN to the +# PACKAGED path that only build_linux.sh refreshes — launching with the default would then run a +# stale binary. Pass BIN explicitly. See docs/rig_build_traps.md. +echo "=== launch the rig on the binary just built ===" +echo " docker exec -e BIN=/OrcaSlicer/build/src/Release/$BIN ${PREFIX}-gui /OrcaSlicer/scripts/CAD/start-headless-gui.sh" +exit "$rc" diff --git a/scripts/CAD/check-gui-click-edit.py b/scripts/CAD/check-gui-click-edit.py new file mode 100755 index 0000000000..159bdb19f1 --- /dev/null +++ b/scripts/CAD/check-gui-click-edit.py @@ -0,0 +1,658 @@ +#!/usr/bin/env python3 +"""The click-edit contract: a value field that opens must accept what is TYPED into it. + +WHY THIS EXISTS SEPARATELY FROM check-gui-sketching.py. That ladder draws geometry and grades the +result, and to make its values land it calls focus_field() — one synthetic click INTO the field +before typing. Its own docstring says why: + + WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED. The field is shown and raised but the + window manager does not give it the keyboard, so xdotool's digits go to the canvas and Return + commits the value the field opened with — the pre-filled as-drawn number. + +That click is a workaround for a defect, and a suite that performs it can never see the defect +again. A user cannot be told to click the field first; when they do not, they get the as-drawn +number and report "the label value is not editable". So this ladder types IMMEDIATELY after the +field opens, exactly as a person does, and fails if the prefill is what gets committed. + +WHAT IT GRADES. The app emits one line per event under ORCA_CAD_UXTRACE=1: + + [UX] open title=Length prefill=154.76 + [UX] commit title=Length typed=80 value=80.0000 + [UX] refused title=Length typed=8O + [UX] cancel title=Length + +For every field the driver opens it asserts: a commit arrived, what the field received is what we +typed, the parsed value equals it, and it differs from the prefill. The last clause is the one +that matters — a field that is on screen but deaf commits its prefill, and every other signal +(the field is visible, a constraint is created, the solve succeeds) looks perfectly healthy. + + scripts/CAD/check-gui-click-edit.py --display :10 --bin build/src/Release/orca-slicer + +With --attach it drives an already-running app instead of launching one; the app must have been +started with ORCA_CAD_UXTRACE=1 and its stderr redirected to --trace. +Exit 0 = every field took what was typed. +""" +import argparse, json, os, re, shutil, signal, socket, subprocess, sys, tempfile, time + +AP = argparse.ArgumentParser() +AP.add_argument("--display", default=os.environ.get("DISPLAY", ":10")) +AP.add_argument("--bin", default="build/src/Release/orca-slicer") +AP.add_argument("--datadir", default="") +AP.add_argument("--trace", default="") +AP.add_argument("--sock", default="/tmp/mcp-uxcheck.sock", + help="the app's MCP socket: the oracle for whether a sketch is really open") +AP.add_argument("--attach", action="store_true", help="drive a running app; do not launch one") +AP.add_argument("--keep", action="store_true", help="leave the app running afterwards") +AP.add_argument("--no-defocus", action="store_true", + help="do NOT take focus off the field before typing (weakens the gate; see below)") +AP.add_argument("--seed-from", default=os.path.expanduser("~/.config/OrcaCAD/OrcaSlicer.conf"), + help="an existing OrcaSlicer.conf to copy presets/settings from") +A = AP.parse_args() + +DISP = A.display +TRACE = A.trace or os.path.join(tempfile.gettempdir(), "ux-click-edit.log") +_fail = 0 +_checks = 0 + + +_n = 0 + + +def call(method, **params): + """One MCP request over the app's unix socket. The socket is the only witness that cannot + lie about sketch state: the keytrace says a key ARRIVED, a screenshot says something is on + screen, and neither distinguishes an open sketch from sketch mode with the plane offer up.""" + global _n + _n += 1 + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(30) + s.connect(A.sock) + s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method, + "params": params}) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + d = s.recv(65536) + if not d: + break + buf += d + r = json.loads(buf.decode().strip()) + if "error" in r: + raise RuntimeError(f"{method}: {r['error']['message']}") + return r["result"] + + +def try_call(method, **params): + try: + return call(method, **params) + except Exception: + return None + + +def sh(cmd): + # bash -c, NOT -lc: a login shell sources the profile on every xdotool call, and this driver + # makes hundreds. On a GNOME box that meant im-config running per call, thousands of journal + # lines, and a window poll slow enough to time out before the app had finished starting. + return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True).stdout + + +def xdo(args): + sh(f"DISPLAY={DISP} xdotool {args}") + + +def key(k, pause=0.35, window=None): + xdo(f"key {'--window ' + str(window) + ' ' if window else ''}{k}") + time.sleep(pause) + + +def typ(s, pause=0.35, window=None): + # --clearmodifiers so a modifier left down by an earlier synthetic key cannot turn digits + # into something else; --delay 60 because ImGui reads one character per frame. + # + # `window` targets a specific window with XSendEvent instead of following the input focus. + # That is the whole gate: see type_into_open_field. + tgt = f"--window {window} " if window else "" + xdo(f"type {tgt}--clearmodifiers --delay 60 -- '{s}'") + time.sleep(pause) + + +def die(msg): + print(f"FATAL {msg}", file=sys.stderr) + sys.exit(2) + + +# ---------------------------------------------------------------- the app + +_proc = None + + +def seed_datadir(datadir): + """The Design tab does not exist unless enable_cad_feature is on, and it needs a RESTART. + + A fresh datadir has it off, so a driver that just points the app at an empty directory gets + Prepare/Preview/Device/Project, no Design tab, and every rung fails for a reason that has + nothing to do with what is being tested. Seed the flag before the first launch. + """ + os.makedirs(datadir, exist_ok=True) + conf = os.path.join(datadir, "OrcaSlicer.conf") + data = {} + if os.path.exists(A.seed_from): + try: + with open(A.seed_from) as f: + data = json.load(f) + except Exception: + data = {} + app = data.setdefault("app", {}) + app["enable_cad_feature"] = True + # Deterministic starting state for the rungs that follow: the bed drawn, loops welded as the + # ~90% case expects. A ladder whose result depends on the developer's own preferences is not + # a gate. + app["auto_close_sketch_loops"] = True + # SILENCE THE NETWORK PLUGIN PROMPT. Without this, GUI_App::post_init() re-raises "Bambu + # Network Plug-in Required" from an IDLE event — after any modal sweep this driver does at + # startup — and ShowModal() then runs a nested event loop. The app is alive, its window is + # there, and the MCP socket answers nothing: indistinguishable from a hang, and it was + # investigated as one, with gdb, twice. `installed_networking` false stops the whole + # networking-plugin path, so m_networking_need_update is never set and the dialog never + # exists to be swept. + app["installed_networking"] = False + with open(conf, "w") as f: + json.dump(data, f, indent=1) + for sub in ("user", "system", "presets", "vendor"): + src = os.path.join(os.path.dirname(A.seed_from), sub) + dst = os.path.join(datadir, sub) + if os.path.isdir(src) and not os.path.exists(dst): + shutil.copytree(src, dst) + + +def launch(): + global _proc + datadir = A.datadir or os.path.join(tempfile.gettempdir(), "orcacad-uxcheck") + seed_datadir(datadir) + env = dict(os.environ) + # WAYLAND_DISPLAY MUST GO, and GDK_BACKEND must say x11. GTK prefers Wayland whenever + # WAYLAND_DISPLAY is set and ignores DISPLAY entirely, so a driver launched from a systemd + # user unit (which inherits it) started the app on the DESKTOP session instead of the rig: + # the process was alive, `xdotool search` on the rig display found nothing, and the window + # was sitting on the user's own screen. Silent, and it drives a stray app at someone's face. + env.pop("WAYLAND_DISPLAY", None) + env.update(DISPLAY=DISP, GDK_BACKEND="x11", ORCA_CAD_UXTRACE="1", + LIBGL_ALWAYS_SOFTWARE="1", GALLIUM_DRIVER="llvmpipe", + # The rig's Xvfb has no input-method daemon, and a dead ibus context makes a + # GtkEntry drop every character while the app looks fine. It cannot affect the + # in-canvas field (ImGui needs no IM) but the app has other text fields, and a + # display full of IBUS warnings has cost a whole misdiagnosis before. + GTK_IM_MODULE="gtk-im-context-simple", XMODIFIERS="@im=none", + # The key tracer is this driver's only positive signal that a keystroke reached + # the Design panel at all. Without it "the field never opened" is indistinguishable + # from "we never got into sketch mode", and the first run of this ladder reported + # seven product failures that were really one driver racing a still-loading app. + ORCA_CAD_KEYTRACE="1", ORCA_CAD_MCP=A.sock, + SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt", + WEBKIT_DISABLE_DMABUF_RENDERER="1", WEBKIT_DISABLE_COMPOSITING_MODE="1") + if os.path.exists(A.sock): + os.unlink(A.sock) # a stale socket from a dead run answers nothing, slowly + log = open(TRACE, "wb") + _proc = subprocess.Popen([A.bin, "--datadir", datadir], env=env, + stdout=subprocess.DEVNULL, stderr=log) + for _ in range(120): + if win_id(): + return + time.sleep(1) + die("the app never showed a window on " + DISP) + + +def window_pid(w): + """_NET_WM_PID for a window, or 0. The property is how we tell a live app from its ghost.""" + out = sh(f"DISPLAY={DISP} xprop -id {w} _NET_WM_PID 2>/dev/null") + m = re.search(r"= *(\d+)", out) + return int(m.group(1)) if m else 0 + + +def pid_alive(pid): + return pid > 0 and os.path.isdir(f"/proc/{pid}") + + +def win_id(): + """The main window: OURS if we launched it, otherwise the biggest LIVE top-level. + + Two rules here, each paid for. + + By PID, not by size, whenever we launched the app. An X window outlives its client if the + connection is not torn down cleanly, and a killed OrcaSlicer can leave a full-screen ghost + mapped on the display. It answers geometry queries exactly like the real thing, it wins "the + biggest window" every time, and every synthetic keystroke sent to it goes nowhere. That is + indistinguishable, from the driver's side, from an app that ignores the keyboard — which is + the very defect this ladder exists to measure. One run reported the entire contract broken + while the real app sat beside the ghost, untouched. + + Never by title: a saved project renames the main window. + """ + if _proc is not None: + for w in sh(f"DISPLAY={DISP} xdotool search --pid {_proc.pid} --onlyvisible --name '.'").split(): + g = dict(l.split("=", 1) for l in + sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines() + if "=" in l) + if "WIDTH" in g and int(g["WIDTH"]) * int(g["HEIGHT"]) > 400 * 400: + return (w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])) + return None + best = None + for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split(): + g = dict(l.split("=", 1) for l in + sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines() + if "=" in l) + if "WIDTH" not in g: + continue + if not pid_alive(window_pid(w)): # a ghost: no client is behind it any more + continue + a = int(g["WIDTH"]) * int(g["HEIGHT"]) + if a > 400 * 400 and (best is None or a > best[0]): + best = (a, w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])) + return best[1:] if best else None + + +_win = None + + +def win(): + global _win + if _win is None: + w = win_id() + if w is None: + die("no app window on " + DISP) + sh(f"DISPLAY={DISP} xdotool windowactivate --sync {w[0]}") + sh(f"DISPLAY={DISP} xdotool windowsize {w[0]} 1920 1080") + sh(f"DISPLAY={DISP} xdotool windowmove {w[0]} 0 0") + time.sleep(1.0) + _win = (w[0], 0, 0, 1920, 1080) + return _win + + +def click(px, py, pause=0.5, btn=1): + _, X, Y, _, _ = win() + xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}") + time.sleep(pause) + + +def visible_windows(): + """(id, name, w, h) for every MAPPED top-level, main window included. + + `--onlyvisible` is what makes this usable. Without it xdotool also returns the app's unmapped + helper windows — a 10x10 and a 200x200 that exist for the whole session — and a caller that + tries to reason about "extra windows" from that list is reasoning about furniture. + """ + out = [] + for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split(): + g = dict(l.split("=", 1) for l in + sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines() + if "=" in l) + if "WIDTH" not in g: + continue + if not pid_alive(window_pid(w)): # see win_id(): a ghost cannot be closed, only ignored + continue + n = sh(f"DISPLAY={DISP} xdotool getwindowname {w}").strip() + out.append((w, n, int(g["WIDTH"]), int(g["HEIGHT"]))) + return out + + +def dismiss_modals(timeout=30): + """Close every modal over the main window, and PROVE none is left. + + This is the rung that decides whether any of the others mean anything. A fresh datadir opens + "Bambu Network Plug-in Required" — 440x259, centred at 742,450 — which sits exactly on top of + the point every drawing gesture in TOOLS starts from. The whole ladder then reports eleven + product failures, all of them the driver clicking a dialog. + + The old version pressed Escape and moved on. This dialog ignores Escape, so it "dismissed" + nothing and said so to no one; the run that found this was red for a reason that had nothing + to do with the contract under test. Escape is still tried first because it is the gentlest + thing that works on the wizard, then WM_DELETE_WINDOW, and then the function asserts what it + was supposed to have achieved instead of assuming it. + """ + # NEVER run without knowing which window to spare. The first version took `keep = main[0] if + # main else None`, so a win_id() that raced the app's mapping made keep None and every visible + # window a modal — this function then sent WM_DELETE to the app's own main window. The app + # survived as a process, printed "GdkWindow unexpectedly destroyed", and answered nothing + # afterwards; the ladder reported "no sketch opened" for 180s. Losing the main window is not a + # state to recover from silently. + deadline = time.time() + timeout + keep = None + while keep is None and time.time() < deadline: + main = win_id() + keep = main[0] if main else None + if keep is None: + time.sleep(0.5) + if keep is None: + die(f"no main window to protect after {timeout}s — refusing to close anything") + scr = sh(f"DISPLAY={DISP} xdotool getdisplaygeometry").split() + full = int(scr[0]) * int(scr[1]) if len(scr) == 2 else 1920 * 1080 + while time.time() < deadline: + # A modal is small. Anything covering half the screen is the app, whatever id win_id() + # happened to return this instant — a second belt on the rule above, because the cost of + # being wrong here is an app that looks alive and answers nothing. + extra = [x for x in visible_windows() if x[0] != keep and x[2] * x[3] < full * 0.5] + if not extra: + return + for (w, n, _, _) in extra: + sh(f"DISPLAY={DISP} xdotool windowactivate {w}") + time.sleep(0.4) + key("Escape", 0.4) + if any(x[0] == w for x in visible_windows()): + sh(f"DISPLAY={DISP} xdotool windowclose {w}") + time.sleep(0.6) + time.sleep(0.5) + left = [f"{n!r} ({w}x{h})" for (i, n, w, h) in visible_windows() + if i != keep and w * h < full * 0.5] + die("a modal is still covering the canvas after " + str(timeout) + "s: " + ", ".join(left) + + " — every drawing gesture would land in it, so nothing below this line could be trusted") + + +def dismiss_first_run(): + dismiss_modals() + + +# ---------------------------------------------------------------- the trace + +def trace_lines(): + try: + with open(TRACE, "r", errors="replace") as f: + return [l.strip() for l in f if l.startswith("[UX] ")] + except OSError: + return [] + + +def trace_mark(): + return len(trace_lines()) + + +def parse(line): + m = re.match(r"\[UX\] (\w+) title=(.*?) (.*)$", line) + if not m: + return None + ev, title, rest = m.group(1), m.group(2), m.group(3) + kv = dict(re.findall(r"(\w+)=(\S*)", rest)) + return ev, title, kv + + +# ---------------------------------------------------------------- grading + +def check(cond, what): + """Returns the verdict so a caller can abandon a rung whose precondition failed.""" + global _fail, _checks + _checks += 1 + if cond: + print(f" ok {what}") + else: + print(f" FAIL {what}", file=sys.stderr) + _fail += 1 + return bool(cond) + + +def type_into_open_field(value, mark): + """Type `value` into whatever field is open, WITHOUT clicking it first, and grade the pair. + + No click: the click is the workaround this ladder exists to refuse. If the field cannot take + the keyboard on its own, `typed` will be the prefill and this fails — which is the report. + """ + # POLL for the field. It opens from a CallAfter that runs after a re-solve, so on llvmpipe it + # is simply not there yet when a fast driver looks — and "no field opened" is the same message + # whether the product never opened one or the driver asked too early. Wait, then decide. + opens = [] + deadline = time.time() + 8.0 + while time.time() < deadline: + opens = [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"] + if opens: + break + time.sleep(0.25) + if not opens: + check(False, f"a value field opened (nothing did; cannot type {value})") + return mark + title = opens[-1][1] + prefill = opens[-1][2].get("prefill", "") + m2 = trace_mark() + # TYPE NORMALLY. NOTHING TO DEFOCUS ANY MORE. + # + # The value field is drawn INSIDE the GL canvas by ImGui, so it is not a window: there is no + # second toplevel for a window manager to grant or refuse the keyboard, and the keystrokes go + # to the app's one window exactly as a person's would. That is the entire point of the design + # — the WM has no say — and it is why this ladder no longer tries to manufacture the failing + # condition. + # + # When the field WAS a floating wxFrame, this spot held two attempts to reproduce + # "field open, keyboard elsewhere", and both are recorded here so neither is tried again: + # - XSetInputFocus onto the main window (`xdotool windowfocus`): the field's own re-focus + # CallAfter wins the race every time; four retries all lost, and the ladder passed twice + # against a binary with the fix compiled out. + # - XSendEvent at the main window (`xdotool type --window`): GTK discards synthetic key + # events, so NEITHER build received anything and every run was red regardless of the code. + # A run that used the second of those is what produced "the app never saw a digit" — a + # property of xdotool, not of the product. + # + # For the in-canvas field the honest gate is simply: type, and see whether the value the app + # commits is the value that was typed. + diag = sh(f"DISPLAY={DISP} xdotool getwindowfocus").strip() + typ(str(value), 0.4) + key("Return", 0.9) + after, commits, refused, commit_at = [], [], [], None + deadline = time.time() + 5.0 + while time.time() < deadline: + after = [parse(l) for l in trace_lines()[m2:]] + commits = [(i, e) for i, e in enumerate(after) if e and e[0] == "commit"] + refused = [e for e in after if e and e[0] == "refused"] + if commits: + commit_at = m2 + commits[-1][0] + commits = [e for _, e in commits] + if commits or refused: + break + time.sleep(0.25) + if refused and not commits: + check(False, f"{title}: field REFUSED {value!r} (typed={refused[-1][2].get('typed')!r})") + key("Escape", 0.5) + return trace_mark() + if not commits: + check(False, f"{title}: typed {value} but nothing committed — the field took no keys") + key("Escape", 0.5) + return trace_mark() + typed = commits[-1][2].get("typed", "") + got = commits[-1][2].get("value", "") + check(typed == str(value), + f"{title}: field received what was typed (typed={typed!r} wanted={value!r}" + f"{' <-- it committed its PREFILL, so it never got the keyboard' if typed == prefill else ''})") + # A value that will not parse is a FAILED CHECK, never an exception. An unguarded float() + # here met a locale-formatted "61,0000" and took the whole run down immediately after the + # first check in the ladder's history had passed — the seven rungs below it were never tried + # and the report read as a total failure. + try: + ok_val = abs(float(got) - float(value)) < 1e-6 + except (TypeError, ValueError): + ok_val = False + check(ok_val, f"{title}: committed value is {got!r} (wanted {value})") + check(str(value) != prefill, f"{title}: the test value differs from the prefill {prefill!r}") + # RESUME JUST AFTER THE COMMIT, not at the end of the trace. A queued chain opens its next + # field from the commit callback, so by the time trace_mark() is read here that "open" line + # is already written — and the next call, searching only after this mark, never sees it. The + # rectangle's Height, the slot's Radius and the label reopen all failed as "nothing did" + # while the trace plainly showed the field open and waiting. + return (commit_at + 1) if commit_at is not None else trace_mark() + + +# ---------------------------------------------------------------- the ladder + +def enter_sketch(timeout=180): + """Open a real sketch on a real plane, and PROVE it with the socket before drawing anything. + + THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane + offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the + viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder + did two of them — Design tab, then Shift+S — and went straight to the tool letters. + + That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and + traced, and not one click draws anything, because there is no plane under them. The ladder + then reports eleven product failures, all of them "a value field opened (nothing did)", and + every one is the driver's. Two whole runs were spent on it. + + So the gate is the ORACLE, not the keytrace: sketch_describe answers only when a sketch is + genuinely open. Waiting on a mode flag is what allowed the wrong state to pass for the right + one in the first place. + """ + deadline = time.time() + timeout + while time.time() < deadline: + click(132, 53) # Design tab + time.sleep(2.0) + dismiss_modals() + click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S + key("shift+s", 1.0) + key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it + key("p", 0.6) # any sketch tool starts the session on that plane + if try_call("sketch_describe") is not None: + # NO Escape here. Every rung already opens with one to drop whatever tool the last + # one left armed, and Escape in the Design tab walks a LIFO: first press drops the + # armed tool, second LEAVES THE SKETCH. Pressing it here made that second press the + # rung's own, so the ladder exited the sketch before drawing anything and then + # reported all eleven checks failed with "nothing opened" — the tools were arming + # into an empty Feature-mode document. + return + die("no sketch opened after plane click + Shift+S within " + f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})") + + +# tool key, the clicks that draw it, and one distinct value per queued field. The values are +# deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them. +# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture +# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does. +PLANE_PX = (913, 359) + +# Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's +# calibration probes land four Points in, i.e. the region PROVEN to be live canvas on a 1920x1080 +# window. Earlier values started at x=950, which is left of that box and also, on a fresh datadir, +# underneath the "Bambu Network Plug-in Required" modal. +TOOLS = [ + ("L", "Line", [(1030, 540), (1360, 540)], [61]), + ("R", "Rectangle", [(1030, 540), (1360, 730)], [62, 43]), + ("C", "Circle", [(1180, 620), (1330, 620)], [64]), + ("S", "Slot", [(1030, 580), (1300, 580), (1300, 640)], [66]), + ("G", "Polygon", [(1180, 620), (1320, 620)], [67]), + ("E", "Ellipse", [(1180, 620), (1370, 620), (1180, 720)], [68]), + ("A", "Arc", [(1040, 660), (1340, 660), (1190, 560)], [69]), +] + + +def rung_tool(k, name, clicks, values): + print(f" {name}") + key("Escape", 0.6) # back to Select, whatever the last tool left armed + # Every rung re-establishes that a sketch is STILL open. One stray Escape too many leaves it, + # and from then on every tool arms into a Feature-mode document that cannot open a value + # field — which the checks below report as eleven independent product failures. + if try_call("sketch_describe") is None: + die(f"{name}: the sketch is no longer open before this rung — an earlier rung left it") + key(k, 0.8) + # MARK BEFORE THE CLICKS, not after. The field is opened from a CallAfter scheduled by the + # render that follows the last click, so it can already be open by the time a mark taken + # afterwards is read — and type_into_open_field, which only looks at events AFTER its mark, + # then finds none and reports "a value field opened (nothing did)" for a field that is on + # screen, open, and waiting. That message accused the product of the exact defect the ladder + # exists to detect, from a bug in the ladder's own bookkeeping. + mark = trace_mark() + for (x, y) in clicks: + click(x, y) + for v in values: + mark = type_into_open_field(v, mark) + + +def rung_rounded_rect(): + """The shape the user actually reported: a ROUNDED rectangle, Width -> Height -> Radius. + + It has no keyboard shortcut — the rectangle family binds R to CornerRect and leaves the other + modes in the toolbar flyout — so TOOLS above cannot reach it and the whole three-step chain + went untested. `run_verb` arms it the way the offer menu does. + + NOTE the id: the OFFER verb is `sk_rect_rounded`; `design_rect_rounded` is the ACTION name and + run_verb throws on it, leaving the tool as Select. A run that misses that draws nothing and + still reaches its assertions, so arm-and-verify rather than arm-and-hope. + """ + print(" Rounded rectangle") + key("Escape", 0.6) + tool = None + for _ in range(8): + try_call("run_verb", verb="sk_rect_rounded") + time.sleep(0.8) + tool = (try_call("sketch_describe") or {}).get("tool") + if tool == "rect_rounded": + break + if not check(tool == "rect_rounded", f"the rounded-rectangle tool armed (tool={tool!r})"): + return + mark = trace_mark() + click(1030, 540); click(1330, 700); click(1300, 660) # corners, then the radius point + for v in (63, 41, 7): + mark = type_into_open_field(v, mark) + + +def rung_label_click(): + """The user's own report: click an existing dimension label and type a new value into it. + + KEEP THE SHAPE AS DRAWN. An earlier version committed 55 and 47 into the queued chain first, + which resized the rectangle — and then clicked the pixel where the label had been before the + resize. It missed, every time, and reported the reopen broken. The shape's on-screen position + is only predictable if nothing has moved it, so Escape the chain instead: the rectangle stays + exactly between the two corners we clicked. + + FIND THE LABEL, do not assume its offset. A dimension label is drawn beside its edge at an + offset that depends on zoom and text metrics, so a single hardcoded pixel is a guess that + silently becomes wrong. Walk a short band across the top edge instead and stop at the first + click that opens a field; if none of them does, that is a real failure and it says so. + """ + print(" label click-to-edit") + key("Escape", 0.6) # Select mode + key("R", 0.8) + click(1020, 530) + click(1350, 740) + time.sleep(1.5) + key("Escape", 0.8) # keep as drawn: abandon the queued value chain + time.sleep(0.8) + key("Escape", 0.6) # back to Select so a click picks rather than draws + + mid_x, top_y = (1020 + 1350) // 2, 530 + candidates = [(mid_x, top_y + dy) for dy in (-26, -20, -14, -8, 0, 8, 14)] + for (cx, cy) in candidates: + mark = trace_mark() + click(cx, cy) + deadline = time.time() + 2.0 + while time.time() < deadline: + if [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]: + check(True, f"clicking a dimension label reopened its value field (at {cx},{cy})") + type_into_open_field(71, mark) + return + time.sleep(0.2) + check(False, "clicking a dimension label reopened its value field " + f"(tried {len(candidates)} points across the top edge at x={mid_x})") + + +def main(): + if not A.attach: + if not os.path.exists(A.bin): + die(f"no binary at {A.bin}") + open(TRACE, "w").close() + launch() + dismiss_first_run() + win() + print(f"click-edit ladder on {DISP}, trace {TRACE}") + enter_sketch() + for (k, name, clicks, values) in TOOLS: + rung_tool(k, name, clicks, values) + rung_rounded_rect() + rung_label_click() + print() + if _fail: + print(f"CLICK-EDIT LADDER FAILED — {_fail} of {_checks} checks", file=sys.stderr) + else: + print(f"CLICK-EDIT LADDER HELD — {_checks} checks") + if _proc is not None and not A.keep: + _proc.send_signal(signal.SIGTERM) + try: + _proc.wait(20) + except subprocess.TimeoutExpired: + _proc.kill() + return 1 if _fail else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/CAD/check-gui-context-menu.py b/scripts/CAD/check-gui-context-menu.py new file mode 100644 index 0000000000..28e404ecf0 --- /dev/null +++ b/scripts/CAD/check-gui-context-menu.py @@ -0,0 +1,1218 @@ +#!/usr/bin/env python3 +"""The OFFER ladder: prove that right-click is the pivot, and that it adapts to what was clicked. + +The gesture ladder (scripts/CAD/check-gui-sketching.py) proved the TARGET — a complex closed profile, precise +in vertices, lengths, arcs and symmetry, with its voids correctly attributed. It proved it by +arming every tool with a letter key. That leaves the goal's own MECHANISM untested: the design +logic pivots on right-click, and the verbs offered are supposed to adapt to the element under the +cursor. Half the vocabulary is only reachable that way — 47 of 86 Design-tab verbs have a GUI +action and no shortcut, so a key-driven ladder cannot reach them at all. + +This ladder drives the menu. Nothing here is asserted from pixels: + + WHAT WAS CLICKED -> the offer's own [OFFER] trace, emitted by show_offer_menu from the same + loop that builds the rows (ORCA_CAD_KEYTRACE). It cannot drift from what + the user is shown, which a hand-written expectation list would. + WHAT IS OFFERED -> the same trace, compared against DesignOffer.hpp parsed independently. + "The menu shows exactly the verbs the table says apply here" is a + property; a copied list of row names is a transcription. + WHAT IT PRODUCED -> the MCP socket, read-only, exactly as in the gesture ladder. + +Run inside the rig container, with the app launched under ORCA_CAD_KEYTRACE=1: + + docker exec orcacad-gui python3 /OrcaSlicer/scripts/CAD/check-gui-context-menu.py [rung ...] +""" +import importlib.util +import math +import os +import re +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# The gesture ladder owns the hand and the eye: the homography per sketch, the window lookup by +# class, the synthetic click, the typed value, the socket. Importing it is the only way those +# stay one implementation — a second copy would drift the first time a rig detail moved. +_spec = importlib.util.spec_from_file_location("gui_ladder", os.path.join(HERE, "check-gui-sketching.py")) +G = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(G) + +LOG = os.environ.get("ORCA_CAD_GUI_LOG", "/tmp/gui-session.log") + +# The rig container's /OrcaSlicer is the image's own baked source tree, not this checkout, so the +# generated header is not where a repo-relative path expects it. Look in both places and say which +# one was read — a ladder that silently graded against the WRONG table would be worse than one +# that refuses to start. +HEADER_CANDIDATES = [os.environ.get("ORCA_CAD_OFFER_HPP", ""), + os.path.join(HERE, "..", "src", "slic3r", "GUI", "CAD", "DesignOffer.hpp"), + os.path.join(HERE, "DesignOffer.hpp")] + +# OfferSel, in the order the generated enum declares it. The trace reports the integer; a test +# that printed "kind=16" and expected the reader to know what that is would be half a test. +SEL = ["None", "FacePlanar", "FaceCyl", "FaceOther", "EdgeStr", "EdgeCirc", "Vertex", + "BodySolid", "BodySheet", "Bodies2", "DatumPlane", "DatumAxis", "CoordSys", "Art", + "SkLoop", "SkNone", "SkLine", "SkArc", "SkPoint", "Sk2Ent"] + + +# ---------------------------------------------------------------- the table, parsed + +def load_table(): + """Every verb in DesignOffer.hpp, as dicts. The independent half of the comparison. + + Parsed from the generated header rather than from tool_atlas.json on purpose: the header is + what the binary was compiled from, and the two have been out of step before (ziam, + where regenerating the header silently dropped the Constrain row). + """ + path = next((p for p in HEADER_CANDIDATES if p and os.path.exists(p)), None) + if path is None: + raise SystemExit("no DesignOffer.hpp found; set ORCA_CAD_OFFER_HPP or copy it beside " + "this script (tried: " + ", ".join(filter(None, HEADER_CANDIDATES)) + ")") + print(f"offer table: {os.path.realpath(path)}") + src = open(path).read() + body = src[src.index("kOfferVerbs[]"):] + body = body[:body.index("\n};")] + out = [] + for line in body.splitlines(): + line = line.strip() + if not line.startswith('{"'): + continue + # id, name, row, key, action, refusal, accepts, need_bodies, need_sketches, need_sheet, + # sketch_mode, family, ... — split on top-level commas, respecting quotes. + f, cur, q, esc = [], "", False, False + for ch in line[1:]: + if esc: + cur += ch; esc = False; continue + if ch == "\\": + cur += ch; esc = True; continue + if ch == '"': + q = not q + if ch == "," and not q: + f.append(cur.strip()); cur = ""; continue + if ch == "}" and not q: + break + cur += ch + f.append(cur.strip()) + if len(f) < 12: + continue + lit = lambda s: None if s == "nullptr" else s.strip('"') + out.append({"id": lit(f[0]), "name": lit(f[1]), "row": int(f[2]), "key": lit(f[3]), + "action": lit(f[4]), "accepts": int(f[6].rstrip("u"), 0), + "need_bodies": int(f[7]), "need_sketches": int(f[8]), + "need_sheet": f[9] == "true", "sketch_mode": f[10] == "true", + "family": lit(f[11])}) + return out + + +TABLE = load_table() + + +def predicted(kind, sketching, bodies=0, sketches=0, sheet=False): + """The verbs show_offer_menu should list for this selection — the table's own answer. + + Mirrors the `applies` lambda and the sketch_mode gate in DesignPanel::show_offer_menu. If the + two ever disagree, one of them is the bug; this ladder says which selection exposed it. + """ + bit = 1 << kind + return [v["id"] for v in TABLE + if v["sketch_mode"] == sketching and (v["accepts"] & bit) + and v["need_bodies"] <= bodies and v["need_sketches"] <= sketches + and (not v["need_sheet"] or sheet)] + + +# ---------------------------------------------------------------- the trace, read back + +def log_mark(): + """Where the log ends now, so the next read sees only this gesture's lines.""" + try: + return os.path.getsize(LOG) + except OSError: + return 0 + + +def log_since(mark): + with open(LOG, "rb") as fh: + fh.seek(mark) + return fh.read().decode("utf-8", "replace") + + +class Offer: + """One opening of the offer menu, as the app described it while building the rows.""" + + def __init__(self, text): + self.kind = None + self.sketching = None + self.entries = [] # top level, in order: dicts with label/enabled/verbs + self.verbs = [] # every live verb id, in menu order + for m in re.finditer(r"^\[OFFER\] (.*)$", text, re.M): + self._line(m.group(1)) + + def _line(self, s): + head = re.match(r"open kind=(\d+) sketching=(\d+) bodies=(\d+)", s) + if head: + self.kind = int(head.group(1)) + self.sketching = head.group(2) == "1" + self.entries = [] + self.verbs = [] + return + dis = re.match(r"row=(\d+) (.*?) DISABLED \((.*)\)$", s) + if dis: + self.entries.append({"row": int(dis.group(1)), "label": dis.group(2), + "enabled": False, "kids": [], "why": dis.group(3)}) + return + one = re.match(r"row=(\d+) (.*?) -> (\S+)(?: \(no GUI route\))?$", s) + if one: + routed = "(no GUI route)" not in s + self.entries.append({"row": int(one.group(1)), "label": one.group(2), + "enabled": routed, "kids": [], "verb": one.group(3)}) + self.verbs.append(one.group(3)) + return + sub = re.match(r"row=(\d+) (.*?) > (.*)$", s) + if sub: + row, label, rest = int(sub.group(1)), sub.group(2), sub.group(3) + routed = "(no GUI route)" not in rest + rest = rest.replace(" (no GUI route)", "") + fam, vid = (rest.split(" > ", 1) + [None])[:2] if " > " in rest else (None, rest) + if not self.entries or self.entries[-1]["row"] != row or "verb" in self.entries[-1]: + self.entries.append({"row": row, "label": label, "enabled": True, "kids": []}) + self.entries[-1]["kids"].append({"family": fam, "verb": vid, "enabled": routed}) + self.verbs.append(vid) + return + + def kind_name(self): + return SEL[self.kind] if self.kind is not None and self.kind < len(SEL) else str(self.kind) + + def path_to(self, verb): + """Keyboard path to a verb: how many Downs at each level, top level first. + + GTK skips insensitive items on arrow navigation, so the count is over ENABLED entries + only — which is exactly why the trace records the enabled state per row instead of the + ladder assuming every row is live. + """ + n = 0 + for e in self.entries: + if not e["enabled"]: + continue + n += 1 + if e.get("verb") == verb: + return [n] + if e["kids"]: + # Families become a nested submenu at the position of their first member. + pos, seen = 0, [] + for k in e["kids"]: + if k["family"]: + if k["family"] not in seen: + seen.append(k["family"]) + pos += 1 + fam_pos = pos + if k["verb"] == verb: + inner = [x for x in e["kids"] if x["family"] == k["family"]] + j = sum(1 for x in inner[:inner.index(k) + 1] if x["enabled"]) + return [n, fam_pos, j] + continue + if not k["enabled"]: + continue + pos += 1 + if k["verb"] == verb: + return [n, pos] + return None + + +def open_offer(X=None, Y=None, pause=1.2): + """Right-click (on the plane point given, or wherever the cursor is) and read the offer back. + + The menu is modal — PopupMenu blocks the main thread — so no socket call may be made between + this and choose()/dismiss(). Every assertion about the menu comes from the trace. + """ + mark = log_mark() + if X is None: + G.xdo("click --delay 120 3") + else: + G.clickmm(X, Y, pause=0.5, btn=3) + time.sleep(pause) + return Offer(log_since(mark)) + + +def choose(offer, verb): + """Walk the open menu to a verb with the keyboard and activate it.""" + path = offer.path_to(verb) + if path is None: + G.die(f"{verb} is not in the offer (kind={offer.kind_name()}, has {offer.verbs})") + for level, downs in enumerate(path): + # At the top level nothing is highlighted when the menu pops, so the first Down lands on + # entry 1. Inside a submenu GTK has ALREADY highlighted its first item as part of opening + # it, so reaching entry k takes k-1 more. Getting this wrong is silent: the walk activates + # a neighbouring verb and the rung grades a shape nobody asked for — the first run of this + # rung drew a circle of area 45238.93 and called it a rectangle. + for _ in range(downs if level == 0 else downs - 1): + G.key("Down", 0.12) + if level < len(path) - 1: + G.key("Right", 0.35) # open the submenu; its first item is now highlighted + G.key("Return", 0.9) + + +def dismiss(offer=None): + """Escape ONLY when a menu is really open. + + A stray Escape on the canvas is not harmless: with no tool armed and no points down, the + sketch's layered exit reads it as "leave the sketch", and the next socket call answers + "no sketch is open" three rungs from where the mistake was made. + """ + if offer is not None and offer.kind is None: + return + G.key("Escape", 0.5) + + +def menu_windows(): + """X windows that are override-redirect popups — evidence the menu really opened.""" + out = [] + for w in G.sh(f"DISPLAY={G.DISP} xdotool search --class '.'").split(): + g = G.sh(f"DISPLAY={G.DISP} xdotool getwindowgeometry --shell {w}") + d = dict(l.split("=", 1) for l in g.strip().splitlines() if "=" in l) + if "WIDTH" in d and 60 < int(d["WIDTH"]) < 700 and 40 < int(d["HEIGHT"]) < 900: + out.append(w) + return out + + +# ---------------------------------------------------------------- rungs + +def draw_line_at(x0, y0, x1, y1, length): + """Draw one horizontal line and COMMIT it by typing its length and angle. + + Deliberately no Escape. Escape is overloaded in a sketch — field, then tool, then the sketch + itself — so a driver that presses it one time too many leaves the session and every later + assertion answers "no sketch is open" from three rungs away. Typing the value closes the + field, which is what the gesture ladder proved commits exactly. + """ + G.key("l", 0.5) + G.clickmm(x0, y0) + G.clickmm(x1, y1) + G.values(int(length), 0) + keep_as_drawn() # drain any straggler field before the caller clicks anything + + +def rung_kinds(): + """O1 — the offer adapts to the element under the cursor, one element type at a time.""" + print("\nO1 the offer reads what was right-clicked") + fresh_sketch("l") + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + + # Empty space first: nothing is selected, so the sketch vocabulary's no-selection row set. + # Right-click has two jobs on a draw tool, and which one it does depends on whether an + # anchor is down. Both are asserted here: the version that consumed EVERY right-click made + # the offer unreachable from any armed tool (ghcz), which is the goal's own + # mechanism failing silently. + hi = y1 - (y1 - y0) * 0.12 + o = open_offer(cx, hi) + G.check("OFFER", o.kind is not None, + "Line armed, nothing anchored: right-click opens the offer") + G.check("OFFER", o.kind == 15, f"right-click on empty space -> {o.kind_name()}") + G.check("OFFER", o.sketching, "the sketch vocabulary is the one being offered") + dismiss(o) + + # ...and with an anchor down it abandons the anchor instead, offering nothing. + G.key("l", 0.5) + G.clickmm(cx, hi) # anchor the first point + o = open_offer(cx + (x1 - x0) * 0.1, hi) + G.check("OFFER", o.kind is None, + "with an anchor down, the same gesture abandons it and does not offer") + o = open_offer(cx, hi) + G.check("OFFER", o.kind is not None, "and the offer is back on the next right-click") + dismiss(o) + G.key("Escape", 0.4) + + # A line. + ax, ay = x0 + (x1 - x0) * 0.15, cy + bx, by = x0 + (x1 - x0) * 0.55, cy + draw_line_at(ax, ay, bx, by, 40) + o = open_offer((ax + bx) / 2.0, ay) + G.check("OFFER", o.kind == 16, f"right-click on a line -> {o.kind_name()}") + dismiss(o) + + # A circle: every curve takes the same vocabulary, which is what SkArc means. + G.key("c", 0.5) + ccx, ccy = x0 + (x1 - x0) * 0.30, cy + (y1 - y0) * 0.25 + G.clickmm(ccx, ccy) + G.clickmm(ccx + (x1 - x0) * 0.10, ccy) + G.values(20) + ents = G.describe()["entities"] + circ = [e for e in ents if e["type"] == "circle"] + G.check("OFFER", len(circ) == 1, "one circle drawn to right-click on") + r = circ[0]["radius"] + o = open_offer(circ[0]["center"][0] + r, circ[0]["center"][1]) + G.check("OFFER", o.kind == 17, f"right-click on a circle -> {o.kind_name()}") + dismiss(o) + + # A point. + G.key("p", 0.5) + pxx, pyy = x0 + (x1 - x0) * 0.80, cy + (y1 - y0) * 0.25 + G.clickmm(pxx, pyy) + o = open_offer(pxx, pyy) + G.check("OFFER", o.kind == 18, f"right-click on a point -> {o.kind_name()}") + dismiss(o) + + # Two entities: a second line, then both picked. Two LINES rather than line-plus-point on + # purpose — the Sk2Ent vocabulary (angle, equal, parallel, the two-entity constraints) is + # about pairs of curves, so the pair the ladder builds should be the pair the verbs mean. + draw_line_at(ax, ay - (y1 - y0) * 0.18, bx, ay - (y1 - y0) * 0.18, 40) + # ONE Escape, to drop the armed tool to Select, and only once the value fields are quiet. + # Left-click means "draw" while a tool is armed, so a picking gesture has to say so first — + # and from a draw tool with no anchor down Escape does exactly that and nothing more; it is + # only a second Escape, from Select, that would leave the sketch. + keep_as_drawn() + G.key("Escape", 0.5) + G.clickmm(*on_line((ax, ay), (bx, ay))) + G.xdo("keydown shift") + G.clickmm(*on_line((ax, ay - (y1 - y0) * 0.18), (bx, ay - (y1 - y0) * 0.18))) + G.xdo("keyup shift") + d = G.describe() + G.check("OFFER", len(d["selection"]) == 2, + f"two entities picked: {d['selection']} (tool={d['tool']} pending={d['pending']} " + f"editing={d['editing']})") + o = open_offer((ax + bx) / 2.0, ay) + G.check("OFFER", o.kind == 19, + f"right-click with two picked -> {o.kind_name()}" + + ("" if o.kind is not None else + f" (no menu: tool={G.describe()['tool']} pending={G.describe()['pending']} " + f"editing={G.describe()['editing']})")) + dismiss(o) + G.leave_sketch() + G.reset_document() + + +def rung_vocabulary(): + """O2 — the rows offered are exactly the ones the table says apply to that selection. + + Five selections, not one. The interesting failure is not "the menu is empty", it is "the + menu is the same whatever you clicked" — and only comparing several kinds against their own + predictions can tell those apart. + """ + print("\nO2 the menu and the offer table agree, selection by selection") + fresh_sketch("l") + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + ax, ay = x0 + (x1 - x0) * 0.15, cy + bx = x0 + (x1 - x0) * 0.55 + draw_line_at(ax, ay, bx, ay, 40) + + G.key("c", 0.5) + ccx, ccy = x0 + (x1 - x0) * 0.30, cy + (y1 - y0) * 0.25 + G.clickmm(ccx, ccy); G.clickmm(ccx + (x1 - x0) * 0.10, ccy) + G.values(20) + circ = [e for e in G.describe()["entities"] if e["type"] == "circle"][0] + + G.key("p", 0.5) + pxx, pyy = x0 + (x1 - x0) * 0.80, cy + (y1 - y0) * 0.25 + G.clickmm(pxx, pyy) + + where = [("SkNone", cx, y1 - (y1 - y0) * 0.12), + ("SkLine", (ax + bx) / 2.0, ay), + ("SkArc", circ["center"][0] + circ["radius"], circ["center"][1]), + ("SkPoint", pxx, pyy), + ("Sk2Ent", None, None)] + seen = {} + for name, X, Y in where: + if name == "Sk2Ent": + # The two-entity vocabulary was the one selection nothing compared against the table, + # and it is where the missing row hid: sk_angdist accepts Sk2Ent and nothing else, so + # an off-by-one that dropped the LAST verb was invisible from every other selection. + keep_as_drawn() + G.key("Escape", 0.5) + G.clickmm(*on_line((ax, ay), (bx, ay))) + G.xdo("keydown shift") + # The TOP of the circle, not its +X point: the radius grip lives there, and a click on + # a grip arms a handle drag which REPLACES the selection with that one entity. The + # pick then silently collapses to one and the offer answers SkLine — right, for the + # selection that actually existed. + G.clickmm(circ["center"][0], circ["center"][1] + circ["radius"]) + G.xdo("keyup shift") + picked = len(G.describe()["selection"]) + G.check("OFFER", picked == 2, f"two entities picked for the pair vocabulary: {picked}") + X, Y = (ax + bx) / 2.0, ay + o = open_offer(X, Y) + want = sorted(predicted(o.kind, o.sketching)) + got = sorted(o.verbs) + G.check("OFFER", o.kind is not None and o.kind_name() == name and got == want, + f"{o.kind_name()}: {len(got)} verbs, exactly the table's set" + + ("" if got == want else f"\n menu {got}\n table {want}")) + seen[name] = set(got) + dismiss(o) + + # And the sets are genuinely DIFFERENT — an offer that adapts is not one that always shows + # the same rows. Without this, four identical menus would have passed four checks. + G.check("OFFER", len(set(map(frozenset, seen.values()))) == len(seen), + "every selection offers a different set: " + + ", ".join(f"{k}={len(v)}" for k, v in seen.items())) + G.check("OFFER", seen["SkLine"] - seen["SkNone"], + f"a picked line adds {len(seen['SkLine'] - seen['SkNone'])} verbs an empty pick has not: " + + ", ".join(sorted(seen["SkLine"] - seen["SkNone"])[:8])) + G.leave_sketch() + G.reset_document() + + +def rung_author(): + """O3 — the target itself, authored through the menu: no tool key is pressed anywhere here.""" + print("\nO3 a precise closed profile, drawn entirely from the right-click offer") + fresh_sketch("p") # 'p' only to open the session; calibration needs the Point tool + G.key("Escape", 0.4) + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + w, h = 120.0, 80.0 + + o = open_offer(cx, cy) + G.check("OFFER", o.kind == 15, f"empty sketch -> {o.kind_name()}") + choose(o, "sk_rect") + G.clickmm(cx - w / 2.0, cy - h / 2.0) + G.clickmm(cx + w / 2.0, cy + h / 2.0) + G.values(int(w), int(h)) + ents = G.describe()["entities"] + G.check("LENGTH", G.lengths(ents) == [80.0, 80.0, 120.0, 120.0], + f"sides {G.lengths(ents)} — typed through the offer, exact") + lp = G.loops() + G.check("CLOSED", len(lp) == 1, f"{len(lp)} closed loop(s)") + G.check("AREA", G.near(abs(lp[0]["area"]), w * h, 1e-9), f"area {abs(lp[0]['area']):.6f}") + G.leave_sketch() + G.reset_document() + + +def rung_no_shortcut(): + """O4 — a tool with NO keyboard route at all, armed from the menu and graded on its geometry. + + This is the half of the vocabulary a key-driven ladder cannot reach: 47 of the 86 Design-tab + verbs have a GUI action and no shortcut, and for those the offer is not one door, it is the + only door. Arming the tool is not the assertion — the exact rectangle it then draws is. + """ + print("\nO4 a tool that has no shortcut, reached the only way it can be") + fresh_sketch("p") + G.key("Escape", 0.5) + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + + o = open_offer(cx, cy) + keyless = [v for v in TABLE if v["id"] in o.verbs and not v["key"] and v["action"]] + G.check("OFFER", len(keyless) >= 10, + f"{len(keyless)} of the {len(o.verbs)} verbs offered here have no shortcut at all") + G.check("OFFER", any(v["id"] == "sk_rect_center" for v in keyless), + "centre rectangle among them — unreachable from the keyboard") + + w, h = 120.0, 80.0 + choose(o, "sk_rect_center") + G.clickmm(cx, cy) # centre + G.clickmm(cx + w / 2.0, cy + h / 2.0) # corner + G.values(int(w), int(h)) + ents = G.describe()["entities"] + G.check("LENGTH", G.lengths(ents) == [80.0, 80.0, 120.0, 120.0], + f"sides {G.lengths(ents)} from a tool with no key") + lp = G.loops() + G.check("CLOSED", len(lp) == 1 and G.near(abs(lp[0]["area"]), w * h, 1e-9), + f"{len(lp)} closed loop, area {abs(lp[0]['area']):.6f}") + xs = sorted({round(p, 6) for e in ents for p in (e["p0"][0], e["p1"][0])}) + ys = sorted({round(p, 6) for e in ents for p in (e["p0"][1], e["p1"][1])}) + # Centred on the CLICK, to within the click itself. A synthetic click lands on a whole + # pixel, so the plane point it names is only ever as exact as one pixel is — grading this to + # 1e-6 would be grading the homography, not the tool. What is exact is the SHAPE, and that + # is asserted above; what is asserted here is that this was a centre rectangle and not a + # corner one, which a whole pixel is plenty to tell apart at 120 x 80. + tol = 1.5 * G.mm_per_px(cx, cy) + mx, my = (xs[0] + xs[-1]) / 2.0, (ys[0] + ys[-1]) / 2.0 + G.check("SYMMETRY", abs(mx - cx) <= tol and abs(my - cy) <= tol, + f"centred on the click within {tol:.3f} mm (one pixel): " + f"off by {abs(mx - cx):.4f}, {abs(my - cy):.4f} — a CENTRE rectangle, not a corner one") + G.leave_sketch() + G.reset_document() + + + +# ---------------------------------------------------------------- fixtures + +def keep_as_drawn(): + """Close an in-canvas value field if one is open, keeping the geometry as drawn. + + Checked, never assumed. Escape is overloaded: with a field open it means keep-as-drawn, with + none open it drops the armed tool, and one Escape too many leaves the sketch. The socket now + reports whether a field IS open ("editing"), so this presses the key only when it means what + the caller wants it to mean. + """ + n = 0 + # A LOOP, not one press: the auto-edit queue opens the next field from a CallAfter as the + # previous one commits (a rectangle queues Width then Height), so one Escape leaves a second + # field on screen and the canvas still frozen. The loop stops the moment nothing is open, + # which is what keeps the last press from being the one that drops the tool. + # + # And it waits for QUIET, not for a single false reading. A field that has not opened YET + # reads exactly like one that will never open, so a driver that looks once, sees nothing and + # moves on gets frozen by the field that arrives a moment later — with no symptom except + # that clicks stop working. Measured: this rung passed alone and failed inside the gate, + # where the app is warmer and the CallAfter lands later; the diagnostic that found it was + # editing=True with an empty selection after two clicks that should have picked two entities. + for _ in range(8): + time.sleep(0.35 * G.PACE) + if not G.describe().get("editing"): + time.sleep(0.35 * G.PACE) + if not G.describe().get("editing"): + return n + G.key("Escape", 0.45) + n += 1 + return n + + +def clear_sketch(): + """Empty the live sketch through the socket. + + Fixture TEARDOWN, not the thing under test: what is being graded is always the geometry a + verb just produced, never how the canvas got emptied. Doing it through the socket keeps each + verb's rung independent without paying for a fresh sketch (four calibration probes) each time. + """ + keep_as_drawn() # a shape left mid-edit freezes the canvas for whatever comes next + n = len(G.describe()["entities"]) + if n: + G.call("sketch_delete", entities=list(range(n))) + + +# Which tool each creation verb is supposed to arm. The menu walk counts rows, and a walk that +# lands ONE ROW OFF arms a neighbouring tool and then draws something plausible with it — the +# first run of this rung drew a circle and graded it as a rectangle. Asserting the armed tool +# turns that whole class of silent misnavigation into a loud failure at the point it happens. +ARMS = {"sk_polyline": "polyline", "sk_rect": "rect_corner", "sk_rect_center": "rect_center", + "sk_rect_oblique": "rect_oblique", "sk_rect_rounded": "rect_rounded", + "sk_circle_2pt": "circle_2pt", "sk_circle_3pt": "circle_3pt", + "sk_arc_tangent": "arc_tangent", "sk_arc_center": "arc_center", + "sk_slot_arc": "slot_arc", "sk_ellipse_arc": "ellipse_arc", + "sk_poly_3": "polygon", "sk_poly_4": "polygon", "sk_poly_5": "polygon", + "sk_poly_8": "polygon", "sk_poly_12": "polygon", + "sk_move": "move", "sk_rotate": "rotate", "sk_scale": "scale", + "sk_array": "array", "sk_array_polar": "array_polar"} + + +def arm(verb, X, Y, check_tool=True): + """Open the offer on empty plane at (X, Y) and pick a verb out of it. No key is ever pressed.""" + o = open_offer(X, Y) + if o.kind is None: + G.die(f"the offer did not open for {verb} (tool={G.describe().get('tool')}, " + f"pending={G.describe().get('pending')})") + choose(o, verb) + if check_tool and verb in ARMS: + got = G.describe().get("tool") + G.check("OFFER", got == ARMS[verb], f"{verb} armed the {got} tool") + return o + + +def ents(kind=None): + e = G.describe()["entities"] + return [x for x in e if kind is None or x["type"] == kind] + + +def clicked(X, Y): + """The plane point the app REALLY saw for clickmm(X, Y). + + A synthetic click lands on a whole pixel, so the plane point it names is not the one asked + for. Rounding the pixel and mapping it back is what the app got, and grading a construction + against it is grading the tool rather than the driver's arithmetic. + """ + u, v = G.px(X, Y) + return G.unpx(int(u), int(v)) + + +def fresh_sketch(tool): + """Enter a sketch from a KNOWN empty state, whatever the previous rung or run left behind. + + check-gui-sketching's enter_sketch dismisses the old session with keys, and a key is exactly what an + open value field swallows — so a session that should have been cancelled survives, the four + calibration probes land in it on top of whatever was already there, and the run dies with + "calibration expected 4 points, got 7". Cancelling through the socket cannot be swallowed: + it reaches the tool directly. This is fixture teardown, not the thing under test. + """ + G.try_call("sketch_cancel") + G.key("Escape", 0.3) + G.enter_sketch(tool) + + +def on_line(a, b, t=0.3): + """A point a fraction t ALONG a line — never its midpoint. + + A left-click within ~24 px of a live dimension label opens that dimension's value editor + instead of selecting anything (the Select branch tests m_live_quotes before it picks), and a + line's Length quote sits at its middle. Clicking there froze the canvas on an open field, so + the following clicks and the right-click all landed on nothing and the pair vocabulary was + never reached. It bit only when the previous step had left the line selected — live quotes are + drawn for the SELECTION — which is why it passed alone and failed inside the gate. + """ + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + + +def poly_click(pt): + """One click of a multi-segment tool, then close whatever value field that click opened. + + The polyline arms a Length field after EVERY segment, and a field freezes the canvas — so a + driver that just clicks four times places two points and loses the rest. Nothing had ever + exercised the polyline (it has no shortcut), so nothing had ever met this. + """ + G.clickmm(*pt) + keep_as_drawn() + + +def dist(a, b): + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +def spread(vals): + return max(vals) - min(vals) + + +# ---------------------------------------------------------------- the keyless 2D vocabulary + +def rung_curves(): + """O5 — every 2D creation verb that has no shortcut, drawn from the offer and graded exactly. + + These are reachable ONLY from the right-click menu, so nothing has ever exercised them. The + assertions are CONSTRUCTION invariants — a regular polygon's vertices are equidistant, a + tangent arc meets its line at a right angle to the radius, a circumscribed polygon's + circumradius is the inscribed one's over cos(pi/n) — because those hold exactly whatever + pixel the click landed on. Where a value field opens, the typed value is graded exactly too. + """ + print("\nO5 the 2D creation verbs that have no keyboard route") + fresh_sketch("p") + G.key("Escape", 0.5) + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + W, H = (x1 - x0), (y1 - y0) + free = (cx, y1 - H * 0.10) # a corner of the safe box that stays empty to right-click + + # --- Polyline: an explicitly CLOSED chain, which is the goal's own shape ------------------ + clear_sketch() + arm("sk_polyline", *free) + ring = [(cx - W * 0.20, cy - H * 0.15), (cx + W * 0.20, cy - H * 0.15), + (cx + W * 0.20, cy + H * 0.15), (cx - W * 0.20, cy + H * 0.15)] + for pt in ring: + poly_click(pt) + poly_click(ring[0]) # click the start again: the explicit close + lines = ents("line") + lp = G.loops() + G.check("CLOSED", len(lines) == 4 and len(lp) == 1, + f"sk_polyline: {len(lines)} lines, {len(lp)} closed loop — closed by clicking the start") + + # --- Oblique rectangle: three clicks, and the point of it is that it is NOT axis-aligned -- + clear_sketch() + arm("sk_rect_oblique", *free) + a = (cx - W * 0.20, cy - H * 0.10) + b = (cx + W * 0.15, cy + H * 0.05) # first edge, deliberately skew + G.clickmm(*a); G.clickmm(*b); G.clickmm(cx - W * 0.10, cy + H * 0.20) + q = ents("line") + G.check("LENGTH", len(q) == 4, f"sk_rect_oblique: {len(q)} lines") + if len(q) == 4: + L = sorted(round(e["length"], 9) for e in q) + G.check("LENGTH", L[0] == L[1] and L[2] == L[3], + f"opposite sides equal to 1e-9: {L}") + angs = [] + for i in range(4): + for j in range(i + 1, 4): + u = (q[i]["p1"][0] - q[i]["p0"][0], q[i]["p1"][1] - q[i]["p0"][1]) + v = (q[j]["p1"][0] - q[j]["p0"][0], q[j]["p1"][1] - q[j]["p0"][1]) + c = abs(u[0] * v[0] + u[1] * v[1]) / (math.hypot(*u) * math.hypot(*v)) + angs.append(c) + G.check("ANGLE", sum(1 for c in angs if c < 1e-9) == 4, + f"four right angles to 1e-9 ({sum(1 for c in angs if c < 1e-9)} perpendicular pairs)") + d0 = (q[0]["p1"][0] - q[0]["p0"][0], q[0]["p1"][1] - q[0]["p0"][1]) + G.check("ANGLE", abs(d0[0]) > 1e-6 and abs(d0[1]) > 1e-6, + f"and it really is oblique: first edge {math.degrees(math.atan2(*d0[::-1])):.3f} deg") + + # --- Rounded rectangle: four lines, four arcs, one radius -------------------------------- + clear_sketch() + arm("sk_rect_rounded", *free) + G.clickmm(cx - W * 0.20, cy - H * 0.15) + G.clickmm(cx + W * 0.20, cy + H * 0.15) + G.clickmm(cx + W * 0.20 - W * 0.04, cy + H * 0.15) # third click sets the radius + ls, ar = ents("line"), ents("arc") + G.check("ARC", len(ls) == 4 and len(ar) == 4, f"sk_rect_rounded: {len(ls)} lines + {len(ar)} arcs") + if len(ar) == 4: + rr = sorted(round(e["radius"], 9) for e in ar) + G.check("ARC", spread(rr) == 0.0, f"all four fillets share one radius to 1e-9: {rr[0]}") + lp = G.loops() + G.check("CLOSED", len(lp) == 1, f"{len(lp)} closed loop") + if len(lp) == 1: + xs = [p for e in ls for p in (e["p0"][0], e["p1"][0])] + ys = [p for e in ls for p in (e["p0"][1], e["p1"][1])] + # The four straight sides already span the FULL outer box — the top edge runs from + # x_min+r to x_max-r at y_max — so their bbox is the rectangle itself, and the + # rounding costs the four corner squares less their quarter-discs: r^2(4 - pi). + w, h, r = max(xs) - min(xs), max(ys) - min(ys), rr[0] + want = w * h - r * r * (4 - math.pi) + G.check("AREA", G.near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.9f} vs W*H - r^2(4-pi) {want:.9f}") + + # --- Two-point circle: the two clicks are the ends of a diameter -------------------------- + clear_sketch() + arm("sk_circle_2pt", *free) + p_a = (cx - W * 0.18, cy - H * 0.10) + p_b = (cx + W * 0.18, cy + H * 0.10) + G.clickmm(*p_a); G.clickmm(*p_b) + c2 = ents("circle") + G.check("ARC", len(c2) == 1, f"sk_circle_2pt: {len(c2)} circle") + if c2: + A, B = clicked(*p_a), clicked(*p_b) + mid = ((A[0] + B[0]) / 2.0, (A[1] + B[1]) / 2.0) + tol = 1.5 * G.mm_per_px(cx, cy) + G.check("VERTEX", dist(c2[0]["center"], mid) <= tol, + f"centred on the midpoint of the two clicks (off by {dist(c2[0]['center'], mid):.4f} mm)") + G.check("ARC", abs(c2[0]["radius"] - dist(A, B) / 2.0) <= tol, + f"radius {c2[0]['radius']:.6f} vs half the click separation {dist(A, B) / 2.0:.6f}") + opened = bool(G.describe().get("editing")) + G.check("OFFER", opened, "a radius field opens for it, as it does for the keyed circle") + if opened: + G.value(30) + got = ents("circle")[0]["radius"] + G.check("ARC", G.near(got, 30.0, 1e-9), + f"and it takes a typed radius exactly: {got:.9f} (asked 30.0)") + # The DoF of ONE CIRCLE is three. Asserted here because it is where the lie showed: + # after a delete the solver was never re-run, so this reported the DoF of the + # geometry that had just been erased. ua9g. + G.check("VERTEX", G.describe()["dof"] == 2, + f"and the sketch reports the DoF of what is actually in it: {G.describe()['dof']}") + + # --- Three-point circle: all three clicks lie on it --------------------------------------- + clear_sketch() + arm("sk_circle_3pt", *free) + three = [(cx - W * 0.18, cy), (cx, cy + H * 0.18), (cx + W * 0.16, cy - H * 0.06)] + for pt in three: + G.clickmm(*pt) + c3 = ents("circle") + G.check("ARC", len(c3) == 1, f"sk_circle_3pt: {len(c3)} circle") + if c3: + ds = [dist(clicked(*pt), c3[0]["center"]) for pt in three] + tol = 1.5 * G.mm_per_px(cx, cy) + G.check("ARC", spread(ds) <= tol and abs(ds[0] - c3[0]["radius"]) <= tol, + f"all three clicks lie on it: distances {[round(d, 4) for d in ds]} " + f"vs radius {c3[0]['radius']:.4f}") + + # --- Centre arc: centre, start, end ------------------------------------------------------- + clear_sketch() + arm("sk_arc_center", *free) + C = (cx, cy) + G.clickmm(*C); G.clickmm(cx + W * 0.15, cy); G.clickmm(cx, cy + H * 0.15) + aa = ents("arc") + G.check("ARC", len(aa) == 1, f"sk_arc_center: {len(aa)} arc") + if aa: + tol = 1.5 * G.mm_per_px(cx, cy) + G.check("VERTEX", dist(aa[0]["center"], clicked(*C)) <= tol, + f"centred on the first click (off by {dist(aa[0]['center'], clicked(*C)):.4f} mm)") + for nm, pt in (("start", aa[0]["p0"]), ("end", aa[0]["p1"])): + G.check("ARC", abs(dist(pt, aa[0]["center"]) - aa[0]["radius"]) < 1e-9, + f"its {nm} sits exactly on the radius, to 1e-9") + + # --- Tangent arc: the construction property, exact whatever the click --------------------- + clear_sketch() + G.key("l", 0.5) # fixture: one line for the arc to leave tangentially + la, lb = (cx - W * 0.20, cy - H * 0.05), (cx + W * 0.05, cy - H * 0.05) + G.clickmm(*la); G.clickmm(*lb) + G.values(40, 0) + line = ents("line")[0] + G.key("Escape", 0.5) + arm("sk_arc_tangent", *free) + G.clickmm(*lb) # start snaps onto the line's endpoint + G.clickmm(cx + W * 0.12, cy + H * 0.12) + ta = ents("arc") + G.check("ARC", len(ta) == 1, f"sk_arc_tangent: {len(ta)} arc off the line's endpoint") + if ta: + end = min((ta[0]["p0"], ta[0]["p1"]), key=lambda q: dist(q, line["p1"])) + rad = (end[0] - ta[0]["center"][0], end[1] - ta[0]["center"][1]) + d = (line["p1"][0] - line["p0"][0], line["p1"][1] - line["p0"][1]) + cosang = abs(rad[0] * d[0] + rad[1] * d[1]) / (math.hypot(*rad) * math.hypot(*d)) + G.check("TANGENT", cosang < 1e-9, + f"its radius at the shared end is perpendicular to the line to 1e-9 (cos={cosang:.2e})") + + # --- Arc slot: two concentric arcs, one width -------------------------------------------- + clear_sketch() + arm("sk_slot_arc", *free) + G.clickmm(cx - W * 0.15, cy) # start + G.clickmm(cx, cy - H * 0.10) # centre + G.clickmm(cx + W * 0.15, cy) # end direction + G.clickmm(cx + W * 0.15, cy + H * 0.04) # width + sa = ents("arc") + G.check("ARC", len(sa) >= 2, f"sk_slot_arc: {len(sa)} arcs") + if len(sa) >= 2: + # Group by centre rather than by size: an arc slot is two RAILS about a common centre + # plus two end caps about their own, and "the two biggest arcs" is not the same set — + # it picked a rail and a cap and called them non-concentric. + groups = {} + for e in sa: + k = (round(e["center"][0], 9), round(e["center"][1], 9)) + groups.setdefault(k, []).append(e["radius"]) + rails = max(groups.values(), key=len) + G.check("ARC", len(rails) == 2, + f"two rails share one centre to 1e-9 (radii {[round(r, 6) for r in sorted(rails)]}), " + f"{len(groups) - 1} cap centre(s) besides") + + # --- Ellipse arc: five clicks, and now the socket can actually see its parameters --------- + clear_sketch() + arm("sk_ellipse_arc", *free) + G.clickmm(cx, cy) + G.clickmm(cx + W * 0.18, cy) + G.clickmm(cx, cy + H * 0.10) + G.clickmm(cx + W * 0.18, cy) + G.clickmm(cx, cy + H * 0.10) + ea = ents("ellipse_arc") + G.check("ARC", len(ea) == 1, f"sk_ellipse_arc: {len(ea)} ellipse arc") + if ea and "radius" in ea[0]: + G.check("ARC", ea[0]["radius"] > ea[0]["rminor"] > 0, + f"semi-axes a={ea[0]['radius']:.6f} b={ea[0]['rminor']:.6f}, a > b > 0") + for nm, pt in (("start", ea[0]["p0"]), ("end", ea[0]["p1"])): + X = (pt[0] - ea[0]["center"][0], pt[1] - ea[0]["center"][1]) + ph = ea[0]["rotation"] + u = (X[0] * math.cos(ph) + X[1] * math.sin(ph)) / ea[0]["radius"] + v = (-X[0] * math.sin(ph) + X[1] * math.cos(ph)) / ea[0]["rminor"] + G.check("ARC", abs(u * u + v * v - 1.0) < 1e-9, + f"its {nm} satisfies (x/a)^2+(y/b)^2 = 1 to 1e-9") + + # --- The five fixed-count polygons: regular, to 1e-9 -------------------------------------- + for verb, n in (("sk_poly_3", 3), ("sk_poly_4", 4), ("sk_poly_5", 5), + ("sk_poly_8", 8), ("sk_poly_12", 12)): + clear_sketch() + arm(verb, *free) + G.clickmm(cx, cy) + G.clickmm(cx + W * 0.15, cy) + q = ents("line") + if len(q) != n: + G.check("LENGTH", False, f"{verb}: {len(q)} sides, expected {n}") + continue + L = [round(e["length"], 9) for e in q] + ctr = clicked(cx, cy) + R = [dist(e["p0"], ctr) for e in q] + G.check("LENGTH", spread(L) == 0.0 and spread(R) < 1.5 * G.mm_per_px(cx, cy), + f"{verb}: {n} equal sides to 1e-9 ({L[0]:.9f}), all vertices on one circle") + + # --- Inscribed vs circumscribed: the exact ratio between them ----------------------------- + radii = {} + for verb, fit in (("sk_poly_inscribed", "inscribed"), ("sk_poly_circumscribed", "circumscribed")): + clear_sketch() + arm(verb, *free) # a tool PARAMETER, chosen from the menu + arm("sk_poly_5", *free) + G.clickmm(cx, cy) + G.clickmm(cx + W * 0.15, cy) + q = ents("line") + ctr = clicked(cx, cy) + radii[fit] = dist(q[0]["p0"], ctr) if q else 0.0 + want = 1.0 / math.cos(math.pi / 5.0) + got = (radii["circumscribed"] / radii["inscribed"]) if radii["inscribed"] else 0.0 + G.check("ARC", G.near(got, want, 1e-6), + f"circumscribed/inscribed circumradius = {got:.9f} vs 1/cos(pi/5) = {want:.9f} " + "— the two fits are genuinely different constructions") + G.leave_sketch() + G.reset_document() + + +def tf_fixture(cx, cy, W, L=40): + """One horizontal line of exactly L mm, drawn by key. Fixture, not the thing under test. + + Horizontal and exactly L because every transform assertion below is derived from it: the + gizmo seeds its parameters from the target's own size (pivot = the line's midpoint, handle + radius = half its length), so knowing the line exactly is what makes the handle and its value + label land on a computable pixel instead of a guessed one. + """ + G.key("l", 0.5) + G.clickmm(cx - W * 0.10, cy) + G.clickmm(cx + W * 0.10, cy) + G.values(L, 0) + e = ents("line")[0] + G.key("Escape", 0.5) + return e + + +def tf_label(pivot, handle, at): + """Where the gizmo prints its value — the same formula render_tf_gizmo uses. + + label = handle + outward * 1.2 * max(15 px, 1e-4), outward = the pivot -> handle direction. + Recomputing it here rather than hunting for it in pixels is what keeps this a click on a + control and not a search: if the formula ever moves, this rung fails loudly instead of + clicking somewhere harmless. + """ + th = max(15.0 * G.mm_per_px(*at), 1e-4) + d = (handle[0] - pivot[0], handle[1] - pivot[1]) + n = math.hypot(*d) or 1.0 + return (handle[0] + d[0] / n * th * 1.2, handle[1] + d[1] / n * th * 1.2) + + +def rung_transforms(): + """O6 — Move, Rotate, Scale, Array and Polar array: five verbs, none with a shortcut. + + Each is a gizmo, so the whole gesture is menu -> pick -> click the value label -> type -> + click empty to apply, with no keyboard route anywhere in it. The assertions are the exact + ones the operation promises: a translation moves every point by the typed amount and nothing + else, a rotation turns the direction by the typed angle and leaves the length alone, a scale + multiplies the length and leaves the direction alone. + """ + print("\nO6 the 2D transforms — gizmo verbs, none of them on the keyboard") + fresh_sketch("p") + G.key("Escape", 0.5) + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + W, H = (x1 - x0), (y1 - y0) + free = (cx, y1 - H * 0.10) + away = (x0 + W * 0.03, y0 + H * 0.03) # empty plane: the click that applies a gizmo + L = 40.0 + half = L / 2.0 + step = max(half * 1.5, 1.0) + + def pivot_of(e): + return ((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0) + + def direction(e): + return math.degrees(math.atan2(e["p1"][1] - e["p0"][1], e["p1"][0] - e["p0"][0])) + + # --- Move: 25 mm along +X, and nothing else changes -------------------------------------- + clear_sketch() + before = tf_fixture(cx, cy, W, L) + # The transforms are offered for a SELECTION, not for empty space — so the right-click that + # opens the menu happens ON the line, which is also what selects it. Then one more click + # picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick. + arm("sk_move", *pivot_of(before)) + G.clickmm(*pivot_of(before)) # pick the line + piv = pivot_of(before) + G.clickmm(*tf_label(piv, (piv[0] + step, piv[1]), (cx, cy))) + G.value(25) + G.clickmm(*away) # empty click applies + after = ents("line") + G.check("VERTEX", len(after) == 1, f"sk_move: {len(after)} line after the transform") + if len(after) == 1: + dx = [after[0]["p0"][0] - before["p0"][0], after[0]["p1"][0] - before["p1"][0]] + dy = [after[0]["p0"][1] - before["p0"][1], after[0]["p1"][1] - before["p1"][1]] + G.check("LENGTH", all(abs(v - 25.0) < 1e-9 for v in dx) and all(abs(v) < 1e-9 for v in dy), + f"every point moved by exactly +25.000000000 in X and 0 in Y: dx={dx} dy={dy}") + + # --- Rotate: 30 degrees about the centroid, length untouched ------------------------------ + clear_sketch() + before = tf_fixture(cx, cy, W, L) + # The transforms are offered for a SELECTION, not for empty space — so the right-click that + # opens the menu happens ON the line, which is also what selects it. Then one more click + # picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick. + arm("sk_rotate", *pivot_of(before)) + G.clickmm(*pivot_of(before)) + piv = pivot_of(before) + h = (piv[0] + half * math.cos(math.pi / 4), piv[1] + half * math.sin(math.pi / 4)) + G.clickmm(*tf_label(piv, h, (cx, cy))) + G.value(30) + G.clickmm(*away) + after = ents("line") + G.check("VERTEX", len(after) == 1, f"sk_rotate: {len(after)} line") + if len(after) == 1: + turned = (direction(after[0]) - direction(before)) % 360.0 + G.check("ANGLE", min(abs(turned - 30.0), abs(turned - 210.0)) < 1e-9, + f"turned by exactly {turned:.9f} deg") + G.check("LENGTH", abs(after[0]["length"] - before["length"]) < 1e-9, + f"and its length is untouched: {after[0]['length']:.9f}") + + # --- Scale: x3 about the centroid, direction untouched ------------------------------------ + clear_sketch() + before = tf_fixture(cx, cy, W, L) + # The transforms are offered for a SELECTION, not for empty space — so the right-click that + # opens the menu happens ON the line, which is also what selects it. Then one more click + # picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick. + arm("sk_scale", *pivot_of(before)) + G.clickmm(*pivot_of(before)) + piv = pivot_of(before) + G.clickmm(*tf_label(piv, (piv[0] + 2.0 * half, piv[1]), (cx, cy))) + G.value(3) + G.clickmm(*away) + after = ents("line") + G.check("VERTEX", len(after) == 1, f"sk_scale: {len(after)} line") + if len(after) == 1: + G.check("LENGTH", abs(after[0]["length"] - 3.0 * before["length"]) < 1e-9, + f"length {before['length']:.9f} -> {after[0]['length']:.9f}, exactly x3") + G.check("ANGLE", abs(direction(after[0]) - direction(before)) < 1e-9, + "and its direction is untouched to 1e-9") + + # --- Linear array: 4 copies at an exact pitch --------------------------------------------- + clear_sketch() + before = tf_fixture(cx, cy, W, L) + # The transforms are offered for a SELECTION, not for empty space — so the right-click that + # opens the menu happens ON the line, which is also what selects it. Then one more click + # picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick. + arm("sk_array", *pivot_of(before)) + G.clickmm(*pivot_of(before)) + piv = pivot_of(before) + # A single LINE target seeds the spacing PERPENDICULAR to it, which for a horizontal line + # is +Y. That is the tool's own rule, not an assumption: see tf_pick's Array branch. + G.clickmm(*tf_label(piv, (piv[0], piv[1] + step), (cx, cy))) + G.value(20) + th = max(15.0 * G.mm_per_px(cx, cy), 1e-4) + G.clickmm(piv[0] + th * 1.5, piv[1] + th * 1.5) # the "xN" count label + G.value(4) + G.clickmm(*away) + rows = sorted(ents("line"), key=lambda e: e["p0"][1]) + G.check("VERTEX", len(rows) == 4, f"sk_array: {len(rows)} lines (1 original + 3 copies)") + if len(rows) == 4: + pitch = [round(rows[i + 1]["p0"][1] - rows[i]["p0"][1], 9) for i in range(3)] + G.check("LENGTH", pitch == [20.0, 20.0, 20.0], f"pitch exactly {pitch} mm") + G.check("LENGTH", spread([round(e["length"], 9) for e in rows]) == 0.0, + "and every copy is the same length to 1e-9") + + # --- Polar array: 6 copies, 60 degrees apart, sharing one centre -------------------------- + clear_sketch() + before = tf_fixture(cx, cy, W, L) + # The transforms are offered for a SELECTION, not for empty space — so the right-click that + # opens the menu happens ON the line, which is also what selects it. Then one more click + # picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick. + arm("sk_array_polar", *pivot_of(before)) + G.clickmm(*pivot_of(before)) + piv = pivot_of(before) + G.clickmm(*tf_label(piv, (piv[0] + half, piv[1]), (cx, cy))) + G.value(360) + th = max(15.0 * G.mm_per_px(cx, cy), 1e-4) + G.clickmm(piv[0] + th * 1.5, piv[1] + th * 1.5) + G.value(6) + G.clickmm(*away) + spokes = ents("line") + G.check("VERTEX", len(spokes) == 6, f"sk_array_polar: {len(spokes)} lines") + if len(spokes) == 6: + mids = [((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0) for e in spokes] + G.check("VERTEX", max(dist(m, mids[0]) for m in mids) < 1e-9, + "all six share one centre to 1e-9 — rotated about the pivot, not scattered") + # mod 360, not 180. A line carries an orientation, and folding the six directions into a + # half-turn collapses opposite spokes onto each other: a perfectly even star then reads + # as gaps of [0, 60, 0, 60, 0] and the rung fails on its own arithmetic. + angs = sorted(direction(e) % 360.0 for e in spokes) + gaps = [round(angs[(i + 1) % 6] - angs[i], 9) % 360.0 for i in range(6)] + G.check("ANGLE", all(abs(g - 60.0) < 1e-9 for g in gaps), + f"and they are 60 deg apart all the way round: {gaps}") + G.leave_sketch() + G.reset_document() + + +def rung_art(): + """O7 — Text and SVG: the last two 2D verbs, and the only two that open a dialog. + + Both are keyless, so the offer is their only door; both also leave the canvas for a modal + window, which is why nothing that drives the canvas had ever reached them. The properties + graded are the ones that survive a change of font or of importer scale: how many CLOSED loops + came back, and the exact aspect ratio of a shape whose proportions are known. + """ + print("\nO7 Text and SVG — the two verbs that go through a dialog") + fresh_sketch("p") + G.key("Escape", 0.5) + x0, x1, y0, y1 = G._SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + H = y1 - y0 + free = (cx, y1 - H * 0.10) + + # --- Text --------------------------------------------------------------------------------- + clear_sketch() + o = open_offer(*free) + G.check("OFFER", "sk_text" in o.verbs, "sk_text is offered on an empty sketch") + choose(o, "sk_text") + time.sleep(1.5) + names = G.sh(f"DISPLAY={G.DISP} xdotool search --name '.' getwindowname %@").split("\n") + G.check("OFFER", any(n.strip() == "Text" for n in names), + "choosing it opens the Text dialog") + G.typ("LT", 0.4) + G.key("Return", 2.5) + lp = G.loops() + G.check("CLOSED", len(lp) == 2 and all(l["closed"] for l in lp), + f"two letters came back as {len(lp)} closed loops") + G.check("VERTEX", all(abs(l["area"]) > 1.0 for l in lp), + f"both enclose real area: {[round(abs(l['area']), 3) for l in lp]}") + + # --- SVG ---------------------------------------------------------------------------------- + # A file whose proportions are known EXACTLY, so the assertion does not depend on what the + # importer decides a user unit is: a 40 x 20 path is 2:1 at any scale. + # FILLED, not stroked. A stroked path imports as its stroke OUTLINE — two loops, an outer and + # an inner, each inflated by half the stroke width — so the shape that comes back is 8 lines + # at 1.952 : 1 and the assertion would be grading the pen, not the importer. + svg = "/tmp/offer-ladder-2to1.svg" + G.sh("cat > %s <<'EOF'\n" + "\nEOF" % svg) + clear_sketch() + o = open_offer(*free) + G.check("OFFER", "sk_svg" in o.verbs, "sk_svg is offered too") + choose(o, "sk_svg") + time.sleep(2.0) + G.key("ctrl+l", 0.6) # GTK's own "type a path" entry: never guess at the file list + G.typ(svg, 0.5) + G.key("Return", 3.0) + ls = ents("line") + lp = G.loops() + G.check("CLOSED", len(lp) == 1 and len(ls) == 4, + f"the imported path is {len(ls)} lines and {len(lp)} closed loop") + if ls: + xs = [p for e in ls for p in (e["p0"][0], e["p1"][0])] + ys = [p for e in ls for p in (e["p0"][1], e["p1"][1])] + w, h = max(xs) - min(xs), max(ys) - min(ys) + # 1e-6, not 1e-9, and the reason is measured rather than tuned away: the imported box is + # 10.583333000 x 5.291667000 where 40 and 20 user units at 25.4/96 are 10.58333333... and + # 5.29166666..., so the SVG path coordinates arrive ROUNDED TO SIX DECIMAL PLACES (both + # numbers are exactly 6 dp, one rounded down and one up — which is also why the ratio is + # 1.999999811 rather than 2). Everything the sketcher itself draws is exact to 1e-9; this + # 1e-6 belongs to the import path alone, and it is the band the assertion allows. + G.check("LENGTH", abs(w / h - 2.0) < 1e-6, + f"and its proportions survived the import: {w:.9f} x {h:.9f} = {w / h:.9f} : 1 " + f"(the import rounds coordinates to 1e-6 mm)") + G.leave_sketch() + G.reset_document() + + +# Every 2D verb this ladder drives from the menu, by id. Kept as data so the coverage claim can +# be CHECKED rather than asserted in prose: rung_coverage compares it against the offer table and +# fails the moment a keyless sketch verb exists that nothing here exercises. +DRIVEN = { + "sk_rect_center", # O4 + "sk_polyline", "sk_rect_oblique", "sk_rect_rounded", # O5 + "sk_circle_2pt", "sk_circle_3pt", "sk_arc_center", "sk_arc_tangent", + "sk_slot_arc", "sk_ellipse_arc", + "sk_poly_3", "sk_poly_4", "sk_poly_5", "sk_poly_8", "sk_poly_12", + "sk_poly_inscribed", "sk_poly_circumscribed", + "sk_move", "sk_rotate", "sk_scale", "sk_array", "sk_array_polar", # O6 + "sk_text", "sk_svg", # O7 +} + + +def rung_coverage(): + """O8 — the coverage claim, checked against the table instead of written in a comment. + + "Every 2D verb with no keyboard route is exercised" is the whole point of the rungs above, and + a claim like that rots the day someone adds a verb. Here it is arithmetic: the set of keyless + sketch verbs in DesignOffer.hpp, minus the set this file drives, must be empty. + """ + print("\nO8 coverage — every keyless 2D verb, checked against the table") + sk = [v for v in TABLE if v["sketch_mode"]] + keyless = {v["id"] for v in sk if v["action"] and not v["key"]} + keyed = {v["id"] for v in sk if v["key"]} + dead = {v["id"] for v in sk if not v["action"]} + missing = keyless - DRIVEN + G.check("OFFER", not missing, + f"all {len(keyless)} keyless 2D verbs are driven from the menu" + + ("" if not missing else f" — MISSING: {sorted(missing)}")) + G.check("OFFER", not (DRIVEN - keyless - keyed), + f"and nothing is driven that is not in the table: {sorted(DRIVEN - keyless - keyed)}") + G.check("OFFER", not dead, + f"no 2D verb is a dead row: {len(sk)} sketch verbs, {len(keyed)} with a shortcut, " + f"{len(keyless)} without, {len(dead)} with no GUI route at all") + + +RUNGS = {"kinds": rung_kinds, "vocabulary": rung_vocabulary, + "author": rung_author, "no_shortcut": rung_no_shortcut, + "curves": rung_curves, "transforms": rung_transforms, + "art": rung_art, "coverage": rung_coverage} + + +def main(): + if not os.path.exists(LOG): + G.die(f"no {LOG} — launch the app through scripts/CAD/start-headless-gui.sh") + if "[OFFER]" not in open(LOG, errors="replace").read()[-400000:]: + print(f"note: no [OFFER] lines in {LOG} yet — the app must run with ORCA_CAD_KEYTRACE=1") + want = sys.argv[1:] or list(RUNGS) + # TWICE. From a cold launch the app shows the Home page over the Design tab, and the first + # click only selects the tab — the second is what brings the viewport forward. A ladder that + # clicked once drew its whole first rung into a webview. + G.go_design(); G.go_design() + G.key("Escape", 0.4) + G.reset_document() + for name in want: + if name not in RUNGS: + G.die(f"unknown rung {name}; have {' '.join(RUNGS)}") + RUNGS[name]() + print(f"\n{G._checks - G._fail}/{G._checks} properties held") + sys.exit(1 if G._fail else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py new file mode 100644 index 0000000000..d4ebd1f1ca --- /dev/null +++ b/scripts/CAD/check-gui-sketching.py @@ -0,0 +1,2024 @@ +#!/usr/bin/env python3 +"""A ladder of sketches drawn the way a person draws them: mouse gestures and typed values. + +WHY THIS EXISTS, next to scripts/CAD/check-sketch-engine.py. That ladder proves the ENGINE — it feeds +geometry through the MCP socket's add_entities_scripted and grades what comes back. The socket +path skips everything the goal actually rests on: gesture state, the auto-edit queue, snapping, +inference at gesture tolerance, and the right-click offer. A ladder that only drives the socket +cannot say the Design tab meets its goal. This one draws with synthetic clicks and types the +values into the in-canvas field, then reads the result back through the socket, which is used +here ONLY as an instrument, never as an author. + +Runs INSIDE the headless rig container (Xvfb :10 + openbox + the app with ORCA_CAD_MCP set): + + docker cp scripts/CAD/check-gui-sketching.py orcacad-gui:/tmp/ && \ + docker exec orcacad-gui python3 /tmp/check-gui-sketching.py [rung ...] + +With no arguments every rung runs. Exit 0 = every property held. +""" +import json, math, os, re, socket, subprocess, sys, time + +SOCK = os.environ.get("ORCA_CAD_MCP", "/tmp/mcp.sock") +DISP = os.environ.get("DISPLAY", ":10") +_n = 0 +_fail = 0 +_checks = 0 + +# ---------------------------------------------------------------- the instrument (read-only) + +def call(method, **params): + global _n + _n += 1 + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(30) + s.connect(SOCK) + s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method, + "params": params}) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + d = s.recv(65536) + if not d: + break + buf += d + r = json.loads(buf.decode().strip()) + if "error" in r: + raise RuntimeError(f"{method}: {r['error']['message']}") + return r["result"] + + +def try_call(method, **params): + try: + return call(method, **params) + except Exception: + return None + + +def describe(): + return call("sketch_describe") + + +# ---------------------------------------------------------------- the hand (synthetic input) + +_win = None + +def win(): + """The app window's id and origin. Asked fresh once per run: a relaunch changes the id.""" + global _win + # BY SIZE, never by title. Saving a project renames the window to the file, and a driver + # that hunts for "Untitled" then reports "no app window" for an app that is running fine — + # which is a false negative in the one place a false negative is most expensive. + if _win is None: + best = None + # --class, not --name: after a project is opened the main window can come back with no + # WM_NAME at all, and a name search then does not list it — the driver picks a 200x200 + # helper and every click lands on nothing. + for w in sh(f"DISPLAY={DISP} xdotool search --class '.'").split(): + g = sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}") + d = dict(l.split("=", 1) for l in g.strip().splitlines() if "=" in l) + if "WIDTH" not in d: + continue + a = int(d["WIDTH"]) * int(d["HEIGHT"]) + if best is None or a > best[0]: + best = (a, w, int(d["X"]), int(d["Y"]), int(d["WIDTH"]), int(d["HEIGHT"])) + if best is None: + die("no app window on " + DISP) + sh(f"DISPLAY={DISP} xdotool windowactivate --sync {best[1]}") + _win = best[1:] + return _win + + +def sh(cmd): + return subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True).stdout + + +def xdo(args): + sh(f"DISPLAY={DISP} xdotool {args}") + + +def key(k, pause=0.35): + xdo(f"key {k}") + time.sleep(pause) + + +def typ(s, pause=0.35): + xdo(f"type --delay 40 -- '{s}'") + time.sleep(pause) + + +def click(px, py, pause=0.45, btn=1): + _, X, Y, _, _ = win() + xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}") + time.sleep(pause) + + +def click_ctrl(px, py, pause=0.45): + """Ctrl+click: EXTEND the sketch selection instead of replacing it. + + A plain second click clears the first pick (DesignSketchTool's Select branch only keeps + one entity unless `extend` is set), so a two-entity constraint driven by two plain clicks + silently arrives with one pick and is rejected for the wrong reason. + """ + _, X, Y, _, _ = win() + xdo(f"keydown ctrl mousemove {X+int(px)} {Y+int(py)} click --delay 120 1 keyup ctrl") + time.sleep(pause) + + +def move(px, py, pause=0.2): + _, X, Y, _, _ = win() + xdo(f"mousemove {X+int(px)} {Y+int(py)}") + time.sleep(pause) + + +def shot(path): + w, X, Y, W, H = win() + sh(f"DISPLAY={DISP} import -window root -crop {W}x{H}+{X}+{Y} +repage {path}") + + +# ---------------------------------------------------------------- pixels <-> plane + +# The viewport is a perspective camera looking at the sketch plane, so pixel -> plane is a +# HOMOGRAPHY, not a scale: the same pixel span covers more millimetres at the far edge than at +# the near one. Four measured correspondences determine it exactly. Measuring beats assuming — +# the camera can be anywhere, and a wrong constant silently puts every click somewhere else. +_H = None # plane -> pixel, row-major 3x3 +_SAFE = None # (xmin, xmax, ymin, ymax) of the plane region the probes covered + + +def _solve(A, b): + """Tiny dense solve; no numpy in the rig container.""" + n = len(A) + M = [row[:] + [b[i]] for i, row in enumerate(A)] + for c in range(n): + p = max(range(c, n), key=lambda r: abs(M[r][c])) + if abs(M[p][c]) < 1e-12: + die("calibration is degenerate — the four probe points are not in general position") + M[c], M[p] = M[p], M[c] + for r in range(n): + if r == c: + continue + f = M[r][c] / M[c][c] + for k in range(c, n + 1): + M[r][k] -= f * M[c][k] + return [M[i][n] / M[i][i] for i in range(n)] + + +def fit_homography(pairs): + """pairs: [((X_mm, Y_mm), (u_px, v_px)), ...] -> 3x3 plane->pixel with h22 = 1.""" + A, b = [], [] + for (X, Y), (u, v) in pairs: + A.append([X, Y, 1, 0, 0, 0, -u * X, -u * Y]); b.append(u) + A.append([0, 0, 0, X, Y, 1, -v * X, -v * Y]); b.append(v) + h = _solve(A, b) + return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0] + + +def px(X, Y): + """Plane millimetres -> window pixels.""" + h = _H + w = h[6] * X + h[7] * Y + h[8] + return ((h[0] * X + h[1] * Y + h[2]) / w, (h[3] * X + h[4] * Y + h[5]) / w) + + +def unpx(u, v): + """Window pixels -> plane millimetres (the homography inverted, by hand).""" + h = _H + A = [[h[0] - u * h[6], h[1] - u * h[7]], [h[3] - v * h[6], h[4] - v * h[7]]] + b = [u * h[8] - h[2], v * h[8] - h[5]] + return tuple(_solve(A, b)) + + +def mm_per_px(X, Y): + """The viewport's local scale at a plane point — what the tool calls unit_per_px.""" + u, v = px(X, Y) + a = unpx(u, v) + b = unpx(u + 1.0, v) + return math.dist(a, b) + + +def clickmm(X, Y, pause=0.45, btn=1): + u, v = px(X, Y) + click(u, v, pause, btn) + + +def movemm(X, Y, pause=0.2): + u, v = px(X, Y) + move(u, v, pause) + + +# ---------------------------------------------------------------- session control + +def leave_sketch(): + """Back to a clean Feature-mode document, whatever state the last rung left behind.""" + try_call("sketch_cancel") + for _ in range(4): + key("Escape", 0.25) + time.sleep(0.5) + + +# EVERY absolute chrome coordinate below is written in the Snapmaker fork's layout and then +# shifted by CHROME_DY, because this fork keeps mainline OrcaSlicer's top row (File / save / +# undo / redo / Calibration, with the document title) which that fork does not have. The whole +# chrome — tab strip, sketch toolbar, confirm button — sits 26 px lower here. +# +# Getting this wrong does not look like a coordinate problem. At the unshifted y the Design tab +# click landed in that toolbar, the app stayed on the Home page, and the first rung reported +# "no sketch opened after plane click + Shift+S"; the unshifted Construction checkbox reported +# "0 construction axis". Both name the wrong subsystem. Canvas coordinates are immune because +# clickmm() derives them from the live canvas geometry — only the chrome constants need this. +CHROME_DY = int(os.environ.get("ORCA_CAD_CHROME_DY", "26")) + +DESIGN_TAB = (128, 29 + CHROME_DY) + +# Feature-tree rows, measured on the rig at 1920x1080: first row centre, then 23 px apart. +# x=300, not the label: a second click ON the label opens the inline rename, and Delete then +# edits the text instead of removing the feature. +# +# CHROME_DY applies here too, and this was the one chrome constant that did not carry it. The +# unshifted click lands 26 px BELOW the first row -- just past its 23 px height -- so the row is +# never selected, Delete does nothing, and reset_document spends 40 rounds on it before dying +# with "could not empty the feature tree". That names the feature tree, which is not the fault. +TREE_ROW0 = (300, 215 + CHROME_DY) + + +def go_design(): + """Make sure the Design tab is in front — loading a project lands on Prepare.""" + click(*DESIGN_TAB, pause=1.0) + + +def reset_document(): + """Delete every committed feature, by picking its tree row and pressing Delete. + + A rung that ends in Constrain COMMITS its sketch, and the next rung's Constrain resolves + 'the last sketch' — which is then the PREVIOUS rung's. That is how D2 first read a rectangle + of exactly 120 x 80 back from a sketch it had drawn at 120.020087: it was grading a sketch + left behind by an earlier run. The document is part of the fixture; reset it like one. + """ + go_design() + leave_sketch() + for _ in range(40): + if not call("describe_scene")["features"]: + return + click(*TREE_ROW0, pause=0.35) + key("Delete", 0.5) + die("could not empty the feature tree") + + +def enter_sketch(tool_key, plane_px=(913, 359)): + """Enter a sketch the way the design law says: pick the plane in the viewport, then the tool. + + Shift+S enters sketch MODE and pops the offer; Escape dismisses it; the tool letter then + starts the session on the plane the click selected. All four steps are real input — nothing + here goes through the socket. + """ + leave_sketch() + click(*plane_px) + key("shift+s", 0.8) + key("Escape", 0.4) # entering sketch mode pops the offer; dismiss it + key("p", 0.6) + if try_call("sketch_describe") is None: + shot("/shots/gl-enter-failed.png") + die("no sketch opened after plane click + Shift+S (see /shots/gl-enter-failed.png)") + calibrate_here() # THIS sketch's own camera map, on THIS sketch's own plane + key(tool_key, 0.6) + + +def calibrate_here(): + """Place four Points in the sketch that is already open, solve the map, then undo them. + + PER SKETCH, not once per run. The camera is wherever the previous rung left it — reopening a + sketch and loading a project both move it — and the plane label the entry click lands on + moves with it, so a later sketch can end up on XZ while the map was solved on XY. Both of + those turn into clicks that land somewhere else, and geometry that looks drawn but is not + where it was asked for. Four points cost about four seconds and remove the whole class. + """ + global _H + probes = [(1000, 500), (1400, 500), (1400, 760), (1000, 760)] + for u, v in probes: + click(u, v) + ents = describe()["entities"] + if len(ents) != 4 or any(e["type"] != "point" for e in ents): + die(f"calibration expected 4 points, got {[e['type'] for e in ents]}") + _H = fit_homography([((e["p"][0], e["p"][1]), probes[i]) for i, e in enumerate(ents)]) + # Prove the fit by round-tripping the probes: a homography through its own four points is + # exact, so anything but a sub-pixel residual means the points came back mismatched. + for i, e in enumerate(ents): + u, v = px(e["p"][0], e["p"][1]) + if abs(u - probes[i][0]) > 0.5 or abs(v - probes[i][1]) > 0.5: + die(f"calibration residual too large at probe {i}: {(u, v)} vs {probes[i]}") + global _SAFE + xs = [e["p"][0] for e in ents]; ys = [e["p"][1] for e in ents] + _SAFE = (min(xs), max(xs), min(ys), max(ys)) + for _ in range(len(probes)): + key("ctrl+z", 0.5) # the probes are scaffolding, not geometry + left = describe()["entities"] + if left: + die(f"{len(left)} calibration probes survived the undo") + + +# ---------------------------------------------------------------- typed values + +# How long to wait for the in-canvas field to appear and to settle after a commit. The queue +# opens each field from a CallAfter that runs AFTER a re-solve, so on a heavy sketch the field is +# simply not there yet when a fast driver starts typing — the digits go nowhere and the value +# stays as drawn. Rungs that work on a thousand entities raise this. +PACE = 1.0 + + +def field_open(): + """Is a sketch value field open? Asked of the APP, not of the window list. + + It used to be answered by hunting for a small top-level window, because the field WAS one. + It is not any more — it is drawn by ImGui inside the GL canvas precisely so that no window + manager gets a vote on whether it may hold the keyboard. Enumerating windows now always + answers "no field", which turns every check built on it into one that cannot fail. + + sketch_describe's `editing` is DesignSketchTool::value_field_open(), i.e. the app's own + answer to the same question. + """ + d = try_call("sketch_describe") + return bool(d and d.get("editing")) + + +def focus_field(): + """Deliberately nothing. + + This used to click into the value field before typing, and its old docstring explained why: + "WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED ... the window manager does not give it + the keyboard, so xdotool's digits go to the canvas". That was a workaround for the field + being a separate top-level window, and it is also what made this ladder blind to the very + defect the user reported — a suite that clicks the field first can never see that typing + without clicking is broken. + + The field is now inside the canvas and the canvas has the keyboard, so typing just works and + there is nothing to click. Kept as a no-op so the call sites still read in order. + """ + return True + + +def value(v, pause=0.6): + """Type one number into the open in-canvas field and commit it. + + Select-all first: the field opens pre-filled with the as-drawn value and pre-selected, but a + pre-selection that a synthetic click has disturbed would otherwise leave the typed digits + appended to it. + """ + time.sleep(0.25 * PACE) + focus_field() + key("ctrl+a", 0.15) + typ(str(v), 0.25) + key("Return", pause * PACE) + + +def values(*vs): + for v in vs: + value(v) + + +# ---------------------------------------------------------------- grading + +def say(msg): + print(f" {msg}") + + +def check(kind, cond, what): + global _fail, _checks + _checks += 1 + if cond: + print(f" {kind:9s} ok {what}") + else: + print(f" {kind:9s} FAIL {what}", file=sys.stderr) + _fail += 1 + + +def near(a, b, tol=1e-6): + return abs(a - b) <= tol + + +def die(msg): + print(f" FATAL {msg}", file=sys.stderr) + sys.exit(2) + + +def lengths(ents): + return sorted(round(e["length"], 6) for e in ents if e["type"] == "line") + + +def loops(): + return describe()["closed_loops"] + + +# =================================================================== LADDER A — one tool each +# Every 2D tool draws its primitive by gesture, then takes its exact value from the keyboard. +# The click only has to be roughly right; the typed number is what must come back exactly. + +def rung_rect(): + print("\nA1 rectangle — two corners, typed 120 x 80") + enter_sketch("r") + clickmm(-60, -40); clickmm(60, 40) + values(120, 80) + d = describe() + ls = lengths(d["entities"]) + check("LENGTH", ls == [80.0, 80.0, 120.0, 120.0], f"sides {ls}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", near(abs(lp[0]["area"]), 9600.0, 1e-6), f"area {abs(lp[0]['area']):.6f}") + check("VERTEX", all(near(abs(e["p1"][0] - e["p0"][0]), 0, 1e-9) + or near(abs(e["p1"][1] - e["p0"][1]), 0, 1e-9) + for e in d["entities"]), "every side axis-aligned") + leave_sketch() + + +def rung_circle(): + print("\nA2 circle — centre then rim, typed radius 25") + enter_sketch("c") + clickmm(0, 0); clickmm(30, 0) + values(25) + d = describe() + e = [x for x in d["entities"] if x["type"] == "circle"] + check("ARC", len(e) == 1 and near(e[0]["radius"], 25.0), f"radius {e[0]['radius'] if e else None}") + check("VERTEX", len(e) == 1 and near(e[0]["center"][0], 0.0, 0.6) and near(e[0]["center"][1], 0.0, 0.6), + f"centre {e[0]['center'] if e else None} at the clicked origin") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), math.pi * 625.0, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs pi r^2 {math.pi*625:.6f}") + leave_sketch() + + +def rung_line(): + print("\nA3 line — two clicks, typed length 50 and angle 30") + enter_sketch("l") + clickmm(-40, -20); clickmm(10, 5) + values(50, 30) + d = describe() + e = [x for x in d["entities"] if x["type"] == "line"] + check("LENGTH", len(e) == 1 and near(e[0]["length"], 50.0), f"length {e[0]['length'] if e else None}") + if e: + a = math.degrees(math.atan2(e[0]["p1"][1] - e[0]["p0"][1], e[0]["p1"][0] - e[0]["p0"][0])) % 360.0 + check("ANGLE", near(a, 30.0, 1e-9), f"angle {a:.9f} deg") + leave_sketch() + + +def rung_arc(): + print("\nA4 three-point arc — typed radius 40 and sweep 90") + enter_sketch("a") + clickmm(-40, 0); clickmm(40, 0); clickmm(0, 40) + values(40, 90) + d = describe() + e = [x for x in d["entities"] if x["type"] == "arc"] + check("ARC", len(e) == 1 and near(e[0]["radius"], 40.0), f"radius {e[0]['radius'] if e else None}") + if e: + sw = abs(e[0]["end_angle"] - e[0]["start_angle"]) * 180.0 / math.pi + # 1e-7 deg, not exact: the sweep is READ BACK as end_angle - start_angle, two atan2 + # results, where the line's angle is STORED as the direction it was given. A 1e-9 deg + # residual here is 7e-10 mm at r=40 — float round-trip, not a defect. + check("ANGLE", near(sw, 90.0, 1e-7), f"sweep {sw:.9f} deg") + ch = math.dist(e[0]["p0"], e[0]["p1"]) + check("VERTEX", near(ch, 40.0 * math.sqrt(2.0), 1e-6), + f"chord {ch:.6f} vs r*sqrt2 {40*math.sqrt(2):.6f}") + leave_sketch() + + +def rung_slot(): + print("\nA5 slot — typed centre distance 60, radius 10, angle 0") + enter_sketch("s") + clickmm(-30, 0); clickmm(30, 0); clickmm(30, 12) + values(60, 10, 0) + d = describe() + arcs = [x for x in d["entities"] if x["type"] == "arc"] + lns = [x for x in d["entities"] if x["type"] == "line"] + check("ARC", len(arcs) == 2 and all(near(a["radius"], 10.0) for a in arcs), + f"two end radii {[round(a['radius'], 9) for a in arcs]}") + check("LENGTH", len(lns) == 2 and all(near(l["length"], 60.0) for l in lns), + f"two flanks {[round(l['length'], 9) for l in lns]}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 60 * 20 + math.pi * 100, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 60*20+pi*100 {60*20+math.pi*100:.6f}") + leave_sketch() + + +def rung_polygon(): + print("\nA6 polygon — typed side 30, angle 0") + enter_sketch("g") + clickmm(0, 0); clickmm(35, 0) + values(30, 0) + d = describe() + e = [x for x in d["entities"] if x["type"] == "line"] + ls = lengths(d["entities"]) + check("LENGTH", len(e) >= 3 and all(near(l, 30.0, 1e-9) for l in ls), + f"{len(e)} equal sides {set(ls)}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + if e: + n = len(e) + want = n * 30.0 ** 2 / (4.0 * math.tan(math.pi / n)) + check("AREA", near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs regular {n}-gon {want:.6f}") + leave_sketch() + + +def rung_ellipse(): + print("\nA7 ellipse — typed major 50, minor 20") + enter_sketch("e") + clickmm(0, 0); clickmm(40, 0); clickmm(0, 15) + values(50, 20) + d = describe() + e = [x for x in d["entities"] if x["type"] == "ellipse"] + check("ARC", len(e) == 1, f"{len(e)} ellipse") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), math.pi * 50 * 20, 2e-2), + f"area {abs(lp[0]['area']):.4f} vs pi*a*b {math.pi*1000:.4f}") + leave_sketch() + + +def rung_point(): + print("\nA8 point — one click, no value to type") + enter_sketch("p") + clickmm(20, 10) + d = describe() + e = [x for x in d["entities"] if x["type"] == "point"] + check("VERTEX", len(e) == 1 and near(e[0]["p"][0], 20.0, 0.6) and near(e[0]["p"][1], 10.0, 0.6), + f"placed at {[round(v, 3) for v in e[0]['p']] if e else None}") + leave_sketch() + + +def rung_spline(): + print("\nA9 spline — click control points, right-click to end") + enter_sketch("b") + for p in [(-40, 0), (-15, 30), (15, -30), (40, 0)]: + clickmm(*p) + clickmm(40, 0, btn=3) + d = describe() + e = [x for x in d["entities"] if x["type"] == "spline"] + check("VERTEX", len(e) == 1, f"{len(e)} spline from 4 control points") + leave_sketch() + + +# =================================================================== LADDER B — voids by hand +# The strategic target itself: one closed outer loop with internal voids, every one of them +# drawn by gesture in a single sketch and given its size from the keyboard. + +def rung_voids(): + print("\nB1 closed profile with two internal voids, all by gesture") + enter_sketch("r") + clickmm(-60, -40); clickmm(60, 40) # outer 120 x 80 + values(120, 80) + key("r", 0.6) # same tool again, from the keyboard + clickmm(-45, -15); clickmm(-5, 15) # void 1: 40 x 30 + values(40, 30) + key("c", 0.6) + clickmm(30, 0); clickmm(42, 0) # void 2: circle r 10 + values(10) + d = describe() + ls = lengths(d["entities"]) + check("LENGTH", ls == [30.0, 30.0, 40.0, 40.0, 80.0, 80.0, 120.0, 120.0], f"sides {ls}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 3 and all(l["closed"] for l in lp), f"{len(lp)} closed loops") + check("CLOSED", d["buildable"] and not d["open_ends"], "buildable, nothing dangling") + # The void attribution is the property under test: the outer loop must OWN both inner ones, + # and neither inner loop may claim a hole of its own. + outer = max(range(len(lp)), key=lambda i: abs(lp[i]["area"])) + holes = sorted(lp[outer]["holes"]) + check("VOID", holes == sorted(i for i in range(len(lp)) if i != outer), + f"outer loop {outer} owns holes {holes}") + check("VOID", all(not lp[i]["holes"] for i in range(len(lp)) if i != outer), + "neither void claims a hole of its own") + a = {i: abs(lp[i]["area"]) for i in range(len(lp))} + check("AREA", near(a[outer], 9600.0, 1e-6), f"outer {a[outer]:.6f}") + inner = sorted(a[i] for i in a if i != outer) + check("AREA", near(inner[0], math.pi * 100, 1e-6) and near(inner[1], 1200.0, 1e-6), + f"voids {inner[0]:.6f} (pi*100) and {inner[1]:.6f} (40*30)") + net = a[outer] - sum(v for i, v in a.items() if i != outer) + check("AREA", near(net, 9600.0 - 1200.0 - math.pi * 100, 1e-6), f"net material {net:.6f}") + leave_sketch() + + +# =================================================================== LADDER C — combining +# Mirror, offset, trim, extend, fillet and chamfer, each driven by the same picks and the same +# on-geometry value label a person would use. The label's place is COMPUTED from the geometry +# the tool itself derives (render_op_gizmo: tip = anchor + dir * value, label = tip + dir * 1.2 +# * max(15 * unit_per_px, 1e-4)) rather than hunted for in the pixels — the tool's own formula +# is the only thing that can be right by construction. + +def op_label_mm(anchor, direction, value, at): + th = max(15.0 * mm_per_px(*at), 1e-4) + d = (direction[0] / math.hypot(*direction), direction[1] / math.hypot(*direction)) + tip = (anchor[0] + d[0] * value, anchor[1] + d[1] * value) + return (tip[0] + d[0] * th * 1.2, tip[1] + d[1] * th * 1.2) + + +def mid(e): + return ((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0) + + +def corner_of(a, b): + """The shared endpoint of two adjacent lines, and the bisector pointing into their wedge.""" + C = min(((pa, pb) for pa in (a["p0"], a["p1"]) for pb in (b["p0"], b["p1"])), + key=lambda t: math.dist(t[0], t[1]))[0] + def away(e): + f = e["p1"] if math.dist(e["p0"], C) < math.dist(e["p1"], C) else e["p0"] + n = math.dist(f, C) + return ((f[0] - C[0]) / n, (f[1] - C[1]) / n) + ua, ub = away(a), away(b) + bis = (ua[0] + ub[0], ua[1] + ub[1]) + return tuple(C), bis + + +def draw_rect(w, h, x0, y0): + clickmm(x0, y0); clickmm(x0 + w, y0 + h) + values(w, h) + + +def rung_fillet(): + print("\nC1 fillet — pick two legs, type radius 8 on the label") + enter_sketch("r") + draw_rect(120, 80, -60, -40) + d0 = describe()["entities"] + a, b = corner_pair(d0) + key("f", 0.6) + clickmm(*mid(a)); clickmm(*mid(b)) + C, bis = corner_of(a, b) + v0 = 0.2 * min(a["length"], b["length"]) + clickmm(*op_label_mm(C, bis, v0, C)) + values(8) + d = describe() + arcs = [x for x in d["entities"] if x["type"] == "arc"] + check("ARC", len(arcs) == 1 and near(arcs[0]["radius"], 8.0), f"radius {arcs[0]['radius'] if arcs else None}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + want = 9600.0 - 64.0 * (1.0 - math.pi / 4.0) + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 9600 - r^2(1-pi/4) {want:.6f}") + if arcs: + # TANGENT: the arc centre must sit exactly r from each surviving leg's line. + legs = [x for x in d["entities"] if x["type"] == "line"] + ds = sorted(point_line_dist(arcs[0]["center"], l) for l in legs)[:2] + check("TANGENT", all(near(x, 8.0, 1e-9) for x in ds), f"centre stands off both legs by {ds}") + leave_sketch() + + +def rung_chamfer(): + print("\nC2 chamfer — pick two legs, type distance 10 on the label") + enter_sketch("r") + draw_rect(120, 80, -60, -40) + d0 = describe()["entities"] + a, b = corner_pair(d0) + key("h", 0.6) + clickmm(*mid(a)); clickmm(*mid(b)) + C, bis = corner_of(a, b) + clickmm(*op_label_mm(C, bis, 0.2 * min(a["length"], b["length"]), C)) + values(10) + d = describe() + lp = d["closed_loops"] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + check("LENGTH", len(lp) == 1 and len(lp[0]["entities"]) == 5, + f"{len(lp[0]['entities'])} sides after the cut") + ls = sorted(e["length"] for e in d["entities"] if e["type"] == "line") + check("LENGTH", any(near(x, 10.0 * math.sqrt(2.0), 1e-9) for x in ls), + f"the new face is d*sqrt2 = {10*math.sqrt(2):.9f}; sides {[round(x,9) for x in ls]}") + check("LENGTH", near(ls[1], 70.0, 1e-9) and near(ls[3], 110.0, 1e-9), + "both legs shortened by exactly d") + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 9600.0 - 50.0, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 9600 - d^2/2") + leave_sketch() + + +def rung_offset(): + print("\nC3 offset — pick a circle, type 5 on the label") + enter_sketch("c") + clickmm(0, 0); clickmm(30, 0) + values(25) + key("o", 0.6) + clickmm(25, 0) # pick the rim + # Circle offset anchors at centre + (r, 0) and grows along +x; the starting value is 0.1 * 2r. + clickmm(*op_label_mm((25.0, 0.0), (1.0, 0.0), 0.1 * 50.0, (25.0, 0.0))) + values(5) + d = describe() + cs = sorted(x["radius"] for x in d["entities"] if x["type"] == "circle") + # The gizmo's arrow starts on the +x side, so the typed 5 lands OUTWARD; what the goal cares + # about is that the separation is exactly the number typed, on whichever side it was given. + check("ARC", len(cs) == 2 and near(cs[0], 25.0) and near(cs[1] - cs[0], 5.0), + f"radii {cs} — separated by exactly {cs[1]-cs[0] if len(cs)==2 else None}") + lp = d["closed_loops"] + check("CLOSED", len(lp) == 2 and all(l["closed"] for l in lp), f"{len(lp)} closed loops") + check("VOID", any(l["holes"] for l in lp), "the inner circle is read as a void of the outer") + leave_sketch() + + +def cross(a, b): + """Where two lines' infinite supports meet.""" + (x1, y1), (x2, y2) = a["p0"], a["p1"] + (x3, y3), (x4, y4) = b["p0"], b["p1"] + d = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3) + t = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / d + return (x1 + t * (x2 - x1), y1 + t * (y2 - y1)) + + +def mid_of(a, b): + return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) + + +def point_line_dist(p, l): + (x0, y0), (x1, y1) = l["p0"], l["p1"] + dx, dy = x1 - x0, y1 - y0 + n = math.hypot(dx, dy) + return abs((p[0] - x0) * dy - (p[1] - y0) * dx) / n + + +def corner_pair(ents): + """Two adjacent lines of a rectangle: the first line and the one sharing an endpoint.""" + ls = [e for e in ents if e["type"] == "line"] + a = ls[0] + for b in ls[1:]: + if min(math.dist(pa, pb) for pa in (a["p0"], a["p1"]) for pb in (b["p0"], b["p1"])) < 1e-6: + return a, b + die("no adjacent pair in what should be a rectangle") + + +CONSTRUCTION_CHECKBOX = (419, 75 + CHROME_DY) + + +def draw_line(x0, y0, x1, y1, length, angle): + clickmm(x0, y0); clickmm(x1, y1) + values(length, angle) + + +def rung_mirror(): + print("\nC4 mirror — a half profile reflected about a construction axis") + enter_sketch("l") + click(*CONSTRUCTION_CHECKBOX) # the axis is reference, not material + key("l", 0.6) + draw_line(0, -40, 0, 40, 80, 90) # the axis, on x = 0 + click(*CONSTRUCTION_CHECKBOX) # back to real geometry + key("l", 0.6) + draw_line(0, -40, 50, -40, 50, 0) + key("l", 0.6) + draw_line(50, -40, 50, 40, 80, 90) + key("l", 0.6) + draw_line(50, 40, 0, 40, 50, 180) + d0 = describe()["entities"] + axis = [e for e in d0 if e.get("construction")] + check("VERTEX", len(axis) == 1, f"{len(axis)} construction axis") + half = [e for e in d0 if e["type"] == "line" and not e.get("construction")] + check("LENGTH", len(half) == 3, f"{len(half)} lines in the half profile") + key("m", 0.6) + clickmm(*mid(axis[0])) + for e in half: + clickmm(*mid(e)) + clickmm(-90, 60) # empty space confirms + d = describe() + real = [e for e in d["entities"] if e["type"] == "line" and not e.get("construction")] + check("LENGTH", len(real) == 6, f"{len(real)} lines after the reflection") + lp = [l for l in d["closed_loops"]] + check("CLOSED", len(lp) == 1 and lp[0]["closed"], f"{len(lp)} closed loop(s)") + # Graded against the geometry ACTUALLY DRAWN, not against the coordinates I aimed at. A + # synthetic click lands on a whole pixel, so the half profile sits a few tenths of a + # millimetre off the origin; the typed values fix its lengths and angles, not its anchor. + # Demanding 8000.000000 here would grade my aim, and the mirror is what is under test. + far = max(half, key=lambda e: e["length"]) # the edge parallel to the axis + w = point_line_dist(mid(far), axis[0]) + want = 2.0 * w * far["length"] + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), want, 1e-6), + f"area {abs(lp[0]['area']):.6f} vs 2 x {w:.6f} x {far['length']:.6f} = {want:.6f}") + # SYMMETRY is the property this rung exists for: every vertex must have its exact reflection + # ABOUT THE AXIS THAT WAS DRAWN. + vs = [tuple(p) for e in real for p in (e["p0"], e["p1"])] + def refl(q): + (ax, ay), (bx, by) = axis[0]["p0"], axis[0]["p1"] + dx, dy = bx - ax, by - ay + n = dx * dx + dy * dy + t = ((q[0] - ax) * dx + (q[1] - ay) * dy) / n + fx, fy = ax + t * dx, ay + t * dy + return (2 * fx - q[0], 2 * fy - q[1]) + missing = [v for v in vs if not any(math.dist(refl(v), o) < 1e-9 for o in vs)] + check("SYMMETRY", not missing, + f"every one of {len(vs)} vertices has its exact reflection about the drawn axis") + leave_sketch() + + +def rung_trim(): + print("\nC5 trim — cut one arm off a crossing") + enter_sketch("l") + draw_line(-50, 0, 50, 0, 100, 0) + key("l", 0.6) + draw_line(0, -50, 0, 50, 100, 90) + d0 = describe()["entities"] + horiz = min(d0, key=lambda e: abs(e["p1"][1] - e["p0"][1])) + vert = max(d0, key=lambda e: abs(e["p1"][1] - e["p0"][1])) + X = cross(horiz, vert) + left = min(horiz["p0"], horiz["p1"]) # the end that must survive + want = math.dist(left, X) + key("t", 0.6) + clickmm(*mid_of(X, max(horiz["p0"], horiz["p1"]))) # the arm on the far side of the crossing + d = describe() + ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line") + check("LENGTH", len(ls) == 2 and near(ls[1], vert["length"], 1e-9) and near(ls[0], want, 1e-9), + f"lengths {ls} — the picked arm is gone at the crossing (expected {want:.9f}), " + f"the other line untouched") + ends = [tuple(p) for e in d["entities"] if e["type"] == "line" for p in (e["p0"], e["p1"])] + check("VERTEX", any(math.dist(X, q) < 1e-9 for q in ends), "the cut lands exactly on the crossing") + leave_sketch() + + +def rung_extend(): + print("\nC6 extend — reach a line to the one it stops short of") + enter_sketch("l") + draw_line(-50, 0, -10, 0, 40, 0) + key("l", 0.6) + draw_line(0, -50, 0, 50, 100, 90) + d0 = describe()["entities"] + short = min(d0, key=lambda e: e["length"]) + vert = max(d0, key=lambda e: e["length"]) + X = cross(short, vert) + far = min((short["p0"], short["p1"]), key=lambda q: q[0]) # the end that stays put + near_end = max((short["p0"], short["p1"]), key=lambda q: q[0]) + want = math.dist(far, X) + key("x", 0.6) + clickmm(*mid_of(near_end, mid_of(far, near_end))) # click the end that must grow + d = describe() + ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line") + check("LENGTH", len(ls) == 2 and near(ls[0], want, 1e-9), + f"lengths {ls} — {short['length']:.6f} grew to exactly {want:.9f}") + ends = [tuple(p) for e in d["entities"] if e["type"] == "line" for p in (e["p0"], e["p1"])] + check("VERTEX", any(math.dist(X, q) < 1e-9 for q in ends), + "the new end sits exactly on the target line") + leave_sketch() + + +# =================================================================== LADDER D — dimensions +# A drawn shape with no numbers on it, then numbers put on it by hand: the Dimension tool for a +# value, the Constrain buttons for a relation. Both must hold the value they were given AND take +# the degrees of freedom away — a dimension that moves the geometry but leaves the DoF standing +# has not constrained anything, it has only nudged it. + +# Constrain-mode toolbar, measured off the rig at 1920x1080 (icon centres, 42 px apart). +# +# THIS LIST MIRRORS THE cbtn() SEQUENCE IN DesignPanel.cpp AND HAS TO BE UPDATED WHEN A BUTTON +# IS INSERTED. Positions are computed by index, so inserting a button shifts every entry after +# it and the map silently points at the wrong icon -- a rung then applies some OTHER constraint +# and fails with a geometric message that says nothing about buttons. +# +# It has already happened once, undetected: equal_radius and collinear (after "equal"), sym_v +# and sym_h (after "symmetric") and dist_x and dist_y (after "fix") were added while this list +# still had 14 names. Everything from index 6 on was wrong in both forks. Nothing caught it +# because the ladder only ever clicks "perpendicular" (3) and "equal" (5), both of which sit +# before the first insertion. The next rung to use "tangent" would have clicked "collinear". +CON_BTN_Y = 76 + CHROME_DY +CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( + ["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal", + "equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric", + "sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])} + +# The SAME twenty buttons, at their SKETCH-mode x. Constraining during a sketch put the group +# after the (wide) sketch toolbar instead of at the start of an otherwise empty row, so every +# button sits 228 px further right. Measured, not derived: the strip was screenshotted in sketch +# mode and the icon columns detected — first centre 677, pitch 42, twenty of them. Deriving it +# from the Constrain-mode map is exactly how this table drifted the last time. +# NOTE FOR THIS FORK: 677 was measured on the OTHER fork's rig. Y is safe (it rides CON_BTN_Y, +# which already carries CHROME_DY), but the X start depends on how wide the sketch toolbar to the +# left of this group renders, and this fork keeps mainline's top row. Re-measure before trusting +# D11 here: screenshot in sketch mode and detect the icon columns, do not derive it by offset. +CON_BTN_SKETCH = {n: (677 + 42 * i, CON_BTN_Y) for i, n in enumerate( + ["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal", + "equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric", + "sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])} + + +def draw_rect_undimensioned(): + """A rectangle by two clicks, with both queued value fields dismissed (Esc keeps it as drawn).""" + clickmm(-60, -40); clickmm(60, 40) + key("Escape", 0.7) # Width — keep as drawn + key("Escape", 0.7) # Height — keep as drawn + + +def rung_dimension(): + print("\nD1 dimension — put a length on a side that had none") + enter_sketch("r") + draw_rect_undimensioned() + d0 = describe() + dof0 = d0["dof"] + check("VERTEX", dof0 > 0, f"the undimensioned rectangle has {dof0} degrees of freedom") + side = max((e for e in d0["entities"] if e["type"] == "line"), key=lambda e: e["length"]) + key("d", 0.6) + clickmm(*mid(side)) + values(90) + d = describe() + ls = sorted(round(e["length"], 9) for e in d["entities"] if e["type"] == "line") + check("LENGTH", any(near(x, 90.0, 1e-9) for x in ls), f"the dimensioned side reads {ls}") + check("VERTEX", d["dof"] < dof0, f"degrees of freedom {dof0} -> {d['dof']}") + check("CLOSED", d["solve_ok"] and len(d["closed_loops"]) == 1, "still one closed, solved loop") + leave_sketch() + + +CONFIRM_BTN = (1751, 75 + CHROME_DY) + + +def confirm_and_reopen(): + """(see reopen_sketch below — same two steps, kept together for the constrain rungs)""" + """Leave Constrain with the action bar's tick, then re-open the sketch for editing. + + THE DoF HAS TO BE READ HERE, not in Constrain mode. While constraining, sketch_describe + reports the LIVE tool's dof and constraint count, which the constrain session does not + touch — it works on the committed feature's own entity_constraints, and the panel computes + its readout from those. Reading during the session says 4 -> 4 for a constraint that really + did land; reading after the round trip says 4 -> 3, and proves the constraint was persisted + rather than merely previewed. + """ + click(*CONFIRM_BTN, pause=1.5) + w, X, Y, _, _ = win() + sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} " + f"click --repeat 2 --delay 120 1") + time.sleep(2.0) + return describe() + + +def rung_constrain(): + reset_document() + print("\nD2 constrain — Equal length on two adjacent sides, from the Constrain toolbar") + enter_sketch("r") + draw_rect_undimensioned() + a, b = corner_pair(describe()["entities"]) + check("LENGTH", not near(a["length"], b["length"], 1e-6), + f"the two sides start unequal: {a['length']:.6f} vs {b['length']:.6f}") + key("k", 1.5) # finish the sketch and enter Constrain + # Re-read the picks from the COMMITTED sketch: finish_sketch repackages the entities, so an + # index taken before Constrain is not the same index afterwards. + d1 = describe() + a, b = corner_pair(d1["entities"]) + dof0 = 4 # an undimensioned rectangle: position + size + ia, ib = d1["entities"].index(a), d1["entities"].index(b) + clickmm(*mid(a)); clickmm(*mid(b)) + click(*CON_BTN["equal"]) + time.sleep(1.0) + d = describe() + la, lb = d["entities"][ia]["length"], d["entities"][ib]["length"] + check("LENGTH", near(la, lb, 1e-9), f"the two sides are now equal: {la:.9f} and {lb:.9f}") + d2 = confirm_and_reopen() + check("VERTEX", d2["dof"] == dof0 - 1, f"degrees of freedom {dof0} -> {d2['dof']} after the round trip") + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + ls2 = sorted(round(e["length"], 9) for e in d2["entities"] if e["type"] == "line") + check("LENGTH", near(ls2[0], la, 1e-9) and near(ls2[-1], la, 1e-9), + f"the geometry came back unchanged: {ls2}") + leave_sketch() + + +def rung_perpendicular(): + reset_document() + print("\nD3 constrain — two free lines made exactly perpendicular") + enter_sketch("l") + clickmm(-50, -30); clickmm(30, -18) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + # 56 degrees off the first line, not 3. The original second point put the pair within + # inference's perpendicular tolerance, so on a rig whose camera maps the click a pixel + # differently the two lines arrive ALREADY at exactly 90.000000 -- inference did the job the + # rung exists to test, and the precondition failed while every later check passed. Held on + # one fork and failed on the other from the same source, which is the signature of a rung + # that depends on luck. Start well outside any snap tolerance so the button has real work. + clickmm(30, -18); clickmm(55, 35) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + check("ANGLE", abs(angle_between(d0["entities"][0], d0["entities"][1]) - 90.0) > 1e-3, + f"they start at {angle_between(d0['entities'][0], d0['entities'][1]):.6f} deg") + key("k", 1.5) + d1 = describe() + clickmm(*mid(d1["entities"][0])); clickmm(*mid(d1["entities"][1])) + click(*CON_BTN["perpendicular"]) + time.sleep(1.0) + d = describe() + ang = angle_between(d["entities"][0], d["entities"][1]) + # 1e-6 deg, which is 1.7e-8 radians: the LIVE solve converges to its own tolerance and lands + # at 89.999999991 from a 51 deg start. The old 1e-9 held only because the pair began 3 deg + # from square, so the correction was tiny -- it was measuring how little work the solver had + # to do, not whether the lines came out perpendicular. The round-trip check below still + # demands exactly 90: the committed feature re-solves from scratch and gets there. + check("ANGLE", near(ang, 90.0, 1e-6), f"now {ang:.9f} deg") + d2 = confirm_and_reopen() + ang2 = angle_between(d2["entities"][0], d2["entities"][1]) + check("ANGLE", near(ang2, 90.0, 1e-9), f"still {ang2:.9f} deg after the round trip") + check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"], + f"{d2['constraints']} constraints survived, dof {d2['dof']}") + leave_sketch() + + +def angle_between(a, b): + va = (a["p1"][0] - a["p0"][0], a["p1"][1] - a["p0"][1]) + vb = (b["p1"][0] - b["p0"][0], b["p1"][1] - b["p0"][1]) + c = (va[0] * vb[0] + va[1] * vb[1]) / (math.hypot(*va) * math.hypot(*vb)) + return math.degrees(math.acos(max(-1.0, min(1.0, c)))) + + +def parallel_gap(a, b): + """How far from parallel, in degrees. Parallel reads 0 or 180; both mean parallel.""" + ang = angle_between(a, b) + return min(ang, 180.0 - ang) + + +def rung_parallel(): + reset_document() + print("\nD10 constrain — two divergent lines made parallel (the button had no rung at all)") + enter_sketch("l") + clickmm(-50, -30); clickmm(30, -18) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + # Well outside inference's 3 deg snap, for the same reason D3 starts at 56: a pair that + # arrives already parallel would let a dead button pass the rung. + clickmm(30, -18); clickmm(55, 35) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + g0 = parallel_gap(d0["entities"][0], d0["entities"][1]) + check("ANGLE", g0 > 1e-3, f"they start {g0:.6f} deg from parallel") + key("k", 1.5) + d1 = describe() + clickmm(*mid(d1["entities"][0])); clickmm(*mid(d1["entities"][1])) + click(*CON_BTN["parallel"]) + time.sleep(1.0) + d = describe() + g = parallel_gap(d["entities"][0], d["entities"][1]) + check("ANGLE", near(g, 0.0, 1e-6), f"now {g:.9f} deg from parallel") + check("LENGTH", d["entities"][0]["length"] > 1.0 and d["entities"][1]["length"] > 1.0, + f"neither line collapsed: {d['entities'][0]['length']:.6f}, {d['entities'][1]['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"], + f"{d2['constraints']} constraints survived the commit, dof {d2['dof']}") + leave_sketch() + + +def rung_live_constrain(): + reset_document() + print("\nD11 constrain WHILE SKETCHING — no commit, no tree pick, no padlock") + enter_sketch("l") + clickmm(-50, -30); clickmm(30, -18) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + clickmm(30, -18); clickmm(55, 35) + # Two Escapes only: the first clears the polyline's pending point, the second drops the + # tool to Select. The session stays LIVE -- that is the whole point of this rung. + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + check("VERTEX", len(d0["entities"]) == 2, f"{len(d0['entities'])} entities in the live sketch") + g0 = parallel_gap(d0["entities"][0], d0["entities"][1]) + check("ANGLE", g0 > 1e-3, f"they start {g0:.6f} deg from parallel") + # Pick both in the LIVE session, then press the button. No "k": pressing Constrain is + # exactly the step this rung exists to prove is no longer necessary. + clickmm(*mid(d0["entities"][0])) + cx, cy = mid(d0["entities"][1]) + click_ctrl(*px(cx, cy)) + click(*CON_BTN_SKETCH["parallel"]) + time.sleep(1.2) + d = describe() + g = parallel_gap(d["entities"][0], d["entities"][1]) + check("ANGLE", near(g, 0.0, 1e-6), f"parallel without ever leaving the sketch: {g:.9f} deg") + check("LENGTH", d["entities"][0]["length"] > 1.0 and d["entities"][1]["length"] > 1.0, + f"neither line collapsed: {d['entities'][0]['length']:.6f}, {d['entities'][1]['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"], + f"{d2['constraints']} constraints survived the commit, dof {d2['dof']}") + leave_sketch() + + +# ---- the eleven buttons no rung had ever pressed ------------------------------------------ +# Twenty constraint buttons, nine of them exercised. The other eleven were "implemented" in the +# sense that the kernel builds the right def for them -- which is exactly what was true of +# Parallel this morning, right up until a user pressed it. What a kernel test cannot see: that +# the BUTTON is wired to the index its name claims. CON_BTN is 449 + 42*i over a hand-written +# name list and it has drifted once already, unnoticed for months, because nothing pressed +# anything past index 5. These rungs press the rest. + +def rung_vertical(): + reset_document() + print("\nD12 constrain — Vertical on one line, from a line that is not") + enter_sketch("l") + clickmm(-20, -35); clickmm(5, 30) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + dx0 = abs(d0["entities"][0]["p1"][0] - d0["entities"][0]["p0"][0]) + check("VERTEX", dx0 > 1.0, f"it starts off-plumb by {dx0:.6f} mm") + key("k", 1.5) + d1 = describe() + clickmm(*mid(d1["entities"][0])) + click(*CON_BTN["vertical"]) + time.sleep(1.0) + d = describe() + e = d["entities"][0] + dx = abs(e["p1"][0] - e["p0"][0]) + check("VERTEX", near(dx, 0.0, 1e-6), f"now plumb: dx = {dx:.9f}") + check("LENGTH", e["length"] > 1.0, f"and it did not collapse: {e['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_equal_radius_button(): + reset_document() + print("\nD13 constrain — the equal_radius button ITSELF (index 6: past where the map drifted)") + # D4 proves the SHARED Equal button promotes on two circles. This presses design_c_equal_radius, + # the first button after the six that were once inserted without updating this table. + enter_sketch("c") + clickmm(-35, 0); clickmm(-20, 0); key("Escape", 0.7) + key("c", 0.6) + clickmm(35, 0); clickmm(60, 0); key("Escape", 0.7) + d0 = describe() + cs = [e for e in d0["entities"] if e["type"] == "circle"] + check("ARC", len(cs) == 2 and not near(cs[0]["radius"], cs[1]["radius"], 1e-6), + f"they start unequal: {[round(c['radius'], 6) for c in cs]}") + key("k", 1.5) + d1 = describe() + cs = [e for e in d1["entities"] if e["type"] == "circle"] + ia, ib = d1["entities"].index(cs[0]), d1["entities"].index(cs[1]) + clickmm(*rim(cs[0])); clickmm(*rim(cs[1])) + click(*CON_BTN["equal_radius"]) + time.sleep(1.0) + d = describe() + ra, rb = d["entities"][ia]["radius"], d["entities"][ib]["radius"] + check("ARC", near(ra, rb, 1e-9), f"equal by the dedicated button: {ra:.9f} and {rb:.9f}") + check("ARC", ra > 1e-6, f"and not equal at zero: {ra:.9f}") + leave_sketch() + + +def rung_concentric(): + reset_document() + print("\nD14 constrain — Concentric puts two circles on one centre, radii untouched") + enter_sketch("c") + clickmm(-30, -10); clickmm(-14, -10); key("Escape", 0.7) + key("c", 0.6) + clickmm(28, 14); clickmm(52, 14); key("Escape", 0.7) + d0 = describe() + cs = [e for e in d0["entities"] if e["type"] == "circle"] + gap0 = math.dist(cs[0]["center"], cs[1]["center"]) + r_before = sorted(round(c["radius"], 9) for c in cs) + check("ARC", gap0 > 1.0, f"centres start {gap0:.6f} mm apart") + key("k", 1.5) + d1 = describe() + cs = [e for e in d1["entities"] if e["type"] == "circle"] + ia, ib = d1["entities"].index(cs[0]), d1["entities"].index(cs[1]) + clickmm(*rim(cs[0])); clickmm(*rim(cs[1])) + click(*CON_BTN["concentric"]) + time.sleep(1.0) + d = describe() + gap = math.dist(d["entities"][ia]["center"], d["entities"][ib]["center"]) + check("ARC", near(gap, 0.0, 1e-6), f"centres now coincide: {gap:.9f} mm apart") + # Concentric is about centres only. A solve that also equalised the radii would be wrong. + r_after = sorted(round(d["entities"][i]["radius"], 9) for i in (ia, ib)) + check("ARC", all(near(a, b, 1e-6) for a, b in zip(r_before, r_after)), + f"radii untouched: {r_before} -> {r_after}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_tangent(): + reset_document() + print("\nD15 constrain — Tangent brings a line to touch a circle exactly once") + enter_sketch("c") + clickmm(0, 0); clickmm(20, 0); key("Escape", 0.7) + key("l", 0.6) + # A line that clearly MISSES the circle: its perpendicular distance from the centre is well + # above the radius, so tangency has real work to do in the direction of shrinking the gap. + clickmm(-45, 38); clickmm(45, 44) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + c0 = next(e for e in d0["entities"] if e["type"] == "circle") + l0 = next(e for e in d0["entities"] if e["type"] == "line") + d_before = point_line_distance(c0["center"], l0) + check("ARC", d_before > c0["radius"] + 1.0, + f"the line misses by {d_before - c0['radius']:.6f} mm") + key("k", 1.5) + d1 = describe() + c1 = next(e for e in d1["entities"] if e["type"] == "circle") + l1 = next(e for e in d1["entities"] if e["type"] == "line") + ic, il = d1["entities"].index(c1), d1["entities"].index(l1) + clickmm(*rim(c1)); clickmm(*mid(l1)) + click(*CON_BTN["tangent"]) + time.sleep(1.2) + d = describe() + cc, ll = d["entities"][ic], d["entities"][il] + dist = point_line_distance(cc["center"], ll) + check("ARC", near(dist, cc["radius"], 1e-6), + f"centre-to-line {dist:.9f} == radius {cc['radius']:.9f}") + check("ARC", cc["radius"] > 1e-6, f"and the circle did not collapse: r = {cc['radius']:.9f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_midpoint(): + reset_document() + print("\nD16 constrain — Midpoint drops a free point onto the middle of a line") + enter_sketch("l") + clickmm(-50, -20); clickmm(40, 26) + key("Escape", 0.7); key("Escape", 0.7) + key("p", 0.6) + clickmm(10, -35) + d0 = describe() + l0 = next(e for e in d0["entities"] if e["type"] == "line") + p0 = next(e for e in d0["entities"] if e["type"] == "point") + mid0 = ((l0["p0"][0] + l0["p1"][0]) / 2.0, (l0["p0"][1] + l0["p1"][1]) / 2.0) + check("VERTEX", math.dist(p0["p"], mid0) > 1.0, + f"the point starts {math.dist(p0['p'], mid0):.6f} mm off the midpoint") + key("k", 1.5) + d1 = describe() + l1 = next(e for e in d1["entities"] if e["type"] == "line") + p1 = next(e for e in d1["entities"] if e["type"] == "point") + il, ip = d1["entities"].index(l1), d1["entities"].index(p1) + # Point first, then the line: Midpoint binds a POINT role on ea and a whole segment on eb. + clickmm(*p1["p"]); clickmm(*mid(l1)) + click(*CON_BTN["midpoint"]) + time.sleep(1.2) + d = describe() + ll, pp = d["entities"][il], d["entities"][ip] + m = ((ll["p0"][0] + ll["p1"][0]) / 2.0, (ll["p0"][1] + ll["p1"][1]) / 2.0) + check("VERTEX", near(math.dist(pp["p"], m), 0.0, 1e-6), + f"the point sits on the midpoint: off by {math.dist(pp['p'], m):.9f}") + check("LENGTH", ll["length"] > 1.0, f"and the line did not collapse: {ll['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_symmetric_line_axis(): + reset_document() + print("\nD17 constrain — Symmetric about a PICKED axis line (the three-pick form)") + # D7 covers the implicit-axis buttons. This is the plain Symmetric: two entities AND an axis + # entity, three picks, the only constraint on the bar that needs a third. + enter_sketch("l") + clickmm(0, -40); clickmm(0, 40) # the axis: a real line on x = 0 + key("Escape", 0.7); key("Escape", 0.7) + key("p", 0.6) + clickmm(-38, 12) + key("p", 0.6) + clickmm(14, 12) + d0 = describe() + ps = [e for e in d0["entities"] if e["type"] == "point"] + check("VERTEX", not near(abs(ps[0]["p"][0]), abs(ps[1]["p"][0]), 1e-3), + f"they start unmirrored: x = {ps[0]['p'][0]:.6f}, {ps[1]['p'][0]:.6f}") + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ax = next(e for e in d1["entities"] if e["type"] == "line") + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + iax = d1["entities"].index(ax) + clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"]); clickmm(*mid(ax)) + click(*CON_BTN["symmetric"]) + time.sleep(1.2) + d = describe() + # Measure against the axis WHERE IT NOW IS, not against x = 0. All three entities are free, + # so the solver is entitled to satisfy the mirror by moving the AXIS instead of the points -- + # and it does: the pair came out symmetric about x = -8.18, which is a correct solution and + # which an "xa == -xb" assertion calls a failure. That was this rung being wrong, not the app. + ax_now = d["entities"][iax] + sa = signed_point_line_distance(d["entities"][ia]["p"], ax_now) + sb = signed_point_line_distance(d["entities"][ib]["p"], ax_now) + ya, yb = d["entities"][ia]["p"][1], d["entities"][ib]["p"][1] + check("VERTEX", near(sa, -sb, 1e-6), + f"mirrored about the picked line: {sa:.9f} and {sb:.9f} from it") + check("VERTEX", abs(sa) > 1e-6, f"and not both collapsed onto it: |d| = {abs(sa):.9f}") + check("VERTEX", near(ya, yb, 1e-6), f"y untouched on both: {ya:.6f}, {yb:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_symmetric_h(): + reset_document() + print("\nD18 constrain — symmetric about the HORIZONTAL axis (sym_h, the twin of D7)") + enter_sketch("p") + clickmm(20, -34) + key("p", 0.6) + clickmm(20, 9) + d0 = describe() + ps = [e for e in d0["entities"] if e["type"] == "point"] + check("VERTEX", not near(abs(ps[0]["p"][1]), abs(ps[1]["p"][1]), 1e-3), + f"they start unmirrored: y = {ps[0]['p'][1]:.6f}, {ps[1]['p'][1]:.6f}") + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"]) + click(*CON_BTN["sym_h"]) + time.sleep(1.0) + d = describe() + xa, xb = d["entities"][ia]["p"][0], d["entities"][ib]["p"][0] + ya, yb = d["entities"][ia]["p"][1], d["entities"][ib]["p"][1] + check("VERTEX", near(ya, -yb, 1e-9), f"mirrored across y = 0: {ya:.9f} and {yb:.9f}") + check("VERTEX", abs(ya) > 1e-6, f"and not both collapsed onto the axis: |y| = {abs(ya):.9f}") + check("VERTEX", near(xa, xb, 1e-6), f"x untouched on both: {xa:.6f}, {xb:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_radius(): + reset_document() + print("\nD19 constrain — a typed Radius drives the circle to exactly that radius") + enter_sketch("c") + clickmm(0, 0); clickmm(31, 0); key("Escape", 0.7) + d0 = describe() + c0 = next(e for e in d0["entities"] if e["type"] == "circle") + check("ARC", not near(c0["radius"], 22.0, 1e-3), f"it starts at r = {c0['radius']:.6f}") + key("k", 1.5) + d1 = describe() + c1 = next(e for e in d1["entities"] if e["type"] == "circle") + ic = d1["entities"].index(c1) + ctr = list(c1["center"]) + clickmm(*rim(c1)) + click(*CON_BTN["radius"]) + time.sleep(0.8) + values(22) + d = describe() + cc = d["entities"][ic] + check("ARC", near(cc["radius"], 22.0, 1e-6), f"radius driven to {cc['radius']:.9f}") + # A Radius constraint moves the RIM, never the centre. + check("ARC", near(math.dist(cc["center"], ctr), 0.0, 1e-6), + f"and the centre stayed put: moved {math.dist(cc['center'], ctr):.9f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_diameter(): + reset_document() + print("\nD20 constrain — a typed Diameter is a diameter, not a radius") + # The failure this exists for is a factor of two: Diameter wired to the Radius handler gives + # a circle of r = 30 for a typed 30, and nothing about the sketch looks wrong. + enter_sketch("c") + clickmm(0, 0); clickmm(19, 0); key("Escape", 0.7) + key("k", 1.5) + d1 = describe() + c1 = next(e for e in d1["entities"] if e["type"] == "circle") + ic = d1["entities"].index(c1) + clickmm(*rim(c1)) + click(*CON_BTN["diameter"]) + time.sleep(0.8) + values(30) + d = describe() + r = d["entities"][ic]["radius"] + check("ARC", near(r, 15.0, 1e-6), f"typed diameter 30 gives radius {r:.9f}, not 30") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_fix(): + reset_document() + print("\nD21 constrain — Fix anchors a point, and the anchor is what the solver moves around") + # Fix on its own is unfalsifiable: nothing moved, so nothing proves it did anything. The + # property only becomes observable when a SECOND constraint would otherwise have moved the + # fixed point -- so drive the pair apart and see which end travels. + enter_sketch("p") + clickmm(-25, 0) + key("p", 0.6) + clickmm(15, 0) + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + anchor = list(ps[0]["p"]) + clickmm(*ps[0]["p"]) + click(*CON_BTN["fix"]) + time.sleep(1.0) + d = describe() + check("VERTEX", near(math.dist(d["entities"][ia]["p"], anchor), 0.0, 1e-9), + "the fixed point did not move when it was fixed") + # Now demand a horizontal gap far from the current one. Only the FREE point may travel. + clickmm(*d["entities"][ia]["p"]); clickmm(*d["entities"][ib]["p"]) + click(*CON_BTN["dist_x"]) + time.sleep(0.8) + values(70) + d = describe() + moved_a = math.dist(d["entities"][ia]["p"], anchor) + gap = abs(d["entities"][ib]["p"][0] - d["entities"][ia]["p"][0]) + check("LENGTH", near(gap, 70.0, 1e-6), f"the gap was driven to {gap:.9f}") + check("VERTEX", near(moved_a, 0.0, 1e-6), + f"and the anchored point held station: moved {moved_a:.9f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 1, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_distance_y(): + reset_document() + print("\nD22 constrain — vertical distance, and it is not the straight-line one either") + enter_sketch("p") + clickmm(-22, -18) + key("p", 0.6) + clickmm(19, 24) + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + dx_before = d1["entities"][ib]["p"][0] - d1["entities"][ia]["p"][0] + clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"]) + click(*CON_BTN["dist_y"]) + time.sleep(0.8) + values(35) + d = describe() + gy = abs(d["entities"][ib]["p"][1] - d["entities"][ia]["p"][1]) + gx = d["entities"][ib]["p"][0] - d["entities"][ia]["p"][0] + check("LENGTH", near(gy, 35.0, 1e-6), f"vertical gap driven to {gy:.9f}") + check("VERTEX", near(gx, dx_before, 1e-6), + f"the horizontal gap is untouched at {gx:.9f} — this is not a straight-line distance") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def signed_point_line_distance(p, line): + """Perpendicular distance with a SIGN, so the two sides of a mirror are distinguishable.""" + x0, y0 = line["p0"]; x1, y1 = line["p1"] + dx, dy = x1 - x0, y1 - y0 + n = math.hypot(dx, dy) + return (dy * (p[0] - x0) - dx * (p[1] - y0)) / n + + +def point_line_distance(p, line): + """Perpendicular distance from a point to the INFINITE line through a segment.""" + x0, y0 = line["p0"]; x1, y1 = line["p1"] + dx, dy = x1 - x0, y1 - y0 + n = math.hypot(dx, dy) + return abs(dy * (p[0] - x0) - dx * (p[1] - y0)) / n + + +# =================================================================== DURABILITY +# Exactness that does not survive an undo or a save is not exactness. + +def rim(e, deg=135.0): + """A point on a circle's rim, away from +X. + + NOT the +X point: a click there grabs the RADIUS GRIP instead of selecting the circle, + and a grip click REPLACES the selection with that one entity — so the second pick of a + two-entity constraint silently discards the first. + """ + a = math.radians(deg) + return (e["center"][0] + e["radius"] * math.cos(a), + e["center"][1] + e["radius"] * math.sin(a)) + + +def rung_equal_radius(): + reset_document() + print("\nD4 constrain — Equal on two CIRCLES means equal radius, not equal length") + # The dead end this fixed: two circles + Equal used to emit EQUAL_LENGTH_LINES, which + # constrains nothing on a curve. The user got a silent no-op with no error and no way to + # tell why. One Equal button, two meanings — lines get length, curves get radius. + # NOT dimensioned: a typed radius is a DRIVING Radius constraint, and EqualRadius on two + # circles pinned to 15 and 25 is genuinely inconsistent -- the kernel refuses the addition + # and is right to. Draw them at different sizes and leave the radius free. + enter_sketch("c") + clickmm(-35, 0); clickmm(-20, 0); key("Escape", 0.7) + key("c", 0.6) + clickmm(35, 0); clickmm(60, 0); key("Escape", 0.7) + d0 = describe() + cs = [e for e in d0["entities"] if e["type"] == "circle"] + check("ARC", len(cs) == 2 and not near(cs[0]["radius"], cs[1]["radius"], 1e-6), + f"they start unequal: {[round(c['radius'], 6) for c in cs]}") + key("k", 1.5) + d1 = describe() + cs = [e for e in d1["entities"] if e["type"] == "circle"] + ia, ib = d1["entities"].index(cs[0]), d1["entities"].index(cs[1]) + clickmm(*rim(cs[0])); clickmm(*rim(cs[1])) + click(*CON_BTN["equal"]) # the SHARED Equal button, not design_c_equal_radius + time.sleep(1.0) + d = describe() + ra, rb = d["entities"][ia]["radius"], d["entities"][ib]["radius"] + check("ARC", near(ra, rb, 1e-9), f"the two radii are now equal: {ra:.9f} and {rb:.9f}") + check("ARC", ra > 1e-6, f"and not equal at zero: {ra:.9f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + rs = sorted(round(e["radius"], 9) for e in d2["entities"] if e["type"] == "circle") + check("ARC", len(rs) == 2 and near(rs[0], rs[-1], 1e-9), + f"still equal after the round trip: {rs}") + leave_sketch() + + +def rung_collinear(): + reset_document() + print("\nD5 constrain — two offset lines brought onto one infinite line") + # Oblique on purpose: axis-aligned segments pick up an auto Horizontal at draw time, and + # this rung is about Collinear, not about interacting with inference. + enter_sketch("l") + clickmm(-60, -14); clickmm(-12, -6) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + clickmm(12, 14); clickmm(60, 22) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + ls = [e for e in d0["entities"] if e["type"] == "line"] + check("VERTEX", len(ls) == 2 and abs(ls[0]["p0"][1] - ls[1]["p0"][1]) > 1.0, + f"they start on different lines, dy = {abs(ls[0]['p0'][1] - ls[1]['p0'][1]):.6f}") + key("k", 1.5) + d1 = describe() + ls = [e for e in d1["entities"] if e["type"] == "line"] + ia, ib = d1["entities"].index(ls[0]), d1["entities"].index(ls[1]) + clickmm(*mid(ls[0])); clickmm(*mid(ls[1])) + click(*CON_BTN["collinear"]) + time.sleep(1.0) + d = describe() + A, B = d["entities"][ia], d["entities"][ib] + ax, ay = A["p0"] + dx, dy = A["p1"][0] - ax, A["p1"][1] - ay + cross = [dx * (q[1] - ay) - dy * (q[0] - ax) for q in (B["p0"], B["p1"])] + check("VERTEX", all(abs(c) < 1e-6 for c in cross), + f"both ends of the second line lie on the first: cross {[round(c, 9) for c in cross]}") + # A line collapsed to a point is trivially collinear with anything, so the cross products + # above pass on a degenerate solve. Both lines have to survive. + check("LENGTH", A["length"] > 1.0 and B["length"] > 1.0, + f"neither line collapsed: {A['length']:.6f}, {B['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_distance_xy(): + reset_document() + print("\nD6 constrain — horizontal distance, and accepting its own value moves nothing") + enter_sketch("p") + clickmm(-30, -20) + key("p", 0.6) + clickmm(25, 18) + # A THIRD point, placed now while the point tool is still armed. The typed half of this rung + # needs a pair that carries no dimension yet, and pressing "p" again once the constrain tool + # has taken over does not re-arm the point tool -- the click is consumed as a pick and no + # point appears, which read as an IndexError three lines later rather than as what it was. + key("p", 0.6) + clickmm(5, 5) + d0 = describe() + ps = [e for e in d0["entities"] if e["type"] == "point"] + check("VERTEX", len(ps) == 3, f"three points placed: {len(ps)}") + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + ic = d1["entities"].index(ps[2]) + + # THE NO-OP PROPERTY, which is the one a unit test cannot reach: the field opens pre-filled + # with the current projected gap, and pressing Return must change nothing. The constraint is + # SIGNED — (pB - pA).dot(axis) — so if the refs were ordered to show |delta| while the real + # delta is negative, accepting the number on screen teleports the point across its anchor. + before = [list(d1["entities"][ia]["p"]), list(d1["entities"][ib]["p"])] + clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"]) + click(*CON_BTN["dist_x"]) + time.sleep(0.8) + key("Return", 1.2) # accept the pre-filled value, type nothing + d = describe() + after = [list(d["entities"][ia]["p"]), list(d["entities"][ib]["p"])] + moved = max(abs(a - b) for pa, pb in zip(before, after) for a, b in zip(pa, pb)) + # 5 microns, not zero: the field shows two decimals, so accepting what it shows commits a + # ROUNDED value and the geometry legitimately shifts by up to half a displayed unit. The + # defect this guards is a sign flip, which moves the point by twice the gap — tens of + # millimetres. A 1e-6 tolerance here failed on a 0.96 micron rounding step, which is the + # instrument disagreeing with the display, not the app misbehaving. + check("VERTEX", moved < 5e-3, + f"accepting the shown value moved nothing (max {moved:.9f})") + # "nothing moved" passes just as happily when the constraint was never applied at all -- + # which is exactly how the phantom-endpoint bug hid. The gap has to be REAL and driveable, + # so prove the mechanism works before trusting the no-op above. + check("LENGTH", abs(d["entities"][ib]["p"][0] - d["entities"][ia]["p"][0]) > 1.0, + "and the two points still have a real horizontal gap to dimension") + + # Now drive it with a TYPED value, on a FRESH pair. Not on the pair just dimensioned: that + # one already carries its DistanceX, the button rightly refuses to dimension it twice, and no + # field opens — so the digits went nowhere and the gap kept the value the no-op step had + # accepted. A green "gap is 54.94" for a rung that typed 40 is the rung's fault, not the app's. + dy_before = d["entities"][ic]["p"][1] - d["entities"][ia]["p"][1] + clickmm(*d["entities"][ia]["p"]); clickmm(*d["entities"][ic]["p"]) + click(*CON_BTN["dist_x"]) + time.sleep(0.8) + values(40) + d = describe() + gx = abs(d["entities"][ic]["p"][0] - d["entities"][ia]["p"][0]) + gy = d["entities"][ic]["p"][1] - d["entities"][ia]["p"][1] + check("LENGTH", near(gx, 40.0, 1e-6), f"horizontal gap driven to {gx:.9f}") + check("VERTEX", near(gy, dy_before, 1e-6), + f"the vertical gap is untouched at {gy:.9f} — this is not a straight-line distance") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_coincident_points(): + reset_document() + print("\nD8 constrain — Coincident on two POINTS actually joins them") + # The regression this exists for: the closest-pair search used to enumerate {P0,p0},{P1,p1} + # on both entities, and a Point's p1 reads (0,0). For two points the phantom pair is at + # distance 0, which ALWAYS wins, so the constraint was added against a role the solver + # cannot resolve and dropped in silence -- constraints=0, the points never moved, no error. + # Two points is the simplest possible use of the button and it did nothing on every press. + enter_sketch("p") + clickmm(-30, -20) + key("p", 0.6) + clickmm(25, 18) + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + gap0 = math.dist(ps[0]["p"], ps[1]["p"]) + check("VERTEX", gap0 > 1.0, f"they start apart: {gap0:.6f}") + clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"]) + click(*CON_BTN["coincident"]) + time.sleep(1.2) + d = describe() + gap = math.dist(d["entities"][ia]["p"], d["entities"][ib]["p"]) + check("VERTEX", gap < 1e-6, f"and are now joined: gap {gap:.9f}") + # The count has to be read AFTER the round trip, never during the session: while + # constraining, sketch_describe reports the LIVE tool's constraints, which the constrain + # session never touches -- it writes the committed feature's entity_constraints. Reading it + # here says 0 for a constraint that really did land. Same trap the D2 rung documents. + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit — a dropped one leaves this at 0") + ps2 = [e for e in d2["entities"] if e["type"] == "point"] + check("VERTEX", math.dist(ps2[0]["p"], ps2[1]["p"]) < 1e-6, + "and they are still joined after the round trip") + leave_sketch() + + +def rung_symmetric_axis(): + reset_document() + print("\nD7 constrain — symmetric about the vertical axis, with no construction line") + # Plain Symmetric needs a third pick for the mirror axis, so being symmetric about the + # sketch's own vertical axis used to mean drawing a construction line first. This button + # uses the implicit axis: two picks, no axis entity. + enter_sketch("p") + clickmm(-40, 15) + key("p", 0.6) + clickmm(12, 15) + key("k", 1.5) + d1 = describe() + ps = [e for e in d1["entities"] if e["type"] == "point"] + ia, ib = d1["entities"].index(ps[0]), d1["entities"].index(ps[1]) + check("VERTEX", not near(abs(ps[0]["p"][0]), abs(ps[1]["p"][0]), 1e-3), + f"they start unmirrored: x = {ps[0]['p'][0]:.6f}, {ps[1]['p'][0]:.6f}") + clickmm(*ps[0]["p"]); clickmm(*ps[1]["p"]) + click(*CON_BTN["sym_v"]) + time.sleep(1.0) + d = describe() + xa, xb = d["entities"][ia]["p"][0], d["entities"][ib]["p"][0] + ya, yb = d["entities"][ia]["p"][1], d["entities"][ib]["p"][1] + check("VERTEX", near(xa, -xb, 1e-9), f"mirrored across x = 0: {xa:.9f} and {xb:.9f}") + # Both collapsing onto the axis satisfies the mirror trivially. + check("VERTEX", abs(xa) > 1e-6, f"and not both collapsed onto the axis: |x| = {abs(xa):.9f}") + check("VERTEX", near(ya, yb, 1e-6), f"y untouched on both: {ya:.6f}, {yb:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["solve_ok"] and d2["constraints"] > 0, + f"{d2['constraints']} constraints survived the commit") + leave_sketch() + + +def rung_type_guards(): + reset_document() + print("\nD9 constrain — a button that cannot apply must SAY so, not store a dead constraint") + # Both halves were live defects found by reviewing the DistanceX/Y fix for its class. + # + # Horizontal on a lone Point: P1 is a role the solver cannot resolve, so the constraint was + # dropped at ref_ok -- but still STORED. Measured before the fix: constraints 0 -> 1 after the + # commit, point unmoved. A constraint listed in the panel that can never do anything is worse + # than a refusal, because the panel then claims the sketch is constrained when it is not. + enter_sketch("p") + clickmm(-30, -20) + key("k", 1.5) + d = describe() + ps = [e for e in d["entities"] if e["type"] == "point"] + ia = d["entities"].index(ps[0]) + clickmm(*ps[0]["p"]) + click(*CON_BTN["horizontal"]) + time.sleep(1.0) + d = describe() + check("VERTEX", near(d["entities"][ia]["p"][0], ps[0]["p"][0], 1e-9) and + near(d["entities"][ia]["p"][1], ps[0]["p"][1], 1e-9), + "Horizontal on a point moved nothing, as it must") + d2 = confirm_and_reopen() + check("CLOSED", d2["constraints"] == 0, + f"and stored NO constraint: {d2['constraints']} (a dead stored one reads 1)") + leave_sketch() + + # Angle on two circles: p1-p0 on a circle is (0,0)-centre, so the field used to pre-fill with + # the angle between the two centre POSITION VECTORS -- 178.83 deg for two circles on the x + # axis -- and committing it fed SLVS_C_ANGLE two circle prims, which are not directions. + reset_document() + enter_sketch("c") + clickmm(-35, 0); clickmm(-20, 0); key("Escape", 0.7) + key("c", 0.6) + clickmm(35, 0); clickmm(60, 0); key("Escape", 0.7) + key("k", 1.5) + d = describe() + cs = [e for e in d["entities"] if e["type"] == "circle"] + clickmm(*rim(cs[0])); clickmm(*rim(cs[1])) + click(*CON_BTN["angle"]) + time.sleep(1.0) + check("ANGLE", not field_open(), + "Angle on two circles opened no value field") + key("Escape", 0.6) + d2 = confirm_and_reopen() + check("CLOSED", d2["constraints"] == 0, + f"and stored no angle: {d2['constraints']}") + leave_sketch() + + +def rung_undo(): + print("\nE1 undo — the last entity goes, the rest do not move") + enter_sketch("l") + draw_line(-50, -30, 0, -30, 50, 0) + key("l", 0.6); draw_line(0, -30, 0, 20, 50, 90) + key("l", 0.6); draw_line(0, 20, -40, 20, 40, 180) + before = describe()["entities"] + check("LENGTH", len(before) == 3, f"{len(before)} entities drawn") + key("ctrl+z", 1.0) + after = describe()["entities"] + check("VERTEX", len(after) == 2, f"{len(after)} entities after one undo") + same = all(math.dist(a["p0"], b["p0"]) == 0.0 and math.dist(a["p1"], b["p1"]) == 0.0 + for a, b in zip(before, after)) + check("VERTEX", same, "the two survivors are bit-identical, not re-solved") + key("ctrl+z", 1.0) + check("VERTEX", len(describe()["entities"]) == 1, "a second undo drops one more") + leave_sketch() + + +def rung_feature_undo(): + print("\nE2 undo/redo across the commit — a deleted sketch comes back exactly") + reset_document() + enter_sketch("r") + draw_rect(120, 80, -60, -40) + click(*CONFIRM_BTN, pause=1.5) + n0 = len(call("describe_scene")["features"]) + check("VERTEX", n0 == 1, f"{n0} feature committed") + click(*TREE_ROW0, pause=0.4) + key("Delete", 0.8) + check("VERTEX", not call("describe_scene")["features"], "the tree is empty after Delete") + key("ctrl+z", 1.5) + check("VERTEX", len(call("describe_scene")["features"]) == 1, "undo brings the feature back") + d = reopen_sketch() + ls = lengths(d["entities"]) + check("LENGTH", ls == [80.0, 80.0, 120.0, 120.0], f"and it is the same rectangle: {ls}") + lp = d["closed_loops"] + check("AREA", len(lp) == 1 and near(abs(lp[0]["area"]), 9600.0, 1e-6), + f"area {abs(lp[0]['area']):.6f}") + leave_sketch() + key("ctrl+y", 1.5) + check("VERTEX", not call("describe_scene")["features"], "redo removes it again") + reset_document() + + +PROJECT_FILE = "/tmp/gl-roundtrip.3mf" + + +def dialog_up(): + names = sh(f"DISPLAY={DISP} xdotool search --class '.' getwindowname %@") + return any(n and n != "snapmaker-orca" and "file" in n.lower() for n in names.splitlines()) + + +def file_dialog(path, settle=5.0): + """Type an absolute path into the GTK file chooser that is up, and accept it.""" + if not dialog_up(): + die("no file chooser came up") + key("ctrl+a", 0.3) + typ(path, 0.5) + key("Return", settle) + global _win + _win = None # saving renames the window; drop the cached geometry + + +def rung_roundtrip(): + print("\nE3 save and reload — the profile comes back to the last decimal") + reset_document() + enter_sketch("r") + draw_rect(120, 80, -60, -40) + key("r", 0.6); clickmm(-45, -15); clickmm(-5, 15); values(40, 30) + key("c", 0.6); clickmm(30, 0); clickmm(42, 0); values(10) + before = describe() + click(*CONFIRM_BTN, pause=1.5) + sh(f"rm -f {PROJECT_FILE}") + # Save AS, not Save: once a project has a path, Ctrl+S writes to it silently and no chooser + # appears — which is correct behaviour and a trap for a driver that assumes the dialog. + key("ctrl+shift+s", 3.0) + file_dialog(PROJECT_FILE) + size = sh(f"stat -c %s {PROJECT_FILE} 2>/dev/null").strip() + check("CLOSED", size.isdigit() and int(size) > 0, f"project written, {size} bytes") + reset_document() # wipe the tree, then read it back off disk + key("ctrl+o", 2.5) + file_dialog(PROJECT_FILE, settle=8.0) + go_design() # opening a project lands on Prepare + feats = call("describe_scene")["features"] + check("VERTEX", len(feats) == 1, f"the reloaded document has {len(feats)} feature(s)") + after = reopen_sketch() + b = sorted((e["type"], tuple(round(c, 12) for c in (e.get("p0") or e.get("center") or e.get("p"))), + round(e.get("length", e.get("radius", 0.0)), 12)) for e in before["entities"]) + a = sorted((e["type"], tuple(round(c, 12) for c in (e.get("p0") or e.get("center") or e.get("p"))), + round(e.get("length", e.get("radius", 0.0)), 12)) for e in after["entities"]) + check("VERTEX", a == b, f"{len(a)} entities identical to 12 decimals after the round trip") + lp = after["closed_loops"] + check("CLOSED", len(lp) == 3 and after["buildable"], f"{len(lp)} loops, buildable") + outer = max(range(len(lp)), key=lambda i: abs(lp[i]["area"])) + check("VOID", sorted(lp[outer]["holes"]) == sorted(i for i in range(len(lp)) if i != outer), + "the voids are still attributed to the outer loop") + check("AREA", near(abs(lp[outer]["area"]), 9600.0, 1e-9), f"outer area {abs(lp[outer]['area']):.9f}") + leave_sketch() + reset_document() + + +def rung_scale(): + print("\nE4 scale — a gesture on top of a sketch that already holds a thousand entities") + enter_sketch("r") + # The heavy profile is bulk-loaded through the socket ON PURPOSE: what is under test here is + # whether the interactive path still works with a large sketch already on screen, not where + # that sketch came from. A plate with a 20 x 15 grid of square cut-outs — 1204 entities. + # Sized to the region the calibration probes covered, so every part of it can actually be + # clicked: the camera is wherever the last rung left it, and a plate drawn off-screen would + # test nothing but my arithmetic. + x0, x1, y0, y1 = _SAFE + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + hw, hh = (x1 - x0) * 0.44, (y1 - y0) * 0.44 + ents = [{"type": "line", "p0": [cx - hw, cy - hh], "p1": [cx + hw, cy - hh]}, + {"type": "line", "p0": [cx + hw, cy - hh], "p1": [cx + hw, cy + hh]}, + {"type": "line", "p0": [cx + hw, cy + hh], "p1": [cx - hw, cy + hh]}, + {"type": "line", "p0": [cx - hw, cy + hh], "p1": [cx - hw, cy - hh]}] + # 300 square cut-outs in the LEFT half; the right half stays clear so the gesture below has + # somewhere to land that is not within snapping distance of a cut-out corner. + pitch_x, pitch_y = hw * 0.9 / 20.0, hh * 1.9 / 15.0 + side = min(pitch_x, pitch_y) * 0.4 + for i in range(20): + for j in range(15): + x = cx - hw * 0.95 + i * pitch_x + y = cy - hh * 0.95 + j * pitch_y + c = [(x, y), (x + side, y), (x + side, y + side), (x, y + side), (x, y)] + for k in range(4): + ents.append({"type": "line", "p0": list(c[k]), "p1": list(c[k + 1])}) + t0 = time.monotonic(); call("sketch_add", entities=ents); t_add = time.monotonic() - t0 + d0 = describe() + check("SCALE", len(d0["entities"]) == len(ents), f"{len(d0['entities'])} entities loaded " + f"in {t_add*1000:.0f} ms") + lp0 = d0["closed_loops"] + check("CLOSED", len(lp0) == 301, f"{len(lp0)} closed loops") + outer = max(range(len(lp0)), key=lambda i: abs(lp0[i]["area"])) + check("AREA", near(abs(lp0[outer]["area"]), 4.0 * hw * hh, 1e-9), + f"outer plate {abs(lp0[outer]['area']):.9f} vs {4.0*hw*hh:.9f}") + check("VOID", len(lp0[outer]["holes"]) == 300, + f"all {len(lp0[outer]['holes'])} cut-outs attributed to the plate") + check("AREA", all(near(abs(lp0[h]["area"]), side * side, 1e-9) for h in lp0[outer]["holes"]), + f"every cut-out is exactly {side:.6f} squared") + # Now the part that matters: draw ONE more entity by hand, on top of all that. + # + # No Escape here, deliberately: this rung is the regression test for j7gc, where a + # bulk sketch_add made while a creation tool is armed was read as a drawn gesture, opened that + # tool's value field and swallowed the next key and click until one Escape dismissed it. The + # gesture below has to land on the FIRST try. Fixed by resyncing m_autoedit_seen in + # add_entities_scripted; if this rung ever needs an Escape again, the bug is back. + key("l", 0.8) + global PACE + PACE = 6.0 # a thousand entities re-solve between fields + t0 = time.monotonic() + ax, ay = cx + hw * 0.15, cy + hh * 0.55 # clear of the grid, inside the plate + want_len = int(hw * 0.5) # a WHOLE number: see value() on separators + clickmm(ax, ay); clickmm(ax + want_len, ay) + value(want_len) + dl = describe() + say(f"after the typed length: solve_ok={dl['solve_ok']} constraints={dl['constraints']} " + f"dof={dl['dof']} entities={len(dl['entities'])}") + value(0) + t_draw = time.monotonic() - t0 + d = describe() + check("SCALE", len(d["entities"]) == len(ents) + 1, + f"the gesture added exactly one entity ({t_draw:.1f} s including four synthetic events)") + new = d["entities"][-1] + check("LENGTH", near(new["length"], float(want_len), 1e-9), + f"and it took its typed length exactly: {new['length']}") + ang = math.degrees(math.atan2(new["p1"][1] - new["p0"][1], new["p1"][0] - new["p0"][0])) % 360.0 + check("ANGLE", near(ang, 0.0, 1e-9) or near(ang, 360.0, 1e-9), f"and its typed angle: {ang}") + same = all(math.dist(a["p0"], b["p0"]) == 0.0 and math.dist(a["p1"], b["p1"]) == 0.0 + for a, b in zip(d0["entities"], d["entities"])) + check("VERTEX", same, "and moved none of the thousand entities already there") + PACE = 1.0 + leave_sketch() + + +def reopen_sketch(): + w, X, Y, _, _ = win() + sh(f"DISPLAY={DISP} xdotool mousemove {X+TREE_ROW0[0]} {Y+TREE_ROW0[1]} " + f"click --repeat 2 --delay 120 1") + time.sleep(2.0) + return describe() + + +def rung_mirror_arcs(): + """C4b — mirror a shape that HAS ARCS. The rung above mirrors three straight lines, which is + why it sat green through the defect a user hit on 2026-08-23: a slot mirrored about a vertical + line came back with its caps at r=32.2 and a 237 deg sweep, one rail collapsed from 62.9 mm to + 2.1 mm, and the ORIGINAL was wrecked along with the copy. An arc has five degrees of freedom + and the copy was bound to its source by its CENTRE alone, so the solver was free to answer with + a different, internally consistent sketch. Circles were unaffected — a circle has no endpoints + to leave free — so the failure read as "circles fine, slots and rounded rectangles destroyed". + + Graded on the property the user actually stated: THE APPLIED RESULT IS THE PREVIEW. The copy is + the source reflected, the source does not move, and no arc comes back reflex. + """ + print("\nC4b mirror — a slot, so the reflection has arcs in it") + enter_sketch("l") + click(*CONSTRUCTION_CHECKBOX) + key("l", 0.6) + draw_line(0, -40, 0, 40, 80, 90) # the axis, on x = 0 + click(*CONSTRUCTION_CHECKBOX) + key("s", 0.6) # slot: two centreline ends, then the width + clickmm(-70, -10); clickmm(-30, -10); clickmm(-30, 0) + values(40, 10, 0) # typed, so the slot is exact before mirroring + d0 = describe()["entities"] + axis = [e for e in d0 if e.get("construction")][0] + slot = [e for e in d0 if not e.get("construction")] + arcs0 = [e for e in slot if e["type"] == "arc"] + check("ARC", len(arcs0) == 2, f"{len(arcs0)} caps on the slot") + + key("m", 0.6) + clickmm(*mid(axis)) + for e in slot: + if e["type"] == "line": + clickmm(*mid(e)) + else: # a point ON the arc, at its mid sweep + a = (e["start_angle"] + e["end_angle"]) / 2.0 + clickmm(e["center"][0] + e["radius"] * math.cos(a), + e["center"][1] + e["radius"] * math.sin(a)) + clickmm(60, 60) # empty space confirms + d1 = describe()["entities"] + check("VERTEX", len(d1) == len(d0) + len(slot), + f"{len(d1) - len(d0)} copies for {len(slot)} picked entities") + + # the sources, entity by entity, must be exactly where they were + def shape_of(e): + if e["type"] == "arc": + return (round(e["radius"], 9), round(abs(e["end_angle"] - e["start_angle"]), 9)) + return (round(math.dist(e["p0"], e["p1"]), 9),) + moved = [i for i, e in enumerate(d0) if shape_of(e) != shape_of(d1[i])] + check("VERTEX", not moved, f"the mirror left every source alone (moved: {moved})") + + # and every copy is its source reflected — endpoints unordered, because a reflection + # reverses orientation and legitimately stores p0/p1 the other way round + (ax, ay), (bx, by) = axis["p0"], axis["p1"] + dx, dy = bx - ax, by - ay + n = math.hypot(dx, dy); dx, dy = dx / n, dy / n + def refl(q): + vx, vy = q[0] - ax, q[1] - ay + k = 2.0 * (vx * dx + vy * dy) + return (ax + k * dx - vx, ay + k * dy - vy) + copies = d1[len(d0):] + worst = 0.0 + for e in slot: + best = min(max(min(max(math.dist(refl(e["p0"]), c["p0"]), math.dist(refl(e["p1"]), c["p1"])), + max(math.dist(refl(e["p0"]), c["p1"]), math.dist(refl(e["p1"]), c["p0"]))), + abs(shape_of(e)[0] - shape_of(c)[0])) + for c in copies if c["type"] == e["type"]) + worst = max(worst, best) + check("SYMMETRY", worst <= 1e-6, f"every copy is the exact reflection (worst {worst:.9f})") + reflex = [c for c in copies + if c["type"] == "arc" and abs(c["end_angle"] - c["start_angle"]) > math.pi + 1e-9] + check("ARC", not reflex, f"{len(reflex)} copied cap(s) came back reflex — the 'cloud' failure") + + +RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": rung_arc, + "slot": rung_slot, "polygon": rung_polygon, "ellipse": rung_ellipse, + "point": rung_point, "spline": rung_spline, "voids": rung_voids, + "fillet": rung_fillet, "chamfer": rung_chamfer, "offset": rung_offset, + "mirror": rung_mirror, "mirror_arcs": rung_mirror_arcs, "trim": rung_trim, "extend": rung_extend, + "dimension": rung_dimension, "constrain": rung_constrain, + "perpendicular": rung_perpendicular, + "equal_radius": rung_equal_radius, "collinear": rung_collinear, + "distance_xy": rung_distance_xy, "symmetric_axis": rung_symmetric_axis, + "coincident_points": rung_coincident_points, + "type_guards": rung_type_guards, + "parallel": rung_parallel, "live_constrain": rung_live_constrain, + "vertical": rung_vertical, "equal_radius_button": rung_equal_radius_button, + "concentric": rung_concentric, "tangent": rung_tangent, "midpoint": rung_midpoint, + "symmetric_line_axis": rung_symmetric_line_axis, "symmetric_h": rung_symmetric_h, + "radius": rung_radius, "diameter": rung_diameter, "fix": rung_fix, + "distance_y": rung_distance_y, + "undo": rung_undo, + "feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip, + "scale": rung_scale} + + +def main(): + want = sys.argv[1:] or list(RUNGS) + reset_document() + for name in want: + if name not in RUNGS: + die(f"unknown rung {name}; have {' '.join(RUNGS)}") + RUNGS[name]() + leave_sketch() + print(f"\n{_checks - _fail}/{_checks} properties held") + sys.exit(1 if _fail else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/CAD/check-mcp-sketch.py b/scripts/CAD/check-mcp-sketch.py new file mode 100755 index 0000000000..2e700921f5 --- /dev/null +++ b/scripts/CAD/check-mcp-sketch.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Autonomous 2D-sketch loop: drive the Design tab's sketch layer over the MCP socket and +assert the things that decide whether a profile is buildable. + +WHY THIS EXISTS. The 2D layer used to be reachable only by clicking, so every question about it +("is this loop closed?", "did the offset survive?", "is the circle a void or a second body?") +cost a GUI session and a human. The socket verbs make each one a call, and this script is the +loop: build a known profile, ask the app what it thinks it has, compare against arithmetic. + +RUN IT AGAINST A RUNNING APP: + ORCA_CAD_MCP=/tmp/mcp.sock # launch with the socket enabled + python3 scripts/CAD/check-mcp-sketch.py [socket] # default /tmp/mcp.sock + +Exit 0 = every assertion held. Anything else prints the first mismatch and stops. +""" +import json, math, socket, sys + +SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock" +_n = 0 + + +def call(method, **params): + global _n + _n += 1 + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(30) + s.connect(SOCK) + s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method, + "params": params}) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + d = s.recv(65536) + if not d: + break + buf += d + r = json.loads(buf.decode().strip()) + if "error" in r: + raise RuntimeError(f"{method}: {r['error']}") + return r["result"] + + +def near(a, b, tol=1e-6): + return abs(a - b) < tol + + +def check(cond, what): + if not cond: + print(f"FAIL: {what}", file=sys.stderr) + sys.exit(1) + print(f" ok {what}") + + +def areas(rep): + return sorted(round(l["area"], 6) for l in rep["closed_loops"]) + + +print("1. a rectangle is one closed loop of exactly its own area") +try: + call("sketch_cancel") +except Exception: + pass +call("sketch_begin", plane="XY") +call("sketch_add", rect=[0, 0, 80, 50]) +r = call("sketch_describe") +check(r["buildable"], "buildable") +check(areas(r) == [4000.0], f"one loop of 4000 mm^2 (got {areas(r)})") + +print("2. a circle inside it is a VOID, not a second profile") +call("sketch_add", type="circle", center=[40, 25], radius=10) +r = call("sketch_describe") +outer = [l for l in r["closed_loops"] if near(l["area"], 4000.0)][0] +check(len(outer["holes"]) == 1, "the rectangle encloses exactly one void") +hole = r["closed_loops"][outer["holes"][0]] +check(near(hole["area"], math.pi * 100), f"the void is pi*r^2 (got {hole['area']})") + +print("3. offsetting the outer loop inward keeps it CLOSED and exact") +call("sketch_select", entities=[0, 1, 2, 3]) +call("sketch_offset", distance=5) +r = call("sketch_describe") +check(r["open_ends"] == [], "no open ends after the offset") +check(any(near(l["area"], 70 * 40) for l in r["closed_loops"]), + f"the offset loop is 70x40 (got {areas(r)})") + +print("4. a gap is REPORTED with its coordinates, then healed into a constraint") +call("sketch_cancel") +call("sketch_begin", plane="XY") +call("sketch_add", entities=[ + {"type": "line", "p0": [0, 0], "p1": [60, 0]}, + {"type": "line", "p0": [60, 0], "p1": [60, 40]}, + {"type": "line", "p0": [60, 40], "p1": [0, 40]}, + {"type": "line", "p0": [0, 40], "p1": [0.4, 0]}, # 0.4 mm short of closing +]) +r = call("sketch_validate", tolerance=1.0) +check(not r["buildable"], "a 0.4 mm gap makes the profile unbuildable") +check(len(r["open_ends"]) == 2, f"both free ends are named (got {r['open_ends']})") +dof_before = r["dof"] +r = call("sketch_heal", tolerance=1.0) +check(r["welded"] == 1, f"one pair welded (got {r['welded']})") +check(r["buildable"] and r["open_ends"] == [], "healed profile is buildable") +check(areas(r) == [2400.0], f"healed loop is 60x40 (got {areas(r)})") +check(r["dof"] < dof_before, + f"the weld recorded a real constraint: DoF {dof_before} -> {r['dof']}") + +print("5. construction geometry is excluded from the profile") +call("sketch_select", entities=[0]) +call("sketch_construction") +r = call("sketch_describe") +check(not r["buildable"], "turning one side into a guide opens the profile again") +call("sketch_construction") +r = call("sketch_describe") +check(r["buildable"], "turning it back closes it again") + +print("6. re-dimensioning one side keeps the rectangle a single closed loop") +call("sketch_cancel") +call("sketch_begin", plane="XY") +call("sketch_add", rect=[0, 0, 60, 40]) +r = call("sketch_describe") +check(areas(r) == [2400.0], f"one loop of 2400 mm^2 (got {areas(r)})") +call("sketch_select", entities=[0]) # the bottom edge, y=0, from x=0 to x=60 +r = call("sketch_set_value", value=40) +check(r["kind"] == "length", f"dimension kind is length (got {r['kind']})") +check(near(r["before"], 60), f"the edge measured 60 before (got {r['before']})") +r = call("sketch_describe") +check(len(r["closed_loops"]) == 1, "the rectangle is still exactly one closed loop") +check(r["open_ends"] == [], "no open ends after re-dimensioning") +# The point of the whole section: a rectangle must SURVIVE one side being re-dimensioned. We do +# not assert a specific area — only that the topology held — but print it so a topology-preserving +# yet geometry-wrong result is visible in the output. +print(f" note resulting rectangle area = {areas(r)} mm^2 (topology held; geometry is what it is)") + +call("sketch_cancel") +print("\nall sketch assertions held") diff --git a/scripts/CAD/check-sketch-engine-corpus.py b/scripts/CAD/check-sketch-engine-corpus.py new file mode 100644 index 0000000000..20eca5d381 --- /dev/null +++ b/scripts/CAD/check-sketch-engine-corpus.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +"""Rung 9: the ladder, graded against real drawings instead of shapes I chose. + +Rungs 1-8 are hand-built. That is their weakness: I wrote both the geometry and the +assertion, so they prove the engine does what I expected on cases I picked. This rung +takes a SYSTEMATIC sample of the StudyCadCam corpus (every 20th sheet, 1..996 — no +cherry-picking) and grades the engine against each drawing's OWN vector geometry, +extracted from the PDF. Nothing here is transcribed by eye; the drawing is the input. + +The method: pdftocairo renders the sheet to SVG, where the drawn geometry is exactly the +stroked (fill="none") paths and the text is filled glyph paths. Beziers are flattened, so +every entity handed to the engine is a straight line and every comparison below is EXACT +— no faceting tolerance to hide behind. The closed chains are then found twice: once by +this script, in plain Python, and once by the engine. The assertions are that the two +agree, and that the engine's own operations preserve what they promise. + + CLOSED the engine finds the same closed loops this script does + AREA the engine's area for each loop equals the shoelace area, to 1e-6 + VOID the engine attributes each void to the loop that actually contains it + MIRROR a real closed profile, mirrored, is still exactly one closed loop + OFFSET a real closed profile, offset, is still closed + +Usage: check-sketch-engine-corpus.py [--sample N] [--corpus DIR] +""" + +import argparse +import glob +import json +import math +import os +import re +import socket +import subprocess +import sys +import tempfile +import time + +SOCK = os.environ.get("ORCA_CAD_MCP", "/tmp/mcp.sock") +TOL = 1e-6 # exact-comparison tolerance (all inputs are lines) +WELD = 0.05 # endpoint-coincidence tolerance, in PDF units + + +# ── the socket ─────────────────────────────────────────────────────────────── +_id = [0] + + +def try_call(method, **params): + """sketch_cancel throws when nothing is open, which is not an error to us.""" + try: + return call(method, **params) + except RuntimeError: + return None + + +def call(method, **params): + _id[0] += 1 + req = json.dumps({"jsonrpc": "2.0", "id": _id[0], "method": method, + "params": params}) + "\n" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(30) + s.connect(SOCK) + s.sendall(req.encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = s.recv(65536) + if not chunk: + break + buf += chunk + s.close() + r = json.loads(buf.decode()) + if "error" in r: + raise RuntimeError(r["error"]["message"]) + return r["result"] + + +# ── SVG → line segments ────────────────────────────────────────────────────── +NUM = r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?" + + +def bezier(p0, p1, p2, p3, n=16): + """Flatten a cubic to n straight segments — the engine then sees only lines.""" + out = [] + for i in range(n): + t0, t1 = i / n, (i + 1) / n + pts = [] + for t in (t0, t1): + u = 1 - t + x = (u ** 3 * p0[0] + 3 * u * u * t * p1[0] + + 3 * u * t * t * p2[0] + t ** 3 * p3[0]) + y = (u ** 3 * p0[1] + 3 * u * u * t * p1[1] + + 3 * u * t * t * p2[1] + t ** 3 * p3[1]) + pts.append((x, y)) + out.append((pts[0], pts[1])) + return out + + +def path_segments(d): + """Parse one SVG path's `d` into straight segments.""" + toks = re.findall(r"([MLCZmlcz])|(" + NUM + ")", d) + cmds, cur, start, segs, i = [], None, None, [], 0 + flat = [] + for a, b in toks: + flat.append(a if a else float(b)) + while i < len(flat): + t = flat[i] + if isinstance(t, str): + cmd = t + i += 1 + # numbers repeat the previous command, as SVG allows + if cmd in ("M", "m"): + x, y = flat[i], flat[i + 1]; i += 2 + cur = (x, y); start = cur + elif cmd in ("L", "l"): + x, y = flat[i], flat[i + 1]; i += 2 + segs.append((cur, (x, y))); cur = (x, y) + elif cmd in ("C", "c"): + p1 = (flat[i], flat[i + 1]); p2 = (flat[i + 2], flat[i + 3]) + p3 = (flat[i + 4], flat[i + 5]); i += 6 + segs.extend(bezier(cur, p1, p2, p3)); cur = p3 + elif cmd in ("Z", "z"): + if cur and start and dist(cur, start) > TOL: + segs.append((cur, start)) + cur = start + else: + i += 1 + return segs + + +def dist(a, b): + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +def drawing_segments(pdf): + """Every stroked segment on the sheet, in PDF units (y already flipped up).""" + with tempfile.TemporaryDirectory() as td: + svg = os.path.join(td, "p.svg") + subprocess.run(["pdftocairo", "-svg", pdf, svg], + check=True, capture_output=True) + s = open(svg).read() + segs = [] + # Drawn geometry is stroked with no fill; glyphs are filled with no stroke. + for m in re.finditer(r']*)d="([^"]+)"', s): + attrs, d = m.group(1), m.group(2) + if 'fill="none"' not in attrs or "stroke=" not in attrs: + continue + segs.extend(path_segments(d)) + return [((a[0], -a[1]), (b[0], -b[1])) for a, b in segs if dist(a, b) > TOL] + + +# ── closed-chain finding, independent of the engine ────────────────────────── +def find_loops(segs): + """Chain segments into closed loops. Returns a list of point rings.""" + key = lambda p: (round(p[0] / WELD), round(p[1] / WELD)) + adj = {} + for i, (a, b) in enumerate(segs): + adj.setdefault(key(a), []).append((i, a, b)) + adj.setdefault(key(b), []).append((i, b, a)) + used, loops = set(), [] + for i0 in range(len(segs)): + if i0 in used: + continue + a, b = segs[i0] + ring, cur, prev = [a, b], b, i0 + used.add(i0) + while True: + nxt = None + for (j, p, q) in adj.get(key(cur), []): + if j in used: + continue + nxt = (j, q) + break + if nxt is None: + break + used.add(nxt[0]) + cur = nxt[1] + ring.append(cur) + if dist(cur, ring[0]) <= WELD: + # Snap the seam shut. The gap is a flattening artefact of the PDF, up to + # WELD wide, and handing the engine a ring that misses closing by 0.03 would + # be testing my extractor's sloppiness rather than the engine's chaining. + ring[-1] = ring[0] + loops.append(ring) + ring = None + break + # an open chain is simply not a loop; it is dropped + return loops + + +def shoelace(ring): + a = 0.0 + for i in range(len(ring) - 1): + a += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1] + return abs(a) * 0.5 + + +def point_in(pt, ring): + inside = False + for i in range(len(ring) - 1): + A, B = ring[i], ring[i + 1] + if (A[1] > pt[1]) != (B[1] > pt[1]) and \ + pt[0] < (B[0] - A[0]) * (pt[1] - A[1]) / (B[1] - A[1]) + A[0]: + inside = not inside + return inside + + +def interior_point(ring): + """A point strictly inside a simple closed ring (first == last). + + The lowest vertex of a simple polygon is always convex, so stepping from it along the + bisector of its two edges goes inward; the step is a small fraction of the shorter edge so + it stays inside however sharp the corner is. + """ + q = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring + if len(q) < 3: + return ring[0] + k = min(range(len(q)), key=lambda i: (q[i][1], q[i][0])) + v = q[k] + a = (q[(k - 1) % len(q)][0] - v[0], q[(k - 1) % len(q)][1] - v[1]) + b = (q[(k + 1) % len(q)][0] - v[0], q[(k + 1) % len(q)][1] - v[1]) + la, lb = math.hypot(*a), math.hypot(*b) + if la < 1e-12 or lb < 1e-12: + return v + a = (a[0] / la, a[1] / la) + b = (b[0] / lb, b[1] / lb) + bx, by = a[0] + b[0], a[1] + b[1] + n = math.hypot(bx, by) + if n < 1e-12: + return v + step = 1e-3 * min(la, lb) + return (v[0] + bx / n * step, v[1] + by / n * step) + + +# ── one drawing ────────────────────────────────────────────────────────────── +def grade(pdf, name, report): + segs = drawing_segments(pdf) + loops = find_loops(segs) + if len(loops) < 2: + report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)") + return None + + # The biggest loop is the sheet frame; the part outlines live inside it. Take the + # largest loop that is NOT the frame, plus every loop contained in it. + loops.sort(key=shoelace, reverse=True) + outer = loops[1] + voids = [r for r in loops[2:] + if shoelace(r) > 1.0 and point_in(r[0], outer)] + if shoelace(outer) < 100.0: + # Not a defect and not a near miss: on these sheets the part outline is not a closed + # stroked path at all, so the only loops extraction recovers are glyph counters and + # arrowheads. Measured on MPD12/30/31/60: the LARGEST loop on the sheet is 5 to 132 mm2. + # Say the number, so nobody has to re-measure to know which kind of skip this is. + report(name, "SKIP", f"no part outline on this sheet — largest loop is only " + f"{shoelace(outer):.1f} mm2") + return None + + # Feed the drawing's own geometry to the engine, as lines only. + ents = [] + rings = [outer] + voids + for ring in rings: + for i in range(len(ring) - 1): + ents.append({"type": "line", + "p0": [ring[i][0], ring[i][1]], + "p1": [ring[i + 1][0], ring[i + 1][1]]}) + try_call("sketch_cancel") + call("sketch_begin", plane="XY") + call("sketch_add", entities=ents) + r = call("sketch_describe") + + ok = True + got = r["closed_loops"] + ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script " + f"finds {len(rings)}", len(got) == len(rings)) + + # AREA — exact, because every entity is a line + mine = sorted(shoelace(x) for x in rings) + theirs = sorted(abs(l["area"]) for l in got) + # The bar: 1e-3 absolute, or 1e-6 relative for the big loops. Not bit-exactness — the + # auto-constraint pass still snaps segments that are already axis-aligned to within its + # 1e-4 rad tolerance, which moves an area by ~1e-4. It is deliberately tight enough to + # have caught the real defect this rung was written for: with the old 3 degree gesture + # slack applied to scripted input, a flattened circle came back 0.067% small — 0.266 on + # an area of 397, some 300x above this line. + same = len(mine) == len(theirs) and all( + abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs)) + ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same) + + # VOID — a loop belongs to the SMALLEST loop that contains it, not to every loop that + # encloses it. A hole inside a boss inside the part is a void of the boss, and the part + # owns the boss. Comparing against "everything inside the outline" was measuring my own + # sloppiness: on MPD781 that counted 36 voids where 26 of them are nested inside another + # void. So compute the same rule here, independently, and compare the whole attribution. + if got: + rings = [outer] + voids + # Probe from a point STRICTLY INSIDE each ring, never from one of its vertices — the + # same rule the engine now uses (DesignSketchTool::region_loops). A vertex is exactly + # where two loops touch in a real drawing, and a ray cast from a point lying ON the + # polygon under test answers by rounding: that alone accounted for every one of the 6 + # sheets where the two attributions used to disagree. 5hvl. + probes = [interior_point(r) for r in rings] + mine_parent = {} + for i, r in enumerate(rings): + best, best_a = -1, 0.0 + for j, q in enumerate(rings): + if i == j or not point_in(probes[i], q): + continue + a = shoelace(q) + if best < 0 or a < best_a: + best, best_a = j, a + if best >= 0: + mine_parent.setdefault(best, []).append(i) + big = max(range(len(got)), key=lambda i: abs(got[i]["area"])) + # match engine loops to my rings by area, then compare the two attributions by COUNT + mine_counts = sorted(len(v) for v in mine_parent.values()) + got_counts = sorted(len(l["holes"]) for l in got if l["holes"]) + ok &= report(name, "VOID", + f"void attribution matches: engine {got_counts}, containment " + f"{mine_counts}", got_counts == mine_counts) + + # MIRROR / OFFSET — engine operations on a REAL profile, not a tidy one + n_outer = len(outer) - 1 + try_call("sketch_cancel") + call("sketch_begin", plane="XY") + call("sketch_add", entities=ents[:n_outer]) + xs = [p[0] for p in outer] + axis = min(xs) - 10.0 + call("sketch_select", entities=list(range(n_outer))) + try: + call("sketch_mirror", axis_a=[axis, 0], axis_b=[axis, 1]) + m = call("sketch_describe") + ok &= report(name, "MIRROR", "the mirrored copy is closed too", + len(m["closed_loops"]) == 2 and m["open_ends"] == []) + except RuntimeError as e: + ok &= report(name, "MIRROR", f"refused: {e}", False) + + try_call("sketch_cancel") + call("sketch_begin", plane="XY") + call("sketch_add", entities=ents[:n_outer]) + try: + call("sketch_offset", distance=0.5, entities=list(range(n_outer))) + o = call("sketch_describe") + ok &= report(name, "OFFSET", "the offset profile is still closed", + any(l["closed"] for l in o["closed_loops"])) + except RuntimeError as e: + ok &= report(name, "OFFSET", f"refused: {e}", False) + return ok + + +# ── scale ──────────────────────────────────────────────────────────────────── +def grade_scale(pdf, name, report, budget): + """Same exactness, on a profile of several hundred entities, and timed. + + "Interactive" is measurable from here even though nothing is clicked: every MCP verb is + serviced on the UI THREAD, so the time a reply takes is time the window was not repainting. + A round trip that stays inside the budget is a window that stayed responsive. + """ + segs = drawing_segments(pdf) + loops = find_loops(segs) + if len(loops) < 2: + report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)") + return None + loops.sort(key=shoelace, reverse=True) + outer = loops[1] + voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)] + rings = [outer] + voids + ents = [] + for ring in rings: + for i in range(len(ring) - 1): + ents.append({"type": "line", + "p0": [ring[i][0], ring[i][1]], + "p1": [ring[i + 1][0], ring[i + 1][1]]}) + if len(ents) < 300: + report(name, "SKIP", f"only {len(ents)} entities — not a scale case") + return None + + try_call("sketch_cancel") + call("sketch_begin", plane="XY") + t0 = time.monotonic(); call("sketch_add", entities=ents); t_add = time.monotonic() - t0 + t0 = time.monotonic(); r = call("sketch_describe"); t_desc = time.monotonic() - t0 + t0 = time.monotonic(); call("sketch_select", entities=list(range(len(ents)))) + t_sel = time.monotonic() - t0 + t0 = time.monotonic(); call("sketch_validate"); t_val = time.monotonic() - t0 + + ok = True + ok &= report(name, "SCALE", f"{len(ents)} entities in {len(rings)} loops", True) + got = r["closed_loops"] + ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script " + f"finds {len(rings)}", len(got) == len(rings)) + mine = sorted(shoelace(x) for x in rings) + theirs = sorted(abs(l["area"]) for l in got) + same = len(mine) == len(theirs) and all( + abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs)) + ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same) + worst = max(t_add, t_desc, t_sel, t_val) + ok &= report(name, "TIME", f"add {t_add*1000:.0f} ms, describe {t_desc*1000:.0f} ms, " + f"select {t_sel*1000:.0f} ms, validate {t_val*1000:.0f} ms " + f"(budget {budget*1000:.0f} ms)", worst <= budget) + return ok + + +def _pdf_error(path): + """What poppler says about a file it refused, so a refusal can be classified.""" + r = subprocess.run(["pdfinfo", path], capture_output=True, text=True) + return (r.stderr or "") + (r.stdout or "") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--corpus", default=os.path.expanduser("~/studycadcam")) + ap.add_argument("--step", type=int, default=20) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--scale", action="store_true", + help="grade the LARGEST drawings instead: exactness plus a UI-thread budget") + ap.add_argument("--budget", type=float, default=2.0, + help="seconds; the slowest round trip a scale drawing may take") + a = ap.parse_args() + + files = {} + for f in glob.glob(os.path.join(a.corpus, "MPD*.pdf")): + m = re.search(r"MPD(\d+)", os.path.basename(f)) + if m: + files[int(m.group(1))] = f + # step 1 means EVERY sheet. Written as `n % step == 1` it silently selected nothing, because + # n % 1 is always 0 — and the run then printed "RUNG 9 HELD" over zero drawings graded. A + # gate that passes by grading nothing is worse than no gate, so the count is checked below. + picks = [files[n] for n in sorted(files) if a.step <= 1 or n % a.step == 1] + if a.scale: + # The heaviest real profiles in the corpus, biggest first — up to ~1300 entities. + sized = [] + for f in files.values(): + try: + segs = drawing_segments(f) + loops = find_loops(segs) + if len(loops) < 2: + continue + loops.sort(key=shoelace, reverse=True) + outer = loops[1] + voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)] + sized.append((sum(len(r) - 1 for r in [outer] + voids), f)) + except Exception: # noqa: BLE001 + continue + sized.sort(reverse=True) + picks = [f for _, f in sized[:max(1, a.limit or 6)]] + elif a.limit: + picks = picks[:a.limit] + print(f"corpus: {len(files)} sheets; systematic sample every {a.step}th " + f"-> {len(picks)} drawings\n") + + fails, results = [], [] + + def report(name, tag, msg, cond=True): + if tag == "SKIP": + print(f" {name:9s} SKIP {msg}") + return True + mark = "ok " if cond else "FAIL" + print(f" {name:9s} {tag:8s} {mark} {msg}") + if not cond: + fails.append(f"{name} {tag}: {msg}") + return cond + + for f in picks: + name = re.search(r"MPD\d+", os.path.basename(f)).group(0) + try: + r = (grade_scale(f, name, report, a.budget) if a.scale + else grade(f, name, report)) + if r is not None: + results.append((name, r)) + except Exception as e: # noqa: BLE001 + # An unreadable SOURCE file is not a grading failure. MPD133 of this corpus is + # password-protected, and pdftocairo says so on stderr while exiting non-zero; + # reporting that as ERROR made one encrypted sheet look like an engine defect. + if "password" in _pdf_error(f).lower(): + report(name, "SKIP", "the PDF is password-protected — nothing to extract") + else: + report(name, "ERROR", str(e)[:120], False) + + graded = len(results) + passed = sum(1 for _, r in results if r) + if graded == 0: + print("\nNOTHING WAS GRADED — that is a harness failure, not a clean run", file=sys.stderr) + sys.exit(2) + print(f"\ngraded {graded} drawings; {passed} fully clean, {graded - passed} with " + f"at least one failure") + if fails: + print("\nfailures:") + for x in fails: + print(" " + x) + sys.exit(1) + print("\nRUNG 9 HELD — the engine agrees with the drawings, not with me") + + +if __name__ == "__main__": + main() diff --git a/scripts/CAD/check-sketch-engine.py b/scripts/CAD/check-sketch-engine.py new file mode 100755 index 0000000000..56fee44568 --- /dev/null +++ b/scripts/CAD/check-sketch-engine.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""A ladder of 2D sketches of increasing complexity, judged the way a person judges them. + +WHY NOT AREA. Area is derived and no one can confirm it by looking. What a human checks at a +glance, and can be exactly right or exactly wrong about, is: + + VERTEX is the corner where I said it is + LENGTH is the side the length I gave it + ARC is the radius the radius I gave it + TANGENT does the straight run into the curve smoothly, or is there a kink + SYMMETRY is the mirrored half the exact reflection of the half I drew + CLOSED is it one closed loop, or does it just look like one + +Every rung asserts those. Area appears only as a cross-check, never as the verdict. + +Entirely 2D: sketch entities only, no extrude, revolve or any solid feature. + + ORCA_CAD_MCP=/tmp/mcp.sock + python3 scripts/CAD/check-sketch-engine.py [socket] + +Exit 0 = every rung held. Otherwise the first broken property is named and the run stops. +""" +import json, math, socket, sys + +SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock" +EPS = 1e-9 +_n = 0 +_fail = 0 + + +def call(method, **params): + global _n + _n += 1 + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(30) + s.connect(SOCK) + s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method, + "params": params}) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + d = s.recv(65536) + if not d: + break + buf += d + r = json.loads(buf.decode().strip()) + if "error" in r: + raise RuntimeError(f"{method}: {r['error']['message']}") + return r["result"] + + +def check(kind, cond, what): + global _fail + if cond: + print(f" {kind:9s} ok {what}") + else: + print(f" {kind:9s} FAIL {what}", file=sys.stderr) + _fail += 1 + + +def near(a, b, tol=1e-6): + return abs(a - b) <= tol + + +def pt_near(p, q, tol=1e-6): + return math.hypot(p[0] - q[0], p[1] - q[1]) <= tol + + +def fresh(plane="XY"): + try: + call("sketch_cancel") + except Exception: + pass + call("sketch_begin", plane=plane) + + +def ents(): + return call("sketch_describe")["entities"] + + +def rep(): + return call("sketch_describe") + + +def endpoints(e): + """Both ends of an open curve, as tuples. Closed curves have none.""" + if "p0" not in e or "p1" not in e: + return () + return tuple(e["p0"]), tuple(e["p1"]) + + +def tangent(e, at_end): + """Unit tangent of entity e at one of its ends, pointing ALONG the curve (p0->p1).""" + if e["type"] == "line": + dx = e["p1"][0] - e["p0"][0] + dy = e["p1"][1] - e["p0"][1] + else: # arc + a = e["start_angle"] if not at_end else e["end_angle"] + ccw = e["end_angle"] >= e["start_angle"] + # d/dtheta (cos, sin) = (-sin, cos), reversed when the sweep is clockwise + dx, dy = -math.sin(a), math.cos(a) + if not ccw: + dx, dy = -dx, -dy + n = math.hypot(dx, dy) + return (dx / n, dy / n) + + +def tangent_at_point(e, p): + """Unit tangent of e at whichever of its ends is p, oriented leaving that point.""" + p0, p1 = endpoints(e) + if pt_near(p0, p): + t = tangent(e, False) + return t + t = tangent(e, True) + return (-t[0], -t[1]) # leaving p1 means going back along the curve + + +def smooth(e1, e2, p): + """G1 at shared point p: the tangent leaving e1 is opposite the tangent leaving e2.""" + a = tangent_at_point(e1, p) + b = tangent_at_point(e2, p) + return abs(a[0] * (-b[0]) - 0) >= 0 and abs(a[0] * b[1] - a[1] * b[0]) <= 1e-6 + + +def closed_one_loop(r, voids=0): + return (r["buildable"] and r["open_ends"] == [] + and len([l for l in r["closed_loops"] if not any( + i in h["holes"] for h in r["closed_loops"] for i in [])]) >= 1) + + +def outer_loop(r): + """The loop that encloses the others (or the only one).""" + if not r["closed_loops"]: + return None + return max(r["closed_loops"], key=lambda l: abs(l["area"])) + + +# ───────────────────────────────────────────────────────────────────────────── +print("RUNG 1 — rectangle: four corners, four lengths, four right angles") +fresh() +W, H = 80.0, 50.0 +call("sketch_add", rect=[0, 0, W, H]) +r = rep() +es = r["entities"] +corners = {(0, 0), (W, 0), (W, H), (0, H)} +got = set() +for e in es: + got.add(tuple(e["p0"])) + got.add(tuple(e["p1"])) +check("VERTEX", all(any(pt_near(c, g) for g in got) for c in corners), + f"all four corners exactly where asked {sorted(corners)}") +lens = sorted(round(e["length"], 9) for e in es) +check("LENGTH", lens == sorted([W, W, H, H]), f"sides are {W}/{H} twice each (got {lens})") +# right angles: consecutive sides meet at 90 degrees +ang_ok = True +for e in es: + for f in es: + if e is f: + continue + for p in endpoints(e): + if any(pt_near(p, q) for q in endpoints(f)): + a, b = tangent_at_point(e, p), tangent_at_point(f, p) + if abs(a[0] * b[0] + a[1] * b[1]) > 1e-6: + ang_ok = False +check("ANGLE", ang_ok, "every corner is exactly 90 degrees") +check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends") + +print("\nRUNG 2 — a circular void inside it") +call("sketch_add", type="circle", center=[W / 2, H / 2], radius=12) +r = rep() +c = [e for e in r["entities"] if e["type"] == "circle"][0] +check("VERTEX", pt_near(tuple(c["center"]), (W / 2, H / 2)), "void centred exactly where asked") +check("ARC", near(c["radius"], 12), f"void radius exactly 12 (got {c['radius']})") +out = outer_loop(r) +check("CLOSED", len(out["holes"]) == 1, "the rectangle encloses exactly one void") +check("CLOSED", r["buildable"] and r["open_ends"] == [], "still closed with the void present") + +print("\nRUNG 3 — stadium: straights running into caps, tangent at every junction") +fresh() +L, R = 50.0, 15.0 +call("sketch_add", entities=[ + {"type": "line", "p0": [-L, -R], "p1": [L, -R]}, + {"type": "arc", "center": [L, 0], "radius": R, + "start_angle": -math.pi / 2, "end_angle": math.pi / 2}, + {"type": "line", "p0": [L, R], "p1": [-L, R]}, + {"type": "arc", "center": [-L, 0], "radius": R, + "start_angle": math.pi / 2, "end_angle": 3 * math.pi / 2}, +]) +r = rep() +es = r["entities"] +check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends") +arcs = [e for e in es if e["type"] == "arc"] +check("ARC", all(near(a["radius"], R) for a in arcs), f"both caps exactly R={R}") +check("LENGTH", all(near(e["length"], 2 * L) for e in es if e["type"] == "line"), + f"both straights exactly {2*L}") +# tangency at all four line/arc junctions +tang = True +for a in arcs: + for p in endpoints(a): + mates = [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))] + for m in mates: + if not smooth(a, m, p): + tang = False +check("TANGENT", tang, "straight meets cap smoothly at all four junctions (no kink)") + +print("\nRUNG 4 — mirror: the reflected half is the exact reflection") +fresh() +half = [ + {"type": "line", "p0": [0, -R], "p1": [L, -R]}, + {"type": "arc", "center": [L, 0], "radius": R, + "start_angle": -math.pi / 2, "end_angle": math.pi / 2}, + {"type": "line", "p0": [L, R], "p1": [0, R]}, +] +call("sketch_add", entities=half) +r = rep() +check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2, + f"half profile is correctly OPEN, both ends named {r['open_ends']}") +call("sketch_select", entities=[0, 1, 2]) +call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1]) +r = rep() +es = r["entities"] +check("CLOSED", r["buildable"] and r["open_ends"] == [], "mirroring closed the loop") +# every source vertex must have its exact reflection present +src = [] +for e in es[:3]: + src += [tuple(e["p0"]), tuple(e["p1"])] +allv = [] +for e in es: + allv += [tuple(e["p0"]), tuple(e["p1"])] +sym = all(any(pt_near((-x, y), v) for v in allv) for (x, y) in src) +check("SYMMETRY", sym, "every vertex has its exact mirror twin across x=0") +mirrored_arc = [e for e in es[3:] if e["type"] == "arc"] +check("ARC", mirrored_arc and near(mirrored_arc[0]["radius"], R) + and pt_near(tuple(mirrored_arc[0]["center"]), (-L, 0)), + f"mirrored cap keeps R={R} and lands at (-{L}, 0)") + +print("\nRUNG 5 — offset: every curve moves by exactly d, and it stays closed") +d = 4.0 +call("sketch_select", entities=list(range(len(es)))) +call("sketch_offset", distance=-d) # -d = outward for this CCW loop +r = rep() +new = r["entities"][len(es):] +check("CLOSED", r["buildable"] and r["open_ends"] == [], "offset result is closed") +off_arcs = [e for e in new if e["type"] == "arc"] +check("ARC", all(near(a["radius"], R + d) for a in off_arcs), + f"each cap radius grew by exactly {d} -> {R+d}") +off_lines = [e for e in new if e["type"] == "line"] +check("VERTEX", all(near(abs(e["p0"][1]), R + d) for e in off_lines), + f"each straight moved out to |y| = {R+d} exactly") + +print("\nRUNG 6 — a gap is found by coordinate, then closed by a real constraint") +fresh() +call("sketch_add", entities=[ + {"type": "line", "p0": [0, 0], "p1": [60, 0]}, + {"type": "line", "p0": [60, 0], "p1": [60, 40]}, + {"type": "line", "p0": [60, 40], "p1": [0, 40]}, + {"type": "line", "p0": [0, 40], "p1": [0.35, 0]}, # 0.35 mm short +]) +r = call("sketch_validate", tolerance=1.0) +check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2, + f"the gap is reported, both free ends named {r['open_ends']}") +dof0 = r["dof"] +r = call("sketch_heal", tolerance=1.0) +check("CLOSED", r["buildable"] and r["open_ends"] == [], "healed into a closed loop") +check("VERTEX", r["welded"] == 1, "exactly one pair of vertices welded") +check("ANGLE", r["dof"] < dof0, + f"the weld is a real constraint, not a nudge: DoF {dof0} -> {r['dof']}") +es = ents() +check("VERTEX", pt_near(tuple(es[3]["p1"]), tuple(es[0]["p0"])), + "the two ends are now the same point") + +print("\nRUNG 7 — the composite: mirrored, tangent, two voids, all at once") +fresh() +call("sketch_add", entities=half) +call("sketch_select", entities=[0, 1, 2]) +call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1]) +call("sketch_add", type="circle", center=[-25, 0], radius=6) +call("sketch_add", type="circle", center=[25, 0], radius=6) +r = rep() +es = r["entities"] +out = outer_loop(r) +check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed outer loop, no free ends") +check("CLOSED", len(out["holes"]) == 2, "it encloses exactly two voids") +circles = [e for e in es if e["type"] == "circle"] +check("ARC", all(near(c["radius"], 6) for c in circles), "both voids exactly R=6") +check("SYMMETRY", pt_near(tuple(circles[0]["center"]), (-25, 0)) + and pt_near(tuple(circles[1]["center"]), (25, 0)), + "the voids sit symmetrically at x = -25 and +25") +tang = True +for a in [e for e in es if e["type"] == "arc"]: + for p in endpoints(a): + for m in [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]: + if not smooth(a, m, p): + tang = False +check("TANGENT", tang, "every straight-to-cap junction is still smooth") +exact = 2 * L * 2 * R + math.pi * R * R +check("LENGTH", near(out["area"], exact, 1e-6), + f"cross-check: enclosed area {out['area']:.4f} = 2L*2R + pi*R^2 = {exact:.4f}") + +print("\nRUNG 8 — a real drawing: StudyCadCam MPD5, the pin's revolve half-profile") +# Ø27 x 95 pin: C1 chamfer on the left end, cylinder to a corner at x=85, an R5 fillet into a +# cone at 23 degrees to the axis, right face at x=95. Interpretation stated so the rung is +# reproducible: 85 is to the CORNER, 23 deg is to the AXIS, C1 is 1 x 45. +fresh() +RAD, LEN, TX, ANG, RF, CH = 13.5, 95.0, 85.0, math.radians(23), 5.0, 1.0 +t = RF * math.tan(ANG / 2) +ax, ay = TX - t, RAD # fillet tangent point on the cylinder +cx, cy = ax, RAD - RF # fillet centre +bx, by = TX + t * math.cos(-ANG), RAD + t * math.sin(-ANG) # tangent point on the cone +ey = by - (LEN - bx) * math.tan(ANG) # where the cone meets the right face +call("sketch_add", entities=[ + {"type": "line", "p0": [0, 0], "p1": [0, RAD - CH]}, # left face + {"type": "line", "p0": [0, RAD - CH], "p1": [CH, RAD]}, # C1 chamfer + {"type": "line", "p0": [CH, RAD], "p1": [ax, ay]}, # cylinder top + {"type": "arc", "center": [cx, cy], "radius": RF, + "start_angle": math.pi / 2, "end_angle": math.pi / 2 - ANG}, # R5 fillet + {"type": "line", "p0": [bx, by], "p1": [LEN, ey]}, # 23 deg cone + {"type": "line", "p0": [LEN, ey], "p1": [LEN, 0]}, # right face + {"type": "line", "p0": [LEN, 0], "p1": [0, 0]}, # axis +]) +r = rep() +es = r["entities"] +check("CLOSED", r["buildable"] and r["open_ends"] == [], "the half-profile is one closed loop") +xs = [v[0] for e in es if "p0" in e for v in (e["p0"], e["p1"])] +ys = [v[1] for e in es if "p0" in e for v in (e["p0"], e["p1"])] +check("LENGTH", near(max(xs) - min(xs), LEN), f"overall length exactly {LEN} (the 95 dimension)") +check("VERTEX", near(max(ys), RAD), f"outer radius exactly {RAD} (the dia 27)") +fil = [e for e in es if e["type"] == "arc"][0] +check("ARC", near(fil["radius"], RF), f"the corner fillet is exactly R{RF:g}") +cone = [e for e in es if e["type"] == "line" + and not near(e["p0"][0], e["p1"][0]) and not near(e["p0"][1], e["p1"][1]) + and e["length"] > 5] +if cone: + c0 = cone[0] + a = abs(math.degrees(math.atan2(c0["p1"][1] - c0["p0"][1], c0["p1"][0] - c0["p0"][0]))) + check("ANGLE", near(a, 23, 1e-6), f"the cone is exactly 23 degrees to the axis (got {a:.6f})") +cham = [e for e in es if e["type"] == "line" and near(e["length"], CH * math.sqrt(2), 1e-9)] +check("ANGLE", bool(cham), "the C1 chamfer is exactly 1 x 45 (length 1*sqrt2)") +tang = True +for p in endpoints(fil): + for m in [e for e in es if e is not fil and any(pt_near(p, q) for q in endpoints(e))]: + if not smooth(fil, m, p): + tang = False +check("TANGENT", tang, "the fillet is tangent to BOTH the cylinder and the cone (no kink)") + +call("sketch_cancel") + +try: + call("sketch_cancel") +except Exception: + pass # a rung may have closed it already +print(f"\n{'ALL RUNGS HELD' if _fail == 0 else str(_fail) + ' CHECK(S) FAILED'}") +sys.exit(1 if _fail else 0) diff --git a/scripts/CAD/focus-loop.sh b/scripts/CAD/focus-loop.sh new file mode 100755 index 0000000000..3c75f64a49 --- /dev/null +++ b/scripts/CAD/focus-loop.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# One turn of the keyboard-focus convergence loop, start to verdict, with no human in it. +# +# scripts/CAD/focus-loop.sh # full turn: sync -> build -> restart -> assert +# SKIP_BUILD=1 scripts/CAD/focus-loop.sh # re-assert against the binary already on the host +# +# Exit 0 only when every gate holds. Any other exit is a failing gate and names which. +# +# WHY THIS EXISTS. The focus defects in the Design tab were chased for days by hand: build, launch +# the GUI, drive it with xdotool, read a screenshot, guess, repeat. That needs a person at every +# step and it is where the days went. This does not: behemoth carries an agent-owned Xvfb :10 with +# openbox, the app, xdotool and an MCP socket that reports sketch state as JSON, so a turn is +# sync -> build -> restart -> assert, and the ASSERTION is the verdict, not my reading of a picture. +# +# WHY BEHEMOTH AND NOT THE orcacad-gui RIG CONTAINER. The rig was the obvious host and it does not +# work for this: its image pins a dependency set 216 non-CAD source files behind cad-mainline +# (assimp among them), so today's CAD sources call GUI_App::is_auto_close_sketch_loops and +# MainFrame::ensure_design_panel, which that tree has never heard of. Syncing all of src/ to fix +# that needs a deps rebuild measured in hours. behemoth already builds this exact tree, already +# runs a WM on :10, and is the machine the user actually runs the product on — so the loop asserts +# against the shipping artefact rather than a stale twin. Reviving the rig means rebuilding its +# deps image first; until then it cannot adjudicate anything about this code. +# +# SC2029: every ssh command below quotes locally-expanded config (HOST, SRC, DISP) on purpose +# -- the remote tree is not this checkout and has no such config of its own. +# shellcheck disable=SC2029 +set -uo pipefail + +HOST="${HOST:-tommaso@100.103.234.2}" +DISP="${DISP:-:10}" +SRC="${SRC:-\$HOME/projects/orca/orcacad-native/src}" +TRACE="${TRACE:-/tmp/ux-focus-loop.log}" +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BIN="build/src/Release/orca-slicer" + +say() { printf '\n=== %s\n' "$*"; } +die() { printf 'GATE FAILED: %s\n' "$*" >&2; exit 1; } + +ssh -o ConnectTimeout=10 "$HOST" true || die "cannot reach $HOST" + +# ---------------------------------------------------------------- S1 sync +# Only the CAD paths and the ladders. behemoth's tree is a full cad-mainline checkout kept in step +# by its own realign; pushing unrelated files from here would make the build host disagree with +# git for reasons no later session could reconstruct. +say "S1 sync" +rsync -q "$REPO"/src/slic3r/GUI/CAD/*.{cpp,hpp} "$HOST:$SRC/src/slic3r/GUI/CAD/" || die "sync GUI/CAD" +rsync -q "$REPO"/src/libslic3r/CAD/*.{cpp,hpp} "$HOST:$SRC/src/libslic3r/CAD/" || die "sync libslic3r/CAD" +rsync -q "$REPO"/scripts/CAD/check-gui-click-edit.py "$REPO"/scripts/CAD/check-gui-sketching.py \ + "$HOST:/tmp/" || die "sync ladders" +echo " sources + ladders in place" + +# ---------------------------------------------------------------- S2 build +# flock: two concurrent Orca builds once OOM'd this machine for 2h28m. Every build script on the +# fleet takes this same lock. +# +# Grade the BINARY'S TIMESTAMP, never the build command's exit code. This is a Ninja Multi-Config +# tree whose default rules are Debug while the artefact under test is Release, so a wrong-config +# invocation returns success in seconds having touched nothing — it cost a wasted cycle here +# before anyone thought to look at the file. +if [ -z "${SKIP_BUILD:-}" ]; then + say "S2 build" + before=$(ssh "$HOST" "stat -c %Y $SRC/$BIN 2>/dev/null || echo 0") + ssh "$HOST" "flock /tmp/orca-rig-build.lock \$HOME/projects/orca/orcacad-native/rebuild.sh > /tmp/focus-build.log 2>&1" + rc=$? + after=$(ssh "$HOST" "stat -c %Y $SRC/$BIN 2>/dev/null || echo 0") + if [ "$rc" != 0 ] || [ "$after" = "$before" ]; then + ssh "$HOST" "grep -m5 -B2 'error:' /tmp/focus-build.log; tail -5 /tmp/focus-build.log" + die "S2 build (exit $rc, binary $( [ "$after" = "$before" ] && echo unchanged || echo rebuilt ))" + fi + echo " built" +fi + +# ---------------------------------------------------------------- S3 F2P +# The ladder launches and tears down the app itself, in its own datadir, so nothing here has to +# manage a process. It types WITHOUT clicking the field first, which is the whole contract. +say "S3 fail-to-pass: type without clicking the field" +ssh "$HOST" "cd $SRC && DISPLAY=$DISP python3 /tmp/check-gui-click-edit.py \ + --display $DISP --bin $BIN --trace $TRACE" +f2p=$? + +# ---------------------------------------------------------------- S4 P2P +say "S4 pass-to-pass: the existing gesture ladder" +ssh "$HOST" "cd $SRC && DISPLAY=$DISP python3 /tmp/check-gui-sketching.py 2>&1 | tail -3" +p2p=$? + +say "VERDICT" +[ "$f2p" = 0 ] || die "F2P: a tool did not take the typed value (exit $f2p)" +[ "$p2p" = 0 ] || die "P2P: the gesture ladder regressed (exit $p2p)" +echo "ALL GATES HELD" diff --git a/scripts/CAD/run-all-checks.sh b/scripts/CAD/run-all-checks.sh new file mode 100755 index 0000000000..1c0388ac89 --- /dev/null +++ b/scripts/CAD/run-all-checks.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Every ladder, in one command, as the gate before a push that touched the Design tab. +# +# WHY A SCRIPT AND NOT CI. Three of the four rungs need a running application with an OpenGL +# canvas and synthetic input; GitHub's runners have neither. So the gate is local and explicit: +# run this, read the last line, and do not push a red one. The kernel suite is the only part CI +# can carry, and it already does. +# +# scripts/CAD/run-all-checks.sh # kernel + engine + corpus (every 20th) + gestures + offer +# FULL=1 scripts/CAD/run-all-checks.sh # corpus over ALL 997 sheets (~25 min) +# SKIP_GUI=1 scripts/CAD/run-all-checks.sh # kernel only, for a machine with no rig +# +# The rig container is expected to be up with the app running and ORCA_CAD_MCP set; bring it up +# with scripts/CAD/start-headless-gui.sh inside it. The corpus lives at /corpus in that container. +set -uo pipefail +# ../.. -- this script lives in scripts/CAD/, so one level up is scripts/, not the repo +# root. It was scripts/ladder-all.sh when it was written; the move fixed the three sibling +# scripts and missed this one, which left every rung looking for its own path under +# scripts/scripts/ and reporting instant failures that were all the same typo. +cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1 + +# orcacad-gui, NOT snapmaker-gui: that is the other fork's rig, and defaulting to it makes +# this gate verify the wrong fork's binary while reporting green. run-kernel-tests.sh +# carries the same warning about the build volume, where the defect was found first. +C="${C:-orcacad-gui}" +CORPUS="${CORPUS:-/corpus}" +STEP="${STEP:-20}" +[ -n "${FULL:-}" ] && STEP=1 +fail=0 + +step() { + local name="$1"; shift + echo + echo "=== $name ===" + if "$@"; then echo "--- $name OK"; else echo "--- $name FAILED"; fail=1; fi +} + +# This fork's rig runs Xvfb on :11, the other fork's on :10, and the check scripts default +# to ":10" when DISPLAY is unset -- which docker exec leaves unset. The rungs that drive the +# GUI therefore looked for a window on a display that does not exist here and reported +# "FATAL no app window on :10", which reads like a dead app rather than a wrong display. +RIG_DISPLAY="${RIG_DISPLAY:-:11}" + +# SC2329: every call goes through step(), which invokes it via "$@", so shellcheck +# cannot see the callers below. +# shellcheck disable=SC2329 +run_in_rig() { # copy the script in fresh, then run it there + docker cp "$1" "$C:/tmp/$(basename "$1")" >/dev/null || return 1 + shift + docker exec -e DISPLAY="$RIG_DISPLAY" "$C" python3 "$@" +} + +# FIRST, and it needs no rig: the offer table the menu is compiled from must be what the atlas +# says. The header calls itself GENERATED and had been hand-edited anyway — which cost four rows +# that existed only in the header, one row wired to the wrong action, and a count of 91 for a +# 92-row array, so the last verb was unreachable (z8rs, ziam). +# docs/CAD/, not docs/: SoftFever moved the design docs into the CAD subfolder +# (bbd1989e1e) and this line kept the old path, so the rung failed on a missing file +# rather than on anything about the table. The other fork still has docs/ux/. +step "offer table matches the atlas" python3 docs/CAD/ux/mockups/gen_offer_table.py --check + +step "kernel suite" scripts/CAD/run-kernel-tests.sh --vol "${KVOL:-orcacad_kerneltest}" + +if [ -z "${SKIP_GUI:-}" ]; then + step "engine ladder (rungs 1-8, scripted geometry)" \ + run_in_rig scripts/CAD/check-sketch-engine.py /tmp/check-sketch-engine.py + step "corpus rung (real drawings, every ${STEP}th)" \ + run_in_rig scripts/CAD/check-sketch-engine-corpus.py /tmp/check-sketch-engine-corpus.py --corpus "$CORPUS" --step "$STEP" + step "corpus scale rung (the heaviest sheets)" \ + run_in_rig scripts/CAD/check-sketch-engine-corpus.py /tmp/check-sketch-engine-corpus.py --corpus "$CORPUS" --scale + step "gesture ladder (mouse and keyboard)" \ + run_in_rig scripts/CAD/check-gui-sketching.py /tmp/check-gui-sketching.py + # The offer ladder needs TWO extra things the others do not: the app must have been launched + # with ORCA_CAD_KEYTRACE=1 (its [OFFER] lines are the whole instrument), and it reads the + # generated offer table to predict what each selection should show — which is not in the + # container's own baked source tree, so it is copied in beside the script — /tmp, where + # run_in_rig puts the script, is one of the paths the ladder looks in. + docker cp src/slic3r/GUI/CAD/DesignOffer.hpp "$C:/tmp/DesignOffer.hpp" >/dev/null + step "offer ladder (right-click, the menu, the verbs behind it)" \ + run_in_rig scripts/CAD/check-gui-context-menu.py /tmp/check-gui-context-menu.py +fi + +echo +if [ "$fail" -eq 0 ]; then echo "ALL LADDERS HELD"; else echo "AT LEAST ONE LADDER FAILED"; fi +exit "$fail" diff --git a/scripts/CAD/run-kernel-tests.sh b/scripts/CAD/run-kernel-tests.sh new file mode 100755 index 0000000000..ef4356cb65 --- /dev/null +++ b/scripts/CAD/run-kernel-tests.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Headless CAD-kernel test loop. THE verification contract for delegated work: +# exit 0 means the kernel builds and the selected Catch2 tags pass. Nothing else counts. +# +# Builds only the `libslic3r_tests` target (not the GUI app), so a round trip is +# minutes, not tens of minutes. Needs no display: every [CadDocument] case builds a +# CadDocument, recompute()s it and asserts on geometry. +# +# Usage: +# scripts/CAD/run-kernel-tests.sh # [CadDocument] tags, default volume +# scripts/CAD/run-kernel-tests.sh --tags '[CadDocument],[Sketch]' +# scripts/CAD/run-kernel-tests.sh --vol wt_mirror # private build cache (parallel workers) +# scripts/CAD/run-kernel-tests.sh --host tommaso@100.103.234.2 # build on a remote host +# +# Parallel workers MUST pass a distinct --vol: two builds sharing one cache corrupt +# each other. A new volume pays one full build; runs after that are incremental. +# +# --host exists because the deps image lives wherever it was first built. It rsyncs this +# working tree to a per-volume staging dir on that host and re-runs this same script +# there, so the verification contract is identical either way. Drop --host once the image +# is present locally. +# Rig build traps already paid for once each (stale project, NLopt cache, pybind11, OCCT_LIBS, SLIC3R_CAD gate): docs/rig_build_traps.md +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# orcacad-deps, NOT snapmaker-deps: this fork is mainline-based and needs Eigen 5.0.1, +# CGAL 5.6.3, wx 3.3.2 and Python 3.12 Development.Embed, none of which snapmaker-deps has. +# With the wrong image CMake dies at configure, which is exactly why this fork went +# M1-M8 without ever compiling (see commit 1633005bba). +IMAGE="${IMAGE:-orcacad-deps}" +# Must NOT default to snapmaker_buildcache: that is the other fork's volume, and pointing +# this fork at it makes the two silently trade build artefacts. build-gui-incremental.sh had the +# identical defect and was fixed to orcacad_buildcache; this script was missed. +VOL="${BUILD_VOL:-orcacad_kerneltest}" +# No exclusions. Both cases that used to be quarantined now run: the solver SIGABRT on +# circle-line tangency is fixed (tkz), and the internal-thread case turned out to have +# correct geometry and a wrong reference in the test (kzy). A green run here now means +# the whole CAD suite passed, not "everything except the two we gave up on". +# +# ...and that claim was still not true, because the default tag was [CadDocument] alone while +# four CAD test files carry their own tags and NOTHING ELSE selected them. test_sketchinference +# ([inference], 15), test_sketchedit ([SketchEdit], 23), test_sketchconstraints +# ([SketchConstraints], 8) and test_sketchimport ([SketchImport], 4) never ran here, nor did the +# older [slvs]-only cases in test_slvs_constraints. Measured 2026-08-31: the default reported +# 2624 assertions / 206 cases, the full set 7648 / 264 -- so the gate was speaking for about a +# third of the assertions, and a whole file could be added, tagged by its own convention, and +# stay dark while the suite printed green. All 58 were passing; the coverage was simply never +# exercised. Adding a tag here is now part of adding a test file. +TAGS="${TAGS:-[CadDocument],[inference],[SketchEdit],[SketchConstraints],[SketchImport],[slvs]}" +HOST="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --vol) VOL="$2"; shift 2 ;; + --tags) TAGS="$2"; shift 2 ;; + --image) IMAGE="$2"; shift 2 ;; + --host) HOST="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +echo "REPO=$REPO IMAGE=$IMAGE VOL=$VOL TAGS=$TAGS HOST=${HOST:-local}" + +if [[ -n "$HOST" ]]; then + # Stage per-volume so parallel workers never share a remote tree. + REMOTE="kt-$VOL" # relative: ssh and rsync both start in the remote home dir + # SC2029: $REMOTE expanding on the CLIENT is the point -- it is derived from $VOL here, + # and the remote has no such variable. The rsync destination below expands it the same way. + # shellcheck disable=SC2029 + ssh "$HOST" "mkdir -p $REMOTE" + # Only the inputs the build reads. --delete keeps a stale file from a prior worker from + # silently compiling in. + rsync -a --delete \ + "$REPO/src" "$REPO/tests" "$REPO/resources" "$REPO/cmake" "$REPO/scripts" \ + "$REPO/CMakeLists.txt" \ + "$HOST:$REMOTE/" + exec ssh "$HOST" "cd $REMOTE && scripts/CAD/run-kernel-tests.sh --vol '$VOL' --tags '$TAGS' --image '$IMAGE'" +fi + +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "FATAL: image '$IMAGE' not present locally. Either transfer it, or pass" >&2 + echo " --host to build where the image already exists." >&2 + exit 3 +fi + +# A private volume is always built CLEAN on first use. Cloning a warm cache from another +# tree looks tempting but is a correctness trap: rsync preserves source mtimes, so ninja +# compares foreign object timestamps against them, decides everything is up to date, and +# relinks stale objects. That produced a binary with NO [CadDocument] tests at all while +# reporting success -- a green run that tested nothing. Pay the one-off full build instead; +# incremental rebuilds within a volume are correct because the mtimes then share a lineage. +if ! docker volume inspect "$VOL" >/dev/null 2>&1; then + echo "=== $VOL: new volume, clean configure + full build (one-off, slow) ===" + docker volume create "$VOL" >/dev/null +fi + +# tests/ is mounted too -- unlike build-gui-incremental.sh, this script exists precisely to +# compile tests being edited. CMakeLists.txt and cmake/ carry the SLIC3R_CAD gate; taking +# them from the baked image instead leaves the gate off and the CAD symbols vanish. + +# ---- OOM guard (2026-08-21) ------------------------------------------------------------- +# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus +# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable, +# lightdm destroyed. Neither build produced a single object. scripts/CAD/build-gui.sh grew the +# bounds first; every script that starts a compile needs the same three, or the guard is only +# as strong as the script you happened not to use. +# flock — the lock path is SHARED with build-gui.sh and the other fork on purpose, so +# concurrent builds serialise instead of summing. +# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average. +# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking +# the host down. --memory-swap equal to --memory forbids swap, which is what made +# ssh hang. +JOBS="${JOBS:-12}" +MEM="${MEM:-40g}" +LOCK=/tmp/orca-rig-build.lock + +exec 9>"$LOCK" +if ! flock -n 9; then + echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)" + flock 9 +fi + +docker run --rm \ + --memory="$MEM" --memory-swap="$MEM" \ + -v "$REPO/src":/OrcaSlicer/src \ + -v "$REPO/tests":/OrcaSlicer/tests \ + -v "$REPO/resources":/OrcaSlicer/resources \ + -v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \ + -v "$REPO/cmake":/OrcaSlicer/cmake \ + -v "$REPO/deps_src":/OrcaSlicer/deps_src \ + -v "$VOL":/OrcaSlicer/build \ + "$IMAGE" \ + bash -lc "set -e + cd /OrcaSlicer + DESTDIR=/OrcaSlicer/deps/build/destdir/usr/local + export PATH=\$DESTDIR/bin:\$PATH # wx-config, and anything else the deps prefix ships + # Configure unconditionally. Guarding on 'CMakeCache.txt exists' is wrong: a FAILED + # configure still writes that file, so the guard then skips reconfiguring forever and + # every later run reuses a poisoned cache while ignoring corrected flags. A no-op + # reconfigure costs seconds; that bug costs an afternoon. + # SLIC3R_GTK=3 and BUILD_TESTS=ON are not optional extras: src/CMakeLists.txt turns + # SLIC3R_GTK into 'wx-config --toolkit=gtk', so omitting it asks for toolkit 'gtk' + # and no wx build matches -> 'Could NOT find wxWidgets'. BUILD_TESTS=ON is what makes + # the libslic3r_tests target exist at all. build_linux.sh sets both (lines 218, 226). + cmake -S . -B build -G 'Ninja Multi-Config' \ + -DCMAKE_PREFIX_PATH=\$DESTDIR \ + -DwxWidgets_CONFIG_EXECUTABLE=\$DESTDIR/bin/wx-config \ + -DSLIC3R_GTK=3 -DBUILD_TESTS=ON -DSLIC3R_GUI=OFF \ + -DSLIC3R_CAD=ON -DSLIC3R_STATIC=1 -DORCA_TOOLS=ON -DCMAKE_BUILD_TYPE=Release + # SLIC3R_GUI=OFF: this script builds ONLY libslic3r_tests, which links libslic3r and no GUI + # code, but cmake still PROCESSES the if SLIC3R_GUI block of src/CMakeLists.txt (lines 16-98) + # and every find_package inside it. That made the kernel suite depend on the GUI dependency + # set for no benefit, and it broke the moment upstream added wxInspector as a REQUIRED + # find_package at line 92: the orcacad-deps image predates it, so configure died pointing at + # src/CMakeLists.txt:92 while nothing about the kernel had changed. Turning the block off is + # not a workaround for that one dependency; it is the kernel suite finally declaring what it + # actually needs, so the next GUI-side dependency upstream adds cannot break it either. + cmake --build build --config Release --target libslic3r_tests -- -j$JOBS + ./build/tests/libslic3r/Release/libslic3r_tests '$TAGS' --order decl" diff --git a/scripts/CAD/start-headless-gui.sh b/scripts/CAD/start-headless-gui.sh new file mode 100755 index 0000000000..d9944da8cf --- /dev/null +++ b/scripts/CAD/start-headless-gui.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Bring the headless GUI up on a VNC-served X display, ready to drive or to attach Remmina to. +# +# Runs INSIDE the long-lived GUI container (see the header of scripts/CAD/build-gui-incremental.sh for how +# that container is created). Idempotent: safe to re-run to recover a session whose app died. +# +# docker exec /OrcaSlicer/scripts/CAD/start-headless-gui.sh # launch + settle +# docker exec /OrcaSlicer/scripts/CAD/start-headless-gui.sh --status # report, change nothing +# +# WHY THIS EXISTS. Dismissing the first-run dialogs by computing the titlebar close box from +# `xdotool getwindowgeometry --shell` and clicking it went wrong whenever the dialog had already +# closed: the eval left the geometry variables stale or empty, the click landed at a garbage +# coordinate, and it repeatedly hit the Sketch button in the toolbar underneath, so the app came up +# in sketch mode with a stray Sketch feature. Three of those in one session. +# +# The titlebar click is nevertheless the RIGHT mechanism and is kept. `xdotool windowclose` looks +# cleaner but kills the app: it destroys the GdkWindow out from under the dialog and the process +# dies with "GdkWindow unexpectedly destroyed", three GLib-GObject criticals and a segfault +# (measured 2026-07-30). Escape does not close the Setup Wizard either. So the fix is not a +# different mechanism, it is refusing to click on geometry we have not validated. +set -euo pipefail + +DISP="${DISP:-:11}" +GEOM="${GEOM:-1920x1080}" +BIN="${BIN:-/OrcaSlicer/build/src/Release/orca-slicer}" +# Packaging cannot bundle python (the deps python layer carries a doubled-DESTDIR RUNPATH), so the +# build-tree binary needs the deps libpython on the path. Packaging-only issue; the app runs fine. +LIBPY="/OrcaSlicer/deps/build/destdir/usr/local/libpython/lib" +LIBPY2="/OrcaSlicer/build/src/Release/python/lib" +LOG="${LOG:-/tmp/gui-session.log}" + +export DISPLAY="$DISP" HOME=/root +export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe +# The ladders read the document back through the MCP socket, and the offer ladder's only +# instrument is the [OFFER] keytrace. Neither is on by default, and a session launched without +# them comes up looking perfectly healthy: the window is there, status says app up, and every +# ladder then dies on "Connection refused" — which reads as a dead app rather than a rig that was +# started without its instrument. The script that launches the rig is where they belong. +export ORCA_CAD_MCP="${ORCA_CAD_MCP:-/tmp/mcp.sock}" +export ORCA_CAD_KEYTRACE="${ORCA_CAD_KEYTRACE:-1}" +export LD_LIBRARY_PATH="$LIBPY:$LIBPY2:${LD_LIBRARY_PATH:-}" +mkdir -p /root/.config # startup dies in boost::filesystem::create_directory without this + +# Skip zombies. This container accumulates instances of the app across runs, and pgrep +# matches them, so the naive "first match" reported a dead pid as if the session were healthy. +app_pid() { + local p + for p in $(pgrep -f "$(basename "$BIN")" 2>/dev/null); do + [ "$(awk "{print \$3}" "/proc/$p/stat" 2>/dev/null)" = "Z" ] && continue + echo "$p"; return 0 + done + return 1 +} + +status() { + echo "display : $(pgrep -f "Xvfb $DISP" >/dev/null && echo up || echo DOWN)" + echo "wm : $(pgrep -x openbox >/dev/null && echo up || echo DOWN)" + if pgrep -x x11vnc >/dev/null; then echo "vnc : up on :5900" + elif ! command -v x11vnc >/dev/null; then echo "vnc : n/a (x11vnc not installed here)" + else echo "vnc : DOWN"; fi + local p; p="$(app_pid || true)" + echo "app : ${p:-DOWN}" + # WHICH binary is on screen, not just that something is. A pid alone cannot tell you whether + # you are looking at the build you just linked or one from last week, and that is precisely + # the question every rig verification is asking. + [ -n "${p:-}" ] && echo "binary : $(readlink -f "/proc/$p/exe" 2>/dev/null || echo unknown)" + [ -n "${p:-}" ] && echo "windows : $(xdotool search --name . getwindowname %@ 2>/dev/null | paste -sd'|' -)" + return 0 +} + +[ "${1:-}" = "--status" ] && { status; exit 0; } + +# --- desktop: Xvfb, a window manager, and the VNC server ------------------------------------ +# openbox is REQUIRED: without it xdotool windowactivate aborts with "windowmanager claims not to +# support _NET_ACTIVE_WINDOW" and dialogs never take focus. +pgrep -f "Xvfb $DISP" >/dev/null || { nohup Xvfb "$DISP" -screen 0 "${GEOM}x24" -nolisten tcp >/tmp/xvfb.log 2>&1 & sleep 3; } +pgrep -x openbox >/dev/null || { nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; } +if ! pgrep -x x11vnc >/dev/null && command -v x11vnc >/dev/null; then + AUTH=() + [ -f /root/.vnc/passwd ] && AUTH=(-rfbauth /root/.vnc/passwd) + nohup x11vnc -display "$DISP" -rfbport 5900 "${AUTH[@]}" -forever -shared -noxdamage \ + >/tmp/x11vnc.log 2>&1 & + sleep 2 +fi + +# --- app ------------------------------------------------------------------------------------ +# Kill by BASENAME, not by "$BIN". The app enforces a single instance, so an older copy launched +# from a DIFFERENT path (the packaged build/package/bin/ one, say, when BIN points at the freshly +# linked build/src/Release/ one) survives a path-matched pkill, keeps the instance lock, and the +# new process exits seconds after loading fonts — leaving no error anywhere. app_pid() below has +# always matched by basename, so status then reported that stale process as a healthy session: +# the launch looked green while the window on screen was days old. Measured 2026-08-02, where it +# nearly passed a UI change against a Jul-30 binary. The killer and the reporter must agree on +# what counts as "the app". +pkill -9 -f "$(basename "$BIN")" 2>/dev/null || true +sleep 2 +nohup "$BIN" >"$LOG" 2>&1 & +echo "launched $(basename "$BIN") pid $!" + +# Wait for the main window rather than sleeping a fixed amount: cold starts vary a lot under +# software GL, and a fixed sleep either wastes time or races. +for _ in $(seq 1 40); do + xdotool search --name "Untitled" >/dev/null 2>&1 && break + sleep 1 +done + +# --- first-run dialogs ---------------------------------------------------------------------- +# Click the titlebar close box, but only on geometry we have just read for a window that still +# exists, and only if the resulting point is inside the screen. Every variable is unset first so a +# failed read cannot leave the previous dialog's numbers behind — that is the whole bug. +screen_w="${GEOM%x*}"; screen_h="${GEOM#*x}" +close_dialog() { + local name="$1" id X Y WIDTH HEIGHT cx cy + id="$(xdotool search --name "$name" 2>/dev/null | head -1 || true)" + [ -z "$id" ] && return 1 + unset X Y WIDTH HEIGHT + eval "$(xdotool getwindowgeometry --shell "$id" 2>/dev/null || true)" + # All four must be present and numeric: an empty or stale read is how the stray click happened. + for v in "${X:-}" "${Y:-}" "${WIDTH:-}" "${HEIGHT:-}"; do + [[ "$v" =~ ^-?[0-9]+$ ]] || { echo " $name: unreadable geometry, not clicking"; return 1; } + done + cx=$((X + WIDTH - 11)); cy=$((Y - 31)) # openbox decoration: close box above the frame + if [ "$cx" -lt 0 ] || [ "$cy" -lt 0 ] || [ "$cx" -ge "$screen_w" ] || [ "$cy" -ge "$screen_h" ]; then + echo " $name: close box at ${cx},${cy} is off-screen, not clicking"; return 1 + fi + echo " $name: closing via titlebar at ${cx},${cy}" + xdotool mousemove "$cx" "$cy" click 1 + sleep 2 + return 0 +} +# "Restore" is not a first-RUN dialog, it is a second-run one: killing the app mid-session leaves +# unsaved items behind, and the next launch asks whether to restore them. It sits over the tab bar +# with a modal grab, so every synthetic click afterwards lands on it and the ladder reports +# geometry that never got drawn — that is the "success with no log" shape twice already. +for name in "Setup Wizard" "New version" "Restore"; do + for _ in 1 2 3; do close_dialog "$name" || break; done +done + +# --- main window ---------------------------------------------------------------------------- +main="$(xdotool search --name "Untitled" 2>/dev/null | head -1 || true)" +if [ -n "$main" ]; then + xdotool windowmove "$main" 0 0 windowsize "$main" "$screen_w" "$screen_h" 2>/dev/null || true + xdotool windowactivate "$main" 2>/dev/null || true + sleep 2 +fi + +echo "--- session ---" +status diff --git a/scripts/Dockerfile.deps b/scripts/Dockerfile.deps new file mode 100644 index 0000000000..295b72028d --- /dev/null +++ b/scripts/Dockerfile.deps @@ -0,0 +1,95 @@ +# Deps-only base image for fast iteration on Orca. +# Identical system+pinned-dependency setup to scripts/Dockerfile, but STOPS after +# `build_linux.sh -dr` (no slicer/AppImage build). Produces an image with the pinned +# deps baked at /OrcaSlicer/deps/build/destdir, so the slicer can be rebuilt +# incrementally via scripts/CAD/build-gui-incremental.sh without re-running the long deps build. +# +# Build once (rebuild only when deps/ changes, e.g. OCCT module flags): +# docker build -t snapmaker-deps -f scripts/Dockerfile.deps . +FROM docker.io/ubuntu:24.04 +LABEL maintainer="Orca CAD iteration base" + +# Disable interactive package configuration +RUN apt-get update && \ + echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections + +# Add a deb-src +RUN echo deb-src http://archive.ubuntu.com/ubuntu \ + $(cat /etc/*release | grep VERSION_CODENAME | cut -d= -f2) main universe>> /etc/apt/sources.list + +RUN apt-get update && apt-get install -y \ + autoconf \ + build-essential \ + cmake \ + curl \ + eglexternalplatform-dev \ + extra-cmake-modules \ + file \ + git \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-libav \ + libcairo2-dev \ + libcurl4-openssl-dev \ + libdbus-1-dev \ + libglew-dev \ + libglu1-mesa-dev \ + libgstreamer1.0-dev \ + libgstreamerd-3-dev \ + libgstreamer-plugins-base1.0-dev \ + libgstreamer-plugins-good1.0-dev \ + libgtk-3-dev \ + libsecret-1-dev \ + libsoup2.4-dev \ + libssl3 \ + libssl-dev \ + libtool \ + libudev-dev \ + libwayland-dev \ + libwebkit2gtk-4.1-dev \ + libxkbcommon-dev \ + locales \ + locales-all \ + m4 \ + pkgconf \ + sudo \ + wayland-protocols \ + wget + +ENV LC_ALL=en_US.utf8 +RUN locale-gen $LC_ALL +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +COPY ./ OrcaSlicer +WORKDIR OrcaSlicer + +# System dependencies +RUN ./build_linux.sh -u + +# Pinned dependencies in ./deps (OCCT 7.6 with ModelingAlgorithms enabled, OpenCV, +# OpenVDB, Boost, wxWidgets, ...). This is the long step; it is baked into the image. +# -j 12, not the default all-cores: on 2026-08-21 an unbounded deps build plus an unbounded +# app build put 42 GB of cc1plus on a 62 GB box and OOM-killed the host for 2h28m. 12 keeps +# the compile fast while leaving the machine usable. Pair with `docker build --memory`. +RUN ./build_linux.sh -dr -j 12 + +# Compatibility symlink. This deps tree installs into deps/build/OrcaSlicer_dep/, while the +# orcacad_buildcache volume was configured against the previous image, whose prefix was +# deps/build/destdir/. Those paths are baked as absolute strings into the app's CMakeCache, so +# without this symlink a rebuild on the new image reconfigures from scratch — hours of compile +# to change one directory name. Same tree, two names. +RUN ln -sfn /OrcaSlicer/deps/build/OrcaSlicer_dep /OrcaSlicer/deps/build/destdir + +# The rig's GUI runtime. This used to arrive for free because orcacad-deps was layered on +# snapmaker-deps; that lineage is Trap 1 in docs/rig_build_traps.md (a baked project(Snapmaker_Orca) +# tree) and building from this Dockerfile is what removes it — along with the X stack the rig +# needs. scripts/CAD/start-headless-gui.sh requires Xvfb and openbox (without a window manager `xdotool +# windowactivate` aborts with "windowmanager claims not to support..."), drives the UI with +# xdotool, and captures to /shots with scrot/ImageMagick. Same set snapmaker-deps carries. +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + imagemagick \ + openbox \ + scrot \ + xauth \ + xdotool \ + xvfb \ + && rm -rf /var/lib/apt/lists/* diff --git a/scripts/Dockerfile.deps-assimp b/scripts/Dockerfile.deps-assimp new file mode 100644 index 0000000000..40566885c0 --- /dev/null +++ b/scripts/Dockerfile.deps-assimp @@ -0,0 +1,70 @@ +# Adds assimp to the orcacad-deps image. +# +# WHY THIS EXISTS. OrcaSlicer mainline gained `find_package(assimp REQUIRED)` in +# src/libslic3r/CMakeLists.txt (glTF/GLB/FBX import for texture-to-colour), and the baked +# orcacad-deps image predates it: the image's deps/ tree has no Assimp directory at all, and +# nothing named assimp exists anywhere in it. On the host, deps/build/dep_Assimp-prefix has +# only `patch` and `update` stamps -- no build, no install -- so the dependency was fetched +# and then never built, in the image or out of it. +# +# The consequence was that scripts/CAD/run-kernel-tests.sh failed at CMake CONFIGURE time, +# before a single source file compiled, so THIS FORK'S KERNEL SUITE COULD NOT RUN AT ALL. +# Every kernel change ported here was parity-checked against Snapmaker and never independently +# tested (w80c). The Snapmaker fork does not hit this: its base requires neither +# assimp nor OpenCV. +# +# WHY A LAYER AND NOT A FULL DEPS REBUILD. Rebuilding every dependency takes hours and would +# rewrite artifacts that are currently working. This adds exactly the one missing package on +# top of the existing image, and it is a Dockerfile rather than a `docker commit` so that what +# was done is reviewable and repeatable instead of being an undocumented mutation. +# +# The flags below are copied from the project's own recipe (deps/Assimp/Assimp.cmake) plus the +# standard superbuild arguments from orcaslicer_add_cmake_project (deps/CMakeLists.txt:158) and +# DEP_CMAKE_OPTS (deps/deps-linux.cmake). Keep them in step with that recipe: this file is a +# stand-in for the superbuild, not an independent opinion about how to build assimp. +# +# BUILD (from the repo root, tarball already in deps/DL_CACHE/Assimp/): +# docker build -f scripts/Dockerfile.deps-assimp -t orcacad-deps . +# +# VERIFY: +# docker run --rm orcacad-deps sh -c \ +# 'ls /OrcaSlicer/deps/build/destdir/usr/local/lib/cmake/assimp*' +# scripts/CAD/run-kernel-tests.sh +# +FROM orcacad-deps + +# v5.4.3 is the version deps/Assimp/Assimp.cmake selects for CMake >= 3.22 (the image has +# 3.28.3). The SHA256 is that recipe's URL_HASH, verified against the cached tarball before +# this file was written -- an unverified archive is not a dependency, it is whatever was +# sitting in the cache. +ARG ASSIMP_SHA256=66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb +COPY deps/DL_CACHE/Assimp/v5.4.3.tar.gz /tmp/assimp.tar.gz + +RUN set -eux; \ + echo "${ASSIMP_SHA256} /tmp/assimp.tar.gz" | sha256sum -c -; \ + mkdir -p /tmp/assimp-src && \ + tar -xzf /tmp/assimp.tar.gz -C /tmp/assimp-src --strip-components=1; \ + DESTDIR=/OrcaSlicer/deps/build/destdir/usr/local; \ + cmake -S /tmp/assimp-src -B /tmp/assimp-build -G Ninja \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$DESTDIR" \ + -DCMAKE_PREFIX_PATH="$DESTDIR" \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DBUILD_SHARED_LIBS=OFF \ + -DASSIMP_BUILD_USE_CCACHE=OFF \ + -DASSIMP_BUILD_TESTS=OFF \ + -DASSIMP_BUILD_SAMPLES=OFF \ + -DASSIMP_BUILD_ASSIMP_TOOLS=OFF \ + -DASSIMP_INSTALL_PDB=OFF \ + -DASSIMP_NO_EXPORT=ON \ + -DASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT=OFF \ + -DASSIMP_BUILD_GLTF_IMPORTER=ON \ + -DASSIMP_BUILD_OBJ_IMPORTER=ON \ + -DASSIMP_BUILD_FBX_IMPORTER=ON \ + -DASSIMP_BUILD_ZLIB=ON \ + -DASSIMP_WARNINGS_AS_ERRORS=OFF \ + -DBUILD_WITH_STATIC_CRT=OFF; \ + cmake --build /tmp/assimp-build --target install -- -j"$(nproc)"; \ + rm -rf /tmp/assimp-src /tmp/assimp-build /tmp/assimp.tar.gz; \ + test -n "$(ls "$DESTDIR"/lib/cmake/assimp* 2>/dev/null)" diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index 668f51334b..fa7c329b4b 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -223,6 +223,12 @@ modules: sha256: 51afe0db79af8386e2027d56d685177135581e0ee82ade9d7f2caff8deab5ec5 dest: external-packages/OpenCSG + # SolveSpace libslvs (2D sketch constraint solver, Design tab) + - type: file + url: https://github.com/JacobStoren/SolveSpaceLib/archive/4d8704523e4bf212fadf5189f92484244f670fea.zip + sha256: 1c4bdde9c3c6ef20ea4b50b73601de56769f2eb131b36927d7c6489f102e6c30 + dest: external-packages/SLVS + # Blosc 1.17.0 (tamasmeszaros fork) - type: file url: https://github.com/tamasmeszaros/c-blosc/archive/refs/heads/v1.17.0_tm.zip diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 4c6eefa470..33e25b18cd 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -313,6 +313,26 @@ void AppConfig::set_defaults() if (get("zoom_to_mouse").empty()) set_bool("zoom_to_mouse", false); +#ifdef SLIC3R_CAD + // Experimental parametric Design tab. Off by default: the tab is not created at all + // until this is turned on, so nothing it builds reaches an unsuspecting user. + if (get("enable_cad_feature").empty()) + set_bool("enable_cad_feature", false); + + // Auto-weld sketch endpoints within kSketchJoinTol when building closed loops. + // Default ON: it is what the ~90% case wants; OFF makes the kernel demand an exact + // joint. The GUI pushes it into SketchEngine via set_sketch_auto_close(). + if (get("auto_close_sketch_loops").empty()) + set_bool("auto_close_sketch_loops", true); + + // Design tab: draw a mate connector as a face rather than as the abstract disc + roll + // quadrant. Defaults ON — face orientation is hardwired perception, so the roll and the + // verse read without being learned, which no abstract glyph achieves. Turning it off + // restores the conventional CAD representation for users who expect it (x0kd). + if (get("design_connector_face_glyph").empty()) + set_bool("design_connector_face_glyph", true); +#endif + //#ifdef SUPPORT_SHOW_HINTS if (get("show_hints").empty()) set_bool("show_hints", false); diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp new file mode 100644 index 0000000000..c20b636ae7 --- /dev/null +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -0,0 +1,4125 @@ +#include "libslic3r/CAD/CadDocument.hpp" +#include "libslic3r/CAD/SketchConstraints.hpp" +#include "libslic3r/CAD/SketchSolver.hpp" +#include "libslic3r/CAD/SketchImport.hpp" // transform_regions for imported art + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // plane_of_face: reject non-planar faces +#include +#include +#include +#include +#include +#include +#include // SurfaceFill: GeomAbs_C0 +#include +#include +#include +#include +#include +#include // multi-body: compound of bodies for display/compat +#include +#include // outward-normal orientation for face-extrude +#include // TopoDS::Edge for SurfaceFill +#include // is_sheet_shape +#include +#include +#include +#include +#include +#include // pattern: rigid copy transforms +#include // STEP export (native B-rep) +#include +#include +#include // pattern: rotation axis (circular) +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Slic3r { + +// ---- helical-thread construction helpers (file-local) ---------------------- + +// Helix spine on a cylinder (radius/pitch/height) about `axis`, as a wire. +static TopoDS_Wire make_helix_wire(const gp_Ax3& axis, double radius, + double pitch, double height) +{ + Handle(Geom_CylindricalSurface) cyl = new Geom_CylindricalSurface(axis, radius); + double turns = (pitch > 1e-6) ? (height / pitch) : 1.0; + // In the surface (u,v) parametrization u is the angle, v the axial height. + gp_Pnt2d p0(0.0, 0.0); + gp_Pnt2d p1(2.0 * M_PI * turns, height); + Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1); + TopoDS_Edge e = BRepBuilderAPI_MakeEdge(seg, cyl).Edge(); + BRepLib::BuildCurves3d(e); + return BRepBuilderAPI_MakeWire(e).Wire(); +} + +// Helix spine from a CadFeature's helix params. Supports cylindrical (taper==0) +// and conical (taper!=0) surfaces; left_handed flips the winding direction. +// Returns null wire if validation fails (error is written to err). +static TopoDS_Wire make_helix_spine(const CadFeature& f, std::string& err) +{ + err.clear(); + const double R = f.helix_radius, P = f.helix_pitch, H = f.helix_height; + const double taper = f.helix_taper_deg * M_PI / 180.0; + + if (R <= 0) { err = "helix radius must be > 0"; return TopoDS_Wire(); } + if (P <= 0) { err = "helix pitch must be > 0"; return TopoDS_Wire(); } + if (H < 0) { err = "helix height must be >= 0"; return TopoDS_Wire(); } + if (H == 0) { err = "helix height of 0 (flat spiral) is not supported"; return TopoDS_Wire(); } + const double turns = H / P; + if (turns > 10000) { err = "helix turn count exceeds limit (10000)"; return TopoDS_Wire(); } + if (std::abs(taper) > 1e-12) { + const double R_top = R + H * std::tan(taper); + if (R_top <= 0) { + err = "helix taper drives radius negative before reaching height"; + return TopoDS_Wire(); + } + } + + gp_Dir zdir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z()); + gp_Dir xdir(f.plane.x_axis.x(), f.plane.x_axis.y(), f.plane.x_axis.z()); + Vec3d ori = f.plane.origin; + gp_Pnt o(ori.x(), ori.y(), ori.z()); + gp_Ax2 ax2(o, zdir, xdir); + gp_Ax3 ax3(o, zdir, xdir); + + TopoDS_Edge e; + if (std::abs(taper) > 1e-12) { + Handle(Geom_ConicalSurface) cone = new Geom_ConicalSurface(ax3, taper, R); + double u1 = f.helix_left_handed ? -2.0 * M_PI * turns : 2.0 * M_PI * turns; + gp_Pnt2d p0(0.0, 0.0); + gp_Pnt2d p1(u1, H); + Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1); + e = BRepBuilderAPI_MakeEdge(seg, cone).Edge(); + } else { + Handle(Geom_CylindricalSurface) cyl = new Geom_CylindricalSurface(ax3, R); + double u1 = f.helix_left_handed ? -2.0 * M_PI * turns : 2.0 * M_PI * turns; + gp_Pnt2d p0(0.0, 0.0); + gp_Pnt2d p1(u1, H); + Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1); + e = BRepBuilderAPI_MakeEdge(seg, cyl).Edge(); + } + BRepLib::BuildCurves3d(e); + return BRepBuilderAPI_MakeWire(e).Wire(); +} + +// Triangular axial thread profile (a planar face) placed at the helix start +// (origin + radius*xdir). Spans +-pitch/2 axially; apex offset radially by depth. +// Both thread kinds sweep the SAME outward-biting V (base on the cylinder wall, +// apex `depth` into the surrounding material). Only the boolean differs: +// - external: the V is FUSED to the rod -> a raised helical ridge. +// - internal: the V is CUT from the wall -> a sunken helical groove. The cut MUST +// go outward into the wall to be visible; an inward V (the old behaviour) only +// sweeps already-empty bore space and removes nothing. +static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, + const gp_Dir& zdir, double radius, + double pitch, double depth, bool internal) +{ + // `internal` is intentionally unused: the V is the same shape either way, always pointing + // radially outward from `radius`. What differs is what the caller DOES with the swept solid + // — an external thread fuses it onto the shaft, an internal one cuts it out of the wall after + // boring at the minor diameter. Keeping the parameter documents that the caller decided, and + // stops someone "fixing" the profile to point inward for the internal case, which would make + // the groove sweep already-empty bore space and cut nothing. + (void)internal; + gp_Vec vx(xdir), vz(zdir); + // Root the V CLEARLY inside the wall (a real overlap, not a 0.05 mm tangency) so the boolean + // has clean intersections — near-coincident faces are what make OCCT's fuse/cut unstable. + const double over = std::min(std::max(depth, 0.25), radius * 0.4); + double inner = radius - over; // base, well inside the wall (solid overlap) + double crest = radius + depth; // apex, `depth` into the surrounding material + // Axial half-height must be < pitch/2 so ADJACENT helix turns don't collide — a full-pitch + // profile makes the swept solid self-intersect (invalid -> never renders, or crashes the + // boolean). 0.42*pitch leaves a clean gap between turns; the V still reads as a thread. + const double half = 0.42 * pitch; + gp_Pnt top (origin.XYZ() + (vx * inner).XYZ() + (vz * ( half)).XYZ()); + gp_Pnt bot (origin.XYZ() + (vx * inner).XYZ() + (vz * (-half)).XYZ()); + gp_Pnt apex(origin.XYZ() + (vx * crest).XYZ()); + BRepBuilderAPI_MakePolygon poly(top, bot, apex, Standard_True); + return poly.Wire(); // closed triangle, swept by MakePipeShell with a fixed binormal +} + +// ---- expression evaluator (file-local) ------------------------------------- +// ponytail: self-contained shunting-yard arithmetic + function set for +// parametric dimension expressions. Extend the function table if needed. + +// Evaluate an arithmetic expression to a double. Supports + - * /, parentheses, +// unary minus, decimal literals, and identifiers resolved via `vars`. Functions: +// sqrt, abs, sin, cos, tan (degrees), min, max, and the constant `pi`. +// Throws std::runtime_error on any parse or lookup error, div-by-zero, bad arity. +static double eval_expr(const std::string& src, const std::map& vars) +{ + struct Token { + enum Type { Num, Id, Op, LParen, RParen, Comma }; + Type type; + std::string val; + double num{0}; + }; + // precedence: 0=paren/comma, 1=+-, 2=*/, 3=unary-minus, 4=function + auto prec = [](char op, bool unary) -> int { + if (unary && op == 'u') return 3; + if (op == '+' || op == '-') return 1; + if (op == '*' || op == '/') return 2; + if (op == '(' || op == ')' || op == ',') return 0; + return 0; + }; + auto is_op = [](char c) { return c == '+' || c == '-' || c == '*' || c == '/'; }; + + // ---- tokenise ---- + std::vector tokens; + size_t i = 0; + bool prev_was_val = false; // tracked for unary-minus detection + while (i < src.size()) { + char c = src[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { ++i; continue; } + if (c == '(') { tokens.push_back({Token::LParen, "("}); ++i; prev_was_val = false; continue; } + if (c == ')') { tokens.push_back({Token::RParen, ")"}); ++i; prev_was_val = true; continue; } + if (c == ',') { tokens.push_back({Token::Comma, ","}); ++i; prev_was_val = false; continue; } + if (isdigit(c) || c == '.') { + size_t start = i; + while (i < src.size() && (isdigit(src[i]) || src[i] == '.')) ++i; + Token t{Token::Num, src.substr(start, i - start)}; + t.num = std::stod(t.val); + tokens.push_back(t); + prev_was_val = true; + continue; + } + if (isalpha(c) || c == '_') { + size_t start = i; + while (i < src.size() && (isalnum(src[i]) || src[i] == '_')) ++i; + std::string id = src.substr(start, i - start); + tokens.push_back({Token::Id, id}); + prev_was_val = true; + continue; + } + if (is_op(c)) { + // unary minus detection + if (c == '-' && !prev_was_val) { + tokens.push_back({Token::Op, "u"}); + } else { + tokens.push_back({Token::Op, std::string(1, c)}); + } + ++i; + prev_was_val = false; + continue; + } + throw std::runtime_error("bad character in expression"); + } + + // ---- shunting-yard: infix -> RPN ---- + std::vector rpn; + std::vector stack; + auto flush_paren = [&]() { + while (!stack.empty() && stack.back().type != Token::LParen) { + rpn.push_back(stack.back()); stack.pop_back(); + } + if (stack.empty()) throw std::runtime_error("mismatched parentheses"); + stack.pop_back(); // discard '(' +#if 0 + // If the '(' belonged to a function call, push the function name + // (ponytail: we track this via the Id token on top of the op stack + // BEFORE the '(' was pushed; after flush we check if the previous + // token was a function-call Id). +#endif + if (!stack.empty() && stack.back().type == Token::Id) { + rpn.push_back(stack.back()); stack.pop_back(); + } + }; + + for (size_t j = 0; j < tokens.size(); ++j) { + const Token& t = tokens[j]; + if (t.type == Token::Num) { + rpn.push_back(t); + } else if (t.type == Token::Id) { + // function call if the next token is '(' + if (j + 1 < tokens.size() && tokens[j + 1].type == Token::LParen) { + stack.push_back(t); + } else { + // identifier — resolve now + double v; + if (t.val == "pi") v = M_PI; + else { + auto it = vars.find(t.val); + if (it == vars.end()) + throw std::runtime_error("unknown identifier: " + t.val); + v = it->second; + } + rpn.push_back({Token::Num, "", v}); + } + } else if (t.type == Token::Op) { + int p = prec(t.val[0], t.val == "u"); + while (!stack.empty()) { + const Token& top = stack.back(); + if (top.type != Token::Op && top.type != Token::Id) break; + int tp = prec(top.val[0], top.val == "u"); + if (tp < 4 && p <= tp) { + rpn.push_back(top); stack.pop_back(); + } else break; + } + stack.push_back(t); + } else if (t.type == Token::LParen) { + stack.push_back(t); + } else if (t.type == Token::RParen) { + flush_paren(); + } else if (t.type == Token::Comma) { + while (!stack.empty() && stack.back().type != Token::LParen) { + rpn.push_back(stack.back()); stack.pop_back(); + } + // , is a no-op separator — just stay inside the paren + } + } + while (!stack.empty()) { + if (stack.back().type == Token::LParen || stack.back().type == Token::RParen) + throw std::runtime_error("mismatched parentheses"); + rpn.push_back(stack.back()); stack.pop_back(); + } + + // ---- evaluate RPN ---- + std::vector vs; + for (const Token& t : rpn) { + if (t.type == Token::Num) { + vs.push_back(t.num); + } else if (t.type == Token::Op) { + if (t.val == "u") { + if (vs.empty()) throw std::runtime_error("missing operand for unary minus"); + vs.back() = -vs.back(); + } else { + if (vs.size() < 2) throw std::runtime_error("not enough operands for '" + t.val + "'"); + double b = vs.back(); vs.pop_back(); + double a = vs.back(); vs.pop_back(); + if (t.val == "+") vs.push_back(a + b); + else if (t.val == "-") vs.push_back(a - b); + else if (t.val == "*") vs.push_back(a * b); + else if (t.val == "/") { if (b == 0) throw std::runtime_error("division by zero"); vs.push_back(a / b); } + } + } else if (t.type == Token::Id) { + // function call (already on RPN via shunting-yard) + auto call_fn = [&](const std::string& name, int arity) { + if ((int)vs.size() < arity) + throw std::runtime_error("not enough arguments for " + name + "()"); + if (name == "sqrt") { double a = vs.back(); vs.pop_back(); vs.push_back(std::sqrt(a)); } + else if (name == "abs") { double a = vs.back(); vs.pop_back(); vs.push_back(std::abs(a)); } + else if (name == "sin") { double a = vs.back(); vs.pop_back(); vs.push_back(std::sin(a * M_PI / 180.0)); } + else if (name == "cos") { double a = vs.back(); vs.pop_back(); vs.push_back(std::cos(a * M_PI / 180.0)); } + else if (name == "tan") { double a = vs.back(); vs.pop_back(); vs.push_back(std::tan(a * M_PI / 180.0)); } + else if (name == "min") { double b = vs.back(); vs.pop_back(); double a = vs.back(); vs.pop_back(); vs.push_back(std::min(a, b)); } + else if (name == "max") { double b = vs.back(); vs.pop_back(); double a = vs.back(); vs.pop_back(); vs.push_back(std::max(a, b)); } + else throw std::runtime_error("unknown function: " + name); + }; + call_fn(t.val, (t.val == "min" || t.val == "max") ? 2 : 1); + } + } + if (vs.size() != 1) throw std::runtime_error("invalid expression"); + return vs[0]; +} + +// Topologically evaluate `variables` (name -> expression) into name -> value. +// An expression may reference other variables; resolution is recursive with a +// visiting set. Throws std::runtime_error("variable cycle: ...") on a dependency +// cycle, or propagates eval_expr errors. +static std::map +evaluate_variables(const std::map& variables) +{ + std::map done; + std::set visiting; + + std::function resolve = [&](const std::string& name) -> double { + auto itd = done.find(name); + if (itd != done.end()) return itd->second; + if (visiting.count(name)) throw std::runtime_error("variable cycle: " + name); + auto itv = variables.find(name); + if (itv == variables.end()) throw std::runtime_error("unknown identifier: " + name); + visiting.insert(name); + // pre-resolve every identifier `name` references: scan for identifiers in + // its expression, resolve them first, then eval with the populated map. + // ponytail: simplest correct approach — depth-first with visited tracking. + std::map scope = done; // start with already-resolved + // Add any variable name found in the expression to scope (resolve recursively) + const std::string& expr = itv->second; + for (size_t i = 0; i < expr.size(); ) { + char c = expr[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { ++i; continue; } + if (isalpha(c) || c == '_') { + size_t start = i; + while (i < expr.size() && (isalnum(expr[i]) || expr[i] == '_')) ++i; + std::string id = expr.substr(start, i - start); + // skip "pi" and function names — they're built-ins, not variables + if (id == "pi" || id == "sqrt" || id == "abs" || id == "sin" || id == "cos" || + id == "tan" || id == "min" || id == "max") continue; + if (!variables.count(id)) throw std::runtime_error("unknown identifier: " + id); + scope[id] = resolve(id); + continue; + } + ++i; + } + double val = eval_expr(expr, scope); + visiting.erase(name); + done[name] = val; + return val; + }; + + for (const auto& [k, _] : variables) resolve(k); + return done; +} + +// Write `value` into the CadFeature numeric field named `field`. Allow-list of +// the geometric dimension fields a variable realistically drives. Integer-valued +// fields are rounded. Throws std::runtime_error("unknown parameter: " + field) +// for anything not in the list. +static void assign_field(CadFeature& f, const std::string& field, double value) +{ + // double fields (alphabetical) + if (field == "distance") { f.distance = value; return; } + if (field == "distance2") { f.distance2 = value; return; } + if (field == "draft_angle") { f.draft_angle = value; return; } + if (field == "dressup_size") { f.dressup_size = value; return; } + if (field == "height") { f.height = value; return; } + if (field == "hole_cbore_diameter") { f.hole_cbore_diameter = value; return; } + if (field == "hole_cbore_depth") { f.hole_cbore_depth = value; return; } + if (field == "hole_csink_angle") { f.hole_csink_angle = value; return; } + if (field == "hole_csink_diameter") { f.hole_csink_diameter = value; return; } + if (field == "hole_depth") { f.hole_depth = value; return; } + if (field == "hole_diameter") { f.hole_diameter = value; return; } + if (field == "hole_x") { f.hole_x = value; return; } + if (field == "hole_y") { f.hole_y = value; return; } + if (field == "import_scale_x") { f.import_scale_x = value; return; } + if (field == "import_scale_y") { f.import_scale_y = value; return; } + if (field == "pattern_angle") { f.pattern_angle = value; return; } + if (field == "pattern_spacing") { f.pattern_spacing = value; return; } + if (field == "plane_angle_tilt") { f.plane_angle_tilt = value; return; } + if (field == "plane_offset") { f.plane_offset = value; return; } + if (field == "radius") { f.radius = value; return; } + if (field == "revolve_angle") { f.revolve_angle = value; return; } + if (field == "rib_depth") { f.rib_depth = value; return; } + if (field == "rib_thickness") { f.rib_thickness = value; return; } + if (field == "shell_thickness") { f.shell_thickness = value; return; } + if (field == "taper_deg") { f.taper_deg = value; return; } + if (field == "thread_depth") { f.thread_depth = value; return; } + if (field == "thread_height") { f.thread_height = value; return; } + if (field == "thread_pitch") { f.thread_pitch = value; return; } + if (field == "thread_radius") { f.thread_radius = value; return; } + if (field == "thread_x") { f.thread_x = value; return; } + if (field == "thread_y") { f.thread_y = value; return; } + if (field == "width") { f.width = value; return; } + // int fields (rounded) + if (field == "pattern_count") { f.pattern_count = (int)std::lround(value); return; } + throw std::runtime_error("unknown parameter: " + field); +} + +// --------------------------------------------------------------------------- + +// Sample a sketch entity at parameter t in [0,1]. Handles Line, Arc, BSpline (cubic, 4 poles). +// Falls back to a p0->p1 lerp for any other type. // ponytail: covers the entities a guide +// curve is realistically drawn with; extend if needed. +static Vec2d sample_entity_2d(const SketchEntity& e, double t) +{ + t = std::max(0.0, std::min(1.0, t)); + switch (e.type) { + case SketchEntity::Type::Line: + return e.p0 + (e.p1 - e.p0) * t; + case SketchEntity::Type::Arc: { + double theta = e.start_angle + (e.end_angle - e.start_angle) * t; + return e.center + e.radius * Vec2d(std::cos(theta), std::sin(theta)); + } + case SketchEntity::Type::BSpline: + if (e.ctrl.size() == 4) { + const double u = 1.0 - t; + const double b0 = u * u * u; + const double b1 = 3.0 * u * u * t; + const double b2 = 3.0 * u * t * t; + const double b3 = t * t * t; + return e.ctrl[0] * b0 + e.ctrl[1] * b1 + e.ctrl[2] * b2 + e.ctrl[3] * b3; + } + return e.ctrl.empty() ? e.p0 + (e.p1 - e.p0) * t + : e.ctrl.front() + (e.ctrl.back() - e.ctrl.front()) * t; + default: + return e.p0 + (e.p1 - e.p0) * t; + } +} + +int CadDocument::add_sketch(SketchShape shape, const SketchPlane& plane, + double width, double height, double radius, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Sketch; + f.name = name; + f.shape = shape; + f.plane = plane; + f.width = width; + f.height = height; + f.radius = radius; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_sketch_profile(const SketchProfile& profile, const SketchPlane& plane, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Sketch; + f.name = name; + f.plane = plane; + f.profile = profile; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_sketch_entities(const std::vector& entities, + const SketchPlane& plane, const std::string& name, + const std::vector& constraints) +{ + CadFeature f; + f.type = CadFeatureType::Sketch; + f.name = name; + f.plane = plane; + f.entities = entities; + f.entity_constraints = constraints; // driving dimensions, solved by solve_sketch_feature + features.push_back(f); + return int(features.size()) - 1; +} + +// Solve Onshape-style constraints on a SketchEntity list (Fase 4.3). All entity +// types participate: Line (P0,P1), Arc (P0,P1,Center), Circle (Center), Point (P0). +// Solved coordinates are written back, with arc angles reflowed from the solved +// center+endpoints. Free function (declared in SketchEngine.hpp) so the in-session +// GUI sketch tool can live-solve the same way committed features do. +bool solve_sketch_entities(std::vector& entities, + const std::vector& constraints) +{ + // Delegated to the vendored SolveSpace solver (SketchSolver / libslvs): full + // constraint set, real DoF + over-constrained detection. + return sketch_solve(entities, constraints).ok; +} + +#if 0 // legacy hand-rolled Gauss-Newton solver — superseded by libslvs, kept for reference +static bool legacy_solve_sketch_entities(std::vector& entities, + const std::vector& constraints) +{ + if (constraints.empty()) return true; + + SketchConstraints sc; + // table[entity][role] -> solver point id, or -1 if that role is unregistered. + std::vector> table(entities.size(), {-1, -1, -1}); + auto reg = [&](int ei, SketchPointRole role, const Vec2d& p) { + table[ei][int(role)] = sc.add_point(p.x(), p.y()); + }; + for (size_t i = 0; i < entities.size(); ++i) { + const SketchEntity& e = entities[i]; + switch (e.type) { + case SketchEntity::Type::Line: + reg(int(i), SketchPointRole::P0, e.p0); + reg(int(i), SketchPointRole::P1, e.p1); + break; + case SketchEntity::Type::Arc: + reg(int(i), SketchPointRole::P0, e.p0); + reg(int(i), SketchPointRole::P1, e.p1); + reg(int(i), SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::Circle: + reg(int(i), SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::Point: + reg(int(i), SketchPointRole::P0, e.p0); + break; + } + } + + auto pid = [&](int ei, SketchPointRole role) -> int { + if (ei < 0 || ei >= int(table.size())) return -1; + return table[ei][int(role)]; + }; + + for (const SketchEntityConstraintDef& c : constraints) { + switch (c.type) { + // Point-form: refs A and B name individual entity points. + case SketchConstraintType::Fix: { + int a = pid(c.ea, c.ra); + if (a >= 0) sc.fix_point(a); + break; + } + case SketchConstraintType::Coincident: { + int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb); + if (a >= 0 && b >= 0) sc.coincident(a, b); + break; + } + case SketchConstraintType::Horizontal: { + int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb); + if (a >= 0 && b >= 0) sc.horizontal(a, b); + break; + } + case SketchConstraintType::Vertical: { + int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb); + if (a >= 0 && b >= 0) sc.vertical(a, b); + break; + } + case SketchConstraintType::Distance: { + int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb); + if (a >= 0 && b >= 0) sc.distance(a, b, c.value); + break; + } + case SketchConstraintType::LockX: { + int a = pid(c.ea, c.ra); + if (a >= 0) sc.lock_x(a, c.value); + break; + } + case SketchConstraintType::LockY: { + int a = pid(c.ea, c.ra); + if (a >= 0) sc.lock_y(a, c.value); + break; + } + // Segment-form: ea and eb name whole line segments (their P0->P1). + case SketchConstraintType::Parallel: + case SketchConstraintType::Perpendicular: + case SketchConstraintType::EqualLength: { + int a0 = pid(c.ea, SketchPointRole::P0), a1 = pid(c.ea, SketchPointRole::P1); + int b0 = pid(c.eb, SketchPointRole::P0), b1 = pid(c.eb, SketchPointRole::P1); + if (a0 < 0 || a1 < 0 || b0 < 0 || b1 < 0) break; + if (c.type == SketchConstraintType::Parallel) sc.parallel(a0, a1, b0, b1); + else if (c.type == SketchConstraintType::Perpendicular) sc.perpendicular(a0, a1, b0, b1); + else sc.equal_length(a0, a1, b0, b1); + break; + } + case SketchConstraintType::Concentric: { + int a = pid(c.ea, SketchPointRole::Center); + int b = pid(c.eb, SketchPointRole::Center); + if (a >= 0 && b >= 0) sc.coincident(a, b); + break; + } + case SketchConstraintType::Midpoint: { + int m = pid(c.ea, c.ra); + int a = pid(c.eb, SketchPointRole::P0); + int b = pid(c.eb, SketchPointRole::P1); + if (m >= 0 && a >= 0 && b >= 0) sc.midpoint(m, a, b); + break; + } + case SketchConstraintType::Symmetric: { + int a = pid(c.ea, c.ra); + int b = pid(c.eb, c.rb); + int x0 = pid(c.ec, SketchPointRole::P0); + int x1 = pid(c.ec, SketchPointRole::P1); + if (a >= 0 && b >= 0 && x0 >= 0 && x1 >= 0) sc.symmetric(a, b, x0, x1); + break; + } + case SketchConstraintType::Angle: { + int a0 = pid(c.ea, SketchPointRole::P0), a1 = pid(c.ea, SketchPointRole::P1); + int b0 = pid(c.eb, SketchPointRole::P0), b1 = pid(c.eb, SketchPointRole::P1); + if (a0 >= 0 && a1 >= 0 && b0 >= 0 && b1 >= 0) sc.angle(a0, a1, b0, b1, c.value); + break; + } + case SketchConstraintType::Radius: + case SketchConstraintType::Diameter: + // dimensions: applied in the post-solve pass below, not via the solver. + break; + case SketchConstraintType::PointOnLine: { + // Point `ea`/`ra` is held at signed perpendicular distance `value` from + // line `eb` (value 0 -> on the line). Drives e.g. a circle centre onto a + // construction axis and keeps it there through later edits. + int p = pid(c.ea, c.ra); + int l0 = pid(c.eb, SketchPointRole::P0), l1 = pid(c.eb, SketchPointRole::P1); + if (p >= 0 && l0 >= 0 && l1 >= 0) sc.point_line_distance(p, l0, l1, c.value); + break; + } + case SketchConstraintType::Tangent: { + auto in_range = [&](int e){ return e >= 0 && e < (int)entities.size(); }; + if (!in_range(c.ea) || !in_range(c.eb)) break; + const SketchEntity& ea_e = entities[c.ea]; + const SketchEntity& eb_e = entities[c.eb]; + auto is_round = [](const SketchEntity& e){ + return e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc; }; + if (is_round(ea_e) && eb_e.type == SketchEntity::Type::Line) { + int cen = pid(c.ea, SketchPointRole::Center); + int l0 = pid(c.eb, SketchPointRole::P0), l1 = pid(c.eb, SketchPointRole::P1); + if (cen >= 0 && l0 >= 0 && l1 >= 0) sc.point_line_distance(cen, l0, l1, ea_e.radius); + } else if (is_round(eb_e) && ea_e.type == SketchEntity::Type::Line) { + int cen = pid(c.eb, SketchPointRole::Center); + int l0 = pid(c.ea, SketchPointRole::P0), l1 = pid(c.ea, SketchPointRole::P1); + if (cen >= 0 && l0 >= 0 && l1 >= 0) sc.point_line_distance(cen, l0, l1, eb_e.radius); + } else if (is_round(ea_e) && is_round(eb_e)) { + int c0 = pid(c.ea, SketchPointRole::Center), c1 = pid(c.eb, SketchPointRole::Center); + if (c0 >= 0 && c1 >= 0) sc.distance(c0, c1, ea_e.radius + eb_e.radius); + } + break; + } + } + } + + const bool ok = sc.solve(); + // Write solved coordinates back into the participating entities. + for (size_t i = 0; i < entities.size(); ++i) { + SketchEntity& e = entities[i]; + int ip0 = table[i][int(SketchPointRole::P0)]; + int ip1 = table[i][int(SketchPointRole::P1)]; + int ic = table[i][int(SketchPointRole::Center)]; + if (ip0 >= 0) e.p0 = sc.get_point(ip0); + if (ip1 >= 0) e.p1 = sc.get_point(ip1); + if (ic >= 0) e.center = sc.get_point(ic); + + if (e.type == SketchEntity::Type::Arc && ic >= 0) { + // Reflow arc angles from solved center + endpoints, preserving the + // original sweep direction (CCW vs CW). + const double old_sweep = e.end_angle - e.start_angle; // signed, original + double ns = std::atan2(e.p0.y() - e.center.y(), e.p0.x() - e.center.x()); + double ne = std::atan2(e.p1.y() - e.center.y(), e.p1.x() - e.center.x()); + double sweep = ne - ns; + // Normalize `sweep` into (-2pi, 2pi) then match the sign of old_sweep so + // the arc keeps turning the same way it did before solving. + const double TWO_PI = 2.0 * M_PI; + while (sweep <= -TWO_PI) sweep += TWO_PI; + while (sweep >= TWO_PI) sweep -= TWO_PI; + if (old_sweep >= 0.0 && sweep < 0.0) sweep += TWO_PI; + if (old_sweep < 0.0 && sweep > 0.0) sweep -= TWO_PI; + e.start_angle = ns; + e.end_angle = ns + sweep; + e.radius = 0.5 * ((e.p0 - e.center).norm() + (e.p1 - e.center).norm()); + } + if (e.type == SketchEntity::Type::Circle && ic >= 0) { + // p0 mirrors the center for circles; keep them consistent. + e.p0 = e.center; + } + } + // Apply radius/diameter dimensions directly (radius is not a solver variable). + for (const auto& c : constraints) { + if (c.type != SketchConstraintType::Radius && + c.type != SketchConstraintType::Diameter) continue; + if (c.ea < 0 || c.ea >= (int)entities.size()) continue; + SketchEntity& e = entities[c.ea]; + if (e.type != SketchEntity::Type::Circle && e.type != SketchEntity::Type::Arc) continue; + const double r = (c.type == SketchConstraintType::Diameter) ? 0.5 * c.value : c.value; + if (r <= 0.0) continue; + e.radius = r; + if (e.type == SketchEntity::Type::Arc) { + // Rescale endpoints to the new radius around the (solved) center, keeping + // each endpoint's direction so the reflowed start/end angles stay valid. + auto rescale = [&](Vec2d& p) { + Vec2d d = p - e.center; + const double n = d.norm(); + if (n > 1e-12) p = e.center + (r / n) * d; + }; + rescale(e.p0); + rescale(e.p1); + } + } + return ok; +} +#endif // legacy solver + +bool CadDocument::solve_sketch_feature(int index) +{ + if (index < 0 || index >= int(features.size())) return false; + CadFeature& f = features[index]; + if (f.type != CadFeatureType::Sketch) return false; + + // Onshape-style entity sketches solve against entity endpoints (Fase 4.2). + if (!f.entities.empty()) + return solve_sketch_entities(f.entities, f.entity_constraints); + + if (f.constraints.empty()) return true; + + SketchConstraints sc; + for (const Vec2d& p : f.profile.points) + sc.add_point(p.x(), p.y()); + + for (const SketchConstraintDef& c : f.constraints) { + switch (c.type) { + case SketchConstraintType::Fix: sc.fix_point(c.a); break; + case SketchConstraintType::Coincident: sc.coincident(c.a, c.b); break; + case SketchConstraintType::Horizontal: sc.horizontal(c.a, c.b); break; + case SketchConstraintType::Vertical: sc.vertical(c.a, c.b); break; + case SketchConstraintType::Distance: sc.distance(c.a, c.b, c.value); break; + case SketchConstraintType::LockX: sc.lock_x(c.a, c.value); break; + case SketchConstraintType::LockY: sc.lock_y(c.a, c.value); break; + case SketchConstraintType::EqualLength: sc.equal_length(c.a, c.b, c.c, c.d); break; + case SketchConstraintType::Parallel: sc.parallel(c.a, c.b, c.c, c.d); break; + case SketchConstraintType::Perpendicular:sc.perpendicular(c.a, c.b, c.c, c.d); break; + } + } + + const bool ok = sc.solve(); + for (size_t i = 0; i < f.profile.points.size(); ++i) + f.profile.points[i] = sc.get_point(int(i)); + return ok; +} + +int CadDocument::add_extrude(int sketch_ref, double distance, bool symmetric, + BooleanMode mode, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Extrude; + f.name = name; + f.sketch_ref = sketch_ref; + f.distance = distance; + f.symmetric = symmetric; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_extrude_entities(const std::vector& entities, + const SketchPlane& plane, double distance, + bool symmetric, BooleanMode mode, const std::string& name) +{ + // Self-contained extrude of a single loop: the entity subset lives on the feature + // itself (sketch_ref = -1), so build_sketch_wire(f) uses f.entities directly. The + // source sketch stays a separate feature, so its other loops remain selectable. + CadFeature f; + f.type = CadFeatureType::Extrude; + f.name = name; + f.sketch_ref = -1; + f.entities = entities; + f.plane = plane; + f.distance = distance; + f.symmetric = symmetric; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_extrude_face(int src_face, double distance, bool symmetric, + BooleanMode mode, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Extrude; + f.name = name; + f.sketch_ref = -1; + f.extrude_src_face = src_face; + f.distance = distance; + f.symmetric = symmetric; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_fillet(double radius, FaceGroup faces, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Fillet; + f.name = name; + f.dressup_size = radius; + f.face_group = faces; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_fillet(double radius, int edge_id, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Fillet; + f.name = name; + f.dressup_size = radius; + f.dressup_edge = edge_id; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_chamfer(double distance, FaceGroup faces, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Chamfer; + f.name = name; + f.dressup_size = distance; + f.face_group = faces; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_chamfer(double distance, int edge_id, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Chamfer; + f.name = name; + f.dressup_size = distance; + f.dressup_edge = edge_id; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_hole(double diameter, double depth, bool through, + double x, double y, const SketchPlane& plane, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Hole; + f.name = name; + f.plane = plane; + f.hole_diameter = diameter; + f.hole_depth = depth; + f.hole_through = through; + f.hole_x = x; + f.hole_y = y; + features.push_back(f); + return int(features.size()) - 1; +} + +// Hole standards lookup table (representative ISO 273 medium / ISO 4762 / ANSI unified). +struct HoleStdEntry { const char* desig; double clearance; double cbore_d; double cbore_depth; double csink_d; }; +static const HoleStdEntry kHoleStdTable[] = { + {"M3", 3.4, 6.0, 3.4, 6.3}, + {"M4", 4.5, 8.0, 4.4, 8.4}, + {"M5", 5.5, 10.0, 5.4, 10.4}, + {"M6", 6.6, 11.0, 6.8, 12.6}, + {"M8", 9.0, 15.0, 8.8, 17.3}, + {"M10", 11.0, 18.0, 11.0, 20.0}, + {"#6-32", 3.7, 8.8, 4.2, 8.7}, + {"#8-32", 4.4, 9.9, 5.1, 10.2}, + {"1/4-20", 6.9, 14.4, 7.2, 14.7}, + {"5/16-18", 8.8, 17.0, 8.2, 17.3}, + {"3/8-16", 10.5, 19.6, 9.5, 19.8}, +}; +static bool hole_std_lookup(const std::string& desig, double& clearance, + double& cbore_d, double& cbore_depth, double& csink_d) +{ + for (const auto& e : kHoleStdTable) { + if (e.desig == desig) { + clearance = e.clearance; + cbore_d = e.cbore_d; + cbore_depth = e.cbore_depth; + csink_d = e.csink_d; + return true; + } + } + return false; +} + +int CadDocument::add_hole_styled(double diameter, double depth, bool through, + double x, double y, const SketchPlane& plane, int style, + double cbore_diameter, double cbore_depth, + double csink_diameter, double csink_angle, + const std::string& standard, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Hole; + f.name = name; + f.plane = plane; + f.hole_diameter = diameter; + f.hole_depth = depth; + f.hole_through = through; + f.hole_x = x; + f.hole_y = y; + f.hole_style = style; + f.hole_cbore_diameter = cbore_diameter; + f.hole_cbore_depth = cbore_depth; + f.hole_csink_diameter = csink_diameter; + f.hole_csink_angle = csink_angle; + f.hole_standard = standard; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_hole_standard(const std::string& designation, int style, bool through, + double depth, double x, double y, + const SketchPlane& plane, const std::string& name) +{ + double clearance, cbore_d, cbore_depth, csink_d; + if (!hole_std_lookup(designation, clearance, cbore_d, cbore_depth, csink_d)) + throw std::runtime_error("unknown hole standard \"" + designation + "\""); + return add_hole_styled(clearance, depth, through, x, y, plane, style, + cbore_d, cbore_depth, csink_d, 90, designation, name); +} + +int CadDocument::add_thread(double radius, double pitch, double height, double depth, + bool internal, double x, double y, const SketchPlane& plane, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Thread; + f.name = name; + f.plane = plane; + f.thread_radius = radius; + f.thread_pitch = pitch; + f.thread_height = height; + f.thread_depth = depth; + f.thread_internal = internal; + f.thread_x = x; + f.thread_y = y; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_revolve(int sketch_ref, double angle, int axis, bool flip, + BooleanMode mode, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Revolve; + f.name = name; + f.sketch_ref = sketch_ref; + f.revolve_angle = angle; + f.revolve_axis = axis; + f.flip = flip; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_revolve_entities(const std::vector& entities, + const SketchPlane& plane, double angle, int axis, + bool flip, BooleanMode mode, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Revolve; + f.name = name; + f.sketch_ref = -1; + f.entities = entities; + f.plane = plane; + f.revolve_angle = angle; + f.revolve_axis = axis; + f.flip = flip; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_sweep(int profile_sketch_ref, int path_sketch_ref, BooleanMode mode, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Sweep; + f.name = name; + f.sketch_ref = profile_sketch_ref; + f.sweep_path_ref = path_sketch_ref; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_loft(const std::vector& profile_refs, bool ruled, BooleanMode mode, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Loft; + f.name = name; + f.loft_profile_refs = profile_refs; + f.loft_ruled = ruled; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_pattern(bool circular, int count, double spacing, int dir, + double angle_deg, int target_body, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Pattern; + f.name = name; + f.pattern_circular = circular; + f.pattern_count = count; + f.pattern_spacing = spacing; + f.pattern_dir = dir; + f.pattern_angle = angle_deg; + f.target_body = target_body; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_pattern_on_curve(int count, int curve_sketch, int curve_entity, + int target, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Pattern; + f.name = name; + f.pattern_count = count; + f.pattern_curve_sketch = curve_sketch; + f.pattern_curve_entity = curve_entity; + f.target_body = target; + f.pattern_circular = false; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_shell(double thickness, int face, int target_body, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Shell; + f.name = name; + f.shell_thickness = thickness; + f.shell_face = face; + f.target_body = target_body; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_rib(int sketch_ref, int entity, double thickness, double depth, + int target_body, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Rib; + f.name = name; + f.rib_sketch_ref = sketch_ref; + f.rib_entity = entity; + f.rib_thickness = thickness; + f.rib_depth = depth; + f.target_body = target_body; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_delete_face(int target_body, const std::vector& faces, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::DeleteFace; + f.name = name; + f.target_body = target_body; + f.delete_faces = faces; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_draft(double angle, int face, int target_body, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Draft; + f.name = name; + f.draft_angle = angle; + f.draft_face = face; + f.target_body = target_body; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_boolean(BooleanMode op, int target_body, int tool_body, bool keep_tool, + double tolerance, int target_face, int tool_face, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Boolean; + f.name = name; + f.mode = op; + f.target_body = target_body; + f.bool_tool_body = tool_body; + f.bool_keep_tool = keep_tool; + f.bool_tolerance = tolerance; + f.bool_target_face = target_face; + f.bool_tool_face = tool_face; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_cut(const SketchPlane& plane, double offset, bool flip, + bool keep_upper, bool keep_lower, int target_body, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Cut; + f.name = name; + f.plane = plane; + f.cut_offset = offset; + f.cut_flip = flip; + f.cut_keep_upper = keep_upper; + f.cut_keep_lower = keep_lower; + f.target_body = target_body; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_split_by_face(int target_body, int face_body, int face, + bool keep_upper, bool keep_lower, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Cut; + f.name = name; + f.target_body = target_body; + f.cut_face_body = face_body; + f.cut_face = face; + f.cut_keep_upper = keep_upper; + f.cut_keep_lower = keep_lower; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_mirror(const SketchPlane& plane, int target_body, BooleanMode mode, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Mirror; + f.name = name; + f.plane = plane; + f.target_body = target_body; + f.mode = mode; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_transform(int target_body, const Vec3d& translate, const Vec3d& axis, + const Vec3d& pivot, double angle_deg, bool copy, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Transform; + f.name = name; + f.target_body = target_body; + f.xf_translate = translate; + f.xf_axis = axis; + f.xf_pivot = pivot; + f.xf_angle_deg = angle_deg; + f.xf_copy = copy; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_thicken(int target_body, int face, double thickness, bool flip, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Thicken; + f.name = name; + f.target_body = target_body; + f.thicken_face = face; + f.thicken_thickness = thickness; + f.thicken_flip = flip; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_thicken_surface(int target_body, double thickness, bool flip, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::ThickenSurface; + f.name = name; + f.target_body = target_body; + f.thicken_thickness = thickness; + f.thicken_flip = flip; + f.mode = BooleanMode::New; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_surface_offset(int target_body, double offset, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::SurfaceOffset; + f.name = name; + f.target_body = target_body; + f.plane_offset = offset; + f.mode = BooleanMode::New; + features.push_back(f); + return int(features.size()) - 1; +} + +static void project_edges_to_entities(const std::vector& edges, + const SketchPlane& plane, + bool construction, + std::vector& out); + +int CadDocument::add_project_edges(int source_body, const std::vector& edge_ids, int face, + const SketchPlane& plane, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Project; + f.name = name; + f.project_source_body = source_body; + f.project_edges = edge_ids; + f.project_face = face; + f.plane = plane; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::project_edges_into_sketch(int sketch_feature, int source_body, + const std::vector& edge_ids, int face) +{ + if (sketch_feature < 0 || sketch_feature >= int(features.size())) return -1; + CadFeature& sk = features[sketch_feature]; + if (sk.type != CadFeatureType::Sketch && sk.type != CadFeatureType::Project) return -1; + if (source_body < 0 || source_body >= int(bodies.size()) + || bodies[source_body].shape.IsNull()) return -1; + const TopoDS_Shape& shape = bodies[source_body].shape; + + std::vector edges; + if (!edge_ids.empty()) { + for (int id : edge_ids) { + TopoDS_Edge e = GeometryEngine::edge_by_index(shape, id); + if (e.IsNull()) return -1; + edges.push_back(e); + } + } else if (face >= 0) { + TopoDS_Face fc = GeometryEngine::face_by_index(shape, face); + if (fc.IsNull()) return -1; + edges = GeometryEngine::edges_of_face(fc); + } else { + edges = GeometryEngine::edges_of(shape); + } + if (edges.empty()) return -1; + + const size_t before = sk.entities.size(); + project_edges_to_entities(edges, sk.plane, /*construction=*/true, sk.entities); + return int(sk.entities.size() - before); +} + +int CadDocument::add_bridge(int sketch_ref, int ent_a, int end_a, int ent_b, int end_b, + const std::string& name) +{ + (void)name; + if (sketch_ref < 0 || sketch_ref >= int(features.size()) + || features[sketch_ref].type != CadFeatureType::Sketch) + throw std::runtime_error("bridge: sketch_ref must refer to a Sketch feature"); + auto& ents = features[sketch_ref].entities; + if (ent_a < 0 || ent_a >= int(ents.size()) || ent_b < 0 || ent_b >= int(ents.size())) + throw std::runtime_error("bridge: entity index out of range in sketch"); + SketchEntity br = SketchEngine::make_bridge(ents[ent_a], end_a, ents[ent_b], end_b); + ents.push_back(br); + return int(ents.size()) - 1; +} + +int CadDocument::add_plane(int base, double offset, double angle_tilt, int axis, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Plane; + f.name = name; + f.plane_base = base; + f.plane_offset = offset; + f.plane_angle_tilt = angle_tilt; + f.plane_axis = axis; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_axis(AxisType axis_type_, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Axis; + f.name = name; + f.axis_type = axis_type_; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_coordsys(CoordSysType type, const Vec3d& point, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::CoordSys; + f.name = name; + f.coordsys_type = type; + f.coordsys_point = point; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_mate(int kind, int cs_a, int cs_b, double offset, double angle_deg, bool flip, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Mate; + f.name = name; + f.mate_kind = kind; + f.mate_cs_a = cs_a; + f.mate_cs_b = cs_b; + f.mate_offset = offset; + f.mate_angle = angle_deg; + f.mate_flip = flip; + features.push_back(f); + return int(features.size()) - 1; +} + +std::vector CadDocument::mate_options(int cs_a, int cs_b) const +{ + std::vector out(5); + for (int k = 0; k < 5; ++k) out[k].kind = k; + + auto is_connector = [&](int idx) -> bool { + return idx >= 0 && idx < int(features.size()) && + features[idx].type == CadFeatureType::CoordSys && + features[idx].enabled; + }; + if (!is_connector(cs_a) || !is_connector(cs_b)) { + const char side = is_connector(cs_a) ? 'B' : 'A'; + for (auto& o : out) { o.viable = false; o.reason = std::string("connector ") + side + " is not a coordinate system"; } + return out; + } + if (cs_a == cs_b) { + for (auto& o : out) { o.viable = false; o.reason = "a mate needs two different connectors"; } + return out; + } + + // B is the connector on the body that MOVES, so B must belong to one; A is the fixed + // reference and needs no body. Without this every kind was offered on a Point(world) + // connector and the mate only failed at RECOMPUTE, with "mate_cs_b has no associated body" — + // one step too late, after the feature already existed in the tree. The same two conditions + // the apply path throws on are checked here, so the offer and the kernel cannot disagree. + const int body_b = features[cs_b].coordsys_body; + if (body_b < 0) { + for (auto& o : out) { + o.viable = false; + o.reason = "connector B is not attached to a body — a mate moves B's body"; + } + return out; + } + if (body_b >= int(bodies.size()) || bodies[body_b].shape.IsNull()) { + for (auto& o : out) { + o.viable = false; + o.reason = "connector B's body no longer exists"; + } + return out; + } + + const int ka = features[cs_a].coordsys_face_kind; + const int kb = features[cs_b].coordsys_face_kind; + auto face_desc = [](int kind) -> std::string { return kind == GeomAbs_Plane ? "a flat face" : "a curved face"; }; + + // Planar: needs a flat face at both ends. + const bool plan_bad_a = ka >= 0 && ka != GeomAbs_Plane; + const bool plan_bad_b = kb >= 0 && kb != GeomAbs_Plane; + if (plan_bad_a || plan_bad_b) { + out[1].viable = false; + if (plan_bad_a && plan_bad_b) + out[1].reason = "needs a flat face at both ends — both connectors are on curved faces"; + else if (plan_bad_a) + out[1].reason = "needs a flat face at both ends — connector A is on " + face_desc(ka); + else + out[1].reason = "needs a flat face at both ends — connector B is on " + face_desc(kb); + } + + // Revolute and Cylindrical: need a cylindrical face at both ends. + for (int k : {2, 4}) { + const bool ax_bad_a = ka >= 0 && ka != GeomAbs_Cylinder; + const bool ax_bad_b = kb >= 0 && kb != GeomAbs_Cylinder; + if (!ax_bad_a && !ax_bad_b) continue; + out[k].viable = false; + if (ax_bad_a && ax_bad_b) + out[k].reason = (ka == GeomAbs_Plane && kb == GeomAbs_Plane) + ? "needs a cylindrical face at both ends — both connectors are on flat faces" + : "needs a cylindrical face at both ends — both connectors are on non-cylindrical faces"; + else if (ax_bad_a) + out[k].reason = "needs a cylindrical face at both ends — connector A is on " + face_desc(ka); + else + out[k].reason = "needs a cylindrical face at both ends — connector B is on " + face_desc(kb); + } + + return out; +} + +int CadDocument::add_helix(const SketchPlane& plane, double radius, double pitch, double height, + bool left_handed, double taper_deg, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Helix; + f.name = name; + f.plane = plane; + f.helix_radius = radius; + f.helix_pitch = pitch; + f.helix_height = height; + f.helix_left_handed = left_handed; + f.helix_taper_deg = taper_deg; + features.push_back(f); + return int(features.size()) - 1; +} + +TopoDS_Wire CadDocument::build_helix_wire(const CadFeature& f, std::string& err) const +{ + return make_helix_spine(f, err); +} + +// Derive a SketchPlane: shift `base` along its normal by `offset`, then tilt +// `angle_deg` about the base's X (axis 0) or Y (axis 1) axis (Rodrigues rotation). +static SketchPlane offset_angle_plane(const SketchPlane& base, double offset, + double angle_deg, int axis) +{ + SketchPlane p; + p.origin = base.origin + base.normal * offset; + Vec3d n = base.normal, x = base.x_axis, y = base.y_axis; + if (std::abs(angle_deg) > 1e-9) { + const double a = angle_deg * M_PI / 180.0; + const Vec3d k = (axis == 1) ? base.y_axis : base.x_axis; // unit rotation axis + auto rot = [&](const Vec3d& v) -> Vec3d { // -> Vec3d forces eval (no dangling Eigen expr) + return v * std::cos(a) + k.cross(v) * std::sin(a) + + k * (k.dot(v)) * (1.0 - std::cos(a)); + }; + n = rot(n); + if (axis == 1) x = rot(x); // tilt about Y rotates X + normal, Y fixed + else y = rot(y); // tilt about X rotates Y + normal, X fixed + } + p.normal = n.normalized(); + p.x_axis = x.normalized(); + p.y_axis = y.normalized(); + return p; +} + +// Build a full orthonormal frame from a normal + origin. +static SketchPlane frame_from(const Vec3d& origin, const Vec3d& normal) +{ + Vec3d n = normal.normalized(); + Vec3d ref = (std::abs(n.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + Vec3d x = ref.cross(n); + if (x.squaredNorm() < 1e-12) x = Vec3d(1, 0, 0); + x.normalize(); + Vec3d y = n.cross(x).normalized(); + SketchPlane p; + p.origin = origin; + p.normal = n; + p.x_axis = x; + p.y_axis = y; + return p; +} + +bool CadDocument::plane_of_face(int body_idx, int face_idx, SketchPlane& out) const +{ + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) return false; + const TopoDS_Face f = GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + if (f.IsNull()) return false; + // Planar only. face_normal_world evaluates the normal at the mid parameter, which on a cylinder + // or a fillet is a tangent plane at one arbitrary point — usable for a datum offset, wrong as a + // sketch plane. Refuse rather than sketch somewhere the user did not point at. + BRepAdaptor_Surface surf(f); + if (surf.GetType() != GeomAbs_Plane) return false; + out = frame_from(GeometryEngine::face_centroid_world(f), GeometryEngine::face_normal_world(f)); + return true; +} + +std::vector> CadDocument::resolve_datum_planes() const +{ + std::vector> out; + for (const CadFeature& f : features) { + if (f.type != CadFeatureType::Plane || !f.enabled) continue; + + // Resolve base reference plane. The default XY/XZ/YZ planes pass through the modeling + // origin (bed centre); datum bases (>=3) are already in world coords from earlier passes. + SketchPlane base; + if (f.plane_base == 1) { base = SketchPlane::XZ(); base.origin += modeling_origin; } + else if (f.plane_base == 2) { base = SketchPlane::YZ(); base.origin += modeling_origin; } + else if (f.plane_base >= 3) { + const int di = f.plane_base - 3; + if (di < int(out.size())) base = out[di].second; + } + else { base = SketchPlane::XY(); base.origin += modeling_origin; } + + // --- Resolve refs from bodies --- + auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face { + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return TopoDS_Face(); + return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + }; + auto resolve_edge = [&](int body_idx, int edge_idx, + Vec3d& p0, Vec3d& dir) -> bool { + if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return false; + TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx); + if (e.IsNull()) return false; + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) return false; + p0 = pts.front(); + dir = (pts.back() - pts.front()).normalized(); + return true; + }; + + // Face A + TopoDS_Face faceA = resolve_face(f.plane_face_body, f.plane_face); + SketchPlane faceA_plane; + bool has_faceA = false; + if (!faceA.IsNull()) { + faceA_plane = frame_from( + GeometryEngine::face_centroid_world(faceA), + GeometryEngine::face_normal_world(faceA)); + has_faceA = true; + } + + // Face B + TopoDS_Face faceB = resolve_face(f.plane_face2_body, f.plane_face2); + SketchPlane faceB_plane; + bool has_faceB = false; + if (!faceB.IsNull()) { + faceB_plane = frame_from( + GeometryEngine::face_centroid_world(faceB), + GeometryEngine::face_normal_world(faceB)); + has_faceB = true; + } + + // Edge A + Vec3d eA_p0, eA_dir; + bool has_edgeA = resolve_edge(f.plane_edge_body, f.plane_edge, eA_p0, eA_dir); + + // Edge B + Vec3d eB_p0, eB_dir; + bool has_edgeB = resolve_edge(f.plane_edge2_body, f.plane_edge2, eB_p0, eB_dir); + + // --- Dispatch on plane_type --- + auto fallback_offset = [&]() { + return offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis); + }; + + SketchPlane result; + switch (f.plane_type) { + + case PlaneType::Offset: { + // From a picked face: pure offset along its normal. From a base/datum plane: + // offset + the legacy tilt-about-axis (keeps the old Offset/Tilt controls live). + if (has_faceA) + result = frame_from(faceA_plane.origin + faceA_plane.normal * f.plane_offset, + faceA_plane.normal); + else + result = offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis); + break; + } + + case PlaneType::Coincident: { + result = has_faceA ? faceA_plane : base; + break; + } + + case PlaneType::Angle: { + if (!has_edgeA) { result = fallback_offset(); break; } + const SketchPlane& ref = has_faceA ? faceA_plane : base; + Vec3d n0 = ref.normal - eA_dir * ref.normal.dot(eA_dir); + if (n0.squaredNorm() < 1e-12) { + Vec3d perp = (std::abs(eA_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + n0 = perp - eA_dir * perp.dot(eA_dir); + } + n0.normalize(); + const double a = f.plane_angle_tilt * M_PI / 180.0; + Vec3d n_rot = n0 * std::cos(a) + eA_dir.cross(n0) * std::sin(a) + + eA_dir * (eA_dir.dot(n0)) * (1.0 - std::cos(a)); + result = frame_from(eA_p0, n_rot); + break; + } + + case PlaneType::Midplane: { + if (!has_faceA || !has_faceB) { result = fallback_offset(); break; } + Vec3d origin = 0.5 * (faceA_plane.origin + faceB_plane.origin); + Vec3d nB = (faceA_plane.normal.dot(faceB_plane.normal) >= 0) + ? faceB_plane.normal : -faceB_plane.normal; + Vec3d normal = (faceA_plane.normal + nB).normalized(); + result = frame_from(origin, normal); + break; + } + + case PlaneType::Tangent: { + if (!has_faceA) { result = fallback_offset(); break; } + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(faceA); + if (!cyl.ok) { result = fallback_offset(); break; } + Vec3d refdir = (std::abs(cyl.axis.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + refdir = refdir - cyl.axis * refdir.dot(cyl.axis); + refdir.normalize(); + const double theta = f.plane_angle_tilt * M_PI / 180.0; + Vec3d r = refdir * std::cos(theta) + cyl.axis.cross(refdir) * std::sin(theta); + Vec3d touch = cyl.base + r * cyl.radius; + result = frame_from(touch, r); + break; + } + + case PlaneType::TwoEdges: { + if (!has_edgeA) { result = fallback_offset(); break; } + if (!has_edgeB) { result = fallback_offset(); break; } + Vec3d cross = eA_dir.cross(eB_dir); + if (cross.squaredNorm() > 1e-12) { + result = frame_from(eA_p0, cross.normalized()); + } else { + Vec3d v = eA_dir.cross(eB_p0 - eA_p0); + if (v.squaredNorm() > 1e-12) { + result = frame_from(eA_p0, v.normalized()); + } else { + result = fallback_offset(); + } + } + break; + } + + } + + out.emplace_back(f.name, result); + } + return out; +} + +// Resolved datum axes in feature order. Origin + unit direction computed from +// construction params; axis_err is non-empty when construction fails (no crash). +std::vector CadDocument::resolve_datum_axes() const +{ + std::vector out; + // For PlaneIntersection we need the already-resolved datum planes. + std::vector> datum_planes = resolve_datum_planes(); + + auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face { + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return TopoDS_Face(); + return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + }; + auto resolve_edge = [&](int body_idx, int edge_idx, + Vec3d& p0, Vec3d& dir) -> bool { + if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return false; + TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx); + if (e.IsNull()) return false; + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) return false; + p0 = pts.front(); + dir = (pts.back() - pts.front()).normalized(); + return true; + }; + + for (const CadFeature& f : features) { + if (f.type != CadFeatureType::Axis || !f.enabled) continue; + + DatumAxis da; + da.name = f.name; + switch (f.axis_type) { + case AxisType::TwoPoints: { + Vec3d dir = f.axis_p2 - f.axis_p1; + if (dir.squaredNorm() < 1e-18) { da.error = "two points are coincident"; break; } + da.origin = f.axis_p1; + da.direction = dir.normalized(); + break; + } + case AxisType::FaceNormal: { + TopoDS_Face fc = resolve_face(f.axis_body, f.axis_face); + if (fc.IsNull()) { da.error = "face not found"; break; } + da.origin = GeometryEngine::face_centroid_world(fc); + da.direction = GeometryEngine::face_normal_world(fc); + break; + } + case AxisType::CylinderCenterline: { + TopoDS_Face fc = resolve_face(f.axis_body, f.axis_face); + if (fc.IsNull()) { da.error = "face not found"; break; } + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(fc); + if (!cyl.ok) { da.error = "face is not a cylinder"; break; } + da.origin = cyl.base; + da.direction = cyl.axis; + break; + } + case AxisType::AlongEdge: { + Vec3d p0, dir; + if (!resolve_edge(f.axis_body, f.axis_edge, p0, dir)) { + da.error = "edge not found"; break; + } + da.origin = p0; + da.direction = dir; + break; + } + case AxisType::PlaneIntersection: { + // Same reference encoding as CadFeature::plane_base, because the GUI fills these + // two fields from populate_plane_choices() — whose rows are XY/XZ/YZ followed by + // the datum planes — and stores the row verbatim. Indexing datum_planes[ref] + // directly, as this did, is off by three: picking XY resolved to datum plane 0 and + // picking the first datum ran off the end with "plane ref not found", so the + // PlaneIntersection axis type could not work at all. Two base planes are also a + // perfectly ordinary way to define an axis (XY x XZ = the X axis), so they must + // resolve rather than be rejected as out of scope. + auto base_plane = [&](int ref, Vec3d& origin, Vec3d& normal) -> bool { + SketchPlane p; + if (ref == 0) { p = SketchPlane::XY(); p.origin += modeling_origin; } + else if (ref == 1) { p = SketchPlane::XZ(); p.origin += modeling_origin; } + else if (ref == 2) { p = SketchPlane::YZ(); p.origin += modeling_origin; } + else if (ref >= 3 && ref - 3 < int(datum_planes.size())) + p = datum_planes[ref - 3].second; // datums are already world-space + else + return false; + origin = p.origin; + normal = p.normal; + return true; + }; + bool ok0 = base_plane(f.axis_plane_a, da.origin, da.direction); // direction reused as normal0 + Vec3d origin1, normal1; + bool ok1 = base_plane(f.axis_plane_b, origin1, normal1); + if (!ok0 || !ok1) { da.error = "plane ref not found"; break; } + // Direction = cross product of the two plane normals. + Vec3d dir = da.direction.cross(normal1); // da.direction was normal0 + if (dir.squaredNorm() < 1e-18) { + da.error = "planes are parallel (no intersection)"; break; + } + dir.normalize(); + // Find a point on the intersection line: closest points between two planes. + // Project origin of plane A onto the intersection line. + Vec3d n0 = da.direction; // normal of plane A (stored temporarily) + Vec3d n1 = normal1; + Vec3d p0 = da.origin; + Vec3d p1 = origin1; + // Solve for point on line of intersection using vector formula. + double d0 = n0.dot(p0); + double d1 = n1.dot(p1); + double n0n1 = n0.dot(n1); + double det = 1.0 - n0n1 * n0n1; + if (std::abs(det) < 1e-18) { da.error = "planes are parallel (no intersection)"; break; } + double t0 = (d0 - d1 * n0n1) / det; + double t1 = (d1 - d0 * n0n1) / det; + da.origin = n0 * t0 + n1 * t1; + da.direction = dir; + break; + } + } + out.push_back(da); + } + return out; +} + +CadDocument::DatumCoordSys CadDocument::datum_frame(const std::vector& bodies, const CadFeature& f) +{ + CadDocument::DatumCoordSys ds; + ds.name = f.name; + + auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face { + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return TopoDS_Face(); + return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + }; + auto resolve_edge = [&](int body_idx, int edge_idx, + Vec3d& p0, Vec3d& dir) -> bool { + if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return false; + TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx); + if (e.IsNull()) return false; + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) return false; + p0 = pts.front(); + dir = (pts.back() - pts.front()).normalized(); + return true; + }; + + switch (f.coordsys_type) { + case CoordSysType::PointWorld: { + ds.origin = f.coordsys_point; + ds.x = Vec3d(1, 0, 0); + ds.y = Vec3d(0, 1, 0); + break; + } + case CoordSysType::FaceAndDirection: { + TopoDS_Face fc = resolve_face(f.coordsys_body, f.coordsys_face); + Vec3d p0, edge_dir; + bool have_edge = resolve_edge(f.coordsys_body, f.coordsys_edge, p0, edge_dir); + if (fc.IsNull()) { ds.error = "face not found"; break; } + ds.origin = GeometryEngine::face_centroid_world(fc); + Vec3d Z = GeometryEngine::face_normal_world(fc); + // Tentative X: an explicit edge reference wins. Failing that, derive X from the + // face's OWN first usable edge, so the frame rotates with the body. Reading it from + // coordsys_x_hint — a world constant — meant a face-only connector could not encode + // spin about its own normal: Z followed the body, X did not, so Fastened and Slider + // claimed to fix an orientation the frame could not see. The hint survives only as a + // last resort, for faces that offer no usable direction (a full circular edge has + // coincident endpoints, and a cylinder's seam projects to nothing in-plane). + Vec3d X_tent = Vec3d::Zero(); + if (have_edge) { + X_tent = edge_dir; + } else { + for (const TopoDS_Edge& fe : GeometryEngine::edges_of_face(fc)) { + auto pts = GeometryEngine::sample_edge_world(fe); + if (pts.size() < 2) continue; + Vec3d d = pts.back() - pts.front(); + if (d.squaredNorm() < 1e-18) continue; // closed edge: endpoints coincide + Vec3d in_plane = d - Z * Z.dot(d); // drop any out-of-plane component + if (in_plane.squaredNorm() < 1e-18) continue; + X_tent = in_plane; + break; + } + if (X_tent.squaredNorm() < 1e-18) X_tent = f.coordsys_x_hint; + } + if (X_tent.squaredNorm() < 1e-18) { ds.error = "zero-length direction"; break; } + X_tent.normalize(); + // Gram-Schmidt: ensure orthonormal, right-handed frame. + // Y = Z x X_tent, X = Y x Z (this makes X perpendicular to Z, not X_tent) + Vec3d Y = Z.cross(X_tent); + if (Y.squaredNorm() < 1e-12) { + // Edge/hint is parallel to Z -> X is degenerate; fall back to world X/Y orthonormalised. + Vec3d ref = (std::abs(Z.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + Y = Z.cross(ref); + if (Y.squaredNorm() < 1e-12) Y = Z.cross(Vec3d(0, 1, 0)); + } + Y.normalize(); + ds.x = Y.cross(Z).normalized(); + ds.y = Y; + break; + } + } + return ds; +} + +std::vector CadDocument::resolve_datum_coordsys() const +{ + std::vector out; + for (const CadFeature& f : features) { + if (f.type != CadFeatureType::CoordSys || !f.enabled) continue; + out.push_back(datum_frame(bodies, f)); + } + return out; +} + +void CadDocument::clear() +{ + features.clear(); + body = TopoDS_Shape(); + bodies.clear(); // multibody result — must clear too (else solids linger) + display_mesh = TriangleMesh{}; + display_body_meshes.clear(); + display_tri_face.clear(); + error.clear(); + mate_conflicts.clear(); + // A cleared document is a fresh start with no history. + m_undo.clear(); + m_redo.clear(); +} + +void CadDocument::checkpoint() +{ + m_undo.push_back(Snapshot{features, variables}); // snapshot the pre-mutation recipe + m_redo.clear(); // any new action invalidates the redo branch + if (m_undo.size() > k_undo_cap) + m_undo.erase(m_undo.begin()); +} + +void CadDocument::abandon_checkpoint() +{ + if (!m_undo.empty()) + m_undo.pop_back(); +} + +bool CadDocument::undo() +{ + if (m_undo.empty()) + return false; + m_redo.push_back(Snapshot{std::move(features), std::move(variables)}); // current state becomes redoable + features = std::move(m_undo.back().features); + variables = std::move(m_undo.back().variables); + m_undo.pop_back(); + recompute(); // benign-empty (only a sketch / empty doc) is a valid undo target + return true; +} + +bool CadDocument::redo() +{ + if (m_redo.empty()) + return false; + m_undo.push_back(Snapshot{std::move(features), std::move(variables)}); + features = std::move(m_redo.back().features); + variables = std::move(m_redo.back().variables); + m_redo.pop_back(); + recompute(); + return true; +} + +// Re-run recompute(); if it fails for a GENUINE geometry error, restore `snapshot` +// and recompute that instead, so a rejected edit leaves the document exactly as it +// was. recompute() also returns false for the BENIGN case where the edit simply +// leaves no solid-producing feature (empty document, or only a sketch) — that is a +// valid result of a deletion, not a failure, so we accept it with an empty body. +static bool commit_or_rollback(CadDocument& doc, std::vector& snapshot) +{ + if (doc.recompute()) + return true; + + bool has_solid_feature = false; + for (const auto& f : doc.features) + if (f.enabled && f.type != CadFeatureType::Sketch) { has_solid_feature = true; break; } + if (!has_solid_feature) { + doc.bodies.clear(); + doc.body = TopoDS_Shape(); + doc.display_mesh = TriangleMesh{}; + doc.display_body_meshes.clear(); + doc.display_tri_face.clear(); + doc.display_tri_body.clear(); + doc.error.clear(); + return true; + } + + std::string fail_err = doc.error; // why the attempted edit failed + doc.features.swap(snapshot); + doc.recompute(); // restore the previous good body (clears error) + doc.error = fail_err.empty() ? std::string("feature is used by a later feature") + : fail_err; + return false; +} + +// Visit every field of `f` that holds an index into features[]. +// +// Deliberately NOT dispatched on f.type. A type switch is what this replaced, and it was +// wrong in the way type switches go wrong: it listed Extrude and Mate, and every feature +// type added afterwards — Revolve, Sweep, Loft, Rib, Pattern-on-curve, the Surface* family — +// silently inherited a remap that skipped its references. Visiting the FIELDS instead means +// a new type is covered the moment it reuses one of them, and reaching a field its type +// never reads costs nothing: unset refs are -1 and every visitor here ignores those. +// +// The list is exactly the fields documented as "index into features[]" / "feature index" in +// CadDocument.hpp. Not included, because they are a different basis and need their own pass: +// plane_base / axis_plane_a / axis_plane_b encode `3 + N` = the Nth DATUM PLANE, an ordinal +// into resolve_datum_planes(), not into features[]. Everything named *_body / *_face / *_edge +// is a body index or a global topology id and must never be remapped here. +template +static void for_each_feature_ref(CadFeature& f, Visit&& visit) +{ + visit(f.sketch_ref); + visit(f.sweep_path_ref); + visit(f.pattern_curve_sketch); + visit(f.rib_sketch_ref); + visit(f.mate_cs_a); + visit(f.mate_cs_b); + for (int& r : f.loft_profile_refs) + visit(r); +} + +bool CadDocument::remove_feature(int index) +{ + if (index < 0 || index >= int(features.size())) + return false; + + std::vector snapshot = features; + + // Deleting a Sketch cascades to every feature that consumes it AS ITS PROFILE — the + // reason is the original one ("a dangling Extrude would have no wire"), and it applies + // unchanged to Revolve, Sweep, Rib and the Surface* family, which all read the profile + // through sketch_ref, plus the sweep spine and the rib line. Testing the FIELD rather + // than the type is what makes that true without a list to keep up to date. + // + // pattern_curve_sketch and loft_profile_refs are deliberately NOT cascaded: a Pattern or + // a Loft that loses one of several inputs is degraded, not meaningless, so those refs go + // to -1 in the remap below and the feature survives. A lone Sketch is harmless either way. + std::vector remove{index}; + if (features[index].type == CadFeatureType::Sketch) { + for (int j = 0; j < int(features.size()); ++j) { + const CadFeature& c = features[j]; + if (c.sketch_ref == index || c.sweep_path_ref == index || c.rib_sketch_ref == index) + remove.push_back(j); + } + } + std::sort(remove.begin(), remove.end()); + remove.erase(std::unique(remove.begin(), remove.end()), remove.end()); + + // Erase high-to-low so earlier indices stay valid. + for (auto it = remove.rbegin(); it != remove.rend(); ++it) + features.erase(features.begin() + *it); + + // Remap surviving feature references through the deletions: subtract the count of + // removed indices that sat before it; orphaned refs (target removed) -> -1. + // + // This must cover EVERY field holding a feature index, not just sketch_ref. A mate's two + // connectors are such indices, and leaving them behind slid them onto whatever features + // landed on those slots — silently, because recompute() only rejects out-of-range and + // non-CoordSys targets, and a shifted index usually lands on the assembly's other CoordSys. + auto remap = [&remove](int& ref) { + if (ref < 0) + return; + if (std::binary_search(remove.begin(), remove.end(), ref)) { + ref = -1; + } else { + int shift = 0; + for (int r : remove) + if (r < ref) ++shift; + ref -= shift; + } + }; + for (auto& f : features) + for_each_feature_ref(f, remap); + + return commit_or_rollback(*this, snapshot); +} + +bool CadDocument::move_feature(int index, int delta) +{ + if (index < 0 || index >= int(features.size())) + return false; + int target = index + delta; + if (target < 0 || target >= int(features.size())) + return true; // clamped at the ends — no-op, not a failure + + std::vector snapshot = features; + std::swap(features[index], features[target]); + + // The two slots traded places: fix every feature reference that pointed at either — + // a mate's connectors as well as sketch_ref, for the reason given in remove_feature(). + auto swap_ref = [index, target](int& ref) { + if (ref == index) ref = target; + else if (ref == target) ref = index; + }; + for (auto& f : features) + for_each_feature_ref(f, swap_ref); + + return commit_or_rollback(*this, snapshot); +} + +bool CadDocument::replace_feature(int index, const CadFeature& edited) +{ + if (index < 0 || index >= int(features.size())) + return false; + + std::vector snapshot = features; + + // Preserve identity (name) and the structural link (sketch_ref) from the + // original; only the user-editable parameters come from `edited`. + CadFeature f = edited; + f.name = features[index].name; + f.type = features[index].type; + if (f.type == CadFeatureType::Extrude) + f.sketch_ref = features[index].sketch_ref; + features[index] = f; + + return commit_or_rollback(*this, snapshot); +} + +bool CadDocument::replace_sketch_extrude(int sketch_idx, int extrude_idx, + const CadFeature& edited) +{ + if (sketch_idx < 0 || sketch_idx >= int(features.size())) return false; + if (extrude_idx < 0 || extrude_idx >= int(features.size())) return false; + + std::vector snapshot = features; + + // A box in the tree is two linked features: the Sketch consumes the profile + // params (shape/plane/width/height/radius), the Extrude consumes the solid + // params (distance/symmetric/mode). `edited` carries all of them; split it + // back into the two slots, preserving each slot's name/type and the link. + CadFeature& sk = features[sketch_idx]; + sk.shape = edited.shape; + sk.plane = edited.plane; + sk.width = edited.width; + sk.height = edited.height; + sk.radius = edited.radius; + + CadFeature& ex = features[extrude_idx]; + ex.distance = edited.distance; + ex.symmetric = edited.symmetric; + ex.mode = edited.mode; + + return commit_or_rollback(*this, snapshot); +} + +// "the sketch is open" is not actionable; WHERE it is open is. Shared by both throws so the +// two ways of reaching an open profile (Revolve via build_sketch_wire, Extrude via +// build_sketch_face) report it identically. +static std::string open_loop_message(const CadFeature& sketch, + const char* head = "sketch entities do not form a closed loop") +{ + std::string msg = head; + const std::vector open = Slic3r::sketch_open_ends(sketch.entities, sketch.plane); + if (!open.empty()) { + const size_t shown = std::min(open.size(), 3); + char buf[96]; + for (size_t i = 0; i < shown; ++i) { + std::snprintf(buf, sizeof buf, "\n Open at (%.3f, %.3f)", open[i].x(), open[i].y()); + msg += buf; + } + if (open.size() > shown) { + std::snprintf(buf, sizeof buf, "\n ...and %zu more open end(s)", open.size() - shown); + msg += buf; + } + } + return msg; +} + +TopoDS_Wire CadDocument::build_sketch_wire(const CadFeature& sketch, bool closed_only) const +{ + if (!sketch.entities.empty()) { + TopoDS_Wire w = SketchEngine::entities_to_wire(sketch.entities, sketch.plane, closed_only); + if (!w.IsNull()) return w; + // An entity sketch that yields no wire is an ERROR, not a cue to fall through. The + // legacy tail of this function ends in a default rectangle built from width/height, + // which for an entity sketch are whatever they happened to be initialised to — so a + // sketch entities_to_wire cannot handle (a circle coexisting with a line, two circles: + // 88v) used to extrude into a box the user never drew, silently. Failing here + // costs the caller an error message; falling through cost them wrong geometry that + // looked deliberate. The legacy profile/shape paths below are still reached by sketches + // that legitimately carry no entities at all. + throw std::runtime_error( + open_loop_message(sketch, + "sketch has entities but they do not form a single closed wire — a closed " + "entity (circle/ellipse) combined with other entities, or several closed " + "entities, is not supported yet")); + } + if (!sketch.profile.points.empty()) { + SketchProfile prof = sketch.profile; + prof.closed = true; // extrude needs a closed wire + TopoDS_Wire w = prof.to_occt_wire(sketch.plane); + if (w.IsNull()) throw std::runtime_error("sketch profile wire failed"); + return w; + } + if (sketch.shape == SketchShape::Circle) { + gp_Pnt o(sketch.plane.origin.x(), sketch.plane.origin.y(), sketch.plane.origin.z()); + gp_Dir n(sketch.plane.normal.x(), sketch.plane.normal.y(), sketch.plane.normal.z()); + gp_Circ circ(gp_Ax2(o, n), sketch.radius); + TopoDS_Edge e = BRepBuilderAPI_MakeEdge(circ).Edge(); + BRepBuilderAPI_MakeWire wm(e); + if (!wm.IsDone()) throw std::runtime_error("circle wire failed"); + return wm.Wire(); + } + // Rectangle centered on the plane origin + SketchProfile prof; + double hw = sketch.width * 0.5, hh = sketch.height * 0.5; + prof.points.push_back(Vec2d(-hw, -hh)); + prof.points.push_back(Vec2d( hw, -hh)); + prof.points.push_back(Vec2d( hw, hh)); + prof.points.push_back(Vec2d(-hw, hh)); + prof.closed = true; + return prof.to_occt_wire(sketch.plane); +} + +TopoDS_Face CadDocument::build_sketch_face(const CadFeature& sketch) const +{ + if (!sketch.entities.empty()) { + const std::vector loops = SketchEngine::entities_to_wires(sketch.entities, sketch.plane, true); + if (loops.empty()) + // Same courtesy as build_sketch_wire: name WHERE the sketch is open. Extrude + // reaches its failure through here, not through build_sketch_wire, so without + // this the most common way to hit an open profile is also the least informative. + throw std::runtime_error(open_loop_message(sketch)); + return SketchEngine::wires_to_face(loops, sketch.plane); + } + return BRepBuilderAPI_MakeFace(build_sketch_wire(sketch, true)).Face(); +} + +void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, + const TopoDS_Shape& context, const CadFeature& f) const +{ + switch (f.type) { + case CadFeatureType::Sketch: + return; // sketches carry no solid; consumed by an extrude + case CadFeatureType::Project: + return; // edges-to-sketch: consumed downstream, no solid body + case CadFeatureType::Helix: + return; // helical curve; consumed by Sweep as a path (like Sketch) + case CadFeatureType::Boolean: + return; // body-body boolean is handled in route_feature/apply_boolean, never here + case CadFeatureType::Import: + // Imported B-rep (STEP): rigid data carried on the feature, not built from + // parameters — adopt it as the new body (New-path: result starts empty). + result = f.imported_solid; + have_body = !result.IsNull(); + return; + case CadFeatureType::Extrude: { + const bool sym = (f.extrude_end == ExtrudeEnd::Symmetric); + const double signed_d = f.flip ? -f.distance : f.distance; + TopoDS_Shape tool; + if (f.extrude_src_face >= 0) { + // The source face is read from `context` (the owner body), which for a New + // face-extrude is the source solid while `result` is the empty new body. + if (context.IsNull()) throw std::runtime_error("face-extrude needs a body"); + TopoDS_Face srcf = GeometryEngine::face_by_index(context, f.extrude_src_face); + if (srcf.IsNull()) throw std::runtime_error("face-extrude: invalid face id"); + SketchPlane fpl = SketchPlane::from_face(srcf); + // from_face takes the surface's geometric normal and IGNORES the topological + // face orientation, so for a REVERSED face (e.g. the top cap of an extruded + // prism) it points INTO the solid -> a default push would fuse to nothing. + // Orient it outward so push/pull grows away from the material (Onshape default); + // the Flip checkbox (signed_d) still lets the user drive it inward for a cut. + if (srcf.Orientation() == TopAbs_REVERSED) fpl.normal = -fpl.normal; + tool = SketchEngine::make_extrude_face(srcf, fpl, signed_d, sym); + } else { + // Use the referenced sketch when sketch_ref is a valid Sketch index, + // otherwise fall back to f's own inline sketch params (this makes a + // single self-contained candidate previewable). + const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) + && (features[f.sketch_ref].type == CadFeatureType::Sketch + || features[f.sketch_ref].type == CadFeatureType::Project)) + ? features[f.sketch_ref] : f; + // Imported rigid art (Text/SVG) extrudes via the faces-with-holes path + // (with its placement transform applied); otherwise build the sketch's planar + // region (outer loop + holes) from entities/profile/shape. + tool = !sk.imported_regions.empty() + ? SketchEngine::make_extrude_regions( + transform_regions(sk.imported_regions, sk.import_offset, + sk.import_scale_x, sk.import_scale_y), + sk.plane, + f.extrude_end == ExtrudeEnd::ThroughAll ? 1e5 : signed_d, + f.extrude_end == ExtrudeEnd::ThroughAll ? true : (sym || f.extrude_end == ExtrudeEnd::TwoSided)) + : [&]() { + const TopoDS_Face profile = build_sketch_face(sk); + // A tapered extrude offsets the profile; a holed profile would have to + // offset its inner loops the opposite way, which is not implemented yet. + auto taper = [&](double L) -> TopoDS_Shape { + // Count the loops off the face we ALREADY built, rather than rebuilding + // every wire from the entities a second time to ask how many there are. + // It is also the more honest test: what matters is whether the profile + // being extruded has holes, not what the entity list could produce. + int nloops = 0; + for (TopExp_Explorer ex(profile, TopAbs_WIRE); ex.More(); ex.Next()) ++nloops; + if (nloops > 1) + throw std::runtime_error("tapered extrude of a sketch with holes is not supported yet"); + return SketchEngine::make_extrude_taper(build_sketch_wire(sk, true), sk.plane, L, f.taper_deg); + }; + TopoDS_Shape t; + switch (f.extrude_end) { + case ExtrudeEnd::Blind: + t = (std::abs(f.taper_deg) > 1e-6) + ? taper(signed_d) + : SketchEngine::make_extrude(profile, sk.plane, signed_d, false); + break; + case ExtrudeEnd::Symmetric: t = SketchEngine::make_extrude(profile, sk.plane, f.distance, true); break; + case ExtrudeEnd::TwoSided: t = SketchEngine::make_extrude_two_sided(profile, sk.plane, f.distance, f.distance2); break; + case ExtrudeEnd::ThroughAll: t = SketchEngine::make_extrude(profile, sk.plane, 1.0e5, true); break; + case ExtrudeEnd::UpToFace: { + const TopoDS_Face tgt = GeometryEngine::face_by_index(context, f.up_to_face); + double L = signed_d; + if (!tgt.IsNull()) { + const Vec3d c = GeometryEngine::face_centroid_world(tgt); + L = (c - sk.plane.origin).dot(sk.plane.normal); + } + t = (std::abs(f.taper_deg) > 1e-6) + ? taper(L) + : SketchEngine::make_extrude(profile, sk.plane, L, false); + break; + } + case ExtrudeEnd::UpToVertex: { + const double L = (f.up_to_point - sk.plane.origin).dot(sk.plane.normal); + t = SketchEngine::make_extrude(profile, sk.plane, L, false); + break; + } + default: t = SketchEngine::make_extrude(profile, sk.plane, signed_d, false); break; + } + return t; + }(); + } + // New / first-of-a-body => result becomes the tool (route_feature sends New extrudes + // here with an empty result, so a face-extrude New builds a fresh body from the source + // face in `context` without touching it). Add/Cut/Intersect boolean into `result`. + if (!have_body || f.mode == BooleanMode::New) { + result = tool; + have_body = true; + } else if (f.mode == BooleanMode::Add) { + BRepAlgoAPI_Fuse fuse(result, tool); + if (!fuse.IsDone()) throw std::runtime_error("fuse failed"); + result = fuse.Shape(); + } else if (f.mode == BooleanMode::Cut) { + BRepAlgoAPI_Cut cut(result, tool); + if (!cut.IsDone()) throw std::runtime_error("cut failed"); + result = cut.Shape(); + } else if (f.mode == BooleanMode::Intersect) { + BRepAlgoAPI_Common common(result, tool); + if (!common.IsDone()) throw std::runtime_error("intersect failed"); + result = common.Shape(); + } + break; + } + case CadFeatureType::Revolve: { + // Resolve the profile sketch like Extrude: referenced Sketch when valid, + // else this feature's own inline entities/profile (self-contained candidate). + const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) + && (features[f.sketch_ref].type == CadFeatureType::Sketch + || features[f.sketch_ref].type == CadFeatureType::Project)) + ? features[f.sketch_ref] : f; + TopoDS_Wire wire = build_sketch_wire(sk, true); + const double ang = f.flip ? -f.revolve_angle : f.revolve_angle; + TopoDS_Shape tool = SketchEngine::make_revolve(wire, sk.plane, ang, f.revolve_axis); + if (!have_body || f.mode == BooleanMode::New) { + result = tool; + have_body = true; + } else if (f.mode == BooleanMode::Add) { + BRepAlgoAPI_Fuse fuse(result, tool); + if (!fuse.IsDone()) throw std::runtime_error("fuse failed"); + result = fuse.Shape(); + } else if (f.mode == BooleanMode::Cut) { + BRepAlgoAPI_Cut cut(result, tool); + if (!cut.IsDone()) throw std::runtime_error("cut failed"); + result = cut.Shape(); + } else if (f.mode == BooleanMode::Intersect) { + BRepAlgoAPI_Common common(result, tool); + if (!common.IsDone()) throw std::runtime_error("intersect failed"); + result = common.Shape(); + } + break; + } + case CadFeatureType::SurfaceExtrude: { + if (f.sketch_ref < 0 || f.sketch_ref >= int(features.size())) + throw std::runtime_error("surface-extrude: bad sketch ref"); + const CadFeature& sk = features[f.sketch_ref]; + if (sk.type != CadFeatureType::Sketch && sk.type != CadFeatureType::Project) + throw std::runtime_error("surface-extrude: ref is not a sketch"); + TopoDS_Wire wire = build_sketch_wire(sk); + if (wire.IsNull()) throw std::runtime_error("surface-extrude: empty profile"); + gp_Dir nrm(sk.plane.normal.x(), sk.plane.normal.y(), sk.plane.normal.z()); + gp_Vec v(nrm.XYZ() * f.distance); + TopoDS_Shape shell = BRepPrimAPI_MakePrism(wire, v, false, true).Shape(); + if (shell.IsNull()) throw std::runtime_error("surface-extrude: prism failed"); + result = shell; have_body = true; + break; + } + case CadFeatureType::SurfaceRevolve: { + if (f.sketch_ref < 0 || f.sketch_ref >= int(features.size())) + throw std::runtime_error("surface-revolve: bad sketch ref"); + const CadFeature& sk = features[f.sketch_ref]; + if (sk.type != CadFeatureType::Sketch && sk.type != CadFeatureType::Project) + throw std::runtime_error("surface-revolve: ref is not a sketch"); + TopoDS_Wire wire = build_sketch_wire(sk); + if (wire.IsNull()) throw std::runtime_error("surface-revolve: empty profile"); + const Vec3d& adir = (f.revolve_axis == 1) ? sk.plane.y_axis : sk.plane.x_axis; + gp_Pnt o(sk.plane.origin.x(), sk.plane.origin.y(), sk.plane.origin.z()); + gp_Dir xd(adir.x(), adir.y(), adir.z()); + gp_Ax1 axis(o, xd); + const double ang = f.revolve_angle * M_PI / 180.0; + BRepPrimAPI_MakeRevol rev(wire, axis, ang, false); + if (!rev.IsDone()) throw std::runtime_error("surface-revolve: revolve failed"); + result = rev.Shape(); have_body = true; + break; + } + case CadFeatureType::SurfaceLoft: { + std::vector profiles; + for (int ref : f.loft_profile_refs) { + if (ref < 0 || ref >= int(features.size()) + || (features[ref].type != CadFeatureType::Sketch + && features[ref].type != CadFeatureType::Project)) + continue; + profiles.push_back(build_sketch_wire(features[ref])); + } + if (profiles.size() < 2) + throw std::runtime_error("surface-loft needs 2+ valid profile sketches"); + TopoDS_Shape skin = SketchEngine::make_loft_surface(profiles, f.loft_ruled); + if (skin.IsNull()) throw std::runtime_error("surface-loft: loft failed"); + result = skin; have_body = true; + break; + } + case CadFeatureType::SurfaceFill: { + if (f.sketch_ref < 0 || f.sketch_ref >= int(features.size())) + throw std::runtime_error("surface-fill: bad sketch ref"); + const CadFeature& sk = features[f.sketch_ref]; + if (sk.type != CadFeatureType::Sketch && sk.type != CadFeatureType::Project) + throw std::runtime_error("surface-fill: ref is not a sketch"); + TopoDS_Wire wire = build_sketch_wire(sk); + if (wire.IsNull()) throw std::runtime_error("surface-fill: empty boundary"); + BRepOffsetAPI_MakeFilling fill; + int nedges = 0; + for (TopExp_Explorer ex(wire, TopAbs_EDGE); ex.More(); ex.Next()) { + fill.Add(TopoDS::Edge(ex.Current()), GeomAbs_C0); + ++nedges; + } + if (nedges == 0) throw std::runtime_error("surface-fill: boundary has no edges"); + fill.Build(); + if (!fill.IsDone()) throw std::runtime_error("surface-fill: fill failed"); + TopoDS_Shape face = fill.Shape(); + if (face.IsNull()) throw std::runtime_error("surface-fill: produced no geometry"); + result = face; have_body = true; + break; + } + case CadFeatureType::Sweep: { + const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) + && (features[f.sketch_ref].type == CadFeatureType::Sketch + || features[f.sketch_ref].type == CadFeatureType::Project)) + ? features[f.sketch_ref] : f; + if (f.sweep_path_ref < 0 || f.sweep_path_ref >= int(features.size())) + throw std::runtime_error("sweep needs a valid path reference"); + const CadFeature& path_feat = features[f.sweep_path_ref]; + TopoDS_Wire path; + if (path_feat.type == CadFeatureType::Helix) { + std::string helix_err; + path = make_helix_spine(path_feat, helix_err); + if (path.IsNull()) throw std::runtime_error("helix path: " + helix_err); + } else if (path_feat.type == CadFeatureType::Sketch) { + path = build_sketch_wire(path_feat); + } else { + throw std::runtime_error("sweep path must be a sketch or helix"); + } + TopoDS_Wire profile = build_sketch_wire(sk, true); + TopoDS_Shape tool = SketchEngine::make_sweep(profile, path); + if (!have_body || f.mode == BooleanMode::New) { + result = tool; + have_body = true; + } else if (f.mode == BooleanMode::Add) { + BRepAlgoAPI_Fuse fuse(result, tool); + if (!fuse.IsDone()) throw std::runtime_error("fuse failed"); + result = fuse.Shape(); + } else if (f.mode == BooleanMode::Cut) { + BRepAlgoAPI_Cut cut(result, tool); + if (!cut.IsDone()) throw std::runtime_error("cut failed"); + result = cut.Shape(); + } else if (f.mode == BooleanMode::Intersect) { + BRepAlgoAPI_Common common(result, tool); + if (!common.IsDone()) throw std::runtime_error("intersect failed"); + result = common.Shape(); + } + break; + } + case CadFeatureType::Loft: { + // Loft through 2+ closed profile Sketches, in recipe order. Each profile builds + // a wire via build_sketch_wire (so it keeps its own plane); make_loft skins them. + std::vector profiles; + for (int ref : f.loft_profile_refs) { + if (ref < 0 || ref >= int(features.size()) + || (features[ref].type != CadFeatureType::Sketch + && features[ref].type != CadFeatureType::Project)) + continue; + profiles.push_back(build_sketch_wire(features[ref], true)); + } + if (profiles.size() < 2) + throw std::runtime_error("loft needs 2+ valid profile sketches"); + TopoDS_Shape tool = SketchEngine::make_loft(profiles, f.loft_ruled); + if (!have_body || f.mode == BooleanMode::New) { + result = tool; + have_body = true; + } else if (f.mode == BooleanMode::Add) { + BRepAlgoAPI_Fuse fuse(result, tool); + if (!fuse.IsDone()) throw std::runtime_error("fuse failed"); + result = fuse.Shape(); + } else if (f.mode == BooleanMode::Cut) { + BRepAlgoAPI_Cut cut(result, tool); + if (!cut.IsDone()) throw std::runtime_error("cut failed"); + result = cut.Shape(); + } else if (f.mode == BooleanMode::Intersect) { + BRepAlgoAPI_Common common(result, tool); + if (!common.IsDone()) throw std::runtime_error("intersect failed"); + result = common.Shape(); + } + break; + } + case CadFeatureType::Pattern: { + // Pattern along a curve: when pattern_curve_sketch >= 0 this mode takes + // precedence over linear/circular. Copies are placed at equal-parameter points + // along the referenced sketch entity, translated by (P_i - P_0). + if (f.pattern_curve_sketch >= 0) { + if (f.pattern_curve_sketch >= (int)features.size()) + throw std::runtime_error("pattern-on-curve: bad sketch ref"); + const CadFeature& gs = features[f.pattern_curve_sketch]; + if (gs.type != CadFeatureType::Sketch) + throw std::runtime_error("pattern-on-curve: ref is not a sketch"); + if (f.pattern_curve_entity < 0 || f.pattern_curve_entity >= (int)gs.entities.size()) + throw std::runtime_error("pattern-on-curve: bad entity"); + if (!have_body) throw std::runtime_error("pattern needs a body"); + const SketchEntity& gc = gs.entities[f.pattern_curve_entity]; + const int n = std::max(1, f.pattern_count); + const TopoDS_Shape seed = result; + Vec3d p0 = gs.plane.to_world(sample_entity_2d(gc, 0.0)); + for (int i = 1; i < n; ++i) { + double t = double(i) / double(n - 1 > 0 ? n - 1 : 1); + Vec3d pi = gs.plane.to_world(sample_entity_2d(gc, t)); + Vec3d d = pi - p0; + gp_Trsf trsf; + trsf.SetTranslation(gp_Vec(d.x(), d.y(), d.z())); + TopoDS_Shape copy = BRepBuilderAPI_Transform(seed, trsf, true).Shape(); + BRepAlgoAPI_Fuse fuse(result, copy); + if (!fuse.IsDone()) throw std::runtime_error("pattern fuse failed"); + result = fuse.Shape(); + } + break; + } + // Replicate the target body. Each copy is a rigid gp_Trsf of the seed, all + // fused into one body. Linear: i*spacing along plane axis pattern_dir + // (0=X,1=Y). Circular: i*(angle/count) about the plane normal through the + // plane origin (so a seed offset from the origin orbits the axis). + if (!have_body) throw std::runtime_error("pattern needs a body"); + const int n = std::max(1, f.pattern_count); + const TopoDS_Shape seed = result; + for (int i = 1; i < n; ++i) { + gp_Trsf trsf; + if (f.pattern_circular) { + Vec3d o = f.plane.to_world(Vec2d(0, 0)); + gp_Ax1 ax(gp_Pnt(o.x(), o.y(), o.z()), + gp_Dir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z())); + const double step = (f.pattern_angle * M_PI / 180.0) / double(n); + trsf.SetRotation(ax, step * i); + } else { + const Vec3d& d = (f.pattern_dir == 1) ? f.plane.y_axis : f.plane.x_axis; + trsf.SetTranslation(gp_Vec(d.x() * f.pattern_spacing * i, + d.y() * f.pattern_spacing * i, + d.z() * f.pattern_spacing * i)); + } + TopoDS_Shape copy = BRepBuilderAPI_Transform(seed, trsf, true).Shape(); + BRepAlgoAPI_Fuse fuse(result, copy); + if (!fuse.IsDone()) throw std::runtime_error("pattern fuse failed"); + result = fuse.Shape(); + } + break; + } + case CadFeatureType::Rib: { + if (!have_body) throw std::runtime_error("rib needs a body"); + if (f.rib_sketch_ref < 0 || f.rib_sketch_ref >= (int)features.size()) + throw std::runtime_error("rib: bad sketch ref"); + const CadFeature& sk = features[f.rib_sketch_ref]; + // Project counts as sketch-like here exactly as it does for Extrude, SurfaceExtrude + // and SurfaceRevolve: it carries plane + Line entities, which is all a rib reads. + // Ribbing along a projected body edge is otherwise unreachable. + if (sk.type != CadFeatureType::Sketch && sk.type != CadFeatureType::Project) + throw std::runtime_error("rib: ref is not a sketch"); + if (f.rib_entity < 0 || f.rib_entity >= (int)sk.entities.size()) + throw std::runtime_error("rib: bad entity"); + const SketchEntity& ln = sk.entities[f.rib_entity]; + if (ln.type != SketchEntity::Type::Line) + throw std::runtime_error("rib: entity must be a line"); // ponytail: line-only for now + // Thin rectangle centred on the line, in the sketch plane: offset both endpoints by + // +/-thickness/2 along the in-plane perpendicular of the line direction. + const SketchPlane& pl = sk.plane; + Vec2d a = ln.p0, b = ln.p1; + Vec2d dir = (b - a); double L = dir.norm(); + if (L < 1e-9) throw std::runtime_error("rib: degenerate line"); + dir /= L; + Vec2d perp(-dir.y(), dir.x()); + double h = f.rib_thickness * 0.5; + Vec2d q0 = a + perp*h, q1 = b + perp*h, q2 = b - perp*h, q3 = a - perp*h; + auto w3 = [&](const Vec2d& p){ Vec3d w = pl.to_world(p); return gp_Pnt(w.x(),w.y(),w.z()); }; + BRepBuilderAPI_MakePolygon poly(w3(q0), w3(q1), w3(q2), w3(q3), Standard_True); + if (!poly.IsDone()) throw std::runtime_error("rib: profile failed"); + TopoDS_Shape wall = SketchEngine::make_extrude(poly.Wire(), pl, f.rib_depth, false, 0.0); + if (wall.IsNull()) throw std::runtime_error("rib: extrude failed"); + BRepAlgoAPI_Fuse fuse(result, wall); + if (!fuse.IsDone()) throw std::runtime_error("rib: fuse failed"); + result = fuse.Shape(); + break; + } + case CadFeatureType::Fillet: + if (!have_body) throw std::runtime_error("fillet needs a body"); + if (f.dressup_edge >= 0) + result = GeometryEngine::apply_fillet(result, f.dressup_size, f.dressup_edge); + else + result = GeometryEngine::apply_fillet(result, f.dressup_size, f.face_group); + break; + case CadFeatureType::Chamfer: + if (!have_body) throw std::runtime_error("chamfer needs a body"); + if (f.dressup_edge >= 0) + result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.dressup_edge); + else + result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.face_group); + break; + case CadFeatureType::Hole: { + if (!have_body) throw std::runtime_error("hole needs a body"); + // Circle wire centered at the positioned point on the plane + Vec3d c = f.plane.to_world(Vec2d(f.hole_x, f.hole_y)); + gp_Pnt o(c.x(), c.y(), c.z()); + gp_Dir n(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z()); + gp_Circ circ(gp_Ax2(o, n), f.hole_diameter * 0.5); + TopoDS_Edge e = BRepBuilderAPI_MakeEdge(circ).Edge(); + BRepBuilderAPI_MakeWire wm(e); + if (!wm.IsDone()) throw std::runtime_error("hole wire failed"); + // Through = symmetric huge cut (passes fully through any body); + // Blind = +normal extrude of hole_depth into the body. + TopoDS_Shape tool = f.hole_through + ? SketchEngine::make_extrude(wm.Wire(), f.plane, 1.0e5, true, 0.0) + : SketchEngine::make_extrude(wm.Wire(), f.plane, f.hole_depth, false, 0.0); + BRepAlgoAPI_Cut cut(result, tool); + if (!cut.IsDone()) throw std::runtime_error("hole cut failed"); + result = cut.Shape(); + + // Enlarge the entry for a screw head (style 1 = counterbore, 2 = countersink). + if (f.hole_style == 1 && f.hole_cbore_diameter > f.hole_diameter + && f.hole_cbore_depth > 1e-6) { + gp_Ax2 cbax(o, n); + TopoDS_Shape cb = BRepPrimAPI_MakeCylinder(cbax, f.hole_cbore_diameter * 0.5, + f.hole_cbore_depth).Shape(); + BRepAlgoAPI_Cut cbc(result, cb); + if (cbc.IsDone() && !cbc.Shape().IsNull()) result = cbc.Shape(); + } else if (f.hole_style == 2 && f.hole_csink_diameter > f.hole_diameter) { + const double Rmaj = f.hole_csink_diameter * 0.5; + const double Rmin = f.hole_diameter * 0.5; + const double half = f.hole_csink_angle * 0.5 * M_PI / 180.0; + const double h = (Rmaj - Rmin) / std::tan(half); + if (h > 1e-6) { + gp_Ax2 csax(o, n); + TopoDS_Shape cs = BRepPrimAPI_MakeCone(csax, Rmaj, Rmin, h).Shape(); + BRepAlgoAPI_Cut csc(result, cs); + if (csc.IsDone() && !csc.Shape().IsNull()) result = csc.Shape(); + } + } + break; + } + case CadFeatureType::Thread: { + // Reject degenerate parameters that make OCCT's helical sweep / boolean unstable (a tiny + // pitch, depth >= half-pitch, an enormous turn count, depth eating the whole wall). Better + // a no-op than a crash. Leave the body unchanged when the spec can't be built safely. + { + const double R = f.thread_radius, P = f.thread_pitch, H = f.thread_height, D = f.thread_depth; + // ISO external thread depth is ~0.61*P, so allow up to 0.7*P (0.49 wrongly rejected + // every real thread -> nothing rendered). Still bound it well under a full pitch. + const bool ok = R > 0.5 && P > 0.1 && D > 1e-3 && D < 0.7 * P && D < 0.45 * R + && H > 0.5 * P && (H / P) < 400.0; + if (!ok) break; // result/have_body untouched + } + // Axis at the positioned point on the plane; +normal = thread rise. + Vec3d c3 = f.plane.to_world(Vec2d(f.thread_x, f.thread_y)); + gp_Pnt c(c3.x(), c3.y(), c3.z()); + gp_Dir zdir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z()); + gp_Dir xdir(f.plane.x_axis.x(), f.plane.x_axis.y(), f.plane.x_axis.z()); + gp_Ax3 ax3(c, zdir, xdir); + gp_Ax2 ax2(c, zdir, xdir); + + // Build the swept helical ridge (guarded — never fatal). + TopoDS_Shape ridge; + bool have_ridge = false; + try { + TopoDS_Wire spine = make_helix_wire(ax3, f.thread_radius, + f.thread_pitch, f.thread_height); + TopoDS_Wire prof = make_thread_profile(c, xdir, zdir, f.thread_radius, + f.thread_pitch, f.thread_depth, + f.thread_internal); + // MakePipeShell with a FIXED BINORMAL = cylinder axis keeps the V-profile's orientation + // constant along the helix (axial edge always parallel to the axis, V always pointing + // radially out). The plain MakePipe used a Frenet frame that TWISTED the profile around + // the helix -> the wedge inclination varied and looked mirrored. + BRepOffsetAPI_MakePipeShell pipe(spine); + pipe.SetMode(zdir); + pipe.Add(prof); + pipe.Build(); + if (pipe.IsDone() && pipe.MakeSolid()) { + ridge = pipe.Shape(); + have_ridge = !ridge.IsNull(); + } + } catch (const std::exception&) { + have_ridge = false; // fall back to the bare cylinder/bore below + } catch (const Standard_Failure&) { + have_ridge = false; // OCCT failure (not a std::exception) — must be caught here too + } + + if (f.thread_internal) { + if (!have_body) throw std::runtime_error("internal thread needs a body"); + // Tapped bore: ensure a clean cylindrical pocket, then carve the + // OUTWARD helical groove into its wall. When the thread is invoked on + // an existing hole the bore cut is coincident (a no-op that may report + // !IsDone) — tolerate it so the visible groove cut below still runs. + // Cut the pocket at the MINOR diameter (radius - depth), not the nominal radius. + // A nominal-radius bore that coincides with an existing hole's wall creates + // coincident faces that foul the following groove boolean (the groove then removes + // ~nothing -> invisible thread). The minor bore stays strictly inside any existing + // hole wall, leaving it clean for the groove; on solid stock it forms the tap-drill. + const double bore_r = std::max(0.5, f.thread_radius - f.thread_depth); + TopoDS_Shape bore = BRepPrimAPI_MakeCylinder(ax2, bore_r, + f.thread_height).Shape(); + try { + BRepAlgoAPI_Cut cut_bore(result, bore); + if (cut_bore.IsDone() && !cut_bore.Shape().IsNull()) + result = cut_bore.Shape(); + } catch (const std::exception&) { /* keep existing bore */ } + if (have_ridge) { + BRepAlgoAPI_Cut cut_ridge(result, ridge); + if (cut_ridge.IsDone() && !cut_ridge.Shape().IsNull()) + result = cut_ridge.Shape(); + } + } else { + // External thread: FUSE the helical ridge ONTO the existing body (the picked cylinder), + // leaving the rest of the part intact. Replacing the body with a bare rod — the old + // behaviour — wiped whatever the user picked; that was the "mess". With no body yet + // (a thread from scratch on a dropdown plane), fall back to a standalone threaded rod. + if (have_body && !result.IsNull()) { + if (have_ridge) { + BRepAlgoAPI_Fuse fuse(result, ridge); + if (fuse.IsDone() && !fuse.Shape().IsNull()) result = fuse.Shape(); + } + } else { + TopoDS_Shape rod = BRepPrimAPI_MakeCylinder(ax2, f.thread_radius, + f.thread_height).Shape(); + if (have_ridge) { + BRepAlgoAPI_Fuse fuse(rod, ridge); + if (fuse.IsDone()) rod = fuse.Shape(); + } + result = rod; + have_body = true; + } + } + break; + } + case CadFeatureType::Shell: { + if (!have_body) throw std::runtime_error("shell needs a body"); + // Hollow the body to a wall thickness; the picked face (if any) is removed so the + // shell is open there. MakeThickSolidByJoin with a NEGATIVE offset shells inward. + TopTools_ListOfShape remove; + if (f.shell_face >= 0) { + TopoDS_Face fc = GeometryEngine::face_by_index(result, f.shell_face); + if (!fc.IsNull()) remove.Append(fc); + } + BRepOffsetAPI_MakeThickSolid mts; + mts.MakeThickSolidByJoin(result, remove, -std::abs(f.shell_thickness), 1.0e-3); + mts.Build(); + if (!mts.IsDone()) throw std::runtime_error("shell failed"); + result = mts.Shape(); + if (result.IsNull()) throw std::runtime_error("shell produced no geometry"); + break; + } + case CadFeatureType::Draft: { + if (!have_body) throw std::runtime_error("draft needs a body"); + if (f.draft_face < 0) throw std::runtime_error("draft needs a picked face"); + TopoDS_Face fc = GeometryEngine::face_by_index(result, f.draft_face); + if (fc.IsNull()) throw std::runtime_error("draft: face not found"); + // Neutral plane = horizontal plane through the body's bbox bottom, pull direction +Z. + // The face pivots about the line where it meets the neutral plane and tilts by the angle. + // ponytail: neutral plane / pull direction fixed to world up; pick-based neutral plane + // deferred (same as the datum-plane pick types, dgv). + Bnd_Box bb; BRepBndLib::Add(result, bb); + Standard_Real xmin, ymin, zmin, xmax, ymax, zmax; + bb.Get(xmin, ymin, zmin, xmax, ymax, zmax); + gp_Dir pull(0, 0, 1); + gp_Pln neutral(gp_Pnt(0, 0, zmin), pull); + BRepOffsetAPI_DraftAngle draft(result); + draft.Add(fc, pull, f.draft_angle * M_PI / 180.0, neutral); + if (!draft.AddDone()) + throw std::runtime_error("draft: face cannot be drafted (is it parallel to the base?)"); + draft.Build(); + if (!draft.IsDone()) throw std::runtime_error("draft failed"); + result = draft.Shape(); + if (result.IsNull()) throw std::runtime_error("draft produced no geometry"); + break; + } + case CadFeatureType::DeleteFace: { + if (!have_body) throw std::runtime_error("delete_face needs a body"); + if (f.delete_faces.empty()) throw std::runtime_error("delete_face needs at least one face"); + BRepAlgoAPI_Defeaturing df; + df.SetShape(result); + for (int fi : f.delete_faces) { + TopoDS_Face fc = GeometryEngine::face_by_index(result, fi); + if (fc.IsNull()) throw std::runtime_error("delete_face: face not found"); + df.AddFaceToRemove(fc); + } + df.Build(); + if (!df.IsDone()) throw std::runtime_error("delete_face failed"); + result = df.Shape(); + if (result.IsNull()) throw std::runtime_error("delete_face produced no geometry"); + break; + } + } +} + +// Compound of all body shapes (1 body => that body verbatim, so single-body display and +// global face/edge ids are byte-identical to the pre-multi-body behaviour). +static TopoDS_Shape compound_of(const std::vector& bodies) +{ + if (bodies.size() == 1) return bodies[0].shape; + TopoDS_Compound comp; + BRep_Builder bld; + bld.MakeCompound(comp); + for (const CadBody& b : bodies) + if (!b.shape.IsNull()) bld.Add(comp, b.shape); + return comp; +} + +// Tessellate every body separately and concatenate into one mesh, recording per-triangle +// (body index, face id WITHIN that body). Single-body => byte-identical to tessellate(body). +static TriangleMesh tessellate_bodies(const std::vector& bodies, + std::vector& tri_face, std::vector& tri_body, + std::vector& body_meshes, + double lin, double ang) +{ + tri_face.clear(); + tri_body.clear(); + body_meshes.clear(); + indexed_triangle_set merged; + for (int bi = 0; bi < int(bodies.size()); ++bi) { + std::vector tf; + TriangleMesh bm = SketchEngine::tessellate(bodies[bi].shape, tf, lin, ang); + const indexed_triangle_set& its = bm.its; + const int voff = int(merged.vertices.size()); + for (const auto& v : its.vertices) merged.vertices.push_back(v); + for (const auto& t : its.indices) + merged.indices.emplace_back(t[0] + voff, t[1] + voff, t[2] + voff); + for (int fid : tf) { tri_face.push_back(fid); tri_body.push_back(bi); } + body_meshes.push_back(std::move(bm)); // per-body mesh kept for distinct GLVolume colors + } + return TriangleMesh(merged); +} + +void CadDocument::apply_boolean(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tool = (f.bool_tool_body >= 0 && f.bool_tool_body < nb) ? f.bool_tool_body : -1; + if (tgt < 0 || tool < 0 || tgt == tool) return; // need two distinct bodies; otherwise no-op + const TopoDS_Shape A = bodies[tgt].shape; // target survives + const TopoDS_Shape B = bodies[tool].shape; // tool, consumed unless kept + if (A.IsNull() || B.IsNull()) return; + + TopTools_ListOfShape args, tools; + args.Append(A); + tools.Append(B); + auto run = [&](BRepAlgoAPI_BooleanOperation& bop) -> TopoDS_Shape { + bop.SetArguments(args); + bop.SetTools(tools); + if (f.bool_tolerance > 0.0) bop.SetFuzzyValue(f.bool_tolerance); // OCCT fuzzy: merge near-coincident faces + bop.Build(); + if (!bop.IsDone()) throw std::runtime_error("boolean operation failed"); + return bop.Shape(); + }; + TopoDS_Shape result; + switch (f.mode) { + case BooleanMode::Add: { BRepAlgoAPI_Fuse op; result = run(op); break; } // union + case BooleanMode::Cut: { BRepAlgoAPI_Cut op; result = run(op); break; } // target - tool + case BooleanMode::Intersect: { BRepAlgoAPI_Common op; result = run(op); break; } // overlap + default: return; // BooleanMode::New is meaningless between two existing bodies + } + if (result.IsNull()) throw std::runtime_error("boolean produced an empty shape"); + + bodies[tgt].shape = result; + if (!f.bool_keep_tool) bodies.erase(bodies.begin() + tool); // consume the tool body +} + +void CadDocument::apply_cut(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("cut: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("cut: no target body"); + + if (!f.cut_keep_upper && !f.cut_keep_lower) + throw std::runtime_error("cut keeps nothing"); + + SketchPlane cp; + if (f.cut_face >= 0) { + const int fb = (f.cut_face_body >= 0 && f.cut_face_body < nb) ? f.cut_face_body : tgt; + if (bodies[fb].shape.IsNull()) throw std::runtime_error("cut: face body is empty"); + TopoDS_Face fc = GeometryEngine::face_by_index(bodies[fb].shape, f.cut_face); + if (fc.IsNull()) throw std::runtime_error("cut: face not found"); + cp = SketchPlane::from_face(fc); + } else { + cp = f.plane; + } + cp.origin += cp.normal * f.cut_offset; + if (f.cut_flip) cp.normal = -cp.normal; + + // Build a large square wire in the cut plane, centered at plane origin. + const double L = 1.0e5; + Vec3d x = cp.x_axis * L; + Vec3d y = cp.y_axis * L; + Vec3d o = cp.origin; + auto p = [&](double sx, double sy) { + Vec3d v = o + x * sx + y * sy; + return gp_Pnt(v.x(), v.y(), v.z()); + }; + BRepBuilderAPI_MakePolygon poly; + poly.Add(p( 1, 1)); + poly.Add(p( 1, -1)); + poly.Add(p(-1, -1)); + poly.Add(p(-1, 1)); + poly.Close(); + if (!poly.IsDone()) throw std::runtime_error("cut: failed to build cut wire"); + TopoDS_Wire wire = poly.Wire(); + + TopoDS_Shape upper_piece, lower_piece; + const TopoDS_Shape& target = bodies[tgt].shape; + + if (f.cut_keep_upper) { + TopoDS_Shape upper_tool = SketchEngine::make_extrude(wire, cp, L, false, 0.0); + BRepAlgoAPI_Common common(target, upper_tool); + if (!common.IsDone()) throw std::runtime_error("cut operation failed"); + upper_piece = common.Shape(); + } + + if (f.cut_keep_lower) { + SketchPlane lp = cp; + lp.normal = -lp.normal; + TopoDS_Shape lower_tool = SketchEngine::make_extrude(wire, lp, L, false, 0.0); + BRepAlgoAPI_Common common(target, lower_tool); + if (!common.IsDone()) throw std::runtime_error("cut operation failed"); + lower_piece = common.Shape(); + } + + if (f.cut_keep_upper && f.cut_keep_lower) { + bodies[tgt].shape = upper_piece; + bodies.insert(bodies.begin() + tgt + 1, + CadBody{ lower_piece, bodies[tgt].name + " (2)" }); + } else if (f.cut_keep_upper) { + bodies[tgt].shape = upper_piece; + } else { + bodies[tgt].shape = lower_piece; + } +} + +void CadDocument::apply_mirror(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("mirror: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("mirror: no target body"); + + const TopoDS_Shape& src = bodies[tgt].shape; + + gp_Trsf trsf; + trsf.SetMirror(gp_Ax2(gp_Pnt(f.plane.origin.x(), f.plane.origin.y(), f.plane.origin.z()), + gp_Dir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z()))); + BRepBuilderAPI_Transform xform(src, trsf, true /*copy*/); + if (!xform.IsDone()) throw std::runtime_error("mirror: transform failed"); + TopoDS_Shape mirrored = xform.Shape(); + + // A mirror reverses orientation — verify the result has positive volume. + { + GProp_GProps props; + BRepGProp::VolumeProperties(mirrored, props); + if (props.Mass() <= 0.0) { + // Flip orientation to get a valid forward solid. + mirrored.Reverse(); + BRepGProp::VolumeProperties(mirrored, props); + if (props.Mass() <= 0.0) + throw std::runtime_error("mirror: result has zero or negative volume"); + } + } + + switch (f.mode) { + case BooleanMode::Add: { + BRepAlgoAPI_Fuse fuse(src, mirrored); + if (!fuse.IsDone()) throw std::runtime_error("mirror fuse failed"); + bodies[tgt].shape = fuse.Shape(); + break; + } + case BooleanMode::New: { + if (!f.mirror_keep_original) + bodies.erase(bodies.begin() + tgt); // replace: the mirrored copy takes the source slot + bodies.push_back({mirrored, f.name.empty() ? std::string("Mirror") : f.name}); + break; + } + default: + throw std::runtime_error("mirror: mode must be New or Add"); + } +} + +void CadDocument::apply_transform(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("transform: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("transform: no target body"); + + gp_Trsf rot; + if (std::abs(f.xf_angle_deg) > 1e-12) { + if (f.xf_axis.norm() < 1e-9) + throw std::runtime_error("transform: rotation axis is degenerate"); + rot.SetRotation(gp_Ax1(gp_Pnt(f.xf_pivot.x(), f.xf_pivot.y(), f.xf_pivot.z()), + gp_Dir(f.xf_axis.x(), f.xf_axis.y(), f.xf_axis.z())), + f.xf_angle_deg * M_PI / 180.0); + } + gp_Trsf tr; + tr.SetTranslation(gp_Vec(f.xf_translate.x(), f.xf_translate.y(), f.xf_translate.z())); + const gp_Trsf trsf = tr * rot; // rotate first, then translate + + BRepBuilderAPI_Transform xform(bodies[tgt].shape, trsf, true /*copy*/); + if (!xform.IsDone()) throw std::runtime_error("transform: failed"); + TopoDS_Shape moved = xform.Shape(); + + if (f.xf_copy) + bodies.push_back({moved, f.name.empty() ? std::string("Transform") : f.name}); + else + bodies[tgt].shape = moved; +} + +void CadDocument::apply_thicken(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("thicken: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("thicken: no target body"); + + TopoDS_Face fc = GeometryEngine::face_by_index(bodies[tgt].shape, f.thicken_face); + if (fc.IsNull()) throw std::runtime_error("thicken: face not found"); + if (std::abs(f.thicken_thickness) < 1e-9) throw std::runtime_error("thicken: thickness is zero"); + + TopoDS_Shell shell; + BRep_Builder bb; + bb.MakeShell(shell); + bb.Add(shell, fc); + + const double off = f.thicken_flip ? -std::abs(f.thicken_thickness) + : std::abs(f.thicken_thickness); + BRepOffsetAPI_MakeThickSolid mts; + mts.MakeThickSolidBySimple(shell, off); + mts.Build(); + if (!mts.IsDone()) throw std::runtime_error("thicken: failed"); + TopoDS_Shape solid = mts.Shape(); + if (solid.IsNull()) throw std::runtime_error("thicken: produced no geometry"); + + // MakeThickSolidBySimple may produce a reversed solid. Ensure positive volume. + { + GProp_GProps props; + BRepGProp::VolumeProperties(solid, props); + if (props.Mass() < 0.0) solid.Reverse(); + } + + bodies.push_back({solid, f.name.empty() ? std::string("Thicken") : f.name}); +} + +void CadDocument::apply_thicken_surface(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("thicken-surface: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("thicken-surface: no target body"); + if (!is_sheet_shape(bodies[tgt].shape)) throw std::runtime_error("thicken-surface: target is not a sheet body"); + if (std::abs(f.thicken_thickness) < 1e-9) throw std::runtime_error("thicken-surface: thickness is zero"); + + const double off = f.thicken_flip ? -std::abs(f.thicken_thickness) + : std::abs(f.thicken_thickness); + const TopoDS_Shape& sheet = bodies[tgt].shape; + + // MakeThickSolidBySimple offsets each face along its own normal and sews the result; it never + // extends neighbours to meet, so at every edge where two faces join at an angle the corner + // material is simply absent. A flat sheet is therefore exact while a 4-walled box is not + // (measured: 29648 against the 44000 that (60^2-50^2)*40 requires). + // + // ByJoin is the call that mitres those corners, and it is what the Shell feature already uses + // successfully a few hundred lines up — but it CANNOT be handed an open sheet. In OCCT, + // BRepOffset_MakeOffset::MakeThickSolid builds the solid only inside `if (!myFaces.IsEmpty())` + // (BRepOffset_MakeOffset.cxx:1115): with no closing faces it stops after the offset shell and + // returns that. So it reports IsDone() and a non-null shape containing no TopAbs_SOLID at all + // — which is exactly how two earlier attempts failed, and why no parameter could have fixed it. + // + // So close the sheet first, then use the working call: cap the free rims, sew into a closed + // shell, make a solid, and hollow it inward passing the caps as the faces to remove. The caps + // come back off, leaving the wall. + // A SINGLE face has no edge shared with a neighbour, so there is no corner to mitre and + // BySimple is already exact on it (flat 60x60 by 5 -> 18000.000). It also has a free + // boundary, so "does it have free wires" is NOT the question to ask here — capping a lone + // face with its own rim sews a zero-thickness shell and the offset then measures 6000. + int n_faces = 0; + for (TopExp_Explorer fe(sheet, TopAbs_FACE); fe.More(); fe.Next()) ++n_faces; + + TopTools_ListOfShape caps; + if (n_faces > 1) { + ShapeAnalysis_FreeBounds fb(sheet); + for (TopExp_Explorer we(fb.GetClosedWires(), TopAbs_WIRE); we.More(); we.Next()) { + BRepBuilderAPI_MakeFace mk(TopoDS::Wire(we.Current())); + if (!mk.IsDone()) + throw std::runtime_error("thicken-surface: cannot cap the sheet rim " + "(is it planar?)"); + caps.Append(mk.Face()); + } + } + + TopoDS_Shape solid; + if (caps.IsEmpty()) { + BRepOffsetAPI_MakeThickSolid mts; + mts.MakeThickSolidBySimple(sheet, off); + mts.Build(); + if (!mts.IsDone()) throw std::runtime_error("thicken-surface: failed"); + solid = mts.Shape(); + } else { + BRepBuilderAPI_Sewing sewer(1.0e-3); + sewer.Add(sheet); + for (TopTools_ListIteratorOfListOfShape it(caps); it.More(); it.Next()) + sewer.Add(it.Value()); + sewer.Perform(); + + TopoDS_Shell closed_shell; + for (TopExp_Explorer se(sewer.SewedShape(), TopAbs_SHELL); se.More(); se.Next()) { + if (!closed_shell.IsNull()) + throw std::runtime_error("thicken-surface: the capped sheet split into " + "more than one shell"); + closed_shell = TopoDS::Shell(se.Current()); + } + if (closed_shell.IsNull() || !BRep_Tool::IsClosed(closed_shell)) + throw std::runtime_error("thicken-surface: the capped sheet is not closed"); + + // A shell sewn from an extruded sheet carries no guarantee that its faces point outward, + // and BRepBuilderAPI_MakeSolid does not fix that. Offsetting an inside-out solid sends the + // wall the wrong way: measured bbox 70x70x40 for an inward offset on a 60x60 box, with a + // volume larger than its own bounding box because the result then overlaps itself. A + // negative mass IS the inverted orientation, so use it as the test. + TopoDS_Solid capped = BRepBuilderAPI_MakeSolid(closed_shell).Solid(); + { + GProp_GProps vp; + BRepGProp::VolumeProperties(capped, vp); + if (vp.Mass() < 0.0) capped.Reverse(); + } + + BRepOffsetAPI_MakeThickSolid mts; + mts.MakeThickSolidByJoin(capped, caps, -std::abs(off), 1.0e-3); + mts.Build(); + if (!mts.IsDone()) throw std::runtime_error("thicken-surface: failed"); + solid = mts.Shape(); + } + if (solid.IsNull()) throw std::runtime_error("thicken-surface: produced no geometry"); + // IsDone() is NOT a success test here — the failed attempts had IsDone() true and no solid. + if (!TopExp_Explorer(solid, TopAbs_SOLID).More()) + throw std::runtime_error("thicken-surface: produced a shell, not a solid"); + + { + GProp_GProps props; + BRepGProp::VolumeProperties(solid, props); + if (props.Mass() < 0.0) solid.Reverse(); + } + + bodies.push_back({solid, f.name.empty() ? std::string("ThickenSurface") : f.name}); +} + +void CadDocument::apply_surface_offset(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("surface-offset: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("surface-offset: no target body"); + if (!is_sheet_shape(bodies[tgt].shape)) throw std::runtime_error("surface-offset: target is not a sheet body"); + const double d = f.plane_offset; + if (std::abs(d) < 1e-9) throw std::runtime_error("surface-offset: zero offset"); + + BRepOffsetAPI_MakeOffsetShape mos; + mos.PerformBySimple(bodies[tgt].shape, d); + if (!mos.IsDone()) throw std::runtime_error("surface-offset: failed"); + TopoDS_Shape off_shape = mos.Shape(); + if (off_shape.IsNull()) throw std::runtime_error("surface-offset: produced no geometry"); + + bodies.push_back({off_shape, f.name.empty() ? std::string("SurfaceOffset") : f.name}); +} + +// Project `edges` of a shape onto `plane`, appending the resulting 2D entities to `out`. +// `construction` marks them as guides rather than built geometry. This is the body of the +// Project feature's conversion loop, factored out so a sketch can borrow the same geometry. +static void project_edges_to_entities(const std::vector& edges, + const SketchPlane& plane, + bool construction, + std::vector& out) +{ + auto to2d = [&](const gp_Pnt& p) -> Vec2d { + Vec3d d(p.X() - plane.origin.x(), p.Y() - plane.origin.y(), p.Z() - plane.origin.z()); + return Vec2d(d.dot(plane.x_axis), d.dot(plane.y_axis)); + }; + + // A segment whose endpoints coincide after projection carries no geometry: that is what + // an edge perpendicular to the target plane becomes. Emitting it as a zero-length line + // would poison the sketch downstream, so drop it here. + auto push_line = [&](const Vec2d& a, const Vec2d& b) { + if ((b - a).norm() < 1e-7) return; + SketchEntity se; se.type = SketchEntity::Type::Line; + se.p0 = a; se.p1 = b; + se.construction = construction; + out.push_back(se); + }; + + for (const TopoDS_Edge& e : edges) { + BRepAdaptor_Curve ac(e); + const GeomAbs_CurveType ct = ac.GetType(); + if (ct == GeomAbs_Line) { + gp_Pnt a = ac.Value(ac.FirstParameter()); + gp_Pnt b = ac.Value(ac.LastParameter()); + push_line(to2d(a), to2d(b)); + } else if (ct == GeomAbs_Circle) { + gp_Circ c = ac.Circle(); + gp_Dir cn = c.Axis().Direction(); + Vec3d cnv(cn.X(), cn.Y(), cn.Z()); + const double par = std::abs(cnv.dot(plane.normal)); + const bool full = BRep_Tool::IsClosed(e) || + std::abs((ac.LastParameter() - ac.FirstParameter()) - 2.0 * M_PI) < 1e-6; + if (par > 0.999) { + Vec2d ctr = to2d(c.Location()); + if (full) { + SketchEntity se; se.type = SketchEntity::Type::Circle; + se.center = ctr; se.radius = c.Radius(); + se.construction = construction; + out.push_back(se); + } else { + gp_Pnt a = ac.Value(ac.FirstParameter()); + gp_Pnt b = ac.Value(ac.LastParameter()); + Vec2d a2 = to2d(a), b2 = to2d(b); + SketchEntity se; se.type = SketchEntity::Type::Arc; + se.center = ctr; se.radius = c.Radius(); + se.p0 = a2; se.p1 = b2; + se.start_angle = std::atan2(a2.y() - ctr.y(), a2.x() - ctr.x()); + se.end_angle = std::atan2(b2.y() - ctr.y(), b2.x() - ctr.x()); + se.construction = construction; + out.push_back(se); + } + continue; + } + std::vector pts = GeometryEngine::sample_edge_world(e); + for (size_t i = 1; i < pts.size(); ++i) + push_line(to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())), + to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()))); + } else { + std::vector pts = GeometryEngine::sample_edge_world(e); + for (size_t i = 1; i < pts.size(); ++i) + push_line(to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())), + to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()))); + } + } +} + +void CadDocument::apply_project(const std::vector& bodies, CadFeature& f) const +{ + f.entities.clear(); + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("project: no source body"); + const int src = (f.project_source_body >= 0 && f.project_source_body < nb) + ? f.project_source_body : nb - 1; + if (src < 0 || bodies[src].shape.IsNull()) throw std::runtime_error("project: source body is empty"); + const TopoDS_Shape& shape = bodies[src].shape; + + std::vector edges; + if (!f.project_edges.empty()) { + for (int id : f.project_edges) { + TopoDS_Edge e = GeometryEngine::edge_by_index(shape, id); + if (e.IsNull()) throw std::runtime_error("project: edge not found"); + edges.push_back(e); + } + } else if (f.project_face >= 0) { + TopoDS_Face fc = GeometryEngine::face_by_index(shape, f.project_face); + if (fc.IsNull()) throw std::runtime_error("project: face not found"); + edges = GeometryEngine::edges_of_face(fc); + } else { + // No face and no explicit selection means "all edges" — the state the Project card + // starts in, and its label says so. Edges perpendicular to the target plane collapse + // to a point when projected; they are dropped below rather than emitted as + // zero-length lines. + edges = GeometryEngine::edges_of(shape); + } + if (edges.empty()) throw std::runtime_error("project: no edges to project"); + + project_edges_to_entities(edges, f.plane, /*construction=*/false, f.entities); + + if (f.entities.empty()) throw std::runtime_error("project: produced no entities"); +} + +void CadDocument::detect_mate_conflicts() +{ + mate_conflicts.clear(); + + const int n = int(features.size()); + std::map first_driver; // body -> feature index of first mate that drives it + std::map> graph; // dst -> list of src (body indices) + + for (int fi = 0; fi < n; ++fi) { + const CadFeature& f = features[fi]; + if (f.type != CadFeatureType::Mate || !f.enabled) continue; + + if (f.mate_cs_a < 0 || f.mate_cs_a >= n) continue; + if (f.mate_cs_b < 0 || f.mate_cs_b >= n) continue; + const CadFeature& fa = features[f.mate_cs_a]; + const CadFeature& fb = features[f.mate_cs_b]; + if (fa.type != CadFeatureType::CoordSys || !fa.enabled) continue; + if (fb.type != CadFeatureType::CoordSys || !fb.enabled) continue; + + const int src = fa.coordsys_body; + const int dst = fb.coordsys_body; + if (dst < 0) continue; // apply_mate already reports this one + + // (a) duplicate target: a second mate driving the same body + auto it = first_driver.find(dst); + if (it != first_driver.end()) { + int first_fi = it->second; + std::string first_name = features[first_fi].name.empty() ? "Mate" : features[first_fi].name; + std::string this_name = f.name.empty() ? "Mate" : f.name; + mate_conflicts.push_back({fi, + "Body " + std::to_string(dst + 1) + " is already positioned by '" + + first_name + "' (feature " + std::to_string(first_fi) + + ") — '" + this_name + "' overrides it; suppress one"}); + } else { + first_driver[dst] = fi; + } + + // (b) self-mate or cycle detection + if (src >= 0 && dst >= 0) { + if (src == dst) { + mate_conflicts.push_back({fi, + "this mate positions Body " + std::to_string(dst + 1) + " against itself"}); + } else { + graph[dst].push_back(src); + } + } + } + + // DFS cycle detection on the graph built above. + // ponytail: iterative DFS avoids recursion depth issues on long chains. + // Colour: 0 = unvisited, 1 = in-progress (grey), 2 = done (black). + std::map colour; + struct Frame { int node; size_t next; }; + std::vector stack; + + for (const auto& [start, _] : graph) { + if (colour[start] == 2) continue; + stack.clear(); + stack.push_back({start, 0}); + colour[start] = 1; + while (!stack.empty()) { + Frame& top = stack.back(); + auto git = graph.find(top.node); + if (git == graph.end() || top.next >= git->second.size()) { + colour[top.node] = 2; + stack.pop_back(); + continue; + } + int child = git->second[top.next++]; + if (colour[child] == 1) { + // Back edge found — find the mate whose (dst==top.node, src==child). + for (int fi = 0; fi < n; ++fi) { + const CadFeature& f = features[fi]; + if (f.type != CadFeatureType::Mate || !f.enabled) continue; + if (f.mate_cs_a < 0 || f.mate_cs_a >= n) continue; + if (f.mate_cs_b < 0 || f.mate_cs_b >= n) continue; + const CadFeature& fa2 = features[f.mate_cs_a]; + const CadFeature& fb2 = features[f.mate_cs_b]; + if (fa2.type != CadFeatureType::CoordSys || !fa2.enabled) continue; + if (fb2.type != CadFeatureType::CoordSys || !fb2.enabled) continue; + int sd = fb2.coordsys_body; + int ss = fa2.coordsys_body; + if (sd == top.node && ss == child) { + mate_conflicts.push_back({fi, + // Worded for ANY cycle length: "leads back" is true transitively, + // where "depends back on" would be a lie for a 3+ body chain. + "circular mate chain: Body " + std::to_string(top.node + 1) + + " depends on Body " + std::to_string(child + 1) + + ", which leads back to Body " + std::to_string(top.node + 1) + + " — the result depends on feature order"}); + break; + } + } + continue; + } + if (colour[child] == 0) { + colour[child] = 1; + stack.push_back({child, 0}); + } + } + } + + // Deliberately NOT in scope: computing the numeric disagreement between two mates + // ("Mate3 puts it at X=10, Mate7 at X=15"). That needs speculative per-mate evaluation. +} + +void CadDocument::apply_mate(std::vector& bodies, const CadFeature& f) const +{ + const int nc = int(features.size()); + if (f.mate_cs_a < 0 || f.mate_cs_a >= nc) + throw std::runtime_error("mate: mate_cs_a out of range"); + if (f.mate_cs_b < 0 || f.mate_cs_b >= nc) + throw std::runtime_error("mate: mate_cs_b out of range"); + + const CadFeature& fa = features[f.mate_cs_a]; + const CadFeature& fb = features[f.mate_cs_b]; + if (fa.type != CadFeatureType::CoordSys || !fa.enabled) + throw std::runtime_error("mate: mate_cs_a is not a valid CoordSys feature"); + if (fb.type != CadFeatureType::CoordSys || !fb.enabled) + throw std::runtime_error("mate: mate_cs_b is not a valid CoordSys feature"); + + CadDocument::DatumCoordSys A = datum_frame(bodies, fa); + CadDocument::DatumCoordSys B = datum_frame(bodies, fb); + if (!A.error.empty()) throw std::runtime_error("mate: " + A.error); + if (!B.error.empty()) throw std::runtime_error("mate: " + B.error); + + const int tgt_body = fb.coordsys_body; + if (tgt_body < 0) + throw std::runtime_error("mate: mate_cs_b has no associated body"); + if (tgt_body >= int(bodies.size()) || bodies[tgt_body].shape.IsNull()) + throw std::runtime_error("mate: target body out of range or null"); + + Vec3d xA(A.x), yA(A.y), oA(A.origin); + Vec3d zA = xA.cross(yA).normalized(); + Vec3d xB(B.x), yB(B.y), oB(B.origin); + Vec3d zB = xB.cross(yB).normalized(); + + auto make_4x4 = [&](const Vec3d& x, const Vec3d& y, const Vec3d& z, const Vec3d& o) { + gp_Trsf T; + T.SetValues(x.x(), y.x(), z.x(), o.x(), + x.y(), y.y(), z.y(), o.y(), + x.z(), y.z(), z.z(), o.z()); + return T; + }; + gp_Trsf M_A = make_4x4(xA, yA, zA, oA); + gp_Trsf M_B = make_4x4(xB, yB, zB, oB); + + gp_Trsf F; + if (f.mate_flip) { + // Rx(pi): flip y→-y, z→-z + F.SetValues(1, 0, 0, 0, + 0, -1, 0, 0, + 0, 0, -1, 0); + } + + gp_Trsf T; + if (f.mate_kind == 0) { + // Fastened: T = M_A * Rz(mate_angle) * Tz(mate_offset) * F * M_B^-1 + gp_Trsf Rz; + Rz.SetRotation(gp_Ax1(gp_Pnt(0,0,0), gp_Dir(0,0,1)), f.mate_angle * M_PI / 180.0); + gp_Trsf Tz; + Tz.SetTranslation(gp_Vec(0, 0, f.mate_offset)); + gp_Trsf M_B_inv = M_B.Inverted(); + T = M_A * Rz * Tz * F * M_B_inv; + } else { + Vec3d z_target = f.mate_flip ? -zA : zA; + + // Minimum-rotation helper: compute R that rotates zB onto z_target + // about an axis through oB. Reused by Planar / Revolute / Cylindrical. + auto make_z_align = [&](const Vec3d& zsrc, const Vec3d& zdst) -> gp_Trsf { + double ddot = zsrc.dot(zdst); + Vec3d rot_axis; + double rot_angle = 0; + if (ddot <= -0.9999) { + Vec3d ref = (std::abs(zsrc.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + rot_axis = zsrc.cross(ref).normalized(); + rot_angle = M_PI; + } else { + rot_axis = zsrc.cross(zdst); + if (rot_axis.squaredNorm() > 1e-18) { + rot_axis.normalize(); + rot_angle = std::acos(std::max(-1.0, std::min(1.0, ddot))); + } + } + gp_Trsf R; + if (rot_angle > 1e-12) { + R.SetRotation(gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()), + gp_Dir(rot_axis.x(), rot_axis.y(), rot_axis.z())), + rot_angle); + } + return R; + }; + + if (f.mate_kind == 1 || f.mate_kind == 2 || f.mate_kind == 4) { + // Planar (1) / Revolute (2) / Cylindrical (4): + // all share the same minimum-rotation z-alignment. + gp_Trsf R_align = make_z_align(zB, z_target); + + gp_Trsf Rz_about_target; + if (std::abs(f.mate_angle) > 1e-12) { + Rz_about_target.SetRotation( + gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()), + gp_Dir(z_target.x(), z_target.y(), z_target.z())), + f.mate_angle * M_PI / 180.0); + } + gp_Trsf R = Rz_about_target * R_align; + + // Translation: depends on which DOFs are constrained + Vec3d trans; + if (f.mate_kind == 1) { + // Planar: normal distance becomes mate_offset + double d = (oB - oA).dot(zA); + trans = zA * (f.mate_offset - d); + } else if (f.mate_kind == 2) { + // Revolute: full position on the axis line + trans = (oA + zA * f.mate_offset) - oB; + } else { + // Cylindrical (4): fix perpendicular, preserve axial + double axial = (oB - oA).dot(zA); + trans = (oA + zA * (axial + f.mate_offset)) - oB; + } + + T.SetValues(1, 0, 0, trans.x(), + 0, 1, 0, trans.y(), + 0, 0, 1, trans.z()); + T = T * R; + } else { + // Slider (mate_kind == 3): full orientation alignment, + // fix perpendicular position, preserve axial translation. + Vec3d x_target = xA; + Vec3d y_target = f.mate_flip ? -yA : yA; + + // R = target * B^T (both bases orthonormal) + double r11 = x_target.x() * xB.x() + y_target.x() * yB.x() + z_target.x() * zB.x(); + double r12 = x_target.x() * xB.y() + y_target.x() * yB.y() + z_target.x() * zB.y(); + double r13 = x_target.x() * xB.z() + y_target.x() * yB.z() + z_target.x() * zB.z(); + double r21 = x_target.y() * xB.x() + y_target.y() * yB.x() + z_target.y() * zB.x(); + double r22 = x_target.y() * xB.y() + y_target.y() * yB.y() + z_target.y() * zB.y(); + double r23 = x_target.y() * xB.z() + y_target.y() * yB.z() + z_target.y() * zB.z(); + double r31 = x_target.z() * xB.x() + y_target.z() * yB.x() + z_target.z() * zB.x(); + double r32 = x_target.z() * xB.y() + y_target.z() * yB.y() + z_target.z() * zB.y(); + double r33 = x_target.z() * xB.z() + y_target.z() * yB.z() + z_target.z() * zB.z(); + + // Build rotation about oB: R_full * p = R * (p - oB) + oB + double tx = oB.x() - (r11 * oB.x() + r12 * oB.y() + r13 * oB.z()); + double ty = oB.y() - (r21 * oB.x() + r22 * oB.y() + r23 * oB.z()); + double tz = oB.z() - (r31 * oB.x() + r32 * oB.y() + r33 * oB.z()); + gp_Trsf R_full; + R_full.SetValues(r11, r12, r13, tx, + r21, r22, r23, ty, + r31, r32, r33, tz); + + double axial = (oB - oA).dot(zA); + Vec3d trans = (oA + zA * (axial + f.mate_offset)) - oB; + + T.SetValues(1, 0, 0, trans.x(), + 0, 1, 0, trans.y(), + 0, 0, 1, trans.z()); + T = T * R_full; + } + } + + BRepBuilderAPI_Transform xform(bodies[tgt_body].shape, T, true /*copy*/); + if (!xform.IsDone()) throw std::runtime_error("mate: transform failed"); + bodies[tgt_body].shape = xform.Shape(); +} + +// Volume of a shape, 0 for anything that isn't a solid we can measure. Used to catch a +// subtraction that removed nothing (daf). +static double solid_volume(const TopoDS_Shape& s) +{ + if (s.IsNull()) return 0.0; + GProp_GProps props; + BRepGProp::VolumeProperties(s, props); + return std::abs(props.Mass()); +} + +void CadDocument::route_feature(std::vector& bodies, const CadFeature& f) const +{ + if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline + if (f.type == CadFeatureType::Axis) return; // datum axis + if (f.type == CadFeatureType::CoordSys) return; // datum coordinate system + if (f.type == CadFeatureType::Helix) return; // helical curve; consumed by Sweep + if (f.type == CadFeatureType::Boolean) { apply_boolean(bodies, f); return; } // body-body op + if (f.type == CadFeatureType::Cut) { apply_cut(bodies, f); return; } // plane-split body + if (f.type == CadFeatureType::Mirror) { apply_mirror(bodies, f); return; } // mirror body about plane + if (f.type == CadFeatureType::Transform) { apply_transform(bodies, f); return; } // move/rotate body + if (f.type == CadFeatureType::Mate) { apply_mate(bodies, f); return; } // assembly mate + if (f.type == CadFeatureType::Thicken) { apply_thicken(bodies, f); return; } // face -> plate + if (f.type == CadFeatureType::ThickenSurface) { apply_thicken_surface(bodies, f); return; } + if (f.type == CadFeatureType::SurfaceOffset) { apply_surface_offset(bodies, f); return; } + if (f.type == CadFeatureType::Project) return; // sketch-like: consumed downstream, no body + // Resolve the target body: explicit target_body when valid, else the last body. + const int t = (f.target_body >= 0 && f.target_body < int(bodies.size())) + ? f.target_body : int(bodies.size()) - 1; + const TopoDS_Shape context = (t >= 0) ? bodies[t].shape : TopoDS_Shape(); + // A New extrude (or the very first solid feature) starts a fresh body; everything else + // mutates the target body in place. + const bool starts_new = bodies.empty() + || f.type == CadFeatureType::Import // an imported solid is always its own base body + || f.type == CadFeatureType::SurfaceExtrude || f.type == CadFeatureType::SurfaceRevolve + || f.type == CadFeatureType::ThickenSurface || f.type == CadFeatureType::SurfaceOffset + || f.type == CadFeatureType::SurfaceLoft || f.type == CadFeatureType::SurfaceFill + || ((f.type == CadFeatureType::Extrude || f.type == CadFeatureType::Revolve + || f.type == CadFeatureType::Sweep || f.type == CadFeatureType::Loft) + && f.mode == BooleanMode::New); + + if (starts_new) { + TopoDS_Shape result; // empty -> apply_feature fills it (New path) + bool have_body = false; + apply_feature(result, have_body, context, f); + if (have_body && !result.IsNull()) + bodies.push_back({ result, f.name.empty() ? std::string("Body") : f.name }); + } else { + if (t < 0) throw std::runtime_error("feature needs a body"); + TopoDS_Shape result = bodies[t].shape; // shallow handle; apply_feature mutates it + bool have_body = true; + // A subtraction whose tool misses the target is a legal boolean that removes nothing, so + // OCCT reports IsDone() and the feature lands in the recipe reporting success. A caller — + // an agent especially — then has no signal at all that the hole it asked for was never + // drilled: same body, same volume, ok:true. Measure the volume across the op and refuse + // the no-op. Only for removals: every other feature type may legitimately leave the volume + // alone (a Transform certainly does). daf. + const bool removes = f.type == CadFeatureType::Hole + || f.type == CadFeatureType::Thread + || ((f.type == CadFeatureType::Extrude || f.type == CadFeatureType::Revolve + || f.type == CadFeatureType::Sweep || f.type == CadFeatureType::Loft) + && f.mode == BooleanMode::Cut); + const double before = removes ? solid_volume(result) : 0.0; + apply_feature(result, have_body, context, f); + if (removes && before > 0.0) { + const double after = solid_volume(result); + // Relative tolerance: a cut that shaves a numerically invisible sliver is a miss too, + // and an absolute epsilon would be wrong across the mm-to-metre range of real parts. + if (after >= before - 1e-9 * std::max(1.0, before)) + throw std::runtime_error(std::string( + f.type == CadFeatureType::Hole ? "hole" : + f.type == CadFeatureType::Thread ? "thread" : "cut") + + " removed no material — the tool does not intersect the target body" + " (coordinates are in the sketch plane's frame, not world)"); + } + bodies[t].shape = result; + } +} + +bool CadDocument::recompute() +{ + error.clear(); + detect_mate_conflicts(); + std::vector built; + // Did any feature in this document even ASK for a solid? A document made only of sketches + // and datums has nothing to build, and that is a legitimate state — it is every document + // between drawing the first profile and extruding it. Reporting it as a failure is what + // made a sketch-only design unsaveable AND unopenable: DesignPanel::recompute_guarded syncs + // the 3MF recipe only "on success", so nothing was written, and deserialize_recipe ends with + // `return recompute()`, so a project that did carry a recipe was refused on load with + // "Could not restore the CAD model" while its features sat correctly in the list. mtav. + bool any_solid_feature = false; + try { + // Parametric pass: evaluate document variables, then each feature's expression bindings, + // writing the results into the feature's numeric fields before geometry runs. + std::map varvals = evaluate_variables(variables); + for (CadFeature& f : features) + for (const auto& [field, e] : f.expr) + assign_field(f, field, eval_expr(e, varvals)); + for (size_t fi = 0; fi < features.size(); ++fi) { + CadFeature& f = features[fi]; + if (!f.enabled) continue; + if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude + if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path + if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand + if (f.type == CadFeatureType::Axis) continue; // datum axis + if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system + // Past the skips: this feature is one that means to leave a body behind. + any_solid_feature = true; + if (f.type == CadFeatureType::Project) { apply_project(built, f); } + else { route_feature(built, f); } + // Record which feature made each body. "Still unset?" is the whole rule, and it is + // sufficient because of an invariant worth stating: NO feature ever replaces a whole + // CadBody. Every in-place op writes only `.shape` (boolean, cut, mirror-fuse, + // transform, dress-up — checked, all 8 sites), so an existing body keeps the stamp it + // was born with; a consumed body is erased outright, taking its stamp with it; and + // the only bodies still at -1 here are the ones THIS feature just pushed. That also + // means a feature type added later needs no change here, as long as it keeps to the + // same invariant. + for (CadBody& b : built) + if (b.source_feature < 0) + b.source_feature = int(fi); + } + } catch (const Standard_Failure& e) { + // OCCT raises Standard_Failure (NOT a std::exception) — must be caught + // here or it escapes the event handler and terminates the app. + error = e.GetMessageString() ? e.GetMessageString() : "OCCT operation failed"; + return false; + } catch (const std::exception& e) { + error = e.what(); + return false; + } catch (...) { + error = "unknown geometry error"; + return false; + } + if (built.empty() && any_solid_feature) { error = "no solid-producing features"; return false; } + + // A feature that leaves a body with a null shape must fail loudly. Until this existed, + // recompute() returned true and the document kept advertising the body: describe_scene + // counted it, error was empty, and only mass_properties on that specific body revealed + // anything was wrong. Returning false hands the caller its normal rollback path, so the + // operation that destroyed the body is undone rather than committed. + for (size_t i = 0; i < built.size(); ++i) { + if (!built[i].shape.IsNull()) continue; + const int src = built[i].source_feature; + // source_feature is -1 for a body no feature claims. "feature 0" would be a lie, and + // this message exists precisely to be trusted about which feature to look at. + const std::string fname = + (src < 0 || src >= int(features.size())) + ? std::string("an unidentified feature") + : (features[src].name.empty() ? ("feature " + std::to_string(src + 1)) + : features[src].name); + error = "body " + std::to_string(i + 1) + " was destroyed by " + fname + + " (the operation produced an empty shape)"; + return false; + } + + // recompute() replaces the bodies vector wholesale, which would drop any per-body + // colour override (Color tool). Body indices are stable across a rebuild (bodies are + // appended in feature order), so carry the override forward by index — same indexing + // contract the GUI relies on for per-body visibility/Move. + for (size_t i = 0; i < built.size() && i < bodies.size(); ++i) { + if (bodies[i].has_color) { + built[i].has_color = true; + built[i].color = bodies[i].color; + } + // ...and the name the user gave the body, for the same reason and by the same index + // contract. Without this a rename would live exactly until the next feature was added. + if (bodies[i].has_user_name) { + built[i].has_user_name = true; + built[i].user_name = bodies[i].user_name; + } + } + bodies = std::move(built); + // The face and edge maps have just been rebuilt, so every global id handed out before this + // point now means something else. Bump here rather than in each mutator: this is the single + // line where the topology is actually replaced, so it cannot be forgotten by a new feature + // type the way a per-mutator bump would be. + ++topo_generation; + + // Face-drift fingerprint for FaceAndDirection CoordSys connectors. This runs AFTER the + // bodies are final and APPENDS to mate_conflicts (detect_mate_conflicts() cleared it at + // the top of recompute() and must not run again here). A mismatch is a WARNING, not an + // error: a legitimate Draft on a mated face renumbers nothing but a real renumber after a + // dress-up silently points the connector at a different face, and that must not abort. + for (int fi = 0; fi < int(features.size()); ++fi) { + CadFeature& f = features[fi]; + if (!f.enabled) continue; + if (f.type != CadFeatureType::CoordSys) continue; + if (f.coordsys_type != CoordSysType::FaceAndDirection) continue; + if (f.coordsys_body < 0 || f.coordsys_body >= int(bodies.size())) continue; + TopoDS_Face face = GeometryEngine::face_by_index(bodies[f.coordsys_body].shape, f.coordsys_face); + if (face.IsNull()) continue; // "face not found" is already reported by datum_frame() + const int kind = int(BRepAdaptor_Surface(face).GetType()); + const int edges = int(GeometryEngine::edges_of_face(face).size()); + if (f.coordsys_face_kind < 0) { + // No fingerprint yet (old recipe, or a connector never resolved): adopt the state + // the user last saw. Self-heals old recipes on first load, and gives both existing + // writers the fingerprint for free. + f.coordsys_face_kind = kind; + f.coordsys_face_edges = edges; + continue; + } + if (f.coordsys_face_kind != kind || f.coordsys_face_edges != edges) { + mate_conflicts.emplace_back(int(fi), + "connector \"" + f.name + "\" may have moved to a different face " + "(an upstream edit renumbered this body's faces)"); + } + } + + body = compound_of(bodies); + display_mesh = tessellate_bodies(bodies, display_tri_face, display_tri_body, + display_body_meshes, + linear_deflection, angular_deflection); + if (display_mesh.its.indices.empty() && any_solid_feature) { + // Empty only because there are no bodies to tessellate is the same legitimate state as + // above: a sketch-only document has nothing to draw as a solid, and that is not a fault. + error = "tessellation produced an empty mesh"; + return false; + } + return true; +} + +bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, + std::vector& out_body_meshes, std::string& err) const +{ + err.clear(); + out_body_meshes.clear(); + std::vector tmp = bodies; // start from the current committed bodies + try { + route_feature(tmp, candidate); // candidate may append a new body or mutate one + } catch (const Standard_Failure& e) { + err = e.GetMessageString() ? e.GetMessageString() : "OCCT operation failed"; + return false; + } catch (const std::exception& e) { + err = e.what(); + return false; + } catch (...) { + err = "unknown geometry error"; + return false; + } + if (tmp.empty()) { + err = "preview produced no geometry"; + return false; + } + // Tessellate per body (same path as recompute) so the GUI can re-apply its display-only + // per-body Move transforms to the ghost; out_mesh is the merged whole. + std::vector tf, tb; + out_mesh = tessellate_bodies(tmp, tf, tb, out_body_meshes, linear_deflection, angular_deflection); + if (out_mesh.its.indices.empty()) { + err = "preview produced an empty mesh"; + return false; + } + return true; +} + +bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, std::string& err) const +{ + std::vector ignore; + return preview(candidate, out_mesh, ignore, err); +} + +std::string brep_to_string(const TopoDS_Shape& s) +{ + if (s.IsNull()) return {}; + std::ostringstream oss; + BRepTools::Write(s, oss); + return oss.str(); +} + +TopoDS_Shape brep_from_string(const std::string& d) +{ + if (d.empty()) return {}; + std::istringstream iss(d); + TopoDS_Shape s; + BRep_Builder b; + BRepTools::Read(s, iss, b); + return s; +} + +std::string CadDocument::serialize_recipe() const +{ + std::ostringstream oss; + { + cereal::BinaryOutputArchive ar(oss); + uint32_t v = ORCA_CAD_RECIPE_VERSION; + ar(v); + uint32_t n = static_cast(features.size()); + ar(n); + for (const CadFeature& f : features) { + std::ostringstream fos; + { + cereal::BinaryOutputArchive fa(fos); + fa(f); + } + std::string fb = fos.str(); + uint32_t len = static_cast(fb.size()); + ar(len); + ar(cereal::binary_data(fb.data(), fb.size())); + } + std::ostringstream vos; + { + cereal::BinaryOutputArchive va(vos); + va(variables); + } + std::string vb = vos.str(); + uint32_t vlen = static_cast(vb.size()); + ar(vlen); + ar(cereal::binary_data(vb.data(), vb.size())); + // BODY NAMES, appended after the variables block. Bodies are not serialised — they are + // recomputed — so a name the user gave one has nowhere else to live, and without this it + // would survive a recompute (see the carry-over in recompute()) but not a save. + // + // APPENDED RATHER THAN VERSION-BUMPED, on purpose: a build that predates this block + // reads features and variables, returns, and never looks at the trailing bytes, so its + // projects still open here AND this build's projects still open there. A version bump + // would have made every project written today unreadable by yesterday's build for the + // sake of one optional field. Written as (index, name) pairs so an unnamed body costs + // nothing. + // A map, not a vector of pairs: cereal's map support is already included here and its + // pair support is not, and one more include for one more field is not worth it. + std::map named; + for (uint32_t i = 0; i < bodies.size(); ++i) + if (bodies[i].has_user_name && !bodies[i].user_name.empty()) + named[i] = bodies[i].user_name; + std::ostringstream bos; + { + cereal::BinaryOutputArchive ba(bos); + ba(named); + } + std::string bb = bos.str(); + uint32_t blen = static_cast(bb.size()); + ar(blen); + ar(cereal::binary_data(bb.data(), bb.size())); + } + return oss.str(); +} + +bool CadDocument::deserialize_recipe(const std::string& blob) +{ + error.clear(); + try { + std::istringstream iss(blob); + cereal::BinaryInputArchive ar(iss); + uint32_t v; + ar(v); + if (v > ORCA_CAD_RECIPE_VERSION) { + error = "saved with a newer version of the Design tab (format v" + + std::to_string(v) + ", this build reads up to v" + + std::to_string(ORCA_CAD_RECIPE_VERSION) + ")"; + return false; + } + // The framed layout has been byte-identical since v5 (v6 advanced the stamp without + // touching the bytes), so every version from 5 up is read below. A future bump that DOES + // change the framing must exclude itself there — this is what forces that decision + // instead of letting a v7 blob be silently misread by the v5 reader. + static_assert(ORCA_CAD_RECIPE_VERSION <= 6, + "recipe version bumped: confirm the new version still uses the v5 framing"); + if (v >= 5) { + // Framed path: every feature is a length-prefixed self-contained cereal stream, so + // the same four lines handle BOTH directions of mismatch. Older file, newer build: + // the sub-stream ends early, fa(f) throws, and the fields already assigned are kept + // (cereal assigns sequentially, so a mid-list throw leaves the earlier fields set) + // while the rest default. Newer file, older build: the sub-stream has MORE bytes + // than this build knows how to read; it reads what it knows and simply stops, and + // the outer stream is unaffected because the length prefix was consumed in full. + features.clear(); + uint32_t count; + ar(count); + for (uint32_t i = 0; i < count; ++i) { + uint32_t len; + ar(len); + std::string buf(len, '\0'); + if (len > 0) + ar(cereal::binary_data(&buf[0], len)); // consume EXACTLY len bytes from the outer stream + CadFeature f; + try { + std::istringstream fs(buf); + cereal::BinaryInputArchive fa(fs); + fa(f); + } catch (...) { + // An OLDER file: the blob ran out before this build's field list did. Everything read + // so far is kept and the remaining fields stay at their defaults. Deliberately NOT an + // error — a field a project predates is not a corrupt project. + } + features.push_back(f); + } + variables.clear(); + uint32_t vlen; + ar(vlen); + std::string vbuf(vlen, '\0'); + if (vlen > 0) + ar(cereal::binary_data(&vbuf[0], vlen)); + try { + std::istringstream vs(vbuf); + cereal::BinaryInputArchive va(vs); + va(variables); + } catch (...) { + // Variables blob predates this build: keep what was read, default the rest. + } + // Body names, if this project carries them. A project written before the block + // simply ends here, so the read throws and there are no names — not an error. + std::map named; + try { + uint32_t blen; + ar(blen); + std::string bbuf(blen, '\0'); + if (blen > 0) + ar(cereal::binary_data(&bbuf[0], blen)); + std::istringstream bs(bbuf); + cereal::BinaryInputArchive ba(bs); + ba(named); + } catch (...) { + named.clear(); + } + // AFTER the rebuild, never before: recompute() replaces the bodies vector wholesale. + const bool ok = recompute(); + for (const auto& kv : named) + if (kv.first < bodies.size()) { + bodies[kv.first].has_user_name = true; + bodies[kv.first].user_name = kv.second; + } + return ok; + } + if (v == 4) { + // Pre-framing flat path, unchanged: v4 projects keep opening exactly as before. + ar(features); + ar(variables); + return recompute(); + } + // v < 4: the field lists for v2/v3 no longer exist in this code, so those files + // cannot be recovered here. This fix is for the future, not the past. + error = "saved with an older version of the Design tab (format v" + + std::to_string(v) + "); this project cannot be opened by this build"; + return false; + } catch (const Standard_Failure& e) { + const char* what = e.GetMessageString(); + error = std::string("CAD data could not be read") + + (what && *what ? ": " + std::string(what) : ""); + return false; + } catch (const std::exception& e) { + error = std::string("CAD data could not be read: ") + e.what(); + return false; + } catch (...) { + error = "CAD data could not be read"; + return false; + } +} + +bool CadDocument::export_step(const std::string& path, + const std::vector& body_xforms, + std::string& err) const +{ + err.clear(); + if (bodies.empty()) { err = "nothing to export"; return false; } + try { + // Compound every body at its displayed (Move-gizmo) position so the STEP matches + // what Commit ships. Move transforms are rigid, so gp_Trsf::SetValues is valid. + BRep_Builder bld; + TopoDS_Compound comp; + bld.MakeCompound(comp); + for (size_t i = 0; i < bodies.size(); ++i) { + if (bodies[i].shape.IsNull()) continue; + TopoDS_Shape s = bodies[i].shape; + if (i < body_xforms.size() && !body_xforms[i].isApprox(Transform3d::Identity())) { + const Transform3d& m = body_xforms[i]; + gp_Trsf t; + t.SetValues(m(0,0), m(0,1), m(0,2), m(0,3), + m(1,0), m(1,1), m(1,2), m(1,3), + m(2,0), m(2,1), m(2,2), m(2,3)); + s = BRepBuilderAPI_Transform(s, t, true).Shape(); + } + bld.Add(comp, s); + } + STEPControl_Writer writer; + if (writer.Transfer(comp, STEPControl_AsIs) != IFSelect_RetDone) { + err = "STEP transfer failed"; + return false; + } + if (writer.Write(path.c_str()) != IFSelect_RetDone) { + err = "cannot write STEP file"; + return false; + } + } catch (const Standard_Failure& e) { + err = e.GetMessageString() ? e.GetMessageString() : "OCCT failed to write STEP"; + return false; + } catch (const std::exception& e) { + err = e.what(); + return false; + } + return true; +} + +GeometryEngine::MassProps CadDocument::body_mass_properties(int body_index) const +{ + if (body_index < 0 || body_index >= int(bodies.size())) return {}; + return GeometryEngine::mass_properties(bodies[body_index].shape); +} + +int CadDocument::add_surface_extrude(int sketch_ref, double distance, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::SurfaceExtrude; + f.name = name; + f.sketch_ref = sketch_ref; + f.distance = distance; + f.mode = BooleanMode::New; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_surface_revolve(int sketch_ref, double angle_deg, int axis, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::SurfaceRevolve; + f.name = name; + f.sketch_ref = sketch_ref; + f.revolve_angle = angle_deg; + f.revolve_axis = axis; + f.mode = BooleanMode::New; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_surface_loft(const std::vector& profile_refs, bool ruled, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::SurfaceLoft; + f.name = name; + f.loft_profile_refs = profile_refs; + f.loft_ruled = ruled; + f.mode = BooleanMode::New; + features.push_back(f); + return int(features.size()) - 1; +} + +int CadDocument::add_surface_fill(int sketch_ref, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::SurfaceFill; + f.name = name; + f.sketch_ref = sketch_ref; + f.mode = BooleanMode::New; + features.push_back(f); + return int(features.size()) - 1; +} + +std::vector CadDocument::check_interference(double min_volume) const +{ + std::vector out; + const int n = int(bodies.size()); + + for (int i = 0; i < n; ++i) { + if (bodies[i].shape.IsNull() || is_sheet_shape(bodies[i].shape)) continue; + for (int j = i + 1; j < n; ++j) { + if (bodies[j].shape.IsNull() || is_sheet_shape(bodies[j].shape)) continue; + + double v = 0; + // A boolean that blows up on one pair must not lose the report for the others, + // and OCCT signals those as Standard_Failure, which is NOT a std::exception. + try { + BRepAlgoAPI_Common common(bodies[i].shape, bodies[j].shape); + common.Build(); + if (!common.IsDone()) continue; + const TopoDS_Shape s = common.Shape(); + if (s.IsNull()) continue; + GProp_GProps props; + BRepGProp::VolumeProperties(s, props); + v = std::abs(props.Mass()); + } catch (const Standard_Failure&) { + continue; + } + + // Bodies that merely touch share a face and enclose no volume, so the + // threshold is what separates contact from interference. + if (v > min_volume) out.push_back({i, j, v}); + } + } + return out; +} + +// ponytail: derived from the OCCT shape type; no stored flag, bodies aren't serialized anyway. +bool CadDocument::is_sheet_shape(const TopoDS_Shape& s) +{ + return !TopExp_Explorer(s, TopAbs_SOLID).More(); +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/CadDocument.hpp b/src/libslic3r/CAD/CadDocument.hpp new file mode 100644 index 0000000000..f62536384a --- /dev/null +++ b/src/libslic3r/CAD/CadDocument.hpp @@ -0,0 +1,776 @@ +#ifndef slic3r_CadDocument_hpp_ +#define slic3r_CadDocument_hpp_ + +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/CAD/GeometryEngine.hpp" // FaceGroup +#include "libslic3r/Color.hpp" // ColorRGBA (per-body display colour override) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix, Transform, Thicken, Project, DeleteFace, Rib, SurfaceExtrude, SurfaceRevolve, ThickenSurface, SurfaceOffset, SurfaceLoft, SurfaceFill, Mate }; +enum class SketchShape { Rectangle, Circle }; +enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident }; +enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge }; +enum class CoordSysType { PointWorld, FaceAndDirection }; +enum class BooleanMode { New, Add, Cut, Intersect }; + +enum class ExtrudeEnd { Blind, Symmetric, TwoSided, ThroughAll, UpToFace, UpToVertex }; + +// Serialize a TopoDS_Shape to/from a BRep string (declared before CadFeature so its +// inline cereal save()/load() can resolve these non-dependent calls). +std::string brep_to_string(const TopoDS_Shape& s); +TopoDS_Shape brep_from_string(const std::string& d); + +struct CadFeature { + CadFeatureType type{CadFeatureType::Sketch}; + std::string name; + bool enabled{true}; + + // Sketch params (centered on the plane origin) + SketchShape shape{SketchShape::Rectangle}; + SketchPlane plane{SketchPlane::XY()}; + double width{20}; + double height{20}; + double radius{10}; + + // Real 2D sketch geometry (Onshape-style). When non-empty this takes + // precedence over the shape/width/height/radius enum path in build_sketch_wire. + SketchProfile profile; + + // Onshape-style multi-entity sketch geometry. When non-empty this takes + // precedence over both `profile` and the shape-enum path in build_sketch_wire. + std::vector entities; + + // 2D geometric constraints on `profile` (point indices). Solved in place. + std::vector constraints; + + // Onshape-style constraints on `entities` (Fase 4.2). Solved in place against + // entity endpoints. Used when `entities` is non-empty (the legacy `constraints` + // vector applies only to the `profile` path). + std::vector entity_constraints; + + // Imported rigid 2D art (Text glyphs / SVG vector paths) as filled regions. + // Each region: contour[0] = outer loop, contour[1..] = holes; points in + // plane (u,v) millimetres. Rendered as a sketch overlay and extruded via a + // faces-with-holes path (SketchEngine::make_extrude_regions) — deliberately + // NOT solver entities, so imported art contributes zero DoF and never + // pollutes the constraint solver / DoF readout. When non-empty it takes + // precedence over the entities/profile/shape paths in the Extrude case. + std::vector>> imported_regions; + + // Imported rigid 3D B-rep solid (STEP). When the feature type is Import this carries + // the OCCT shape verbatim — it is adopted as a base body in route_feature (no parametric + // recipe). Downstream face/edge features (fillet/chamfer/cut/shell/...) act on it like any + // other body. TopoDS_Shape is a cheap handle, so copying it through recompute/checkpoint + // snapshots is cheap. In-session only for now (no BRep serialization yet). + TopoDS_Shape imported_solid; + + // Non-destructive placement transform for imported_regions (Text/SVG), + // applied at display + extrude time as + // p -> (p.x*import_scale_x + import_offset.x, p.y*import_scale_y + import_offset.y). + // Lets the art be moved / enlarged / stretched (independent X/Y) repeatedly + // without re-vectorising. Identity = no change. + Vec2d import_offset{0, 0}; + double import_scale_x{1.0}; + double import_scale_y{1.0}; + // Text/SVG dropped ONTO a solid face (centred on it): the extrude then defaults to an + // inward Cut (engraving) targeting `import_face_body`. False = free art on a plane. + bool import_on_face{false}; + int import_face_body{-1}; + + // Extrude params + int sketch_ref{-1}; // index into features[] of the consumed sketch + double distance{10}; + bool symmetric{false}; + BooleanMode mode{BooleanMode::New}; + ExtrudeEnd extrude_end{ExtrudeEnd::Blind}; + double distance2{0}; // second-side depth for TwoSided + double taper_deg{0}; // draft angle (C4-part2) + bool flip{false}; // reverse the extrude direction (negate plane normal) + int up_to_face{-1}; // target solid-face id for UpToFace (C4-part2) + int extrude_src_face{-1}; // global face id on the current body to extrude as a profile; -1 = use sketch wire + Vec3d up_to_point{0,0,0}; // target for UpToVertex (C4-part2) + // Multi-body target: which body (index into CadDocument::bodies) this feature acts on. + // -1 = auto (last body). A New extrude appends a fresh body; Add/Cut/Intersect, dress-up, + // hole and face-extrude(non-New) mutate bodies[target]; face-extrude reads its source + // face from bodies[target] too. The source-face owner for face-extrude lives here. + int target_body{-1}; + + // Dress-up params (Fillet/Chamfer) — applied to the current body in order + double dressup_size{1.0}; // fillet radius or chamfer distance + FaceGroup face_group{FaceGroup::All}; + int dressup_edge{-1}; // global edge id for edge-targeted fillet/chamfer; -1 = use face_group + + // Hole params (positioned circular cut into the current body) + double hole_diameter{5}; + double hole_depth{10}; + bool hole_through{true}; // true = symmetric through-cut, ignores hole_depth + double hole_x{0}; // position on the plane (plane u/x axis) + double hole_y{0}; // position on the plane (plane v/y axis) + + // Hole standards library (extends the plain bore above). + // hole_style: 0 = simple, 1 = counterbore, 2 = countersink. + int hole_style{0}; + double hole_cbore_diameter{0}; // counterbore cylinder diameter (mm), style==1 + double hole_cbore_depth{0}; // counterbore depth from the entry face (mm), style==1 + double hole_csink_diameter{0}; // countersink major diameter at entry face (mm), style==2 + double hole_csink_angle{90}; // countersink included angle (degrees), style==2 + std::string hole_standard; // provenance only, e.g. "M6" / "1/4-20"; not used by geometry + + // Thread params (helical thread about the plane normal at a positioned point) + double thread_radius{5}; // nominal cylinder radius + double thread_pitch{2}; // axial advance per turn + double thread_height{10}; // total axial length + double thread_depth{1}; // radial crest depth of the thread profile + bool thread_internal{false}; // false = external threaded rod (New body); + // true = tapped bore cut into the current body + double thread_x{0}; // axis position on the plane (u/x axis) + double thread_y{0}; // axis position on the plane (v/y axis) + + // Shell params (hollow the current body to a wall thickness, removing one open face) + double shell_thickness{2}; // wall thickness (inward offset) + int shell_face{-1}; // global face id to remove (open the shell); -1 = none + + // Draft params (taper a single solid face about a neutral plane = body bbox bottom, pull +Z) + int draft_face{-1}; // global face id to draft; -1 = none + double draft_angle{5}; // draft angle in degrees (signed: + leans the face inward) + + // Revolve params (sweep a profile about an in-plane axis through the plane origin). + // Reuses sketch_ref / entities (profile), flip (direction), mode (boolean) and + // target_body. revolve_axis: 0 = plane X axis, 1 = plane Y axis. + double revolve_angle{360}; // sweep angle in degrees (1..360) + int revolve_axis{0}; // 0 = plane X, 1 = plane Y + + // Sweep: profile carried by sketch_ref / entities (like Extrude); the spine is a + // second Sketch referenced by sweep_path_ref (an open or closed wire). Reuses + // mode (boolean) and target_body. + int sweep_path_ref{-1}; // index into features[] of the path Sketch + + // Loft: build a solid through 2+ closed profile Sketches (loft_profile_refs, in + // order, each on its own plane). loft_ruled=false → smooth sections, true → ruled. + // Reuses mode (boolean) and target_body. + std::vector loft_profile_refs; // ordered indices into features[] of profile Sketches + bool loft_ruled{false}; + + // Pattern: replicate the target body, copies fused into it. pattern_circular=false + // → linear (pattern_count instances spaced pattern_spacing along plane axis + // pattern_dir: 0=X, 1=Y); true → circular (pattern_count instances over + // pattern_angle° total about the plane normal through the plane origin, so a seed + // offset from the origin orbits the axis). Reuses target_body + plane. + bool pattern_circular{false}; + int pattern_count{3}; // total instances incl. the seed (>=1) + double pattern_spacing{20}; // linear step (mm) + int pattern_dir{0}; // linear direction: 0 = plane X, 1 = plane Y + double pattern_angle{360}; // circular total angle (degrees) + + // Pattern along a curve: when pattern_curve_sketch >= 0 this mode takes precedence over + // linear/circular. Copies are placed at equal-parameter points along entity + // pattern_curve_entity of sketch pattern_curve_sketch, translated by (P_i - P_0). + int pattern_curve_sketch{-1}; // feature index of the Sketch holding the guide curve + int pattern_curve_entity{-1}; // entity index of the guide curve within that sketch + + // Parametric bindings: field-member-name -> expression string. On recompute() each entry + // is evaluated against the document variables and written into the named numeric field + // BEFORE geometry runs. Empty (the common case) means the feature uses its literal fields. + std::map expr; + + // Datum/reference plane: a derived SketchPlane the document offers as a selectable + // sketch plane (no solid). plane_base selects the reference (0=XY,1=XZ,2=YZ, or 3+N + // = the Nth earlier datum plane); plane_offset shifts along the base normal; + // plane_angle tilts plane_angle° about the base axis plane_axis (0=base X, 1=base Y). + int plane_base{0}; + double plane_offset{20}; + double plane_angle_tilt{0}; // degrees (named *_tilt to avoid clash w/ revolve) + int plane_axis{0}; // tilt axis: 0 = base X, 1 = base Y + PlaneType plane_type{PlaneType::Offset}; + int plane_face_body{-1}; + int plane_face{-1}; + int plane_face2_body{-1}; + int plane_face2{-1}; + int plane_edge_body{-1}; + int plane_edge{-1}; + int plane_edge2_body{-1}; + int plane_edge2{-1}; + double plane_u_size{60}; + double plane_v_size{60}; + + // Boolean: combine two EXISTING bodies. `mode` reuses BooleanMode (Add = union, + // Cut = subtract tool from target, Intersect = keep overlap; New unused). `target_body` + // is the body that survives (result written back to it); `bool_tool_body` is the other + // operand, consumed (erased) unless `bool_keep_tool`. `bool_tolerance` = OCCT fuzzy value + // (0 = exact). Per-face merge: when both bool_target_face/bool_tool_face are set, the tool + // is first snapped so those two faces are coincident (gap closed within bool_tolerance), + // then the boolean welds them and coplanar faces are unified into one clean face. + int bool_tool_body{-1}; + bool bool_keep_tool{false}; + double bool_tolerance{0.0}; + int bool_target_face{-1}; // global face id on the target body to mate (-1 = none) + int bool_tool_face{-1}; // global face id on the tool body to mate (-1 = none) + + // Cut: split one target body with a plane, keeping the upper half, lower half, or both. + // Reuses `plane` for the cut plane and `target_body` for which body is cut. + double cut_offset{0.0}; // offset along the cut-plane normal (mm) + bool cut_flip{false}; // flip the normal => swaps which side is "upper" + bool cut_keep_upper{true}; // keep the +normal half + bool cut_keep_lower{false}; // keep the -normal half (both => split into two bodies) + + // Mirror: reflect a body about a plane. Reuses `plane` (mirror plane, as Cut does), + // `target_body` (body to mirror), and `mode` (New = separate mirrored copy, + // Add = fuse the mirror back into the source). mirror_keep_original decides whether + // the source body survives when mode is New. + bool mirror_keep_original{true}; + + // Datum axis: reference line (no solid). Construction params stored; resolve_datum_axes() + // computes the world-space origin + unit direction on demand. + AxisType axis_type{AxisType::TwoPoints}; + Vec3d axis_p1{0, 0, 0}; + Vec3d axis_p2{0, 0, 10}; + int axis_body{-1}; + int axis_face{-1}; + int axis_edge{-1}; + int axis_plane_a{-1}; + int axis_plane_b{-1}; + + // Datum coordinate system (no solid). Stored as point + two orthonormal axes. + CoordSysType coordsys_type{CoordSysType::PointWorld}; + Vec3d coordsys_point{0, 0, 0}; + int coordsys_body{-1}; + int coordsys_face{-1}; + int coordsys_edge{-1}; + Vec3d coordsys_x_hint{1, 0, 0}; + + // Fingerprint of the face this connector was bound to, for drift detection. -1 = not yet + // recorded (an old recipe, or a connector that has never resolved). + // + // Surface TYPE and EDGE COUNT specifically, because they survive every legitimate edit: + // Transform moves the body, Draft tilts the face, a dimension change resizes it, and none + // of those change either value. Centroid, area and normal all fail that test — see the + // issue. The cost is that a slide from one planar 4-edge face to another planar 4-edge face + // is invisible; a detector that never cries wolf is worth more here than a total one. + int coordsys_face_kind{-1}; // GeomAbs_SurfaceType as int + int coordsys_face_edges{-1}; // number of edges bounding the face + + // Helix curve params (consumed as a sweep path to build springs/coils/augers). + // Axis = plane normal through plane origin. pitch = axial rise per full turn. + // left_handed flips the winding direction. taper_deg != 0 gives a conical helix. + double helix_radius{10}; + double helix_pitch{5}; + double helix_height{20}; + bool helix_left_handed{false}; + double helix_taper_deg{0}; + + // Transform feature: rigid move/rotate of an existing body. Rotation is applied + // first (about xf_axis through xf_pivot), then the translation. + Vec3d xf_translate{0, 0, 0}; + Vec3d xf_axis{0, 0, 1}; + Vec3d xf_pivot{0, 0, 0}; + double xf_angle_deg{0}; + bool xf_copy{false}; // true: keep the original, append the moved copy as a new body + + // Thicken feature: offset one face of an existing body into a new thin solid body. + // The face belongs to `target_body`; the offset runs along the face normal. + int thicken_face{-1}; // global face id on the target body; -1 = invalid + double thicken_thickness{2}; // wall thickness (always used as |value|) + bool thicken_flip{false}; // true: offset against the face normal + + // Cut-by-face: when cut_face >= 0, apply_cut derives the cut plane from this face + // (via SketchPlane::from_face) instead of the base `plane`. cut_offset / cut_flip + // still apply along the derived normal. + int cut_face_body{-1}; // body owning the face; -1 = the target body + int cut_face{-1}; // global face id to cut along; -1 = use `plane` + + // Project feature: convert edges of an existing solid into sketch entities on `plane`. + int project_source_body{-1}; // body owning the edges; -1 = last body + std::vector project_edges; // global edge ids to project; empty => use project_face + int project_face{-1}; // if project_edges empty, project every edge of this face + + // Direct edit: faces to remove (global face indices into target_body's shape), + // healed via BRepAlgoAPI_Defeaturing. + std::vector delete_faces; + + // Rib: a thin wall grown from an open sketch line, fused to the body. + int rib_sketch_ref{-1}; // feature index of the Sketch holding the profile + int rib_entity{-1}; // index of the open Line entity within that sketch + double rib_thickness{2}; // wall thickness (mm), centred on the line + double rib_depth{10}; // extrude distance along the sketch-plane normal (mm) + + // --- Mate (assembly) --- + // 0 Fastened — all 6 DOF fixed: B's frame is driven onto A's exactly. + // 1 Planar — z axes aligned, normal distance set to mate_offset; in-plane position free. + // 2 Revolute — axes collinear, position on the axis fixed; rotation about z free. + // 3 Slider — orientation fully fixed, perpendicular position fixed; axial slide free. + // 4 Cylindrical— axes collinear, perpendicular fixed; both spin and axial slide free. + // A "free" DOF is preserved from the body's current placement, not zeroed. + int mate_kind{0}; + int mate_cs_a{-1}; // feature index of the FIXED CoordSys (mate connector A) + int mate_cs_b{-1}; // feature index of the CoordSys on the body that MOVES + double mate_offset{0}; // translation along A's z, mm + double mate_angle{0}; // rotation about A's z, degrees + bool mate_flip{false}; // oppose the two z axes (face-to-face) + + template + void save(Archive& ar) const { + std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); + ar(type, name, enabled, shape, plane, width, height, radius, + profile, entities, constraints, entity_constraints, imported_regions, + import_offset, import_scale_x, import_scale_y, import_on_face, import_face_body, + sketch_ref, distance, symmetric, mode, extrude_end, distance2, taper_deg, flip, + up_to_face, extrude_src_face, up_to_point, target_body, + dressup_size, face_group, dressup_edge, + hole_diameter, hole_depth, hole_through, hole_x, hole_y, + thread_radius, thread_pitch, thread_height, thread_depth, thread_internal, thread_x, thread_y, + shell_thickness, shell_face, + draft_face, draft_angle, + revolve_angle, revolve_axis, + sweep_path_ref, loft_profile_refs, loft_ruled, + pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle, + plane_base, plane_offset, plane_angle_tilt, plane_axis, + bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, + cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, + brep, + plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, + plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, + mirror_keep_original, + axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, + helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg, + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip, + cut_face_body, cut_face, + project_source_body, project_edges, project_face, + delete_faces, + hole_style, hole_cbore_diameter, hole_cbore_depth, + hole_csink_diameter, hole_csink_angle, hole_standard, + rib_sketch_ref, rib_entity, rib_thickness, rib_depth, + pattern_curve_sketch, pattern_curve_entity, + expr, + mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip, + coordsys_face_kind, coordsys_face_edges); + } + template + void load(Archive& ar) { + std::string brep; + ar(type, name, enabled, shape, plane, width, height, radius, + profile, entities, constraints, entity_constraints, imported_regions, + import_offset, import_scale_x, import_scale_y, import_on_face, import_face_body, + sketch_ref, distance, symmetric, mode, extrude_end, distance2, taper_deg, flip, + up_to_face, extrude_src_face, up_to_point, target_body, + dressup_size, face_group, dressup_edge, + hole_diameter, hole_depth, hole_through, hole_x, hole_y, + thread_radius, thread_pitch, thread_height, thread_depth, thread_internal, thread_x, thread_y, + shell_thickness, shell_face, + draft_face, draft_angle, + revolve_angle, revolve_axis, + sweep_path_ref, loft_profile_refs, loft_ruled, + pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle, + plane_base, plane_offset, plane_angle_tilt, plane_axis, + bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, + cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, + brep, + plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, + plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, + mirror_keep_original, + axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, + helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg, + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip, + cut_face_body, cut_face, + project_source_body, project_edges, project_face, + delete_faces, + hole_style, hole_cbore_diameter, hole_cbore_depth, + hole_csink_diameter, hole_csink_angle, hole_standard, + rib_sketch_ref, rib_entity, rib_thickness, rib_depth, + pattern_curve_sketch, pattern_curve_entity, + expr, + mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip, + coordsys_face_kind, coordsys_face_edges); + imported_solid = brep_from_string(brep); + } +}; + +// Serialize a TopoDS_Shape to/from a BRep string for cereal persistence. +std::string brep_to_string(const TopoDS_Shape& s); +TopoDS_Shape brep_from_string(const std::string& d); + +// One independent solid in a multi-body document. +struct CadBody { + TopoDS_Shape shape; + std::string name; + // The name the USER gave this body. A body is NOT its first feature: an Extrude, a Cut and + // a Fillet all land on the same body, so renaming `source_feature` renames one operation in + // the history, not the object — which is exactly the bug this field exists to end. `name` + // above is the DERIVED label (the maker's name, restamped every recompute) and stays that; + // this one is set only by a rename, carried across recompute() by body index, and written + // into the recipe so it survives save/load. + bool has_user_name{false}; + std::string user_name; + // Per-body display colour override (Color tool). When has_color is false the GUI + // falls back to the auto body-index palette. Carried across recompute() by body index. + bool has_color{false}; + ColorRGBA color; + // Index into `features` of the feature that CREATED this body, or -1. A body is a + // recomputed result, so without this there is no way back to its maker and "delete this + // body" cannot be expressed at all — the GUI could only answer "select the FEATURE that + // created this body". Stamped in one place, the recompute loop; see the note there for + // why a single "still unset?" test is sufficient and stays correct for new feature types. + int source_feature{-1}; +}; + +// OCCT-only feature tree backing the Design tab. No GUI dependencies (lives in libslic3r). +class CadDocument { +public: + std::vector features; + // Named document variables: name -> expression. Evaluated topologically each recompute(); + // an expression may reference other variables. Feature `expr` bindings resolve against these. + std::map variables; + // Multi-body result of the last replay. A "New" extrude appends a body; other ops + // mutate a target body. Empty after a failed/empty recompute. + std::vector bodies; + TopoDS_Shape body; // compound of all bodies (1 body => that body) — display/compat + TriangleMesh display_mesh; // tessellation of all bodies, concatenated (picking) + std::vector display_body_meshes; // one mesh per body, in `bodies` order (per-body color) + std::vector display_tri_face; // per-triangle face id WITHIN its source body + std::vector display_tri_body; // per-triangle source body index (into bodies) + std::string error; // last recompute error ("" = ok) + + // Mate diagnostics, refilled by every recompute(). Non-fatal by design: the + // document still evaluates — this only names what the user should look at. + // .first = index of the offending Mate feature, .second = human-readable reason. + // NOT "over-constraint" — this kernel has no solver, so there is no DOF analysis + // behind these; they are graph facts about which mate drives which body. + std::vector> mate_conflicts; + + // Modeling origin: the world point the default XY/XZ/YZ planes pass through. The GUI sets this + // to the bed centre so sketches/datums land in the middle of the bed (not the bed corner = + // world 0). Not serialized — the GUI re-applies it from the live bed on every tab show. + Vec3d modeling_origin{Vec3d::Zero()}; + + // Tessellation quality, matched to Orca's OWN STEP importer (Format/STEP.hpp defaults: + // linear 0.003, angular 0.5 rad) so a body modelled here reaches the screen at the same + // density as the identical body imported through Prepare. It was 0.01 linear — 3.3x coarser + // than anything else in the app, which is why curved faces read as faceted next to an + // imported part. Angular already matched. Same BRepMesh_IncrementalMesh call, same GLVolume + // path, same shaders: the renderer was never the difference, the mesh fed to it was. + double linear_deflection{0.003}; + double angular_deflection{0.5}; + + int add_sketch(SketchShape shape, const SketchPlane& plane, + double width, double height, double radius, + const std::string& name); + int add_sketch_profile(const SketchProfile& profile, const SketchPlane& plane, + const std::string& name); + // Onshape-style multi-entity sketch: stores the entity list verbatim. When + // non-empty it takes precedence over profile/enum in build_sketch_wire. + int add_sketch_entities(const std::vector& entities, + const SketchPlane& plane, const std::string& name, + const std::vector& constraints = {}); + // Project edges of source_body onto plane, producing a sketch feature whose + // entities are (re)derived on every recompute. + int add_project_edges(int source_body, const std::vector& edge_ids, int face, + const SketchPlane& plane, const std::string& name); + // Onshape's "Use" / SolidWorks' "Convert Entities": project a body's edges onto the plane of + // an EXISTING sketch feature and append them to that sketch as CONSTRUCTION entities, so new + // geometry can be constrained to them. Returns the number of entities appended, or -1 if the + // sketch or body reference is invalid. Unlike add_project_edges this creates no feature: the + // references become part of the sketch that borrows them. + int project_edges_into_sketch(int sketch_feature, int source_body, + const std::vector& edge_ids, int face); + // Append a bridging BSpline entity connecting endpoint `end_a` of entity `ent_a` to + // endpoint `end_b` of entity `ent_b`, both within sketch feature `sketch_ref`. Returns + // the new entity's index within that sketch's entities vector. Non-parametric: computed + // once from the current endpoints (does not auto-follow later solver moves). + int add_bridge(int sketch_ref, int ent_a, int end_a, int ent_b, int end_b, + const std::string& name); + // Solve features[index]'s sketch constraints, writing solved coordinates back + // into its profile.points. No-op (returns true) if the feature has no + // constraints. Returns false if index is invalid / not a Sketch / solve fails. + bool solve_sketch_feature(int index); + int add_extrude(int sketch_ref, double distance, bool symmetric, + BooleanMode mode, const std::string& name); + // Extrude a single loop given directly as entities (sketch_ref = -1, plane carried). + int add_extrude_entities(const std::vector& entities, + const SketchPlane& plane, double distance, bool symmetric, + BooleanMode mode, const std::string& name); + // Extrude an existing solid FACE (global face id on the body) as the profile. + int add_extrude_face(int src_face, double distance, bool symmetric, + BooleanMode mode, const std::string& name); + int add_fillet(double radius, FaceGroup faces, const std::string& name); + int add_fillet(double radius, int edge_id, const std::string& name); + int add_chamfer(double distance, FaceGroup faces, const std::string& name); + int add_chamfer(double distance, int edge_id, const std::string& name); + int add_hole(double diameter, double depth, bool through, + double x, double y, const SketchPlane& plane, + const std::string& name); + int add_hole_styled(double diameter, double depth, bool through, + double x, double y, const SketchPlane& plane, int style, + double cbore_diameter, double cbore_depth, + double csink_diameter, double csink_angle, + const std::string& standard, const std::string& name); + int add_hole_standard(const std::string& designation, int style, bool through, + double depth, double x, double y, + const SketchPlane& plane, const std::string& name); + int add_thread(double radius, double pitch, double height, double depth, + bool internal, double x, double y, const SketchPlane& plane, + const std::string& name); + int add_revolve(int sketch_ref, double angle, int axis, bool flip, + BooleanMode mode, const std::string& name); + // Self-contained revolve of a single loop given directly as entities (sketch_ref=-1). + int add_revolve_entities(const std::vector& entities, + const SketchPlane& plane, double angle, int axis, bool flip, + BooleanMode mode, const std::string& name); + // Sweep the profile Sketch (profile_sketch_ref) along the path Sketch (path_sketch_ref). + int add_pattern(bool circular, int count, double spacing, int dir, + double angle_deg, int target_body, const std::string& name); + // Pattern `count` copies of `target` along entity `curve_entity` of sketch `curve_sketch`. + int add_pattern_on_curve(int count, int curve_sketch, int curve_entity, int target, + const std::string& name); + int add_sweep(int profile_sketch_ref, int path_sketch_ref, BooleanMode mode, + const std::string& name); + // Loft through the ordered profile Sketches (each a closed wire on its own plane). + int add_loft(const std::vector& profile_refs, bool ruled, BooleanMode mode, + const std::string& name); + // Skin 2+ profile sketches open (no end caps) -> a sheet body. + int add_surface_loft(const std::vector& profile_refs, bool ruled, const std::string& name); + // Fill sketch sketch_ref's closed boundary wire with a smooth face -> a one-face sheet body. + int add_surface_fill(int sketch_ref, const std::string& name); + int add_shell(double thickness, int face, int target_body, const std::string& name); + // Grow a thin rib wall (thickness, depth) from the open Line entity `entity` inside sketch + // feature `sketch_ref`, fused to `target_body`. Returns the new feature index. + int add_rib(int sketch_ref, int entity, double thickness, double depth, + int target_body, const std::string& name); + int add_draft(double angle, int face, int target_body, const std::string& name); + // Boolean between two existing bodies. op reuses BooleanMode (Add=union, Cut=subtract, + // Intersect=common; New invalid). target survives, tool is consumed unless keep_tool. + // tolerance = OCCT fuzzy value; target_face/tool_face (-1 = none) drive the per-face snap+merge. + int add_boolean(BooleanMode op, int target_body, int tool_body, bool keep_tool, + double tolerance, int target_face, int tool_face, const std::string& name); + // Plane Cut (Onshape split-by-plane): trim target_body by the plane (origin offset along + // its normal by `offset`, normal flipped iff `flip`). keep_upper/keep_lower select the + // +normal / -normal half; both => the body is split into two coexisting bodies. + int add_cut(const SketchPlane& plane, double offset, bool flip, + bool keep_upper, bool keep_lower, int target_body, const std::string& name); + // Split target_body along the plane of face `face` (owned by face_body, -1 = target). + // keep_upper/keep_lower select which half survives; both => split into two bodies. + int add_split_by_face(int target_body, int face_body, int face, + bool keep_upper, bool keep_lower, const std::string& name); + int add_mirror(const SketchPlane& plane, int target_body, BooleanMode mode, + const std::string& name); + // Rigid body transform: rotate `angle_deg` about `axis` through `pivot`, then translate. + // copy=true keeps the source body and appends the transformed one as a new body. + int add_transform(int target_body, const Vec3d& translate, const Vec3d& axis, + const Vec3d& pivot, double angle_deg, bool copy, const std::string& name); + // Offset face `face` of `target_body` by `thickness` along its normal, producing a new + // thin solid appended as a new body. flip=true offsets against the normal. + int add_thicken(int target_body, int face, double thickness, bool flip, const std::string& name); + // Thicken an entire SHEET body's shell into a solid. + int add_thicken_surface(int target_body, double thickness, bool flip, const std::string& name); + // Offset a SHEET body's shell by a signed distance, producing another SHEET body. + int add_surface_offset(int target_body, double offset, const std::string& name); + int add_delete_face(int target_body, const std::vector& faces, + const std::string& name); + int add_surface_extrude(int sketch_ref, double distance, const std::string& name); + int add_surface_revolve(int sketch_ref, double angle_deg, int axis, const std::string& name); + // Datum plane: derived from base (0=XY/1=XZ/2=YZ/3+N=Nth earlier datum), offset + // along its normal, optional tilt about a base axis. Produces no solid. + int add_plane(int base, double offset, double angle_tilt, int axis, + const std::string& name); + // Datum axis: construction method axis_type determines which ref fields are read. + int add_axis(AxisType axis_type, const std::string& name); + // Datum coordinate system. + int add_coordsys(CoordSysType type, const Vec3d& point, const std::string& name); + int add_mate(int kind, int cs_a, int cs_b, double offset, double angle_deg, bool flip, + const std::string& name); + + // Which mate types apply to a connector pair, as reported to the viewport palette. + struct MateOption { + int kind{0}; // 0..4, the five mate types in CadDocument.hpp:308-314 + bool viable{true}; + std::string reason; // empty when viable; why not, when not + }; + // ALWAYS all five entries, ALWAYS in kind order. Never filtered: the caller dims what is + // not viable rather than hiding it, so the list must be stable in length and order between + // calls. Pure query over existing data — records nothing, mutates nothing. + std::vector mate_options(int cs_a, int cs_b) const; + + int add_helix(const SketchPlane& plane, double radius, double pitch, double height, + bool left_handed, double taper_deg, const std::string& name); + // Build the helix wire from a Helix feature's params (exposed for tests). + TopoDS_Wire build_helix_wire(const CadFeature& f, std::string& err) const; + // Every datum plane currently in the recipe, in feature order, as (name, plane). + // Used by the GUI to populate plane pickers (after the 3 base planes). + std::vector> resolve_datum_planes() const; + + // World-space sketch plane lying on a body's PLANAR face, so a face picked in the viewport can + // be sketched on directly — no datum plane in between and nothing to choose from a list. + // Returns false when the indices don't resolve or the face isn't planar (a cylinder or a fillet + // has no single plane, and guessing one from a mid-parameter normal would silently sketch on a + // tangent). Same derivation the Coincident datum method uses, shared so the two cannot drift. + bool plane_of_face(int body_idx, int face_idx, SketchPlane& out) const; + // Resolved datum axes in feature order. axis_err is non-empty if construction failed. + struct DatumAxis { std::string name; Vec3d origin{0,0,0}; Vec3d direction{0,0,1}; + std::string error; }; + std::vector resolve_datum_axes() const; + // Resolved datum coordinate systems. X/Y unit, orthonormal (Z = X.cross(Y)). + struct DatumCoordSys { std::string name; Vec3d origin{0,0,0}; Vec3d x{1,0,0}; + Vec3d y{0,1,0}; std::string error; }; + std::vector resolve_datum_coordsys() const; + void clear(); + bool recompute(); // replay features -> body + display_mesh; false on error + + // CadRecipe serialization contract: + // - v1 blobs are deliberately not loadable; there is no migration path by design + // - append fields ONLY at the end of save/load, never reorder (golden fixture enforces this) + // Bumped every time the bodies are rebuilt, i.e. every time the face and edge MAPS change. + // Global face/edge ids are indices into TopExp::MapShapes and mean nothing across a rebuild, + // so any caller holding an id from an earlier state is holding a wrong one. This is the + // handle that lets it find out instead of silently addressing the wrong edge. + // + // Session-scoped and deliberately NOT serialized: an id is only meaningful within the run + // that produced it, so persisting the counter would imply a promise across loads that the + // ids themselves cannot keep. + uint64_t topo_generation{1}; + + // v5: every feature is length-framed, so a reader can stop early on an older file and skip + // the tail of a newer one. This is the LAST version that has to break anything — from here a + // new field only needs appending to save/load, with no bump and no orphaned projects. + // v6: no wire-format change — the bytes are v5's, and both are read by the same framed path. + // The stamp advances only to put a project-container change on the record; the 3MF backends + // own that story. v4 still opens through the pre-framing flat path. + static constexpr uint32_t ORCA_CAD_RECIPE_VERSION = 6; + std::string serialize_recipe() const; + bool deserialize_recipe(const std::string& blob); + + // Export every body to a STEP file as native B-rep (not mesh). body_xforms is the + // per-body display transform (Move gizmo); when supplied the bodies are written at + // those positions so the STEP matches what Commit ships. false + err on failure. + bool export_step(const std::string& path, + const std::vector& body_xforms, + std::string& err) const; + + GeometryEngine::MassProps body_mass_properties(int body_index) const; + + // One overlapping pair of solid bodies. Indices are into `bodies`, a_ < b_. + struct Interference { int body_a{-1}; int body_b{-1}; double volume{0}; }; + // Every pair of solid bodies whose intersection encloses more than min_volume (mm^3). + // Reports only — mutates nothing, so mates and placements are unaffected by calling it. + // Sheet bodies are skipped: an intersection involving one encloses no volume. + std::vector check_interference(double min_volume = 1e-6) const; + + // ponytail: derived from the OCCT shape type; no stored flag, bodies aren't serialized anyway. + static bool is_sheet_shape(const TopoDS_Shape& s); // true if TopExp finds no TopAbs_SOLID + + // Undo/redo of the feature recipe (Onshape-style Ctrl+Z). The caller marks a + // user-action boundary by calling checkpoint() BEFORE the mutation(s) for that + // action (add/delete/move/replace, or a direct features edit). undo()/redo() then + // restore the snapshot and recompute(). Because everything else (bodies/meshes/ + // body) is derived by recompute(), snapshotting `features` alone is a complete, + // exact history; one checkpoint == one Ctrl+Z step. + void checkpoint(); // snapshot `features` for undo + invalidate redo + // Drop the most recent checkpoint. For a mutation that took a checkpoint, then failed + // and restored the pre-mutation state itself (the constraint paths reject an + // over-constrained addition this way): the snapshot now describes a state identical to + // the current one, and leaving it turns the next Ctrl+Z into a press that does nothing. + void abandon_checkpoint(); + bool can_undo() const { return !m_undo.empty(); } + bool can_redo() const { return !m_redo.empty(); } + size_t undo_depth() const { return m_undo.size(); } + size_t redo_depth() const { return m_redo.size(); } + bool undo(); // restore the previous feature list + recompute(); false if no history + bool redo(); // re-apply the most recently undone change; false if none + + // Feature-tree editing (Onshape-style). All are transactional: they snapshot + // features, mutate, recompute(), and roll back to the snapshot (re-recomputing) + // if the result is invalid — so a failed edit never leaves a broken body. + // + // remove_feature: erase features[index]; deleting a Sketch cascades to the + // Extrude(s) that consume it; surviving sketch_ref indices are remapped. + // move_feature: shift features[index] by delta (-1 up / +1 down), clamped; + // sketch_ref indices of the two swapped slots are remapped. + // replace_feature: overwrite features[index] with `edited` (its name and, for + // an Extrude, its sketch_ref are preserved from the original). + bool remove_feature(int index); + bool move_feature(int index, int delta); + bool replace_feature(int index, const CadFeature& edited); + // replace_sketch_extrude: a box is two linked features (Sketch + Extrude); + // overwrite both slots from one `edited` candidate (sketch params -> + // features[sketch_idx], extrude params -> features[extrude_idx]), keeping + // each slot's name/type and the sketch_ref link. Transactional like above. + bool replace_sketch_extrude(int sketch_idx, int extrude_idx, const CadFeature& edited); + + // Apply ONE candidate feature on top of the current committed body and + // tessellate the result into out_mesh, WITHOUT modifying features/body/ + // display_mesh. Returns false (with err set) if the candidate is invalid. + // Used by the Design tab to show a translucent ghost before Confirm. + bool preview(const CadFeature& candidate, TriangleMesh& out_mesh, std::string& err) const; + // Same, but also returns the per-body meshes (in `bodies` order; the candidate may append + // one), so the GUI can apply its display-only per-body Move transforms to the ghost and keep + // it overlaid on the moved body instead of floating back at the untransformed origin. + bool preview(const CadFeature& candidate, TriangleMesh& out_mesh, + std::vector& out_body_meshes, std::string& err) const; + +private: + TopoDS_Wire build_sketch_wire(const CadFeature& sketch, bool closed_only = false) const; + // The planar region an Extrude sweeps: the sketch's outer loop with its inner loops as + // holes. Falls back to a face over build_sketch_wire() for the legacy profile/shape paths, + // which have no concept of a second loop. + TopoDS_Face build_sketch_face(const CadFeature& sketch) const; + // Apply a single feature to (result, have_body), throwing std::runtime_error on + // failure. `context` is the body whose faces/edges the feature reads (face-extrude + // source, up-to-face target, dress-up, hole) — it differs from `result` only when the + // feature builds a NEW body from an existing one (face-extrude New). Shared by route. + void apply_feature(TopoDS_Shape& result, bool& have_body, + const TopoDS_Shape& context, const CadFeature& f) const; + // Route one feature into the bodies list: resolve its target body, decide whether it + // starts a new body (empty list, or an Extrude with mode New) vs mutates an existing + // one, then apply_feature. Shared by recompute() (replay all) and preview() (candidate). + void route_feature(std::vector& bodies, const CadFeature& f) const; + // Boolean between two existing bodies: resolve target + tool, optionally snap the tool so + // the picked faces mate, run the OCCT op (with fuzzy tolerance), write the result back to the + // target and erase the consumed tool. Mutates the bodies vector directly (unlike apply_feature, + // which works on a single result shape). Throws std::runtime_error on a failed op. + void apply_boolean(std::vector& bodies, const CadFeature& f) const; + void apply_cut(std::vector& bodies, const CadFeature& f) const; + void apply_mirror(std::vector& bodies, const CadFeature& f) const; + void apply_transform(std::vector& bodies, const CadFeature& f) const; + void apply_thicken(std::vector& bodies, const CadFeature& f) const; + void apply_thicken_surface(std::vector& bodies, const CadFeature& f) const; + void apply_surface_offset(std::vector& bodies, const CadFeature& f) const; + void apply_project(const std::vector& bodies, CadFeature& f) const; + static DatumCoordSys datum_frame(const std::vector& bodies, const CadFeature& f); + void apply_mate(std::vector& bodies, const CadFeature& f) const; + void detect_mate_conflicts(); // refills mate_conflicts from the feature list alone + + // Undo/redo stacks of recipe snapshots. checkpoint() pushes onto m_undo and clears + // m_redo; undo()/redo() shuffle the current state between them. Capped so a long + // session can't grow unbounded. + // + // The snapshot MUST carry `variables` as well as `features`: a caller that sets a bad + // variable, sees recompute() fail and calls undo() to roll it back would otherwise be + // left with the bad variable still in the document, so every later recompute fails — + // the exact corruption the checkpoint/undo pattern exists to prevent. Not serialized, + // so this changes no on-disk format. + struct Snapshot { + std::vector features; + std::map variables; + }; + std::vector m_undo; + std::vector m_redo; + static constexpr size_t k_undo_cap = 200; +}; + +} // namespace Slic3r + +#endif // slic3r_CadDocument_hpp_ diff --git a/src/libslic3r/CAD/GeometryEngine.cpp b/src/libslic3r/CAD/GeometryEngine.cpp new file mode 100644 index 0000000000..ede3924056 --- /dev/null +++ b/src/libslic3r/CAD/GeometryEngine.cpp @@ -0,0 +1,711 @@ +#include "libslic3r/CAD/GeometryEngine.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +// ---- STEP import (B-rep, not mesh) ---- +std::vector GeometryEngine::read_step_solids(const std::string& path, std::string& err) +{ + err.clear(); + std::vector out; + try { + STEPControl_Reader reader; + if (reader.ReadFile(path.c_str()) != IFSelect_RetDone) { + err = "cannot read STEP file"; + return out; + } + reader.TransferRoots(); + const TopoDS_Shape shape = reader.OneShape(); + if (shape.IsNull()) { err = "STEP file has no geometry"; return out; } + // One body per top-level solid; fall back to the whole shape (shells/faces) if none. + for (TopExp_Explorer ex(shape, TopAbs_SOLID); ex.More(); ex.Next()) + out.push_back(ex.Current()); + if (out.empty()) + out.push_back(shape); + } catch (const Standard_Failure& e) { + err = e.GetMessageString() ? e.GetMessageString() : "OCCT failed to read STEP"; + out.clear(); + } + return out; +} + +// ---- Mesh -> B-rep (faceted, shared topology by construction) ---- +// +// Port of mesh2step's brep_build.py. Two properties are load-bearing and easy to lose: +// +// 1. The edge cache is keyed on the UNORDERED vertex-index pair, and a triangle that walks +// the edge backwards (i > j) gets edge.Reversed(). Consistently-wound meshes (STL/OBJ/3MF +// all are) walk every shared edge in opposite directions from its two adjacent triangles, +// so this reversal is exactly what leaves the faces coherently outward-oriented. +// 2. Degeneracy is split in two, deliberately. A triangle is dropped as sub-resolution noise +// only if its longest edge is below `tolerance` (an absolute floor), while sliver rejection +// is scale-INDEPENDENT (area < 1e-9 * longest_edge^2). Folding the two together under one +// `area < tolerance^2` test rejects legitimate thin CAD slivers whenever tolerance is coarse +// relative to them, turning a watertight input into a falsely-open shell — a real regression +// mesh2step hit on a 62k-triangle mechanical part. +TopoDS_Shape GeometryEngine::mesh_to_brep(const indexed_triangle_set& its, + double tolerance, + double merge_angle_deg, + MeshBrepStats& stats) +{ + stats = MeshBrepStats{}; + stats.input_tris = int(its.indices.size()); + if (tolerance <= 0.0) + throw std::runtime_error("mesh_to_brep: tolerance must be > 0"); + if (its.indices.empty()) + throw std::runtime_error("mesh_to_brep: mesh has no triangles"); + + // 1. Tolerance-quantized vertex dedup. A merged vertex keeps the exact coordinates of the + // first input occurrence — vertices are grouped by a cell, never snapped onto its grid. + std::map, int> cell_to_new; + std::vector old_to_new(its.vertices.size(), -1); + std::vector verts; + verts.reserve(its.vertices.size()); + for (size_t i = 0; i < its.vertices.size(); ++i) { + const Vec3d p = its.vertices[i].cast(); + const std::array cell{ (long long) std::llround(p.x() / tolerance), + (long long) std::llround(p.y() / tolerance), + (long long) std::llround(p.z() / tolerance) }; + auto ins = cell_to_new.emplace(cell, int(verts.size())); + if (ins.second) + verts.push_back(p); + old_to_new[i] = ins.first->second; + } + + // 2. Reject degenerate triangles (see the two-part rule in the comment above). + std::vector tris; + tris.reserve(its.indices.size()); + for (const Vec3i32& t : its.indices) { + const int a = old_to_new[t(0)], b = old_to_new[t(1)], c = old_to_new[t(2)]; + if (a == b || b == c || a == c) { ++stats.degenerate_collapsed; continue; } + const Vec3d& pa = verts[a]; const Vec3d& pb = verts[b]; const Vec3d& pc = verts[c]; + const double e0 = (pb - pa).norm(), e1 = (pc - pb).norm(), e2 = (pa - pc).norm(); + const double longest = std::max(e0, std::max(e1, e2)); + if (longest < tolerance) { ++stats.degenerate_collapsed; continue; } + const double area = 0.5 * (pb - pa).cross(pc - pa).norm(); + if (area < 1e-9 * longest * longest) { ++stats.degenerate_sliver; continue; } + tris.emplace_back(a, b, c); + } + stats.kept_tris = int(tris.size()); + if (tris.empty()) + throw std::runtime_error("mesh_to_brep: every triangle was rejected as degenerate " + "(try a smaller tolerance)"); + + // 3. One face per triangle, sharing vertices and edges through the caches. + std::vector vertex_cache(verts.size()); + std::vector vertex_made(verts.size(), false); + auto get_vertex = [&](int i) -> const TopoDS_Vertex& { + if (!vertex_made[i]) { + const Vec3d& p = verts[i]; + vertex_cache[i] = BRepBuilderAPI_MakeVertex(gp_Pnt(p.x(), p.y(), p.z())).Vertex(); + vertex_made[i] = true; + } + return vertex_cache[i]; + }; + + std::map, TopoDS_Edge> edge_cache; + std::map, int> edge_usage; + auto get_edge = [&](int i, int j) -> TopoDS_Edge { + const std::pair key = (i < j) ? std::make_pair(i, j) : std::make_pair(j, i); + ++edge_usage[key]; + auto it = edge_cache.find(key); + if (it == edge_cache.end()) + it = edge_cache.emplace(key, + BRepBuilderAPI_MakeEdge(get_vertex(key.first), get_vertex(key.second)).Edge()).first; + return (i > j) ? TopoDS::Edge(it->second.Reversed()) : it->second; + }; + + BRep_Builder builder; + TopoDS_Shell shell; + builder.MakeShell(shell); + + for (const Vec3i32& t : tris) { + try { + BRepBuilderAPI_MakeWire mk_wire(get_edge(t(0), t(1)), get_edge(t(1), t(2)), get_edge(t(2), t(0))); + if (!mk_wire.IsDone()) { ++stats.faces_failed; continue; } + BRepBuilderAPI_MakeFace mk_face(mk_wire.Wire()); + if (!mk_face.IsDone()) { ++stats.faces_failed; continue; } + builder.Add(shell, mk_face.Face()); + ++stats.faces_built; + } catch (const Standard_Failure&) { + ++stats.faces_failed; + } + } + + // 4. Watertightness falls straight out of the usage counts the cache already gathered. + for (const auto& kv : edge_usage) { + if (kv.second == 1) ++stats.boundary_edges; + else if (kv.second >= 3) ++stats.nonmanifold_edges; + } + stats.unique_edges = int(edge_usage.size()); + stats.watertight = stats.boundary_edges == 0 && stats.nonmanifold_edges == 0 && stats.unique_edges > 0; + + TopoDS_Shape shape = shell; + if (stats.watertight && stats.faces_built > 0) { + BRepBuilderAPI_MakeSolid mk_solid(shell); + if (mk_solid.IsDone()) { + TopoDS_Solid solid = mk_solid.Solid(); + GProp_GProps props; + BRepGProp::VolumeProperties(solid, props); + double vol = props.Mass(); + if (vol < 0.0) { // inward-wound input + solid = TopoDS::Solid(solid.Reversed()); + vol = -vol; + } + if (vol > 0.0) { + shape = solid; + stats.is_solid = true; + stats.volume = vol; + } + } + } + + // 5. Optional coplanar merge. Faceted output is one planar face per triangle — exact, but + // you cannot meaningfully fillet or extrude a face that IS a single triangle. Merging + // coplanar neighbours is what turns the import into something the face/edge tools can + // actually operate on (a 12-triangle cube collapses to its 6 real faces). + if (merge_angle_deg > 0.0) { + try { + ShapeUpgrade_UnifySameDomain unifier(shape, true, true, true); + unifier.SetAngularTolerance(merge_angle_deg * M_PI / 180.0); + unifier.SetLinearTolerance(tolerance); + unifier.Build(); + const TopoDS_Shape merged = unifier.Shape(); + if (!merged.IsNull()) + shape = merged; + } catch (const Standard_Failure&) { + // Merging is an optimisation, not a correctness step: keep the exact faceted shape. + } + } + stats.faces_final = face_count(shape); + return shape; +} + +// ---- Primitive creation ---- + +TopoDS_Solid GeometryEngine::make_primitive(const PrimitiveParams& params) +{ + switch (params.type) { + case PrimitiveType::Box: + return BRepPrimAPI_MakeBox(gp_Pnt(-params.box_w/2, -params.box_d/2, 0), + params.box_w, params.box_d, params.box_h).Solid(); + case PrimitiveType::Cylinder: + return BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(0,0,0), gp_Dir(0,0,1)), + params.cyl_radius, params.cyl_height).Solid(); + case PrimitiveType::Sphere: + return BRepPrimAPI_MakeSphere(gp_Pnt(0,0,params.sph_radius), params.sph_radius).Solid(); + case PrimitiveType::Cone: + return BRepPrimAPI_MakeCone(gp_Ax2(gp_Pnt(0,0,0), gp_Dir(0,0,1)), + params.cone_r1, params.cone_r2, params.cone_height).Solid(); + case PrimitiveType::Torus: + return BRepPrimAPI_MakeTorus(gp_Ax2(gp_Pnt(0,0,params.torus_r2), gp_Dir(0,0,1)), + params.torus_r1, params.torus_r2).Solid(); + default: + return BRepPrimAPI_MakeBox(gp_Pnt(-10,-10,0), 20,20,20).Solid(); + } +} + +// ---- Face classification ---- + +FaceGroup GeometryEngine::classify_face(const TopoDS_Face& face, const TopoDS_Shape& /*solid*/) +{ + try { + BRepAdaptor_Surface surf(face); + if (surf.GetType() == GeomAbs_Plane) { + // Sample normal at center UV + double u = (surf.FirstUParameter() + surf.LastUParameter()) / 2.0; + double v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0; + gp_Pnt pt; gp_Vec du, dv; + surf.D1(u, v, pt, du, dv); + gp_Dir n = du.Crossed(dv); + if (face.Orientation() == TopAbs_REVERSED) n.Reverse(); + + if (n.Z() > 0.7) return FaceGroup::Top; + if (n.Z() < -0.7) return FaceGroup::Bottom; + return FaceGroup::Lateral; + } + } catch (...) {} + return FaceGroup::Lateral; +} + +// ---- Edge collection ---- + +std::vector GeometryEngine::collect_edges(const TopoDS_Shape& solid, FaceGroup target) +{ + std::vector result; + if (target == FaceGroup::All) { + for (TopExp_Explorer exp(solid, TopAbs_EDGE); exp.More(); exp.Next()) + result.push_back(TopoDS::Edge(exp.Current())); + return result; + } + + // Build edge-to-face map once + TopTools_IndexedDataMapOfShapeListOfShape edgeFaceMap; + TopExp::MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_FACE, edgeFaceMap); + + for (TopExp_Explorer edgeExp(solid, TopAbs_EDGE); edgeExp.More(); edgeExp.Next()) { + const TopoDS_Edge& edge = TopoDS::Edge(edgeExp.Current()); + if (!edgeFaceMap.Contains(edge)) continue; + const TopTools_ListOfShape& faces = edgeFaceMap.FindFromKey(edge); + + bool include = false; + for (auto it = faces.begin(); it != faces.end(); ++it) { + FaceGroup fg = classify_face(TopoDS::Face(*it), solid); + if (target == FaceGroup::Top && fg == FaceGroup::Top) { include = true; break; } + if (target == FaceGroup::Bottom && fg == FaceGroup::Bottom) { include = true; break; } + if (target == FaceGroup::Lateral && fg == FaceGroup::Lateral) { include = true; break; } + } + + if (!include && target == FaceGroup::Top) { + for (auto it = faces.begin(); it != faces.end(); ++it) { + if (classify_face(TopoDS::Face(*it), solid) == FaceGroup::Top) { include = true; break; } + } + } + if (!include && target == FaceGroup::Bottom) { + for (auto it = faces.begin(); it != faces.end(); ++it) { + if (classify_face(TopoDS::Face(*it), solid) == FaceGroup::Bottom) { include = true; break; } + } + } + if (target == FaceGroup::Lateral && !include) { + int lateralCount = 0; + for (auto it = faces.begin(); it != faces.end(); ++it) { + if (classify_face(TopoDS::Face(*it), solid) == FaceGroup::Lateral) ++lateralCount; + } + if (lateralCount >= 2) include = true; + } + + if (include) result.push_back(edge); + } + + return result; +} + +// ---- Fillet/Chamfer ---- + +TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, FaceGroup faces) +{ + if (radius <= 0.001) return solid; + + std::vector edges = collect_edges(solid, faces); + if (edges.empty()) return solid; + + BRepFilletAPI_MakeFillet fillet(solid); + for (const auto& edge : edges) + fillet.Add(radius, edge); + fillet.Build(); + + // A too-large radius (e.g. >= half the smallest spanned dimension) makes the + // operation degenerate; OCCT leaves IsDone() false. Report it instead of + // silently returning the unfilleted solid (which reads as a false success). + if (!fillet.IsDone()) throw std::runtime_error("fillet radius too large for this geometry"); + return fillet.Shape(); +} + +TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, FaceGroup faces) +{ + if (distance <= 0.001) return solid; + + std::vector edges = collect_edges(solid, faces); + if (edges.empty()) return solid; + + BRepFilletAPI_MakeChamfer chamfer(solid); + for (const auto& edge : edges) + chamfer.Add(distance, edge); // symmetric chamfer + chamfer.Build(); + + if (!chamfer.IsDone()) throw std::runtime_error("chamfer distance too large for this geometry"); + return chamfer.Shape(); +} + +TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, int edge_id) +{ + if (radius <= 0.001) return solid; + + TopoDS_Edge edge = edge_by_index(solid, edge_id); + if (edge.IsNull()) throw std::runtime_error("apply_fillet: invalid edge id"); + + BRepFilletAPI_MakeFillet mk(solid); + mk.Add(radius, edge); + mk.Build(); + + if (!mk.IsDone()) throw std::runtime_error("apply_fillet: OCCT fillet failed"); + return mk.Shape(); +} + +TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, int edge_id) +{ + if (distance <= 0.001) return solid; + + TopoDS_Edge edge = edge_by_index(solid, edge_id); + if (edge.IsNull()) throw std::runtime_error("apply_chamfer: invalid edge id"); + + BRepFilletAPI_MakeChamfer mk(solid); + mk.Add(distance, edge); + mk.Build(); + + if (!mk.IsDone()) throw std::runtime_error("apply_chamfer: OCCT chamfer failed"); + return mk.Shape(); +} + +// ---- Tessellation ---- + +TriangleMesh GeometryEngine::tessellate(const TopoDS_Shape& shape, + double linear_deflection, + double angular_deflection) +{ + BRepMesh_IncrementalMesh mesh(shape, linear_deflection, false, angular_deflection, true); + + int nbNodes = 0, nbTri = 0; + for (TopExp_Explorer exp(shape, TopAbs_FACE); exp.More(); exp.Next()) { + TopLoc_Location loc; + Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(TopoDS::Face(exp.Current()), loc); + if (!tri.IsNull()) { nbNodes += tri->NbNodes(); nbTri += tri->NbTriangles(); } + } + if (nbTri == 0 || nbNodes == 0) return TriangleMesh{}; + + stl_file stl; + stl.stats.type = inmemory; + stl.stats.number_of_facets = (uint32_t)nbTri; + stl.stats.original_num_facets = stl.stats.number_of_facets; + stl_allocate(&stl); + + std::vector pts; pts.reserve(nbNodes); + int ndOff = 0, trOff = 0; + for (TopExp_Explorer exp(shape, TopAbs_FACE); exp.More(); exp.Next()) { + const TopoDS_Shape& F = exp.Current(); + TopLoc_Location loc; + Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(TopoDS::Face(F), loc); + if (tri.IsNull()) continue; + gp_Trsf T = loc.Transformation(); + for (int i = 1; i <= tri->NbNodes(); ++i) { + gp_Pnt p = tri->Node(i); p.Transform(T); + pts.emplace_back(Vec3f(p.X(), p.Y(), p.Z())); + } + auto orient = exp.Current().Orientation(); + int ids[3]; + for (int i = 1; i <= tri->NbTriangles(); ++i) { + Poly_Triangle t = tri->Triangle(i); t.Get(ids[0], ids[1], ids[2]); + if (orient == TopAbs_REVERSED) std::swap(ids[1], ids[2]); + stl_facet f; + f.vertex[0] = pts[ids[0]+ndOff-1].cast(); + f.vertex[1] = pts[ids[1]+ndOff-1].cast(); + f.vertex[2] = pts[ids[2]+ndOff-1].cast(); + f.extra[0]=0; f.extra[1]=0; + stl_normal n; stl_calculate_normal(n,&f); stl_normalize_vector(n); + f.normal=n; stl.facet_start[trOff+i-1]=f; + } + ndOff += tri->NbNodes(); trOff += tri->NbTriangles(); + } + TriangleMesh result; result.from_stl(stl); return result; +} + +GeometryEngine::Deviation +GeometryEngine::surface_deviation(const TopoDS_Shape& candidate, + const TopoDS_Shape& reference, + double linear_deflection) +{ + Deviation d; + if (candidate.IsNull() || reference.IsNull()) return d; + TriangleMesh mesh = tessellate(candidate, linear_deflection, 0.5); + const auto& verts = mesh.its.vertices; + if (verts.empty()) return d; + double sum = 0.0, sumsq = 0.0; + int n = 0; + for (const auto& v : verts) { + gp_Pnt p(v.x(), v.y(), v.z()); + BRepExtrema_DistShapeShape dss(BRepBuilderAPI_MakeVertex(p).Vertex(), reference); + if (!dss.IsDone() || dss.NbSolution() < 1) continue; + double dist = dss.Value(); + d.max_mm = std::max(d.max_mm, dist); + sum += dist; sumsq += dist * dist; ++n; + } + d.sample_count = n; + if (n > 0) { d.mean_mm = sum / n; d.rms_mm = std::sqrt(sumsq / n); } + return d; +} + +GeometryEngine::MassProps GeometryEngine::mass_properties(const TopoDS_Shape& shape) +{ + MassProps p; + if (shape.IsNull()) return p; + try { + // A sheet body (open shell, no solid) encloses nothing, and BRepGProp::VolumeProperties + // integrates the divergence theorem over whatever faces exist — on an open shell that is + // not a volume at all. It came back as 96000 with an inertia diagonal of + // [-4.2e7, -4.2e7, -6.9e7] for a 60x60x40 four-walled box: negative principal moments, + // which no real body can have. The old code then hid the only obvious tell by taking + // std::abs() of the mass. Report the honest answer instead — surface area is still + // meaningful, so this is not a failure, just not a solid. + p.is_solid = TopExp_Explorer(shape, TopAbs_SOLID).More(); + if (!p.is_solid) { + GProp_GProps sonly; + BRepGProp::SurfaceProperties(shape, sonly); + p.surface_area = sonly.Mass(); + p.valid = true; // the area IS trustworthy; volume/inertia stay zero + return p; + } + GProp_GProps vprops; + BRepGProp::VolumeProperties(shape, vprops); + double mass = vprops.Mass(); + if (std::abs(mass) < 1e-30) return p; + p.volume = std::abs(mass); + p.center_of_mass = Vec3d(vprops.CentreOfMass().X(), vprops.CentreOfMass().Y(), vprops.CentreOfMass().Z()); + gp_Mat mat = vprops.MatrixOfInertia(); + p.inertia = {{ + mat(1,1), mat(1,2), mat(1,3), + mat(2,1), mat(2,2), mat(2,3), + mat(3,1), mat(3,2), mat(3,3), + }}; + GProp_GProps sprops; + BRepGProp::SurfaceProperties(shape, sprops); + p.surface_area = sprops.Mass(); + p.valid = true; + } catch (const Standard_Failure&) { + // leave valid = false + } + return p; +} + +std::string GeometryEngine::primitive_name(PrimitiveType type) +{ + switch (type) { + case PrimitiveType::Box: return "Box"; + case PrimitiveType::Cylinder: return "Cylinder"; + case PrimitiveType::Sphere: return "Sphere"; + case PrimitiveType::Cone: return "Cone"; + case PrimitiveType::Torus: return "Torus"; + default: return "Unknown"; + } +} + +// ---- Topology accessors ---- + +int GeometryEngine::face_count(const TopoDS_Shape& shape) +{ + int n = 0; + for (TopExp_Explorer e(shape, TopAbs_FACE); e.More(); e.Next()) + ++n; + return n; +} + +TopoDS_Face GeometryEngine::face_by_index(const TopoDS_Shape& shape, int index) +{ + if (index < 0) return TopoDS_Face(); + int ordinal = 0; + for (TopExp_Explorer e(shape, TopAbs_FACE); e.More(); e.Next()) { + if (ordinal == index) + return TopoDS::Face(e.Current()); + ++ordinal; + } + return TopoDS_Face(); +} + +std::vector GeometryEngine::faces_of(const TopoDS_Shape& shape) +{ + std::vector out; + for (TopExp_Explorer e(shape, TopAbs_FACE); e.More(); e.Next()) + out.push_back(TopoDS::Face(e.Current())); // same order as face_by_index + return out; +} + +std::vector GeometryEngine::edges_of(const TopoDS_Shape& shape) +{ + TopTools_IndexedMapOfShape map; + TopExp::MapShapes(shape, TopAbs_EDGE, map); // same order as edge_by_index + std::vector out; + out.reserve(map.Extent()); + for (int i = 1; i <= map.Extent(); ++i) + out.push_back(TopoDS::Edge(map(i))); + return out; +} + +std::vector GeometryEngine::edges_of_face(const TopoDS_Face& face) +{ + std::vector result; + TopTools_IndexedMapOfShape map; + TopExp::MapShapes(face, TopAbs_EDGE, map); + for (int i = 1; i <= map.Extent(); ++i) + result.push_back(TopoDS::Edge(map(i))); + return result; +} + +std::vector GeometryEngine::sample_edge_world(const TopoDS_Edge& edge, double chord_tol) +{ + if (BRep_Tool::Degenerated(edge)) + return {}; + + BRepAdaptor_Curve curve(edge); + GCPnts_TangentialDeflection disc(curve, 0.1, chord_tol); + + std::vector pts; + if (disc.NbPoints() >= 2) { + for (int i = 1; i <= disc.NbPoints(); ++i) { + gp_Pnt p = disc.Value(i); + pts.emplace_back(p.X(), p.Y(), p.Z()); + } + } else { + gp_Pnt p0 = curve.Value(curve.FirstParameter()); + gp_Pnt p1 = curve.Value(curve.LastParameter()); + pts.emplace_back(p0.X(), p0.Y(), p0.Z()); + pts.emplace_back(p1.X(), p1.Y(), p1.Z()); + } + return pts; +} + +Vec3d GeometryEngine::face_centroid_world(const TopoDS_Face& face) +{ + GProp_GProps props; + BRepGProp::SurfaceProperties(face, props); + gp_Pnt c = props.CentreOfMass(); + return Vec3d(c.X(), c.Y(), c.Z()); +} + +Vec3d GeometryEngine::face_normal_world(const TopoDS_Face& face) +{ + BRepAdaptor_Surface surf(face); + const double u = 0.5 * (surf.FirstUParameter() + surf.LastUParameter()); + const double v = 0.5 * (surf.FirstVParameter() + surf.LastVParameter()); + BRepLProp_SLProps props(surf, u, v, 1, 1e-6); + gp_Dir n(0.0, 0.0, 1.0); + if (props.IsNormalDefined()) n = props.Normal(); + if (face.Orientation() == TopAbs_REVERSED) n.Reverse(); // outward (account for face winding) + return Vec3d(n.X(), n.Y(), n.Z()); +} + +GeometryEngine::CylinderFace GeometryEngine::cylinder_of_face(const TopoDS_Face& face) +{ + CylinderFace cf; + if (face.IsNull()) return cf; + BRepAdaptor_Surface surf(face); + if (surf.GetType() != GeomAbs_Cylinder) return cf; + + const gp_Cylinder cyl = surf.Cylinder(); + const gp_Ax1 ax = cyl.Axis(); + const Vec3d axis(ax.Direction().X(), ax.Direction().Y(), ax.Direction().Z()); + const Vec3d apt (ax.Location().X(), ax.Location().Y(), ax.Location().Z()); + cf.radius = cyl.Radius(); + + // Axial extent: V is the axial parameter on a cylinder; bound the face's two ends and + // order them so `axis` points base -> top. + const double umid = 0.5 * (surf.FirstUParameter() + surf.LastUParameter()); + const gp_Pnt e0 = surf.Value(umid, surf.FirstVParameter()); + const gp_Pnt e1 = surf.Value(umid, surf.LastVParameter()); + double t0 = (Vec3d(e0.X(), e0.Y(), e0.Z()) - apt).dot(axis); + double t1 = (Vec3d(e1.X(), e1.Y(), e1.Z()) - apt).dot(axis); + if (t1 < t0) std::swap(t0, t1); + cf.base = apt + axis * t0; + cf.axis = axis; + cf.height = t1 - t0; + + // Internal (bore) vs external: compare the face's outward normal at its centre to the + // outward radial direction. A bore's normal points toward the axis (dot < 0). + const gp_Pnt sp = surf.Value(umid, 0.5 * (surf.FirstVParameter() + surf.LastVParameter())); + const Vec3d S(sp.X(), sp.Y(), sp.Z()); + const Vec3d axpt = cf.base + axis * (S - cf.base).dot(axis); + const Vec3d radial = (S - axpt).normalized(); + cf.internal = face_normal_world(face).dot(radial) < 0.0; + cf.ok = true; + return cf; +} + +GeometryEngine::CylinderFace GeometryEngine::circle_of_edge(const TopoDS_Edge& edge) +{ + CylinderFace cf; + if (edge.IsNull()) return cf; + BRepAdaptor_Curve curve(edge); + if (curve.GetType() != GeomAbs_Circle) return cf; + const gp_Circ c = curve.Circle(); + const gp_Ax1 ax = c.Axis(); + cf.base = Vec3d(c.Location().X(), c.Location().Y(), c.Location().Z()); + cf.axis = Vec3d(ax.Direction().X(), ax.Direction().Y(), ax.Direction().Z()); + cf.radius = c.Radius(); + cf.height = 0.0; // an edge carries no axial extent; the card keeps the current length + cf.internal = false; // ambiguous from an edge alone — default external, user can toggle + cf.ok = true; + return cf; +} + +bool GeometryEngine::face_plane_bounds(const TopoDS_Face& face, const Vec3d& origin, + const Vec3d& x_axis, const Vec3d& y_axis, + double& umin, double& umax, double& vmin, double& vmax) +{ + umin = vmin = 1e30; umax = vmax = -1e30; + bool any = false; + for (TopExp_Explorer ex(face, TopAbs_VERTEX); ex.More(); ex.Next()) { + const gp_Pnt p = BRep_Tool::Pnt(TopoDS::Vertex(ex.Current())); + const Vec3d P(p.X(), p.Y(), p.Z()); + const double u = (P - origin).dot(x_axis); + const double v = (P - origin).dot(y_axis); + umin = std::min(umin, u); umax = std::max(umax, u); + vmin = std::min(vmin, v); vmax = std::max(vmax, v); + any = true; + } + return any; +} + +int GeometryEngine::edge_count(const TopoDS_Shape& shape) +{ + TopTools_IndexedMapOfShape map; + TopExp::MapShapes(shape, TopAbs_EDGE, map); + return map.Extent(); +} + +TopoDS_Edge GeometryEngine::edge_by_index(const TopoDS_Shape& shape, int index) +{ + TopTools_IndexedMapOfShape map; + TopExp::MapShapes(shape, TopAbs_EDGE, map); + if (index < 0 || index >= map.Extent()) + return TopoDS_Edge(); + return TopoDS::Edge(map(index + 1)); +} + +int GeometryEngine::edge_index_of(const TopoDS_Shape& shape, const TopoDS_Edge& edge) +{ + TopTools_IndexedMapOfShape map; + TopExp::MapShapes(shape, TopAbs_EDGE, map); + int idx = map.FindIndex(edge); + return (idx > 0) ? (idx - 1) : -1; +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/GeometryEngine.hpp b/src/libslic3r/CAD/GeometryEngine.hpp new file mode 100644 index 0000000000..f27a0f2f17 --- /dev/null +++ b/src/libslic3r/CAD/GeometryEngine.hpp @@ -0,0 +1,183 @@ +#ifndef slic3r_GeometryEngine_hpp_ +#define slic3r_GeometryEngine_hpp_ + +#include "libslic3r/TriangleMesh.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +enum class PrimitiveType { Box, Cylinder, Sphere, Cone, Torus, COUNT }; +enum class DressUpType { Fillet, Chamfer }; +enum class FaceGroup { Top, Bottom, Lateral, All }; + +struct PrimitiveParams { + PrimitiveType type{PrimitiveType::Box}; + double box_w{20}, box_h{20}, box_d{20}; + double cyl_radius{10}, cyl_height{20}; + double sph_radius{10}; + double cone_r1{10}, cone_r2{5}, cone_height{20}; + double torus_r1{10}, torus_r2{3}; + + // Dress-up + bool dressup_enabled{false}; + DressUpType dressup_type{DressUpType::Fillet}; + FaceGroup dressup_faces{FaceGroup::All}; + double dressup_radius{1.0}; // fillet radius + double dressup_chamfer_dist{1.0}; // chamfer distance (symmetric) + + // Mesh quality + double linear_deflection{0.01}; + double angular_deflection{0.5}; + + template + void serialize(Archive& ar) { + ar(type, box_w, box_h, box_d, cyl_radius, cyl_height, sph_radius, + cone_r1, cone_r2, cone_height, torus_r1, torus_r2, + dressup_enabled, dressup_type, dressup_faces, dressup_radius, dressup_chamfer_dist, + linear_deflection, angular_deflection); + } +}; + +class GeometryEngine +{ +public: + static TopoDS_Solid make_primitive(const PrimitiveParams& params); + + // Read a STEP file into its top-level solids (one TopoDS_Shape per solid; falls back to + // the whole shape if it contains no closed solids). Reuses OCCT's STEPControl_Reader, + // already linked via Format/STEP.cpp — no new dependency. err is set on failure (empty result). + static std::vector read_step_solids(const std::string& path, std::string& err); + + // Triangle mesh -> B-rep solid. Native port of mesh2step + // (github.com/tommasobbianchi/mesh2step): vertices and edges are SHARED across triangles + // at construction time (vertex cache by deduped index, edge cache by unordered index pair), + // so there is no BRepBuilderAPI_Sewing pass to reconstruct topology afterwards — which is + // both faster and what makes watertightness fall out of the edge-usage counts for free. + // Runs in-process on the OCCT kernel libslic3r already links: no STEP file is written or + // re-read (a faceted STEP of a 62k-triangle mesh is ~149 MB and takes OCCT's reader >300 s + // to parse back, so routing the Design tab through a file would hang the GUI). + struct MeshBrepStats { + int input_tris{0}; + int kept_tris{0}; + int degenerate_collapsed{0}; // <3 distinct vertices after tolerance quantization + int degenerate_sliver{0}; // 3 distinct vertices but near-collinear + int faces_built{0}; + int faces_failed{0}; + int unique_edges{0}; + int boundary_edges{0}; // used by exactly 1 triangle -> open shell + int nonmanifold_edges{0}; // used by >=3 triangles + bool watertight{false}; // every edge used exactly twice + bool is_solid{false}; // watertight AND MakeSolid gave a positive volume + double volume{0.0}; + int faces_final{0}; // after the optional coplanar merge + }; + // tolerance: spatial quantization cell used ONLY for vertex dedup and as the + // sub-resolution floor below which a triangle is noise. Never a sew tolerance. + // merge_angle_deg > 0: run ShapeUpgrade_UnifySameDomain to merge coplanar neighbours into + // single faces (a 12-triangle cube -> 6 pickable faces). This is what makes the imported + // body editable with the face/edge tools; <= 0 keeps the exact one-face-per-triangle form. + // Never wraps a non-watertight shell as a fake solid: an open mesh comes back as a shell, + // with the reason (boundary / non-manifold edge counts) reported in stats. + static TopoDS_Shape mesh_to_brep(const indexed_triangle_set& its, + double tolerance, + double merge_angle_deg, + MeshBrepStats& stats); + + struct MassProps { + double volume{0.0}; + double surface_area{0.0}; + Vec3d center_of_mass{Vec3d::Zero()}; + std::array inertia{}; + bool valid{false}; + // False for a sheet body (an open shell with no solid). Volume and inertia are then + // meaningless and are reported as zero; surface_area stays meaningful. See the .cpp. + bool is_solid{false}; + }; + static MassProps mass_properties(const TopoDS_Shape& shape); + + struct Deviation { double max_mm{0}; double mean_mm{0}; double rms_mm{0}; int sample_count{0}; }; + static Deviation surface_deviation(const TopoDS_Shape& candidate, + const TopoDS_Shape& reference, + double linear_deflection = 0.5); + + static TopoDS_Shape apply_fillet(const TopoDS_Shape& solid, double radius, + FaceGroup faces = FaceGroup::All); + static TopoDS_Shape apply_fillet(const TopoDS_Shape& solid, double radius, + int edge_id); + static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance, + FaceGroup faces = FaceGroup::All); + static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance, + int edge_id); + + static TriangleMesh tessellate(const TopoDS_Shape& shape, + double linear_deflection = 0.01, + double angular_deflection = 0.5); + static std::string primitive_name(PrimitiveType type); + + // Topology accessors for in-viewport face/edge picking (Design tab). Face index is the + // TopExp_Explorer(shape, TopAbs_FACE) ordinal — identical to SketchEngine::tessellate's + // per-triangle face id, so a picked triangle's id maps back to a face here. + static TopoDS_Face face_by_index(const TopoDS_Shape& shape, int index); // null if out of range + static int face_count(const TopoDS_Shape& shape); + // Bulk enumeration in the SAME order as face_by_index / edge_by_index, so ids are + // interchangeable. Walking a body with the _by_index accessors is quadratic (each call + // rescans the shape — edge_by_index even rebuilds the whole indexed map), which cost + // ~15 s on a 4.7k-face imported solid; enumerate once instead. + static std::vector faces_of(const TopoDS_Shape& shape); + static std::vector edges_of(const TopoDS_Shape& shape); + static std::vector edges_of_face(const TopoDS_Face& face); + // Centre of mass (world) of a face — used to compute the extrude length for "up to face". + static Vec3d face_centroid_world(const TopoDS_Face& face); + // Outward unit normal of a face at its UV midpoint (orientation-aware) — for the shell gizmo. + static Vec3d face_normal_world(const TopoDS_Face& face); + // Sample an edge into a world-space polyline (>=2 pts) for pick-distance + highlight. + static std::vector sample_edge_world(const TopoDS_Edge& edge, double chord_tol = 0.05); + // 0-based edge index into TopExp::MapShapes(shape, TopAbs_EDGE, map). + static int edge_count(const TopoDS_Shape& shape); + static TopoDS_Edge edge_by_index(const TopoDS_Shape& shape, int index); + static int edge_index_of(const TopoDS_Shape& shape, const TopoDS_Edge& edge); + + // Analysis of a cylindrical face for the Thread tool (a hole bore or a cylinder's lateral + // surface): axis (base at the lower axial end + unit direction), radius, axial extent, and + // whether it is a bore (face normal points toward the axis = internal thread). ok=false if + // the face is not a cylinder. + struct CylinderFace { + bool ok{false}; + Vec3d base{0, 0, 0}; + Vec3d axis{0, 0, 1}; + double radius{0}; + double height{0}; + bool internal{false}; + }; + static CylinderFace cylinder_of_face(const TopoDS_Face& face); + // Circular edge (a cylinder's perimeter): base = circle centre, axis = circle normal, + // radius = circle radius, height = 0 (unknown from an edge), internal = false. ok=false if + // the edge is not a circle. Lets the Thread tool be driven by a picked circular rim. + static CylinderFace circle_of_edge(const TopoDS_Edge& edge); + + // Plane-coordinate (u,v) bounding box of a face's vertices, measured from `origin` along + // `x_axis`/`y_axis`. Lets the Hole tool dimension the hole from the face SIDES (umin/vmin = + // two adjacent edges) instead of from the centre. Returns false if the face has no vertices. + static bool face_plane_bounds(const TopoDS_Face& face, const Vec3d& origin, + const Vec3d& x_axis, const Vec3d& y_axis, + double& umin, double& umax, double& vmin, double& vmax); + +private: + static std::vector collect_edges(const TopoDS_Shape& solid, FaceGroup faces); + static FaceGroup classify_face(const TopoDS_Face& face, const TopoDS_Shape& solid); +}; + +} // namespace Slic3r + +#endif // slic3r_GeometryEngine_hpp_ diff --git a/src/libslic3r/CAD/SketchConstraints.cpp b/src/libslic3r/CAD/SketchConstraints.cpp new file mode 100644 index 0000000000..003afb4d63 --- /dev/null +++ b/src/libslic3r/CAD/SketchConstraints.cpp @@ -0,0 +1,307 @@ +#include "libslic3r/CAD/SketchConstraints.hpp" +#include +#include + +namespace Slic3r { + +int SketchConstraints::add_point(double x, double y) +{ + m_vars.push_back(x); + m_vars.push_back(y); + return static_cast(m_vars.size() / 2) - 1; +} + +void SketchConstraints::set_point(int id, double x, double y) +{ + size_t idx = 2 * id; + m_vars[idx] = x; + m_vars[idx + 1] = y; +} + +Vec2d SketchConstraints::get_point(int id) const +{ + size_t idx = 2 * id; + return Vec2d(m_vars[idx], m_vars[idx + 1]); +} + +int SketchConstraints::point_count() const +{ + return static_cast(m_vars.size() / 2); +} + +void SketchConstraints::fix_point(int id) +{ + size_t idx = 2 * id; + Con c; + c.type = FIX_POINT; + c.a = id; + c.b = c.c = c.d = 0; + c.k0 = m_vars[idx]; + c.k1 = m_vars[idx + 1]; + m_cons.push_back(c); +} + +void SketchConstraints::coincident(int a, int b) +{ + Con c; + c.type = COINCIDENT; + c.a = a; c.b = b; c.c = c.d = 0; + c.k0 = c.k1 = 0; + m_cons.push_back(c); +} + +void SketchConstraints::horizontal(int a, int b) +{ + Con c; + c.type = HORIZONTAL; + c.a = a; c.b = b; c.c = c.d = 0; + c.k0 = c.k1 = 0; + m_cons.push_back(c); +} + +void SketchConstraints::vertical(int a, int b) +{ + Con c; + c.type = VERTICAL; + c.a = a; c.b = b; c.c = c.d = 0; + c.k0 = c.k1 = 0; + m_cons.push_back(c); +} + +void SketchConstraints::distance(int a, int b, double d) +{ + Con c; + c.type = DISTANCE; + c.a = a; c.b = b; c.c = c.d = 0; + c.k0 = d; c.k1 = 0; + m_cons.push_back(c); +} + +void SketchConstraints::lock_x(int id, double x) +{ + Con c; + c.type = LOCK_X; + c.a = id; + c.b = c.c = c.d = 0; + c.k0 = x; c.k1 = 0; + m_cons.push_back(c); +} + +void SketchConstraints::lock_y(int id, double y) +{ + Con c; + c.type = LOCK_Y; + c.a = id; + c.b = c.c = c.d = 0; + c.k0 = y; c.k1 = 0; + m_cons.push_back(c); +} + +void SketchConstraints::equal_length(int a, int b, int c, int d) +{ + Con con; + con.type = EQUAL_LENGTH; + con.a = a; con.b = b; con.c = c; con.d = d; + con.k0 = con.k1 = 0; + m_cons.push_back(con); +} + +void SketchConstraints::parallel(int a, int b, int c, int d) +{ + Con con; + con.type = PARALLEL; + con.a = a; con.b = b; con.c = c; con.d = d; + con.k0 = con.k1 = 0; + m_cons.push_back(con); +} + +void SketchConstraints::perpendicular(int a, int b, int c, int d) +{ + Con con; + con.type = PERPENDICULAR; + con.a = a; con.b = b; con.c = c; con.d = d; + con.k0 = con.k1 = 0; + m_cons.push_back(con); +} + +void SketchConstraints::midpoint(int m, int a, int b) +{ + Con con; + con.type = MIDPOINT; + con.a = m; con.b = a; con.c = b; con.d = -1; + con.k0 = con.k1 = 0; + m_cons.push_back(con); +} + +void SketchConstraints::symmetric(int a, int b, int c, int d) +{ + Con con; + con.type = SYMMETRIC; + con.a = a; con.b = b; con.c = c; con.d = d; + con.k0 = con.k1 = 0; + m_cons.push_back(con); +} + +void SketchConstraints::angle(int a, int b, int c, int d, double radians) +{ + Con con; + con.type = ANGLE; + con.a = a; con.b = b; con.c = c; con.d = d; + con.k0 = radians; con.k1 = 0; + m_cons.push_back(con); +} + +void SketchConstraints::point_line_distance(int p, int a, int b, double dist) +{ + Con con; + con.type = PT_LINE_DIST; + con.a = p; con.b = a; con.c = b; con.d = -1; + con.k0 = dist; con.k1 = 0; + m_cons.push_back(con); +} + +Eigen::VectorXd SketchConstraints::residuals(const std::vector& v) const +{ + auto X = [&](int i) { return v[2 * i]; }; + auto Y = [&](int i) { return v[2 * i + 1]; }; + + std::vector res; + for (const auto& c : m_cons) { + switch (c.type) { + case FIX_POINT: + res.push_back(X(c.a) - c.k0); + res.push_back(Y(c.a) - c.k1); + break; + case COINCIDENT: + res.push_back(X(c.a) - X(c.b)); + res.push_back(Y(c.a) - Y(c.b)); + break; + case HORIZONTAL: + res.push_back(Y(c.a) - Y(c.b)); + break; + case VERTICAL: + res.push_back(X(c.a) - X(c.b)); + break; + case DISTANCE: + res.push_back(std::hypot(X(c.a) - X(c.b), Y(c.a) - Y(c.b)) - c.k0); + break; + case LOCK_X: + res.push_back(X(c.a) - c.k0); + break; + case LOCK_Y: + res.push_back(Y(c.a) - c.k0); + break; + case EQUAL_LENGTH: + res.push_back(std::hypot(X(c.a) - X(c.b), Y(c.a) - Y(c.b)) - + std::hypot(X(c.c) - X(c.d), Y(c.c) - Y(c.d))); + break; + case PARALLEL: + res.push_back((X(c.b) - X(c.a)) * (Y(c.d) - Y(c.c)) - + (Y(c.b) - Y(c.a)) * (X(c.d) - X(c.c))); + break; + case PERPENDICULAR: + res.push_back((X(c.b) - X(c.a)) * (X(c.d) - X(c.c)) + + (Y(c.b) - Y(c.a)) * (Y(c.d) - Y(c.c))); + break; + case MIDPOINT: + res.push_back(X(c.a) - 0.5 * (X(c.b) + X(c.c))); + res.push_back(Y(c.a) - 0.5 * (Y(c.b) + Y(c.c))); + break; + case SYMMETRIC: { + const double abx = X(c.b) - X(c.a), aby = Y(c.b) - Y(c.a); + const double cdx = X(c.d) - X(c.c), cdy = Y(c.d) - Y(c.c); + res.push_back(abx * cdx + aby * cdy); + const double mx = 0.5 * (X(c.a) + X(c.b)); + const double my = 0.5 * (Y(c.a) + Y(c.b)); + res.push_back((mx - X(c.c)) * cdy - (my - Y(c.c)) * cdx); + break; + } + case ANGLE: { + const double ux = X(c.b) - X(c.a), uy = Y(c.b) - Y(c.a); + const double wx = X(c.d) - X(c.c), wy = Y(c.d) - Y(c.c); + const double cross = ux * wy - uy * wx; + const double dot = ux * wx + uy * wy; + res.push_back(std::atan2(cross, dot) - c.k0); + break; + } + case PT_LINE_DIST: { + const double bx = X(c.b), by = Y(c.b); + const double cx = X(c.c), cy = Y(c.c); + const double L = std::hypot(cx - bx, cy - by); + const double num = (X(c.a) - bx) * (cy - by) - (Y(c.a) - by) * (cx - bx); + res.push_back((L > 1e-12 ? std::abs(num) / L : 0.0) - c.k0); + break; + } + } + } + + Eigen::VectorXd r(static_cast(res.size())); + for (size_t i = 0; i < res.size(); ++i) + r(static_cast(i)) = res[i]; + return r; +} + +Eigen::MatrixXd SketchConstraints::jacobian(const std::vector& v) const +{ + int m = static_cast(residuals(v).size()); + int n = static_cast(v.size()); + Eigen::MatrixXd J(m, n); + const double eps = 1e-7; + + std::vector vp = v; + std::vector vm = v; + + for (int j = 0; j < n; ++j) { + vp[j] = v[j] + eps; + vm[j] = v[j] - eps; + Eigen::VectorXd rp = residuals(vp); + Eigen::VectorXd rm = residuals(vm); + vp[j] = v[j]; + vm[j] = v[j]; + J.col(j) = (rp - rm) / (2.0 * eps); + } + + return J; +} + +bool SketchConstraints::solve(int max_iter, double tol) +{ + if (m_cons.empty()) return true; + double lambda = 1e-3; + Eigen::VectorXd r = residuals(m_vars); + for (int it = 0; it < max_iter; ++it) { + double rn = r.norm(); + if (rn < tol) return true; + Eigen::MatrixXd J = jacobian(m_vars); + Eigen::MatrixXd A = J.transpose() * J; + Eigen::VectorXd g = J.transpose() * r; + bool stepped = false; + for (int t = 0; t < 12; ++t) { + Eigen::MatrixXd Ad = A; + for (int i = 0; i < Ad.rows(); ++i) + Ad(i, i) += lambda * (1.0 + Ad(i, i)); + Eigen::VectorXd dx = Ad.ldlt().solve(-g); + std::vector cand = m_vars; + for (size_t i = 0; i < cand.size(); ++i) + cand[i] += dx[static_cast(i)]; + Eigen::VectorXd rc = residuals(cand); + if (rc.norm() < rn) { + m_vars = cand; + r = rc; + lambda = std::max(lambda * 0.4, 1e-12); + stepped = true; + break; + } + lambda *= 3.0; + } + if (!stepped) break; + } + return r.norm() < tol * 100; +} + +double SketchConstraints::residual_norm() const +{ + return residuals(m_vars).norm(); +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchConstraints.hpp b/src/libslic3r/CAD/SketchConstraints.hpp new file mode 100644 index 0000000000..88bcadc403 --- /dev/null +++ b/src/libslic3r/CAD/SketchConstraints.hpp @@ -0,0 +1,68 @@ +#ifndef slic3r_SketchConstraints_hpp_ +#define slic3r_SketchConstraints_hpp_ + +#include "libslic3r/Point.hpp" +#include +#include + +namespace Slic3r { + +class SketchConstraints { +public: + int add_point(double x, double y); + void set_point(int id, double x, double y); + Vec2d get_point(int id) const; + int point_count() const; + + void fix_point(int id); + void coincident(int a, int b); + void horizontal(int a, int b); + void vertical(int a, int b); + void distance(int a, int b, double d); + void lock_x(int id, double x); + void lock_y(int id, double y); + void equal_length(int a, int b, int c, int d); + void parallel(int a, int b, int c, int d); + void perpendicular(int a, int b, int c, int d); + void midpoint(int m, int a, int b); + void symmetric(int a, int b, int c, int d); + void angle(int a, int b, int c, int d, double radians); + void point_line_distance(int p, int a, int b, double dist); + + bool solve(int max_iter = 200, double tol = 1e-10); + double residual_norm() const; + +private: + std::vector m_vars; + + enum ConType : int { + FIX_POINT = 0, + COINCIDENT, + HORIZONTAL, + VERTICAL, + DISTANCE, + LOCK_X, + LOCK_Y, + EQUAL_LENGTH, + PARALLEL, + PERPENDICULAR, + MIDPOINT, + SYMMETRIC, + ANGLE, + PT_LINE_DIST + }; + + struct Con { + int type; + int a, b, c, d; + double k0, k1; + }; + std::vector m_cons; + + Eigen::VectorXd residuals(const std::vector& v) const; + Eigen::MatrixXd jacobian(const std::vector& v) const; +}; + +} // namespace Slic3r + +#endif // slic3r_SketchConstraints_hpp_ diff --git a/src/libslic3r/CAD/SketchEngine.cpp b/src/libslic3r/CAD/SketchEngine.cpp new file mode 100644 index 0000000000..5cc36ce648 --- /dev/null +++ b/src/libslic3r/CAD/SketchEngine.cpp @@ -0,0 +1,2361 @@ +#include "libslic3r/CAD/SketchEngine.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +// Single source of truth for the weld tolerance the viewport and the kernel share. +// Defaults ON so headless/kernel-only callers keep welding; the GUI pushes the +// "auto_close_sketch_loops" preference in via set_sketch_auto_close(). +static bool s_auto_close = true; + +double sketch_join_tol() { return s_auto_close ? kSketchJoinTol : 0.0; } +void set_sketch_auto_close(bool on) { s_auto_close = on; } + +// ---- SketchPlane ---- + +gp_Pln SketchPlane::to_occt() const +{ + gp_Pnt o(origin.x(), origin.y(), origin.z()); + gp_Dir n(normal.x(), normal.y(), normal.z()); + gp_Dir x(x_axis.x(), x_axis.y(), x_axis.z()); + return gp_Pln(gp_Ax3(o, n, x)); +} + +SketchPlane SketchPlane::from_face(const TopoDS_Face& face) +{ + SketchPlane sp; + // Use the first triangulation vertex + face normal + TopLoc_Location loc; + Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(face, loc); + if (!tri.IsNull() && tri->NbNodes() > 0) { + gp_Pnt p = tri->Node(1).Transformed(loc.Transformation()); + sp.origin = Vec3d(p.X(), p.Y(), p.Z()); + } + // Compute normal from BRep face + BRepAdaptor_Surface surf(face); + if (surf.GetType() != GeomAbs_Plane) { + // Fallback: try to use the first uv point + double u1 = surf.FirstUParameter(), v1 = surf.FirstVParameter(); + gp_Pnt pt; gp_Vec du, dv; + surf.D1(u1, v1, pt, du, dv); + gp_Dir n = gp_Dir(du.Crossed(dv)); + sp.normal = Vec3d(n.X(), n.Y(), n.Z()); + sp.x_axis = Vec3d(du.X(), du.Y(), du.Z()).normalized(); + sp.y_axis = sp.normal.cross(sp.x_axis).normalized(); + return sp; + } + // Plane face — use the plane directly + gp_Pln pln = surf.Plane(); + sp.origin = Vec3d(pln.Location().X(), pln.Location().Y(), pln.Location().Z()); + gp_Dir n = pln.Axis().Direction(); + sp.normal = Vec3d(n.X(), n.Y(), n.Z()); + gp_Dir xd = pln.XAxis().Direction(); + sp.x_axis = Vec3d(xd.X(), xd.Y(), xd.Z()); + sp.y_axis = sp.normal.cross(sp.x_axis).normalized(); + return sp; +} + +Vec2d SketchPlane::project(const Vec3d& ray_origin, const Vec3d& ray_dir) const +{ + double denom = ray_dir.dot(normal); + if (std::abs(denom) < 1e-12) + return {0, 0}; // ray parallel to plane + double t = (origin - ray_origin).dot(normal) / denom; + if (t < 0) + return {0, 0}; // behind camera + Vec3d hit = ray_origin + t * ray_dir; + Vec3d local = hit - origin; + return {local.dot(x_axis), local.dot(y_axis)}; +} + +Vec3d SketchPlane::to_world(const Vec2d& pt) const +{ + return origin + x_axis * pt.x() + y_axis * pt.y(); +} + +// ---- SketchProfile ---- + +bool SketchProfile::is_closed(double tolerance) const +{ + if (points.size() < 3) return false; + return (points.front() - points.back()).norm() < tolerance; +} + +bool SketchProfile::try_close(double tolerance) +{ + if (is_closed(tolerance)) { + closed = true; + return true; + } + if (points.size() < 2) return false; + if ((points.front() - points.back()).norm() < tolerance) { + closed = true; + return true; + } + return false; +} + +TopoDS_Wire SketchProfile::to_occt_wire(const SketchPlane& plane) const +{ + if (points.size() < 2) + throw std::runtime_error("Profile has fewer than 2 points"); + + BRepBuilderAPI_MakeWire builder; + for (size_t i = 0; i < points.size(); ++i) { + Vec3d a3 = plane.to_world(points[i]); + Vec3d b3 = plane.to_world(points[(i + 1) % points.size()]); + gp_Pnt pa(a3.x(), a3.y(), a3.z()); + gp_Pnt pb(b3.x(), b3.y(), b3.z()); + builder.Add(BRepBuilderAPI_MakeEdge(pa, pb).Edge()); + } + builder.Build(); + if (!builder.IsDone()) + throw std::runtime_error("Failed to build wire from profile"); + return builder.Wire(); +} + +// ---- SketchEngine ---- + +static TopoDS_Shape extrude_face_internal(const TopoDS_Face& face, const gp_Dir& dir, double length, bool symmetric) +{ + gp_Vec vec = gp_Vec(dir) * length; + if (symmetric) { + gp_Vec halfVec = gp_Vec(dir) * (length / 2.0); + BRepPrimAPI_MakePrism pos(face, halfVec); + BRepPrimAPI_MakePrism neg(face, -halfVec); + if (!pos.IsDone() || !neg.IsDone()) throw std::runtime_error("Symmetric extrude failed"); + BRepAlgoAPI_Fuse fuse(pos.Shape(), neg.Shape()); + if (!fuse.IsDone()) throw std::runtime_error("Fuse failed"); + return fuse.Shape(); + } + BRepPrimAPI_MakePrism prism(face, vec); + if (!prism.IsDone()) throw std::runtime_error("Extrude failed"); + return prism.Shape(); +} + +TopoDS_Shape SketchEngine::make_extrude(const TopoDS_Wire& wire, const SketchPlane& plane, + double length, bool symmetric, double taper_deg) +{ + BRepBuilderAPI_MakeFace fm(wire); + if (!fm.IsDone()) throw std::runtime_error("Failed to make face from wire"); + return make_extrude(fm.Face(), plane, length, symmetric, taper_deg); +} + +TopoDS_Shape SketchEngine::make_extrude(const TopoDS_Face& face, const SketchPlane& plane, + double length, bool symmetric, double /*taper_deg*/) +{ + gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); + return extrude_face_internal(face, dir, length, symmetric); +} + +TopoDS_Shape SketchEngine::make_extrude_two_sided(const TopoDS_Wire& wire, const SketchPlane& plane, + double up, double down) +{ + BRepBuilderAPI_MakeFace fm(wire); + if (!fm.IsDone()) throw std::runtime_error("Failed to make face from wire"); + return make_extrude_two_sided(fm.Face(), plane, up, down); +} + +TopoDS_Shape SketchEngine::make_extrude_two_sided(const TopoDS_Face& face, const SketchPlane& plane, + double up, double down) +{ + gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); + const double u = std::abs(up), d = std::abs(down); + if (u < 1e-9 && d < 1e-9) return TopoDS_Shape(); + if (d < 1e-9) { BRepPrimAPI_MakePrism p(face, gp_Vec(dir) * u); return p.Shape(); } + if (u < 1e-9) { BRepPrimAPI_MakePrism p(face, gp_Vec(dir) * -d); return p.Shape(); } + BRepPrimAPI_MakePrism pos(face, gp_Vec(dir) * u); + BRepPrimAPI_MakePrism neg(face, gp_Vec(dir) * -d); + BRepAlgoAPI_Fuse fuse(pos.Shape(), neg.Shape()); + if (!fuse.IsDone()) throw std::runtime_error("two-sided extrude fuse failed"); + return fuse.Shape(); +} + +TopoDS_Shape SketchEngine::make_extrude_taper(const TopoDS_Wire& wire, const SketchPlane& plane, + double length, double taper_deg) +{ + gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); + auto straight = [&]() -> TopoDS_Shape { + BRepBuilderAPI_MakeFace fm(wire); + BRepPrimAPI_MakePrism prism(fm.Face(), gp_Vec(dir) * length); + return prism.Shape(); + }; + if (std::abs(taper_deg) >= 89.0 || std::abs(length) < 1e-9) return straight(); + const double off = length * std::tan(taper_deg * M_PI / 180.0); + if (std::abs(off) < 1e-7) return straight(); + try { + // 1) offset the planar base wire in its own plane by `off` + BRepOffsetAPI_MakeOffset mko(wire, GeomAbs_Arc); + mko.Perform(off); + if (!mko.IsDone()) return straight(); + TopoDS_Shape offShape = mko.Shape(); + TopoDS_Wire topFlat; + if (offShape.ShapeType() == TopAbs_WIRE) topFlat = TopoDS::Wire(offShape); + else { for (TopExp_Explorer ex(offShape, TopAbs_WIRE); ex.More(); ex.Next()) { topFlat = TopoDS::Wire(ex.Current()); break; } } + if (topFlat.IsNull()) return straight(); + // 2) lift it along the normal by `length` + gp_Trsf tr; tr.SetTranslation(gp_Vec(dir) * length); + BRepBuilderAPI_Transform xf(topFlat, tr, Standard_True); + TopoDS_Wire topWire = TopoDS::Wire(xf.Shape()); + // 3) loft base -> top into a solid + BRepOffsetAPI_ThruSections loft(Standard_True /*solid*/, Standard_False /*ruled*/); + loft.AddWire(wire); + loft.AddWire(topWire); + loft.Build(); + if (!loft.IsDone()) return straight(); + TopoDS_Shape s = loft.Shape(); + if (s.IsNull()) return straight(); + return s; + } catch (const Standard_Failure&) { + return straight(); + } +} + +TopoDS_Shape SketchEngine::make_extrude_face(const TopoDS_Face& face, const SketchPlane& plane, + double length, bool symmetric, double /*taper_deg*/) +{ + gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); + return extrude_face_internal(face, dir, length, symmetric); +} + +TopoDS_Shape SketchEngine::make_extrude_regions( + const std::vector>>& regions, + const SketchPlane& plane, double length, bool symmetric) +{ + // Drop consecutive coincident points and the closing duplicate. FreeType / + // SVG flattening routinely emits repeated points which would build a + // degenerate OCCT edge and make the wire builder throw — sanitising keeps a + // single bad glyph from killing the whole extrude. + auto clean = [](const std::vector& pts) { + const double eps2 = 1e-12; // ~1e-6 mm + std::vector out; + out.reserve(pts.size()); + for (const Vec2d& p : pts) + if (out.empty() || (p - out.back()).squaredNorm() > eps2) + out.push_back(p); + while (out.size() >= 2 && (out.front() - out.back()).squaredNorm() <= eps2) + out.pop_back(); + return out; + }; + + // Build a closed planar wire from a contour. Never throws — returns a null + // wire on any failure so the caller can skip just that contour. Winding is + // NOT normalised here: ShapeFix_Face::FixOrientation() below classifies outer + // vs hole by geometry and fixes orientations, which is robust to the + // inconsistent winding Emboss/NSVG glyph contours arrive with (manual + // winding guesses extrude holed glyphs (P, e, o, 8) inverted or solid). + auto contour_wire = [&](const std::vector& raw) -> TopoDS_Wire { + std::vector pts = clean(raw); + if (pts.size() < 3) return TopoDS_Wire{}; + SketchProfile prof; + prof.points = std::move(pts); + prof.closed = true; + try { return prof.to_occt_wire(plane); } + catch (...) { return TopoDS_Wire{}; } + }; + + gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); + + // Accumulate each region's solid into a compound rather than boolean-fusing: + // glyphs are independent profiles, so a compound avoids every boolean-failure + // mode (and is faster). Holes are still handled per region by MakeFace. + BRep_Builder bb; + TopoDS_Compound comp; + bb.MakeCompound(comp); + int count = 0; + TopoDS_Shape last; + + for (const auto& region : regions) { + if (region.empty()) continue; + try { + TopoDS_Wire outer = contour_wire(region[0]); + if (outer.IsNull()) continue; + + // Add the outer loop + every hole loop as-is, then let ShapeFix_Face + // classify outer vs holes by area/containment and set correct wire + // orientations. This is winding-independent, so holed glyphs extrude + // with a solid body and empty counters regardless of source winding. + BRepBuilderAPI_MakeFace fm(outer); + if (!fm.IsDone()) continue; + for (size_t h = 1; h < region.size(); ++h) { + TopoDS_Wire hole = contour_wire(region[h]); + if (hole.IsNull()) continue; + fm.Add(hole); + } + if (!fm.IsDone()) continue; + + ShapeFix_Face sff(fm.Face()); + sff.FixOrientation(); + const TopoDS_Face face = sff.Face(); + + TopoDS_Shape solid = extrude_face_internal(face, dir, length, symmetric); + bb.Add(comp, solid); + last = solid; + ++count; + } catch (...) { + continue; // skip one bad glyph rather than fail the whole insert + } + } + + if (count == 0) throw std::runtime_error("imported regions produced no extrudable geometry"); + return count == 1 ? last : TopoDS_Shape(comp); // avoid a compound-of-one +} + +TopoDS_Shape SketchEngine::make_revolve(const TopoDS_Wire& wire, const SketchPlane& plane, + double angle_deg, int axis_sel) +{ + BRepBuilderAPI_MakeFace faceMaker(wire); + if (!faceMaker.IsDone()) + throw std::runtime_error("Failed to make face from wire"); + TopoDS_Face face = faceMaker.Face(); + + // Revolution axis lies in the sketch plane through its origin: X (0) or Y (1). + const Vec3d& adir = (axis_sel == 1) ? plane.y_axis : plane.x_axis; + gp_Pnt o(plane.origin.x(), plane.origin.y(), plane.origin.z()); + gp_Dir xd(adir.x(), adir.y(), adir.z()); + gp_Ax1 axis(o, xd); + + double angle_rad = angle_deg * M_PI / 180.0; + // A negative angle is expressed as a positive sweep about the reversed axis, + // since BRepPrimAPI_MakeRevol expects an angle in (0, 2*pi]. + if (angle_rad < 0) { axis.Reverse(); angle_rad = -angle_rad; } + BRepPrimAPI_MakeRevol rev(face, axis, angle_rad); + if (!rev.IsDone()) + throw std::runtime_error("Failed to revolve"); + return rev.Shape(); +} + +TopoDS_Shape SketchEngine::make_sweep(const TopoDS_Wire& profile, const TopoDS_Wire& path) +{ + BRepBuilderAPI_MakeFace faceMaker(profile); + if (!faceMaker.IsDone()) + throw std::runtime_error("Failed to make face from sweep profile"); + TopoDS_Face face = faceMaker.Face(); + + BRepOffsetAPI_MakePipe pipe(path, face); + pipe.Build(); + if (!pipe.IsDone()) + throw std::runtime_error("Failed to sweep profile along path"); + return pipe.Shape(); +} + +TopoDS_Shape SketchEngine::make_loft(const std::vector& profiles, bool ruled) +{ + if (profiles.size() < 2) + throw std::runtime_error("loft needs at least 2 profiles"); + BRepOffsetAPI_ThruSections loft(Standard_True /*solid*/, + ruled ? Standard_True : Standard_False); + for (const TopoDS_Wire& w : profiles) { + if (w.IsNull()) throw std::runtime_error("loft: null profile wire"); + loft.AddWire(w); + } + loft.Build(); + if (!loft.IsDone()) throw std::runtime_error("loft failed"); + TopoDS_Shape s = loft.Shape(); + if (s.IsNull()) throw std::runtime_error("loft produced no solid"); + return s; +} + +// ponytail: sibling of make_loft that builds an open shell (sheet) instead of a solid. +TopoDS_Shape SketchEngine::make_loft_surface(const std::vector& profiles, bool ruled) +{ + if (profiles.size() < 2) + throw std::runtime_error("loft needs at least 2 profiles"); + BRepOffsetAPI_ThruSections loft(Standard_False /*shell, no end caps*/, + ruled ? Standard_True : Standard_False); + for (const TopoDS_Wire& w : profiles) { + if (w.IsNull()) throw std::runtime_error("loft: null profile wire"); + loft.AddWire(w); + } + loft.Build(); + if (!loft.IsDone()) throw std::runtime_error("loft failed"); + TopoDS_Shape s = loft.Shape(); + if (s.IsNull()) throw std::runtime_error("loft produced no shape"); + return s; +} + +TopoDS_Shape SketchEngine::make_pocket(const TopoDS_Wire& wire, const SketchPlane& plane, + const TopoDS_Shape& target, double depth) +{ + BRepBuilderAPI_MakeFace fm(wire); + if (!fm.IsDone()) throw std::runtime_error("Pocket face failed"); + TopoDS_Shape tool = extrude_face_internal(fm.Face(), + gp_Dir(plane.normal.x(), plane.normal.y(), plane.normal.z()), depth + 1.0, false); + BRepAlgoAPI_Cut cut(target, tool); + if (!cut.IsDone()) throw std::runtime_error("Pocket cut failed"); + return cut.Shape(); +} + +TriangleMesh SketchEngine::tessellate(const TopoDS_Shape& shape, + double linear_deflection, + double angular_deflection) +{ + std::vector dummy; + return tessellate(shape, dummy, linear_deflection, angular_deflection); +} + +TriangleMesh SketchEngine::tessellate(const TopoDS_Shape& shape, + std::vector& tri_face, + double linear_deflection, + double angular_deflection) +{ + tri_face.clear(); + BRepMesh_IncrementalMesh mesh(shape, linear_deflection, false, angular_deflection, true); + + int nbNodes = 0, nbTriangles = 0; + for (TopExp_Explorer exp(shape, TopAbs_FACE); exp.More(); exp.Next()) { + TopLoc_Location loc; + Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(TopoDS::Face(exp.Current()), loc); + if (!tri.IsNull()) { + nbNodes += tri->NbNodes(); + nbTriangles += tri->NbTriangles(); + } + } + + if (nbTriangles == 0 || nbNodes == 0) + return TriangleMesh{}; + + indexed_triangle_set raw; + raw.vertices.reserve(nbNodes); + raw.indices.reserve(nbTriangles); + tri_face.reserve(nbTriangles); + + int faceIdx = -1; + int nodeOff = 0; + for (TopExp_Explorer exp(shape, TopAbs_FACE); exp.More(); exp.Next()) { + ++faceIdx; + + TopLoc_Location loc; + Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(TopoDS::Face(exp.Current()), loc); + if (tri.IsNull()) continue; + + gp_Trsf trsf = loc.Transformation(); + for (int i = 1; i <= tri->NbNodes(); ++i) { + gp_Pnt p = tri->Node(i); + p.Transform(trsf); + raw.vertices.emplace_back(Vec3f(p.X(), p.Y(), p.Z())); + } + + TopAbs_Orientation orient = exp.Current().Orientation(); + int ids[3]; + for (int i = 1; i <= tri->NbTriangles(); ++i) { + Poly_Triangle t = tri->Triangle(i); + t.Get(ids[0], ids[1], ids[2]); + if (orient == TopAbs_REVERSED) + std::swap(ids[1], ids[2]); + + raw.indices.emplace_back(nodeOff + ids[0] - 1, + nodeOff + ids[1] - 1, + nodeOff + ids[2] - 1); + tri_face.push_back(faceIdx); + } + nodeOff += tri->NbNodes(); + } + + std::map, int> vmap; + indexed_triangle_set its; + its.indices.reserve(raw.indices.size()); + its.vertices.reserve(raw.vertices.size() / 2); + + std::vector kept_face; + kept_face.reserve(tri_face.size()); + + for (size_t ti = 0; ti < raw.indices.size(); ++ti) { + const auto& tri = raw.indices[ti]; + stl_triangle_vertex_indices new_tri; + for (int j = 0; j < 3; ++j) { + const stl_vertex& v = raw.vertices[tri[j]]; + auto key = std::make_tuple(v.x(), v.y(), v.z()); + auto it = vmap.find(key); + if (it == vmap.end()) { + int new_id = static_cast(its.vertices.size()); + vmap[key] = new_id; + its.vertices.push_back(v); + new_tri[j] = new_id; + } else { + new_tri[j] = it->second; + } + } + // Drop triangles that welding collapsed to a repeated vertex. OCCT emits one at the + // pole of every degenerate surface parameterization — a sphere patch at a filleted + // corner has exactly one — and its v->v edge can never pair with a neighbour, so the + // mesh reports an open edge per corner and the slicer declares the model non-manifold + // and tells the user to repair it elsewhere. The triangle has zero area: removing it + // changes no geometry, only the mesh's bookkeeping. + if (new_tri[0] == new_tri[1] || new_tri[1] == new_tri[2] || new_tri[0] == new_tri[2]) + continue; + its.indices.push_back(new_tri); + kept_face.push_back(tri_face[ti]); + } + tri_face.swap(kept_face); // tri_face stays index-aligned with its.indices + + return TriangleMesh(std::move(its)); +} + +std::vector SketchEngine::entities_to_wires(const std::vector& entities, + const SketchPlane& plane, + bool closed_only) +{ + // Effective weld tolerance: kSketchJoinTol when auto-close is on, 0.0 when off. + // Read ONCE so the union-find, the node weld and the vertex tolerance below all + // agree. With 0.0 the comparisons use <= so exactly coincident endpoints still join. + const double tol = sketch_join_tol(); + + struct Item { const SketchEntity* e; size_t idx; }; + std::vector valid; + valid.reserve(entities.size()); + for (size_t i = 0; i < entities.size(); ++i) { + const SketchEntity& e = entities[i]; + if (e.construction) continue; + if (e.type == SketchEntity::Type::Point) continue; + valid.push_back({&e, i}); + } + if (valid.empty()) return {}; + + // Build an OCCT ellipse (gp_Elips) in the sketch plane from an Ellipse(Arc) + // entity. Major-axis direction = plane-rotated (cos phi, sin phi). Enforces + // a >= b (OCCT requirement); the GUI builder already guarantees this. + auto make_elips = [&](const SketchEntity& c) -> gp_Elips { + Vec3d c3 = plane.to_world(c.center); + gp_Pnt center(c3.x(), c3.y(), c3.z()); + gp_Dir n(plane.normal.x(), plane.normal.y(), plane.normal.z()); + Vec2d maj2(std::cos(c.rotation), std::sin(c.rotation)); + Vec3d x3 = plane.to_world(c.center + maj2) - c3; + gp_Dir xdir(x3.x(), x3.y(), x3.z()); + double a = c.radius, b = c.rminor; + if (a < b) std::swap(a, b); + return gp_Elips(gp_Ax2(center, n, xdir), a, b); + }; + + // Clamped uniform B-spline (degree min(3, n-1)) through the control poles. The + // knot construction is mirrored in DesignSketchTool's GUI sampler so the on-screen + // curve matches the extruded geometry exactly. + auto make_bspline = [&](const SketchEntity& c) -> Handle(Geom_BSplineCurve) { + const int n = int(c.ctrl.size()); + const int p = n >= 4 ? 3 : (n >= 2 ? n - 1 : 0); + if (p < 1) return Handle(Geom_BSplineCurve)(); + TColgp_Array1OfPnt poles(1, n); + for (int i = 0; i < n; ++i) { + Vec3d w = plane.to_world(c.ctrl[i]); + poles.SetValue(i + 1, gp_Pnt(w.x(), w.y(), w.z())); + } + const int interior = n - p - 1; // count of single interior knots + const int nknots = interior + 2; + TColStd_Array1OfReal knots(1, nknots); + TColStd_Array1OfInteger mults(1, nknots); + knots.SetValue(1, 0.0); mults.SetValue(1, p + 1); + for (int i = 1; i <= interior; ++i) { knots.SetValue(i + 1, double(i)); mults.SetValue(i + 1, 1); } + knots.SetValue(nknots, double(interior + 1)); mults.SetValue(nknots, p + 1); + return new Geom_BSplineCurve(poles, knots, mults, p); + }; + + auto is_chain = [](const SketchEntity& e) { + return e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc || + e.type == SketchEntity::Type::EllipseArc || e.type == SketchEntity::Type::BSpline; + }; + + // Endpoints of a chain entity in SKETCH coordinates (before to_world). False on a + // degenerate (fewer than two control points) BSpline, which can never close a loop. + auto endpoints = [&](const SketchEntity& e, Vec2d& a, Vec2d& b) -> bool { + if (e.type == SketchEntity::Type::BSpline) { + if (e.ctrl.size() < 2) return false; + a = e.ctrl.front(); b = e.ctrl.back(); + return true; + } + a = e.p0; b = e.p1; + return true; + }; + + // Sketch weld tolerance. Nothing legitimate in a mm-scale sketch is 1 um apart, so two + // endpoints within this distance count as one joint. The union-find grouping and the wire + // build below MUST use the SAME number, or a joint can be united into a loop and then + // rejected by the wire builder (which silently drops the edge — see the wire build). + // `<=` (not `<`) so exactly coincident endpoints still join when tol == 0 (auto-close off). + auto same = [&](const Vec2d& p, const Vec2d& q) { return (p - q).norm() <= tol; }; + + // Union-find over the valid index list: chain entities sharing an endpoint belong to one loop. + std::vector parent(valid.size()); + for (size_t i = 0; i < valid.size(); ++i) parent[i] = int(i); + auto find = [&](int x) { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + }; + auto unite = [&](int x, int y) { + int rx = find(x), ry = find(y); + if (rx != ry) parent[rx] = ry; + }; + + std::vector chain_idx; + chain_idx.reserve(valid.size()); + for (size_t i = 0; i < valid.size(); ++i) + if (is_chain(*valid[i].e)) chain_idx.push_back(i); + + for (size_t a = 0; a < chain_idx.size(); ++a) { + const size_t i = chain_idx[a]; + Vec2d i0, i1; + if (!endpoints(*valid[i].e, i0, i1)) continue; + for (size_t b = a + 1; b < chain_idx.size(); ++b) { + const size_t j = chain_idx[b]; + Vec2d j0, j1; + if (!endpoints(*valid[j].e, j0, j1)) continue; + if (same(i0, j0) || same(i0, j1) || same(i1, j0) || same(i1, j1)) + unite(int(i), int(j)); + } + } + + // Group chain entities by connected-component root. + std::map> comps; + for (size_t i = 0; i < valid.size(); ++i) { + if (!is_chain(*valid[i].e)) continue; + comps[find(int(i))].push_back(i); + } + + // Loop descriptors: each Circle/Ellipse is its own loop; each chain component is a loop. + struct Loop { size_t min_idx{0}; bool closed_single{false}; size_t member{0}; std::vector members; }; + std::vector loops; + for (size_t i = 0; i < valid.size(); ++i) { + const SketchEntity& e = *valid[i].e; + if (e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Ellipse) { + Loop l; l.min_idx = valid[i].idx; l.closed_single = true; l.member = i; + loops.push_back(l); + } + } + for (const auto& kv : comps) { + Loop l; l.closed_single = false; l.members = kv.second; + size_t mn = std::numeric_limits::max(); + for (size_t m : kv.second) mn = std::min(mn, valid[m].idx); + l.min_idx = mn; + loops.push_back(l); + } + // Deterministic order: by the index of each loop's first entity. + std::sort(loops.begin(), loops.end(), [](const Loop& x, const Loop& y) { return x.min_idx < y.min_idx; }); + + // Build each loop. All-or-nothing: one failed loop poisons the whole result. + std::vector out; + out.reserve(loops.size()); + for (const Loop& loop : loops) { + BRepBuilderAPI_MakeWire wm; + if (loop.closed_single) { + const SketchEntity& c = *valid[loop.member].e; + TopoDS_Edge e; + if (c.type == SketchEntity::Type::Ellipse) { + if (c.radius <= 1e-9 || c.rminor <= 1e-9) return {}; + e = BRepBuilderAPI_MakeEdge(make_elips(c)).Edge(); + } else { + Vec3d c3 = plane.to_world(c.center); + gp_Pnt center(c3.x(), c3.y(), c3.z()); + gp_Dir n(plane.normal.x(), plane.normal.y(), plane.normal.z()); + gp_Circ circ(gp_Ax2(center, n), c.radius); + e = BRepBuilderAPI_MakeEdge(circ).Edge(); + } + wm.Add(e); + } else { + // loop.members is in ENTITY-CREATION order, not loop-traversal order. A partial + // wire rejects an out-of-order edge even for a perfectly closed sketch, so first + // weld every endpoint into a shared node, then traverse the chain in order. Each + // endpoint snaps to an existing node when within `tol`, and every edge + // touching a node shares ONE TopoDS_Vertex: the wire builder then sees vertex + // identity, not geometric proximity, so a joint open by a few um (larger than + // OCCT's default 1e-7 vertex tolerance) still connects. + struct Member { size_t v; int a; int b; }; // v = index into `valid`, a/b = node ids + std::vector node_pt; // welded sketch point per node + std::vector node_deg; // endpoint count per node + auto node_id = [&](const Vec2d& p) -> int { + for (size_t i = 0; i < node_pt.size(); ++i) + if ((node_pt[i] - p).norm() <= tol) return int(i); + node_pt.push_back(p); + node_deg.push_back(0); + return int(node_pt.size()) - 1; + }; + std::vector ms; + ms.reserve(loop.members.size()); + for (size_t m : loop.members) { + Vec2d p0, p1; + if (!endpoints(*valid[m].e, p0, p1)) return {}; + int a = node_id(p0), b = node_id(p1); + node_deg[a]++; node_deg[b]++; + ms.push_back({m, a, b}); + } + + // Traverse: begin at a degree-1 node for an open chain, else at any member, then + // repeatedly take the unused member sharing the current open node. + std::vector order; + order.reserve(ms.size()); + std::vector used(ms.size(), 0); + size_t start = 0; + for (size_t i = 0; i < ms.size(); ++i) + if (node_deg[ms[i].a] == 1 || node_deg[ms[i].b] == 1) { start = i; break; } + const int start_node = (node_deg[ms[start].a] == 1) ? ms[start].a + : (node_deg[ms[start].b] == 1) ? ms[start].b + : ms[start].a; + int open = start_node; + for (;;) { + size_t next = ms.size(); + for (size_t k = 0; k < ms.size(); ++k) { + if (used[k]) continue; + if (ms[k].a == open || ms[k].b == open) { next = k; break; } + } + if (next == ms.size()) break; + order.push_back(next); + used[next] = 1; + open = (ms[next].a == open) ? ms[next].b : ms[next].a; + } + if (order.size() != ms.size()) return {}; + + // The walk ends on the node it could not leave; that node equals the starting + // node exactly when the chain is a closed cycle. When closed_only is set, an open + // chain is DISCARDED (skipped), never an error: the viewport discards open chains + // when it decides a region is extrudable (region_loops exists to find EXTRUDABLE + // regions), so the kernel must discard them too, or the two disagree about what + // belongs to the profile and a stray click breaks a feature that looks perfect on + // screen. Reuses the welded node ids — no second tolerance. + // A component is OPEN exactly when some welded node has only one edge on it — + // that free endpoint is where the chain stops. Do NOT define it as "the walk + // returned to its starting node": a legitimately closed loop that also carries an + // extra edge between two of its nodes (a bridge added across a C profile, so the + // gap is spanned twice) has no free endpoint but its Eulerian walk still ends + // somewhere else, and that definition discarded it. Degree-1 is the property that + // actually distinguishes a stray segment from a closed profile. + if (closed_only) { + bool has_free_end = false; + for (const Member& mm : ms) + if (node_deg[mm.a] == 1 || node_deg[mm.b] == 1) { has_free_end = true; break; } + if (has_free_end) continue; + } + + // One TopoDS_Vertex per node at the welded world point. Tolerance widened to + // `tol` because MakeEdge(curve, va, vb) projects each vertex onto the + // curve within the vertex tolerance (BRepLib_MakeEdge::Init), and a welded node + // can be up to the weld gap off another entity's curve. OCCT must never be handed + // a zero vertex tolerance, so clamp at Precision::Confusion() when tol == 0. + std::vector verts(node_pt.size()); + BRep_Builder B; + for (size_t i = 0; i < node_pt.size(); ++i) { + Vec3d w = plane.to_world(node_pt[i]); + verts[i] = BRepBuilderAPI_MakeVertex(gp_Pnt(w.x(), w.y(), w.z())).Vertex(); + B.UpdateVertex(verts[i], std::max(tol, Precision::Confusion())); + } + + for (size_t o : order) { + const Member& mm = ms[o]; + const SketchEntity* e = valid[mm.v].e; + const TopoDS_Vertex& va = verts[mm.a]; + const TopoDS_Vertex& vb = verts[mm.b]; + if (e->type == SketchEntity::Type::Line) { + wm.Add(BRepBuilderAPI_MakeEdge(va, vb).Edge()); + } else if (e->type == SketchEntity::Type::EllipseArc) { + if (e->radius <= 1e-9 || e->rminor <= 1e-9) return {}; + GC_MakeArcOfEllipse arc_maker(make_elips(*e), e->start_angle, e->end_angle, Standard_True); + if (!arc_maker.IsDone()) return {}; + wm.Add(BRepBuilderAPI_MakeEdge(arc_maker.Value(), va, vb).Edge()); + } else if (e->type == SketchEntity::Type::Arc) { + Vec3d p0 = plane.to_world(e->p0); + Vec3d p1 = plane.to_world(e->p1); + double mid_angle = (e->start_angle + e->end_angle) * 0.5; + Vec2d mid_2d(e->center.x() + e->radius * std::cos(mid_angle), + e->center.y() + e->radius * std::sin(mid_angle)); + Vec3d mid_3d = plane.to_world(mid_2d); + gp_Pnt pa(p0.x(), p0.y(), p0.z()); + gp_Pnt pm(mid_3d.x(), mid_3d.y(), mid_3d.z()); + gp_Pnt pb(p1.x(), p1.y(), p1.z()); + GC_MakeArcOfCircle arc_maker(pa, pm, pb); + if (!arc_maker.IsDone()) return {}; + Handle(Geom_TrimmedCurve) curve = arc_maker.Value(); + wm.Add(BRepBuilderAPI_MakeEdge(curve, va, vb).Edge()); + } else if (e->type == SketchEntity::Type::BSpline) { + Handle(Geom_BSplineCurve) crv = make_bspline(*e); + if (crv.IsNull()) return {}; + wm.Add(BRepBuilderAPI_MakeEdge(crv, va, vb).Edge()); + } + } + } + wm.Build(); + // IsDone() reflects only the LAST Add: BRepLib_MakeWire::Add sets BRepLib_DisconnectedWire + // + NotDone() and returns on a disconnected edge (dropping it), but every successful Add + // finishes with BRepLib_WireDone + Done(), overwriting that failure (BRepLib_MakeWire.cxx). + // So dropped edges go unnoticed — count the edges actually in the wire instead. + if (!wm.IsDone()) return {}; + const TopoDS_Wire wire = wm.Wire(); + size_t edge_count = 0; + for (TopExp_Explorer ex(wire, TopAbs_EDGE); ex.More(); ex.Next()) ++edge_count; + if (edge_count != (loop.closed_single ? 1 : loop.members.size())) return {}; + out.push_back(wire); + } + return out; +} + +TopoDS_Wire SketchEngine::entities_to_wire(const std::vector& entities, + const SketchPlane& plane, + bool closed_only) +{ + const std::vector w = entities_to_wires(entities, plane, closed_only); + return w.size() == 1 ? w[0] : TopoDS_Wire{}; +} + +std::vector sketch_open_ends(const std::vector& entities, + const SketchPlane& /*plane*/) +{ + const double tol = sketch_join_tol(); + + auto is_chain = [](const SketchEntity& e) { + return e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc || + e.type == SketchEntity::Type::EllipseArc || e.type == SketchEntity::Type::BSpline; + }; + auto endpoints = [](const SketchEntity& e, Vec2d& a, Vec2d& b) -> bool { + if (e.type == SketchEntity::Type::BSpline) { + if (e.ctrl.size() < 2) return false; + a = e.ctrl.front(); b = e.ctrl.back(); + return true; + } + a = e.p0; b = e.p1; + return true; + }; + + // Weld every chain endpoint into a shared node under the SAME tolerance the wire + // build uses, then report the degree-1 nodes: they are where a chain fails to close. + // Circle/Ellipse are always closed and contribute no endpoint. + std::vector node_pt; + std::vector node_deg; + auto node_id = [&](const Vec2d& p) -> int { + for (size_t i = 0; i < node_pt.size(); ++i) + if ((node_pt[i] - p).norm() <= tol) return int(i); + node_pt.push_back(p); + node_deg.push_back(0); + return int(node_pt.size()) - 1; + }; + + for (const SketchEntity& e : entities) { + if (e.construction) continue; + if (!is_chain(e)) continue; + Vec2d a, b; + if (!endpoints(e, a, b)) continue; + int ia = node_id(a), ib = node_id(b); + node_deg[ia]++; + node_deg[ib]++; + } + + std::vector out; + for (size_t i = 0; i < node_pt.size(); ++i) + if (node_deg[i] == 1) out.push_back(node_pt[i]); + return out; +} + +TopoDS_Face SketchEngine::wires_to_face(const std::vector& wires, + const SketchPlane& plane) +{ + if (wires.empty()) throw std::runtime_error("sketch has no closed loop"); + + // The ASSEMBLED face below is built on the SKETCH's own plane rather than on a surface OCCT + // infers from the outer wire. The inferred plane has no reason to share the sketch's normal, + // and when they disagree the hole classification produces no hole: a plate sketched on a + // plane whose normal points -Z came out as the full box PLUS a disc (measured 220274 mm3 + // where 163726 was due — 192000 box + 28274 disc). `plane` was a parameter this function + // never used. Only the assembly is named: the single-wire and per-wire-area builds keep the + // inferred surface, because naming a plane also makes MakeFace accept a wire that does not + // bound a face, and that failure is the check an open stray line is caught by. + const gp_Pln pln(gp_Pnt(plane.origin.x(), plane.origin.y(), plane.origin.z()), + gp_Dir(plane.normal.x(), plane.normal.y(), plane.normal.z())); + + if (wires.size() == 1) { + BRepBuilderAPI_MakeFace fm(wires[0]); + if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); + return fm.Face(); + } + + // Two or more loops: build a face per wire and let the largest area be the outer + // boundary; every other loop is a candidate hole inside it. + std::vector faces; + faces.reserve(wires.size()); + std::vector areas; + areas.reserve(wires.size()); + for (const TopoDS_Wire& w : wires) { + BRepBuilderAPI_MakeFace fm(w); + if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); + faces.push_back(fm.Face()); + GProp_GProps props; + BRepGProp::SurfaceProperties(faces.back(), props); + areas.push_back(props.Mass()); + } + + size_t outer = 0; + for (size_t i = 1; i < areas.size(); ++i) + if (areas[i] > areas[outer]) outer = i; + + // Note: NOT MakeFace(faces[outer], wires[outer]) — that constructor copies the outer face + // (including its existing boundary wire) and then adds the wire again, doubling the outer + // boundary. The wire-only constructor starts clean and the reversed holes follow. + BRepBuilderAPI_MakeFace fm(pln, wires[outer]); + for (size_t i = 0; i < wires.size(); ++i) { + if (i == outer) continue; + // Containment is checked, not assumed: a vertex of the inner wire must lie strictly + // inside the outer face. A loop outside the largest one is a second island, not a hole. + gp_Pnt p; + bool got = false; + for (TopExp_Explorer ex(wires[i], TopAbs_VERTEX); ex.More(); ex.Next()) { + p = BRep_Tool::Pnt(TopoDS::Vertex(ex.Current())); + got = true; + break; + } + if (!got) throw std::runtime_error("sketch loop does not bound a face"); + BRepClass_FaceClassifier fc(faces[outer], p, 1e-7); + if (fc.State() != TopAbs_IN) + throw std::runtime_error("sketch has two disjoint regions; put each in its own sketch"); + // Add the hole loop AS-IS and let ShapeFix_Face sort the orientations out below. + // Reversing it here only works when the sketch happened to wind both loops the same + // way: a circle drawn clockwise inside a counter-clockwise rectangle comes out matching + // the outer boundary, OCCT sweeps it as a second contour, and the prism is the plate + // with the bore FILLED and the disc's volume counted twice. Measured on the rig: + // bbox 67.17 x 219.67 x 10 (the whole plate) with volume 152088 mm3 against a solid-box + // 147520 — a body larger than its own bounding box, which is the signature of it. + fm.Add(wires[i]); + } + if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); + // Winding-independent classification of outer vs holes — the same idiom make_extrude_regions + // already uses for imported glyphs, which is why holed TEXT extruded correctly all along + // while a holed SKETCH did not. + ShapeFix_Face sff(fm.Face()); + sff.FixOrientation(); + return sff.Face(); +} + +std::vector SketchEngine::mirror_entities( + const std::vector& src, const Vec2d& a, const Vec2d& b) +{ + Vec2d dir = b - a; + if (dir.norm() < 1e-12) + return src; + + dir.normalize(); + + auto reflect = [&](const Vec2d& p) -> Vec2d { + Vec2d v = p - a; + return a + (2.0 * v.dot(dir)) * dir - v; + }; + + std::vector out; + out.reserve(src.size()); + + for (const auto& e : src) { + SketchEntity m = e; + switch (e.type) { + case SketchEntity::Type::Line: + m.p0 = reflect(e.p0); + m.p1 = reflect(e.p1); + break; + case SketchEntity::Type::Point: + m.p0 = reflect(e.p0); + break; + case SketchEntity::Type::Circle: + m.center = reflect(e.center); + m.p0 = m.center; + break; + case SketchEntity::Type::Arc: { + m.p0 = reflect(e.p0); + m.p1 = reflect(e.p1); + m.center = reflect(e.center); + + const Vec2d& c = m.center; + m.start_angle = std::atan2(m.p0.y() - c.y(), m.p0.x() - c.x()); + double raw_end = std::atan2(m.p1.y() - c.y(), m.p1.x() - c.x()); + + double s = e.end_angle - e.start_angle; + + double sweep = raw_end - m.start_angle; + while (sweep <= -2.0 * M_PI) sweep += 2.0 * M_PI; + while (sweep >= 2.0 * M_PI) sweep -= 2.0 * M_PI; + + if (s != 0.0 && sweep * s > 0.0) { + if (sweep > 0.0) + sweep -= 2.0 * M_PI; + else + sweep += 2.0 * M_PI; + } + + m.end_angle = m.start_angle + sweep; + m.radius = e.radius; + break; + } + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: { + m.center = reflect(e.center); + // Reflect the major-axis direction; a/b unchanged. + const Vec2d majdir(std::cos(e.rotation), std::sin(e.rotation)); + const Vec2d rdir = reflect(e.center + majdir) - m.center; + m.rotation = std::atan2(rdir.y(), rdir.x()); + if (e.type == SketchEntity::Type::Ellipse) { + m.p0 = m.center; + } else { + m.p0 = reflect(e.p0); + m.p1 = reflect(e.p1); + // Reflection reverses orientation: recompute parametric angles in + // the reflected frame, original end -> new start (CCW sense kept). + auto param = [&](const Vec2d& P) { + const Vec2d d = P - m.center; + const double cu = std::cos(m.rotation), su = std::sin(m.rotation); + const double u = d.x() * cu + d.y() * su; + const double v = -d.x() * su + d.y() * cu; + return std::atan2(v / std::max(e.rminor, 1e-9), u / std::max(e.radius, 1e-9)); + }; + m.start_angle = param(m.p1); + m.end_angle = param(m.p0); + } + break; + } + case SketchEntity::Type::BSpline: + for (auto& cp : m.ctrl) cp = reflect(cp); + m.p0 = reflect(e.p0); + m.p1 = reflect(e.p1); + break; + } + out.push_back(m); + } + + // A REFLECTION REVERSES ORIENTATION, so the reflected half is handed back reversed — in + // order, and each entity flipped — or it does not CONTINUE the chain it was made from. + // + // Draw half a stadium left-to-right along the bottom, round the cap, right-to-left along the + // top, ending at (0, R). Reflecting each entity in place gives a half whose top run STARTS at + // (-L, R) and ENDS at (0, R): it meets the original head-to-head, not head-to-tail. Every + // consumer that walks the loop then has to cope, and two already had to be taught — the loop + // area cancelled its own arc correction against the negated sweep, and offset put the + // reflected half on the wrong side because it read each entity's STORED direction. Reversed + // here, the two halves are one walkable chain and a mirrored CCW loop stays CCW. + std::reverse(out.begin(), out.end()); + for (SketchEntity& m : out) { + switch (m.type) { + case SketchEntity::Type::Line: + std::swap(m.p0, m.p1); + break; + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: + std::swap(m.p0, m.p1); + std::swap(m.start_angle, m.end_angle); // what "walked the other way" means + break; + case SketchEntity::Type::BSpline: + std::swap(m.p0, m.p1); + std::reverse(m.ctrl.begin(), m.ctrl.end()); + break; + default: + break; // circle, ellipse, point: no direction to reverse + } + } + + return out; +} + +// ---- offset: chain-aware, with corner repair ------------------------------- +// +// Offsetting each entity on its own is geometrically correct per entity and USELESS as a +// sketch operation: a closed rectangle offset that way comes back as four parallel segments +// that no longer touch, so the result is four open wires and nothing can be extruded from it +// (measured — tests/libslic3r/test_sketchprofile.cpp). A profile is a chain, and the property +// that has to survive the operation is the chain, not the individual coordinates. +// +// So the offset runs in three steps: split the input into chains of entities joined by shared +// endpoints; offset every entity in a chain; then repair each seam by trimming/extending the +// two neighbours to the intersection of their offset supports (a miter join). Closed chains +// get their last-to-first seam repaired too, which is what makes the result closed again. +namespace { + +constexpr double kOffJoinEps = 1e-6; + +bool off_same(const Vec2d& a, const Vec2d& b) { return (a - b).squaredNorm() < kOffJoinEps * kOffJoinEps; } + +// Does this entity type take part in chaining (i.e. does it have two ends)? +bool off_is_open_curve(const SketchEntity& e) +{ + return e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc; +} + +// Infinite-support intersections. Each returns the candidate closest to `seed`, which is where +// the seam is expected to land, so the branch choice never depends on entity orientation. +bool off_pick(const std::vector& cands, const Vec2d& seed, Vec2d& out) +{ + if (cands.empty()) return false; + double best = std::numeric_limits::max(); + for (const Vec2d& c : cands) { + const double d = (c - seed).squaredNorm(); + if (d < best) { best = d; out = c; } + } + return true; +} + +bool off_line_line(const Vec2d& a0, const Vec2d& a1, const Vec2d& b0, const Vec2d& b1, + const Vec2d& seed, Vec2d& out) +{ + const Vec2d da = a1 - a0, db = b1 - b0; + const double den = da.x() * db.y() - da.y() * db.x(); + if (std::abs(den) < 1e-12) return false; // parallel: no miter exists + const Vec2d w = b0 - a0; + const double t = (w.x() * db.y() - w.y() * db.x()) / den; + out = a0 + t * da; + (void)seed; + return true; +} + +std::vector off_line_circle(const Vec2d& p0, const Vec2d& p1, const Vec2d& c, double r) +{ + std::vector out; + Vec2d d = p1 - p0; + const double dd = d.squaredNorm(); + if (dd < 1e-18 || r <= 0.0) return out; + const Vec2d f = p0 - c; + const double b = 2.0 * f.dot(d), cc = f.squaredNorm() - r * r; + const double disc = b * b - 4.0 * dd * cc; + if (disc < 0.0) return out; + const double sq = std::sqrt(disc); + out.push_back(p0 + ((-b - sq) / (2.0 * dd)) * d); + out.push_back(p0 + ((-b + sq) / (2.0 * dd)) * d); + return out; +} + +std::vector off_circle_circle(const Vec2d& c0, double r0, const Vec2d& c1, double r1) +{ + std::vector out; + const Vec2d d = c1 - c0; + const double L = d.norm(); + if (L < 1e-12 || L > r0 + r1 || L < std::abs(r0 - r1)) return out; + const double a = (r0 * r0 - r1 * r1 + L * L) / (2.0 * L); + const double h2 = r0 * r0 - a * a; + const double h = h2 > 0.0 ? std::sqrt(h2) : 0.0; + const Vec2d u = d / L, n(-u.y(), u.x()); + out.push_back(c0 + a * u + h * n); + out.push_back(c0 + a * u - h * n); + return out; +} + +// Move one end of an entity to `q`, keeping the entity's kind consistent (an arc re-derives +// the parametric angle from its centre, and its sweep direction is preserved). +void off_set_end(SketchEntity& e, bool at_end, const Vec2d& q) +{ + if (e.type == SketchEntity::Type::Line) { + (at_end ? e.p1 : e.p0) = q; + return; + } + if (e.type != SketchEntity::Type::Arc) return; + const bool ccw = e.end_angle >= e.start_angle; + const double ang = std::atan2(q.y() - e.center.y(), q.x() - e.center.x()); + if (at_end) { + e.p1 = q; + double a = ang; + if (ccw) { while (a < e.start_angle) a += 2.0 * M_PI; while (a - e.start_angle > 2.0 * M_PI) a -= 2.0 * M_PI; } + else { while (a > e.start_angle) a -= 2.0 * M_PI; while (e.start_angle - a > 2.0 * M_PI) a += 2.0 * M_PI; } + e.end_angle = a; + } else { + e.p0 = q; + double a = ang; + if (ccw) { while (a > e.end_angle) a -= 2.0 * M_PI; while (e.end_angle - a > 2.0 * M_PI) a += 2.0 * M_PI; } + else { while (a < e.end_angle) a += 2.0 * M_PI; while (a - e.end_angle > 2.0 * M_PI) a -= 2.0 * M_PI; } + e.start_angle = a; + } +} + +// Offset ONE entity, unrepaired. Returns false for the kinds v1 does not offset. +bool off_one(const SketchEntity& e, double d, SketchEntity& out) +{ + switch (e.type) { + case SketchEntity::Type::Line: { + Vec2d t = e.p1 - e.p0; + if (t.norm() < 1e-12) return false; + t.normalize(); + const Vec2d n(-t.y(), t.x()); + out = e; + out.p0 = e.p0 + d * n; + out.p1 = e.p1 + d * n; + return true; + } + case SketchEntity::Type::Circle: { + const double r = e.radius + d; + if (r <= 1e-9) return false; + out = e; out.radius = r; out.p0 = out.center; + return true; + } + case SketchEntity::Type::Arc: { + // Same convention as the Line above: +d moves the curve to the LEFT of its direction + // of travel. For a CCW arc the left side is the inside, so the radius SHRINKS; for a + // CW arc it grows. Reading the sign off the sweep is what keeps a stadium outline + // (lines + caps) offsetting as one body instead of the lines going one way and the + // caps the other — which is what a plain `radius + d` did. + const double sgn = (e.end_angle >= e.start_angle) ? -1.0 : 1.0; + const double r = e.radius + sgn * d; + if (r <= 1e-9) return false; + out = e; + out.radius = r; + out.p0 = e.center + r * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); + out.p1 = e.center + r * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + return true; + } + default: + // Point has nothing to offset; a true parallel of an ellipse is not an ellipse and of a + // spline is not a same-degree spline, so both stay out of v1 rather than lie about it. + return false; + } +} + +// Repair the seam between `a`'s end and `b`'s start: both are trimmed/extended to the +// intersection of their infinite supports nearest the gap. Returns false when no such point +// exists (parallel lines, non-intersecting circles), in which case the seam stays open. +bool off_join(SketchEntity& a, SketchEntity& b) +{ + const Vec2d seed = 0.5 * (a.p1 + b.p0); + Vec2d q; + const bool aL = a.type == SketchEntity::Type::Line; + const bool bL = b.type == SketchEntity::Type::Line; + if (aL && bL) { + if (!off_line_line(a.p0, a.p1, b.p0, b.p1, seed, q)) return false; + } else if (aL) { + if (!off_pick(off_line_circle(a.p0, a.p1, b.center, b.radius), seed, q)) return false; + } else if (bL) { + if (!off_pick(off_line_circle(b.p0, b.p1, a.center, a.radius), seed, q)) return false; + } else { + if (!off_pick(off_circle_circle(a.center, a.radius, b.center, b.radius), seed, q)) return false; + } + off_set_end(a, true, q); + off_set_end(b, false, q); + return true; +} + +// Normalise an entity that was offset while traversed REVERSED to head-to-tail traversal order: +// swap its stored ends so p0 is the traversal start and p1 the traversal end. An arc must swap +// its stored sweep too, because "traversed the other way" reverses the stored sweep direction. +void off_reverse(SketchEntity& e) +{ + std::swap(e.p0, e.p1); + if (e.type == SketchEntity::Type::Arc) + std::swap(e.start_angle, e.end_angle); +} + +} // namespace + +std::vector SketchEngine::offset_entities( + const std::vector& src, double d) +{ + std::vector out; + + // Closed and unchainable kinds first: they carry no seams, so they pass straight through. + std::vector open_idx; + for (int i = 0; i < int(src.size()); ++i) { + if (off_is_open_curve(src[i])) { open_idx.push_back(i); continue; } + SketchEntity o; + if (off_one(src[i], d, o)) out.push_back(o); + } + + std::vector used(open_idx.size(), false); + + // Stored endpoints of the k-th open entity. + auto ends = [&](int k, Vec2d& p0, Vec2d& p1) { p0 = src[open_idx[k]].p0; p1 = src[open_idx[k]].p1; }; + + // An endpoint shared by no OTHER unused open curve is a FREE end: the loose end of an open + // chain rather than a seam. (`used` matters — entities already pulled into a chain must not + // count, otherwise the far end of the chain we just walked would look shared.) + auto is_free = [&](int k, const Vec2d& pt) { + for (size_t j = 0; j < open_idx.size(); ++j) { + if (int(j) == k || used[j]) continue; + Vec2d q0, q1; ends(int(j), q0, q1); + if (off_same(pt, q0) || off_same(pt, q1)) return false; + } + return true; + }; + + struct Chain { + std::vector> items; // (entity index, reversed) + Vec2d start, end; // traversal start/end points + }; + + // Walk one chain from the unused seed `s`, traversing AWAY from its free end. `reversed` + // means the traversal enters at the entity's p1 and leaves at its p0, i.e. the entity is + // travelled opposite to its STORED direction. An entity whose p1 is free (but p0 is not) + // is the head of an open chain and must start reversed; an isolated entity or a closed + // loop starts forward. + auto walk = [&](int s) -> Chain { + Chain c; + Vec2d s0, s1; ends(s, s0, s1); + const bool rev = !is_free(s, s0) && is_free(s, s1); + c.items.emplace_back(s, rev); + used[s] = true; + c.start = rev ? s1 : s0; + c.end = rev ? s0 : s1; + for (;;) { + int nxt = -1; + bool nrev = false; + for (size_t j = 0; j < open_idx.size(); ++j) { + if (used[j]) continue; + Vec2d q0, q1; ends(int(j), q0, q1); + if (off_same(c.end, q0)) { nxt = int(j); nrev = false; break; } + if (off_same(c.end, q1)) { nxt = int(j); nrev = true; break; } + } + if (nxt < 0) break; + c.items.emplace_back(nxt, nrev); + used[nxt] = true; + Vec2d q0, q1; ends(nxt, q0, q1); + c.end = nrev ? q0 : q1; + } + return c; + }; + + // Offset one chain as traversed: every entity is offset with an EFFECTIVE distance that + // already accounts for how it was walked, then reversed entities have their stored ends + // swapped so the emitted chain is head-to-tail in traversal order (which is what keeps the + // seam repair below — and any later offset/mirror — well-oriented). A chain whose final + // traversal end coincides with its first traversal start is CLOSED. + auto emit = [&](const Chain& c) { + const bool closed = c.items.size() > 1 && off_same(c.end, c.start); + std::vector off; + off.reserve(c.items.size()); + for (const auto& it : c.items) { + // A reversed entity offsets with -d rather than +d: + // * a line walked backwards has its left-hand side on the other side, so -d; + // * an arc walked backwards has its sweep sign effectively flipped, which is exactly + // the `sgn` term off_one reads, so -d again. + const double ed = it.second ? -d : d; + SketchEntity o; + if (!off_one(src[open_idx[it.first]], ed, o)) continue; + if (it.second) off_reverse(o); + off.push_back(o); + } + if (off.empty()) return; + for (size_t i = 0; i + 1 < off.size(); ++i) off_join(off[i], off[i + 1]); + if (closed && off.size() > 1) off_join(off.back(), off.front()); + for (auto& o : off) out.push_back(o); + }; + + // Pass 1: open chains, seeded at a free end so they are never entered mid-way (which would + // split one open chain in two and lose a seam). + for (size_t s = 0; s < open_idx.size(); ++s) { + if (used[s]) continue; + Vec2d s0, s1; ends(int(s), s0, s1); + if (!is_free(int(s), s0) && !is_free(int(s), s1)) continue; + emit(walk(int(s))); + } + // Pass 2: what is left has no free end and is a CLOSED loop; start anywhere, forward. + for (size_t s = 0; s < open_idx.size(); ++s) { + if (used[s]) continue; + emit(walk(int(s))); + } + + return out; +} + +std::vector SketchEngine::array_entities( + const std::vector& src, int count, + const Vec2d& step, double angle_step, const Vec2d& pivot) +{ + std::vector out; + if (count < 2) return out; + + for (int i = 1; i < count; ++i) { + const double ang = i * angle_step; + const double ca = std::cos(ang), sa = std::sin(ang); + const Vec2d tr = double(i) * step; + // Rigid map: rotate about pivot by `ang`, then translate by `tr`. + auto xf = [&](const Vec2d& p) -> Vec2d { + const Vec2d d = p - pivot; + return Vec2d(pivot.x() + ca * d.x() - sa * d.y(), + pivot.y() + sa * d.x() + ca * d.y()) + tr; + }; + + for (const auto& e : src) { + SketchEntity m = e; // carry construction flag, radii, etc. + switch (e.type) { + case SketchEntity::Type::Line: + m.p0 = xf(e.p0); m.p1 = xf(e.p1); + break; + case SketchEntity::Type::Point: + m.p0 = xf(e.p0); + break; + case SketchEntity::Type::Circle: + m.center = xf(e.center); m.p0 = m.center; // radius unchanged + break; + case SketchEntity::Type::Arc: { + m.center = xf(e.center); + m.start_angle = e.start_angle + ang; + m.end_angle = e.end_angle + ang; // rigid: sweep preserved + m.p0 = m.center + e.radius * Vec2d(std::cos(m.start_angle), std::sin(m.start_angle)); + m.p1 = m.center + e.radius * Vec2d(std::cos(m.end_angle), std::sin(m.end_angle)); + break; + } + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: + m.center = xf(e.center); + m.rotation = e.rotation + ang; // major axis rotates with the body + if (e.type == SketchEntity::Type::Ellipse) { + m.p0 = m.center; + } else { + m.p0 = xf(e.p0); m.p1 = xf(e.p1); + // Parametric angles are in the (rotated) body frame -> unchanged. + } + break; + case SketchEntity::Type::BSpline: + for (auto& cp : m.ctrl) cp = xf(cp); + m.p0 = xf(e.p0); m.p1 = xf(e.p1); + break; + } + out.push_back(m); + } + } + return out; +} + +std::vector SketchEngine::transform_entities( + const std::vector& src, + const Vec2d& move, double angle, double scale, const Vec2d& pivot) +{ + std::vector out; + out.reserve(src.size()); + const double ca = std::cos(angle), sa = std::sin(angle); + const double rs = std::abs(scale); // radii are unsigned magnitudes + // Affine map: translate pivot to origin, scale, rotate, then translate by `move`. + auto xf = [&](const Vec2d& p) -> Vec2d { + const Vec2d d = scale * (p - pivot); + return Vec2d(pivot.x() + ca * d.x() - sa * d.y(), + pivot.y() + sa * d.x() + ca * d.y()) + move; + }; + + for (const auto& e : src) { + SketchEntity m = e; // carry construction flag, etc. + switch (e.type) { + case SketchEntity::Type::Line: + m.p0 = xf(e.p0); m.p1 = xf(e.p1); + break; + case SketchEntity::Type::Point: + m.p0 = xf(e.p0); + break; + case SketchEntity::Type::Circle: + m.center = xf(e.center); m.radius = e.radius * rs; m.p0 = m.center; + break; + case SketchEntity::Type::Arc: { + m.center = xf(e.center); + m.radius = e.radius * rs; + m.start_angle = e.start_angle + angle; + m.end_angle = e.end_angle + angle; // rigid sweep, shifted by rotation + m.p0 = m.center + m.radius * Vec2d(std::cos(m.start_angle), std::sin(m.start_angle)); + m.p1 = m.center + m.radius * Vec2d(std::cos(m.end_angle), std::sin(m.end_angle)); + break; + } + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: + m.center = xf(e.center); + m.radius = e.radius * rs; + m.rminor = e.rminor * rs; + m.rotation = e.rotation + angle; // major axis rotates with the body + if (e.type == SketchEntity::Type::Ellipse) { + m.p0 = m.center; + } else { + m.p0 = xf(e.p0); m.p1 = xf(e.p1); + // Parametric angles live in the (rotated) body frame -> unchanged. + } + break; + case SketchEntity::Type::BSpline: + for (auto& cp : m.ctrl) cp = xf(cp); + m.p0 = xf(e.p0); m.p1 = xf(e.p1); + break; + } + out.push_back(m); + } + return out; +} + +bool SketchEngine::fillet_lines(const SketchEntity& a, const SketchEntity& b, double r, + SketchEntity& a_out, SketchEntity& b_out, SketchEntity& arc_out) +{ + if (a.type != SketchEntity::Type::Line || b.type != SketchEntity::Type::Line || r <= 1e-9) + return false; + + Vec2d da = a.p1 - a.p0; + Vec2d db = b.p1 - b.p0; + double denom = da.x() * db.y() - da.y() * db.x(); + if (std::abs(denom) < 1e-12) + return false; + + Vec2d diff = b.p0 - a.p0; + double s = (diff.x() * db.y() - diff.y() * db.x()) / denom; + Vec2d C = a.p0 + s * da; + + Vec2d ua; + int a_near_idx; + { + double d0 = (a.p0 - C).norm(); + double d1 = (a.p1 - C).norm(); + if (d0 <= d1) { + a_near_idx = 0; + ua = a.p1 - C; + } else { + a_near_idx = 1; + ua = a.p0 - C; + } + } + if (ua.norm() < 1e-12) return false; + ua.normalize(); + + Vec2d ub; + int b_near_idx; + { + double d0 = (b.p0 - C).norm(); + double d1 = (b.p1 - C).norm(); + if (d0 <= d1) { + b_near_idx = 0; + ub = b.p1 - C; + } else { + b_near_idx = 1; + ub = b.p0 - C; + } + } + if (ub.norm() < 1e-12) return false; + ub.normalize(); + + double cosT = ua.dot(ub); + cosT = std::max(-1.0, std::min(1.0, cosT)); + double theta = std::acos(cosT); + if (theta < 1e-6 || theta > M_PI - 1e-6) + return false; + + double t = r / std::tan(theta / 2.0); + { + Vec2d a_far = (a_near_idx == 0) ? a.p1 : a.p0; + Vec2d b_far = (b_near_idx == 0) ? b.p1 : b.p0; + if (t > (a_far - C).norm() || t > (b_far - C).norm()) + return false; + } + + Vec2d Ta = C + t * ua; + Vec2d Tb = C + t * ub; + + Vec2d bis = ua + ub; + if (bis.norm() < 1e-12) return false; + bis.normalize(); + + double dCO = r / std::sin(theta / 2.0); + Vec2d O = C + dCO * bis; + + a_out = a; + b_out = b; + if (a_near_idx == 0) + a_out.p0 = Ta; + else + a_out.p1 = Ta; + + if (b_near_idx == 0) + b_out.p0 = Tb; + else + b_out.p1 = Tb; + + arc_out = SketchEntity{}; + arc_out.type = SketchEntity::Type::Arc; + arc_out.center = O; + arc_out.radius = r; + arc_out.p0 = Ta; + arc_out.p1 = Tb; + arc_out.start_angle = std::atan2(Ta.y() - O.y(), Ta.x() - O.x()); + + double sb = std::atan2(Tb.y() - O.y(), Tb.x() - O.x()); + double sweep = sb - arc_out.start_angle; + while (sweep <= -M_PI) sweep += 2.0 * M_PI; + while (sweep > M_PI) sweep -= 2.0 * M_PI; + arc_out.end_angle = arc_out.start_angle + sweep; + + return true; +} + +bool SketchEngine::chamfer_lines(const SketchEntity& a, const SketchEntity& b, double d, + SketchEntity& a_out, SketchEntity& b_out, SketchEntity& seg_out) +{ + if (a.type != SketchEntity::Type::Line || b.type != SketchEntity::Type::Line || d <= 1e-9) + return false; + + Vec2d da = a.p1 - a.p0; + Vec2d db = b.p1 - b.p0; + double denom = da.x() * db.y() - da.y() * db.x(); + if (std::abs(denom) < 1e-12) + return false; // parallel: no corner to chamfer + + // Shared corner = line/line intersection. + Vec2d diff = b.p0 - a.p0; + double s = (diff.x() * db.y() - diff.y() * db.x()) / denom; + Vec2d C = a.p0 + s * da; + + // For each line, unit vector pointing from the corner toward its far endpoint + // (the endpoint that is kept); the near endpoint is the one trimmed back. + auto pick = [&](const SketchEntity& ln, int& near_idx, Vec2d& u) -> bool { + double d0 = (ln.p0 - C).norm(); + double d1 = (ln.p1 - C).norm(); + if (d0 <= d1) { near_idx = 0; u = ln.p1 - C; } + else { near_idx = 1; u = ln.p0 - C; } + if (u.norm() < 1e-12) return false; + u.normalize(); + return true; + }; + int a_near_idx, b_near_idx; + Vec2d ua, ub; + if (!pick(a, a_near_idx, ua)) return false; + if (!pick(b, b_near_idx, ub)) return false; + + // Reject collinear (no real corner) and ensure the setback fits both lines. + double cosT = std::max(-1.0, std::min(1.0, ua.dot(ub))); + if (cosT > 1.0 - 1e-9 || cosT < -1.0 + 1e-9) + return false; + { + Vec2d a_far = (a_near_idx == 0) ? a.p1 : a.p0; + Vec2d b_far = (b_near_idx == 0) ? b.p1 : b.p0; + if (d > (a_far - C).norm() || d > (b_far - C).norm()) + return false; + } + + Vec2d Ta = C + d * ua; + Vec2d Tb = C + d * ub; + + a_out = a; + b_out = b; + if (a_near_idx == 0) a_out.p0 = Ta; else a_out.p1 = Ta; + if (b_near_idx == 0) b_out.p0 = Tb; else b_out.p1 = Tb; + + seg_out = SketchEntity{}; + seg_out.type = SketchEntity::Type::Line; + seg_out.p0 = Ta; + seg_out.p1 = Tb; + return true; +} + +static std::vector line_entity_hits(const Vec2d& a0, const Vec2d& adir, + const SketchEntity& other) +{ + std::vector hits; + double La2 = adir.dot(adir); + if (La2 < 1e-18) return hits; + + switch (other.type) { + case SketchEntity::Type::Line: { + Vec2d bdir = other.p1 - other.p0; + double bxa = bdir.x() * adir.y() - bdir.y() * adir.x(); + if (std::abs(bxa) < 1e-12) return hits; + + Vec2d w = a0 - other.p0; + double u = (w.x() * adir.y() - w.y() * adir.x()) / bxa; + if (u < -1e-9 || u > 1.0 + 1e-9) return hits; + + double axb = adir.x() * bdir.y() - adir.y() * bdir.x(); + Vec2d w2 = other.p0 - a0; + double t = (w2.x() * bdir.y() - w2.y() * bdir.x()) / axb; + hits.push_back(t); + break; + } + case SketchEntity::Type::Circle: + case SketchEntity::Type::Arc: { + double R = other.radius; + Vec2d f = a0 - other.center; + double A = La2; + double B = 2.0 * adir.dot(f); + double Cc = f.dot(f) - R * R; + double disc = B * B - 4.0 * A * Cc; + if (disc < -1e-12) return hits; + if (disc < 0.0) disc = 0.0; + double sq = std::sqrt(disc); + double t1 = (-B - sq) / (2.0 * A); + double t2 = (-B + sq) / (2.0 * A); + + auto angle_in_sweep = [&](const Vec2d& P) -> bool { + double phi = std::atan2(P.y() - other.center.y(), P.x() - other.center.x()); + double sweep = other.end_angle - other.start_angle; + double delta = phi - other.start_angle; + if (sweep >= 0.0) { + while (delta < -1e-9) delta += 2.0 * M_PI; + while (delta > 2.0 * M_PI) delta -= 2.0 * M_PI; + return delta <= sweep + 1e-9; + } else { + while (delta > 1e-9) delta -= 2.0 * M_PI; + while (delta < -2.0 * M_PI) delta += 2.0 * M_PI; + return delta >= sweep - 1e-9; + } + }; + + auto check = [&](double t) { + Vec2d P = a0 + t * adir; + if (other.type == SketchEntity::Type::Circle || angle_in_sweep(P)) + hits.push_back(t); + }; + + check(t1); + if (disc > 1e-12) + check(t2); + break; + } + default: + break; + } + + return hits; +} + +// Angles (atan2, radians) where `other` crosses the circle of radius R about C. +// For Arc/Circle cutters the crossing point must lie within the cutter's own +// sweep (a full circle always qualifies). Powers arc/circle-subject trim/extend, +// where the subject is parametrized by angle rather than by a line ray param. +static std::vector circle_cross_angles(const Vec2d& C, double R, + const SketchEntity& other) +{ + std::vector out; + if (R < 1e-12) return out; + + auto on_other = [&](const Vec2d& P) -> bool { + switch (other.type) { + case SketchEntity::Type::Line: { + Vec2d d = other.p1 - other.p0; + double L2 = d.dot(d); + if (L2 < 1e-18) return false; + double u = (P - other.p0).dot(d) / L2; + return u > -1e-9 && u < 1.0 + 1e-9; + } + case SketchEntity::Type::Circle: + return true; + case SketchEntity::Type::Arc: { + double phi = std::atan2(P.y() - other.center.y(), P.x() - other.center.x()); + double sweep = other.end_angle - other.start_angle; + double delta = phi - other.start_angle; + if (sweep >= 0.0) { + while (delta < -1e-9) delta += 2.0 * M_PI; + while (delta > 2.0 * M_PI) delta -= 2.0 * M_PI; + return delta <= sweep + 1e-9; + } else { + while (delta > 1e-9) delta -= 2.0 * M_PI; + while (delta < -2.0 * M_PI) delta += 2.0 * M_PI; + return delta >= sweep - 1e-9; + } + } + default: + return false; + } + }; + auto add = [&](const Vec2d& P) { + out.push_back(std::atan2(P.y() - C.y(), P.x() - C.x())); + }; + + switch (other.type) { + case SketchEntity::Type::Line: { + Vec2d a0 = other.p0, adir = other.p1 - other.p0; + double A = adir.dot(adir); + if (A < 1e-18) break; + Vec2d f = a0 - C; + double B = 2.0 * adir.dot(f); + double Cc = f.dot(f) - R * R; + double disc = B * B - 4.0 * A * Cc; + if (disc < 0.0) break; + double sq = std::sqrt(disc); + Vec2d P1 = a0 + ((-B - sq) / (2.0 * A)) * adir; + if (on_other(P1)) add(P1); + if (disc > 1e-12) { + Vec2d P2 = a0 + ((-B + sq) / (2.0 * A)) * adir; + if (on_other(P2)) add(P2); + } + break; + } + case SketchEntity::Type::Circle: + case SketchEntity::Type::Arc: { + Vec2d C2 = other.center; + double R2 = other.radius; + Vec2d d = C2 - C; + double dd = d.norm(); + if (dd < 1e-12) break; // concentric + if (dd > R + R2 + 1e-9) break; // too far apart + if (dd < std::abs(R - R2) - 1e-9) break; // one circle inside the other + double a = (R * R - R2 * R2 + dd * dd) / (2.0 * dd); + double h2 = R * R - a * a; + if (h2 < 0.0) h2 = 0.0; + double h = std::sqrt(h2); + Vec2d mid = C + (a / dd) * d; + Vec2d perp(-d.y() / dd, d.x() / dd); + Vec2d P1 = mid + h * perp; + if (on_other(P1)) add(P1); + if (h > 1e-12) { + Vec2d P2 = mid - h * perp; + if (on_other(P2)) add(P2); + } + break; + } + default: + break; + } + return out; +} + +// Wrap x into [0, 2pi). +static double wrap_2pi(double x) +{ + while (x < 0.0) x += 2.0 * M_PI; + while (x >= 2.0 * M_PI) x -= 2.0 * M_PI; + return x; +} + +bool SketchEngine::trim_entity(SketchEntity& e, const std::vector& others, + const Vec2d& pick) +{ + // Arc subject: parametrize by sweep fraction u in [0,1]; cut on the picked side. + if (e.type == SketchEntity::Type::Arc) { + double sweep = e.end_angle - e.start_angle; + if (std::abs(sweep) < 1e-12) return false; + double phi_pick = std::atan2(pick.y() - e.center.y(), pick.x() - e.center.x()); + double u_pick = (phi_pick - e.start_angle) / sweep; + // Bring the pick onto the arc's [0,1] domain. + while (u_pick < -1e-9) u_pick += (2.0 * M_PI) / std::abs(sweep); + u_pick = std::max(0.0, std::min(1.0, u_pick)); + + std::vector cuts; + for (const auto& other : others) { + for (double phi : circle_cross_angles(e.center, e.radius, other)) { + double u = (phi - e.start_angle) / sweep; + while (u < -1e-9) u += (2.0 * M_PI) / std::abs(sweep); + if (u > 1e-9 && u < 1.0 - 1e-9) cuts.push_back(u); + } + } + if (cuts.empty()) return false; + + if (u_pick <= 0.5) { + double uc = std::numeric_limits::max(); + for (double u : cuts) if (u > u_pick + 1e-9 && u < uc) uc = u; + if (uc == std::numeric_limits::max()) return false; + e.start_angle = e.start_angle + uc * sweep; // drop [0, uc) + } else { + double uc = -std::numeric_limits::max(); + for (double u : cuts) if (u < u_pick - 1e-9 && u > uc) uc = u; + if (uc == -std::numeric_limits::max()) return false; + e.end_angle = e.start_angle + uc * sweep; // drop (uc, 1] + } + return true; + } + + // Circle subject: trimming opens it into an Arc that excludes the picked gap. + if (e.type == SketchEntity::Type::Circle) { + std::vector ang; + for (const auto& other : others) + for (double phi : circle_cross_angles(e.center, e.radius, other)) + ang.push_back(wrap_2pi(phi)); + std::sort(ang.begin(), ang.end()); + ang.erase(std::unique(ang.begin(), ang.end(), + [](double a, double b){ return std::abs(a - b) < 1e-7; }), + ang.end()); + if (ang.size() < 2) return false; + + double pk = wrap_2pi(std::atan2(pick.y() - e.center.y(), pick.x() - e.center.x())); + const int n = int(ang.size()); + int idx = -1; + for (int i = 0; i < n; ++i) { + double lo = ang[i]; + double hi = (i + 1 < n) ? ang[i + 1] : ang[0] + 2.0 * M_PI; + double p = (pk < lo - 1e-12) ? pk + 2.0 * M_PI : pk; + if (p >= lo - 1e-12 && p < hi + 1e-12) { idx = i; break; } + } + if (idx < 0) return false; + double lo = ang[idx]; + double hi = (idx + 1 < n) ? ang[idx + 1] : ang[0] + 2.0 * M_PI; + // Keep the complement of the (lo,hi) gap: sweep ccw from hi back round to lo. + e.type = SketchEntity::Type::Arc; + e.start_angle = hi; + e.end_angle = lo + 2.0 * M_PI; + return true; + } + + if (e.type != SketchEntity::Type::Line) return false; + + Vec2d adir = e.p1 - e.p0; + double La2 = adir.dot(adir); + if (La2 < 1e-18) return false; + + double t_pick = (pick - e.p0).dot(adir) / La2; + t_pick = std::max(0.0, std::min(1.0, t_pick)); + + std::vector cuts; + for (const auto& other : others) { + auto h = line_entity_hits(e.p0, adir, other); + for (double t : h) { + if (t > 1e-9 && t < 1.0 - 1e-9) + cuts.push_back(t); + } + } + if (cuts.empty()) return false; + + if (t_pick <= 0.5) { + double tc = std::numeric_limits::max(); + for (double t : cuts) { + if (t > t_pick + 1e-9 && t < tc) + tc = t; + } + if (tc == std::numeric_limits::max()) return false; + e.p0 = e.p0 + tc * adir; + } else { + double tc = -std::numeric_limits::max(); + for (double t : cuts) { + if (t < t_pick - 1e-9 && t > tc) + tc = t; + } + if (tc == -std::numeric_limits::max()) return false; + e.p1 = e.p0 + tc * adir; + } + + return true; +} + +bool SketchEngine::extend_entity(SketchEntity& e, const std::vector& others, + const Vec2d& pick) +{ + // Arc subject: grow the sweep toward the picked end up to the nearest crossing, + // capped at a full turn so the arc never self-overlaps. (A Circle is already + // closed — nothing to extend.) + if (e.type == SketchEntity::Type::Arc) { + double sweep = e.end_angle - e.start_angle; + double mag = std::abs(sweep); + if (mag < 1e-12) return false; + double sgn = (sweep >= 0.0) ? 1.0 : -1.0; + double room = 2.0 * M_PI - mag; // max extra sweep before a full turn + if (room <= 1e-9) return false; + + double phi_pick = std::atan2(pick.y() - e.center.y(), pick.x() - e.center.x()); + double up = wrap_2pi(sgn * (phi_pick - e.start_angle)) / mag; // pick fraction on arc + const bool extend_end = (up > 0.5); + + double best = std::numeric_limits::max(); + for (const auto& other : others) { + for (double phi : circle_cross_angles(e.center, e.radius, other)) { + double adv = extend_end ? wrap_2pi(sgn * (phi - e.end_angle)) + : wrap_2pi(sgn * (e.start_angle - phi)); + if (adv > 1e-9 && adv <= room + 1e-9 && adv < best) best = adv; + } + } + if (best == std::numeric_limits::max()) return false; + if (extend_end) e.end_angle += sgn * best; + else e.start_angle -= sgn * best; + return true; + } + + if (e.type != SketchEntity::Type::Line) return false; + + Vec2d adir = e.p1 - e.p0; + double La2 = adir.dot(adir); + if (La2 < 1e-18) return false; + + double t_pick = (pick - e.p0).dot(adir) / La2; + + std::vector hits; + for (const auto& other : others) { + auto h = line_entity_hits(e.p0, adir, other); + hits.insert(hits.end(), h.begin(), h.end()); + } + if (hits.empty()) return false; + + if (t_pick > 0.5) { + double tc = std::numeric_limits::max(); + for (double t : hits) { + if (t > 1.0 + 1e-9 && t < tc) + tc = t; + } + if (tc == std::numeric_limits::max()) return false; + e.p1 = e.p0 + tc * adir; + } else { + double tc = -std::numeric_limits::max(); + for (double t : hits) { + if (t < -1e-9 && t > tc) + tc = t; + } + if (tc == -std::numeric_limits::max()) return false; + e.p0 = e.p0 + tc * adir; + } + + return true; +} + +// Bridge: cubic Bézier with G1 continuity at both ends. +// Poles = {Pa, Pa + Ta*d/3, Pb - Tb*d/3, Pb}, where d = |Pb - Pa|. +SketchEntity SketchEngine::make_bridge(const SketchEntity& a, int a_end, + const SketchEntity& b, int b_end) +{ + auto endpoint = [](const SketchEntity& e, int end) -> Vec2d { + return end == 0 ? e.p0 : e.p1; + }; + + auto tangent = [](const SketchEntity& e, int end, const Vec2d& fallback_dir) -> Vec2d { + switch (e.type) { + case SketchEntity::Type::Line: { + Vec2d dir = end == 1 ? e.p1 - e.p0 : e.p0 - e.p1; + double len = dir.norm(); + if (len < 1e-12) return fallback_dir; + return dir / len; + } + case SketchEntity::Type::Arc: { + double theta = end == 1 ? e.end_angle : e.start_angle; + // Tangent to the circle at angle theta, CCW: (-sin θ, cos θ). + // At end=1 (end_angle), the outward direction is along the sweep direction. + // At end=0 (start_angle), outward is opposite the sweep direction. + double sweep = e.end_angle - e.start_angle; + int sign = end == 1 ? (sweep >= 0 ? 1 : -1) : (sweep >= 0 ? -1 : 1); + Vec2d t(-std::sin(theta), std::cos(theta)); + return t * double(sign); + } + default: + // ponytail: straight-ish bridge for unsupported entity types. + return fallback_dir; + } + }; + + const Vec2d Pa = endpoint(a, a_end); + const Vec2d Pb = endpoint(b, b_end); + const double d = (Pb - Pa).norm(); + + // Fallback tangent direction: point toward the other endpoint. + Vec2d fallback = d < 1e-9 ? Vec2d(1, 0) : (Pb - Pa) / d; + Vec2d Ta = tangent(a, a_end, fallback); + Vec2d Tb = tangent(b, b_end, fallback * -1.0); + + const double k = std::max(d, 1e-9) / 3.0; + + SketchEntity e; + e.type = SketchEntity::Type::BSpline; + e.construction = false; + e.ctrl = { Pa, Pa + Ta * k, Pb - Tb * k, Pb }; + e.p0 = e.ctrl.front(); + e.p1 = e.ctrl.back(); + return e; +} + +// ---- entity-constraint planning (Fase 4.2) ---- +// Pure kernel port of DesignPanel::apply_entity_constraint's decision logic, so the GUI +// and the live-sketch tool share ONE legality/role/value decision instead of each carrying +// its own copy. The Coincident phantom-p1 defect was fixed in one branch and stayed alive +// in the next one down precisely because the logic lived in a wx method that could not be +// unit-tested. No wx, no translation: the caller maps ConstraintReject to a string. + +int sketch_entity_ends(const SketchEntity& e, std::pair out[2]) +{ + using ET = SketchEntity::Type; + using R = SketchPointRole; + switch (e.type) { + case ET::Line: case ET::Arc: case ET::BSpline: case ET::EllipseArc: + out[0] = {R::P0, e.p0}; out[1] = {R::P1, e.p1}; return 2; + case ET::Point: + out[0] = {R::P0, e.p0}; return 1; + case ET::Circle: case ET::Ellipse: + out[0] = {R::Center, e.center}; return 1; + } + return 0; +} + +bool sketch_closest_ends(const SketchEntity& A, const SketchEntity& B, + SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb) +{ + std::pair aps[2], bps[2]; + const int na = sketch_entity_ends(A, aps), nb = sketch_entity_ends(B, bps); + if (na == 0 || nb == 0) return false; + double best = 1e30; + ra = aps[0].first; rb = bps[0].first; pa = aps[0].second; pb = bps[0].second; + for (int i = 0; i < na; ++i) + for (int j = 0; j < nb; ++j) { + const double d = (aps[i].second - bps[j].second).squaredNorm(); + if (d < best) { + best = d; + ra = aps[i].first; rb = bps[j].first; + pa = aps[i].second; pb = bps[j].second; + } + } + return true; +} + +ConstraintPlan plan_entity_constraint(const std::vector& ents, + int e0, int e1, int e2, SketchConstraintType type) +{ + using R = SketchPointRole; + using T = SketchConstraintType; + const int n = int(ents.size()); + + ConstraintPlan plan; + + auto is_round = [](const SketchEntity& e) { + return e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc; }; + + // One Equal button, two meanings: lines get equal length, curves equal radius. + if (type == T::EqualLength && e0 >= 0 && e1 >= 0 && e0 < n && e1 < n && + is_round(ents[e0]) && is_round(ents[e1])) + type = T::EqualRadius; + + const bool needs_two = (type == T::Parallel || type == T::Perpendicular || + type == T::EqualLength || type == T::Coincident || + type == T::Concentric || type == T::Tangent || + type == T::Angle || type == T::Midpoint || + type == T::Symmetric || type == T::EqualRadius || + type == T::Collinear || + type == T::SymmetricAboutY || type == T::SymmetricAboutX || + type == T::DistanceX || type == T::DistanceY); + if (e0 < 0 || e0 >= n || (needs_two && (e1 < 0 || e1 >= n))) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = needs_two ? ConstraintReject::NeedTwoEntities : ConstraintReject::NeedOneEntity; + return plan; + } + + plan.kind = ConstraintPlan::Kind::Apply; + SketchEntityConstraintDef def; + def.type = type; + def.value = 0.0; + switch (type) { + case T::Horizontal: + case T::Vertical: + // One line: level/plumb its own two endpoints. Not pedantry -- with a Point or + // Circle picked, P1 is a role the solver silently drops while STORING the + // constraint, so the sketch claims to be constrained when it is not. + if (ents[e0].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedALine; + return plan; + } + def.ea = e0; def.ra = R::P0; + def.eb = e0; def.rb = R::P1; + break; + case T::Parallel: + case T::Perpendicular: + case T::EqualLength: + // Two whole line segments (roles unused). Guard ADDED here (the GUI does not check + // this yet): a non-line pick produced a def the solver drops, the same silent no-op + // as Horizontal on a Point above. + if (ents[e0].type != SketchEntity::Type::Line || + ents[e1].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoLines; + return plan; + } + def.ea = e0; def.eb = e1; + break; + case T::Coincident: { + // Join the closest point pair, NOT {p0,p1} on both -- two Points would otherwise + // resolve to their phantom (0,0) p1s and the constraint would do nothing at all. + R ra, rb; Vec2d pa, pb; + if (!sketch_closest_ends(ents[e0], ents[e1], ra, rb, pa, pb)) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedJoinablePoints; + return plan; + } + def.ea = e0; def.ra = ra; def.eb = e1; def.rb = rb; + break; + } + case T::DistanceX: + case T::DistanceY: { + R ra, rb; Vec2d pa, pb; + if (!sketch_closest_ends(ents[e0], ents[e1], ra, rb, pa, pb)) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedMeasurablePoints; + return plan; + } + // The constraint is SIGNED (fixes (pB - pA).dot(axis)). Order the refs so the shown + // value is the positive one -- accepting a dimension must be a no-op, not a flip. + int a = e0, b = e1; + double delta = (type == T::DistanceX) ? (pb.x() - pa.x()) : (pb.y() - pa.y()); + if (delta < 0.0) { std::swap(a, b); std::swap(ra, rb); delta = -delta; } + plan.kind = ConstraintPlan::Kind::AskValue; + def.ea = a; def.ra = ra; + def.eb = b; def.rb = rb; + plan.prefill = delta; + break; + } + case T::Concentric: + if (!is_round(ents[e0]) || !is_round(ents[e1])) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoRounds; + return plan; + } + def.ea = e0; def.ra = R::Center; def.eb = e1; def.rb = R::Center; + break; + case T::Tangent: { + // line+round or round+round; the kernel detects the entity types. + const bool ok = (is_round(ents[e0]) && ents[e1].type == SketchEntity::Type::Line) || + (is_round(ents[e1]) && ents[e0].type == SketchEntity::Type::Line) || + (is_round(ents[e0]) && is_round(ents[e1])); + if (!ok) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTangentPair; + return plan; + } + def.ea = e0; def.eb = e1; + break; + } + case T::Angle: { + // Angle between two line segments. "Line segments" is a check, not an assumption: + // p1-p0 on a Circle is (0,0)-centre, so two circles used to pre-fill with the angle + // between their centre POSITION vectors. + if (ents[e0].type != SketchEntity::Type::Line || + ents[e1].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoLines; + return plan; + } + const int a = e0, b = e1; + const Vec2d da = ents[a].p1 - ents[a].p0; + const Vec2d db = ents[b].p1 - ents[b].p0; + double cur = 90.0; + const double na = da.norm(), nb = db.norm(); + if (na > 1e-9 && nb > 1e-9) { + const double c = std::max(-1.0, std::min(1.0, da.dot(db) / (na * nb))); + cur = std::acos(c) * 180.0 / M_PI; + } + plan.kind = ConstraintPlan::Kind::AskValue; + def.ea = a; def.eb = b; + plan.prefill = cur; // degrees; the caller converts to radians on commit + break; + } + case T::Midpoint: { + // One pick is a Point, the other a Line: the point is the line's midpoint. + const SketchEntity& A = ents[e0]; + const SketchEntity& B = ents[e1]; + int pt = -1, ln = -1; + if (A.type == SketchEntity::Type::Point && B.type == SketchEntity::Type::Line) { pt = e0; ln = e1; } + else if (B.type == SketchEntity::Type::Point && A.type == SketchEntity::Type::Line) { pt = e1; ln = e0; } + else { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedPointAndLine; + return plan; + } + def.ea = pt; def.ra = R::P0; def.eb = ln; + break; + } + case T::Symmetric: { + // Two entities made symmetric about a third (axis) line: slot0=A, slot1=B, e2=axis. + // Two Points -> one pair; two Lines -> two endpoint pairs (P0/P0 and P1/P1), exactly + // the defs DesignPanel builds today. + const int axis = e2; + if (axis < 0 || axis >= n || ents[axis].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedAxisLine; + return plan; + } + using ET = SketchEntity::Type; + const ET ta = ents[e0].type, tb = ents[e1].type; + if (!((ta == ET::Point && tb == ET::Point) || (ta == ET::Line && tb == ET::Line))) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoPointsOrLines; + return plan; + } + auto mk = [&](R ra, R rb) { + SketchEntityConstraintDef d; + d.type = T::Symmetric; + d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; + plan.defs.push_back(d); + }; + if (ta == ET::Point) { mk(R::P0, R::P0); } + else { mk(R::P0, R::P0); mk(R::P1, R::P1); } + return plan; + } + case T::SymmetricAboutY: + case T::SymmetricAboutX: { + // Two entities made symmetric about the sketch's vertical/horizontal axis, which is + // implicit (no picked axis line): e2 is ignored and the axis is a negative sentinel + // in ec. + using ET = SketchEntity::Type; + const ET ta = ents[e0].type, tb = ents[e1].type; + if (!((ta == ET::Point && tb == ET::Point) || (ta == ET::Line && tb == ET::Line))) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoPointsOrLines; + return plan; + } + const int axis = (type == T::SymmetricAboutY) ? kSketchRefAxisY : kSketchRefAxisX; + auto mk = [&](R ra, R rb) { + SketchEntityConstraintDef d; + d.type = type; + d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; + plan.defs.push_back(d); + }; + if (ta == ET::Point) { mk(R::P0, R::P0); } + else { mk(R::P0, R::P0); mk(R::P1, R::P1); } + return plan; + } + case T::EqualRadius: + if (!is_round(ents[e0]) || !is_round(ents[e1])) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoRounds; + return plan; + } + def.ea = e0; def.eb = e1; + break; + case T::Collinear: + if (ents[e0].type != SketchEntity::Type::Line || ents[e1].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoLines; + return plan; + } + def.ea = e0; def.eb = e1; + break; + case T::Fix: { + // Anchor the picked entity's reference point to its current coordinate. A single + // point -- not both endpoints -- so it composes with an existing H/V/length + // constraint instead of duplicating it. + using ET = SketchEntity::Type; + const ET et = ents[e0].type; + def.ea = e0; + def.ra = (et == ET::Circle || et == ET::Ellipse || + et == ET::Arc || et == ET::EllipseArc) ? R::Center : R::P0; + break; + } + case T::Radius: + case T::Diameter: { + if (!is_round(ents[e0])) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedRound; + return plan; + } + plan.kind = ConstraintPlan::Kind::AskValue; + def.ea = e0; def.ra = R::Center; + plan.prefill = (type == T::Diameter) ? 2.0 * ents[e0].radius : ents[e0].radius; + break; + } + default: + // Distance / LockX / LockY / PointOnLine / PointOnObject (and any future type) have + // no entity-constraint binding; the GUI's own switch falls to "Unsupported". + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::Unsupported; + return plan; + } + plan.defs.push_back(def); + return plan; +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchEngine.hpp b/src/libslic3r/CAD/SketchEngine.hpp new file mode 100644 index 0000000000..f2166e96a6 --- /dev/null +++ b/src/libslic3r/CAD/SketchEngine.hpp @@ -0,0 +1,373 @@ +#ifndef slic3r_SketchEngine_hpp_ +#define slic3r_SketchEngine_hpp_ + +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/CAD/GeometryEngine.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +struct SketchSegment { + enum Type { Line, Arc, Circle, Rectangle, Polygon }; + Type type{Line}; + Vec2d p0{0,0}, p1{0,0}; + Vec2d center{0,0}; + double radius{0}, start_angle{0}, end_angle{0}; + std::vector points; + template + void serialize(Archive& ar) { ar(type, p0, p1, center, radius, start_angle, end_angle, points); } +}; + +struct SketchEntity { + enum class Type { Line, Arc, Circle, Point, Ellipse, EllipseArc, BSpline }; + Type type{Type::Line}; + Vec2d p0{0,0}; // Line: start; Arc/EllipseArc: start; Circle/Point/Ellipse: center; BSpline: first pole + Vec2d p1{0,0}; // Line: end; Arc/EllipseArc: end; (unused for Circle/Point/Ellipse); BSpline: last pole + Vec2d center{0,0}; // Arc/Circle/Ellipse(Arc) center + double radius{0}; // Circle/Arc radius; Ellipse(Arc): semi-major axis (a) + double start_angle{0}; // Arc sweep start; Ellipse(Arc): parametric start angle (radians) + double end_angle{0}; // Arc sweep end; Ellipse(Arc): parametric end angle + bool construction{false}; + double rminor{0}; // Ellipse(Arc): semi-minor axis (b) + double rotation{0}; // Ellipse(Arc): major-axis angle phi (radians, about center) + std::vector ctrl; // BSpline: control points (poles); p0/p1 mirror first/last pole + template + void serialize(Archive& ar) { + // Append-only: rminor/rotation added for Ellipse(Arc) (P2 Tier-B.1); ctrl for BSpline (B.2). + ar(type, p0, p1, center, radius, start_angle, end_angle, construction, rminor, rotation, ctrl); + } +}; + +struct SketchPlane { + Vec3d origin{0,0,0}; + Vec3d normal{0,0,1}; + Vec3d x_axis{1,0,0}; + Vec3d y_axis{0,1,0}; + + gp_Pln to_occt() const; + static SketchPlane from_face(const TopoDS_Face& face); + static SketchPlane XY() { return {}; } + static SketchPlane XZ() { return {{0,0,0}, {0,1,0}, {1,0,0}, {0,0,1}}; } + static SketchPlane YZ() { return {{0,0,0}, {1,0,0}, {0,1,0}, {0,0,1}}; } + + Vec2d project(const Vec3d& ray_origin, const Vec3d& ray_dir) const; + Vec3d to_world(const Vec2d& pt) const; + + template + void serialize(Archive& ar) { ar(origin, normal, x_axis, y_axis); } +}; + +struct SketchProfile { + std::vector points; + bool closed{false}; + + bool is_closed(double tolerance = 0.5) const; + bool try_close(double tolerance = 0.5); + void clear() { points.clear(); closed = false; } + TopoDS_Wire to_occt_wire(const SketchPlane& plane) const; + + template + void serialize(Archive& ar) { ar(points, closed); } +}; + +// Two sketch endpoints this close are ONE joint. Shared deliberately by the viewport +// (region_loops / connected_loop / open-end detection) and by the kernel +// (entities_to_wires): the viewport is what shades a region closed and offers it for +// extrude, so the kernel MUST be able to build every loop the viewport shades. When +// these two numbers disagreed the viewport promised a closed region at 1e-3 and the +// kernel refused it at 1e-4, which extruded a solid the user never drew. +// Nothing legitimate in a mm-scale sketch is 1 um apart. +inline constexpr double kSketchJoinTol = 1e-3; // mm + +// Effective sketch joint tolerance. ONE value for the viewport (region_loops / +// loop_report / connected_loop) and the kernel (entities_to_wires): if these ever +// disagree again, the viewport shades a region closed that the kernel refuses to +// build, which is how a sketch got extruded into the wrong solid. The GUI pushes +// the "auto_close_sketch_loops" preference in via set_sketch_auto_close(); the +// kernel defaults to ON so headless/kernel-only callers keep welding. +double sketch_join_tol(); +void set_sketch_auto_close(bool on); + +enum class SketchConstraintType { + Fix, Coincident, Horizontal, Vertical, Distance, + LockX, LockY, EqualLength, Parallel, Perpendicular, + Concentric, + Tangent, Midpoint, Symmetric, Angle, + Radius, Diameter, + PointOnLine, // a point lies on a line (or at signed perpendicular distance `value`) + PointOnObject, // a point lies on an entity edge (line -> PT_ON_LINE, circle -> PT_ON_CIRCLE) + // Append-only: cereal serializes this enum positionally as its underlying int, so + // inserting anywhere but the end reinterprets every constraint in every saved recipe. + EqualRadius, + Collinear, + DistanceX, // |dx| between two points, projected onto the sketch X axis + DistanceY, // |dy| between two points, projected onto the sketch Y axis + SymmetricAboutY, // mirror across the sketch's vertical axis (x = 0); axis is implicit + SymmetricAboutX // mirror across the sketch's horizontal axis (y = 0); axis is implicit +}; + +// Constraint on a SketchProfile, referencing profile point indices (a,b,c,d). +// `value` carries the target for Distance/LockX/LockY (ignored otherwise). +struct SketchConstraintDef { + SketchConstraintType type{SketchConstraintType::Coincident}; + int a{-1}, b{-1}, c{-1}, d{-1}; + double value{0.0}; + template void serialize(Archive& ar) { ar(type, a, b, c, d, value); } +}; + +// Which point of an entity a constraint reference names. +// P0 = SketchEntity::p0 (Line start / Point position) +// P1 = SketchEntity::p1 (Line end) +// Center = SketchEntity::center (Arc/Circle center) +enum class SketchPointRole { P0, P1, Center }; + +// Constraint on coexisting SketchEntity objects (Fase 4.2). Each reference is an +// (entity index, point role) pair. Point-form constraints +// (Fix/Coincident/Horizontal/Vertical/Distance/LockX/LockY) use refs A and B as +// individual points. Segment-form constraints (Parallel/Perpendicular/EqualLength) +// use entity indices `ea`/`eb` as whole line segments (their P0->P1); roles are +// ignored for those. `value` carries the target for Distance/LockX/LockY. +struct SketchEntityConstraintDef { + SketchConstraintType type{SketchConstraintType::Coincident}; + int ea{-1}, eb{-1}; // entity indices + SketchPointRole ra{SketchPointRole::P0}; // role within ea + SketchPointRole rb{SketchPointRole::P0}; // role within eb + double value{0.0}; + int ec{-1}; // third entity ref (Symmetric axis) + SketchPointRole rc{SketchPointRole::P0}; // role within ec + template void serialize(Archive& ar) { ar(type, ea, eb, ra, rb, value, ec, rc); } +}; + +// Implicit references every sketch has, addressable from a constraint's ea/eb/ec without +// existing as SketchEntity objects. NEGATIVE so they cannot collide with an entity index; +// -1 is already "unset" and stays that way. Values are serialized inside existing int +// fields, so they are append-only in spirit: never renumber these. +constexpr int kSketchRefOrigin = -2; // the sketch origin point (0,0) +constexpr int kSketchRefAxisX = -3; // the sketch X axis, through the origin, +X +constexpr int kSketchRefAxisY = -4; // the sketch Y axis, through the origin, +Y + +inline bool is_sketch_ref(int ei) { return ei <= kSketchRefOrigin; } + +// How many real endpoints a type exposes, and which roles they are. p1 is UNUSED for +// Circle/Point/Ellipse (SketchEntity::p1 above) and reads (0,0) — walking {P0,P1} blindly +// over those invents a phantom endpoint at the origin, which for a pair of Points always +// wins a closest-pair search at distance 0 and binds a role the solver silently refuses. +int sketch_entity_ends(const SketchEntity& e, std::pair out[2]); +bool sketch_closest_ends(const SketchEntity& A, const SketchEntity& B, + SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb); + +// Why an entity-constraint pick is refused. The caller maps a reason to a localized string; +// the planner itself stays translation-free. +enum class ConstraintReject { + None, NeedOneEntity, NeedTwoEntities, NeedALine, NeedTwoLines, + NeedTwoRounds, NeedTangentPair, NeedJoinablePoints, NeedMeasurablePoints, + // The following are not in the GUI's current switch but are the faithful outcomes of + // its remaining branches; they need a reason too or the caller cannot tell them apart. + NeedPointAndLine, // Midpoint: one Point + one Line + NeedTwoPointsOrLines, // Symmetric / SymmetricAboutX/Y: two Points or two Lines + NeedAxisLine, // Symmetric: e2 must be a Line to act as the axis + NeedRound, // Radius/Diameter: a Circle or Arc + Unsupported // entity-constraint path has no binding for this type +}; + +struct ConstraintPlan { + enum class Kind { Reject, Apply, AskValue }; + Kind kind{Kind::Reject}; + ConstraintReject reason{ConstraintReject::None}; + // Apply/AskValue only: the defs to commit. One element for every ordinary type, TWO for + // Symmetric/SymmetricAboutX/Y on two lines (P0/P0 and P1/P1), matching the GUI's builds. + std::vector defs{}; + double prefill{0.0}; // AskValue only: the value to show pre-filled +}; + +// Pure: no wx, no translation, no UI. The caller maps `reason` to a localized string. +// e2 is the axis-line pick Symmetric needs (def.ec); every other type ignores it. +ConstraintPlan plan_entity_constraint(const std::vector& ents, + int e0, int e1, int e2, SketchConstraintType type); + +// Solve a bare entity list in place against entity-form constraints. Shared by +// CadDocument::solve_sketch_feature (committed features) and the in-session GUI +// sketch tool (live solving as dimensions/constraints are added). Returns true on +// convergence; an empty constraint list is a no-op that returns true. +bool solve_sketch_entities(std::vector& entities, + const std::vector& constraints); + +struct SketchParams { + // Extrude/Revolve + double extrude_len{10}; bool extrude_sym{false}; double extrude_taper{0}; + double revolve_deg{360}; + bool is_pocket{false}; // cut into selected object instead of new + + // Dress-up + bool dressup_enabled{false}; + DressUpType dressup_type{DressUpType::Fillet}; + FaceGroup dressup_faces{FaceGroup::All}; + double dressup_radius{1.0}; + double dressup_chamfer_dist{1.0}; + + // Mesh + double linear_deflection{0.01}; + + template + void serialize(Archive& ar) { + ar(extrude_len, extrude_sym, extrude_taper, revolve_deg, is_pocket, + dressup_enabled, dressup_type, dressup_faces, dressup_radius, dressup_chamfer_dist, + linear_deflection); + } +}; + +class SketchEngine +{ +public: + static TopoDS_Shape make_extrude(const TopoDS_Wire& wire, const SketchPlane& plane, + double length, bool symmetric = false, double taper_deg = 0.0); + static TopoDS_Shape make_extrude(const TopoDS_Face& face, const SketchPlane& plane, + double length, bool symmetric = false, double taper_deg = 0.0); + // Asymmetric two-sided prism: extrude the wire's face by `up` along +normal and `down` + // along -normal, fused into one solid. up/down are non-negative magnitudes. + // Tapered (draft) extrude of a planar wire: the top profile is the base wire offset in its + // plane by length*tan(taper_deg), lofted from base to top. Falls back to a straight prism on + // any failure (self-intersecting offset / loft error). taper_deg>0 widens the top. + static TopoDS_Shape make_extrude_taper(const TopoDS_Wire& wire, const SketchPlane& plane, + double length, double taper_deg); + static TopoDS_Shape make_extrude_two_sided(const TopoDS_Wire& wire, const SketchPlane& plane, + double up, double down); + static TopoDS_Shape make_extrude_two_sided(const TopoDS_Face& face, const SketchPlane& plane, + double up, double down); + static TopoDS_Shape make_extrude_face(const TopoDS_Face& face, const SketchPlane& plane, + double length, bool symmetric = false, double taper_deg = 0.0); + + // Extrude a set of imported rigid regions (Text/SVG). Each region is + // contour[0]=outer loop + contour[1..]=hole loops, in plane (u,v) mm. Builds + // one planar face-with-holes per region, extrudes it, and fuses all region + // solids into a single shape. Empty/degenerate contours are skipped. + static TopoDS_Shape make_extrude_regions( + const std::vector>>& regions, + const SketchPlane& plane, double length, bool symmetric = false); + + // Revolve a planar profile wire about an axis lying in the sketch plane and + // passing through the plane origin: axis_sel 0 = plane X axis, 1 = plane Y axis. + // A negative angle_deg sweeps the opposite direction (Flip). The profile must + // lie to one side of the axis (Onshape rule); a straddling profile self-intersects. + static TopoDS_Shape make_revolve(const TopoDS_Wire& wire, const SketchPlane& plane, + double angle_deg = 360.0, int axis_sel = 0); + + // Sweep a planar profile wire along a path (spine) wire. The profile is turned + // into a face and swept with BRepOffsetAPI_MakePipe, which keeps the profile + // perpendicular to the spine along its length. The path may be open or closed; + // for a clean solid the path's first point should sit on/near the profile plane. + static TopoDS_Shape make_sweep(const TopoDS_Wire& profile, const TopoDS_Wire& path); + + // Loft a solid through 2+ closed profile wires (each on its own plane), in the + // given order. ruled=true => straight (ruled) sections; false => smooth (C2). + static TopoDS_Shape make_loft(const std::vector& profiles, bool ruled); + + // Skin `profiles` WITHOUT end caps -> an open shell (sheet). Same as make_loft but the + // ThruSections solid flag is false. // ponytail: a sibling instead of a bool param, so no + // existing call site changes. + static TopoDS_Shape make_loft_surface(const std::vector& profiles, bool ruled); + + static TopoDS_Shape make_pocket(const TopoDS_Wire& wire, const SketchPlane& plane, + const TopoDS_Shape& target, double depth); + + static TriangleMesh tessellate(const TopoDS_Shape& shape, + double linear_deflection = 0.01, + double angular_deflection = 0.5); + + static TriangleMesh tessellate(const TopoDS_Shape& shape, + std::vector& tri_face, + double linear_deflection = 0.01, + double angular_deflection = 0.5); + + static TopoDS_Wire entities_to_wire(const std::vector& entities, + const SketchPlane& plane, + bool closed_only = false); + + // Every loop the sketch holds, in the order each loop's FIRST entity appears in + // `entities`. A Circle or Ellipse is a loop on its own; Line/Arc/EllipseArc/BSpline + // entities are grouped into loops by shared endpoints. An OPEN chain is returned too — + // a sweep path is legitimately open, so open-ness is not an error here — unless + // `closed_only` is true, in which case an open chain is DISCARDED (skipped, not an + // error). Empty vector = nothing usable; the caller decides whether that is an error. + static std::vector entities_to_wires(const std::vector& entities, + const SketchPlane& plane, + bool closed_only = false); + + // A planar face from a set of coplanar loops: the largest-area loop is the outer boundary + // and every other loop is a hole in it. Throws std::runtime_error with a message naming the + // problem when the loops do not describe one such region. + static TopoDS_Face wires_to_face(const std::vector& wires, + const SketchPlane& plane); + + static std::vector mirror_entities( + const std::vector& src, const Vec2d& a, const Vec2d& b); + + // Offset a sketch by `d`, PRESERVING CHAINS. Entities joined by shared endpoints are + // offset together and their seams repaired (miter join), so a closed profile comes back + // closed and can still be extruded; per-entity offsetting cannot do that. Sign convention: + // +d moves each curve to the LEFT of its direction of travel, which for a CCW closed loop + // is inward. Ellipses and splines are not offset (a parallel of either is not the same + // kind of curve) and are dropped from the result. + static std::vector offset_entities( + const std::vector& src, double d); + + // Rigid-transform array. Returns the (count-1) copies for instance i=1..count-1 + // (the originals in `src` are NOT included). Each copy i is `src` rigidly + // transformed by: rotate by i*angle_step about `pivot`, then translate by i*step. + // Rectangular/linear array: angle_step = 0, step = spacing*direction (pivot unused). + // Polar array: step = (0,0), angle_step = sweep/count, pivot = centre. + // Orientation-preserving, so arc/ellipse parametric angles shift by i*angle_step. + static std::vector array_entities( + const std::vector& src, int count, + const Vec2d& step, double angle_step, const Vec2d& pivot); + + // General affine transform (move / rotate / scale), applied IN PLACE: returns + // the SAME entities (same count and order), each mapped by + // p -> pivot + scale * R(angle) * (p - pivot) + move + // (radii scale by |scale|; arc/ellipse parametric/rotation angles shift by + // `angle`). Unlike array_entities this mutates the subjects rather than adding + // copies. Move: angle=0, scale=1. Rotate-in-place: move=(0,0), scale=1, + // pivot=centroid. Scale: angle=0. + static std::vector transform_entities( + const std::vector& src, + const Vec2d& move, double angle, double scale, const Vec2d& pivot); + + static bool fillet_lines(const SketchEntity& a, const SketchEntity& b, double r, + SketchEntity& a_out, SketchEntity& b_out, SketchEntity& arc_out); + + // Symmetric chamfer between two lines meeting at a corner: trims each line back + // by setback distance `d` from the shared corner and returns the connecting + // straight segment (seg_out) in place of the corner. a_out/b_out are the trimmed + // lines; seg_out goes seg_out.p0 (on a) -> seg_out.p1 (on b). False if the lines + // are parallel or `d` overruns either line. + static bool chamfer_lines(const SketchEntity& a, const SketchEntity& b, double d, + SketchEntity& a_out, SketchEntity& b_out, SketchEntity& seg_out); + + static bool trim_entity(SketchEntity& e, const std::vector& others, + const Vec2d& pick); + + static bool extend_entity(SketchEntity& e, const std::vector& others, + const Vec2d& pick); + + // Build a cubic-Bezier G1 bridge (as a BSpline entity, 4 poles) connecting endpoint + // `a_end` of `a` to endpoint `b_end` of `b` (0 = start/p0 side, 1 = end/p1 side). + // Tangent-continuous with both entities where the endpoint tangent is defined. + static SketchEntity make_bridge(const SketchEntity& a, int a_end, + const SketchEntity& b, int b_end); +}; + +// Free endpoints of a sketch: the sketch-space points where a chain fails to close. +// Same weld tolerance as the wire build, so it can never contradict it. +std::vector sketch_open_ends(const std::vector&, const SketchPlane&); + +} // namespace Slic3r + +#endif // slic3r_SketchEngine_hpp_ diff --git a/src/libslic3r/CAD/SketchImport.cpp b/src/libslic3r/CAD/SketchImport.cpp new file mode 100644 index 0000000000..7140776603 --- /dev/null +++ b/src/libslic3r/CAD/SketchImport.cpp @@ -0,0 +1,145 @@ +#include "libslic3r/CAD/SketchImport.hpp" + +#include "libslic3r/Emboss.hpp" +#include "libslic3r/NSVGUtils.hpp" +#include "libslic3r/ExPolygon.hpp" +#include "libslic3r/TextConfiguration.hpp" // FontProp +#include "libslic3r/libslic3r.h" // SCALING_FACTOR +#include "libslic3r/Utils.hpp" // resources_dir + +#include +#include + +namespace Slic3r { + +// Convert one ExPolygon (outer contour + CW holes) into an ImportRegion, +// mapping each integer Point to plane (u,v) mm via `to_mm`. +template +static ImportRegion expoly_to_region(const ExPolygon& ex, ToMm to_mm) +{ + auto contour_pts = [&](const Polygon& poly) { + std::vector c; + c.reserve(poly.points.size()); + for (const Point& p : poly.points) + c.push_back(to_mm(p)); + return c; + }; + ImportRegion region; + region.push_back(contour_pts(ex.contour)); + for (const Polygon& h : ex.holes) + region.push_back(contour_pts(h)); + return region; +} + +// Shift all regions so their common bounding-box centre sits on the origin +// (Onshape/typical CAD insert places imported art centred on the sketch). +static void center_regions(ImportRegions& regs) +{ + double lo_x = std::numeric_limits::max(); + double lo_y = std::numeric_limits::max(); + double hi_x = -std::numeric_limits::max(); + double hi_y = -std::numeric_limits::max(); + bool any = false; + for (const auto& region : regs) + for (const auto& contour : region) + for (const Vec2d& p : contour) { + lo_x = std::min(lo_x, p.x()); hi_x = std::max(hi_x, p.x()); + lo_y = std::min(lo_y, p.y()); hi_y = std::max(hi_y, p.y()); + any = true; + } + if (!any) return; + const Vec2d c(0.5 * (lo_x + hi_x), 0.5 * (lo_y + hi_y)); + for (auto& region : regs) + for (auto& contour : region) + for (Vec2d& p : contour) + p -= c; +} + +static std::string default_font_path() +{ + return resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; +} + +ImportRegions text_to_regions(const std::string& utf8, double size_mm, + const std::string& font_path) +{ + if (utf8.empty() || size_mm <= 0.0) + return {}; + + const std::string path = font_path.empty() ? default_font_path() : font_path; + std::unique_ptr ff = Emboss::create_font_file(path.c_str()); + if (!ff) + return {}; + Emboss::FontFileWithCache fwc(std::move(ff)); + if (!fwc.has_value()) + return {}; + + FontProp prop(static_cast(size_mm)); // per_glyph=false + HealedExPolygons healed = Emboss::text2shapes(fwc, utf8.c_str(), prop); + if (healed.expolygons.empty()) + return {}; + + // Shape points are integers scaled by 1/SHAPE_SCALE in font units; + // get_text_shape_scale collapses (size_in_mm / unit_per_em) * SHAPE_SCALE + // into a single mm-per-shape-unit factor. FreeType y is up already. + const double s = Emboss::get_text_shape_scale(prop, *fwc.font_file); + auto to_mm = [s](const Point& p) { return Vec2d(p.x() * s, p.y() * s); }; + + ImportRegions regs; + regs.reserve(healed.expolygons.size()); + for (const ExPolygon& ex : healed.expolygons) + regs.push_back(expoly_to_region(ex, to_mm)); + + center_regions(regs); + return regs; +} + +ImportRegions svg_to_regions(const std::string& svg_path, double scale) +{ + if (svg_path.empty() || scale <= 0.0) + return {}; + + NSVGimage_ptr image = nsvgParseFromFile(svg_path, "mm", 96.0f); + if (!image) + return {}; + + // A filled shape that also carries a stroke would import the stroke as a + // thick outline band wrapped around the fill (the reported "too large line + // width"). For CAD import the fill silhouette is what's wanted, so drop the + // stroke on any shape that has a fill; stroke-only line art is kept. + for (NSVGshape* s = image->shapes; s != nullptr; s = s->next) + if (s->fill.type != NSVG_PAINT_NONE) + s->stroke.type = NSVG_PAINT_NONE; + + // tesselation tolerance is in image (mm) scale; 0.3 mm keeps curves smooth + // without exploding the contour count. is_y_negative (default) flips SVG's + // y-down to the sketch's y-up. + NSVGLineParams param(0.3); + ExPolygonsWithIds ids = create_shape_with_ids(*image, param); + + // NSVG points are integers scaled by 1/SCALING_FACTOR (param.scale default): + // mm = point * SCALING_FACTOR, then the user scale factor. + const double s = SCALING_FACTOR * scale; + auto to_mm = [s](const Point& p) { return Vec2d(p.x() * s, p.y() * s); }; + + ImportRegions regs; + for (const ExPolygonsWithId& w : ids) + for (const ExPolygon& ex : w.expoly) + regs.push_back(expoly_to_region(ex, to_mm)); + + center_regions(regs); + return regs; +} + +ImportRegions transform_regions(const ImportRegions& src, const Vec2d& offset, + double scale_x, double scale_y) +{ + ImportRegions out = src; + for (auto& region : out) + for (auto& contour : region) + for (Vec2d& p : contour) + p = Vec2d(p.x() * scale_x + offset.x(), p.y() * scale_y + offset.y()); + return out; +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchImport.hpp b/src/libslic3r/CAD/SketchImport.hpp new file mode 100644 index 0000000000..0873b0b861 --- /dev/null +++ b/src/libslic3r/CAD/SketchImport.hpp @@ -0,0 +1,37 @@ +#ifndef slic3r_SketchImport_hpp_ +#define slic3r_SketchImport_hpp_ + +#include "libslic3r/Point.hpp" // Vec2d + +#include +#include + +namespace Slic3r { + +// A rigid imported region: contour[0] = outer loop, contour[1..] = holes; +// points in plane (u,v) millimetres. The nested vector type matches +// CadFeature::imported_regions exactly, so results assign directly. +using ImportRegion = std::vector>; +using ImportRegions = std::vector; + +// Vectorize UTF-8 text into filled regions (mm), centred on the origin. +// `size_mm` is the cap/line height. `font_path` empty -> a bundled default +// font (resources/fonts). Returns an empty vector on any failure. +ImportRegions text_to_regions(const std::string& utf8, double size_mm, + const std::string& font_path = std::string()); + +// Parse an SVG file's filled paths into regions (mm), centred on the origin. +// `scale` multiplies the authored size (1.0 = as authored). Returns an empty +// vector on any failure. +ImportRegions svg_to_regions(const std::string& svg_path, double scale = 1.0); + +// Apply an axis-aligned placement transform to regions: +// p -> ( p.x * scale_x + offset.x, p.y * scale_y + offset.y ) +// Used to move / enlarge / stretch imported art non-destructively (the +// feature keeps the centred source regions + this transform). +ImportRegions transform_regions(const ImportRegions& src, const Vec2d& offset, + double scale_x, double scale_y); + +} // namespace Slic3r + +#endif // slic3r_SketchImport_hpp_ diff --git a/src/libslic3r/CAD/SketchInference.cpp b/src/libslic3r/CAD/SketchInference.cpp new file mode 100644 index 0000000000..cfeb2cd509 --- /dev/null +++ b/src/libslic3r/CAD/SketchInference.cpp @@ -0,0 +1,234 @@ +#include "libslic3r/CAD/SketchInference.hpp" + +#include +#include + +namespace Slic3r { + +// Candidate target collected during the scan; we keep the closest within each +// priority tier and resolve ties by tier then distance. +namespace { +struct Cand { + InferenceSnap::Kind kind{InferenceSnap::Kind::None}; + int entity{-1}; + SketchPointRole role{SketchPointRole::P0}; + Vec2d point{0, 0}; + double dist{0.0}; +}; + +// Lower number = higher priority. +int tier(InferenceSnap::Kind k) +{ + switch (k) { + case InferenceSnap::Kind::Endpoint: return 0; + case InferenceSnap::Kind::Center: return 1; + case InferenceSnap::Kind::Origin: return 2; + case InferenceSnap::Kind::Midpoint: return 3; + case InferenceSnap::Kind::OnEdge: return 4; + default: return 9; + } +} +} // namespace + +InferenceSnap infer_point_snap(const std::vector& entities, + const Vec2d& query, double tol, + bool include_origin) +{ + Cand best; + best.kind = InferenceSnap::Kind::None; + best.point = query; + + auto offer = [&](InferenceSnap::Kind k, int ent, SketchPointRole r, const Vec2d& q) { + const double d = (q - query).norm(); + if (d > tol) return; + const bool better = (best.kind == InferenceSnap::Kind::None) || + (tier(k) < tier(best.kind)) || + (tier(k) == tier(best.kind) && d < best.dist); + if (better) { best.kind = k; best.entity = ent; best.role = r; best.point = q; best.dist = d; } + }; + + for (size_t i = 0; i < entities.size(); ++i) { + const SketchEntity& e = entities[i]; + const int ei = int(i); + switch (e.type) { + case SketchEntity::Type::Line: { + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P0, e.p0); + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P1, e.p1); + offer(InferenceSnap::Kind::Midpoint, ei, SketchPointRole::P0, 0.5 * (e.p0 + e.p1)); + // Projection onto the segment interior (PointOnObject candidate). + const Vec2d d = e.p1 - e.p0; + const double L2 = d.squaredNorm(); + if (L2 > 1e-12) { + double t = (query - e.p0).dot(d) / L2; + if (t > 0.02 && t < 0.98) + offer(InferenceSnap::Kind::OnEdge, ei, SketchPointRole::P0, e.p0 + t * d); + } + break; + } + case SketchEntity::Type::Arc: { + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P0, e.p0); + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P1, e.p1); + offer(InferenceSnap::Kind::Center, ei, SketchPointRole::Center, e.center); + // Mid-arc point, so an arc is as snappable in its middle as a line is. + const double am = 0.5 * (e.start_angle + e.end_angle); + offer(InferenceSnap::Kind::Midpoint, ei, SketchPointRole::P0, + Vec2d(e.center.x() + e.radius * std::cos(am), + e.center.y() + e.radius * std::sin(am))); + break; + } + case SketchEntity::Type::Circle: { + offer(InferenceSnap::Kind::Center, ei, SketchPointRole::Center, e.center); + // Nearest point on the circle rim (PointOnObject candidate). + const Vec2d v = query - e.center; + const double n = v.norm(); + if (n > 1e-9 && e.radius > 1e-9) + offer(InferenceSnap::Kind::OnEdge, ei, SketchPointRole::Center, + e.center + v * (e.radius / n)); + break; + } + case SketchEntity::Type::Point: + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P0, e.p0); + break; + case SketchEntity::Type::EllipseArc: + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P0, e.p0); + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P1, e.p1); + offer(InferenceSnap::Kind::Center, ei, SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::Ellipse: + offer(InferenceSnap::Kind::Center, ei, SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::BSpline: + // Endpoints (first/last pole) snap for loop closure. + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P0, e.p0); + offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P1, e.p1); + break; + } + } + + if (include_origin) + offer(InferenceSnap::Kind::Origin, -1, SketchPointRole::P0, Vec2d(0, 0)); + + InferenceSnap r; + r.kind = best.kind; r.entity = best.entity; r.role = best.role; r.point = best.point; + return r; +} + +std::optional +infer_axis_constraint(const Vec2d& anchor, const Vec2d& tip, double ang_tol_rad) +{ + const Vec2d d = tip - anchor; + if (d.squaredNorm() < 1e-12) return std::nullopt; + const double ang = std::atan2(std::abs(d.y()), std::abs(d.x())); // 0=horizontal, pi/2=vertical + if (ang <= ang_tol_rad) return SketchConstraintType::Horizontal; + if (ang >= M_PI / 2.0 - ang_tol_rad) return SketchConstraintType::Vertical; + return std::nullopt; +} + +// Unsigned angle between two (unnormalized) direction vectors, in [0, pi]. 0 = same +// direction, pi = opposite, pi/2 = perpendicular. Inputs must be non-degenerate. +// static: this is a file-local helper, not part of the module's interface -- at namespace +// scope with external linkage it would be a link-time collision waiting to happen. +static double unsigned_angle(const Vec2d& a, const Vec2d& b) +{ + const double cross = a.x() * b.y() - a.y() * b.x(); + const double dot = a.x() * b.x() + a.y() * b.y(); + return std::atan2(std::abs(cross), dot); +} + +std::vector +infer_relations(const std::vector& entities, int new_ei, + double ang_tol_rad, double len_tol_frac) +{ + std::vector out; + if (new_ei <= 0 || new_ei >= int(entities.size())) return out; + + // AT MOST ONE constraint per rule per new entity, not one per PAIR. Without this the + // function is quadratic in the sketch: a drawing with 200 equal holes yields ~20000 + // EqualRadius candidates, the batch is rejected as over-constrained, and the caller's + // one-at-a-time fallback then runs a solve per constraint. Measured 2026-08-31: that + // pinned the app at 95% of a core with the MCP socket unresponsive -- the same failure + // the axes batch above already carries a warning about. Keep the best candidate only. + int best_ang_j = -1, best_rad_j = -1, best_tan_j = -1; + double best_ang_err = 1e30, best_rad_err = 1e30, best_tan_err = 1e30; + SketchConstraintType best_ang_type = SketchConstraintType::Parallel; + + const SketchEntity& n = entities[new_ei]; + const bool n_line = n.type == SketchEntity::Type::Line; + const bool n_curve = n.type == SketchEntity::Type::Arc || n.type == SketchEntity::Type::Circle; + if (!n_line && !n_curve) return out; // not a Line / Arc / Circle + if (n_line && (n.p1 - n.p0).squaredNorm() < 1e-18) return out; // degenerate + if (n_curve && n.radius < 1e-9) return out; + + for (int j = 0; j < new_ei; ++j) { + const SketchEntity& o = entities[j]; + const bool o_line = o.type == SketchEntity::Type::Line; + const bool o_curve = o.type == SketchEntity::Type::Arc || o.type == SketchEntity::Type::Circle; + if (!o_line && !o_curve) continue; + if (o_line && (o.p1 - o.p0).squaredNorm() < 1e-18) continue; + if (o_curve && o.radius < 1e-9) continue; + + if (n_line && o_line) { + // R1 — parallel / perpendicular, restricted to CONNECTED lines. Connection is + // what keeps this from firing on every distant line that is roughly parallel. + const bool connected = (n.p0 - o.p0).squaredNorm() <= 1e-14 || + (n.p0 - o.p1).squaredNorm() <= 1e-14 || + (n.p1 - o.p0).squaredNorm() <= 1e-14 || + (n.p1 - o.p1).squaredNorm() <= 1e-14; + if (!connected) continue; + const double ang = unsigned_angle(n.p1 - n.p0, o.p1 - o.p0); + const double par_err = std::min(ang, M_PI - ang); + const double per_err = std::abs(ang - M_PI / 2.0); + if (par_err <= ang_tol_rad && par_err < best_ang_err) { + best_ang_err = par_err; best_ang_j = j; + best_ang_type = SketchConstraintType::Parallel; + } else if (per_err <= ang_tol_rad && per_err < best_ang_err) { + best_ang_err = per_err; best_ang_j = j; + best_ang_type = SketchConstraintType::Perpendicular; + } + } else if (n_curve && o_curve) { + // R2 — equal radius between circles / arcs, relative to the larger. + const double larger = n.radius > o.radius ? n.radius : o.radius; + const double err = std::abs(n.radius - o.radius) / larger; + if (err <= len_tol_frac && err < best_rad_err) { best_rad_err = err; best_rad_j = j; } + } else { + // R3 — tangent where a line meets a circle / arc at a shared endpoint, and only + // when the line is ALREADY perpendicular to the radius at that point. + const SketchEntity& ln = n_line ? n : o; + const SketchEntity& cv = n_line ? o : n; + const Vec2d ldir = ln.p1 - ln.p0; + bool tangent = false; + const Vec2d le[2] = { ln.p0, ln.p1 }; + for (int k = 0; k < 2 && !tangent; ++k) { + if (cv.type == SketchEntity::Type::Arc) { + const Vec2d ce[2] = { cv.p0, cv.p1 }; + for (int m = 0; m < 2; ++m) { + if ((le[k] - ce[m]).squaredNorm() > 1e-14) continue; + const Vec2d r = ce[m] - cv.center; + if (r.squaredNorm() < 1e-18) continue; + tangent = std::abs(unsigned_angle(ldir, r) - M_PI / 2.0) <= ang_tol_rad; + if (tangent) break; + } + } else { // Circle: shared point is a line endpoint on the rim. + const Vec2d r = le[k] - cv.center; + if (std::abs(r.norm() - cv.radius) > 1e-7) continue; + if (r.squaredNorm() < 1e-18) continue; + tangent = std::abs(unsigned_angle(ldir, r) - M_PI / 2.0) <= ang_tol_rad; + } + } + if (tangent && best_tan_err > 0.0) { best_tan_err = 0.0; best_tan_j = j; } + } + } + + auto emit = [&](SketchConstraintType t, int j) { + if (j < 0) return; + SketchEntityConstraintDef c; + c.type = t; c.ea = j; c.eb = new_ei; + out.push_back(c); + }; + emit(best_ang_type, best_ang_j); // R1 + emit(SketchConstraintType::EqualRadius, best_rad_j); // R2 + emit(SketchConstraintType::Tangent, best_tan_j); // R3 + return out; +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchInference.hpp b/src/libslic3r/CAD/SketchInference.hpp new file mode 100644 index 0000000000..be51f0240e --- /dev/null +++ b/src/libslic3r/CAD/SketchInference.hpp @@ -0,0 +1,55 @@ +#ifndef slic3r_SketchInference_hpp_ +#define slic3r_SketchInference_hpp_ + +#include "libslic3r/CAD/SketchEngine.hpp" +#include +#include +#include + +namespace Slic3r { + +// Result of snapping a free cursor point onto the most relevant inference target +// among the committed sketch entities and the sketch origin. This is the backbone +// that lets geometry self-constrain as it is drawn: the GUI records the returned +// target at click time and, once the entity it belongs to exists, emits the +// matching constraint (Coincident onto an endpoint/centre, Fix onto the origin, +// PointOnObject onto an edge) so the relation survives a re-solve. +struct InferenceSnap { + enum class Kind { None, Endpoint, Center, Midpoint, OnEdge, Origin }; + Kind kind{Kind::None}; + int entity{-1}; // hit entity index (-1 = origin/none) + SketchPointRole role{SketchPointRole::P0}; // which point of `entity` (Endpoint/Center) + Vec2d point{0, 0}; // snapped coordinate (== query when None) + + bool snapped() const { return kind != Kind::None; } +}; + +// Snap `query` onto the best inference target within `tol` plane units. Priority, +// highest first: Endpoint, Center, Origin, Midpoint, OnEdge. Construction entities +// participate (you constrain to them too). Returns {None, query} when nothing is in +// range. Pure — no GUI / GL dependencies, so it is unit-testable in libslic3r. +InferenceSnap infer_point_snap(const std::vector& entities, + const Vec2d& query, double tol, + bool include_origin = true); + +// Relational inference for an in-progress segment anchor->tip. If its direction is +// within `ang_tol_rad` of an axis, returns Horizontal or Vertical (the constraint to +// auto-emit on the committed segment); std::nullopt otherwise. Degenerate (near-zero +// length) segments return nullopt. +std::optional +infer_axis_constraint(const Vec2d& anchor, const Vec2d& tip, double ang_tol_rad = 3.0 * M_PI / 180.0); + +// Relational constraints to auto-emit for a newly drawn entity `new_ei` against the +// entities already in the sketch. Pure, no GUI/GL dependencies, unit-testable. +// +// Deliberately conservative: every rule requires the relation to be ALREADY TRUE within +// tolerance, so an inferred constraint never moves geometry the user drew — it only pins a +// relation that is visibly there. Returns an empty vector when nothing qualifies. +std::vector +infer_relations(const std::vector& entities, int new_ei, + double ang_tol_rad = 2.0 * M_PI / 180.0, + double len_tol_frac = 0.01); + +} // namespace Slic3r + +#endif // slic3r_SketchInference_hpp_ diff --git a/src/libslic3r/CAD/SketchSolver.cpp b/src/libslic3r/CAD/SketchSolver.cpp new file mode 100644 index 0000000000..952f9de16f --- /dev/null +++ b/src/libslic3r/CAD/SketchSolver.cpp @@ -0,0 +1,555 @@ +#include "libslic3r/CAD/SketchSolver.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace Slic3r { + +using CT = SketchConstraintType; +using Role = SketchPointRole; + +namespace { + +constexpr Slvs_hGroup G_FIXED = 1; // workplane / reference: held constant +constexpr Slvs_hGroup G_SK = 2; // sketch geometry: the group we solve + +// Per-entity slvs handles. p0/p1/center are point2d entity handles; prim is the +// line/arc/circle entity; rparam is the circle radius param. +struct Slots { + Slvs_hEntity prim{0}, p0{0}, p1{0}, center{0}; + Slvs_hParam rparam{0}; + std::vector pts; // BSpline control points (point2d handles) +}; + +struct Build { + std::vector params; + std::vector ents; + std::vector cons; + Slvs_hParam ph{0}; + Slvs_hEntity eh{0}; + Slvs_hConstraint ch{0}; + Slvs_hEntity wp{0}, normal{0}; + + Slvs_hParam P(Slvs_hGroup g, double v) { params.push_back(Slvs_MakeParam(++ph, g, v)); return ph; } + Slvs_hEntity E(Slvs_Entity e) { ents.push_back(e); return e.h; } + Slvs_hEntity pt2d(Slvs_hGroup g, double u, double v) + { return E(Slvs_MakePoint2d(++eh, g, wp, P(g, u), P(g, v))); } + + // Generic constraint (entityC unused by Slvs_MakeConstraint — set it manually below). + void C(int type, double val, Slvs_hEntity ptA, Slvs_hEntity ptB, + Slvs_hEntity eA, Slvs_hEntity eB, Slvs_hEntity eC = 0, int other = 0) + { + Slvs_Constraint c = Slvs_MakeConstraint(++ch, G_SK, type, wp, val, ptA, ptB, eA, eB); + c.entityC = eC; + c.other = other; + cons.push_back(c); + } +}; + +inline int role_idx(Role r) { return int(r); } + +} // namespace + +static SketchSolveResult solve_system(std::vector& entities, + const std::vector& constraints, + int dragged_ei, Role dragged_role) +{ + SketchSolveResult out; + if (constraints.empty()) { out.ok = true; out.dof = -1; return out; } + + Build b; + + // ---- Fixed 2D XY workplane (origin at 0,0,0; identity normal) ------------------- + Slvs_hEntity origin = b.E(Slvs_MakePoint3d(++b.eh, G_FIXED, + b.P(G_FIXED, 0.0), b.P(G_FIXED, 0.0), b.P(G_FIXED, 0.0))); + double qw, qx, qy, qz; + Slvs_MakeQuaternion(1, 0, 0, 0, 1, 0, &qw, &qx, &qy, &qz); + b.normal = b.E(Slvs_MakeNormal3d(++b.eh, G_FIXED, + b.P(G_FIXED, qw), b.P(G_FIXED, qx), b.P(G_FIXED, qy), b.P(G_FIXED, qz))); + b.wp = b.E(Slvs_MakeWorkplane(++b.eh, G_FIXED, origin, b.normal)); + + // Unit direction references for the axis-projected distance constraints. Both live in + // G_FIXED, so they are held constant and add no DOF to the system. + // libslvs defines a LINE_SEGMENT's direction as point[0] - point[1] (entity.cpp + // VectorGetExprs), so the unit vector's head is listed first to yield +X / +Y. + const Slvs_hEntity dir_x [[maybe_unused]] = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, + b.pt2d(G_FIXED, 1.0, 0.0), b.pt2d(G_FIXED, 0.0, 0.0))); + const Slvs_hEntity dir_y [[maybe_unused]] = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, + b.pt2d(G_FIXED, 0.0, 1.0), b.pt2d(G_FIXED, 0.0, 0.0))); + + // Implicit sketch references (origin, X axis, Y axis), addressable by the negative + // sentinels in SketchEngine.hpp. G_FIXED: held constant, zero added DOF. The axis lines + // are built head-first so their direction reads +X / +Y, matching dir_x / dir_y. + const Slvs_hEntity ref_origin_pt = b.pt2d(G_FIXED, 0.0, 0.0); + const Slvs_hEntity ref_axis_x = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, + b.pt2d(G_FIXED, 1.0, 0.0), ref_origin_pt)); + const Slvs_hEntity ref_axis_y = b.E(Slvs_MakeLineSegment(++b.eh, G_FIXED, b.wp, + b.pt2d(G_FIXED, 0.0, 1.0), ref_origin_pt)); + + // ---- Entities ------------------------------------------------------------------- + std::vector slot(entities.size()); + for (size_t i = 0; i < entities.size(); ++i) { + const SketchEntity& e = entities[i]; + Slots s; + switch (e.type) { + case SketchEntity::Type::Line: + s.p0 = b.pt2d(G_SK, e.p0.x(), e.p0.y()); + s.p1 = b.pt2d(G_SK, e.p1.x(), e.p1.y()); + s.prim = b.E(Slvs_MakeLineSegment(++b.eh, G_SK, b.wp, s.p0, s.p1)); + break; + case SketchEntity::Type::Point: + s.p0 = b.pt2d(G_SK, e.p0.x(), e.p0.y()); + break; + case SketchEntity::Type::Circle: { + s.center = b.pt2d(G_SK, e.center.x(), e.center.y()); + s.p0 = s.center; // p0 mirrors centre for circles + s.rparam = b.P(G_SK, e.radius > 1e-9 ? e.radius : 1.0); + Slvs_hEntity dist = b.E(Slvs_MakeDistance(++b.eh, G_SK, b.wp, s.rparam)); + s.prim = b.E(Slvs_MakeCircle(++b.eh, G_SK, b.wp, s.center, b.normal, dist)); + break; + } + case SketchEntity::Type::Arc: + s.center = b.pt2d(G_SK, e.center.x(), e.center.y()); + s.p0 = b.pt2d(G_SK, e.p0.x(), e.p0.y()); // start + s.p1 = b.pt2d(G_SK, e.p1.x(), e.p1.y()); // end + s.prim = b.E(Slvs_MakeArcOfCircle(++b.eh, G_SK, b.wp, b.normal, s.center, s.p0, s.p1)); + break; + // libslvs has no conic entity (scope note): register the ellipse's defining + // points only (center + arc endpoints) so center/endpoint constraints solve; + // the a/b/phi shape params pass through unsolved. + case SketchEntity::Type::Ellipse: + s.center = b.pt2d(G_SK, e.center.x(), e.center.y()); + s.p0 = s.center; // p0 mirrors centre (circle convention) + break; + case SketchEntity::Type::EllipseArc: + s.center = b.pt2d(G_SK, e.center.x(), e.center.y()); + s.p0 = b.pt2d(G_SK, e.p0.x(), e.p0.y()); // start + s.p1 = b.pt2d(G_SK, e.p1.x(), e.p1.y()); // end + break; + // No native slvs curve for an arbitrary-degree spline: register the control + // poles as point2d so endpoints (and any pole-targeted constraint) solve. The + // OCCT curve is rebuilt from the solved poles. p0/p1 mirror first/last pole so + // Coincident at the spline ends closes loops just like a Line. + case SketchEntity::Type::BSpline: + s.pts.reserve(e.ctrl.size()); + for (const Vec2d& cp : e.ctrl) + s.pts.push_back(b.pt2d(G_SK, cp.x(), cp.y())); + if (!s.pts.empty()) { s.p0 = s.pts.front(); s.p1 = s.pts.back(); } + break; + } + slot[i] = s; + } + + auto valid = [&](int ei) { return ei >= 0 && ei < int(entities.size()); }; + auto ptOf = [&](int ei, Role r) -> Slvs_hEntity { + if (ei == kSketchRefOrigin) return ref_origin_pt; + if (ei == kSketchRefAxisX || ei == kSketchRefAxisY) return ref_origin_pt; // axes pass through it + if (!valid(ei)) return 0; + const Slots& s = slot[ei]; + switch (r) { + case Role::P0: return s.p0; + case Role::P1: return s.p1; + case Role::Center: return s.center ? s.center : s.p0; + } + return 0; + }; + auto primOf = [&](int ei) -> Slvs_hEntity { + if (ei == kSketchRefAxisX) return ref_axis_x; + if (ei == kSketchRefAxisY) return ref_axis_y; + return valid(ei) ? slot[ei].prim : 0; // origin has no prim: it is a point + }; + auto coordOf = [&](int ei, Role r) -> Vec2d { + if (is_sketch_ref(ei)) return Vec2d(0, 0); // all three pass through the origin + if (!valid(ei)) return Vec2d(0, 0); + const SketchEntity& e = entities[ei]; + switch (r) { case Role::P0: return e.p0; case Role::P1: return e.p1; case Role::Center: return e.center; } + return e.p0; + }; + // A fixed reference point at (x,y) — used to pin coordinates (Fix / LockX / LockY). + auto fixedRef = [&](double x, double y) -> Slvs_hEntity { return b.pt2d(G_FIXED, x, y); }; + + // ---- Constraints ---------------------------------------------------------------- + for (const auto& c : constraints) { + // Robustness: never feed libslvs a null handle. A constraint that references an + // entity which produced no solver primitive (Point/Ellipse/EllipseArc/BSpline get + // no `prim`) or no point for the requested role would make Slvs FindById abort the + // whole process. Skip such a constraint instead of crashing. + bool ref_ok = true; + switch (c.type) { + case CT::Coincident: case CT::Horizontal: case CT::Vertical: case CT::Distance: + ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb); break; + case CT::DistanceX: + case CT::DistanceY: + ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb); break; + case CT::Concentric: + ref_ok = ptOf(c.ea, Role::Center) && ptOf(c.eb, Role::Center); break; + case CT::Fix: case CT::LockX: case CT::LockY: + ref_ok = ptOf(c.ea, c.ra) != 0; break; + case CT::EqualLength: case CT::Parallel: case CT::Perpendicular: + case CT::Angle: case CT::Tangent: + ref_ok = primOf(c.ea) && primOf(c.eb); break; + case CT::Radius: case CT::Diameter: + ref_ok = primOf(c.ea) != 0; break; + case CT::Midpoint: + ref_ok = ptOf(c.ea, c.ra) && primOf(c.eb); break; + case CT::Symmetric: + ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb) && primOf(c.ec); break; + case CT::SymmetricAboutY: case CT::SymmetricAboutX: + ref_ok = ptOf(c.ea, c.ra) && ptOf(c.eb, c.rb); break; + case CT::PointOnLine: case CT::PointOnObject: + ref_ok = ptOf(c.ea, c.ra) && primOf(c.eb); break; + case CT::EqualRadius: + case CT::Collinear: + ref_ok = primOf(c.ea) && primOf(c.eb); break; + } + if (!ref_ok) continue; + switch (c.type) { + case CT::Coincident: + b.C(SLVS_C_POINTS_COINCIDENT, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), 0, 0); + break; + case CT::Concentric: + b.C(SLVS_C_POINTS_COINCIDENT, 0, ptOf(c.ea, Role::Center), ptOf(c.eb, Role::Center), 0, 0); + break; + case CT::Horizontal: + b.C(SLVS_C_HORIZONTAL, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), 0, 0); + break; + case CT::Vertical: + b.C(SLVS_C_VERTICAL, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), 0, 0); + break; + case CT::Distance: + b.C(SLVS_C_PT_PT_DISTANCE, c.value, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), 0, 0); + break; + case CT::DistanceX: + // Distance between the two points measured along X only: project the vector + // between them onto the fixed unit X direction. + b.C(SLVS_C_PROJ_PT_DISTANCE, c.value, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), dir_x, 0); + break; + case CT::DistanceY: + b.C(SLVS_C_PROJ_PT_DISTANCE, c.value, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), dir_y, 0); + break; + case CT::Fix: { + const Vec2d p = coordOf(c.ea, c.ra); + b.C(SLVS_C_POINTS_COINCIDENT, 0, ptOf(c.ea, c.ra), fixedRef(p.x(), p.y()), 0, 0); + break; + } + case CT::LockX: { + const Vec2d p = coordOf(c.ea, c.ra); + b.C(SLVS_C_VERTICAL, 0, ptOf(c.ea, c.ra), fixedRef(c.value, p.y()), 0, 0); + break; + } + case CT::LockY: { + const Vec2d p = coordOf(c.ea, c.ra); + b.C(SLVS_C_HORIZONTAL, 0, ptOf(c.ea, c.ra), fixedRef(p.x(), c.value), 0, 0); + break; + } + case CT::EqualLength: + b.C(SLVS_C_EQUAL_LENGTH_LINES, 0, 0, 0, primOf(c.ea), primOf(c.eb)); + break; + case CT::Parallel: + b.C(SLVS_C_PARALLEL, 0, 0, 0, primOf(c.ea), primOf(c.eb)); + break; + case CT::Perpendicular: + b.C(SLVS_C_PERPENDICULAR, 0, 0, 0, primOf(c.ea), primOf(c.eb)); + break; + case CT::Midpoint: + b.C(SLVS_C_AT_MIDPOINT, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); + break; + case CT::Symmetric: + // ptA, ptB symmetric about the axis line (ec). + b.C(SLVS_C_SYMMETRIC_LINE, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), primOf(c.ec), 0); + break; + case CT::SymmetricAboutY: + b.C(SLVS_C_SYMMETRIC_LINE, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), primOf(kSketchRefAxisY), 0); + break; + case CT::SymmetricAboutX: + b.C(SLVS_C_SYMMETRIC_LINE, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), primOf(kSketchRefAxisX), 0); + break; + case CT::Angle: + // model stores radians; slvs angle is in degrees. + b.C(SLVS_C_ANGLE, c.value * 180.0 / M_PI, 0, 0, primOf(c.ea), primOf(c.eb)); + break; + case CT::Radius: + b.C(SLVS_C_DIAMETER, 2.0 * c.value, 0, 0, primOf(c.ea), 0); + break; + case CT::Diameter: + b.C(SLVS_C_DIAMETER, c.value, 0, 0, primOf(c.ea), 0); + break; + case CT::Tangent: { + const bool aCurve = valid(c.ea) && entities[c.ea].type != SketchEntity::Type::Line; + const bool bCurve = valid(c.eb) && entities[c.eb].type != SketchEntity::Type::Line; + if (aCurve && bCurve) + b.C(SLVS_C_CURVE_CURVE_TANGENT, 0, 0, 0, primOf(c.ea), primOf(c.eb)); + else { + const int ci = aCurve ? c.ea : c.eb; // the curve + const int li = aCurve ? c.eb : c.ea; // the line + if (valid(ci) && entities[ci].type == SketchEntity::Type::Circle) { + // A FULL circle cannot use SLVS_C_ARC_LINE_TANGENT. That constraint reads + // arc->point[1] / point[2] — the arc's endpoints (see constrainteq.cpp, + // Type::ARC_LINE_TANGENT) — and a circle entity only has point[0], its + // centre. The zero handles send FindById into "Cannot find handle", which + // ABORTS the process rather than failing the solve, taking every later test + // with it. It is also the wrong equation for a circle: it only makes the + // line perpendicular to the radius AT AN ENDPOINT that does not exist. + // + // For a circle, tangency is exactly "the centre sits one radius away from + // the line", which slvs expresses directly. + // + // ponytail: the radius is captured here rather than tied as a variable — + // the C API takes a constant distance and offers no way to reference the + // circle's radius parameter. Exact whenever the radius is fixed or simply + // not being changed by another constraint in the same solve; if some other + // constraint drives the radius, re-solving restores tangency. Tying them + // would need an auxiliary point constrained onto both circle and line. + b.C(SLVS_C_PT_LINE_DISTANCE, entities[ci].radius, + ptOf(ci, Role::Center), 0, primOf(li), 0); + } else { + b.C(SLVS_C_ARC_LINE_TANGENT, 0, 0, 0, primOf(ci), primOf(li)); + } + } + break; + } + case CT::PointOnLine: + if (std::abs(c.value) < 1e-9) + b.C(SLVS_C_PT_ON_LINE, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); + else + b.C(SLVS_C_PT_LINE_DISTANCE, std::abs(c.value), ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); + break; + case CT::PointOnObject: + // Point (ea,ra) lies on entity edge eb: a circle rim -> PT_ON_CIRCLE, + // otherwise the segment line -> PT_ON_LINE. + if (valid(c.eb) && entities[c.eb].type == SketchEntity::Type::Circle) + b.C(SLVS_C_PT_ON_CIRCLE, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); + else + b.C(SLVS_C_PT_ON_LINE, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); + break; + case CT::EqualRadius: + b.C(SLVS_C_EQUAL_RADIUS, 0, 0, 0, primOf(c.ea), primOf(c.eb)); + break; + case CT::Collinear: + // libslvs has no collinear code. Two lines are collinear iff they are parallel + // AND a point of one lies on the other's infinite line — emit both. + b.C(SLVS_C_PARALLEL, 0, 0, 0, primOf(c.ea), primOf(c.eb)); + // Point-on-infinite-line via PT_LINE_DISTANCE=0 rather than PT_ON_LINE: the + // latter creates an internal `valP` param that this port's Slvs_Solve leaves at + // 0 in the working set (ModifyToSatisfy only updates SK.param), so an already + // collinear pair drifts. PT_LINE_DISTANCE=0 is the same condition with no extra + // parameter, so an already-satisfied solve is a clean no-op. + b.C(SLVS_C_PT_LINE_DISTANCE, 0, ptOf(c.eb, Role::P0), 0, primOf(c.ea), 0); + break; + } + } + + // ---- Solve ---------------------------------------------------------------------- + Slvs_System sys; + std::memset(&sys, 0, sizeof(sys)); + sys.param = b.params.data(); sys.params = int(b.params.size()); + sys.entity = b.ents.data(); sys.entities = int(b.ents.size()); + sys.constraint = b.cons.data(); sys.constraints = int(b.cons.size()); + std::vector failed(b.cons.size() + 1, 0); + sys.failed = failed.data(); + sys.faileds = int(failed.size()); + sys.calculateFaileds = 1; + + // Drag pin: feed the dragged point's two params into sys.dragged[] so the solver + // favours keeping that point at the cursor and re-solves the rest around it. + if (dragged_ei >= 0) { + const Slvs_hEntity h = ptOf(dragged_ei, dragged_role); + for (const Slvs_Entity& en : b.ents) + if (en.h == h) { sys.dragged[0] = en.param[0]; sys.dragged[1] = en.param[1]; break; } + } + + Slvs_Solve(&sys, G_SK); + + out.result = sys.result; + out.dof = sys.dof; + out.ok = (sys.result == SLVS_RESULT_OKAY); + + // Map solved param handles -> values, then read points back. + std::unordered_map pv; + pv.reserve(sys.params * 2); + for (int i = 0; i < sys.params; ++i) pv[sys.param[i].h] = sys.param[i].val; + std::unordered_map byH; + byH.reserve(sys.entities * 2); + for (int i = 0; i < sys.entities; ++i) byH[sys.entity[i].h] = &sys.entity[i]; + auto coord = [&](Slvs_hEntity h) -> Vec2d { + auto it = byH.find(h); + if (it == byH.end()) return Vec2d(0, 0); + return Vec2d(pv[it->second->param[0]], pv[it->second->param[1]]); + }; + + // Map failed constraint handles back to indices into `constraints`. + if (!out.ok && sys.faileds > 0) { + std::unordered_map chToIdx; + // constraint handles were assigned in order starting after the fixed group; the + // i-th sketch constraint in b.cons has handle = its position. Rebuild by scanning. + for (size_t k = 0; k < b.cons.size(); ++k) chToIdx[b.cons[k].h] = int(k); + for (int i = 0; i < sys.faileds; ++i) { + auto it = chToIdx.find(failed[i]); + if (it != chToIdx.end() && it->second < int(constraints.size())) + out.bad.push_back(it->second); + } + } + + // ---- Read solved geometry back -------------------------------------------------- + // ONLY on success. A failed solve leaves libslvs' params holding its last Newton + // iterate — geometry that satisfies nothing and is usually wildly deformed. Writing + // that back made every rejected attempt destructive: the caller rolls the constraints + // back, but the sketch it rolls back to is already wreckage, so the next attempt starts + // from the corpse. The fillet degrade ladder hit this on every corner — rung 1 (a + // tangent on each leg) is legitimately over-constrained against the legs' own H/V, and + // its wreckage then failed rungs 2 and 3, which solve cleanly on their own. The arc + // ended up with no constraints at all and the solver snapped the corner shut. pl5. + if (!out.ok) return out; + for (size_t i = 0; i < entities.size(); ++i) { + SketchEntity& e = entities[i]; + const Slots& s = slot[i]; + if (s.p0) e.p0 = coord(s.p0); + if (s.p1) e.p1 = coord(s.p1); + if (s.center) e.center = coord(s.center); + + if (e.type == SketchEntity::Type::BSpline) { + for (size_t k = 0; k < s.pts.size() && k < e.ctrl.size(); ++k) + e.ctrl[k] = coord(s.pts[k]); + if (!e.ctrl.empty()) { e.p0 = e.ctrl.front(); e.p1 = e.ctrl.back(); } + } else if (e.type == SketchEntity::Type::Circle) { + if (s.rparam) { auto it = pv.find(s.rparam); if (it != pv.end()) e.radius = it->second; } + e.p0 = e.center; + } else if (e.type == SketchEntity::Type::Arc && s.center) { + // Reflow arc angles from solved centre + endpoints, preserving sweep sign. + const double old_sweep = e.end_angle - e.start_angle; + const double ns = std::atan2(e.p0.y() - e.center.y(), e.p0.x() - e.center.x()); + const double ne = std::atan2(e.p1.y() - e.center.y(), e.p1.x() - e.center.x()); + double sweep = ne - ns; + const double TWO_PI = 2.0 * M_PI; + while (sweep <= -TWO_PI) sweep += TWO_PI; + while (sweep >= TWO_PI) sweep -= TWO_PI; + if (old_sweep >= 0.0 && sweep < 0.0) sweep += TWO_PI; + if (old_sweep < 0.0 && sweep > 0.0) sweep -= TWO_PI; + e.start_angle = ns; + e.end_angle = ns + sweep; + e.radius = 0.5 * ((e.p0 - e.center).norm() + (e.p1 - e.center).norm()); + } + } + + return out; +} + +// libslvs carries a COMPILE-TIME ceiling: solvespace.h declares `enum { MAX_UNKNOWNS = 1024 }` +// and sizes the System's param and equation arrays with it. solve_system() hands the solver every +// entity in the sketch, constrained or not, at 2 params per point — so a sketch of about 480 lines +// is the last one that fits, and the very next one comes back TOO_MANY_UNKNOWNS. +// +// What that did, before this: DesignSketchTool::try_add_constraints rolls the whole batch back +// when the solve fails, so the auto-constraint pass over a large sketch dropped EVERY constraint +// it had just inferred. Measured on the rig — 480 lines: 960 constraints, dof 480. 520 lines: +// 0 constraints, dof unknown. Nothing was said, and from there on no dimension and no constraint +// could ever be applied to that sketch, because each attempt re-solved the same oversized system +// and was rejected in turn. A typed length simply did nothing. +// +// Constraints only couple entities that SHARE a point, so a sketch is naturally a set of +// independent systems — a plate with 300 cut-outs is 301 little problems, not one big one. +// Solving them separately keeps every one of them far under the ceiling AND is faster, since the +// solver's work is superlinear in system size. +// +// The whole system is still tried FIRST, and this runs only on TOO_MANY_UNKNOWNS, so every sketch +// that fits today keeps its exact current behaviour, including its reported degrees of freedom. +// A genuinely over-constrained sketch still fails: the conflict lives inside one component and +// that component still rejects it. +static SketchSolveResult solve_partitioned(std::vector& entities, + const std::vector& constraints, + int dragged_ei, Role dragged_role) +{ + const int n = int(entities.size()); + std::vector parent(n); + for (int i = 0; i < n; ++i) parent[i] = i; + std::function find = [&](int a) { + while (parent[a] != a) { parent[a] = parent[parent[a]]; a = parent[a]; } + return a; + }; + auto unite = [&](int a, int b) { + if (a < 0 || b < 0 || a >= n || b >= n) return; + a = find(a); b = find(b); + if (a != b) parent[a] = b; + }; + for (const auto& c : constraints) { unite(c.ea, c.eb); unite(c.ea, c.ec); } + + // Group the constraints by the component they belong to. + std::map> groups; + for (size_t i = 0; i < constraints.size(); ++i) { + const int a = constraints[i].ea; + if (a < 0 || a >= n) continue; + groups[find(a)].push_back(int(i)); + } + + SketchSolveResult out; + out.ok = true; + out.dof = 0; + // Solve into COPIES and commit only if every component succeeded. The contract callers rely + // on is all-or-nothing — try_add_constraints rolls the batch back and expects the geometry it + // rolls back to be untouched — and partial writes would break it. + std::vector, std::vector>> solved; + for (const auto& [root, cidx] : groups) { + std::vector ents; // global indices, in order + std::map local; // global -> local + auto take = [&](int e) { + if (e < 0 || e >= n || local.count(e)) return; + local[e] = int(ents.size()); + ents.push_back(e); + }; + for (int ci : cidx) { take(constraints[ci].ea); take(constraints[ci].eb); take(constraints[ci].ec); } + std::vector sub; + sub.reserve(ents.size()); + for (int e : ents) sub.push_back(entities[e]); + std::vector subc; + subc.reserve(cidx.size()); + for (int ci : cidx) { + SketchEntityConstraintDef d = constraints[ci]; + auto map1 = [&](int& e) { e = (e >= 0 && local.count(e)) ? local[e] : -1; }; + map1(d.ea); map1(d.eb); map1(d.ec); + subc.push_back(d); + } + const int sub_drag = (dragged_ei >= 0 && local.count(dragged_ei)) ? local[dragged_ei] : -1; + SketchSolveResult r = solve_system(sub, subc, sub_drag, dragged_role); + if (!r.ok) { + out.ok = false; + out.result = r.result; + for (int bi : r.bad) + if (bi >= 0 && bi < int(cidx.size())) out.bad.push_back(cidx[bi]); + } + if (r.dof > 0) out.dof += r.dof; + solved.emplace_back(std::move(ents), std::move(sub)); + } + if (!out.ok) return out; + for (auto& [ents, sub] : solved) + for (size_t k = 0; k < ents.size(); ++k) entities[ents[k]] = sub[k]; + return out; +} + +static SketchSolveResult solve_impl(std::vector& entities, + const std::vector& constraints, + int dragged_ei, Role dragged_role) +{ + SketchSolveResult out = solve_system(entities, constraints, dragged_ei, dragged_role); + if (out.ok || out.result != SLVS_RESULT_TOO_MANY_UNKNOWNS) return out; + return solve_partitioned(entities, constraints, dragged_ei, dragged_role); +} + +SketchSolveResult sketch_solve(std::vector& entities, + const std::vector& constraints) +{ + return solve_impl(entities, constraints, -1, Role::P0); +} + +SketchSolveResult sketch_solve_drag(std::vector& entities, + const std::vector& constraints, + int dragged_ei, SketchPointRole dragged_role) +{ + return solve_impl(entities, constraints, dragged_ei, dragged_role); +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchSolver.hpp b/src/libslic3r/CAD/SketchSolver.hpp new file mode 100644 index 0000000000..ada4474c7a --- /dev/null +++ b/src/libslic3r/CAD/SketchSolver.hpp @@ -0,0 +1,37 @@ +#ifndef slic3r_SketchSolver_hpp_ +#define slic3r_SketchSolver_hpp_ + +// Bridge from the Design tab's SketchEntity / SketchEntityConstraintDef model onto the +// vendored SolveSpace constraint solver (src/libslic3r/slvs, libslvs). Replaces the +// hand-rolled SketchConstraints: full constraint set, real DoF counting, and +// over-constrained (bad-constraint) detection. Solves on a fixed 2D XY workplane. + +#include "libslic3r/CAD/SketchEngine.hpp" +#include + +namespace Slic3r { + +struct SketchSolveResult { + bool ok{false}; // solver converged & consistent + int dof{-1}; // remaining degrees of freedom (>0 under-constrained) + int result{0}; // raw SLVS_RESULT_* code + std::vector bad; // indices (into `constraints`) of conflicting constraints +}; + +// Solve `constraints` over `entities` in place (writes solved coordinates back into the +// entities; arc angles are reflowed preserving sweep direction). No-op success when +// `constraints` is empty. +SketchSolveResult sketch_solve(std::vector& entities, + const std::vector& constraints); + +// Drag-aware solve: pins the (dragged_ei, dragged_role) point's parameters via the +// solver's `dragged[]` priority list so the solver keeps that point where the cursor +// placed it (caller must have moved it first) and moves the OTHER free geometry to +// re-satisfy the constraints. dragged_ei < 0 behaves identically to sketch_solve. +SketchSolveResult sketch_solve_drag(std::vector& entities, + const std::vector& constraints, + int dragged_ei, SketchPointRole dragged_role); + +} // namespace Slic3r + +#endif diff --git a/src/libslic3r/CAD/ThreadStandards.cpp b/src/libslic3r/CAD/ThreadStandards.cpp new file mode 100644 index 0000000000..2d113d01e6 --- /dev/null +++ b/src/libslic3r/CAD/ThreadStandards.cpp @@ -0,0 +1,95 @@ +#include "libslic3r/CAD/ThreadStandards.hpp" + +namespace Slic3r { + +// Imperial helpers: convert nominal inch diameter / threads-per-inch to mm. +static constexpr double IN = 25.4; +static inline double tpi_pitch(double tpi) { return IN / tpi; } + +const std::vector& thread_standards() +{ + using S = ThreadSpec::Series; + static const std::vector table = { + // --- ISO metric, coarse pitch (ISO 261 preferred series) --- + {"M1", 1.0, 0.25, S::MetricCoarse}, + {"M1.2", 1.2, 0.25, S::MetricCoarse}, + {"M1.6", 1.6, 0.35, S::MetricCoarse}, + {"M2", 2.0, 0.40, S::MetricCoarse}, + {"M2.5", 2.5, 0.45, S::MetricCoarse}, + {"M3", 3.0, 0.50, S::MetricCoarse}, + {"M4", 4.0, 0.70, S::MetricCoarse}, + {"M5", 5.0, 0.80, S::MetricCoarse}, + {"M6", 6.0, 1.00, S::MetricCoarse}, + {"M8", 8.0, 1.25, S::MetricCoarse}, + {"M10", 10.0, 1.50, S::MetricCoarse}, + {"M12", 12.0, 1.75, S::MetricCoarse}, + {"M14", 14.0, 2.00, S::MetricCoarse}, + {"M16", 16.0, 2.00, S::MetricCoarse}, + {"M20", 20.0, 2.50, S::MetricCoarse}, + {"M24", 24.0, 3.00, S::MetricCoarse}, + {"M30", 30.0, 3.50, S::MetricCoarse}, + {"M36", 36.0, 4.00, S::MetricCoarse}, + {"M42", 42.0, 4.50, S::MetricCoarse}, + {"M48", 48.0, 5.00, S::MetricCoarse}, + {"M56", 56.0, 5.50, S::MetricCoarse}, + {"M64", 64.0, 6.00, S::MetricCoarse}, + + // --- ISO metric, common fine pitches (ISO 261 fine series) --- + {"M8x1", 8.0, 1.00, S::MetricFine}, + {"M10x1.25", 10.0, 1.25, S::MetricFine}, + {"M10x1", 10.0, 1.00, S::MetricFine}, + {"M12x1.5", 12.0, 1.50, S::MetricFine}, + {"M12x1.25", 12.0, 1.25, S::MetricFine}, + {"M16x1.5", 16.0, 1.50, S::MetricFine}, + {"M20x1.5", 20.0, 1.50, S::MetricFine}, + {"M24x2", 24.0, 2.00, S::MetricFine}, + + // --- Unified National Coarse (UTS / ASME B1.1) --- + {"#1-64 UNC", 0.073 * IN, tpi_pitch(64), S::UNC}, + {"#2-56 UNC", 0.086 * IN, tpi_pitch(56), S::UNC}, + {"#3-48 UNC", 0.099 * IN, tpi_pitch(48), S::UNC}, + {"#4-40 UNC", 0.112 * IN, tpi_pitch(40), S::UNC}, + {"#5-40 UNC", 0.125 * IN, tpi_pitch(40), S::UNC}, + {"#6-32 UNC", 0.138 * IN, tpi_pitch(32), S::UNC}, + {"#8-32 UNC", 0.164 * IN, tpi_pitch(32), S::UNC}, + {"#10-24 UNC", 0.190 * IN, tpi_pitch(24), S::UNC}, + {"#12-24 UNC", 0.216 * IN, tpi_pitch(24), S::UNC}, + {"1/4-20 UNC", 0.250 * IN, tpi_pitch(20), S::UNC}, + {"5/16-18 UNC", 0.3125 * IN, tpi_pitch(18), S::UNC}, + {"3/8-16 UNC", 0.375 * IN, tpi_pitch(16), S::UNC}, + {"7/16-14 UNC", 0.4375 * IN, tpi_pitch(14), S::UNC}, + {"1/2-13 UNC", 0.500 * IN, tpi_pitch(13), S::UNC}, + {"9/16-12 UNC", 0.5625 * IN, tpi_pitch(12), S::UNC}, + {"5/8-11 UNC", 0.625 * IN, tpi_pitch(11), S::UNC}, + {"3/4-10 UNC", 0.750 * IN, tpi_pitch(10), S::UNC}, + {"7/8-9 UNC", 0.875 * IN, tpi_pitch(9), S::UNC}, + {"1-8 UNC", 1.000 * IN, tpi_pitch(8), S::UNC}, + + // --- Unified National Fine (UTS / ASME B1.1) --- + {"#2-64 UNF", 0.086 * IN, tpi_pitch(64), S::UNF}, + {"#4-48 UNF", 0.112 * IN, tpi_pitch(48), S::UNF}, + {"#6-40 UNF", 0.138 * IN, tpi_pitch(40), S::UNF}, + {"#8-36 UNF", 0.164 * IN, tpi_pitch(36), S::UNF}, + {"#10-32 UNF", 0.190 * IN, tpi_pitch(32), S::UNF}, + {"1/4-28 UNF", 0.250 * IN, tpi_pitch(28), S::UNF}, + {"5/16-24 UNF", 0.3125 * IN, tpi_pitch(24), S::UNF}, + {"3/8-24 UNF", 0.375 * IN, tpi_pitch(24), S::UNF}, + {"7/16-20 UNF", 0.4375 * IN, tpi_pitch(20), S::UNF}, + {"1/2-20 UNF", 0.500 * IN, tpi_pitch(20), S::UNF}, + {"9/16-18 UNF", 0.5625 * IN, tpi_pitch(18), S::UNF}, + {"5/8-18 UNF", 0.625 * IN, tpi_pitch(18), S::UNF}, + {"3/4-16 UNF", 0.750 * IN, tpi_pitch(16), S::UNF}, + {"1-12 UNF", 1.000 * IN, tpi_pitch(12), S::UNF}, + }; + return table; +} + +const ThreadSpec* find_thread_standard(const std::string& name) +{ + for (const ThreadSpec& s : thread_standards()) + if (s.name == name) + return &s; + return nullptr; +} + +} // namespace Slic3r diff --git a/src/libslic3r/CAD/ThreadStandards.hpp b/src/libslic3r/CAD/ThreadStandards.hpp new file mode 100644 index 0000000000..e9bfcc314f --- /dev/null +++ b/src/libslic3r/CAD/ThreadStandards.hpp @@ -0,0 +1,39 @@ +#ifndef slic3r_ThreadStandards_hpp_ +#define slic3r_ThreadStandards_hpp_ + +#include +#include + +namespace Slic3r { + +// Canonical mechanical thread specifications (ISO metric + Unified imperial). +// All dimensions are stored in millimetres so the CAD kernel can consume them +// directly. The profile is the common 60deg V shared by ISO 261/965 and ASME +// B1.1 (UTS), so the cut/ridge depth used by the Design-tab Thread tool is the +// basic external thread height h = 0.6134 * pitch, and the internal (tapped) +// minor diameter is D1 = D - 1.0825 * pitch (= D - 2*5H/8). +struct ThreadSpec { + enum class Series { MetricCoarse, MetricFine, UNC, UNF }; + + std::string name; // designation, e.g. "M6", "1/4-20 UNC" + double major_diameter_mm; // nominal (crest) diameter + double pitch_mm; // axial advance per turn + Series series; + + // 60deg basic external thread height (radial crest-to-root engagement). + double thread_depth_mm() const { return 0.6134 * pitch_mm; } + // Internal/tapped minor (tap-drill) diameter for the same nominal thread. + double minor_diameter_mm() const { return major_diameter_mm - 1.0825 * pitch_mm; } + + bool imperial() const { return series == Series::UNC || series == Series::UNF; } +}; + +// Full ordered table (metric coarse, metric fine, UNC, UNF) for GUI listing. +const std::vector& thread_standards(); + +// Exact case-sensitive designation lookup; nullptr if not a known standard. +const ThreadSpec* find_thread_standard(const std::string& name); + +} // namespace Slic3r + +#endif diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 1d68db497d..e734c036fa 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -21,6 +21,11 @@ endif() option(BUILD_SHARED_LIBS "Build shared libs" OFF) option(USE_SLIC3R_CONSOLE_LOG "Enable console logging in RelWithDebInfo builds" OFF) +# SolveSpace constraint solver (2D sketch solver backbone), built in deps/SLVS. +if (SLIC3R_CAD) + find_package(SLVS REQUIRED) +endif () + set(lisbslic3r_sources AABBMesh.cpp AABBMesh.hpp @@ -508,6 +513,29 @@ set(lisbslic3r_sources FlushVolPredictor.cpp ) +# Parametric Design/CAD kernel. Needs OCCT's ModelingAlgorithms module and the +# vendored SolveSpace solver; both are pulled in only when SLIC3R_CAD is ON. +if (SLIC3R_CAD) + list(APPEND lisbslic3r_sources + CAD/GeometryEngine.cpp + CAD/GeometryEngine.hpp + CAD/SketchEngine.cpp + CAD/SketchEngine.hpp + CAD/SketchConstraints.cpp + CAD/SketchConstraints.hpp + CAD/SketchSolver.cpp + CAD/SketchSolver.hpp + CAD/SketchInference.cpp + CAD/SketchInference.hpp + CAD/SketchImport.cpp + CAD/SketchImport.hpp + CAD/CadDocument.cpp + CAD/CadDocument.hpp + CAD/ThreadStandards.cpp + CAD/ThreadStandards.hpp + ) +endif () + if (APPLE) list(APPEND lisbslic3r_sources MacUtils.mm @@ -619,6 +647,30 @@ set(OCCT_LIBS TKMath TKernel ) +# The CAD kernel is the only consumer of OCCT's ModelingAlgorithms module: TKFillet +# (BRepFilletAPI), TKOffset (BRepOffsetAPI) and TKBool, which the other two need. +# +# PREPEND, never append: this list is single-pass static link order, dependents before +# dependencies — note TKernel, which everything needs, is deliberately last. TKOffset +# references BRepAlgo_Loop, which TKBool defines, so TKOffset must come BEFORE TKBool. +# Appending put it after, and a strictly single-pass linker (the Flatpak build) failed with +# libTKOffset.a(BRepOffset_MakeLoops.cxx.o): undefined reference to +# `BRepAlgo_Loop::BRepAlgo_Loop()' +# while the ordinary Linux, macOS and Windows links resolved it anyway. Use set() rather +# than list(PREPEND), which needs CMake 3.15 and this project supports 3.13. +if (SLIC3R_CAD) + set(OCCT_LIBS TKFillet TKOffset TKBool ${OCCT_LIBS}) + # deps is configured separately, so its SLIC3R_CAD can differ from ours. The module is + # all-or-nothing, so one absent toolkit proves it; fail here rather than at link time. + if (NOT TARGET TKFillet) + message(FATAL_ERROR + "SLIC3R_CAD is ON, but the OpenCASCADE in ${CMAKE_PREFIX_PATH} was built without " + "BUILD_MODULE_ModelingAlgorithms. Rebuild the dependencies with -DSLIC3R_CAD=ON, " + "or configure this project with -DSLIC3R_CAD=OFF.") + endif () +endif () +# Published for the Windows packaging step in the top-level CMakeLists.txt. +set(OCCT_LIBS "${OCCT_LIBS}" CACHE INTERNAL "OCCT toolkits linked by libslic3r") target_link_libraries(libslic3r PUBLIC @@ -671,6 +723,10 @@ if (TARGET OpenVDB::openvdb) target_link_libraries(libslic3r PRIVATE OpenVDB::openvdb) endif() +if (SLIC3R_CAD) + target_link_libraries(libslic3r PUBLIC SLVS::slvs) +endif () + if(WIN32) target_link_libraries(libslic3r PRIVATE Psapi.lib bcrypt.lib) endif() diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index e2091da1db..09899aa4c2 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -175,6 +175,10 @@ const std::string BBS_MODEL_CONFIG_RELS_FILE = "Metadata/_rels/model_settings.co const std::string SLICE_INFO_CONFIG_FILE = "Metadata/slice_info.config"; const std::string FILAMENT_SEQUENCE_FILE = "Metadata/filament_sequence.json"; const std::string BBS_LAYER_HEIGHTS_PROFILE_FILE = "Metadata/layer_heights_profile.txt"; +const std::string ORCA_CAD_RECIPE_FILE = "Metadata/orca_cad.bin"; +// Read-only: the recipe entry's pre-rename name. A reader that knows only the new one drops the +// feature tree of every project written before the move, without a word. Never written. +const std::string LEGACY_CAD_RECIPE_FILE = "Metadata/SnapOrca_cad.bin"; const std::string LAYER_CONFIG_RANGES_FILE = "Metadata/layer_config_ranges.xml"; const std::string BRIM_EAR_POINTS_FILE = "Metadata/brim_ear_points.txt"; /*const std::string SLA_SUPPORT_POINTS_FILE = "Metadata/Slic3r_PE_sla_support_points.txt"; @@ -1950,6 +1954,15 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // extract slic3r print config file _extract_project_config_from_archive(archive, stat, config, config_substitutions, model); } + else if (boost::algorithm::iequals(name, ORCA_CAD_RECIPE_FILE) + || boost::algorithm::iequals(name, LEGACY_CAD_RECIPE_FILE)) { + // Restore the editable CAD recipe (optional; absent in non-CAD projects). + if (stat.m_uncomp_size > 0) { + std::string buf((size_t)stat.m_uncomp_size, '\0'); + if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, buf.data(), buf.size(), 0)) + model.cad_recipe = std::move(buf); + } + } else if (boost::algorithm::iequals(name, CUT_INFORMATION_FILE)) { // extract object cut info _extract_cut_information_from_archive(archive, stat, config_substitutions); @@ -6008,6 +6021,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool _add_mesh_to_object_stream(std::function const &flush, ObjectData const &object_data) const; bool _add_build_to_model_stream(std::stringstream& stream, const BuildItemsList& build_items) const; bool _add_layer_height_profile_file_to_archive(mz_zip_archive& archive, Model& model); + bool _add_cad_recipe_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_layer_config_ranges_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_brim_ear_points_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_sla_support_points_file_to_archive(mz_zip_archive& archive, Model& model); @@ -6404,6 +6418,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return false; } + if (!_add_cad_recipe_file_to_archive(archive, model)) { + close_zip_writer(&archive); + return false; + } + // BBS progress point /*BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format("export 3mf EXPORT_STAGE_ADD_LAYER_RANGE\n"); if (proFn) { @@ -7658,6 +7677,19 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return true; } + bool _BBS_3MF_Exporter::_add_cad_recipe_file_to_archive(mz_zip_archive& archive, Model& model) + { + if (model.cad_recipe.empty()) + return true; + if (!mz_zip_writer_add_mem(&archive, ORCA_CAD_RECIPE_FILE.c_str(), + (const void*)model.cad_recipe.data(), model.cad_recipe.length(), + MZ_DEFAULT_COMPRESSION)) { + add_error("Unable to add CAD recipe file to archive"); + return false; + } + return true; + } + bool _BBS_3MF_Exporter::_add_layer_config_ranges_file_to_archive(mz_zip_archive& archive, Model& model) { std::string out = ""; diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 714d45ad9b..209ccdafa2 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -108,6 +108,8 @@ Model& Model::assign_copy(const Model &rhs) this->md_value = rhs.md_value; this->texture_mesh = rhs.texture_mesh; + this->cad_recipe = rhs.cad_recipe; + return *this; } @@ -152,6 +154,7 @@ Model& Model::assign_copy(Model &&rhs) rhs.model_info.reset(); this->profile_info = rhs.profile_info; rhs.profile_info.reset(); + this->cad_recipe = std::move(rhs.cad_recipe); return *this; } diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 8da1340fa9..815b93c362 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -1569,6 +1569,10 @@ public: std::vector md_name; std::vector md_value; + // Opaque parametric CAD recipe (CadDocument::serialize_recipe()), round-tripped through + // the 3MF as Metadata/orca_cad.bin. Empty for non-CAD projects. + std::string cad_recipe; + void SetDesigner(std::string designer, std::string designer_user_id) { if (design_info == nullptr) { design_info = std::make_shared(); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 656d41f338..e924c317cc 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -788,6 +788,30 @@ set(SLIC3R_GUI_SOURCES Utils/wxInspectorPlugins/Registration.hpp ) +# Design/CAD tab: parametric sketch UI, its gizmos, and the MCP control socket. +# All of it sits behind SLIC3R_CAD and links the CAD kernel in libslic3r. +if (SLIC3R_CAD) + list(APPEND SLIC3R_GUI_SOURCES + GUI/CAD/DesignPanel.cpp + GUI/CAD/DesignPanel.hpp + GUI/CAD/DesignCanvas.cpp + GUI/CAD/DesignCanvas.hpp + GUI/CAD/DesignSketchTool.cpp + GUI/CAD/DesignSketchTool.hpp + GUI/CAD/DesignOffer.hpp + GUI/CAD/DesignInteraction.hpp + GUI/CAD/SketchInlineEditor.cpp + GUI/CAD/SketchInlineEditor.hpp + GUI/CAD/McpControl.cpp + GUI/CAD/McpControl.hpp + GUI/Gizmos/GLGizmoSketch.cpp + GUI/Gizmos/GLGizmoSketch.hpp + # Needs GeometryEngine (make_primitive / apply_fillet / tessellate). + GUI/Gizmos/GLGizmoPrimitive.cpp + GUI/Gizmos/GLGizmoPrimitive.hpp + ) +endif () + add_subdirectory(GUI/DeviceCore) add_subdirectory(GUI/DeviceTab) diff --git a/src/slic3r/GUI/3DBed.hpp b/src/slic3r/GUI/3DBed.hpp index b791635fd3..e6335fba3a 100644 --- a/src/slic3r/GUI/3DBed.hpp +++ b/src/slic3r/GUI/3DBed.hpp @@ -134,6 +134,7 @@ public: void set_position(Vec2d& position); void set_axes_mode(bool origin); + void set_axes_origin(const Vec3d& origin) { m_axes.set_origin(origin); } // Design tab: triad at bed centre const Vec2d& get_position() const { return m_position; } // Build volume geometry for various collision detection tasks. diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp new file mode 100644 index 0000000000..8db2afbbc2 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -0,0 +1,1673 @@ +#include "slic3r/GUI/CAD/DesignCanvas.hpp" + +#include "slic3r/GUI/CAD/SketchInlineEditor.hpp" +#include "slic3r/GUI/CAD/DesignInteraction.hpp" +#include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/OpenGLManager.hpp" +#include "slic3r/GUI/3DBed.hpp" +#include "slic3r/GUI/Camera.hpp" // N: look down the sketch plane normal +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Plater.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "slic3r/GUI/3DScene.hpp" +#include "slic3r/GUI/MeshUtils.hpp" // ClippingPlane (section view) +#include "libslic3r/Config.hpp" +#include +#include +#include + +#include +#include // wxGetLocalTimeMillis: the right-click vs right-hold budget +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +DesignCanvas::DesignCanvas(wxWindow* parent) + : wxPanel() +{ + if (!Create(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0)) + return; + + m_canvas_widget = OpenGLManager::create_wxglcanvas(*this); + if (m_canvas_widget == nullptr) + return; + + m_canvas = new GLCanvas3D(m_canvas_widget, m_bed); + m_canvas->set_context(wxGetApp().init_glcontext(*m_canvas_widget)); + m_canvas->allow_multisample(OpenGLManager::can_multisample()); + m_canvas->set_config(wxGetApp().plater()->config()); + m_canvas->set_model(&m_model); + // Nothing to slice here, but GLCanvas3D derefs the process unguarded every frame, so it + // cannot be null. Borrow the plater's, as the editor canvases do. + m_canvas->set_process(&wxGetApp().plater()->background_process()); + m_canvas->set_type(GLCanvas3D::ECanvasType::CanvasView3D); + + // CAD navigation, this canvas only: left-drag sweeps a selection rubber band, so orbit + // moves to middle-drag and pan to right-drag. Design is a different modality from + // Prepare/Preview and every CAD the user already knows maps the mouse this way; the other + // tabs are untouched. + m_canvas->set_cad_navigation(true); + + m_canvas->enable_picking(false); // viewport face/edge picking is custom (TODO) + m_canvas->enable_moving(false); + m_canvas->enable_gizmos(false); + m_canvas->enable_selection(false); // stock volume selection unused; solid highlight is tree-driven + m_canvas->enable_main_toolbar(false); + m_canvas->enable_select_plate_toolbar(false); + m_canvas->enable_assemble_view_toolbar(false); + m_canvas->enable_separator_toolbar(false); + m_canvas->enable_collapse_toolbar(false); + m_canvas->enable_plate_chrome(false); + m_canvas->enable_labels(false); + m_canvas->set_axes_at_bed_center(true); // triad at bed centre = modeling origin + + m_canvas->set_design_sketch_tool(&m_sketch_tool); + m_sketch_tool.on_commit = [this](const SketchProfile& prof, const SketchPlane& pl) { + if (m_on_sketch_commit) m_on_sketch_commit(prof, pl); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); + }; + m_sketch_tool.on_commit_entities = [this](const std::vector& ents, + const std::vector& cons, + const SketchPlane& pl) { + if (m_on_sketch_entities_commit) m_on_sketch_entities_commit(ents, cons, pl); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); + }; + + // Onshape-style in-canvas value editor, floating over the GL canvas. The tool hands + // us a screen pixel (device px) + a commit/cancel pair; we convert to logical client + // px and wrap the callbacks so each one re-solves and re-renders the viewport. + m_inline_editor = std::make_unique(); + // The tool draws it: it owns the frame's ImGui pass and the render scale. Handing it a raw + // pointer rather than the unique_ptr keeps the ownership where it was. + m_sketch_tool.inline_editor = m_inline_editor.get(); + // SCHEDULE a paint, do not render one. request_repaint() renders SYNCHRONOUSLY on software + // GL, and this callback runs from inside DesignSketchTool::render() — so using it here asks + // for a render from within a render. The frames stopped after nine, which is what a + // re-entrancy guard giving up looks like. Refresh() posts a paint event instead: the current + // frame finishes, the event loop runs (which is where ImGui's queued characters are consumed), + // and the next frame starts clean. + // MEASUREMENT: does a typed character reach the GL canvas at all? Everything downstream of + // this point is known good (ImGui reports want_text=1 and our InputText active), so if these + // lines do not appear the character never got past the panel's CHAR_HOOK / the focus chain, + // and no amount of work inside the field will help. Skips always: a pure observer. + if (m_canvas_widget != nullptr && std::getenv("ORCA_CAD_UXTRACE")) { + m_canvas_widget->Bind(wxEVT_CHAR, [](wxKeyEvent& e) { + fprintf(stderr, "[UX] canvas_char key=%d\n", e.GetKeyCode()); + fflush(stderr); + e.Skip(); + }); + } + m_inline_editor->request_frame = [this] { + // BOTH halves, and the dirty flag first: GLCanvas3D's paint handler returns without + // rendering when the canvas is not marked dirty, so a bare Refresh() posts an event that + // draws nothing and the frames still stop. request_repaint() does exactly this pair on + // the hardware path; what it must NOT do here is its software path, which renders + // synchronously — and this callback runs from inside render(). + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(false); + }; + m_sketch_tool.on_inline_edit = [this](wxPoint screen_px, double current, + const std::string& title, + std::function commit, + std::function cancel) { + if (!m_inline_editor) { if (cancel) cancel(); return; } + // The tool hands us canvas device px and the field is now drawn IN the canvas, so this + // is already the coordinate space it wants — no conversion to screen coordinates, and no + // window to place there. + // Freeze the sketch tool while the field is open so a stray click/move on the GL + // canvas can't draw under the field; released on commit or cancel. + m_sketch_tool.set_inline_busy(true); + // AND PUT THE KEYBOARD ON THE CANVAS. The field is drawn by ImGui, and ImGui is fed from + // GLCanvas3D's own key handler, so a key only reaches it if the canvas is the focused + // widget. That is a focus move WITHIN one window — the toolkit's business, not the window + // manager's, which is the whole point of not being a window any more — but it still has + // to be asked for: after a toolbar click or a tree selection the focus is elsewhere in + // the panel, and the field would sit there taking nothing. + if (m_canvas_widget) m_canvas_widget->SetFocus(); + m_inline_editor->open(screen_px, current, title, + [this, commit](double v) { + m_sketch_tool.set_inline_busy(false); + if (commit) commit(v); + request_repaint(); + }, + [this, cancel]() { + m_sketch_tool.set_inline_busy(false); + if (cancel) cancel(); + request_repaint(); + }); + }; + // Let the tool force-close the field (keep-as-drawn) — polyline right-click/double-click + // ends the chain even while a per-segment value field is open. + m_sketch_tool.on_inline_dismiss = [this]() { + if (m_inline_editor) m_inline_editor->cancel(); + }; + m_sketch_tool.on_inline_commit = [this]() { + if (m_inline_editor) m_inline_editor->commit(); + }; + + // Bottom-right viewport HUD: a borderless, non-focusable float label showing the active + // tool's current values. Top-level (a child widget is hidden by the GL surface, same as + // the inline editor). Fed every frame by the tool's on_readout; empty text hides it. + // NON-FOCUSABLE IS THE LOAD-BEARING WORD, and a wxFrame is not: see the header. The chip + // outlives the gesture that drew it, and while it held the X input focus every sketch + // shortcut was swallowed until the user clicked the canvas. Same window class as the status + // chip below for the same reason. Do not "simplify" it back to a wxFrame. + { + wxWindow* top = wxGetTopLevelParent(m_canvas_widget); + m_hud = new wxPopupWindow(top, wxBORDER_NONE); + m_hud->SetBackgroundColour(wxColour(28, 30, 34)); + m_hud_label = new wxStaticText(m_hud, wxID_ANY, wxEmptyString); + m_hud_label->SetForegroundColour(wxColour(0x46, 0xE0, 0xC8)); // teal, reads on dark bed + wxFont f = m_hud_label->GetFont(); f.MakeBold(); m_hud_label->SetFont(f); + auto* hs = new wxBoxSizer(wxHORIZONTAL); + hs->Add(m_hud_label, 0, wxALL, 6); + m_hud->SetSizerAndFit(hs); + m_hud->Hide(); + } + m_sketch_tool.on_readout = [this](const std::string& s) { set_readout(s); }; + + // Bottom-LEFT twin, carrying the status line. Top-level for the same reason as the readout + // (a child widget is hidden by the GL surface) but a wxPopupWindow rather than a wxFrame, + // because a popup cannot take keyboard focus. The readout gets away with a frame only + // because it appears mid-gesture and the next input is the mouse; this one is up + // permanently and is re-raised on every status change. As a frame it took the WM's focus + // each time and the canvas stopped receiving keys at all — every sketch shortcut silently + // dead, which reads as a broken tool. Do not "simplify" it back to a wxFrame. + // Its colour is set per message — the panel decides whether a line is neutral or an error. + { + wxWindow* top = wxGetTopLevelParent(m_canvas_widget); + m_status_hud = new wxPopupWindow(top, wxBORDER_NONE); + m_status_hud->SetBackgroundColour(wxColour(28, 30, 34)); + m_status_hud_label = new wxStaticText(m_status_hud, wxID_ANY, wxEmptyString); + auto* ss = new wxBoxSizer(wxHORIZONTAL); + // The line never wraps — there is a whole window's width down here — so the chip is + // ONE LINE tall. Spacers rather than a wxALL border because the two axes want + // different numbers: roomy at the sides so it reads as a label, and just enough top + // and bottom to clear the descenders. Zero vertical clips the glyphs; 6 (what the + // readout chip uses) makes it look like a two-line box. + ss->AddSpacer(10); + ss->Add(m_status_hud_label, 0, wxTOP | wxBOTTOM, 3); + ss->AddSpacer(10); + m_status_hud->SetSizerAndFit(ss); + m_status_hud->Hide(); + } + // A floating frame does not follow its parent, so the anchor has to be recomputed whenever + // the canvas changes size (that bind is below bind_event_handlers(), for the reason given + // there). The readout HUD gets away without this because it is transient; the status line is + // on screen almost permanently and would visibly detach. + // ...and it does not follow the WINDOW either. A popup is override-redirect: the window + // manager does not own it, so minimising the app leaves the chip sitting on the bare desktop + // (seen on the rig: whole screen black, chip still there), and it stacks above other + // applications rather than behind them. IsShownOnScreen does not catch this — an iconised + // frame still counts as shown — so the frame has to say so itself. Deactivating the app is + // the same case one step weaker: the chip belongs to a viewport the user is no longer + // looking at. Showing it back is safe because a popup cannot take focus, so neither event + // can be re-triggered by our own Show(). + // Members rather than lambdas so unbind_canvas_event_handlers() can Unbind them: these sit on + // a frame that OUTLIVES this canvas, and a lambda cannot be unbound. + if (wxWindow* top = wxGetTopLevelParent(m_canvas_widget)) { + top->Bind(wxEVT_ICONIZE, &DesignCanvas::on_frame_iconize, this); + top->Bind(wxEVT_ACTIVATE, &DesignCanvas::on_frame_activate, this); + // The anchor is an ABSOLUTE SCREEN position (ClientToScreen below), so moving the window + // moves the canvas out from under a chip that stays where it was. Dragging the frame by + // its title bar left the chip stranded mid-viewport until the next size, status or tab + // change happened to re-place it. Nothing on the canvas fires for a move that does not + // also resize, so it has to come from the frame. + top->Bind(wxEVT_MOVE, &DesignCanvas::on_status_hud_reanchor, this); + } + + refresh_bed(); + + // The view this canvas opens on. Built lazily, on the way into the Design tab, so this + // is the view the user is looking at right now. + m_parked_camera = wxGetApp().plater()->get_camera(); + + // Before any of this class's own Binds below: wx calls dynamically bound handlers in + // reverse order of binding, and GLCanvas3D swallows several events without skipping them — + // wxEVT_RIGHT_UP and wxEVT_ENTER_WINDOW in on_mouse, and wxEVT_SIZE in on_size, which is + // just `m_dirty = true;`. For those, whatever is bound LAST is the only handler that runs. + // The context menu, the focus-follows-mouse and the status-chip re-anchor all depend on + // running first, which is only true while this call stays ahead of them. + m_canvas->bind_event_handlers(); + + // The status-chip re-anchor promised above, bound AFTER the call so it runs first — ahead of + // it the handler never ran at all, leaving a stale anchor and wrap width after any resize + // that did not also move the frame or change the text. Its e.Skip() is load-bearing the + // other way: it falls through to on_size, which is what still marks the canvas dirty. + m_canvas_widget->Bind(wxEVT_SIZE, &DesignCanvas::on_status_hud_reanchor, this); + + // The Design GL canvas only receives key events (Esc to exit/enter Select, Ctrl+Z undo) + // while it holds keyboard focus. Clicking a side-panel button steals focus, after which + // Esc/Ctrl+Z silently do nothing until the viewport is clicked again. Restore focus + // whenever the pointer enters the viewport (focus-follows-mouse, standard CAD behaviour). + m_canvas_widget->Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& e) { + // …but NOT while an inline value field is open: the field floats over the canvas, so + // the smallest pointer jiggle re-enters the viewport and would yank focus off the + // field (the "no cursor focus on the number, click to focus" bug). + if (m_canvas_widget && !m_sketch_tool.inline_busy()) m_canvas_widget->SetFocus(); + e.Skip(); + }); + + + auto* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(m_canvas_widget, 1, wxEXPAND); + SetSizer(sizer); + SetMinSize(wxSize(300, 300)); +} + +// Both are idempotent, and neither destroys anything: the destructor still owns that. +void DesignCanvas::unbind_canvas_event_handlers() +{ + if (wxWindow* top = wxGetTopLevelParent(m_canvas_widget)) { + top->Unbind(wxEVT_ICONIZE, &DesignCanvas::on_frame_iconize, this); + top->Unbind(wxEVT_ACTIVATE, &DesignCanvas::on_frame_activate, this); + top->Unbind(wxEVT_MOVE, &DesignCanvas::on_status_hud_reanchor, this); + } + // Before the popups go down, or a resize still in flight re-places and re-shows the chip. + if (m_canvas_widget) + m_canvas_widget->Unbind(wxEVT_SIZE, &DesignCanvas::on_status_hud_reanchor, this); + // A popup is override-redirect: it does not go down with the frame, so one left showing sits + // on the bare desktop for however long the teardown takes. + show_status_hud(false); + if (m_hud) m_hud->Hide(); + if (m_canvas) m_canvas->unbind_event_handlers(); +} + +void DesignCanvas::reset_canvas_volumes() +{ + if (m_canvas) m_canvas->reset_volumes(); +} + +DesignCanvas::~DesignCanvas() +{ + if (m_hud) m_hud->Destroy(); + delete m_canvas; + delete m_canvas_widget; +} + +// Distinct per-body colours (Onshape-style). Body 0 keeps the familiar gold; the rest +// cycle through a small saturated palette so coexisting solids read as separate parts. +static ColorRGBA body_palette(int body_idx) +{ + static const ColorRGBA kPalette[] = { + ColorRGBA(0.86f, 0.66f, 0.20f, 1.0f), // gold + ColorRGBA(0.30f, 0.62f, 0.90f, 1.0f), // blue + ColorRGBA(0.45f, 0.78f, 0.42f, 1.0f), // green + ColorRGBA(0.86f, 0.45f, 0.40f, 1.0f), // coral + ColorRGBA(0.70f, 0.52f, 0.86f, 1.0f), // violet + ColorRGBA(0.90f, 0.70f, 0.35f, 1.0f), // amber + }; + const int n = int(sizeof(kPalette) / sizeof(kPalette[0])); + return kPalette[((body_idx % n) + n) % n]; +} + +void DesignCanvas::request_repaint() +{ + if (!m_canvas) + return; + m_canvas->set_as_dirty(); + + // NOTHING may touch GL before the canvas has initialised it. The backend probe below calls + // OpenGLManager::get_gl_info().get_renderer(), which runs GLInfo::detect() -> glGetString + // with no context current and, before init_opengl(), no loaded function pointers — a + // segfault at startup with no window and nothing in the log. Anything that asks for a + // repaint while the panel is still being built lands here, so the guard belongs at the top + // rather than around the render() call: the crash was in the PROBE, not in the paint. + if (!m_canvas->is_initialized()) { + if (m_canvas_widget) + m_canvas_widget->Refresh(); // the first real paint draws the current state anyway + return; + } + + if (m_sw_gl < 0) { + // Cache the backend once it's known; the renderer string is empty until + // GL is initialised, so stay "unknown" and take the safe direct path till then. + const std::string& r = OpenGLManager::get_gl_info().get_renderer(); + if (!r.empty()) + m_sw_gl = (boost::icontains(r, "llvmpipe") || boost::icontains(r, "softpipe") || + boost::icontains(r, "swrast") || boost::icontains(r, "software")) ? 1 : 0; + } + + if (m_sw_gl == 0) { + if (m_canvas_widget) + m_canvas_widget->Refresh(); // hardware GL: paint cycle drives render() + } else { + m_canvas->render(); // software GL or backend not yet known + } +} + +void DesignCanvas::enter_viewport() +{ + if (!m_camera_swapped) swap_camera(); +} + +void DesignCanvas::leave_viewport() +{ + if (m_camera_swapped) swap_camera(); +} + +void DesignCanvas::swap_camera() +{ + Plater* plater = wxGetApp().plater(); + if (plater == nullptr) return; + std::swap(plater->get_camera(), m_parked_camera); + m_camera_swapped = !m_camera_swapped; +} + +void DesignCanvas::force_repaint() +{ + if (m_canvas == nullptr || m_canvas_widget == nullptr) + return; + + CallAfter([this]() { + if (m_canvas == nullptr || m_canvas_widget == nullptr) + return; + m_canvas->set_as_dirty(); + m_canvas_widget->Refresh(); + m_canvas_widget->Update(); // synchronous: an expose may never come after a page show + }); +} + +void DesignCanvas::repaint_now() +{ + if (m_canvas == nullptr || m_canvas_widget == nullptr) + return; + request_repaint(); // mark dirty + Refresh (hardware GL) or render (software GL) + m_canvas_widget->Update(); // service the pending paint immediately (a modal popup owns the loop) +} + +void DesignCanvas::reload(bool keep_view) +{ + m_canvas->reset_volumes(); + + for (int i = 0; i < (int)m_model.objects.size(); ++i) + m_canvas->load_object(m_model, i); + + const ColorRGBA sel_gold = design_selection_color(); // same colour as every other selection + const ColorRGBA ghost(0.26f, 0.66f, 1.0f, 0.45f); + + const auto& volumes = m_canvas->get_volumes().volumes; + for (auto* v : volumes) { + int obj_idx = v->object_idx(); + if (obj_idx == 0) { + // Object 0 holds one volume per body — colour each by its body index so + // multiple coexisting solids are visually distinct (Onshape per-part colour). + const int b = v->volume_idx(); + bool hidden = (b >= 0 && b < int(m_body_visible.size())) && !m_body_visible[b]; + // Preview-only mode (fillet/chamfer/draft, once a valid target is picked): hide + // every base body so only the result ghost is on screen until Confirm. + if (m_body_hidden) hidden = true; + v->is_active = !hidden; // per-body visibility toggle + if (!hidden) { + // An EXPLICIT colour outranks the selection tint. m_body_selected is a + // document-wide flag raised whenever a non-Sketch feature row is selected — + // the normal resting state after any modelling operation — so painting every + // body gold on it made the Color tool look broken: the override was written, + // carried across recompute and read back correctly, and then overpainted here + // every single frame. A body the user deliberately coloured keeps its colour; + // the rest still tint, which is all the tint was ever for. + const bool overridden = m_color_bodies != nullptr && b >= 0 + && b < int(m_color_bodies->size()) + && (*m_color_bodies)[b].has_color; + ColorRGBA c = (m_body_selected && !overridden) ? sel_gold : body_color(b); + if (b == m_hl_body_target) c = ColorRGBA(0.30f, 0.90f, 0.70f, 1.0f); // target = teal-green + else if (b == m_hl_body_tool) c = ColorRGBA(1.00f, 0.55f, 0.15f, 1.0f); // tool = orange + if (m_body_translucent) c.a(0.30f); + else if (m_xray_focus >= 0 && b != m_xray_focus) c.a(0.25f); + v->set_color(c); + } + } else if (obj_idx == 1) { + // The ghost is normally a faint blue overlay on the visible body. In preview-only + // mode it IS the result (base bodies hidden), so render it opaque so it reads as a + // finished solid rather than a see-through hint. + v->set_color(m_body_hidden ? ColorRGBA(0.40f, 0.82f, 1.0f, 1.0f) : ghost); + } + } + + if (!keep_view) { + if (m_first_frame && !m_model.objects.empty()) { + m_canvas->select_view("iso"); + m_canvas->zoom_to_volumes(); + m_first_frame = false; + } + } + + m_canvas->set_as_dirty(); + if (m_canvas_widget) + m_canvas_widget->Refresh(); +} + +void DesignCanvas::set_mesh(const TriangleMesh& mesh) +{ + if (m_model.objects.empty()) { + auto* obj = m_model.add_object(); + obj->add_volume(mesh); + obj->add_instance(); + } else { + ModelObject* obj = m_model.objects.front(); + obj->clear_volumes(); + obj->add_volume(mesh); + if (obj->instances.empty()) + obj->add_instance(); + } + + reload(!m_first_frame); +} + +void DesignCanvas::set_bodies(const std::vector& body_meshes, + const std::vector& visible) +{ + // Object 0 carries one GLVolume per body so reload() can colour each distinctly. + // Falls back to a single-volume object when there's only one body (identical look + // to the old set_mesh path). Picking still uses the combined mesh via set_solid_pick. + m_body_visible = visible; // empty => all visible; reload() reads this per volume + if (body_meshes.empty()) { clear_mesh(); return; } + + ModelObject* obj = m_model.objects.empty() ? m_model.add_object() + : m_model.objects.front(); + obj->clear_volumes(); + for (const TriangleMesh& m : body_meshes) + obj->add_volume(m); + if (obj->instances.empty()) + obj->add_instance(); + + reload(!m_first_frame); +} + +void DesignCanvas::clear_mesh() +{ + if (!m_model.objects.empty()) { + m_model.delete_object((size_t)0); + reload(true); + } +} + +void DesignCanvas::set_preview_mesh(const TriangleMesh& mesh) +{ + // Remove existing ghost (object 1) if present + if (m_model.objects.size() > 1) + m_model.delete_object((size_t)1); + + auto* obj = m_model.add_object(); + obj->add_volume(mesh); + obj->add_instance(); + + reload(true); +} + +void DesignCanvas::clear_preview() +{ + if (m_model.objects.size() > 1) { + m_model.delete_object((size_t)1); + reload(true); + } +} + +void DesignCanvas::fit_view() +{ + if (m_canvas && !m_model.objects.empty()) { + m_canvas->zoom_to_volumes(); + m_canvas->set_as_dirty(); + if (m_canvas_widget) + m_canvas_widget->Refresh(); + } +} + +void DesignCanvas::set_view(const std::string& view_name) +{ + if (m_canvas) { + m_canvas->select_view(view_name); + m_canvas->zoom_to_volumes(); + m_canvas->set_as_dirty(); + if (m_canvas_widget) + m_canvas_widget->Refresh(); + } +} + +void DesignCanvas::begin_sketch(const SketchPlane& plane, DesignSketchTool::Mode mode) +{ + m_sketch_tool.begin(plane, mode); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); +} + +void DesignCanvas::set_sketch_plane(const SketchPlane& plane) +{ + m_sketch_tool.set_plane(plane); // keeps the 2D entities; only the carrier plane changes + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); +} + +void DesignCanvas::edit_sketch(const std::vector& entities, + const std::vector& constraints, + const SketchPlane& plane) +{ + m_sketch_tool.begin_edit(entities, constraints, plane); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); +} + +void DesignCanvas::set_sketch_tool(DesignSketchTool::Mode mode) +{ + m_sketch_tool.set_tool(mode); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); +} + +void DesignCanvas::set_sketch_construction(bool c) +{ + m_sketch_tool.set_construction(c); +} + +bool DesignCanvas::edit_sketch_selection_value() +{ + const bool ok = m_sketch_tool.open_selection_dimension_editor(); + if (ok) request_repaint(); + return ok; +} + +int DesignCanvas::toggle_sketch_construction_selection() +{ + const int n = m_sketch_tool.toggle_selection_construction(); + if (n > 0) request_repaint(); + return n; +} + +bool DesignCanvas::add_sketch_regions( + const std::vector>>& regions) +{ + const bool ok = m_sketch_tool.add_imported_regions(regions); + if (ok) request_repaint(); + return ok; +} + +void DesignCanvas::set_sketch_polygon_sides(int n) +{ + m_sketch_tool.set_polygon_sides(n); +} + +void DesignCanvas::set_sketch_polygon_circumscribed(bool c) +{ + m_sketch_tool.set_polygon_circumscribed(c); +} + +void DesignCanvas::finish_sketch() +{ + m_sketch_tool.finish(); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); +} + +// Sync the Design bed to the CURRENT printer bed. Done on every tab activation, not just at +// construction: the panel is built early (before the active printer profile is fully applied), +// so a one-shot read picked up the 200x200 default while the real bed (e.g. 270x270) only +// loaded later — leaving the PartPlate grid spilling past the smaller bed quad. +void DesignCanvas::refresh_bed() +{ + const DynamicPrintConfig* config = wxGetApp().plater()->config(); + if (!config) return; + const auto* bed_shape_opt = config->opt("printable_area"); + if (!bed_shape_opt) return; + double printable_height = 100.0; + const auto* ph_opt = config->opt("printable_height"); + if (ph_opt) printable_height = ph_opt->value; + m_bed.set_shape(bed_shape_opt->values, printable_height, {}, {}, "", false); // mainline added extruder_areas/heights params +} + +void DesignCanvas::set_show_bed(bool b) +{ + if (!m_canvas) return; + if (m_canvas->get_show_bed() == b) return; // no repaint for a no-op toggle + m_canvas->set_show_bed(b); + request_repaint(); +} + +bool DesignCanvas::is_sketching() const { return m_sketch_tool.is_active(); } + +void DesignCanvas::cancel_sketch() +{ + m_sketch_tool.cancel(); + if (m_canvas) m_canvas->set_as_dirty(); + if (m_canvas_widget) m_canvas_widget->Refresh(); +} + +void DesignCanvas::set_on_sketch_commit(std::function cb) +{ + m_on_sketch_commit = std::move(cb); +} + +void DesignCanvas::set_on_sketch_entities_commit( + std::function&, + const std::vector&, + const SketchPlane&)> cb) +{ + m_on_sketch_entities_commit = std::move(cb); +} + +void DesignCanvas::set_on_segment_drawn(std::function cb) +{ + m_sketch_tool.on_segment_drawn = std::move(cb); +} + +void DesignCanvas::set_on_cursor_metrics(std::function cb) +{ + m_sketch_tool.on_cursor_metrics = std::move(cb); +} + +void DesignCanvas::set_on_solve_state(std::function cb) +{ + m_sketch_tool.on_solve_state = std::move(cb); +} + +void DesignCanvas::set_on_sketch_step(std::function cb) +{ + m_sketch_tool.on_step_changed = std::move(cb); +} + +void DesignCanvas::apply_segment_length(double len) +{ + m_sketch_tool.apply_segment_length(len); + request_repaint(); +} + +void DesignCanvas::keep_segment_as_drawn() +{ + m_sketch_tool.keep_segment_as_drawn(); + request_repaint(); +} + +void DesignCanvas::set_on_sketch_selection_changed(std::function cb) +{ + m_sketch_tool.on_selection_changed = std::move(cb); +} + +void DesignCanvas::set_on_sketch_face_selected(std::function cb) +{ + m_sketch_tool.on_face_selected = std::move(cb); +} + +void DesignCanvas::set_on_display_sketch_selected(std::function cb) +{ + m_sketch_tool.on_display_sketch_selected = std::move(cb); +} + +void DesignCanvas::set_on_display_sketch_activated(std::function cb) +{ + m_sketch_tool.on_display_sketch_activated = std::move(cb); +} + +std::vector DesignCanvas::selected_loop_entities() const +{ + return m_sketch_tool.selected_loop_entities(); +} + +std::vector> DesignCanvas::region_entity_indices(const std::vector& ents) const +{ + return m_sketch_tool.region_entity_indices(ents); +} + +std::vector> DesignCanvas::region_entity_indices_with_holes(const std::vector& ents) const +{ + return m_sketch_tool.region_entity_indices_with_holes(ents); +} + +void DesignCanvas::clear_loop_pick() +{ + m_sketch_tool.clear_display_pick(); +} + +void DesignCanvas::set_loop_pick(int feature, int region) +{ + m_sketch_tool.set_display_pick(feature, region); + request_repaint(); +} + +void DesignCanvas::set_escalate_on_repick(bool on) +{ + m_sketch_tool.set_escalate_on_repick(on); +} + +void DesignCanvas::set_solid_pick(const std::vector* bodies, const TriangleMesh* mesh, + const std::vector* tri_face, const std::vector* tri_body, + const std::vector* visible, + const std::vector* xform) +{ + m_color_bodies = bodies; // stable address (m_doc.bodies); reload() reads colour overrides + m_sketch_tool.set_solid_pick(bodies, mesh, tri_face, tri_body, visible, xform); +} + +// Effective display colour for a body: per-body override (Color tool) when set, else the +// auto body-index palette. body_palette() is the file-static helper defined above reload(). +ColorRGBA DesignCanvas::body_color(int body) const +{ + if (m_color_bodies != nullptr && body >= 0 && body < int(m_color_bodies->size()) + && (*m_color_bodies)[body].has_color) + return (*m_color_bodies)[body].color; + return body_palette(body); +} + +void DesignCanvas::begin_move_body(int body, const Vec3d& pivot, const Transform3d& base_xform, + double body_radius) +{ + m_sketch_tool.set_move_gizmo(body, pivot, base_xform, body_radius); + request_repaint(); +} + +void DesignCanvas::clear_move_gizmo() +{ + m_sketch_tool.clear_move_gizmo(); + request_repaint(); +} + +bool DesignCanvas::moving_body() const { return m_sketch_tool.moving_body(); } + +void DesignCanvas::set_on_body_move_changed(std::function cb) +{ + m_sketch_tool.on_body_move_changed = std::move(cb); +} + +bool DesignCanvas::begin_fillet_gizmo(const Vec3d& body_centroid, double radius) +{ + const bool ok = m_sketch_tool.set_fillet_gizmo(body_centroid, radius); + request_repaint(); + return ok; +} + +void DesignCanvas::clear_fillet_gizmo() +{ + m_sketch_tool.clear_fillet_gizmo(); + request_repaint(); +} + +bool DesignCanvas::filleting() const { return m_sketch_tool.filleting(); } + +void DesignCanvas::set_on_fillet_radius_changed(std::function cb) +{ + m_sketch_tool.on_fillet_radius_changed = std::move(cb); +} + +void DesignCanvas::begin_hole_gizmo(const SketchPlane& plane, double x, double y, + double diameter, double depth, bool through) +{ + m_sketch_tool.set_hole_gizmo(plane, x, y, diameter, depth, through); + request_repaint(); +} + +void DesignCanvas::set_hole_face_bounds(bool has, double umin, double umax, double vmin, double vmax) +{ + m_sketch_tool.set_hole_face_bounds(has, umin, umax, vmin, vmax); +} + +void DesignCanvas::clear_hole_gizmo() +{ + m_sketch_tool.clear_hole_gizmo(); + request_repaint(); +} + +bool DesignCanvas::holing() const { return m_sketch_tool.holing(); } + +void DesignCanvas::set_on_hole_changed(std::function cb) +{ + m_sketch_tool.on_hole_changed = std::move(cb); +} + +void DesignCanvas::begin_thread_gizmo(const SketchPlane& plane, double x, double y, + double radius, double height) +{ + m_sketch_tool.set_thread_gizmo(plane, x, y, radius, height); + request_repaint(); +} + +void DesignCanvas::clear_thread_gizmo() +{ + m_sketch_tool.clear_thread_gizmo(); + request_repaint(); +} + +bool DesignCanvas::threading() const { return m_sketch_tool.threading(); } + +void DesignCanvas::set_on_thread_changed(std::function cb) +{ + m_sketch_tool.on_thread_changed = std::move(cb); +} + +void DesignCanvas::begin_shell_gizmo(const Vec3d& face_centroid, const Vec3d& inward_dir, + double thickness) +{ + m_sketch_tool.set_shell_gizmo(face_centroid, inward_dir, thickness); + request_repaint(); +} + +void DesignCanvas::clear_shell_gizmo() +{ + m_sketch_tool.clear_shell_gizmo(); + request_repaint(); +} + +bool DesignCanvas::shelling() const { return m_sketch_tool.shelling(); } + +void DesignCanvas::set_on_shell_thickness_changed(std::function cb) +{ + m_sketch_tool.on_shell_thickness_changed = std::move(cb); +} + +void DesignCanvas::begin_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, + int axis_sel, double angle, bool flip) +{ + m_sketch_tool.set_revolve_gizmo(plane, centroid, axis_sel, angle, flip); + request_repaint(); +} + +void DesignCanvas::clear_revolve_gizmo() +{ + m_sketch_tool.clear_revolve_gizmo(); + request_repaint(); +} + +bool DesignCanvas::revolving() const { return m_sketch_tool.revolving(); } + +void DesignCanvas::set_on_revolve_angle_changed(std::function cb) +{ + m_sketch_tool.on_revolve_angle_changed = std::move(cb); +} + +void DesignCanvas::set_draft_gizmo(const Vec3d& face_centroid, const Vec3d& face_normal, double angle) +{ + m_sketch_tool.set_draft_gizmo(face_centroid, face_normal, angle); + request_repaint(); +} + +void DesignCanvas::clear_draft_gizmo() +{ + m_sketch_tool.clear_draft_gizmo(); + request_repaint(); +} + +bool DesignCanvas::drafting() const { return m_sketch_tool.drafting(); } + +void DesignCanvas::set_on_draft_angle_changed(std::function cb) +{ + m_sketch_tool.set_on_draft_angle_changed(std::move(cb)); +} + +void DesignCanvas::set_cut_gizmo(const SketchPlane& plane, double offset, const Vec3d& body_center, double half_extent) +{ + m_sketch_tool.set_cut_gizmo(plane, offset, body_center, half_extent); + request_repaint(); +} + +void DesignCanvas::clear_cut_gizmo() +{ + m_sketch_tool.clear_cut_gizmo(); + request_repaint(); +} + +bool DesignCanvas::cutting() const { return m_sketch_tool.cutting(); } + +void DesignCanvas::set_on_cut_offset_changed(std::function cb) +{ + m_sketch_tool.set_on_cut_offset_changed(std::move(cb)); +} + +void DesignCanvas::begin_pattern_gizmo(const SketchPlane& plane, const Vec3d& body_centroid, + bool circular, int count, int dir, double spacing, double angle) +{ + m_sketch_tool.set_pattern_gizmo(plane, body_centroid, circular, count, dir, spacing, angle); + request_repaint(); +} + +void DesignCanvas::clear_pattern_gizmo() +{ + m_sketch_tool.clear_pattern_gizmo(); + request_repaint(); +} + +bool DesignCanvas::patterning() const { return m_sketch_tool.patterning(); } + +void DesignCanvas::set_on_pattern_changed(std::function cb) +{ + m_sketch_tool.on_pattern_changed = std::move(cb); +} + +void DesignCanvas::set_on_solid_selection_changed(std::function cb) +{ + m_sketch_tool.on_solid_selection_changed = std::move(cb); +} + +void DesignCanvas::set_on_place_on_face(std::function cb) +{ + m_sketch_tool.on_place_on_face = std::move(cb); +} + +void DesignCanvas::select_body(int body) +{ + m_sketch_tool.select_body(body); + request_repaint(); +} + +void DesignCanvas::set_extrude_gizmo(const SketchPlane& plane, const Vec2d& centroid, + double depth, double depth2, bool two_sided, bool flip) +{ + m_sketch_tool.set_extrude_gizmo(plane, centroid, depth, depth2, two_sided, flip); + request_repaint(); +} + +void DesignCanvas::clear_extrude_gizmo() +{ + m_sketch_tool.clear_extrude_gizmo(); + request_repaint(); +} + +void DesignCanvas::set_on_extrude_depth_changed(std::function cb) +{ + m_sketch_tool.on_extrude_depth_changed = std::move(cb); +} + +void DesignCanvas::set_datum_gizmo(const SketchPlane& plane, double usize, double vsize, + const Vec3d& base_origin, const Vec3d& base_normal, + double offset, bool offset_on) +{ + m_sketch_tool.set_datum_gizmo(plane, usize, vsize, base_origin, base_normal, offset, offset_on); + request_repaint(); +} + +void DesignCanvas::clear_datum_gizmo() +{ + m_sketch_tool.clear_datum_gizmo(); + request_repaint(); +} + +void DesignCanvas::set_on_datum_size_changed(std::function cb) +{ + m_sketch_tool.on_datum_size_changed = std::move(cb); +} + +void DesignCanvas::set_on_datum_offset_changed(std::function cb) +{ + m_sketch_tool.on_datum_offset_changed = std::move(cb); +} + +void DesignCanvas::set_helix_gizmo(const SketchPlane& plane, double radius, double pitch, + double height, double taper, bool left_handed) +{ + m_sketch_tool.set_helix_gizmo(plane, radius, pitch, height, taper, left_handed); + request_repaint(); +} + +void DesignCanvas::clear_helix_gizmo() +{ + m_sketch_tool.clear_helix_gizmo(); + request_repaint(); +} + +void DesignCanvas::set_on_helix_changed(std::function cb) +{ + m_sketch_tool.on_helix_changed = std::move(cb); +} + +void DesignCanvas::set_rib_gizmo(const SketchPlane& plane, const Vec2d& p0, const Vec2d& p1, + double thickness) +{ + m_sketch_tool.set_rib_gizmo(plane, p0, p1, thickness); + request_repaint(); +} + +void DesignCanvas::clear_rib_gizmo() +{ + m_sketch_tool.clear_rib_gizmo(); + request_repaint(); +} + +void DesignCanvas::set_on_rib_thickness_changed(std::function cb) +{ + m_sketch_tool.on_rib_thickness_changed = std::move(cb); +} + +void DesignCanvas::set_base_pick(std::vector planes, std::vector bases, + std::vector labels) +{ + m_sketch_tool.set_base_pick(std::move(planes), std::move(bases), std::move(labels)); + request_repaint(); +} + +void DesignCanvas::clear_base_pick() +{ + m_sketch_tool.clear_base_pick(); + request_repaint(); +} + +void DesignCanvas::set_on_datum_base_picked(std::function cb) +{ + m_sketch_tool.on_datum_base_picked = std::move(cb); +} + +void DesignCanvas::set_on_sketch_exit(std::function cb) +{ + m_sketch_tool.on_exit = std::move(cb); +} + +void DesignCanvas::set_on_sketch_exit_refused(std::function cb) +{ + m_sketch_tool.on_exit_refused = std::move(cb); +} + +void DesignCanvas::set_on_move_exit(std::function cb) +{ + m_sketch_tool.on_move_exit = std::move(cb); +} + +void DesignCanvas::set_on_context_menu(std::function cb) +{ + m_on_context_menu = std::move(cb); + if (!m_canvas_widget || m_ctx_bound) + return; + m_ctx_bound = true; + // Bound AFTER GLCanvas3D's own handlers, so this runs first and can consume the event. + // It only consumes when it actually opens the offer; every other right-click still falls + // through to the polyline-chain end and the move gizmo, which were there first. + // Right-drag pans. Without remembering where the press landed, every pan ended by popping + // the offer over wherever the camera stopped — the menu appearing as the reward for moving + // the view. The offer is the release of a STATIONARY right-click, at the same 8 px budget + // the left-click pick uses. + m_canvas_widget->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { + m_ctx_press = e.GetPosition(); + m_ctx_press_ms = wxGetLocalTimeMillis().GetValue(); + e.Skip(); // the canvas still needs the press to seed the orbit + }); + m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { + const wxPoint d = e.GetPosition() - m_ctx_press; + const long long dt = wxGetLocalTimeMillis().GetValue() - m_ctx_press_ms; + // Always read-and-clear, even when another guard already rules the offer out, or a + // terminator recorded under one condition would still be pending under the next. + const bool terminated = m_sketch_tool.take_right_consumed(); + // Click, or navigation? Both budgets must hold: a press that travelled orbited, and a + // press that was HELD was aiming to orbit even if the hand never quite moved. Two + // independent budgets because the two failure modes are independent — the drift one + // alone still popped a menu at the end of a slow, careful orbit. + const bool is_click = std::max(std::abs(d.x), std::abs(d.y)) <= kCadRightClickDriftPx + && dt <= kCadRightClickMs; + if (m_on_context_menu && !terminated && !inline_busy() && is_click) { + // The menu belongs to what you POINTED AT — and pointing happened at the PRESS, not + // at the release, so the raycast uses the press position. Within a 3 px budget the + // two are the same pixel in practice; using the press is what makes that a + // guarantee rather than a coincidence. Pick first, so a right-click on a line offers + // that line's verbs instead of the empty-selection vocabulary. Selecting an entity + // that is already selected is a no-op, so a multi-entity pick survives a + // right-click on one of its members. + if (m_canvas && m_sketch_tool.select_at_screen(*m_canvas, m_ctx_press.x, m_ctx_press.y)) + request_repaint(); + m_on_context_menu(m_canvas_widget->ClientToScreen(m_ctx_press)); + return; // consumed + } + e.Skip(); + }); +} + +void DesignCanvas::set_on_undo_redo(std::function cb) +{ + m_sketch_tool.on_undo_redo = std::move(cb); +} + +void DesignCanvas::set_display_sketches(std::vector ds) +{ + m_sketch_tool.set_display_sketches(std::move(ds)); + // Overlay changed programmatically (no mouse event) — force a repaint. + request_repaint(); +} + +void DesignCanvas::set_datum_planes(std::vector planes, std::vector sizes) +{ + m_sketch_tool.set_datum_planes(std::move(planes), std::move(sizes)); + request_repaint(); +} + +void DesignCanvas::set_mate_connectors(std::vector g) +{ + m_sketch_tool.set_mate_connectors(std::move(g)); + request_repaint(); +} + +void DesignCanvas::set_mate_links(std::vector> l) +{ + m_sketch_tool.set_mate_links(std::move(l)); + request_repaint(); +} + +bool DesignCanvas::toggle_planes() +{ + const bool on = m_sketch_tool.toggle_show_planes(); + request_repaint(); + return on; +} + +bool DesignCanvas::toggle_axes() +{ + const bool on = m_sketch_tool.toggle_show_axes(); + request_repaint(); + return on; +} + +void DesignCanvas::set_section_plane(bool on, double z, bool keep_upper) +{ + m_section_on = on; + // The kept half must read as a SOLID part, never a see-through ghost: make sure no leftover + // preview translucency is applied while the section is on. Guarded — a no-op if already opaque. + if (on) { set_body_translucent(false); set_body_hidden(false); } + if (m_canvas) { + if (on) { + // GLCanvas3D turns the two clipping planes into a Z-RANGE: set_z_range(-p0.offset, + // p1.offset). keep_upper=false keeps the LOWER half (z in [-1e5, z]); keep_upper=true + // keeps the OPPOSITE, UPPER half (z in [z, +1e5]). Only HIDES geometry — no bodies. + if (keep_upper) { + m_canvas->set_clipping_plane(0, ClippingPlane(Vec3d(0.0, 0.0, 1.0), -z)); // min_z = z + m_canvas->set_clipping_plane(1, ClippingPlane(Vec3d(0.0, 0.0, 1.0), 1.0e5)); // max_z = +1e5 + } else { + m_canvas->set_clipping_plane(0, ClippingPlane(Vec3d(0.0, 0.0, 1.0), 1.0e5)); // min_z = -1e5 + m_canvas->set_clipping_plane(1, ClippingPlane(Vec3d(0.0, 0.0, 1.0), z)); // max_z = z + } + m_canvas->set_use_clipping_planes(true); + } else { + m_canvas->set_use_clipping_planes(false); + } + m_canvas->set_as_dirty(); + } + request_repaint(); +} + +double DesignCanvas::model_mid_z() const +{ + const BoundingBoxf3 bb = m_model.bounding_box_exact(); + return bb.defined ? bb.center().z() : 0.0; +} + +void DesignCanvas::set_readout(const std::string& text) +{ + if (!m_hud || !m_hud_label || !m_canvas_widget) return; + if (text == m_hud_last) return; // only touch the WM on a real change + m_hud_last = text; + if (text.empty()) { m_hud->Hide(); return; } + m_hud_label->SetLabel(wxString::FromUTF8(text)); + place_readout_hud(); +} + +void DesignCanvas::place_readout_hud() +{ + if (!m_hud || !m_hud_label || !m_canvas_widget) return; + if (m_hud_last.empty() || !m_canvas_widget->IsShownOnScreen()) { m_hud->Hide(); return; } + m_hud->Fit(); + // Anchor to the canvas's bottom-right corner with a small margin (screen coords). + const wxSize cs = m_canvas_widget->GetClientSize(); + const wxSize hs = m_hud->GetSize(); + const wxPoint br = m_canvas_widget->ClientToScreen( + wxPoint(cs.GetWidth() - hs.GetWidth() - 12, cs.GetHeight() - hs.GetHeight() - 12)); + if (!m_hud->IsShown()) m_hud->Show(); // Show before Move (GTK ignores pre-map Move) + m_hud->Move(br); + m_hud->Raise(); +} + +// A popup is override-redirect: the window manager does not own it, so an iconised or +// deactivated app would leave the chip sitting on the bare desktop. The status chip already +// had to answer this; now that the readout is a popup too, it answers it the same way. +void DesignCanvas::show_readout_hud(bool on) +{ + if (!m_hud) return; + if (on) place_readout_hud(); + else m_hud->Hide(); +} + +// Clear of the view cube and the two round view buttons, which own the bottom-left corner. +// Shared by the placement and by the wrap width, which have to agree or the chip wraps to a +// width it is then not given. +static constexpr int kStatusHudLeftInsetDip = 190; + +void DesignCanvas::set_status_text(const wxString& text, const wxColour& colour) +{ + if (!m_status_hud || !m_status_hud_label || !m_canvas_widget) return; + if (text == m_status_hud_last && colour == m_status_hud_colour) return; + m_status_hud_last = text; + m_status_hud_colour = colour; + if (text.IsEmpty()) { m_status_hud->Hide(); return; } + m_status_hud_label->SetForegroundColour(colour); + apply_status_label(); + place_status_hud(); +} + +// SetLabel + Wrap + Fit, in that order and always together. Moving the status out of the panel +// removed the clipping of 8cc but not the underlying problem: the chip is a top-level +// popup that Fit()s to its text, so a long sentence simply grew past the right edge of the canvas +// and hung over the window. Wrapping to the room actually available is what makes the earlier +// promise — "a sentence can be a sentence" — true at every window width, including the charter's +// 1366 reach. Wrap() rewrites the label it is given, so it must follow a fresh SetLabel every +// time; that is the whole reason this is one function instead of three call sites. +void DesignCanvas::apply_status_label() +{ + if (!m_status_hud || !m_status_hud_label || !m_canvas_widget) return; + m_status_hud_label->SetLabel(m_status_hud_last); + const int avail = m_canvas_widget->GetClientSize().GetWidth() + - m_canvas_widget->FromDIP(kStatusHudLeftInsetDip) + - m_canvas_widget->FromDIP(24); + if (avail > m_canvas_widget->FromDIP(120)) // a uselessly narrow canvas: leave it unwrapped + m_status_hud_label->Wrap(avail); + m_status_hud->Fit(); +} + +void DesignCanvas::place_status_hud() +{ + if (!m_status_hud || !m_canvas_widget || m_status_hud_last.IsEmpty()) return; + // The canvas has a client size even while its page is hidden, and it is not the size the + // page will have when shown — anchoring against it put the chip up on the tab bar, where it + // then stayed until the next status change moved it. Nothing to anchor to: stay down. + if (!m_canvas_widget->IsShownOnScreen()) { m_status_hud->Hide(); return; } + const wxSize cs = m_canvas_widget->GetClientSize(); + // Re-wrap first: this also runs on resize, and a chip wrapped for the old width either + // overhangs a narrowed canvas or wastes a widened one. + apply_status_label(); + const wxSize hs = m_status_hud->GetSize(); + const int kLeftInset = m_canvas_widget->FromDIP(kStatusHudLeftInsetDip); + const wxPoint bl = m_canvas_widget->ClientToScreen( + wxPoint(kLeftInset, cs.GetHeight() - hs.GetHeight() - 12)); + // No Raise() and no focus juggling: a popup neither takes focus nor falls behind. This was + // caught with ORCA_CAD_KEYTRACE — shift+S logged a line, the following R logged nothing, and + // the only thing between them was the first status update showing this window. + if (!m_status_hud->IsShown()) m_status_hud->Show(); // Show before Move (GTK ignores pre-map Move) + m_status_hud->Move(bl); +} + +void DesignCanvas::on_frame_iconize(wxIconizeEvent& e) +{ + show_status_hud(!e.IsIconized()); + show_readout_hud(!e.IsIconized()); + e.Skip(); +} + +void DesignCanvas::on_frame_activate(wxActivateEvent& e) +{ + show_status_hud(e.GetActive()); + show_readout_hud(e.GetActive()); + e.Skip(); +} + +// wxEvent& so one handler serves both events that invalidate the anchor: the frame moving out +// from under the chip, and the canvas resizing under it. +void DesignCanvas::on_status_hud_reanchor(wxEvent& e) +{ + place_status_hud(); + e.Skip(); +} + +void DesignCanvas::show_status_hud(bool on) +{ + if (!m_status_hud) return; + if (on) place_status_hud(); // re-anchors first: the page may have been resized while away + else m_status_hud->Hide(); +} + +void DesignCanvas::set_body_highlight(bool on) +{ + if (m_body_selected == on) return; + m_body_selected = on; + reload(true); // recolours the body volume (selected = cyan tint) +} + +void DesignCanvas::set_operand_bodies(int target_body, int tool_body) +{ + if (m_hl_body_target == target_body && m_hl_body_tool == tool_body) return; + m_hl_body_target = target_body; + m_hl_body_tool = tool_body; + reload(true); // recolours the body volumes (same idiom set_body_highlight uses) +} + +void DesignCanvas::set_highlight_sketches(std::vector> hl) +{ + m_sketch_tool.set_highlight_sketches(std::move(hl)); + request_repaint(); +} + +void DesignCanvas::set_body_translucent(bool on) +{ + if (m_body_translucent == on) return; + m_body_translucent = on; + reload(true); // re-applies object-0 alpha so the solid fades for the fillet preview +} + +void DesignCanvas::set_xray_focus(int body) +{ + if (m_xray_focus == body) return; + m_xray_focus = body; + m_sketch_tool.set_pick_only_body(body); + reload(true); // re-applies per-body alpha so the non-focused bodies fade +} + +void DesignCanvas::set_body_hidden(bool on) +{ + if (m_body_hidden == on) return; + m_body_hidden = on; + reload(true); // hides/show base bodies + flips the ghost opaque/faint for preview-only mode +} + +void DesignCanvas::delete_selected_sketch_entities() +{ + m_sketch_tool.delete_selected(); + request_repaint(); +} + +bool DesignCanvas::inline_busy() const +{ + // Two sources, still: the TOOL's flag says a value is pending, the editor says a field is + // drawn. They agree now that the field is not a window — the orphan state (logically closed, + // still on screen, still eating keys) cannot be represented when there is nothing to leave + // mapped — but the union costs nothing and is the honest question to ask. + return m_sketch_tool.inline_busy() + || (m_inline_editor && m_inline_editor->is_open()); +} + +bool DesignCanvas::inline_has_focus() const +{ + return m_inline_editor && m_inline_editor->has_focus(); +} + +void DesignCanvas::inline_commit() +{ + if (m_inline_editor) m_inline_editor->commit(); +} + +void DesignCanvas::inline_cancel() +{ + if (m_inline_editor) m_inline_editor->cancel(); +} + +void DesignCanvas::request_sketch_exit() +{ + m_sketch_tool.request_exit(); + request_repaint(); +} + +bool DesignCanvas::live_sketch_has_work() const +{ + return m_sketch_tool.live_sketch_has_work(); +} + +bool DesignCanvas::undo_last_sketch_entity() +{ + const bool did = m_sketch_tool.undo_last_entity(); + if (did) request_repaint(); + return did; +} + +bool DesignCanvas::delete_selected_or_last_sketch_entity() +{ + const bool did = m_sketch_tool.delete_selected_or_last(); + if (did) request_repaint(); + return did; +} + +void DesignCanvas::clear_sketch_selection() +{ + m_sketch_tool.clear_selection(); + request_repaint(); +} + +DesignSketchTool::DimType DesignCanvas::sketch_dimension_kind() const +{ + return m_sketch_tool.dimension_kind(); +} + +double DesignCanvas::sketch_dimension_current() const +{ + return m_sketch_tool.dimension_current(); +} + +void DesignCanvas::apply_sketch_dimension(double v) +{ + m_sketch_tool.apply_dimension(v); + request_repaint(); +} + +void DesignCanvas::open_inline_value(double current, std::function commit, + std::function cancel) +{ + if (!m_inline_editor || !m_canvas_widget) { if (cancel) cancel(); return; } + // Host-driven value entry (committed-feature Constrain path): the trigger is a toolbar + // button. Anchor the field OVER the picked geometry (same as the draw-then-edit tools) when + // the tool can project it; else fall back to the middle of the canvas, where the sketch is + // in view. Everything here is canvas DEVICE px, the space the field is drawn in. + const wxSize cs = m_canvas_widget->GetClientSize(); + const double sf = m_canvas_widget->GetContentScaleFactor(); + wxPoint anchor(int(cs.GetWidth() * sf) / 2, int(cs.GetHeight() * sf) / 2); + m_sketch_tool.constrain_value_anchor(anchor); // device px in the canvas viewport + m_sketch_tool.set_inline_busy(true); + m_canvas_widget->SetFocus(); // same reason as the draw-then-edit path: ImGui reads the + // canvas's key events, so the canvas must be the focused widget + + m_inline_editor->open(anchor, current, "", + [this, commit](double v) { + m_sketch_tool.set_inline_busy(false); + if (commit) commit(v); + request_repaint(); + }, + [this, cancel]() { + m_sketch_tool.set_inline_busy(false); + if (cancel) cancel(); + request_repaint(); + }); +} + +void DesignCanvas::set_on_dimension_pick_complete(std::function cb) +{ + m_sketch_tool.on_dimension_pick_complete = std::move(cb); +} + +DesignSketchTool::DimType DesignCanvas::pending_dimension_type() const +{ + return m_sketch_tool.pending_dimension_type(); +} + +void DesignCanvas::set_sketch_dimension_value(double v) +{ + m_sketch_tool.set_dimension_value(v); + request_repaint(); +} + +void DesignCanvas::cancel_sketch_dimension() +{ + m_sketch_tool.cancel_dimension_value(); + request_repaint(); +} + +void DesignCanvas::begin_constrain(const SketchProfile& prof, const SketchPlane& plane) +{ + m_sketch_tool.begin_constrain(prof, plane); + // The overlay must appear immediately (no mouse move to trigger a repaint). + request_repaint(); +} + +void DesignCanvas::begin_imported_transform( + int feat, const std::vector>>& base_regions, + const SketchPlane& plane, const Vec2d& offset, double scale_x, double scale_y) +{ + m_sketch_tool.begin_imported_transform(feat, base_regions, plane, offset, scale_x, scale_y); + request_repaint(); +} + +void DesignCanvas::set_on_imported_transform(std::function cb) +{ + m_sketch_tool.on_imported_transform = std::move(cb); +} + +void DesignCanvas::end_constrain() +{ + // cancel() clears m_active + the picked-segment/entity indices, so the + // constrain overlay (highlighted picks) disappears on the next render. + m_sketch_tool.cancel(); + request_repaint(); +} + +bool DesignCanvas::is_constraining() const { return m_sketch_tool.is_constraining(); } + +bool DesignCanvas::selected_segment(int& a, int& b) const +{ + return m_sketch_tool.selected_segment(a, b); +} + +void DesignCanvas::update_constrain_profile(const std::vector& pts) +{ + m_sketch_tool.set_profile_points(pts); + request_repaint(); +} + +void DesignCanvas::begin_constrain_entities(const std::vector& ents, + const SketchPlane& plane) +{ + m_sketch_tool.begin_constrain_entities(ents, plane); + request_repaint(); +} + +bool DesignCanvas::is_constraining_entities() const +{ + return m_sketch_tool.is_constraining_entities(); +} + +int DesignCanvas::sketch_selection_count() const +{ + return int(m_sketch_tool.selection().size()); +} + +bool DesignCanvas::view_normal_to_sketch() +{ + if (m_canvas == nullptr) return false; + const SketchPlane& pl = m_sketch_tool.plane(); + Camera& cam = wxGetApp().plater()->get_camera(); + // Keep the distance: this is an orientation change, not a zoom. The plane's own y axis is + // the up vector, so "up" on screen is up in sketch coordinates — which is what makes a + // dimension typed after pressing N land where the eye expects it. + const double dist = cam.get_distance(); + cam.look_at(pl.origin + pl.normal * dist, pl.origin, pl.y_axis); + request_repaint(); + return true; +} + +bool DesignCanvas::sketch_abort_gesture() +{ + if (!m_sketch_tool.abort_gesture()) return false; + request_repaint(); // the rubber band is gone; the canvas must stop drawing it + return true; +} + +bool DesignCanvas::sketch_disarm_tool() +{ + if (!m_sketch_tool.disarm_tool()) return false; + request_repaint(); + return true; +} + +bool DesignCanvas::drawing_in_progress() const +{ + return m_sketch_tool.pending_points() > 0; +} + +bool DesignCanvas::has_any_selection() const +{ + return m_sketch_tool.has_solid_selection() || m_sketch_tool.sketch_has_selection(); +} + +bool DesignCanvas::clear_any_selection() +{ + if (!has_any_selection()) return false; + // Both, unconditionally: which of the two is live depends on the mode, and Esc at idle means + // "nothing is picked" in either of them. clear_selection() reports through the tool's own + // on_selection_changed; the solid side has no such notification, so the panel refreshes what + // depends on it (see DesignPanel::escape). + m_sketch_tool.clear_selection(); + m_sketch_tool.clear_solid_selection(); + // clear_solid_selection() is silent by design (recomputes call it while ids are invalid), but + // the panel mirrors the pick to aim Extrude and the dress-up tools. An Esc that cleared the + // highlight without telling the panel would leave those aimed at a body nothing points to. + if (m_sketch_tool.on_solid_selection_changed) + m_sketch_tool.on_solid_selection_changed(0, -1, -1, -1); + request_repaint(); + return true; +} + +bool DesignCanvas::sketch_first_selected_type(SketchEntity::Type& out) const +{ + return m_sketch_tool.first_selected_type(out); +} + +const std::vector& DesignCanvas::sketch_selection() const +{ + return m_sketch_tool.selection(); +} + +const std::vector& DesignCanvas::sketch_entities() const +{ + return m_sketch_tool.entities(); +} + +int DesignCanvas::sketch_constraint_count() const +{ + return int(m_sketch_tool.constraints().size()); +} + +const std::vector& DesignCanvas::sketch_constraints() const +{ + return m_sketch_tool.constraints(); +} + +bool DesignCanvas::remove_sketch_constraint(int idx) +{ + const bool removed = m_sketch_tool.remove_constraint(idx); + if (removed) request_repaint(); + return removed; +} + +void DesignCanvas::set_on_sketch_constraints_changed(std::function cb) +{ + m_sketch_tool.on_constraints_changed = std::move(cb); +} + +bool DesignCanvas::try_add_sketch_constraints(const std::vector& defs) +{ + return m_sketch_tool.try_add_constraints(defs); +} + +bool DesignCanvas::selected_constrain_entities(int& e0, int& e1) const +{ + return m_sketch_tool.selected_constrain_entities(e0, e1); +} + +int DesignCanvas::selected_constrain_axis() const +{ + return m_sketch_tool.pick2(); +} + +bool DesignCanvas::pick0_point(Vec2d& out) const +{ + return m_sketch_tool.pick0_point(out); +} + +void DesignCanvas::update_constrain_entities(const std::vector& ents) +{ + m_sketch_tool.set_constrain_entities(ents); + request_repaint(); +} + +void DesignCanvas::set_constraint_highlight(std::vector entities) +{ + m_sketch_tool.set_constraint_highlight(std::move(entities)); + request_repaint(); +} + +void DesignCanvas::set_constraint_glyphs(std::vector cons) +{ + m_sketch_tool.set_constraint_glyphs(std::move(cons)); + request_repaint(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp new file mode 100644 index 0000000000..fab2e556e3 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -0,0 +1,436 @@ +#ifndef slic3r_DesignCanvas_hpp_ +#define slic3r_DesignCanvas_hpp_ + +#include +#include + +#include +#include +#include + +#include "slic3r/GUI/3DBed.hpp" +#include "slic3r/GUI/Camera.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" + +class wxGLCanvas; +class wxFrame; +class wxStaticText; + +namespace Slic3r { + +class TriangleMesh; + +namespace GUI { + +class GLCanvas3D; +class SketchInlineEditor; + +class DesignCanvas : public wxPanel +{ +public: + explicit DesignCanvas(wxWindow* parent); + ~DesignCanvas() override; + + void set_mesh(const TriangleMesh& mesh); + // Multi-body display: one GLVolume per body, each coloured distinctly (per-body colour). + // `visible` (optional, indexed by body) hides bodies whose flag is false. + void set_bodies(const std::vector& body_meshes, + const std::vector& visible = {}); + void clear_mesh(); + + void set_preview_mesh(const TriangleMesh& mesh); + void clear_preview(); + + void fit_view(); + void set_view(const std::string& view_name); + + void begin_sketch(const SketchPlane& plane, DesignSketchTool::Mode mode); + // Re-open a committed entity sketch for full in-canvas editing (load geometry + + // constraints, re-detect feature groups). Re-commits via finish_sketch(). + void edit_sketch(const std::vector& entities, + const std::vector& constraints, + const SketchPlane& plane); + void set_sketch_tool(DesignSketchTool::Mode mode); + void set_sketch_plane(const SketchPlane& plane); // re-plane the live sketch when a reference plane is clicked in 3D + void set_sketch_construction(bool c); + // Flip the sketch selection between construction and real geometry; returns the + // number of entities changed (0 = nothing selected, caller falls back to the mode). + // Open the in-canvas value field on the sketch selection's defining number. + bool edit_sketch_selection_value(); + int toggle_sketch_construction_selection(); + // Is the sketch tool on Select (as opposed to a draw/edit tool being armed)? The + // Construction box needs it to tell "convert what I picked" from "arm what I draw next". + bool sketch_is_selecting() const { return m_sketch_tool.mode() == DesignSketchTool::Mode::Select; } + // Text / SVG art into the LIVE sketch, as ordinary editable lines. False = no session. + bool add_sketch_regions(const std::vector>>& regions); + void set_sketch_polygon_sides(int n); + void set_sketch_polygon_circumscribed(bool c); + void finish_sketch(); + bool is_sketching() const; + void refresh_bed(); // re-sync the bed to the current printer (call on tab activation) + // The Camera is Plater-owned and shared with Prepare/Preview/Assemble; GLCanvas3D has no + // per-canvas camera, so every orbit here would otherwise overwrite what the editor tabs + // show. Exactly one of the two views is live at a time, so entering and leaving are the + // same operation: trade the live camera for the parked one. That also keeps this canvas's + // own view across a tab switch. + void enter_viewport(); + void leave_viewport(); + void unbind_canvas_event_handlers(); // app close / language switch, from the plater's teardown + void reset_canvas_volumes(); + void set_show_bed(bool b); // view option: draw the printer bed + plate grid, or not + // N: look straight down the sketch plane's normal, keeping the current zoom. A sketch drawn + // at an angle is a sketch drawn wrong, and no amount of orbiting by hand lands exactly square. + bool view_normal_to_sketch(); + void cancel_sketch(); + void set_on_sketch_commit(std::function cb); + void set_on_sketch_entities_commit( + std::function&, + const std::vector&, + const SketchPlane&)> cb); + + // Line tool: pending-segment length entry + live readout (Phase 2). + void set_on_segment_drawn(std::function cb); + void set_on_cursor_metrics(std::function cb); + void set_on_solve_state(std::function cb); // dof, ok, has_constraints + // Live per-step guidance from the armed sketch tool (mode, step, picks). 1c0c. + void set_on_sketch_step(std::function cb); + void apply_segment_length(double len); // exact length, then commit & repaint + void keep_segment_as_drawn(); // commit as-drawn & repaint + + // Sketch selection (Mode::Select). + void set_on_sketch_selection_changed(std::function cb); + void set_on_sketch_face_selected(std::function cb); // closed loop clicked: region index passed + void set_on_display_sketch_selected(std::function cb); // committed loop clicked: (feature, region, entity) + void set_on_display_sketch_activated(std::function cb); // committed sketch DOUBLE-clicked: edit it + std::vector selected_loop_entities() const; // entities of the click-selected loop + std::vector> region_entity_indices(const std::vector& ents) const; + // Like region_entity_indices, but each region's entry is its OWN entities followed by the + // entities of each of its holes — the same order selected_loop_entities() hands the kernel. + // A per-loop extrude of a region WITH holes stores exactly this, so this is the shape a + // consumed loop must be compared against. + std::vector> region_entity_indices_with_holes(const std::vector& ents) const; + void clear_loop_pick(); // drop the click-selected loop highlight (e.g. after extrude) + void set_loop_pick(int feature, int region); // adopt a loop pick made before the commit + void set_escalate_on_repick(bool on); // off while a card has armed a face/edge pick + // Solid whole/face/edge selection: point the tool at the bodies + concatenated + // tessellation (with per-triangle face & body ids), and a callback fired on each + // whole->face->edge cycle (level, body index, face id, edge id). + void set_solid_pick(const std::vector* bodies, const TriangleMesh* mesh, + const std::vector* tri_face, const std::vector* tri_body, + const std::vector* visible = nullptr, + const std::vector* xform = nullptr); + void set_on_solid_selection_changed(std::function cb); + void set_on_place_on_face(std::function cb); // F key: Place on Face + void select_body(int body); // Parts-list -> highlight a whole body by index + // Effective display colour of a body: the per-body override (Color tool) when set, + // otherwise the auto body-index palette. Single source of truth shared with reload(). + ColorRGBA body_color(int body) const; + // Move-body gizmo (M5): three world-axis drag arrows on a body; drag fires the move + // callback with the body index + accumulated translation (display-only, host applies it). + // body_radius = bounding-sphere radius of the body in world mm; the gizmo scales with it so + // the rotation rings sit OUTSIDE the solid (Orca's Prepare gizmos do the same). + void begin_move_body(int body, const Vec3d& pivot, const Transform3d& base_xform, + double body_radius); + void clear_move_gizmo(); + bool moving_body() const; + void set_on_body_move_changed(std::function cb); + // Visual Fillet/Chamfer radius gizmo: when a solid edge is picked, anchor a radius arrow on + // it; drag/edit fire the radius callback. Returns false if no edge is currently picked. + bool begin_fillet_gizmo(const Vec3d& body_centroid, double radius); + void clear_fillet_gizmo(); + bool filleting() const; + void set_on_fillet_radius_changed(std::function cb); + // Visual Hole gizmo: the panel feeds the hole plane + position + diameter/depth/through while + // its Hole card is open; drag/edit fire the hole callback (x, y, diameter, depth). + void begin_hole_gizmo(const SketchPlane& plane, double x, double y, + double diameter, double depth, bool through); + void set_hole_face_bounds(bool has, double umin, double umax, double vmin, double vmax); + void clear_hole_gizmo(); + bool holing() const; + void set_on_hole_changed(std::function cb); + // Visual Thread gizmo: footprint circle + radius/length arrows + draggable centre. + void begin_thread_gizmo(const SketchPlane& plane, double x, double y, + double radius, double height); + void clear_thread_gizmo(); + bool threading() const; + void set_on_thread_changed(std::function cb); + // Visual Shell gizmo: inward thickness arrow at the picked open-face centroid. + void begin_shell_gizmo(const Vec3d& face_centroid, const Vec3d& inward_dir, double thickness); + void clear_shell_gizmo(); + bool shelling() const; + void set_on_shell_thickness_changed(std::function cb); + // Visual Revolve angle-arc gizmo: the panel feeds the sketch plane + profile centroid + axis + // (0=plane X, 1=plane Y) + angle + flip while its Revolve card is open; drag/edit fire the + // angle callback. + void begin_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, + int axis_sel, double angle, bool flip); + void clear_revolve_gizmo(); + bool revolving() const; + void set_on_revolve_angle_changed(std::function cb); + // Visual Draft angle-arc gizmo: the panel feeds the face centroid + face normal + angle while + // its Draft card is open; drag/edit fire the angle callback. + void set_draft_gizmo(const Vec3d& face_centroid, const Vec3d& face_normal, double angle); + void clear_draft_gizmo(); + bool drafting() const; + void set_on_draft_angle_changed(std::function cb); + // Visual Cut gizmo: plane-rectangle preview + draggable normal offset arrow while + // the Cut card is open; drag fires the offset callback. + void set_cut_gizmo(const SketchPlane& plane, double offset, const Vec3d& body_center, double half_extent); + void clear_cut_gizmo(); + bool cutting() const; + void set_on_cut_offset_changed(std::function cb); + // Visual Pattern gizmo: the panel feeds the (world XY) plane + target body centroid + mode + + // count/dir/spacing/angle while its Pattern card is open; drag/edit fire the value callback. + void begin_pattern_gizmo(const SketchPlane& plane, const Vec3d& body_centroid, bool circular, + int count, int dir, double spacing, double angle); + void clear_pattern_gizmo(); + bool patterning() const; + void set_on_pattern_changed(std::function cb); + // Visual Extrude depth-arrow gizmo (C5b): the panel feeds the profile plane + centroid + + // live depths/flags while its Extrude card is open; drag/edit fire the depth callback. + void set_extrude_gizmo(const SketchPlane& plane, const Vec2d& centroid, + double depth, double depth2, bool two_sided, bool flip); + void clear_extrude_gizmo(); + void set_on_extrude_depth_changed(std::function cb); + void set_datum_gizmo(const SketchPlane& plane, double usize, double vsize, + const Vec3d& base_origin, const Vec3d& base_normal, + double offset, bool offset_on); // C3 resize handles + offset arrow + void clear_datum_gizmo(); + void set_on_datum_size_changed(std::function cb); + void set_on_datum_offset_changed(std::function cb); + void set_helix_gizmo(const SketchPlane& plane, double radius, double pitch, double height, + double taper, bool left_handed); // helix curve + 3 drag handles + void clear_helix_gizmo(); + void set_on_helix_changed(std::function cb); + void set_rib_gizmo(const SketchPlane& plane, const Vec2d& p0, const Vec2d& p1, double thickness); // rib slab footprint + 2 thickness handles + void clear_rib_gizmo(); + void set_on_rib_thickness_changed(std::function cb); + void set_base_pick(std::vector planes, std::vector bases, + std::vector labels = {}); // clickable labelled reference planes + void clear_base_pick(); + void set_on_datum_base_picked(std::function cb); + void set_on_sketch_exit(std::function cb); // Esc -> exit the tool + void set_on_sketch_exit_refused(std::function cb); // Esc declined: sketch has work + void set_on_undo_redo(std::function cb); // Ctrl+Z / Ctrl+Shift+Z + // Persistently draw committed sketches (un-consumed ones stay visible). + void set_display_sketches(std::vector ds); + void set_highlight_sketches(std::vector> hl); + void set_datum_planes(std::vector planes, + std::vector sizes = {}); // draw datum/reference planes (u/v extents) + // Mate connectors, drawn as frames so their verse and polarity are visible (wgsc). + void set_mate_connectors(std::vector g); + void set_mate_links(std::vector> l); + void set_body_highlight(bool on); // tint the solid when its feature is tree-selected + // The status line, shown along the BASE OF THE VIEWPORT rather than in the side panel: + // the panel clips it at ~73 characters with no warning (8cc), the viewport's + // bottom margin has the whole window width to spare. Empty text hides it. + void set_status_text(const wxString& text, const wxColour& colour); + // Take the status line down / bring it back when the Design page leaves and re-enters view. + // A popup is a TOP-LEVEL window: hiding the page it belongs to does not hide it. Keeps the + // text, so coming back needs no re-selection. + void show_status_hud(bool on); + void set_operand_bodies(int target_body, int tool_body); // -1,-1 clears + void set_body_translucent(bool on); // render the solid see-through (fillet/chamfer preview) + void set_xray_focus(int body); // >=0: fade+lock out every other body (CoordSys picking) + void set_body_hidden(bool on); // preview-only: hide base bodies, show only the result ghost + void set_on_move_exit(std::function cb); // right-click finished the move-body gizmo + // Right-click (or its platform equivalent) on the viewport with no tool running: open the + // object-driven offer there. Fires with SCREEN coordinates. Deliberately NOT fired while a + // tool is live — right-click already ends a polyline chain and finishes the move gizmo, and + // taking those over would break two working interactions in order to add a third. + void set_on_context_menu(std::function cb); + void delete_selected_sketch_entities(); + bool inline_busy() const; // a sketch value field is open (guard keys) + bool inline_has_focus() const; // the field itself holds keyboard focus + void inline_commit(); // accept the typed value (Enter/Tab) + void inline_cancel(); // discard the typed value (Esc) + // The layered Esc: abandon the points of the gesture in progress, else drop the armed tool + // back to Select, else leave the sketch. Same call GLCanvas3D::on_char makes, exposed so the + // panel can do it when focus is not on the canvas. + void request_sketch_exit(); + bool live_sketch_has_work() const; // the live sketch holds entities a cancel would destroy + bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last entity + bool delete_selected_or_last_sketch_entity(); // Delete in a sketch: selected, else last + void clear_sketch_selection(); + + // View toggles (keys P / A): origin planes, world axis triad. Each returns the new on/off + // state so the caller can echo it in the status bar. + bool toggle_planes(); + bool toggle_axes(); + + // Section views (non-destructive): the panel owns the named "Section View N" list; the canvas + // just applies/clears one horizontal clip at a time. model_mid_z() is the default cut height. + void set_section_plane(bool on, double z, bool keep_upper = false); + double model_mid_z() const; + + // Dimension tool: act on the current sketch selection. + DesignSketchTool::DimType sketch_dimension_kind() const; + double sketch_dimension_current() const; + void apply_sketch_dimension(double v); + + // Open the in-canvas value editor at the cursor for a host-driven value (the + // committed-feature Constrain path uses this instead of a docked numeric card). + void open_inline_value(double current, std::function commit, + std::function cancel = {}); + + // Dimension tool (Mode::Dimension): click-to-place quotes. The pick-complete + // callback lets the panel pop the value card; set/cancel apply or keep the value. + void set_on_dimension_pick_complete(std::function cb); + DesignSketchTool::DimType pending_dimension_type() const; + void set_sketch_dimension_value(double v); + void cancel_sketch_dimension(); + + // Constrain mode: load a committed profile for picking + constraint editing. + void begin_constrain(const SketchProfile& prof, const SketchPlane& plane); + // Leave constrain mode and clear any picked-entity highlight from the overlay. + void end_constrain(); + bool is_constraining() const; + bool selected_segment(int& a, int& b) const; + void update_constrain_profile(const std::vector& pts); + + // Entity-aware Constrain (Fase 4.2): pick Line entities of a committed sketch. + void begin_constrain_entities(const std::vector& ents, const SketchPlane& plane); + bool is_constraining_entities() const; + // Sketch selection, for the offer menu: how many entities are selected and what the first + // one is. Returns 0 when nothing is selected. + int sketch_selection_count() const; + // Esc routing (DesignInteraction.hpp). The panel decides WHICH level one press belongs to; + // these are the levels it can act on inside the canvas. Each returns whether it did anything, + // so the panel can fall through to the next level without asking twice. + bool sketch_abort_gesture(); // CadLevel::Gesture — drop the entity being drawn + bool sketch_disarm_tool(); // CadLevel::Tool — armed sketch tool falls back to Select + bool drawing_in_progress() const;// an entity has clicks down but is not committed + bool has_any_selection() const; // model pick or sketch pick + bool clear_any_selection(); // CadLevel::Idle — drop both; true if anything was dropped + bool sketch_first_selected_type(SketchEntity::Type& out) const; + // Live sketch session (Fase 4.2 live constraint path): the panel reads the in-session + // selection and entities, and commits a planned constraint through the tool's + // append->solve->keep-or-rollback, rather than reaching into mcp_sketch_tool(). + const std::vector& sketch_selection() const; + const std::vector& sketch_entities() const; + // How many constraints the LIVE session holds. Only a count: the hint line needs to know + // whether any badge is on screen to talk about, nothing more. + int sketch_constraint_count() const; + const std::vector& sketch_constraints() const; + bool remove_sketch_constraint(int idx); + void set_on_sketch_constraints_changed(std::function cb); + bool try_add_sketch_constraints(const std::vector& defs); + + // In-canvas bbox transform of imported Text/SVG art (replaces the Move/Scale dialog). + void begin_imported_transform(int feat, + const std::vector>>& base_regions, + const SketchPlane& plane, const Vec2d& offset, + double scale_x, double scale_y); + void set_on_imported_transform(std::function cb); + bool selected_constrain_entities(int& e0, int& e1) const; + int selected_constrain_axis() const; // third pick slot (Symmetric axis), -1 if unset + bool pick0_point(Vec2d& out) const; // plane-coords of the slot-0 pick (trim/extend) + void update_constrain_entities(const std::vector& ents); + // Constraint manager (C3.4): highlight the entities referenced by a selected + // constraint (yellow tint in Constrain mode); empty clears the highlight. + void set_constraint_highlight(std::vector entities); + // Constraint glyph badges (C3.4b): the feature's constraints, drawn as iconic + // marks near their entities in Constrain mode; empty clears them. + void set_constraint_glyphs(std::vector cons); + // Repaint the embedded canvas the right way for the active GL backend: + // hardware GL gets a scheduled wxEVT_PAINT (render() runs inside the paint + // cycle); software GL (llvmpipe etc.) gets a direct render() because a + // scheduled Refresh() is frequently dropped there. Backend cached on first use. + // Public: DesignPanel calls it after a tree edit to force a frame on software GL. + // Scripted (MCP) access to the live sketch. One accessor rather than a passthrough per + // verb: the MCP layer drives the SAME tool the mouse drives, which is the whole point of + // having it — a socket that talked to a private copy would prove nothing about the app. + DesignSketchTool& mcp_sketch_tool() { return m_sketch_tool; } + const DesignSketchTool& mcp_sketch_tool() const { return m_sketch_tool; } + + void request_repaint(); + // Repaint synchronously, once the pending show/resize has settled. Needed when the + // notebook re-shows the Design page: an invalidation issued while the page is still + // being shown is dropped on hardware GL and no wxEVT_PAINT ever follows, leaving the + // canvas blank until another tab switch forces an expose. + void force_repaint(); + // Repaint synchronously, for use while a modal popup (the offer menu) owns the event loop: + // a queued Refresh() is not serviced until the popup closes, so a hover ghost drawn behind it + // would never appear. Mirrors DesignPanel's m_status->Update() flush. + void repaint_now(); + +private: + void reload(bool keep_view); + void swap_camera(); // enter_viewport / leave_viewport, in the one direction they share + + wxGLCanvas* m_canvas_widget{nullptr}; + GLCanvas3D* m_canvas{nullptr}; + int m_sw_gl{-1}; // -1 unknown, 0 hardware GL, 1 software GL + + std::function m_on_context_menu; + bool m_ctx_bound{false}; // bind the RIGHT_UP handler once, however often the cb is set + wxPoint m_ctx_press{0, 0}; // right-press origin: a right-DRAG orbits, it must not offer + long long m_ctx_press_ms{0}; // and a right-HOLD is navigation too, however still it is held + + Bed3D m_bed; + // The half of the camera swap above that is NOT on screen: the editor tabs' view while + // Design is up, this canvas's view while it is not. Seeded in the constructor so the first + // entry inherits the view the user was already looking at. + Camera m_parked_camera; + bool m_camera_swapped{false}; // guards a leave without an enter, and the reverse + Model m_model; + bool m_first_frame{true}; + bool m_body_selected{false}; // tree selected a body feature → tint the solid + int m_hl_body_target{-1}; + int m_hl_body_tool{-1}; + bool m_body_translucent{false};// fillet/chamfer preview → render the body see-through + int m_xray_focus{-1}; // >=0: only this body is opaque+clickable (CoordSys picking) + bool m_body_hidden{false}; // preview-only mode → hide base bodies, ghost = the result + std::vector m_body_visible; // per-body visibility (empty => all visible) + // Live pointer to the document's bodies (stable address: m_doc.bodies), stashed by + // set_solid_pick so reload()/body_color() can read each body's colour override. + const std::vector* m_color_bodies{nullptr}; + + DesignSketchTool m_sketch_tool; + + // Section view: whether a horizontal clip is currently applied (guards Alt+Wheel). The cut + // height and the named-view list live in DesignPanel; the canvas is a dumb applier. + bool m_section_on{false}; + + std::unique_ptr m_inline_editor; // floating in-canvas value editor + // Bottom-right viewport HUD: a borderless float label over the GL canvas showing the + // active tool's current values (fed by the tool's on_readout). Empty text hides it. + // A wxPopupWindow for the SAME reason as the status chip below, and it was a wxFrame until + // the reason was measured rather than assumed: "it appears mid-gesture and the next input is + // the mouse" is false. The chip keeps the last value on screen AFTER the gesture ends, and a + // frame holds the X input focus once it has it — so the next keystroke went to a 119x31 + // window that has no use for it. Measured on :10: focus on the chip, `r` produced no + // CHAR_HOOK line at all; one bare canvas click moved focus back and the same key armed the + // tool. That is every sketch shortcut dead after every dimensioned entity. + wxPopupWindow* m_hud{nullptr}; + wxStaticText* m_hud_label{nullptr}; + std::string m_hud_last; + void set_readout(const std::string& text); + void place_readout_hud(); // anchor + show, using m_hud_last + void show_readout_hud(bool on); // iconise/deactivate: a popup would float on the desktop + + // Bottom-LEFT viewport HUD: the selection / tool status line, written by DesignPanel. + // A wxPopupWindow, NOT the wxFrame the readout HUD uses: a frame accepts keyboard focus, + // and this one is on screen permanently and re-raised on every status change, so it stole + // the keyboard from the canvas and killed every sketch shortcut in the tab. + wxPopupWindow* m_status_hud{nullptr}; + wxStaticText* m_status_hud_label{nullptr}; + wxString m_status_hud_last; + wxColour m_status_hud_colour; + void place_status_hud(); // re-anchors to the canvas corner (also on resize) + void apply_status_label(); // SetLabel + Wrap to the canvas width + Fit, always together + // On the top-level frame, which outlives this canvas — members so they can be unbound. + void on_frame_iconize(wxIconizeEvent& e); + void on_frame_activate(wxActivateEvent& e); + void on_status_hud_reanchor(wxEvent& e); // frame wxEVT_MOVE and canvas wxEVT_SIZE + std::function m_on_sketch_commit; + std::function&, + const std::vector&, + const SketchPlane&)> m_on_sketch_entities_commit; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_DesignCanvas_hpp_ diff --git a/src/slic3r/GUI/CAD/DesignInteraction.hpp b/src/slic3r/GUI/CAD/DesignInteraction.hpp new file mode 100644 index 0000000000..d849b779e4 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignInteraction.hpp @@ -0,0 +1,59 @@ +#ifndef slic3r_GUI_DesignInteraction_hpp_ +#define slic3r_GUI_DesignInteraction_hpp_ + +namespace Slic3r { namespace GUI { + +// The Design tab's interaction stack, and the ONE rule Esc obeys. +// +// Esc unwinds exactly one level per press, deepest first, and never more. The enum value IS +// the LIFO depth, so "which level does this press belong to" is a comparison, not a chain of +// special cases scattered over three files — which is what it was, and why two presses in a +// row could reach past a tool and destroy the sketch underneath it. +// +// STRICT INVARIANT (the bug this exists to make unrepresentable): no level of Esc deletes a +// feature, discards a sketch that holds geometry, or rolls history back. Destroying work needs +// a gesture that says so — Delete/Backspace on an explicit selection, the banner's Cancel, or +// Ctrl+Z. An Esc that can destroy is an Esc nobody can press with confidence, and being the +// safe key is the whole point of it. +enum class CadLevel : int { + Idle = 0, // nothing transient is up: Esc clears the selection + Tool = 1, // a feature card / armed sketch tool / constrain session: Esc exits it + Gesture = 2, // an uncommitted delta (entity being drawn, body being dragged): Esc reverts it + Transient = 3, // a value field or a popup menu: Esc closes just that +}; + +// What the tab is doing, reduced to the four bits the routing actually needs. Kept as a POD of +// answers rather than a pointer to the panel so the rule below is decidable — and checkable — +// without a window, a GL context or an event loop. +struct CadInteractionState { + bool value_field_open{false}; // in-canvas value field, or the panel's value card + bool gesture_active{false}; // in-progress entity points, or a body being moved + bool tool_armed{false}; // feature card open, sketch draw tool armed, constrain session + bool has_selection{false}; // something is picked (model or sketch) +}; + +// The whole routing rule. Deepest live level wins; Idle is the floor. +constexpr CadLevel cad_escape_level(const CadInteractionState& s) +{ + if (s.value_field_open) return CadLevel::Transient; + if (s.gesture_active) return CadLevel::Gesture; + if (s.tool_armed) return CadLevel::Tool; + return CadLevel::Idle; +} + +// The ordering is the entire contract, so it is checked where it is defined, at compile time. +static_assert(cad_escape_level({true, true, true, true}) == CadLevel::Transient, "value field is deepest"); +static_assert(cad_escape_level({false, true, true, true}) == CadLevel::Gesture, "gesture beats tool"); +static_assert(cad_escape_level({false, false, true, true}) == CadLevel::Tool, "tool beats idle"); +static_assert(cad_escape_level({false, false, false, true}) == CadLevel::Idle, "selection is idle-level"); +static_assert(cad_escape_level({false, false, false, false}) == CadLevel::Idle, "empty is idle"); + +// Right-click vs. right-hold-orbit. A press that stays put and is let go promptly is a click and +// summons the offer; anything longer or further was navigation, and navigation must never be +// rewarded with a menu over wherever the camera happened to stop. +inline constexpr int kCadRightClickMs = 200; // press->release budget +inline constexpr int kCadRightClickDriftPx = 3; // cursor drift budget, max(|dx|,|dy|) + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_DesignInteraction_hpp_ diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp new file mode 100644 index 0000000000..ad1998d5c5 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -0,0 +1,189 @@ +// GENERATED FILE — DO NOT EDIT. +// Source: docs/ux/tool_atlas.json Generator: docs/ux/mockups/gen_offer_table.py +// +// The object-driven tool offer (charter 4.1): every verb has ONE row index, that index +// is the same in every selection it appears in, and verbs that do not apply are shown +// disabled in place with their reason rather than removed. Row order was ratified +// 2026-07-31; changing an index is a breaking change to every user's muscle memory. +#ifndef slic3r_GUI_DesignOffer_hpp_ +#define slic3r_GUI_DesignOffer_hpp_ + +#include + +namespace Slic3r { namespace GUI { + +// What the viewport has selected. Ordered as in tool_atlas.json; the bitmask in +// OfferVerb::accepts indexes these. +enum class OfferSel : int { + None = 0, + FacePlanar = 1, + FaceCyl = 2, + FaceOther = 3, + EdgeStr = 4, + EdgeCirc = 5, + Vertex = 6, + BodySolid = 7, + BodySheet = 8, + Bodies2 = 9, + DatumPlane = 10, + DatumAxis = 11, + CoordSys = 12, + Art = 13, + SkLoop = 14, + SkNone = 15, + SkLine = 16, + SkArc = 17, + SkPoint = 18, + Sk2Ent = 19, + Count = 20 +}; + +inline uint32_t offer_bit(OfferSel s) { return 1u << int(s); } + +// One row of the offer. `action` routes to the code that already implements the verb: +// "key:S+E" -> m_keys_feature[SHIFT('E')] +// "key:L" -> m_keys_sketch['L'] +// "fly:material#4" -> row 4 of the "material" feature flyout +// "btn:delete" -> a standalone toolbar button +// nullptr -> kernel support exists, no GUI path yet (row shows disabled) +struct OfferVerb { + const char* id; + const char* name; // drawing-office word (L10); translated at use with wxGetTranslation + int row; // 0..7, the ratified index — NEVER reorder + const char* key; // shortcut shown in the row, or nullptr + const char* action; + const char* refusal; // why this row is greyed, in the product's own words + uint32_t accepts; // bitmask over OfferSel + int need_bodies; + int need_sketches; + bool need_sheet; + bool sketch_mode; // belongs to the sketch-mode vocabulary, not the model one + // Second level INSIDE a row, for tools that come in variants: "Rectangle" holds corner, + // centre, oblique and rounded. nullptr = sits directly in the row. Keeps the row's own + // address fixed (L4.1) while the variants hang one level below it, mirroring the toolbar's + // grouping instead of flattening 19 create tools into one wall. + const char* family; + const char* icon; // resources/images name, or nullptr — the offer draws it beside the row + const char* hint; // what the verb does / what to click; shown on hover +}; + +// Row labels, in ratified order. +static const char* const kOfferRowNames[] = { + "Create", + "Add material", + "Remove", + "Fillet / chamfer / draft", + "Repeat", + "Transform", + "Reference", + "Modify", +}; +static const int kOfferRowCount = 8; + +static const OfferVerb kOfferVerbs[] = { + {"sketch", "Sketch", 0, "Shift+S", "key:S+S", "Click a face or a reference plane in the viewport, then a sketch tool", 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", "Click a face or a reference plane, then pick a drawing tool"}, + {"extrude", "Extrude", 1, "Shift+E", "key:S+E", "Create a sketch, or pick a solid face, first", 0x00004002u, 0, 0, false, false, nullptr, "design_extrude", "Extrude a sketch profile, or push/pull a picked face"}, + {"revolve", "Revolve", 1, "Shift+R", "key:S+R", "Create a sketch profile to revolve first", 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", "Revolve a profile about an axis"}, + {"sweep", "Sweep", 1, "Shift+W", "key:S+W", "Create a profile sketch to sweep first", 0x00004000u, 0, 2, false, false, nullptr, "design_sweep", "Sweep a profile along a path"}, + {"loft", "Loft", 1, "Shift+L", "key:S+L", "Create at least two profile sketches to loft", 0x00004000u, 0, 2, false, false, nullptr, "design_loft", "Loft (skin) between two or more profiles"}, + {"thicken", "Thicken", 1, nullptr, "fly:material#4", "Thicken needs a solid body — add or import one first", 0x0000000au, 1, 0, false, false, nullptr, "design_thicken", "Offset a solid face into a thin plate (new body)"}, + {"rib", "Rib", 1, nullptr, "fly:material#5", "Rib needs a solid body — add or import one first", 0x00010000u, 1, 0, false, false, nullptr, "design_rib", "Grow a thin wall from an open sketch line, fused to a body"}, + {"boolean", "Union", 1, "Shift+B", "btn:bool#0", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", "Fuse the tool body into the target — one solid, no seam"}, + {"bool_subtract", "Subtract", 1, nullptr, "btn:bool#1", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", "Cut the tool body out of the target"}, + {"bool_intersect", "Intersect", 1, nullptr, "btn:bool#2", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", "Keep only where the two bodies overlap"}, + {"surf_extrude", "Surface Extrude", 1, "Shift+G", "key:S+G", "Create a sketch first", 0x00004000u, 0, 0, false, false, nullptr, "design_extrude", "Extrude a sketch into a sheet body (no end caps)"}, + {"surf_revolve", "Surface Revolve", 1, nullptr, "fly:surface#1", "Create a sketch profile to revolve first", 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", "Revolve a sketch profile into a sheet body"}, + {"surf_loft", "Surface Loft", 1, nullptr, "fly:surface#2", "Create at least two profile sketches to loft", 0x00004000u, 0, 2, false, false, nullptr, "design_loft", "Loft (skin) between 2+ profiles, open (no end caps)"}, + {"surf_fill", "Surface Fill", 1, nullptr, "fly:surface#3", "Create a closed sketch first", 0x00004000u, 0, 0, false, false, nullptr, "design_surface", "Fill a sketch boundary with a smooth face"}, + {"thicken_surf", "Thicken Surface", 1, nullptr, "fly:surface#5", "target is not a sheet body", 0x00000100u, 0, 0, true, false, nullptr, "design_thicken", "Thicken a sheet body into a solid"}, + {"hole", "Hole", 2, "Shift+H", "key:S+H", "Pick a face or a plane to drill into", 0x00000402u, 1, 0, false, false, nullptr, "design_hole", "Drill a hole, centred on a picked face or placed on a plane"}, + {"thread", "Thread", 2, "Shift+T", "key:S+T", "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", 0x00000024u, 1, 0, false, false, nullptr, "design_thread", "Thread a cylindrical surface (inner bore / outer) or a circular edge"}, + {"shell", "Shell", 2, "Shift+K", "key:S+K", "Shell needs a solid body", 0x00000082u, 1, 0, false, false, nullptr, "design_shell", "Hollow the body to a wall thickness, opening a picked face"}, + {"cut", "Cut", 2, "Shift+X", "key:S+X", "Create a solid body to cut first", 0x000004feu, 1, 0, false, false, nullptr, "design_cut", "Trim the body with a plane — drag the offset arrow; keep one half or both"}, + {"split", "Split", 2, nullptr, nullptr, "Split needs a solid body", 0x000000feu, 1, 0, false, false, nullptr, nullptr, "Split the body along a picked face into two solids"}, + {"fillet", "Fillet", 3, "Shift+F", "btn:dress#0", "Pick an edge to round", 0x000000b2u, 1, 0, false, false, nullptr, "design_filletedge", "Pick an edge, then drag the radius arrow or type it"}, + {"chamfer", "Chamfer", 3, nullptr, "btn:dress#1", "Pick an edge to bevel", 0x000000b2u, 1, 0, false, false, nullptr, "design_chamfer", "Pick an edge, then drag the distance arrow or type it"}, + {"draft", "Draft", 3, "Shift+D", "key:S+D", "Pick a face to taper", 0x0000000au, 1, 0, false, false, nullptr, "design_draft", "Tilt a picked face by a draft angle"}, + {"surf_offset", "Surface Offset", 3, nullptr, "fly:surface#4", "target is not a sheet body", 0x00000100u, 0, 0, true, false, nullptr, "design_offset", "Offset a sheet body's shell by a signed distance"}, + {"pattern", "Linear pattern", 4, "Shift+N", "btn:pat#0", "Create a solid body to pattern first", 0x00006082u, 1, 0, false, false, nullptr, "design_array", "Repeat the body along a direction — drag the spacing, set the count"}, + {"pattern_circular", "Circular pattern", 4, nullptr, "btn:pat#1", "Create a solid body to pattern first", 0x00006082u, 1, 0, false, false, nullptr, "design_polararray", "Repeat the body around an axis — set the count and sweep"}, + {"mirror", "Mirror", 4, "Shift+Z", "key:S+Z", "Mirror needs a body — add or import one first", 0x000004feu, 1, 0, false, false, nullptr, "design_mirror", "Reflect a body about a plane"}, + {"pat_curve", "Pattern on Curve", 4, nullptr, nullptr, "Pattern on curve needs a body and a curve", 0x00000090u, 1, 0, false, false, nullptr, nullptr, "Repeat the body along a picked curve"}, + {"transform", "Move", 5, "Shift+Y", "key:S+Y", "Transform needs a body — add or import one first", 0x000021feu, 1, 0, false, false, nullptr, "design_move", "Move and/or rotate an existing body"}, + {"mate", "Mate", 5, nullptr, "fly:placement#2", "A mate needs two coordinate systems", 0x00001202u, 2, 0, false, false, nullptr, "design_c_coincident", "Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)"}, + {"align", "Align to", 5, nullptr, nullptr, "Align needs a body", 0x00000002u, 1, 0, false, false, nullptr, nullptr, "Align the body to a picked face or plane"}, + {"plane", "Plane", 6, "Shift+P", "key:S+P", nullptr, 0x00000453u, 0, 0, false, false, nullptr, "design_plane", "Reference plane (offset / tilt / midplane / tangent / two edges / coincident)"}, + {"axis", "Axis", 6, "Shift+A", "key:S+A", nullptr, 0x00000057u, 0, 0, false, false, nullptr, "design_line", "Datum axis (two points, face normal, cylinder centerline, two planes, along edge)"}, + {"coordsys_v", "Coord Sys", 6, "Shift+C", "key:S+C", nullptr, 0x00000043u, 0, 0, false, false, nullptr, "design_point", "Datum coordinate system (world point, or face + direction edge)"}, + {"helix", "Helix", 6, nullptr, "fly:plane#3", nullptr, 0x00000405u, 0, 0, false, false, nullptr, "design_thread", "Helical curve (spring path) — use as a sweep path for coils / springs / augers"}, + {"project", "Project", 6, nullptr, "fly:plane#4", "Project needs a body — add or import one first", 0x00000482u, 1, 0, false, false, nullptr, "design_sketch", "Project body edges onto a plane as sketch entities"}, + {"measure", "Measure", 6, nullptr, nullptr, nullptr, 0x000b03feu, 0, 0, false, false, nullptr, nullptr, "Measure between the picked points, edges or faces"}, + {"mass_props", "Mass", 6, nullptr, "btn:mass", nullptr, 0x000000feu, 1, 0, false, false, nullptr, "info", "Report the volume and surface area of the selected body"}, + {"interference", "Interference", 6, nullptr, nullptr, nullptr, 0x00000200u, 2, 0, false, false, nullptr, nullptr, "Check whether two bodies overlap — reports, changes nothing"}, + {"edit_feature", "Edit", 7, nullptr, "btn:edit", nullptr, 0x00007d8eu, 0, 0, false, false, nullptr, "design_edit", "Reopen the selected feature to change what it was made from"}, + {"rename", "Rename…", 7, "F2", "btn:rename", "Select a feature, or a body, to rename it", 0x00004080u, 0, 0, false, false, nullptr, nullptr, "Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)"}, + {"delete_face", "Delete Face", 7, nullptr, "fly:dressup#3", "Delete Face needs a body — add or import one first", 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", "Remove faces from a body and heal the solid"}, + {"colour", "Colour", 7, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", "Set the selected body's display colour"}, + {"delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", "Delete what is selected"}, + {"delete_body", "Delete Body", 7, nullptr, "btn:delete_body", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "design_delete", "Delete this whole body — removes the feature it was made from"}, + {"sk_line_t", "Line", 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, "Line", "design_line", "Line — click start, then end"}, + {"sk_polyline", "Polyline", 0, nullptr, "fly:design_line#1", nullptr, 0x000f8000u, 0, 0, false, true, "Line", "design_polyline", "Click points; click the first point to close the loop, right-click to end it open"}, + {"sk_rect", "Corner rectangle", 0, "R", "key:R", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect", "Rectangle — click two opposite corners"}, + {"sk_rect_center", "Centre rectangle", 0, nullptr, "fly:design_rect#1", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_crect", "Click center, then a corner"}, + {"sk_rect_oblique", "Oblique rectangle", 0, nullptr, "fly:design_rect#2", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect_oblique", "Click two corners of one edge, then a point for the width"}, + {"sk_rect_rounded", "Rounded rectangle", 0, nullptr, "fly:design_rect#3", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect_rounded", "Click two opposite corners, then a point for the corner radius"}, + {"sk_circle", "Centre circle", 0, "C", "key:C", nullptr, 0x000f8000u, 0, 0, false, true, "Circle", "design_circle", "Circle — click center, then radius"}, + {"sk_circle_2pt", "2-point circle", 0, nullptr, "fly:design_circle#1", nullptr, 0x000f8000u, 0, 0, false, true, "Circle", "design_circle2pt", "Click two ends of the diameter"}, + {"sk_circle_3pt", "3-point circle", 0, nullptr, "fly:design_circle#2", nullptr, 0x000f8000u, 0, 0, false, true, "Circle", "design_circle3pt", "Click three points on the circle"}, + {"sk_arc_t", "3-point arc", 0, "A", "key:A", nullptr, 0x000f8000u, 0, 0, false, true, "Arc", "design_arc3pt", "Arc — click start, end, then a point"}, + {"sk_arc_tangent", "Tangent arc", 0, nullptr, "fly:design_arc3pt#1", nullptr, 0x000f8000u, 0, 0, false, true, "Arc", "design_tangentarc", "Click start (on the last entity) then end"}, + {"sk_arc_center", "Centre-point arc", 0, nullptr, "fly:design_arc3pt#2", nullptr, 0x000f8000u, 0, 0, false, true, "Arc", "design_arc_center", "Click center, then start, then a point for the end angle"}, + {"sk_slot", "Slot", 0, "S", "key:S", nullptr, 0x000f8000u, 0, 0, false, true, "Slot", "design_slot", "Slot — two centerline ends, then end radius"}, + {"sk_slot_arc", "Arc slot", 0, nullptr, "fly:design_slot#1", nullptr, 0x000f8000u, 0, 0, false, true, "Slot", "design_slot_arc", "Click center, start, end, then a point for the width"}, + {"sk_ellipse", "Ellipse", 0, "E", "key:E", nullptr, 0x000f8000u, 0, 0, false, true, "Ellipse", "design_ellipse", "Ellipse — center, major end, minor point"}, + {"sk_ellipse_arc", "Elliptical arc", 0, nullptr, "fly:design_ellipse#1", nullptr, 0x000f8000u, 0, 0, false, true, "Ellipse", "design_ellipse_arc", "Click center, major-axis end, minor point, then arc start and end"}, + {"sk_spline", "Spline", 0, "B", "key:B", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_bspline", "Spline — click control points"}, + {"sk_poly_3", "Triangle", 0, nullptr, "btn:poly#3", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Triangle — click centre, then a vertex"}, + {"sk_poly_4", "Square", 0, nullptr, "btn:poly#4", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Square — click centre, then a vertex"}, + {"sk_poly_5", "Pentagon", 0, nullptr, "btn:poly#5", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Pentagon — click centre, then a vertex"}, + {"sk_polygon", "Hexagon", 0, "G", "btn:poly#6", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Hexagon — click centre, then a vertex"}, + {"sk_poly_8", "Octagon", 0, nullptr, "btn:poly#8", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Octagon — click centre, then a vertex"}, + {"sk_poly_12", "Dodecagon", 0, nullptr, "btn:poly#12", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Dodecagon — click centre, then a vertex"}, + {"sk_poly_inscribed", "Inscribed", 0, nullptr, "btn:polyfit#0", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Measure the polygon to its corners (inscribed)"}, + {"sk_poly_circumscribed", "Circumscribed", 0, nullptr, "btn:polyfit#1", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Measure the polygon to its flats (circumscribed)"}, + {"sk_point_t", "Point", 0, "P", "key:P", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_point", "Point — click to place"}, + {"sk_text", "Text", 0, nullptr, "btn:text", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_text", "Type text; its outline is added to this sketch as editable lines"}, + {"sk_svg", "SVG", 0, nullptr, "btn:svg", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_svg", "Import an SVG outline into this sketch as editable lines"}, + {"sk_offset", "Offset", 1, "O", "key:O", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_offset", "Offset — pick an entity, drag the distance"}, + {"sk_trim", "Trim", 2, "T", "key:T", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_trim", "Trim — click a segment to trim it"}, + {"sk_fillet", "Fillet", 3, "F", "key:F", nullptr, 0x00090000u, 0, 0, false, true, nullptr, "design_filletedge", "Fillet — pick two lines, set the radius"}, + {"sk_chamfer", "Chamfer", 3, "H", "key:H", nullptr, 0x00090000u, 0, 0, false, true, nullptr, "design_chamfer", "Chamfer — pick two lines, set the distance"}, + {"sk_array", "Linear array", 4, nullptr, "fly:design_array#0", nullptr, 0x000b0000u, 0, 0, false, true, "Array", "design_array", "Pick entities, drag the spacing handle, click the count; click empty to apply"}, + {"sk_array_polar", "Polar array", 4, nullptr, "fly:design_array#1", nullptr, 0x000b0000u, 0, 0, false, true, "Array", "design_polararray", "Pick entities, drag the sweep handle, click the count; click empty to apply"}, + {"sk_mirror", "Mirror", 4, "M", "key:M", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_mirror", "Mirror — pick axis, then entities"}, + {"sk_move", "Move", 5, nullptr, "fly:design_move#0", nullptr, 0x000f0000u, 0, 0, false, true, "Move", "design_move", "Pick entities, then drag the handle or click the distance; click empty to apply"}, + {"sk_rotate", "Rotate", 5, nullptr, "fly:design_move#1", nullptr, 0x000f0000u, 0, 0, false, true, "Move", "design_rotate", "Pick entities, then drag around the pivot or click the angle; click empty to apply"}, + {"sk_scale", "Scale", 5, nullptr, "fly:design_move#2", nullptr, 0x000f0000u, 0, 0, false, true, "Move", "design_scale", "Pick entities, then drag the handle or click the factor; click empty to apply"}, + {"sk_dimension", "Dimension", 6, "D", "key:D", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_dimension", "Dimension — click 2 points or an entity"}, + {"sk_constrain", "Constrain", 6, "K", "key:K", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_constrain", "Constrain the selected sketch entities to each other"}, + // Same verb, model-mode vocabulary: offered when a SKETCH is selected (bit 14, SkLoop), the + // state a user is in right after finishing one. Without this row the only way in was the + // toolbar icon, and constraints read as absent — see the Onshape-comparison report. + {"constrain", "Constrain sketch", 7, nullptr, "btn:constrain", "Select a sketch to constrain it", 0x00004000u, 0, 1, false, false, nullptr, "design_constrain", "Add dimensions and relations (coincident, tangent, parallel...) to the selected sketch"}, + {"sk_construct", "Construction", 6, "Q", "key:Q", nullptr, 0x000b8000u, 0, 0, false, true, nullptr, nullptr, "Toggle construction: geometry that guides but is never built"}, + {"sk_extend", "Extend", 7, "X", "key:X", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_extend", "Extend — click a line/arc to extend it"}, + {"sk_delete", "Delete", 7, "Del", "btn:sk_delete", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_delete", "Delete the selected sketch entities"}, + // Typing the defining number of the element you pointed at. Three rows rather than one so + // each names the quantity in the drawing-office word for THAT element; all three land on + // the same handler, because dimension_kind() already resolves the quantity from the + // selection. Without these, an element's own numbers were reachable only by arming the + // Dimension tool and re-picking geometry that was already selected. + {"sk_length", "Length…", 7, "V", "key:V", nullptr, 0x00010000u, 0, 0, false, true, nullptr, "design_dimension", "Type the length of this line"}, + {"sk_radius", "Radius / diameter…", 7, "V", "key:V", nullptr, 0x00020000u, 0, 0, false, true, nullptr, "design_dimension", "Type the radius of this arc, or the diameter of this circle"}, + {"sk_angdist", "Angle / distance…", 7, "V", "key:V", nullptr, 0x00080000u, 0, 0, false, true, nullptr, "design_dimension", "Type the angle between two lines, or the distance between the two picks"}, +}; +static const int kOfferVerbCount = 92; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_DesignOffer_hpp_ diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp new file mode 100644 index 0000000000..6d9454a829 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -0,0 +1,11775 @@ +#include "slic3r/GUI/CAD/DesignPanel.hpp" +#include "slic3r/GUI/CAD/DesignCanvas.hpp" +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" +#include "slic3r/GUI/CAD/DesignOffer.hpp" // generated offer table — see docs/ux/tool_atlas.json +#include "libslic3r/CAD/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor +#include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set +#include "libslic3r/Format/OBJ.hpp" +#include "libslic3r/Format/bbs_3mf.hpp" // put_other_changes: mark the project dirty outside the undo stack + +#include +#include +#include // the offer/atlas join check reports on the log +#include + +#include +#include // offer_trace: diagnostic row dump for the offer ladder +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // wxWindowDisabler, wxMilliSleep +#include // wxMessageBox + +#include +#include +#include +#include +#include +#include +#include + +#include "slic3r/GUI/wxExtensions.hpp" // ScalableButton, create_scaled_bitmap +#include "slic3r/GUI/Widgets/Label.hpp" // HarmonyOS Sans fonts (Head_*/Body_*) shared with the rest of Orca +#include "slic3r/GUI/Widgets/DropDown.hpp" // Orca-themed combo dropdown (white/teal selector) for the tool flyouts +#include "slic3r/GUI/Widgets/Button.hpp" // Orca-styled Button (ButtonStyle/ButtonType) — same look as Prepare +#include "slic3r/GUI/Widgets/CheckBox.hpp" // Orca teal check (label lives in the row's left column) +#include "slic3r/GUI/Widgets/ComboBox.hpp" // Orca dropdown — replaces wxChoice in every Design card +#include "slic3r/GUI/Widgets/StaticBox.hpp" // Prepare's rounded white card frame around each tool dialog +#include "libslic3r/CAD/SketchImport.hpp" // text_to_regions / svg_to_regions +#include "libslic3r/CAD/ThreadStandards.hpp" // ISO metric / Unified imperial thread tables +#include "libslic3r/Model.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Plater.hpp" +#include "libslic3r/BuildVolume.hpp" +#include "slic3r/GUI/MainFrame.hpp" +#include "slic3r/GUI/GUI_ObjectList.hpp" + +// English-only pin for the Design tab (see design-ux-contract): one lever +// de-translates this whole TU so our strings never half-translate against the host's +// localized chrome. Host UI still follows the app locale; only this tab is pinned EN. +// GOTCHA: every _L(...) in this file must take a STRING LITERAL (FromUTF8 wants const char*). +#ifdef _L +#undef _L +#endif +#define _L(s) wxString::FromUTF8(s) + +namespace Slic3r { namespace GUI { + +// Mesh -> B-rep import defaults (see GeometryEngine::mesh_to_brep). +// Tolerance is a vertex-dedup cell, not a sew tolerance: 10 um is well under any printable +// feature yet coarse enough to weld the float noise a mesh exporter leaves on shared vertices. +static constexpr double MESH_IMPORT_TOLERANCE = 0.01; // mm +// Merge coplanar neighbours so the body has real, pickable faces instead of one face per +// triangle. 5 deg tolerates the small normal jitter of an exported/scanned flat face while +// still keeping genuinely curved regions faceted. +static constexpr double MESH_IMPORT_MERGE_ANGLE_DEG = 5.0; +// Above this, warn before converting: the build is one OCCT face per triangle. +static constexpr size_t MESH_IMPORT_TRIANGLE_WARN = 50000; + +// Two-column parameter form (label left, control right), matching Prepare's row idiom. The +// control column grows, so every control lines up on the panel's right edge instead of sitting +// at its natural width beside the label — the forms used a plain non-growable grid before, which +// is why Design's rows looked nothing like Prepare's. +static wxFlexGridSizer* two_col_form() +{ + auto* f = new wxFlexGridSizer(2, 6, 8); + f->AddGrowableCol(1, 1); + f->SetFlexibleDirection(wxHORIZONTAL); + return f; +} + + +// Orca's dropdown, sized like Prepare's sidebar combos. Read-only: every Design +// picker is a fixed list, never free text. +static ComboBox* make_combo(wxWindow* parent) +{ + // Explicit width: ComboBox's natural best size is its widest item, which inflated the + // sidebar's virtual width past the viewport and left the panel horizontally scrolled — + // that is what clipped the first letter of every label. The form column grows anyway. + auto* c = new ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(parent->FromDIP(90), parent->FromDIP(24)), 0, nullptr, wxCB_READONLY); + c->SetMinSize(wxSize(parent->FromDIP(90), parent->FromDIP(24))); + return c; +} + +// Append a row that carries a feature/body index as client data. +// +// It must go through SetClientData(), not Append()'s clientData argument. Orca's ComboBox keeps +// client data in its own vector and its Append() writes that vector directly, never routing +// through wxItemContainer — so the container's m_clientDataItemsType stays wxClientData_None. +// wxItemContainer::GetClientData() opens with +// wxCHECK_MSG( HasClientUntypedData(), NULL, ... ); +// and wxCHECK_MSG is an early RETURN, not a debug-only assert. So the read handed back NULL for +// every row and every caller resolved it to index 0 no matter what the user had picked — silently, +// because index 0 is a legal answer. SetClientData() flips the type to wxClientData_Void, after +// which the value survives the round trip. +static int combo_append_index(ComboBox* c, const wxString& label, int index) +{ + const int row = c->Append(label, wxNullBitmap); + c->SetClientData(unsigned(row), reinterpret_cast(intptr_t(index))); + return row; +} + +// Format a value with the international ('.') decimal separator regardless of the +// app's LC_NUMERIC locale (wx sets it to the user locale at startup). snprintf may +// emit a comma, so normalise it. +static wxString en_format(double v, int digits = 2) +{ + char fmt[16]; + std::snprintf(fmt, sizeof(fmt), "%%.%df", digits); + char buf[64]; + std::snprintf(buf, sizeof(buf), fmt, v); + for (char* c = buf; *c; ++c) if (*c == ',') *c = '.'; + return wxString::FromUTF8(buf); +} +// Parse a user-typed value accepting either '.' or ',' as the decimal separator. +static bool en_parse(const wxString& text, double& out) +{ + wxString t(text); + t.Replace(wxT(","), wxT(".")); + return t.ToCDouble(&out); +} + +// Design-tab chrome tokens. The dark branch returns the EXACT legacy values so the +// (correct) dark theme stays byte-identical; the light branch maps each onto Orca's +// light surface so the ribbon/sidebar follow the app theme instead of staying black. +static bool dp_dark() { return wxGetApp().dark_mode(); } +static wxColour dp_ribbon_bg() { return dp_dark() ? wxColour(0x36,0x36,0x3C) : wxColour(0xEC,0xEC,0xEE); } +static wxColour dp_ribbon_hover() { return dp_dark() ? wxColour(0x4D,0x4D,0x54) : wxColour(0xD7,0xD7,0xDB); } +static wxColour dp_panel_bg() { return dp_dark() ? wxColour(0x2D,0x2D,0x30) : wxColour(0xFB,0xFB,0xFD); } +static wxColour dp_sec_text() { return dp_dark() ? wxColour(0x81,0x81,0x83) : wxColour(0x66,0x66,0x68); } +static wxColour dp_ctl_text() { return dp_dark() ? wxColour(0xC8,0xC8,0xC8) : wxColour(0x35,0x35,0x37); } +static wxColour dp_item_text() { return dp_dark() ? wxColour(0xE0,0xE0,0xE0) : wxColour(0x2C,0x2C,0x2E); } +static wxColour dp_item_dim() { return dp_dark() ? wxColour(0x80,0x80,0x80) : wxColour(0xA0,0xA0,0xA2); } + +// Prepare's control-outline grey, sampled from its sidebar: #4A4A51 on the #2D2D31 dark +// panel, #DBDBDB on light. Every framed thing in Design uses this so the tab matches. +static wxColour dp_border_col() { return dp_dark() ? wxColour(0x4A,0x4A,0x51) : wxColour(0xDB,0xDB,0xDB); } + +// A tool card: Prepare's rounded white-bordered panel (Plater.cpp's panel_printer_preset +// idiom — radius 8, #EEEEEE border, green on hover). Every card's controls are parented +// to it, so the border actually encloses them. +static StaticBox* make_card(wxWindow* parent) +{ + auto* c = new StaticBox(parent); + c->SetCornerRadius(8); + c->SetBorderColorNormal(dp_border_col()); // no hover accent: the frame is not clickable + return c; +} + +// Prepare outlines every numeric field (rounded, #4A4A51 on dark). wxSpinCtrlDouble is a +// native GTK control that cannot draw that, and Orca's own SpinInput is int-only — it would +// silently truncate a 2.5 mm radius. So the double spin keeps its arrows and validation and +// sits inside an Orca StaticBox that supplies the frame. spin_frame() returns that box, which +// is what goes into the layout sizer. +static wxSpinCtrlDouble* make_spin(wxWindow* parent, double val, + double mn = 0.1, double mx = 1000.0) +{ + auto* box = new StaticBox(parent); + box->SetCornerRadius(4); + box->SetBorderColorNormal(dp_border_col()); + auto* s = new wxSpinCtrlDouble(box, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1), + wxSP_ARROW_KEYS | wxBORDER_NONE); + s->SetRange(mn, mx); + s->SetDigits(2); + s->SetValue(val); + // THE WHEEL SCROLLS THE PANEL, IT DOES NOT EDIT THE VALUE. wxSpinCtrlDouble takes the wheel + // whenever the pointer is over it, so a card taller than the panel could not be scrolled past + // without silently incrementing whatever field happened to be under the cursor — measured: + // eight notches turned an X hint from 1,00 into 6,00, and the same gesture over an Extrude + // distance or a mate Offset is a silent model change made by someone who thought they were + // navigating. Skipping the event lets it reach the scrolled cards panel. A spin the user has + // deliberately focused still takes the wheel, which is the one case where editing is meant. + s->Bind(wxEVT_MOUSEWHEEL, [s](wxMouseEvent& e) { + if (wxWindow::FindFocus() == s) e.Skip(); // focused: wheel edits, as expected + else if (wxWindow* p = s->GetParent()) // otherwise hand it to the panel + wxPostEvent(p, e); + }); + auto* sz = new wxBoxSizer(wxHORIZONTAL); + sz->Add(s, 1, wxEXPAND | wxALL, parent->FromDIP(2)); + box->SetSizer(sz); + return s; +} + +// The framed spin's parent IS its frame; lay that out instead of the bare control. +static wxWindow* spin_frame(wxWindow* spin) { return spin->GetParent(); } + +static SketchPlane plane_from_index(int i) +{ + switch (i) { + case 1: return SketchPlane::XZ(); + case 2: return SketchPlane::YZ(); + default: return SketchPlane::XY(); + } +} + +// Inverse of plane_from_index: recover the ComboBox row from a plane's normal. +// XY normal=(0,0,1)->0, XZ normal=(0,1,0)->1, YZ normal=(1,0,0)->2. +static int index_from_plane(const SketchPlane& p) +{ + if (std::abs(p.normal.y()) > 0.5) return 1; // XZ + if (std::abs(p.normal.x()) > 0.5) return 2; // YZ + return 0; // XY +} + +// Does this plane survive a round trip through the Hole/Thread plane dropdown? The dropdown holds +// only XY/XZ/YZ and index_from_plane SNAPS anything else to the nearest of the three, so a plane +// that is not one of them cannot be restored from the row and has to be carried explicitly. +// Origin is compared too, not just the axes: a face plane parallel to XY but 12 mm up snaps to +// row 0 and would otherwise come back at z=0. Vector norms rather than isApprox, which compares +// relative to magnitude and so is useless against the zero origin. +// modeling_origin is not optional: hole_plane()/thread_plane() ADD it to the dropdown plane before +// the feature stores it, so on a document with a shifted origin every dropdown hole would fail a +// bare XY/XZ/YZ comparison and be mistaken for a face pick. +static bool is_base_plane(const SketchPlane& p, const Vec3d& modeling_origin) +{ + SketchPlane b = plane_from_index(index_from_plane(p)); + b.origin += modeling_origin; + const double e = 1e-6; + return (p.origin - b.origin).norm() < e && (p.normal - b.normal).norm() < e + && (p.x_axis - b.x_axis).norm() < e && (p.y_axis - b.y_axis).norm() < e; +} + +// #2: a sketch plane on `face` with origin at the face centroid and normal pointing INTO the +// solid, so a positioned hole drills inward and its (x,y) read as the offset from the face +// centre. A hole is rotationally symmetric, so the arbitrary in-plane basis is harmless. +static SketchPlane face_plane_inward(const TopoDS_Face& face) +{ + SketchPlane p; + p.origin = GeometryEngine::face_centroid_world(face); + p.normal = (-GeometryEngine::face_normal_world(face)).normalized(); // inward + // Align the in-plane x-axis with the face's LONGEST straight edge so the (u,v) frame matches + // the face sides — then "distance from a side" (the hole construction dims) reads correctly. + Vec3d x(0, 0, 0); double best = 0; + for (const TopoDS_Edge& e : GeometryEngine::edges_of_face(face)) { + const std::vector pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) continue; + Vec3d d = pts.back() - pts.front(); + d = d - p.normal * d.dot(p.normal); // project the edge direction into the plane + const double len = d.norm(); + if (len > best) { best = len; x = d / len; } + } + if (best < 1e-9) { // curved/edgeless face: fall back to an arbitrary in-plane basis + const Vec3d ref = std::abs(p.normal.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + x = ref.cross(p.normal).normalized(); + } + p.x_axis = x.normalized(); + p.y_axis = p.normal.cross(p.x_axis).normalized(); + return p; +} + +// Highest upward-facing planar face of a solid — the surface the user is looking down on. +// Hole placement defaults here (instead of the z=0 datum) so the footprint sits on the top +// face at the right depth, not on the model's underside where a top-view drag reads parallax- +// shifted. Returns -1 if the shape has no clearly-upward face. +static int top_face_index_of(const TopoDS_Shape& shape) +{ + int best = -1; double bestz = -1e30; + const int n = GeometryEngine::face_count(shape); + for (int i = 0; i < n; ++i) { + const TopoDS_Face f = GeometryEngine::face_by_index(shape, i); + if (f.IsNull()) continue; + const Vec3d nrm = GeometryEngine::face_normal_world(f); + if (nrm.z() < 0.5) continue; // only faces pointing substantially up + const Vec3d c = GeometryEngine::face_centroid_world(f); + if (c.z() > bestz) { bestz = c.z(); best = i; } + } + return best; +} + +DesignPanel::DesignPanel(wxWindow* parent) + : wxPanel(parent, wxID_ANY) +{ + // Left column: a slim feature-tree + docked tool-dialog column. All form + // controls are parented to m_form so it can scroll independently of the + // live GL viewport. The tool buttons live in the top toolbar (built below). + m_form = new wxScrolledWindow(this, wxID_ANY); + // The sidebar/panel never carried an explicit background, so in light theme it + // inherited the dark window colour and stayed black. Paint it on the light surface; + // dark is left untouched (it already reads correctly via inheritance). + if (!dp_dark()) { + SetBackgroundColour(dp_panel_bg()); + m_form->SetBackgroundColour(dp_panel_bg()); + } + + auto* root = new wxBoxSizer(wxVERTICAL); + { + auto* hdr = new wxStaticText(m_form, wxID_ANY, _L("Design")); + hdr->SetFont(Label::Head_16); // Orca shared HarmonyOS section-title font + root->Add(hdr, 0, wxLEFT | wxRIGHT | wxTOP, 12); + root->AddSpacer(2); + } + + // === Top contextual toolbar (Onshape-style icon strip) === + // Parented to the panel (sits above the form/viewport row). Only the active + // mode's group is shown; the others are hidden by set_ui_mode(). + // Scrollable ribbon: on a narrow/windowed screen the far-right action bar (Confirm/Cancel) + // used to be clipped off the edge with no way to reach it. Horizontal-only scroll (vertical + // rate 0) keeps it reachable; on a wide screen the stretch spacer still pins it far-right. + m_toolbar = new wxScrolledWindow(this, wxID_ANY); + m_toolbar->SetScrollRate(15, 0); + m_toolbar->ShowScrollbars(wxSHOW_SB_DEFAULT, wxSHOW_SB_NEVER); + // Theme-aware tool ribbon: dark uses Orca's elevated surface (#36363C / hover + // #4D4D54); light maps onto the app's light chrome so the strip follows the theme. + m_toolbar->SetBackgroundColour(dp_ribbon_bg()); + + const wxColour tb_bg = dp_ribbon_bg(); + const wxColour tb_hover = dp_ribbon_hover(); + auto icon_btn = [this, tb_bg, tb_hover](const char* icon, const wxString& tip) { + // Prepare's main toolbar: 40 px icon cell, 4 px gap (GLToolbar::Default_Icons_Size + // and set_gap_size(4)) -> 44 px pitch. Match it exactly. + auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); + b->SetToolTip(tip); + b->SetBackgroundColour(tb_bg); + m_tool_btns.push_back(b); + // Hover affordance, honouring the active-tool teal state. + b->Bind(wxEVT_ENTER_WINDOW, [this, b, tb_hover](wxMouseEvent& e) { + b->SetBackgroundColour(b == m_active_tool_btn ? wxColour(0x52, 0xC7, 0xB8) : tb_hover); + b->Refresh(); e.Skip(); }); + b->Bind(wxEVT_LEAVE_WINDOW, [this, b, tb_bg](wxMouseEvent& e) { + b->SetBackgroundColour(b == m_active_tool_btn ? wxColour(0x00, 0x96, 0x88) : tb_bg); + b->Refresh(); e.Skip(); }); + // Mark this tool active (teal) on press — a separate event from the + // button's command handler, so it never swallows the click action. + b->Bind(wxEVT_LEFT_DOWN, [this, b](wxMouseEvent& e) { + set_active_tool_btn(b); e.Skip(); }); + return b; + }; + // Small grey group caption (Onshape-style section hint) for each toolbar mode. + auto caption = [this](const wxString& t) { + auto* s = new wxStaticText(m_toolbar, wxID_ANY, t); + wxFont f = Label::Body_12; f.SetWeight(wxFONTWEIGHT_BOLD); + s->SetFont(f); + s->SetForegroundColour(dp_sec_text()); // Orca dark secondary text + return s; + }; + auto add_sep = [this](wxSizer* row) { + row->AddSpacer(5); + row->Add(new wxStaticLine(m_toolbar, wxID_ANY, wxDefaultPosition, wxSize(1, 22), wxLI_VERTICAL), + 0, wxALIGN_CENTER_VERTICAL); + row->AddSpacer(5); + }; + + // Shared sketch-tool selector: begins a session on first use, then switches + // the active entity tool. The Construction toggle marks following entities as + // construction geometry (excluded from the wire). + m_construction = new wxCheckBox(m_toolbar, wxID_ANY, _L("Construction")); + m_construction->SetForegroundColour(dp_ctl_text()); + auto select_tool = [this](DesignSketchTool::Mode mode, const wxString& hint) { + if (!m_viewport) return; + if (!m_viewport->is_sketching()) { + wxString on; + const SketchPlane plane = sketch_plane_from_selection(on); + m_viewport->begin_sketch(plane, mode); + m_construction->SetValue(false); // a fresh session starts non-construction + m_sketch_on = on; // shown with the tool hint, so the target is visible + // The face has been CONSUMED as the sketch plane, so drop the pick. Leaving it live + // meant the next Extrude saw a selected face and push/pulled it instead of extruding + // the sketch just drawn — the same trap the imported-art path already guards against. + if (m_sel_solid_face >= 0 || m_pick_face >= 0) { + m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1; + m_pick_face = m_pick_face_body = -1; + } + } else { + m_viewport->set_sketch_tool(mode); + } + m_viewport->set_sketch_construction(m_construction->GetValue()); + m_status->SetForegroundColour(wxNullColour); + set_status(m_sketch_on.IsEmpty() ? hint + : wxString::Format(_L("%s · on %s"), hint, m_sketch_on)); + m_status->Refresh(); + }; + + // Sketch-tool shortcuts (single letters, active only while a sketch is open). Family tools + // bind to their default mode; the other modes stay in the toolbar flyout. Registered here + // where select_tool is in scope; the closures run at key-press time (members are live by then). + auto sk_key = [this, select_tool](int ch, DesignSketchTool::Mode m, const wxString& h) { + m_keys_sketch[ch] = [this, select_tool, m, h] { select_tool(m, h); }; + }; + sk_key('L', DesignSketchTool::Mode::Line, _L("Line — click start, then end")); + sk_key('R', DesignSketchTool::Mode::CornerRect, _L("Rectangle — click two opposite corners")); + sk_key('C', DesignSketchTool::Mode::CenterCircle, _L("Circle — click center, then radius")); + sk_key('A', DesignSketchTool::Mode::ThreePointArc,_L("Arc — click start, end, then a point")); + sk_key('S', DesignSketchTool::Mode::Slot, _L("Slot — two centerline ends, then end radius")); + sk_key('E', DesignSketchTool::Mode::Ellipse, _L("Ellipse — center, major end, minor point")); + sk_key('B', DesignSketchTool::Mode::BSpline, _L("Spline — click control points")); + sk_key('P', DesignSketchTool::Mode::Point, _L("Point — click to place")); + sk_key('D', DesignSketchTool::Mode::Dimension, _L("Dimension — click 2 points or an entity")); + sk_key('T', DesignSketchTool::Mode::Trim, _L("Trim — click a segment to trim it")); + sk_key('X', DesignSketchTool::Mode::Extend, _L("Extend — click a line/arc to extend it")); + sk_key('O', DesignSketchTool::Mode::Offset, _L("Offset — pick an entity, drag the distance")); + sk_key('M', DesignSketchTool::Mode::Mirror, _L("Mirror — pick axis, then entities")); + sk_key('F', DesignSketchTool::Mode::Fillet, _L("Fillet — pick two lines, set the radius")); + sk_key('H', DesignSketchTool::Mode::Chamfer, _L("Chamfer — pick two lines, set the distance")); + // Polygon needs its side count / circumscribed flag pushed to the tool before it starts. + m_keys_sketch['G'] = [this, select_tool] { + if (m_viewport) { + push_polygon_params(); + } + select_tool(DesignSketchTool::Mode::Polygon, _L("Polygon — click center, then a vertex")); + }; + // Constrain (finish the live sketch + enter constrain), and Construction toggle. + // V for Value: type the defining number of whatever is selected — a line's length, an arc's + // radius, a circle's diameter, the angle between two lines. One handler behind three offer + // rows, because the quantity comes from the selection, not from which row was clicked. + // + // It needs a KEY, not just a menu row. The deck profile in VSD_n1_streamcontroller is + // generated from these two key tables, so a verb with no shortcut cannot be put on a + // physical button at all — which is the whole point of that profile for a user who drives + // the app from buttons rather than a menu. + m_keys_sketch['V'] = [this] { + if (m_viewport && m_viewport->is_sketching() && !m_viewport->edit_sketch_selection_value()) + set_status(_L("Nothing here has a value to type — pick a line, an arc, a circle, or two entities")); + }; + m_keys_sketch['K'] = [this] { enter_constrain_inline(); }; + // N: square up to the plane. Not a view preference but part of drawing — a sketch read at an + // angle is a sketch whose right angles do not look like right angles, and no hand-orbit lands + // exactly normal. Sketch map only: in Feature mode the navigator orb owns orientation. + m_keys_sketch['N'] = [this] { + if (m_viewport && m_viewport->view_normal_to_sketch()) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Normal to the sketch plane")); + m_status->Refresh(); + } + }; + m_keys_sketch['Q'] = [this] { + // With geometry selected, Q converts THAT geometry (6zic) — the reading + // everyone arrives with from other sketchers. With nothing selected it keeps its + // old meaning: arm construction for whatever you draw next. + if (m_viewport && m_viewport->is_sketching() && + m_viewport->toggle_sketch_construction_selection() > 0) { + set_status(_L("Converted the selection between construction and real geometry")); + return; + } + if (m_construction) { + m_construction->SetValue(!m_construction->GetValue()); + if (m_viewport && m_viewport->is_sketching()) + m_viewport->set_sketch_construction(m_construction->GetValue()); + } + }; + + // Shift+letter encoder for the feature-tool shortcuts (registered via FeatVar::key below, + // and explicitly for the standalone feature buttons). + auto SHIFT = [](int ch) { return ch | SC_SHIFT; }; + + // View toggles (single letters, active when no sketch is open): P origin planes, A world + // axes, X section view (Alt+Wheel slides the cut). Distinct from Shift+P/Shift+X features. + auto status_flag = [this](const wxString& on_msg, const wxString& off_msg, bool on) { + m_status->SetForegroundColour(wxNullColour); + set_status(on ? on_msg : off_msg); + m_status->Refresh(); + }; + m_keys_feature['P'] = [this, status_flag] { + if (m_viewport) status_flag(_L("Origin planes shown"), _L("Origin planes hidden"), + m_viewport->toggle_planes()); + }; + m_keys_feature['A'] = [this, status_flag] { + if (m_viewport) status_flag(_L("World axes shown"), _L("World axes hidden"), + m_viewport->toggle_axes()); + }; + m_keys_feature['X'] = [this] { toggle_section_view(); }; // toggle the single section on/off + + // Home: axonometric view, fitted to the model. + // + // DesignCanvas::set_view() and fit_view() were written and then never called from + // anywhere in the tree, so the Design viewport had NO way back to a standard view — + // no key, no button, nothing but orbiting by hand until the model happened to be in + // frame. A camera left pointing along the bed plane looks exactly like a renderer + // that has failed, which is how this was found. + // + // Home rather than a letter because every letter A-Z is already a Shift+letter tool + // shortcut, and because Home is the reset-the-view key users arrive with. set_view() + // already does select_view + zoom_to_volumes, so this is fit and orient in one. + m_keys_feature[WXK_HOME] = [this] { + if (!m_viewport) return; + m_viewport->set_view("iso"); + set_status(_L("Isometric view, fitted")); + }; + + // Commit to Plate and the bed toggle were mouse-only: a toolbar button and a checkbox with + // no accelerator between them, so neither could be reached from the keyboard at all, nor by + // anything driving the keyboard. Ctrl+Shift+P is Plate, Ctrl+Shift+B is Bed; neither + // collides with Orca's own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate). + m_keys_feature['P' | SC_SHIFT | SC_CTRL] = [this] { on_commit(); }; + m_keys_feature['B' | SC_SHIFT | SC_CTRL] = [this] { + if (!m_show_bed) return; + const bool show = !m_show_bed->GetValue(); + m_show_bed->SetValue(show); + if (m_viewport) m_viewport->set_show_bed(show); + set_status(show ? _L("Bed shown") : _L("Bed hidden")); + }; + + // Shared flyout glyph tint (used by BOTH the feature and sketch toolbars). Re-tint each + // design_* glyph to the DropDown's resolved TEXT colour so it reads on the popup in either + // theme: text_color is 0x363636, which darkModeColorFor() maps to a light tone in dark mode + // (the popup bg is darkModeColorFor(white) = dark) and leaves dark in light mode. The alpha + // (the glyph shape) is preserved; only RGB is replaced. + // ponytail: wxBitmap(img) drops the HiDPI scale factor (no scale ctor before wx 3.1.6); the + // deploy target runs at scale 1.0, so this is exact there. + const wxColour drop_icon_col = StateColor::darkModeColorFor(wxColour(0x36, 0x36, 0x36)); + auto tint = [](wxBitmap bmp, const wxColour& c) -> wxBitmap { + if (!bmp.IsOk()) return bmp; + wxImage img = bmp.ConvertToImage(); + if (!img.HasAlpha()) img.InitAlpha(); + const int w = img.GetWidth(), h = img.GetHeight(); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + img.SetRGB(x, y, c.Red(), c.Green(), c.Blue()); + return wxBitmap(img); + }; + + // --- Feature group: Sketch / Extrude / Fillet-Chamfer / Hole / Thread / Constrain + m_tb_feature = new wxBoxSizer(wxHORIZONTAL); + // Buttons are created in whatever order reads best in code, but laid out in the order the + // user specified. fadd() records into tb_slot; the flush below the doc group emits them. + // A dropdown occupies two entries (button + chevron) that must stay adjacent. + std::map> tb_slot; + // THE TOOLBAR IS CHROME. Every CAD verb is reached from the offer, so a tool's button is + // still BUILT — that is what registers its "fly:#" address and its Shift+key — + // but it is never placed on the bar. Hiding rather than skipping construction is deliberate: + // the addresses are created inside the widget-building loops, so not building would silently + // delete 42 verbs from the offer while they still rendered. 7ih records the cleanup + // that lets the construction go away too. + // What stays: the two doc-row imports (consumed by add_doc below) and the view controls, + // which are chrome_only in the atlas and so have no offer row to fall back on. + static const std::set kBarKeep = { "step", "mesh", "place", "section", "flip" }; + auto fadd = [&tb_slot](const char* id, wxWindow* w) { + if (kBarKeep.count(id) == 0) { w->Hide(); return; } + tb_slot[id].push_back(w); + }; + m_tb_feature->Add(caption(_L("FEATURES")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + { + // Onshape-style FEATURE flyouts: same themed-DropDown pattern as the sketch toolbar + // (tinted glyphs, Body_14 measure, content-width popup) but each entry runs an + // arbitrary action — the existing per-feature handler — instead of selecting a Mode. + struct FeatVar { const char* icon; wxString tip; wxString hint; std::function action; int key = 0; }; + struct FeatFlyout { + std::vector items; // mainline DropDown is Item-based (text/tip/icon per row) + std::vector> actions; + std::vector icon_names; + ScalableButton* btn = nullptr; + DropDown drop; // declared LAST: destroyed before the vector it references + FeatFlyout() : drop(items) {} + }; + auto feat_dropdown = [&](const char* id, const char* def_icon, const wxString& grp, std::vector vars) { + // REGISTER FIRST, BUILD SECOND. A verb's two addresses — "fly:#" for the + // offer and its Shift+key — are data. The widget is one door onto them, not their + // owner. Doing this before any wxWindow exists is what lets the build below be skipped + // outright for a family the bar no longer carries. It used to sit INSIDE the build + // loop, so a retired family still had to be constructed and then Hide()n: skipping it + // would have deleted 42 verbs from the offer while their rows still rendered and did + // nothing when picked. 7ih. + // Keyed on "fly:#" so the generated table can name a variant without the + // item struct growing a field at 26 call sites. + for (size_t i = 0; i < vars.size(); ++i) { + if (vars[i].key) m_keys_feature[vars[i].key] = vars[i].action; // key runs the same action + m_verb_actions["fly:" + std::string(id) + "#" + std::to_string(i)] = vars[i].action; + } + if (kBarKeep.count(id) == 0) + return; // reached from the offer alone — no button, no chevron, no popup + auto* b = icon_btn(def_icon, grp); + b->SetFont(Label::Body_14); // measure popup labels in the popup's font (no truncation) + auto fo = std::make_shared(); + for (auto& v : vars) { + DropDown::Item it; + it.text = v.tip; + it.tip = v.hint; + it.icon = tint(create_scaled_bitmap(v.icon, m_form, 18), drop_icon_col); + fo->items.push_back(it); + fo->actions.push_back(std::move(v.action)); + fo->icon_names.emplace_back(v.icon); + } + fo->btn = b; + fo->drop.Create(b); + fo->drop.SetUseContentWidth(true, false); + fo->drop.Invalidate(true); + FeatFlyout* fp = fo.get(); + fo->drop.Bind(wxEVT_COMBOBOX, [this, fp](wxCommandEvent& e) { + int i = e.GetInt(); + if (i >= 0 && i < (int) fp->actions.size()) { + fp->btn->SetBitmap_(fp->icon_names[i]); // button face follows the last pick + fp->actions[i](); + set_active_tool_btn(fp->btn); + } + }); + b->Bind(wxEVT_BUTTON, [b, fp](wxCommandEvent&) { + // Force a fresh content measure before Popup() (ComboBox does this via the + // private autoPosition()); otherwise the popup maps at a stale narrow size. + fp->drop.Invalidate(true); + fp->drop.SetUseContentWidth(false, false); + fp->drop.SetUseContentWidth(true, false); + wxPoint pos = b->ClientToScreen(wxPoint(0, -6)); + fp->drop.Position(pos, wxSize(0, b->GetSize().y + 12)); + fp->drop.Popup(); + }); + m_flyout_keepalive.push_back(fo); + fadd(id, b); + auto* chev = new wxStaticText(m_toolbar, wxID_ANY, wxString::FromUTF8("\xE2\x96\xBE")); + chev->SetForegroundColour(dp_sec_text()); + chev->SetFont(Label::Body_9); + fadd(id, chev); + }; + + auto* b_sketch = icon_btn("design_sketch", _L("Sketch")); + // Sketch means a tool. Pressing it used to set the mode and then ask, unconditionally, + // for the very thing the user had just done — click XZ, read "XZ plane selected, press + // Sketch", press Sketch, and be told to click a reference plane. The plane was never + // lost (m_ref_plane holds it and begin_sketch captures it when the first tool is armed); + // the sentence was simply false, and with right-click excluded in sketch mode there was + // no door to the tools at all, so the only way on was the toolbar this tab is retiring. + std::function act_sketch = [this] { + set_ui_mode(UiMode::Sketch); + wxString where; + const bool have_plane = sketch_plane_target(where); + m_status->SetForegroundColour(wxNullColour); + set_status(have_plane + ? wxString::Format(_L("Sketching on %s — pick a tool"), where) + : _L("Click a face or a reference plane in the viewport, then a sketch tool")); + m_status->Refresh(); + if (m_sketch_hint) { // the card must agree with the status line, not argue with it + m_sketch_hint->SetLabel(have_plane + ? wxString::Format(_L("Drawing on %s.\nPick a tool, or press Menu for the list."), where) + : _L("Click a face or a reference plane, then a sketch tool.")); + m_sketch_hint->Refresh(); + m_cards->Layout(); + } + // Hand over the tools rather than naming them in a status line. CallAfter so the + // mode change has settled before a modal menu takes the loop; the menu carries each + // tool's shortcut, so pressing the key instead of picking a row costs nothing. + if (have_plane) + CallAfter([this] { show_offer_menu(offer_anchor()); }); + }; + b_sketch->Bind(wxEVT_BUTTON, [act_sketch](wxCommandEvent&) { act_sketch(); }); + m_keys_feature[SHIFT('S')] = act_sketch; + fadd("sketch", b_sketch); + // Add material: every feature that grows new solid material — from a profile + // (extrude/revolve/sweep/loft), from a face (thicken) or from a line (rib). + feat_dropdown("material", "design_extrude", _L("Add material (extrude / revolve / sweep / loft / thicken / rib)"), { + {"design_extrude", _L("Extrude"), _L("Extrude a sketch profile, or push/pull a picked face"), + [this] { + // Onshape push/pull: an explicitly picked solid face (Face-level cycle, no loop + // selected) is extruded as the profile — this takes priority over re-extruding an + // already-consumed sketch (resolve_extrude_sketch always returns the last Sketch). + if (m_sel_solid_face >= 0 && !m_doc.body.IsNull() && m_sel_sketch_region < 0) { + m_extrude_face_src = m_sel_solid_face; + m_extrude_sketch_ref = -1; + open_tool(Tool::Extrude); + return; + } + m_extrude_face_src = -1; // ordinary sketch/loop extrude + m_extrude_sketch_ref = resolve_extrude_sketch(); + if (m_extrude_sketch_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a sketch, or pick a solid face, first")); + m_status->Refresh(); + return; + } + open_tool(Tool::Extrude); + }, SHIFT('E')}, + {"design_revolve", _L("Revolve"), _L("Revolve a profile about an axis"), + [this] { + m_revolve_sketch_ref = resolve_extrude_sketch(); + if (m_revolve_sketch_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a sketch profile to revolve first")); + m_status->Refresh(); + return; + } + open_tool(Tool::Revolve); + }, SHIFT('R')}, + {"design_sweep", _L("Sweep"), _L("Sweep a profile along a path"), + [this] { + m_sweep_profile_ref = resolve_extrude_sketch(); + m_sweep_path_ref = -1; // fresh sweep: default the picker to the first sketch + if (m_sweep_profile_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a profile sketch to sweep first")); + m_status->Refresh(); + return; + } + open_tool(Tool::Sweep); + }, SHIFT('W')}, + {"design_loft", _L("Loft"), _L("Loft (skin) between two or more profiles"), + [this] { + // Loft skins 2+ profile sketches; need at least two to be meaningful. + int n = 0; + for (const auto& f : m_doc.features) + if (f.type == CadFeatureType::Sketch) ++n; + if (n < 2) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create at least two profile sketches to loft")); + m_status->Refresh(); + return; + } + m_loft_refs.clear(); // fresh loft: nothing pre-checked + open_tool(Tool::Loft); + }, SHIFT('L')}, + {"design_thicken", _L("Thicken"), _L("Offset a solid face into a thin plate (new body)"), + [this] { + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Thicken needs a solid body — add or import one first")); + m_status->Refresh(); + return; + } + { + m_thicken_body->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + const std::string& n = m_doc.bodies[i].name; + m_thicken_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (m_thicken_body->GetCount() > 0) + m_thicken_body->SetSelection(std::min(selected_body_default(), + int(m_thicken_body->GetCount()) - 1)); + } + // KEEP a face the user has already picked. Clearing it unconditionally was right + // when the only door was a toolbar button, which carries no selection: you pressed + // Thicken and were then asked to point at something. Reached from the offer the + // verb is invoked ON a face, so discarding it opened the card reading "(pick a + // solid face)" over an immediate "thicken: face not found" — the user pointed at + // the face and the card said it could not find one. kgx. + // The index is per-body, so it only survives if the body combo landed on the body + // it came from; selected_body_default() above returns exactly that when valid. + if (m_thicken_body->GetSelection() != m_sel_solid_body) + m_sel_solid_face = -1; + open_tool(Tool::Thicken); // syncs m_thicken_face_label from m_sel_solid_face + }, 0}, + {"design_rib", _L("Rib"), _L("Grow a thin wall from an open sketch line, fused to a body"), + [this] { + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Rib needs a solid body — add or import one first")); + m_status->Refresh(); + return; + } + { + m_rib_body->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + const std::string& n = m_doc.bodies[i].name; + m_rib_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (m_rib_body->GetCount() > 0) + m_rib_body->SetSelection(std::min(selected_body_default(), + int(m_rib_body->GetCount()) - 1)); + } + { + m_rib_sketch->Clear(); + for (int i = 0; i < int(m_doc.features.size()); ++i) { + // 3-arg Append: ComboBox's own Append(text, bitmap) hides + // wxItemContainer's (text, void*) — see the Sweep picker. + // Project features too: they carry Line entities the kernel ribs from + // just like a drawn sketch, so a projected body edge is a valid path. + if (m_doc.features[i].type == CadFeatureType::Sketch || + m_doc.features[i].type == CadFeatureType::Project) + combo_append_index(m_rib_sketch, wxString::FromUTF8(m_doc.features[i].name), i); + } + if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0); + else { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a sketch with an open line first")); + m_status->Refresh(); + return; + } + } + open_tool(Tool::Rib); + }, 'R'}, // plain R, not SHIFT+R (that is Revolve): every SHIFT letter A-Z + // was already taken, and the feature map already carries unshifted + // keys (P, A, X). Rib was the only verb in this dropdown with no + // shortcut at all, which also made its card unreachable to the rig. + }); + + auto* b_pattern = icon_btn("design_pattern", _L("Pattern")); + std::function act_pattern = [this] { + // Pattern replicates an existing body — needs at least one solid. + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a solid body to pattern first")); + m_status->Refresh(); + return; + } + open_tool(Tool::Pattern); + }; + b_pattern->Bind(wxEVT_BUTTON, [act_pattern](wxCommandEvent&) { act_pattern(); }); + m_keys_feature[SHIFT('N')] = act_pattern; + fadd("pattern", b_pattern); + m_body_gates.push_back({b_pattern, 1, b_pattern->GetToolTipText(), + _L("Pattern — needs a solid body to replicate")}); + + // Surface: sheet-body tools (extrude / revolve / loft / fill / offset / thicken) + // The drawer BUTTON is design_surface, not design_extrude: sharing a face with the + // Add-material drawer made the two buttons indistinguishable in the bar. The entries + // inside may reuse the solid glyphs — a menu row carries its own text label. + feat_dropdown("surface", "design_surface", _L("Surface (extrude / revolve / loft / fill / offset / thicken)"), { + {"design_extrude", _L("Surface Extrude"), _L("Extrude a sketch into a sheet body (no end caps)"), + [this] { + m_surf_extrude_sketch_ref = resolve_extrude_sketch(); + if (m_surf_extrude_sketch_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a sketch first")); + m_status->Refresh(); + return; + } + open_tool(Tool::SurfaceExtrude); + }, SHIFT('G')}, + {"design_revolve", _L("Surface Revolve"), _L("Revolve a sketch profile into a sheet body"), + [this] { + m_surf_revolve_sketch_ref = resolve_extrude_sketch(); + if (m_surf_revolve_sketch_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a sketch profile to revolve first")); + m_status->Refresh(); + return; + } + open_tool(Tool::SurfaceRevolve); + }, SHIFT('J')}, + {"design_loft", _L("Surface Loft"), _L("Loft (skin) between 2+ profiles, open (no end caps)"), + [this] { + int n = 0; + for (const auto& f : m_doc.features) + if (f.type == CadFeatureType::Sketch) ++n; + if (n < 2) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create at least two profile sketches to loft")); + m_status->Refresh(); + return; + } + m_surf_loft_refs.clear(); + open_tool(Tool::SurfaceLoft); + }, SHIFT('O')}, + {"design_surface", _L("Surface Fill"), _L("Fill a sketch boundary with a smooth face"), + [this] { + m_surf_fill_sketch_ref = resolve_extrude_sketch(); + if (m_surf_fill_sketch_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a sketch profile to fill first")); + m_status->Refresh(); + return; + } + open_tool(Tool::SurfaceFill); + }, SHIFT('Q')}, + {"design_offset", _L("Surface Offset"), _L("Offset a sheet body's shell by a signed distance"), + [this] { + populate_sheet_body_choices(m_surf_offset_body); + if (m_surf_offset_body->GetCount() == 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("No sheet body to offset — create a surface feature first")); + m_status->Refresh(); + return; + } + open_tool(Tool::SurfaceOffset); + }, SHIFT('U')}, + {"design_thicken", _L("Thicken Surface"), _L("Thicken a sheet body into a solid"), + [this] { + populate_sheet_body_choices(m_surf_thicken_body); + if (m_surf_thicken_body->GetCount() == 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("No sheet body to thicken — create a surface feature first")); + m_status->Refresh(); + return; + } + open_tool(Tool::ThickenSurface); + }, SHIFT('V')}, + }); + + feat_dropdown("plane", "design_plane", _L("Datum / Curve (plane / axis / coord sys / helix / project)"), { + {"design_plane", _L("Plane"), _L("Reference plane (offset / tilt / midplane / tangent / two edges / coincident)"), + [this] { + populate_plane_choices(m_plane_base); + reset_plane_refs(); + open_tool(Tool::Plane); + }, SHIFT('P')}, + {"design_line", _L("Axis"), _L("Datum axis (two points, face normal, cylinder centerline, two planes, along edge)"), + [this] { + populate_plane_choices(m_axis_plane_a); + populate_plane_choices(m_axis_plane_b); + reset_axis_refs(); + open_tool(Tool::Axis); + }, SHIFT('A')}, + {"design_point", _L("Coord Sys"), _L("Datum coordinate system (world point, or face + direction edge)"), + [this] { + reset_coordsys_refs(); + open_tool(Tool::CoordSys); + }, SHIFT('C')}, + {"design_thread", _L("Helix"), _L("Helical curve (spring path) — use as a sweep path for coils / springs / augers"), + [this] { + populate_plane_choices(m_helix_plane); + open_tool(Tool::Helix); + }, 0}, + // Project belongs here, not in Dress-up: it consumes a body but PRODUCES sketch + // geometry, so it is reference/curve creation like the four above, not a finishing op. + {"design_sketch", _L("Project"), _L("Project body edges onto a plane as sketch entities"), + [this] { + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Project needs a body — add or import one first")); + m_status->Refresh(); + return; + } + { + m_proj_source_body->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + const std::string& n = m_doc.bodies[i].name; + m_proj_source_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (m_proj_source_body->GetCount() > 0) + m_proj_source_body->SetSelection(std::min(selected_body_default(), + int(m_proj_source_body->GetCount()) - 1)); + } + populate_plane_choices(m_proj_plane); + m_sel_solid_face = -1; + m_proj_face_label->SetLabel(_L("(all edges)")); + open_tool(Tool::Project); + }, 0}, + }); + + // Placement — operations that MOVE a body without changing its shape. Transform and + // Mirror place one body directly; a Mate places one body relative to another. They were + // in Dress-up (fillet/draft/shell) and Datum, which mixed shape-finishing and reference + // geometry with rigid-body placement. + // Own slot id: "place" is taken by the Place-on-Face button, and put() formats slot + // item 0 as the control and every later item as its chevron, so sharing a slot would + // bottom-align this drawer's button like a chevron. + feat_dropdown("placement", "design_move", _L("Placement (transform / mirror / mate)"), { + {"design_move", _L("Transform"), _L("Move and/or rotate an existing body"), + [this] { + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Transform needs a body — add or import one first")); + m_status->Refresh(); + return; + } + { + m_xf_body->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + const std::string& n = m_doc.bodies[i].name; + m_xf_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (m_xf_body->GetCount() > 0) + m_xf_body->SetSelection(std::min(selected_body_default(), + int(m_xf_body->GetCount()) - 1)); + } + open_tool(Tool::Transform); + }, SHIFT('Y')}, + {"design_mirror", _L("Mirror"), _L("Reflect a body about a plane"), + [this] { + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mirror needs a body — add or import one first")); + m_status->Refresh(); + return; + } + { + m_mirror_body->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + const std::string& n = m_doc.bodies[i].name; + m_mirror_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (m_mirror_body->GetCount() > 0) + m_mirror_body->SetSelection(std::min(selected_body_default(), + int(m_mirror_body->GetCount()) - 1)); + } + populate_plane_choices(m_mirror_plane); + open_tool(Tool::Mirror); + }, SHIFT('Z')}, + {"design_c_coincident", _L("Mate"), _L("Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)"), + [this] { + open_tool(Tool::Mate); + }, 0}, + }); + + auto* b_boolean = icon_btn("design_boolean", _L("Boolean (combine bodies)")); + std::function act_boolean = [this] { on_boolean_tool(); }; + b_boolean->Bind(wxEVT_BUTTON, [act_boolean](wxCommandEvent&) { act_boolean(); }); + m_keys_feature[SHIFT('B')] = act_boolean; + fadd("boolean", b_boolean); + m_body_gates.push_back({b_boolean, 2, b_boolean->GetToolTipText(), + _L("Boolean — needs two solid bodies to combine")}); + + auto* b_cut = icon_btn("design_cut", _L("Cut (split a body with a plane)")); + std::function act_cut = [this] { + // A plane cut needs at least one solid to slice. + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Create a solid body to cut first")); + m_status->Refresh(); + return; + } + populate_plane_choices(m_cut_plane); + populate_body_choices(); + open_tool(Tool::Cut); + }; + b_cut->Bind(wxEVT_BUTTON, [act_cut](wxCommandEvent&) { act_cut(); }); + m_keys_feature[SHIFT('X')] = act_cut; + fadd("cut", b_cut); + m_body_gates.push_back({b_cut, 1, b_cut->GetToolTipText(), + _L("Cut — needs a solid body to slice")}); + + // Color — override the selected body's display colour (per-body, survives recompute). + auto* b_color = icon_btn("color_palette", _L("Color — set the selected body's display colour")); + b_color->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); + fadd("color", b_color); + m_verb_actions["btn:colour"] = [this] { on_set_body_color(); }; + m_verb_actions["btn:delete"] = [this] { on_delete_feature(); }; + // Rename exists as a slow double-click on the row too, but the offer is this tab's only + // tool vocabulary — a rename that only a double-click reveals is not discoverable, and + // the row IS the object, so it belongs in the menu (and on F2) as well as on the row. + auto rename_feature = [this] { + // A BODY renames ITSELF. The earlier version resolved the body to + // CadBody::source_feature and renamed that feature, which is the wrong object: a + // body accumulates many features and the first one is not its name. The body row + // is editable now (CadBody::user_name), so the verb opens the editor there. + const int b = tree_body_selection(); + if (b >= 0 && b < int(m_tree_body_items.size())) { + const wxTreeItemId row = m_tree_body_items[b]; + // After the menu, not inside it: an editor opened from within PopupMenu's + // nested loop never appears. + CallAfter([this, row] { m_parts->SetFocus(); m_parts->EditLabel(row); }); + return; + } + const int sel = tree_selection(); + if (sel != wxNOT_FOUND && sel < int(m_tree_items.size())) { + const wxTreeItemId row = m_tree_items[sel]; + CallAfter([this, row] { m_tree->SetFocus(); m_tree->EditLabel(row); }); + } else { + m_status->SetForegroundColour(wxNullColour); // "nothing selected" is not an error + set_status(_L("Select a feature, or a body, first — then rename it")); + m_status->Refresh(); + } + }; + m_verb_actions["btn:rename"] = rename_feature; + m_keys_feature[WXK_F2] = rename_feature; // a function key, so no letter space spent + // The sketch's own Delete. It used to share "btn:delete" with the feature tree, so + // choosing Delete on a selected LINE ran on_delete_feature() and removed a tree row (or + // nothing) while the line stayed — the reported "I click a line and cannot remove it". + m_verb_actions["btn:sk_delete"] = [this] { + if (m_viewport && m_viewport->is_sketching()) + m_viewport->delete_selected_sketch_entities(); + else + on_delete_feature(); + }; + + m_verb_actions["btn:delete_body"] = [this] { on_delete_body(); }; + m_verb_actions["btn:edit"] = [this] { on_edit_feature(); }; + m_verb_actions["btn:mass"] = [this] { on_mass_properties(); }; + // Reachable from the offer menu on a SELECTED SKETCH, not only from the toolbar icon. + // A user evaluating against Onshape reported that "adding constraints seems to be + // missing" — with nineteen constraint types and a solver shipped. The only paths in + // were an icon-only button and a sketch-mode-only offer row filed under "Reference", + // so after finishing a sketch there was no affordance where users actually look. + m_verb_actions["btn:constrain"] = [this] { + on_begin_constrain(); + if (m_viewport && (m_viewport->is_constraining() || m_viewport->is_constraining_entities())) + set_ui_mode(UiMode::Constrain); + }; + + // Dress-up: finishing operations on the faces and edges of an existing solid — nothing + // that moves a body (see the Placement drawer) and nothing that creates geometry. + feat_dropdown("dressup", "design_dressup", _L("Fillet / chamfer / draft / shell / delete face"), { + {"design_dressup", _L("Fillet / Chamfer"), _L("Round or bevel a picked edge"), + [this] { open_tool(Tool::Dressup); }, SHIFT('F')}, + {"design_draft", _L("Draft (taper a face)"), _L("Tilt a picked face by a draft angle"), + [this] { open_tool(Tool::Draft); }, SHIFT('D')}, + {"design_shell", _L("Shell"), _L("Hollow the body to a wall thickness, opening a picked face"), + [this] { open_tool(Tool::Shell); }, SHIFT('K')}, + {"design_delete", _L("Delete Face"), _L("Remove faces from a body and heal the solid"), + [this] { + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Delete Face needs a body — add or import one first")); + m_status->Refresh(); + return; + } + { + m_del_face_body->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + const std::string& n = m_doc.bodies[i].name; + m_del_face_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (m_del_face_body->GetCount() > 0) + m_del_face_body->SetSelection(std::min(selected_body_default(), + int(m_del_face_body->GetCount()) - 1)); + } + m_del_faces.clear(); + m_del_face_list->SetLabel(_L("(none)")); + open_tool(Tool::DeleteFace); + }, 0}, + }); + + // Hole / Thread — drilling into a solid (both face-aware) + feat_dropdown("hole", "design_hole", _L("Hole / thread"), { + {"design_hole", _L("Hole"), _L("Drill a hole, centred on a picked face or placed on a plane"), + [this] { + // #2: drill on the picked solid face, centred on it (origin = face centroid, + // normal = inward). Otherwise fall back to the plane dropdown. m_hole_x/y then + // read as the offset from the face centre (editable for precise placement). + m_hole_on_face = false; + m_hole_face_body = -1; + m_hole_has_bounds = false; + set_hole_target_label(-1); + // Use the explicitly-picked face; otherwise default to the top face of the + // selected (or first) body so the hole lands on the surface being viewed, not + // the z=0 datum under the model. The XY/XZ/YZ dropdown still overrides. + int hb = m_sel_solid_body, hf = m_sel_solid_face; + if (hf < 0 && !m_doc.bodies.empty()) { + hb = (hb >= 0 && hb < int(m_doc.bodies.size())) ? hb : 0; + hf = top_face_index_of(m_doc.bodies[hb].shape); + } + if (hf >= 0 && hb >= 0 && hb < int(m_doc.bodies.size())) { + const TopoDS_Face face = GeometryEngine::face_by_index( + m_doc.bodies[hb].shape, hf); + if (!face.IsNull()) { + m_hole_face_plane = face_plane_inward(face); + m_hole_on_face = true; + m_hole_face_body = hb; + set_hole_target_label(hf); // may be the top-face default, not a pick + // Face (u,v) extents so the hole dims read from the sides (#2 Part B). + m_hole_has_bounds = GeometryEngine::face_plane_bounds( + face, m_hole_face_plane.origin, m_hole_face_plane.x_axis, + m_hole_face_plane.y_axis, m_hole_umin, m_hole_umax, m_hole_vmin, m_hole_vmax); + if (m_hole_x) m_hole_x->SetValue(0.0); // start at the face centre + if (m_hole_y) m_hole_y->SetValue(0.0); + // Reflect the face's orientation in the dropdown so it doesn't keep + // showing a stale "XY" while the hole actually drills on this face. + if (m_hole_plane) + m_hole_plane->SetSelection(index_from_plane(m_hole_face_plane)); + } + } + open_tool(Tool::Hole); + }, SHIFT('H')}, + {"design_thread", _L("Thread"), _L("Thread a cylindrical surface (inner bore / outer) or a circular edge"), + [this] { + // Driven by a picked CYLINDRICAL surface (inner bore = internal, outer = external) + // OR a circular EDGE (a cylinder's rim) — axis + diameter come from the geometry, so + // the user never types a radius. The diameter field shows what was derived. + m_thread_on_face = false; + m_thread_face_body = -1; + set_thread_target_label(-1, -1); + GeometryEngine::CylinderFace cf; + bool from_face = false; // which of the two the cylinder actually came from + if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { + const TopoDS_Shape& shape = m_doc.bodies[m_sel_solid_body].shape; + if (m_sel_solid_face >= 0) + cf = GeometryEngine::cylinder_of_face(GeometryEngine::face_by_index(shape, m_sel_solid_face)); + from_face = cf.ok; + if (!cf.ok && m_sel_solid_edge >= 0) + cf = GeometryEngine::circle_of_edge(GeometryEngine::edge_by_index(shape, m_sel_solid_edge)); + } + if (cf.ok) { + SketchPlane p; // plane on the axis (origin at the base) + p.origin = cf.base; + p.normal = cf.axis; + const Vec3d ref = std::abs(cf.axis.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + p.x_axis = ref.cross(cf.axis).normalized(); + p.y_axis = cf.axis.cross(p.x_axis).normalized(); + m_thread_face_plane = p; + m_thread_on_face = true; + m_thread_face_body = m_sel_solid_body; + set_thread_target_label(from_face ? m_sel_solid_face : -1, + from_face ? -1 : m_sel_solid_edge); + infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); + if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + if (m_thread_x) m_thread_x->SetValue(0.0); // on the axis + if (m_thread_y) m_thread_y->SetValue(0.0); + } else if (m_sel_solid_face >= 0 || m_sel_solid_edge >= 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); + m_status->Refresh(); + } + open_tool(Tool::Thread); + }, SHIFT('T')}, + }); + // Text / SVG insert tools live in the SKETCH toolbar (they produce 2D profiles = + // sketches), not here. STEP stays in Features: it imports a whole B-rep solid. + // Import STEP — standalone: a STEP comes in as a whole editable B-rep body, not a profile. + auto* b_step = icon_btn("design_step", _L("Import STEP (editable B-rep solid)")); + b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); + m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; + fadd("step", b_step); + // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has + // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). + auto* b_mesh = icon_btn("param_triangles", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); + b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); + m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; + fadd("mesh", b_mesh); + auto* b_constrain = icon_btn("design_constrain", _L("Constrain selected sketch")); + b_constrain->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + on_begin_constrain(); + if (m_viewport && (m_viewport->is_constraining() || m_viewport->is_constraining_entities())) + set_ui_mode(UiMode::Constrain); + }); + fadd("constrain", b_constrain); + } + + // --- Sketch group: plane + entity tools + Construction + Finish + m_tb_sketch = new wxBoxSizer(wxHORIZONTAL); + // Sketch carries the most tools of any mode: pack it tight (no inter-icon gap) so the + // whole entity palette fits without crowding the right-hand actions. + // Same for the sketch bar: the drawing tools live in the offer. Only Delete selected stays, + // via sadd_bar. sadd() still runs so sk_key/fly: registrations and the flyout popups survive. + auto sadd = [](wxWindow* w) { w->Hide(); }; + auto sadd_bar = [this](wxWindow* w) { m_tb_sketch->Add(w, 0, wxALIGN_CENTER_VERTICAL); }; + m_tb_sketch->Add(caption(_L("SKETCH")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + { + // The plane/orientation choice lives in the docked Sketch card (Phase 3), + // not in the toolbar; the toolbar carries only the drawing tools. + auto skbtn = [&](const char* icon, DesignSketchTool::Mode mode, + const wxString& tip, const wxString& hint) { + auto* b = icon_btn(icon, tip); + b->Bind(wxEVT_BUTTON, [select_tool, mode, hint](wxCommandEvent&) { select_tool(mode, hint); }); + sadd(b); + }; + // Onshape-style family flyout, rendered with Orca's themed DropDown + // (white/teal selector, #DBDBDB border, HarmonyOS Body_14) — same widget + // as the settings combo dropdowns. The button shows the current variant's + // icon; clicking drops the variants; a small chevron marks it as a group. + struct SkVar { const char* icon; DesignSketchTool::Mode mode; wxString tip; wxString hint; }; + struct ToolFlyout { + std::vector items; // mainline DropDown is Item-based (text/tip/icon per row) + std::vector modes; + std::vector hints; + std::vector icon_names; + ScalableButton* btn = nullptr; + DropDown drop; // declared LAST: destroyed before the vector it references + ToolFlyout() : drop(items) {} + }; + auto dropdown = [&](const char* def_icon, const wxString& grp, std::vector vars) { + // Register first, build second — the same law as feat_dropdown, for the same reason. + // The offer reaches each tool by its ratified address; without these the offer could + // name a family but only ever arm its FIRST tool: picking "Rectangle" ran key:R and + // gave you a corner rectangle, with oblique and rounded unreachable. Keyed on the icon + // id (already unique per family) so no call site grows an argument. 6vs. + for (size_t i = 0; i < vars.size(); ++i) { + const DesignSketchTool::Mode mode = vars[i].mode; + const wxString hint = vars[i].hint; + m_verb_actions["fly:" + std::string(def_icon) + "#" + std::to_string(i)] = + [mode, hint, select_tool] { select_tool(mode, hint); }; + } + if (kBarKeep.count(def_icon) == 0) + return; // the drawing tools live in the offer; nothing of this family is built + auto* b = icon_btn(def_icon, grp); + // messureSize() measures labels with the PARENT's font (this button) but the + // popup draws them in Body_14 — so an under-sized button font truncates rows. + // The button is icon-only (no label), so giving it Body_14 is invisible and + // makes the content-width measure match the draw. + b->SetFont(Label::Body_14); + auto fo = std::make_shared(); + for (auto& v : vars) { + DropDown::Item it; + it.text = v.tip; + it.tip = v.hint; + it.icon = tint(create_scaled_bitmap(v.icon, m_form, 18), drop_icon_col); + fo->items.push_back(it); + fo->modes.push_back(v.mode); + fo->hints.push_back(v.hint); + fo->icon_names.emplace_back(v.icon); + } + fo->btn = b; + fo->drop.Create(b); + fo->drop.SetUseContentWidth(true, false); + fo->drop.Invalidate(true); + ToolFlyout* fp = fo.get(); + fo->drop.Bind(wxEVT_COMBOBOX, [this, fp, select_tool](wxCommandEvent& e) { + int i = e.GetInt(); + if (i >= 0 && i < (int) fp->modes.size()) { + fp->btn->SetBitmap_(fp->icon_names[i]); + select_tool(fp->modes[i], fp->hints[i]); + set_active_tool_btn(fp->btn); + } + }); + b->Bind(wxEVT_BUTTON, [b, fp](wxCommandEvent&) { + // autoPosition()/messureSize() are private; ComboBox calls them before + // Popup() so the window is sized to its content first. Without that the + // popup maps at a stale narrow size and labels ellipsize ("Oblique + // rectang…"). Force a fresh content measure by toggling use_content_width + // (messureSize only runs when the flag actually changes), then show. + fp->drop.Invalidate(true); + fp->drop.SetUseContentWidth(false, false); + fp->drop.SetUseContentWidth(true, false); + wxPoint pos = b->ClientToScreen(wxPoint(0, -6)); + fp->drop.Position(pos, wxSize(0, b->GetSize().y + 12)); + fp->drop.Popup(); + }); + m_flyout_keepalive.push_back(fo); + sadd(b); + auto* chev = new wxStaticText(m_toolbar, wxID_ANY, wxString::FromUTF8("\xE2\x96\xBE")); + chev->SetForegroundColour(dp_sec_text()); + chev->SetFont(Label::Body_9); + sadd(chev); // follows its button off the bar + }; + skbtn("design_select", DesignSketchTool::Mode::Select, _L("Select"), + _L("Click to select; Shift to add; double-click for a whole loop")); + skbtn("design_dimension", DesignSketchTool::Mode::Dimension, _L("Dimension"), + _L("Click 2 points or a line / circle / arc to place a dimension")); + // (separator dropped: the group it divided is now reached from the offer) + dropdown("design_line", _L("Line / polyline"), { + {"design_line", DesignSketchTool::Mode::Line, _L("Line"), _L("Click start, then end — then set the exact length")}, + {"design_polyline", DesignSketchTool::Mode::Polyline, _L("Polyline"), _L("Click points; click first / right-click to close the loop")} }); + dropdown("design_rect", _L("Rectangle"), { + {"design_rect", DesignSketchTool::Mode::CornerRect, _L("Corner rectangle"), _L("Click two opposite corners")}, + {"design_crect", DesignSketchTool::Mode::CenterRect, _L("Center rectangle"), _L("Click center, then a corner")}, + {"design_rect_oblique", DesignSketchTool::Mode::ObliqueRect, _L("Oblique rectangle"), _L("Click two corners of one edge, then a point for the width")}, + {"design_rect_rounded", DesignSketchTool::Mode::RoundedRect, _L("Rounded rectangle"), _L("Click two opposite corners, then a point for the corner radius")} }); + dropdown("design_circle", _L("Circle"), { + {"design_circle", DesignSketchTool::Mode::CenterCircle, _L("Center circle"), _L("Click center, then radius")}, + {"design_circle2pt", DesignSketchTool::Mode::TwoPointCircle, _L("2-point circle"), _L("Click two ends of the diameter")}, + {"design_circle3pt", DesignSketchTool::Mode::ThreePointCircle, _L("3-point circle"), _L("Click three points on the circle")} }); + dropdown("design_arc3pt", _L("Arc"), { + {"design_arc3pt", DesignSketchTool::Mode::ThreePointArc, _L("3-point arc"), _L("Click start, end, then a point on the arc")}, + {"design_tangentarc", DesignSketchTool::Mode::TangentArc, _L("Tangent arc"), _L("Click start (on the last entity) then end")}, + {"design_arc_center", DesignSketchTool::Mode::CenterArc, _L("Center-point arc"), _L("Click center, then start, then a point for the end angle")} }); + dropdown("design_slot", _L("Slot"), { + {"design_slot", DesignSketchTool::Mode::Slot, _L("Slot"), _L("Click two centerline ends, then a point for the end radius")}, + {"design_slot_arc", DesignSketchTool::Mode::ArcSlot, _L("Arc slot"), _L("Click center, start, end, then a point for the width")} }); + dropdown("design_ellipse", _L("Ellipse"), { + {"design_ellipse", DesignSketchTool::Mode::Ellipse, _L("Ellipse"), _L("Click center, a major-axis end, then a point for the minor axis")}, + {"design_ellipse_arc", DesignSketchTool::Mode::EllipseArc, _L("Elliptical arc"), _L("Click center, major-axis end, minor point, then arc start and end")} }); + skbtn("design_bspline", DesignSketchTool::Mode::BSpline, _L("Spline"), + _L("Click control points; double-click or right-click to finish")); + skbtn("design_point", DesignSketchTool::Mode::Point, _L("Point"), + _L("Click to place a point")); + // (separator dropped: the group it divided is now reached from the offer) + // Insert tools — Text / SVG produce a 2D profile (a sketch), so they belong with + // the sketch tools, not in the generic Features strip. Each places the art + // in-canvas, then commits via the Insert card's Confirm. + { + // In Sketch MODE the art must land in the sketch — but begin_sketch() does not run + // until the first tool is armed, so pressing Sketch and then Text would find no + // session and commit a separate feature. Arm Select first: it starts the session on + // the picked plane without drawing anything, so Text behaves the same whether or not + // you had already drawn a line. (MODE vs SESSION — the distinction that has bitten + // this panel before.) + auto ensure_sketch = [this, select_tool] { + if (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->is_sketching()) + select_tool(DesignSketchTool::Mode::Select, + _L("Select — click to select; Shift to add")); + }; + auto* b_text = icon_btn("design_text", _L("Text — emboss text as a profile")); + b_text->Bind(wxEVT_BUTTON, [this, ensure_sketch](wxCommandEvent&) { + ensure_sketch(); on_add_text(); }); + sadd(b_text); + auto* b_svg = icon_btn("design_svg", _L("SVG — import an outline as a profile")); + b_svg->Bind(wxEVT_BUTTON, [this, ensure_sketch](wxCommandEvent&) { + ensure_sketch(); on_import_svg(); }); + sadd(b_svg); + // …and reachable from the offer's Create row. These were on the atlas's chrome_only + // list under "document-level actions act on the DOCUMENT, not on a selection" — which + // is not what they do: both call add_imported_sketch(), which drops the art ON a + // picked solid face (SketchPlane::from_face, centred on it) exactly as Sketch does. + // Selection-consuming profile creators, so they belong with the other Create verbs. + m_verb_actions["btn:text"] = [this, ensure_sketch] { ensure_sketch(); on_add_text(); }; + m_verb_actions["btn:svg"] = [this, ensure_sketch] { ensure_sketch(); on_import_svg(); }; + } + // (separator dropped: the group it divided is now reached from the offer) + // In-canvas edit-op tools (drag gizmo / click label), grouped by family. + dropdown("design_filletedge", _L("Fillet / chamfer"), { + {"design_filletedge", DesignSketchTool::Mode::Fillet, _L("Fillet"), _L("Pick two lines, then drag the arrow or click the radius to set it")}, + {"design_chamfer", DesignSketchTool::Mode::Chamfer, _L("Chamfer"), _L("Pick two lines, then drag the arrow or click the distance to set it")} }); + skbtn("design_offset", DesignSketchTool::Mode::Offset, _L("Offset"), + _L("Pick an entity, then drag the arrow or click the distance; click empty to apply")); + skbtn("design_mirror", DesignSketchTool::Mode::Mirror, _L("Mirror"), + _L("Pick a mirror-axis line, then the entities to mirror; click empty to apply")); + // Trim / Extend scissors — standalone sketch tools (NOT inside Constrain): click a + // segment to cut it back to / out to its nearest intersection. One cut per click. + skbtn("design_trim", DesignSketchTool::Mode::Trim, _L("Trim"), + _L("Click a segment to trim it back to its nearest intersection; right-click exits")); + skbtn("design_extend", DesignSketchTool::Mode::Extend, _L("Extend"), + _L("Click a line or arc to extend it to the nearest entity; right-click exits")); + // Constrain — grouped with the edit tools so it's easy to find (nde #13: it was buried + // far-right next to Construction and went unnoticed). Commits the live sketch in place + // and drops into Constrain mode (geometric/dimensional palette). + auto* b_constrain_sk = icon_btn("design_constrain", + _L("Constrain — add geometric/dimensional relations")); + b_constrain_sk->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { enter_constrain_inline(); }); + sadd(b_constrain_sk); + dropdown("design_move", _L("Move / rotate / scale"), { + {"design_move", DesignSketchTool::Mode::Move, _L("Move (translate)"), _L("Pick entities, then drag the handle or click the distance; click empty to apply")}, + {"design_rotate", DesignSketchTool::Mode::Rotate, _L("Rotate (about centroid)"), _L("Pick entities, then drag around the pivot or click the angle; click empty to apply")}, + {"design_scale", DesignSketchTool::Mode::Scale, _L("Scale (about centroid)"), _L("Pick entities, then drag the handle or click the factor; click empty to apply")} }); + dropdown("design_array", _L("Linear / polar array"), { + {"design_array", DesignSketchTool::Mode::Array, _L("Linear array"), _L("Pick entities, drag the spacing handle, click the count; click empty to apply")}, + {"design_polararray", DesignSketchTool::Mode::PolarArray, _L("Polar array (about centroid)"), _L("Pick entities, drag the sweep handle, click the count; click empty to apply")} }); + + // Polygon's side count and fit are TOOL PARAMETERS, so they are chosen from the tool: + // the offer's Create > Polygon submenu. They used to sit inline in this row, then in a + // sidebar card; both put the choice somewhere you had to leave the geometry to reach, + // and the count cannot be recovered afterwards (a drawn polygon's inline editor offers + // Side and Angle, never the count). e1p. + auto arm_polygon = [this, select_tool] { + push_polygon_params(); + select_tool(DesignSketchTool::Mode::Polygon, + wxString::Format(_L("Click center then a vertex — %d sides, %s"), + m_poly_sides, + m_poly_circumscribed ? _L("circumscribed") : _L("inscribed"))); + }; + for (int n : {3, 4, 5, 6, 8, 12}) + m_verb_actions["btn:poly#" + std::to_string(n)] = + [this, arm_polygon, n] { m_poly_sides = n; arm_polygon(); }; + for (int c : {0, 1}) + m_verb_actions["btn:polyfit#" + std::to_string(c)] = + [this, arm_polygon, c] { m_poly_circumscribed = (c == 1); arm_polygon(); }; + + // Same defect, one level up: a combo whose entries are different VERBS wearing a single + // name. "Dress-up" is Fillet or Chamfer; "Combine" is union, subtract or intersect; + // "Pattern" is linear or circular. The choice decides WHAT YOU ARE DOING, so it belongs + // where you chose the tool — not behind a card you must open to discover it existed. + // Each address opens the tool exactly as its shortcut does, then says which one. + // The members are read at INVOCATION, not capture: the cards are built after this row. + // e1p. + auto open_feature = [this](int key) { + auto it = m_keys_feature.find(key); + if (it != m_keys_feature.end() && it->second) it->second(); + }; + // SHIFT() is a constructor-local helper, so resolve the codes here rather than inside + // the stored lambdas, which outlive it. + const int k_dress = SHIFT('F'), k_bool = SHIFT('B'), k_pat = SHIFT('N'); + // Choose FIRST, then open: open_tool() titles the card from m_dressup_type, so setting + // it afterwards left the header reading "Fillet 1" over a chamfer. Nothing in the + // opener resets the combo, so this order is safe. + m_verb_actions["btn:dress#0"] = [this, open_feature, k_dress] { + if (m_dressup_type) m_dressup_type->SetSelection(0); open_feature(k_dress); }; + m_verb_actions["btn:dress#1"] = [this, open_feature, k_dress] { + if (m_dressup_type) m_dressup_type->SetSelection(1); open_feature(k_dress); }; + for (int op = 0; op < 3; ++op) + m_verb_actions["btn:bool#" + std::to_string(op)] = [this, open_feature, k_bool, op] { + open_feature(k_bool); + if (m_bool_op) { m_bool_op->SetSelection(op); refresh_preview(); } }; + for (int t = 0; t < 2; ++t) + m_verb_actions["btn:pat#" + std::to_string(t)] = [this, open_feature, k_pat, t] { + open_feature(k_pat); if (m_pattern_type) m_pattern_type->SetSelection(t); }; + + auto* b_poly = icon_btn("design_polygon", _L("Polygon")); + b_poly->Bind(wxEVT_BUTTON, [arm_polygon](wxCommandEvent&) { arm_polygon(); }); + sadd(b_poly); + // (separator dropped: the group it divided is now reached from the offer) + // Q's semantics, on the control that carries the word. With geometry selected the box + // CONVERTS it — that is what a user who has just selected a construction circle and + // reached for the box labelled "Construction" is asking for, and until now it was the + // only one of the three routes (Q, the offer's Reference row, this box) that could not + // do it: it armed the mode for the NEXT entity, silently, changing nothing about the + // shape on screen and flipping the draw mode behind the user's back. The tick is a MODE + // indicator, so after a conversion it goes back to what it was. + m_construction->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { + if (!m_viewport || !m_viewport->is_sketching()) + return; + // ONLY IN SELECT MODE. Drawing auto-selects what was just drawn (draw-then-edit), so + // with a draw tool armed "there is a selection" does not mean the user picked + // anything — it means they finished a line. Converting there turns the box into a + // trap: arm construction, draw the axis, click the box to go back to real geometry, + // and instead of disarming the mode it converts the axis you just drew. The gesture + // ladder's mirror rung does exactly that and reported three construction entities + // where it wanted one. In Select mode the intent is unambiguous. + const int n = m_viewport->sketch_is_selecting() + ? m_viewport->toggle_sketch_construction_selection() : 0; + if (n > 0) { + m_construction->SetValue(!m_construction->GetValue()); // the mode did not move + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format( + _L("Converted %d entit%s between construction and real geometry"), + n, n == 1 ? "y" : "ies")); + m_status->Refresh(); + return; + } + m_viewport->set_sketch_construction(m_construction->GetValue()); }); + // STAYS on the bar. Construction is not a tool, it is a persistent MODE — the same kind + // of thing as the Bed checkbox — and the sketch bar is already shown only in Sketch mode, + // so it appears exactly while it can apply. Hiding it left Q and the offer's Construction + // row still toggling a checkbox nobody could see: you could not tell whether the next + // line would be construction geometry. A stateful toggle has to show its state. + sadd_bar(m_construction); + add_sep(m_tb_sketch); + auto* b_del = icon_btn("design_delete", _L("Delete selected")); + b_del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + if (m_viewport) m_viewport->delete_selected_sketch_entities(); }); + sadd_bar(b_del); // keep-list: Delete selected stays on the bar + // Finish sketch = the unified ✓ Confirm in the action bar (tool_confirm). + } + + // --- Constrain group: geometric constraints + dimensions + edit ops + Done + // Fase 4.2 live path: these buttons are shown during a SKETCH too, not only in Constrain + // mode, so a constraint applies to the live selection without committing first. The caption + // travels with them; the session's Confirm/Cancel stay in m_tb_action (mode-appropriate). + m_tb_relations = new wxBoxSizer(wxHORIZONTAL); + auto cadd = [this](wxWindow* w) { m_tb_relations->Add(w, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); }; + m_tb_relations->Add(caption(_L("CONSTRAIN")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + { + auto cbtn = [&](const char* icon, const wxString& tip, SketchConstraintType type) { + auto* b = icon_btn(icon, tip); + b->Bind(wxEVT_BUTTON, [this, type](wxCommandEvent&) { apply_constraint(type); }); + cadd(b); + }; + cbtn("design_c_horizontal", _L("Horizontal"), SketchConstraintType::Horizontal); + cbtn("design_c_vertical", _L("Vertical"), SketchConstraintType::Vertical); + cbtn("design_c_parallel", _L("Parallel"), SketchConstraintType::Parallel); + cbtn("design_c_perpendicular", _L("Perpendicular"), SketchConstraintType::Perpendicular); + cbtn("design_c_coincident", _L("Coincident"), SketchConstraintType::Coincident); + cbtn("design_c_equal", _L("Equal length"), SketchConstraintType::EqualLength); + cbtn("design_c_equal_radius", _L("Equal radius"), SketchConstraintType::EqualRadius); + cbtn("design_c_collinear", _L("Collinear"), SketchConstraintType::Collinear); + cbtn("design_c_concentric", _L("Concentric"), SketchConstraintType::Concentric); + cbtn("design_c_tangent", _L("Tangent"), SketchConstraintType::Tangent); + cbtn("design_c_midpoint", _L("Midpoint"), SketchConstraintType::Midpoint); + cbtn("design_c_symmetric", _L("Symmetric"), SketchConstraintType::Symmetric); + cbtn("design_c_sym_v", _L("Symmetric about the vertical axis"), SketchConstraintType::SymmetricAboutY); + cbtn("design_c_sym_h", _L("Symmetric about the horizontal axis"), SketchConstraintType::SymmetricAboutX); + cbtn("design_c_angle", _L("Angle"), SketchConstraintType::Angle); + cbtn("design_c_radius", _L("Radius"), SketchConstraintType::Radius); + cbtn("design_c_diameter", _L("Diameter"), SketchConstraintType::Diameter); + cbtn("design_c_fix", _L("Fix point (anchor in place)"), SketchConstraintType::Fix); + cbtn("design_c_dist_x", _L("Horizontal distance"), SketchConstraintType::DistanceX); + cbtn("design_c_dist_y", _L("Vertical distance"), SketchConstraintType::DistanceY); + // Trim/Extend are now standalone SKETCH scissors (Mode::Trim/Extend) in the sketch + // toolbar, NOT Constrain buttons. The other edit ops (Mirror/Offset/Fillet/Chamfer/ + // Move/…) are first-class sketch tools too. Done constraining = the action-bar ✓. + } + + // Unified action bar: the ONE Confirm/Cancel surface for every tool and mode. Lives at + // the right end of the ribbon (the "tool dashboard"); shown only while a tool/mode is + // active (update_action_bar). Replaces the 13 per-card buttons + sketch Finish + Done. + m_tb_action = new wxBoxSizer(wxHORIZONTAL); + { + auto* ok = new wxButton(m_toolbar, wxID_ANY, _L("✓ Confirm")); + ok->SetForegroundColour(*wxWHITE); + ok->SetBackgroundColour(wxColour(0x00, 0x96, 0x88)); // Orca teal accent + ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { tool_confirm(); }); + m_confirm_btns.push_back(ok); // refresh_preview greys this on an invalid candidate + auto* no = new wxButton(m_toolbar, wxID_ANY, _L("✗ Cancel")); + no->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { tool_cancel(); }); + m_tb_action->Add(ok, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + m_tb_action->Add(no, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 10); + } + + // Persistent Undo/Redo group: always visible (not mode-gated like the tool groups), so + // history is reachable from Feature, Sketch and Constrain alike. These are momentary + // actions, so — unlike icon_btn — they are NOT registered in m_tool_btns and never take + // the teal active-tool highlight. They route to the SAME do_undo_redo as the keyboard + // Ctrl+Z / Ctrl+Shift+Z path, and are greyed by update_undo_redo_buttons(). + m_tb_history = new wxBoxSizer(wxHORIZONTAL); + { + auto hist_btn = [this, tb_bg, tb_hover](const char* icon, const wxString& tip) { + auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); + b->SetToolTip(tip); + b->SetBackgroundColour(tb_bg); + b->Bind(wxEVT_ENTER_WINDOW, [b, tb_hover](wxMouseEvent& e) { + if (b->IsEnabled()) { b->SetBackgroundColour(tb_hover); b->Refresh(); } e.Skip(); }); + b->Bind(wxEVT_LEAVE_WINDOW, [b, tb_bg](wxMouseEvent& e) { + b->SetBackgroundColour(tb_bg); b->Refresh(); e.Skip(); }); + return b; + }; + m_btn_undo = hist_btn("menu_undo", _L("Undo (Ctrl+Z)")); + m_btn_undo->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { do_undo_redo(false); }); + m_btn_redo = hist_btn("menu_redo", _L("Redo (Ctrl+Shift+Z)")); + m_btn_redo->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { do_undo_redo(true); }); + m_btn_undo->Enable(false); // nothing to undo/redo on a fresh document + m_btn_redo->Enable(false); + m_tb_history->Add(m_btn_undo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + m_tb_history->Add(m_btn_redo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + } + + // Document actions, left of Undo/Redo and always visible (not mode-gated like the tools). + // Same styling as the history pair: momentary actions, never the teal active-tool state. + m_tb_doc = new wxBoxSizer(wxHORIZONTAL); + const wxColour tb_glyph_col = StateColor::darkModeColorFor(wxColour(0x36, 0x36, 0x36)); + const wxColour tb_commit_col(0x00, 0x96, 0x88); // Orca Confirm accent + { + // Some Orca glyphs (toolbar_add_plate, toolbar_flatten) are drawn for a light toolbar and + // come out the same tone as this dark one — Commit was effectively invisible. Re-tint + // those: Commit in the teal accent it carries as the tab's primary action, the rest in + // the same grey the other toolbar glyphs resolve to. + auto doc_btn = [this, tb_bg, tb_hover, &tint](const char* icon, const wxString& tip, + const wxColour* glyph = nullptr) { + auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); + if (glyph != nullptr) + b->SetBitmap(tint(create_scaled_bitmap(icon, m_toolbar, 42), *glyph)); + b->SetToolTip(tip); + b->SetBackgroundColour(tb_bg); + b->Bind(wxEVT_ENTER_WINDOW, [b, tb_hover](wxMouseEvent& e) { + if (b->IsEnabled()) { b->SetBackgroundColour(tb_hover); b->Refresh(); } e.Skip(); }); + b->Bind(wxEVT_LEAVE_WINDOW, [b, tb_bg](wxMouseEvent& e) { + b->SetBackgroundColour(tb_bg); b->Refresh(); e.Skip(); }); + return b; + }; + auto add_doc = [this](ScalableButton* b) { + m_tb_doc->Add(b, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); }; + + auto* b_new = doc_btn("add", _L("New Design — clear the feature tree")); + b_new->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_new_design(); }); + add_doc(b_new); + add_doc(static_cast(tb_slot["step"][0])); // 2. Import STEP + add_doc(static_cast(tb_slot["mesh"][0])); // 3. Import mesh + auto* b_export = doc_btn("save", _L("Export STEP…")); + b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); + add_doc(b_export); + + // View option, not a document action: hide the printer bed to model without it. Lives in + // this row because it must stay reachable with no tool open — a card would come and go. + m_show_bed = new CheckBox(m_toolbar); + m_show_bed->SetValue(true); // bed visible by default, as the tab opens today + m_show_bed->SetToolTip(_L("Show the printer bed and its plate grid")); + // wxEVT_TOGGLEBUTTON, NOT wxEVT_CHECKBOX: Orca's CheckBox derives from + // wxBitmapToggleButton (Widgets/CheckBox.hpp), so a wxEVT_CHECKBOX handler never fires. + // Read the control rather than the event so the state cannot disagree with the glyph. + m_show_bed->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { + if (m_viewport) m_viewport->set_show_bed(m_show_bed->GetValue()); + e.Skip(); + }); + auto* bed_lbl = new wxStaticText(m_toolbar, wxID_ANY, _L("Bed")); + bed_lbl->SetForegroundColour(dp_sec_text()); + m_tb_doc->Add(m_show_bed, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 6); + m_tb_doc->Add(bed_lbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 4); + + // These act on bodies / the view, so they ride in the feature group, in the slots + // the user assigned them (9, 11bis, 16). + auto* b_place = doc_btn("toolbar_flatten", _L("Place on Face (F) — lay the picked face on the bed"), + &tb_glyph_col); + b_place->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { place_on_face(); }); + tb_slot["place"].push_back(b_place); + + auto* b_section = doc_btn("split_parts", _L("Section View — hide part of the model to see inside. " + "PageUp/PageDown move the plane; Delete removes it.")); + b_section->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { toggle_section_view(); }); + tb_slot["section"].push_back(b_section); + + m_section_flip_btn = doc_btn("design_mirror", _L("Flip Section — show the opposite half")); + m_section_flip_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { flip_section_view(); }); + m_section_flip_btn->Enable(false); // only usable while a section view is active + tb_slot["flip"].push_back(m_section_flip_btn); + + // Commit is the tab's primary action and sits far right, next to Confirm/Cancel. + m_tb_commit = new wxBoxSizer(wxHORIZONTAL); + auto* b_commit = doc_btn("toolbar_add_plate", _L("Commit to Plate — send the solid to Prepare"), + &tb_commit_col); + b_commit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); }); + m_tb_commit->Add(b_commit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + } + + // Feature-group layout, in the requested left-to-right order. + { + auto put = [&](const char* id) { + auto it = tb_slot.find(id); + if (it == tb_slot.end()) return; + for (size_t i = 0; i < it->second.size(); ++i) + m_tb_feature->Add(it->second[i], 0, + i == 0 ? (wxALIGN_CENTER_VERTICAL | wxRIGHT) + : (wxALIGN_BOTTOM | wxBOTTOM | wxRIGHT), + i == 0 ? 4 : 5); + }; + put("sketch"); put("constrain"); // 7, 7bis + add_sep(m_tb_feature); + put("material"); put("place"); put("placement"); put("plane"); // 8, 9, 9bis, 10 + put("dressup"); put("section"); // 11, 11bis + put("hole"); put("boolean"); put("cut"); // 12, 13, 14 + put("surface"); put("color"); put("flip"); // 14bis, 15, 16 + put("pattern"); // kept, at the end + } + + auto* tbrow = new wxBoxSizer(wxHORIZONTAL); + tbrow->AddSpacer(8); + tbrow->Add(m_tb_doc, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + add_sep(tbrow); + tbrow->Add(m_tb_history, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + add_sep(tbrow); + tbrow->Add(m_tb_feature, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + tbrow->Add(m_tb_sketch, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + tbrow->Add(m_tb_relations, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + tbrow->AddStretchSpacer(); + tbrow->Add(m_tb_commit, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + tbrow->Add(m_tb_action, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + tbrow->AddSpacer(8); + m_toolbar->SetSizer(tbrow); + // Apply the body gates once now: an empty document is exactly the state the bug was reported + // in, and feed_bodies() has not run yet on a fresh tab. + update_body_gates(); + + // Onshape-style dialog-card header: feature icon + bold title. out receives + // the title control so open_tool() can retitle it per feature. + auto card_header = [](wxWindow* card, const char* icon, const wxString& title, wxStaticText*& out) -> wxSizer* { + auto* h = new wxBoxSizer(wxHORIZONTAL); + auto* ic = new wxStaticBitmap(card, wxID_ANY, create_scaled_bitmap(icon, card, 18)); + out = new wxStaticText(card, wxID_ANY, title); + out->SetFont(Label::Head_14); // Orca shared HarmonyOS card-title font + h->Add(ic, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + h->Add(out, 0, wxALIGN_CENTER_VERTICAL); + return h; + }; + + // Every tool dialog lives inside ONE framed card (only one is ever visible), so the + // active dialog reads as a bordered panel like Prepare's sidebar boxes instead of + // free-floating rows. `cards` is the frame's sizer; open_tool() shows/hides within it. + m_cards = make_card(m_form); + auto* cards = new wxBoxSizer(wxVERTICAL); + m_cards->SetSizer(cards); + root->Add(m_cards, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8); + + // --- Sketch dialog (shape definition only — no distance/mode) --- + auto* form = two_col_form(); + + m_shape = make_combo(m_cards); + m_shape->Append(_L("Rectangle")); + m_shape->Append(_L("Circle")); + m_shape->SetSelection(0); + form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Shape")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(m_shape, 0, wxEXPAND); + + // NO plane row. A sketch takes its plane from what is picked in the VIEWPORT — a planar face + // on a solid, or one of the reference-plane ghosts clicked in 3D — resolved by + // sketch_plane_from_selection(). A three-row XY/XZ/YZ combo could not express either of those + // targets, so it displayed a value that was at best redundant and at worst false. e1p. + + m_width = make_spin(m_cards, 20); + form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Width / X")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(spin_frame(m_width), 0, wxEXPAND); + + m_height = make_spin(m_cards, 20); + form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Height / Y")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(spin_frame(m_height), 0, wxEXPAND); + + m_radius = make_spin(m_cards, 10); + form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Radius")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(spin_frame(m_radius), 0, wxEXPAND); + + m_box_sketch = new wxBoxSizer(wxVERTICAL); + m_box_sketch->Add(card_header(m_cards, "design_sketch", _L("Sketch"), m_hdr_sketch), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_sketch->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_sketch->Add(form, 0, wxEXPAND | wxALL, 12); + cards->Add(m_box_sketch, 0, wxEXPAND); + + // --- Move / Rotate body --- + { + auto* mform = two_col_form(); + m_move_dx = make_spin(m_cards, 0.0, -100000.0, 100000.0); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance X")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(spin_frame(m_move_dx), 0, wxEXPAND); + m_move_dy = make_spin(m_cards, 0.0, -100000.0, 100000.0); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance Y")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(spin_frame(m_move_dy), 0, wxEXPAND); + m_move_dz = make_spin(m_cards, 0.0, -100000.0, 100000.0); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance Z")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(spin_frame(m_move_dz), 0, wxEXPAND); + + m_move_axis = make_combo(m_cards); + m_move_axis->Append(_L("X")); + m_move_axis->Append(_L("Y")); + m_move_axis->Append(_L("Z")); + m_move_axis->SetSelection(2); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Rotation axis")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(m_move_axis, 0, wxEXPAND); + + m_move_angle = make_spin(m_cards, 0.0, -360.0, 360.0); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(spin_frame(m_move_angle), 0, wxEXPAND); + + for (wxSpinCtrlDouble* sp : { m_move_dx, m_move_dy, m_move_dz, m_move_angle }) + sp->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent& e) { apply_move_card(); e.Skip(); }); + m_move_axis->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { apply_move_card(); e.Skip(); }); + + m_box_move = new wxBoxSizer(wxVERTICAL); + m_box_move->Add(card_header(m_cards, "design_move", _L("Move / Rotate"), m_hdr_move), 0, + wxLEFT | wxRIGHT | wxTOP, 12); + m_box_move->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_move->Add(mform, 0, wxEXPAND | wxALL, 12); + cards->Add(m_box_move, 0, wxEXPAND); + } + + // --- Polygon (sketch tool options) --- + { + // NO polygon card. Sides and Circumscribed are tool parameters and are chosen from the + // tool — the offer's Create > Polygon submenu names the common side counts and the two + // fits, and arming from there sets both. A spin field on the left could not be reached + // without leaving the geometry, and the count is unrecoverable afterwards: the inline + // editor a drawn polygon opens offers Side and Angle, never the count. e1p. + } + + // --- Extrude dialog (consumes the selected sketch) --- + m_box_extrude = new wxBoxSizer(wxVERTICAL); + m_box_extrude->Add(card_header(m_cards, "design_extrude", _L("Extrude"), m_hdr_extrude), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_extrude->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_extrude_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —")); + m_box_extrude->Add(m_extrude_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); + { + auto* eform = two_col_form(); + + // A negative distance extrudes the other way — Onshape behaviour, where a negative + // depth IS the flip. The default 0.1 floor made that impossible to type, so the only + // way inward was the Flip box, and a user who typed "-5" silently got +0.1 instead. + // Flip and a negative distance double-negate, which is what setting both asked for. + m_distance = make_spin(m_cards, 10, -1000.0, 1000.0); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Extrude dist")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(spin_frame(m_distance), 0, wxEXPAND); + + // End condition (order MUST match ExtrudeEnd: Blind/Symmetric/TwoSided/ThroughAll/ + // UpToFace/UpToVertex). Up-to-face uses the currently click-selected solid face. + m_extrude_end = make_combo(m_cards); + for (const char* s : { "Blind", "Symmetric", "Two-sided", "Through all", + "Up to face", "Up to vertex" }) + m_extrude_end->Append(s); + m_extrude_end->SetSelection(0); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("End")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(m_extrude_end, 0, wxEXPAND); + + m_distance2 = make_spin(m_cards, 5, 0.0, 100000.0); // second-side depth (Two-sided) + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("2nd dist")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(spin_frame(m_distance2), 0, wxEXPAND); + + m_taper = make_spin(m_cards, 0.0, -89.0, 89.0); // draft angle (deg) + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Taper °")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(spin_frame(m_taper), 0, wxEXPAND); + + m_mode = make_combo(m_cards); + // Order is load-bearing: index maps to BooleanMode (New=0, Add=1, Cut=2, Intersect=3). + // Labels use Onshape wording so the choice reads as the user thinks of it. + m_mode->Append(_L("New body")); // separate coexisting solid + m_mode->Append(_L("Join")); // fuse into the target body (was "Add") + m_mode->Append(_L("Cut")); // subtract from the target body + m_mode->Append(_L("Intersect")); // keep only the overlap + m_mode->SetSelection(0); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Result")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(m_mode, 0, wxEXPAND); + + m_flip = new CheckBox(m_cards); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(m_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + + m_box_extrude->Add(eform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_extrude, 0, wxEXPAND); + + // --- Dress-up (Fillet / Chamfer) --- + auto* dform = two_col_form(); + + m_dressup_type = make_combo(m_cards); + m_dressup_type->Append(_L("Fillet")); + m_dressup_type->Append(_L("Chamfer")); + m_dressup_type->SetSelection(0); + dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Type")), 0, wxALIGN_CENTER_VERTICAL); + dform->Add(m_dressup_type, 0, wxEXPAND); + + // The card can dress ONE picked edge or a whole face-group, and which one it will do is + // decided by a viewport pick the card previously said nothing about — so a user who had not + // picked an edge saw only the group combo and concluded per-edge rounding did not exist, + // while a user who HAD picked one saw the combo still reading "All" and was told the opposite + // of what Confirm would do. This row states the actual target, as Shell and Draft already do. + m_dressup_edge_label = new wxStaticText(m_cards, wxID_ANY, _L("(no edge picked — group below)")); + dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target")), 0, wxALIGN_CENTER_VERTICAL); + dform->Add(m_dressup_edge_label, 0, wxALIGN_CENTER_VERTICAL); + + m_face_group = make_combo(m_cards); + m_face_group->Append(_L("Top")); // index 0 -> FaceGroup::Top + m_face_group->Append(_L("Bottom")); // 1 -> Bottom + m_face_group->Append(_L("Lateral")); // 2 -> Lateral + m_face_group->Append(_L("All")); // 3 -> All + m_face_group->SetSelection(3); + dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Edges")), 0, wxALIGN_CENTER_VERTICAL); + dform->Add(m_face_group, 0, wxEXPAND); + + m_dressup_size = make_spin(m_cards, 2.0); + dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Size (r/dist)")), 0, wxALIGN_CENTER_VERTICAL); + dform->Add(spin_frame(m_dressup_size), 0, wxEXPAND); + + m_box_dressup = new wxBoxSizer(wxVERTICAL); + m_box_dressup->Add(card_header(m_cards, "design_dressup", _L("Fillet / Chamfer"), m_hdr_dressup), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_dressup->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_dressup->Add(dform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + cards->Add(m_box_dressup, 0, wxEXPAND); + + // --- Hole (positioned circular cut) --- + auto* hform = two_col_form(); + + m_hole_plane = make_combo(m_cards); + m_hole_plane->Append(_L("XY")); + m_hole_plane->Append(_L("XZ")); + m_hole_plane->Append(_L("YZ")); + m_hole_plane->SetSelection(0); + // Picking a plane here is an explicit choice: drop any on-face hijack (a stale face pick + // could keep m_hole_on_face true, so the dropdown was ignored and the hole drilled on the + // face's plane instead of the chosen XY/XZ/YZ). + m_hole_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { + m_hole_on_face = false; + m_hole_has_bounds = false; + set_hole_target_label(-1); + update_hole_gizmo(); + refresh_preview(); + e.Skip(); + }); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Hole plane")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(m_hole_plane, 0, wxEXPAND); + + m_hole_diameter = make_spin(m_cards, 6.0); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_hole_diameter), 0, wxEXPAND); + + m_hole_depth = make_spin(m_cards, 10.0); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Depth (blind)")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_hole_depth), 0, wxEXPAND); + + m_hole_x = make_spin(m_cards, 0.0, -1000.0, 1000.0); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos X")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_hole_x), 0, wxEXPAND); + + m_hole_y = make_spin(m_cards, 0.0, -1000.0, 1000.0); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos Y")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_hole_y), 0, wxEXPAND); + + m_hole_through = new CheckBox(m_cards); + m_hole_through->SetValue(true); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Through")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(m_hole_through, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + + m_hole_target_label = new wxStaticText(m_cards, wxID_ANY, _L("(none — uses Hole plane)")); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("On face")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(m_hole_target_label, 0, wxALIGN_CENTER_VERTICAL); + + m_box_hole = new wxBoxSizer(wxVERTICAL); + m_box_hole->Add(card_header(m_cards, "design_hole", _L("Hole"), m_hdr_hole), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_hole->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_hole->Add(hform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + cards->Add(m_box_hole, 0, wxEXPAND); + + // --- Thread (helical) --- + auto* tform = two_col_form(); + + m_thread_plane = make_combo(m_cards); + m_thread_plane->Append(_L("XY")); + m_thread_plane->Append(_L("XZ")); + m_thread_plane->Append(_L("YZ")); + m_thread_plane->SetSelection(0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thread plane")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_thread_plane, 0, wxEXPAND); + + // Standard designation picker — fills pitch/depth (and nominal radius) from the + // ISO metric / Unified imperial tables. "Custom" leaves the manual spins alone. + m_thread_std = make_combo(m_cards); + m_thread_std->Append(_L("Custom")); + for (const ThreadSpec& s : thread_standards()) + m_thread_std->Append(s.name); + m_thread_std->SetSelection(0); + m_thread_std->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { apply_thread_standard(); }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Standard")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_thread_std, 0, wxEXPAND); + + // Threads are specified by DIAMETER (M6 = Ø6); the value is derived from the picked cylindrical + // surface / circular edge, so it's a readout users rarely type. Stored field holds the diameter. + m_thread_radius = make_spin(m_cards, 10.0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thread_radius), 0, wxEXPAND); + + m_thread_pitch = make_spin(m_cards, 2.0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pitch")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thread_pitch), 0, wxEXPAND); + + m_thread_height = make_spin(m_cards, 10.0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Length")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thread_height), 0, wxEXPAND); + + m_thread_depth = make_spin(m_cards, 1.0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thread depth")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thread_depth), 0, wxEXPAND); + + m_thread_x = make_spin(m_cards, 0.0, -1000.0, 1000.0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos X")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thread_x), 0, wxEXPAND); + + m_thread_y = make_spin(m_cards, 0.0, -1000.0, 1000.0); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos Y")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thread_y), 0, wxEXPAND); + + m_thread_internal = new CheckBox(m_cards); + m_thread_internal->SetValue(false); + // External rod uses the major radius; an internal tapped bore uses the minor + // (tap-drill) radius — re-derive the nominal radius when the role flips. + m_thread_internal->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { + apply_thread_standard(); + e.Skip(); + }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Internal")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_thread_internal, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + + m_thread_target_label = new wxStaticText(m_cards, wxID_ANY, _L("(none — uses Thread plane)")); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("On face")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_thread_target_label, 0, wxALIGN_CENTER_VERTICAL); + + m_box_thread = new wxBoxSizer(wxVERTICAL); + m_box_thread->Add(card_header(m_cards, "design_thread", _L("Thread"), m_hdr_thread), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_thread->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_thread->Add(tform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + cards->Add(m_box_thread, 0, wxEXPAND); + + // --- Revolve (sweep a sketch profile about an in-plane axis) --- + m_box_revolve = new wxBoxSizer(wxVERTICAL); + m_box_revolve->Add(card_header(m_cards, "design_revolve", _L("Revolve"), m_hdr_revolve), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_revolve->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_revolve_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —")); + m_box_revolve->Add(m_revolve_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); + { + auto* rform = two_col_form(); + + m_revolve_angle = make_spin(m_cards, 360.0, 1.0, 360.0); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(spin_frame(m_revolve_angle), 0, wxEXPAND); + + m_revolve_axis = make_combo(m_cards); + m_revolve_axis->Append(_L("Plane X")); + m_revolve_axis->Append(_L("Plane Y")); + m_revolve_axis->SetSelection(0); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Axis")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_revolve_axis, 0, wxEXPAND); + + m_revolve_mode = make_combo(m_cards); + m_revolve_mode->Append(_L("New")); + m_revolve_mode->Append(_L("Add")); + m_revolve_mode->Append(_L("Cut")); + m_revolve_mode->Append(_L("Intersect")); + m_revolve_mode->SetSelection(0); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_revolve_mode, 0, wxEXPAND); + + m_revolve_flip = new CheckBox(m_cards); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_revolve_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + + m_box_revolve->Add(rform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_revolve, 0, wxEXPAND); + + // --- Sweep (sweep a profile sketch along a path sketch) --- + m_box_sweep = new wxBoxSizer(wxVERTICAL); + m_box_sweep->Add(card_header(m_cards, "design_sweep", _L("Sweep"), m_hdr_sweep), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_sweep->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_sweep_profile_label = new wxStaticText(m_cards, wxID_ANY, _L("Profile: —")); + m_box_sweep->Add(m_sweep_profile_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); + { + auto* sform = two_col_form(); + + m_sweep_path = make_combo(m_cards); + sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Path")), 0, wxALIGN_CENTER_VERTICAL); + sform->Add(m_sweep_path, 0, wxEXPAND); + + m_sweep_mode = make_combo(m_cards); + m_sweep_mode->Append(_L("New")); + m_sweep_mode->Append(_L("Add")); + m_sweep_mode->Append(_L("Cut")); + m_sweep_mode->Append(_L("Intersect")); + m_sweep_mode->SetSelection(0); + sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); + sform->Add(m_sweep_mode, 0, wxEXPAND); + + m_box_sweep->Add(sform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_sweep, 0, wxEXPAND); + + // --- Pattern (replicate the target body: linear or circular) --- + m_box_pattern = new wxBoxSizer(wxVERTICAL); + m_box_pattern->Add(card_header(m_cards, "design_pattern", _L("Pattern"), m_hdr_pattern), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_pattern->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* pform = two_col_form(); + + m_pattern_type = make_combo(m_cards); + m_pattern_type->Append(_L("Linear")); + m_pattern_type->Append(_L("Circular")); + m_pattern_type->SetSelection(0); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Type")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(m_pattern_type, 0, wxEXPAND); + + m_pattern_count = make_spin(m_cards, 3, 1, 999); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Count")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(spin_frame(m_pattern_count), 0, wxEXPAND); + + m_pattern_spacing = make_spin(m_cards, 20.0, 0.01, 100000.0); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Spacing")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(spin_frame(m_pattern_spacing), 0, wxEXPAND); + + m_pattern_dir = make_combo(m_cards); + m_pattern_dir->Append(_L("Plane X")); + m_pattern_dir->Append(_L("Plane Y")); + m_pattern_dir->SetSelection(0); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Direction")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(m_pattern_dir, 0, wxEXPAND); + + m_pattern_angle = make_spin(m_cards, 360.0, 1.0, 360.0); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Total angle°")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(spin_frame(m_pattern_angle), 0, wxEXPAND); + + m_box_pattern->Add(pform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_pattern, 0, wxEXPAND); + + // --- Boolean (combine two existing bodies: union / subtract / intersect) --- + m_box_boolean = new wxBoxSizer(wxVERTICAL); + m_box_boolean->Add(card_header(m_cards, "design_boolean", _L("Boolean"), m_hdr_boolean), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_boolean->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* bform = two_col_form(); + + m_bool_op = make_combo(m_cards); + m_bool_op->Append(_L("Union (join)")); + m_bool_op->Append(_L("Subtract (cut)")); + m_bool_op->Append(_L("Intersect")); + m_bool_op->SetSelection(0); + m_bool_op->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Operation")), 0, wxALIGN_CENTER_VERTICAL); + bform->Add(m_bool_op, 0, wxEXPAND); + + m_bool_target = make_combo(m_cards); + m_bool_target->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target (kept)")), 0, wxALIGN_CENTER_VERTICAL); + bform->Add(m_bool_target, 0, wxEXPAND); + + m_bool_tool = make_combo(m_cards); + m_bool_tool->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Tool")), 0, wxALIGN_CENTER_VERTICAL); + bform->Add(m_bool_tool, 0, wxEXPAND); + + // Fuzzy tolerance (mm): the main use is a tool body cutting a destination — a small + // tolerance lets near-coincident mating faces resolve into a clean cut instead of a + // failed boolean or sliver faces. 0 = exact. + m_bool_tol = make_spin(m_cards, 0.0, 0.0, 100.0); + m_bool_tol->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Tolerance")), 0, wxALIGN_CENTER_VERTICAL); + bform->Add(spin_frame(m_bool_tol), 0, wxEXPAND); + + m_box_boolean->Add(bform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + + m_bool_keep = new CheckBox(m_cards); + m_bool_keep->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + auto* keeprow = new wxBoxSizer(wxHORIZONTAL); + keeprow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Keep tool body")), 0, wxALIGN_CENTER_VERTICAL); + keeprow->AddStretchSpacer(); + keeprow->Add(m_bool_keep, 0, wxALIGN_CENTER_VERTICAL); + m_box_boolean->Add(keeprow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_boolean, 0, wxEXPAND); + + // --- Cut (split a body with a plane): parameters only; ✓/✗ live on the ribbon --- + m_box_cut = new wxBoxSizer(wxVERTICAL); + m_box_cut->Add(card_header(m_cards, "design_cut", _L("Cut"), m_hdr_cut), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_cut->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* cform = two_col_form(); + + m_cut_target = make_combo(m_cards); + m_cut_target->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + cform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); + cform->Add(m_cut_target, 0, wxEXPAND); + + m_cut_plane = make_combo(m_cards); + m_cut_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + cform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL); + cform->Add(m_cut_plane, 0, wxEXPAND); + + m_cut_offset = make_spin(m_cards, 0.0, -10000.0, 10000.0); + m_cut_offset->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + cform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL); + cform->Add(spin_frame(m_cut_offset), 0, wxEXPAND); + + m_box_cut->Add(cform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + // The cut always leaves BOTH pieces as separate bodies (a non-destructive split); + // delete one from the tree afterwards if you only want a half. + } + cards->Add(m_box_cut, 0, wxEXPAND); + + // --- Insert (Text / SVG placement): Confirm/Cancel for the in-canvas art transform --- + m_box_insert = new wxBoxSizer(wxVERTICAL); + m_box_insert->Add(card_header(m_cards, "design_text", _L("Insert"), m_hdr_insert), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_insert->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_insert->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Drag a corner to size, the centre to move.\nConfirm or Cancel in the toolbar above.")), + 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); + cards->Add(m_box_insert, 0, wxEXPAND); + + // --- Plane (datum/reference plane: offset + tilt from a base plane; no solid) --- + m_box_plane = new wxBoxSizer(wxVERTICAL); + m_box_plane->Add(card_header(m_cards, "design_plane", _L("Plane"), m_hdr_plane), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_plane->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + // Plane type chooses which inputs matter (Onshape/Fusion parity): + // Offset = Base (or Face A) + Offset (+ Tilt about a base axis) + // Angle = Edge A (line) + Base/Face A reference + Angle° + // Midplane = Face A + Face B (halfway between) + // Tangent = Face A (a cylinder) + Angle° around its axis + // Two edges = Edge A + Edge B + // Coincident = Face A (lie on that face) + m_plane_type = make_combo(m_cards); + for (const wxString& t : { _L("Offset"), _L("Angle"), _L("Midplane"), + _L("Tangent"), _L("Two edges"), _L("Coincident") }) + m_plane_type->Append(t); + m_plane_type->SetSelection(0); + m_box_plane->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane type")), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_plane->Add(m_plane_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + + auto* plform = two_col_form(); + + m_plane_base = make_combo(m_cards); + populate_plane_choices(m_plane_base); // XY/XZ/YZ + any existing datum planes + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Base")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(m_plane_base, 0, wxEXPAND); + + m_plane_offset = make_spin(m_cards, 20.0, -100000.0, 100000.0); + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(spin_frame(m_plane_offset), 0, wxEXPAND); + + m_plane_tilt = make_spin(m_cards, 0.0, -180.0, 180.0); + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle°")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(spin_frame(m_plane_tilt), 0, wxEXPAND); + + m_plane_tilt_axis = make_combo(m_cards); + m_plane_tilt_axis->Append(_L("Base X")); + m_plane_tilt_axis->Append(_L("Base Y")); + m_plane_tilt_axis->SetSelection(0); + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Tilt axis")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(m_plane_tilt_axis, 0, wxEXPAND); + + // Contextual reference picks: arm a target, then click a solid face/edge in the canvas. + auto pick_row = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, PlanePick target) { + btn = new wxButton(m_cards, wxID_ANY, label); + lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); + btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_plane_pick(target); }); + plform->Add(btn); + plform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL); + }; + pick_row(_L("Pick Face A"), m_plane_pick_faceA, m_plane_faceA_lbl, PlanePick::FaceA); + pick_row(_L("Pick Face B"), m_plane_pick_faceB, m_plane_faceB_lbl, PlanePick::FaceB); + pick_row(_L("Pick Edge A"), m_plane_pick_edgeA, m_plane_edgeA_lbl, PlanePick::EdgeA); + pick_row(_L("Pick Edge B"), m_plane_pick_edgeB, m_plane_edgeB_lbl, PlanePick::EdgeB); + + m_plane_usize = make_spin(m_cards, 60.0, 1.0, 100000.0); + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Size U")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(spin_frame(m_plane_usize), 0, wxEXPAND); + m_plane_vsize = make_spin(m_cards, 60.0, 1.0, 100000.0); + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Size V")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(spin_frame(m_plane_vsize), 0, wxEXPAND); + + m_box_plane->Add(plform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_plane, 0, wxEXPAND); + + // --- Loft (skin a solid through 2+ ordered profile sketches) --- + m_box_loft = new wxBoxSizer(wxVERTICAL); + m_box_loft->Add(card_header(m_cards, "design_loft", _L("Loft"), m_hdr_loft), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_loft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_loft->Add(new wxStaticText(m_cards, wxID_ANY, _L("Profiles (check 2+, in order):")), + 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, wxSize(-1, 120)); + m_loft_list->Bind(wxEVT_CHECKLISTBOX, [this](wxCommandEvent&) { refresh_preview(); }); + m_box_loft->Add(m_loft_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + { + auto* lform = two_col_form(); + + m_loft_mode = make_combo(m_cards); + m_loft_mode->Append(_L("New")); + m_loft_mode->Append(_L("Add")); + m_loft_mode->Append(_L("Cut")); + m_loft_mode->Append(_L("Intersect")); + m_loft_mode->SetSelection(0); + lform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); + lform->Add(m_loft_mode, 0, wxEXPAND); + + m_box_loft->Add(lform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + m_loft_ruled = new CheckBox(m_cards); + { + auto* lrow = new wxBoxSizer(wxHORIZONTAL); + lrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Ruled (straight) sections")), 0, wxALIGN_CENTER_VERTICAL); + lrow->AddStretchSpacer(); + lrow->Add(m_loft_ruled, 0, wxALIGN_CENTER_VERTICAL); + m_box_loft->Add(lrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_loft, 0, wxEXPAND); + + // --- Surface Extrude (sheet body from sketch) --- + m_box_surf_extrude = new wxBoxSizer(wxVERTICAL); + m_box_surf_extrude->Add(card_header(m_cards, "design_extrude", _L("Surface Extrude"), m_hdr_surf_extrude), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_extrude->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_surf_extrude_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —")); + m_box_surf_extrude->Add(m_surf_extrude_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); + { + auto* sform = two_col_form(); + m_surf_extrude_distance = make_spin(m_cards, 10); + sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance")), 0, wxALIGN_CENTER_VERTICAL); + sform->Add(spin_frame(m_surf_extrude_distance), 0, wxEXPAND); + m_box_surf_extrude->Add(sform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_surf_extrude, 0, wxEXPAND); + + // --- Surface Revolve (sheet body from sketch about axis) --- + m_box_surf_revolve = new wxBoxSizer(wxVERTICAL); + m_box_surf_revolve->Add(card_header(m_cards, "design_revolve", _L("Surface Revolve"), m_hdr_surf_revolve), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_revolve->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_surf_revolve_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —")); + m_box_surf_revolve->Add(m_surf_revolve_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); + { + auto* rform = two_col_form(); + m_surf_revolve_angle = make_spin(m_cards, 360.0, 1.0, 360.0); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(spin_frame(m_surf_revolve_angle), 0, wxEXPAND); + m_surf_revolve_axis = make_combo(m_cards); + m_surf_revolve_axis->Append(_L("Plane X")); + m_surf_revolve_axis->Append(_L("Plane Y")); + m_surf_revolve_axis->SetSelection(0); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Axis")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_surf_revolve_axis, 0, wxEXPAND); + m_surf_revolve_flip = new CheckBox(m_cards); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_surf_revolve_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + m_box_surf_revolve->Add(rform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_surf_revolve, 0, wxEXPAND); + + // --- Surface Loft (sheet skin through 2+ ordered profile sketches) --- + m_box_surf_loft = new wxBoxSizer(wxVERTICAL); + m_box_surf_loft->Add(card_header(m_cards, "design_loft", _L("Surface Loft"), m_hdr_surf_loft), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_loft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_surf_loft->Add(new wxStaticText(m_cards, wxID_ANY, _L("Profiles (check 2+, in order):")), + 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_surf_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, wxSize(-1, 120)); + m_surf_loft_list->Bind(wxEVT_CHECKLISTBOX, [this](wxCommandEvent&) { refresh_preview(); }); + m_box_surf_loft->Add(m_surf_loft_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_surf_loft_ruled = new CheckBox(m_cards); + { + auto* lrow = new wxBoxSizer(wxHORIZONTAL); + lrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Ruled (straight) sections")), 0, wxALIGN_CENTER_VERTICAL); + lrow->AddStretchSpacer(); + lrow->Add(m_surf_loft_ruled, 0, wxALIGN_CENTER_VERTICAL); + m_box_surf_loft->Add(lrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_surf_loft, 0, wxEXPAND); + + // --- Surface Fill (one-face sheet from sketch boundary) --- + m_box_surf_fill = new wxBoxSizer(wxVERTICAL); + m_box_surf_fill->Add(card_header(m_cards, "design_surface", _L("Surface Fill"), m_hdr_surf_fill), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_fill->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_surf_fill_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —")); + m_box_surf_fill->Add(m_surf_fill_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_fill->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Fills the sketch's closed boundary with a smooth face.")), + 0, wxLEFT | wxRIGHT, 12); + cards->Add(m_box_surf_fill, 0, wxEXPAND); + + // --- Surface Offset (offset a sheet body) --- + m_box_surf_offset = new wxBoxSizer(wxVERTICAL); + m_box_surf_offset->Add(card_header(m_cards, "design_offset", _L("Surface Offset"), m_hdr_surf_offset), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_offset->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* oform = two_col_form(); + m_surf_offset_body = make_combo(m_cards); + m_surf_offset_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + oform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sheet body")), 0, wxALIGN_CENTER_VERTICAL); + oform->Add(m_surf_offset_body, 0, wxEXPAND); + m_surf_offset_distance = make_spin(m_cards, 1.0, -100000.0, 100000.0); + m_surf_offset_distance->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + oform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL); + oform->Add(spin_frame(m_surf_offset_distance), 0, wxEXPAND); + m_box_surf_offset->Add(oform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_offset->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Offsets a sheet body's shell. Positive = outward, negative = inward.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_surf_offset, 0, wxEXPAND); + + // --- Thicken Surface (thicken a sheet body into a solid) --- + m_box_surf_thicken = new wxBoxSizer(wxVERTICAL); + m_box_surf_thicken->Add(card_header(m_cards, "design_thicken", _L("Thicken Surface"), m_hdr_surf_thicken), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_thicken->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* tform = two_col_form(); + m_surf_thicken_body = make_combo(m_cards); + m_surf_thicken_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sheet body")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_surf_thicken_body, 0, wxEXPAND); + m_surf_thicken_thickness = make_spin(m_cards, 1.0, 0.01, 100000.0); + m_surf_thicken_thickness->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_surf_thicken_thickness), 0, wxEXPAND); + m_surf_thicken_flip = new CheckBox(m_cards); + m_surf_thicken_flip->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_surf_thicken_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL); + m_box_surf_thicken->Add(tform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_surf_thicken->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Thickens a sheet body into a solid. Flip reverses the direction.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_surf_thicken, 0, wxEXPAND); + + // --- Shell (hollow the current body to a wall thickness, removing one picked face) --- + auto* sform = two_col_form(); + m_shell_thickness = make_spin(m_cards, 2.0, 0.01, 100000.0); + sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL); + sform->Add(spin_frame(m_shell_thickness), 0, wxEXPAND); + m_shell_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(all faces — closed hollow)")); + sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Open face")), 0, wxALIGN_CENTER_VERTICAL); + sform->Add(m_shell_face_label, 0, wxALIGN_CENTER_VERTICAL); + + m_box_shell = new wxBoxSizer(wxVERTICAL); + m_box_shell->Add(card_header(m_cards, "design_shell", _L("Shell"), m_hdr_shell), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_shell->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_shell->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Pick a solid face to open it, then set the wall thickness.")), + 0, wxLEFT | wxRIGHT, 12); + m_box_shell->Add(sform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + cards->Add(m_box_shell, 0, wxEXPAND); + + // --- Draft (taper a single picked solid face about the body bottom) --- + auto* drform = two_col_form(); + m_draft_angle = make_spin(m_cards, 5.0, -89.0, 89.0); + drform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); + drform->Add(spin_frame(m_draft_angle), 0, wxEXPAND); + m_draft_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(pick a side face)")); + drform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Face")), 0, wxALIGN_CENTER_VERTICAL); + drform->Add(m_draft_face_label, 0, wxALIGN_CENTER_VERTICAL); + + m_box_draft = new wxBoxSizer(wxVERTICAL); + m_box_draft->Add(card_header(m_cards, "design_draft", _L("Draft"), m_hdr_draft), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_draft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_box_draft->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Pick a side face, then set the draft angle. The face pivots about the body base.")), + 0, wxLEFT | wxRIGHT, 12); + m_box_draft->Add(drform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + cards->Add(m_box_draft, 0, wxEXPAND); + + // --- Transform (rigid move/rotate of an existing body) --- + m_box_transform = new wxBoxSizer(wxVERTICAL); + m_box_transform->Add(card_header(m_cards, "design_move", _L("Transform"), m_hdr_transform), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_transform->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* xform = two_col_form(); + m_xf_body = make_combo(m_cards); + m_xf_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(m_xf_body, 0, wxEXPAND); + m_xf_dx = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_xf_dx->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Translate X")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_dx), 0, wxEXPAND); + m_xf_dy = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_xf_dy->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Translate Y")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_dy), 0, wxEXPAND); + m_xf_dz = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_xf_dz->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Translate Z")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_dz), 0, wxEXPAND); + m_xf_axis = make_combo(m_cards); + m_xf_axis->Append(_L("X")); + m_xf_axis->Append(_L("Y")); + m_xf_axis->Append(_L("Z")); + m_xf_axis->SetSelection(2); // default Z + m_xf_axis->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Rotate axis")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(m_xf_axis, 0, wxEXPAND); + m_xf_angle = make_spin(m_cards, 0.0, -360.0, 360.0); + m_xf_angle->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_angle), 0, wxEXPAND); + m_xf_pivot_x = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_xf_pivot_x->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pivot X")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_pivot_x), 0, wxEXPAND); + m_xf_pivot_y = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_xf_pivot_y->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pivot Y")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_pivot_y), 0, wxEXPAND); + m_xf_pivot_z = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_xf_pivot_z->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pivot Z")), 0, wxALIGN_CENTER_VERTICAL); + xform->Add(spin_frame(m_xf_pivot_z), 0, wxEXPAND); + m_box_transform->Add(xform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_xf_copy = new CheckBox(m_cards); + m_xf_copy->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + auto* cpro = new wxBoxSizer(wxHORIZONTAL); + cpro->Add(new wxStaticText(m_cards, wxID_ANY, _L("Keep original (make a copy)")), 0, wxALIGN_CENTER_VERTICAL); + cpro->AddStretchSpacer(); + cpro->Add(m_xf_copy, 0, wxALIGN_CENTER_VERTICAL); + m_box_transform->Add(cpro, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_transform->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Translate and/or rotate the selected body. Rotation is applied about the pivot, then translation follows.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_transform, 0, wxEXPAND); + + // --- Mirror (reflect a body about a plane) --- + m_box_mirror = new wxBoxSizer(wxVERTICAL); + m_box_mirror->Add(card_header(m_cards, "design_mirror", _L("Mirror"), m_hdr_mirror), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_mirror->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* mform = two_col_form(); + m_mirror_body = make_combo(m_cards); + m_mirror_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(m_mirror_body, 0, wxEXPAND); + m_mirror_plane = make_combo(m_cards); + m_mirror_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mirror plane")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(m_mirror_plane, 0, wxEXPAND); + m_box_mirror->Add(mform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_mirror_keep = new CheckBox(m_cards); + m_mirror_keep->SetValue(true); + m_mirror_keep->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + auto* mkrow = new wxBoxSizer(wxHORIZONTAL); + mkrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Keep original")), 0, wxALIGN_CENTER_VERTICAL); + mkrow->AddStretchSpacer(); + mkrow->Add(m_mirror_keep, 0, wxALIGN_CENTER_VERTICAL); + m_box_mirror->Add(mkrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_mirror->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Reflect the selected body about a plane. The mirror is always a new body; keeping the original gives you both.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_mirror, 0, wxEXPAND); + + // --- Thicken (offset one solid face into a thin plate) --- + m_box_thicken = new wxBoxSizer(wxVERTICAL); + m_box_thicken->Add(card_header(m_cards, "design_thicken", _L("Thicken"), m_hdr_thicken), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_thicken->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* tform = two_col_form(); + m_thicken_body = make_combo(m_cards); + m_thicken_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_thicken_body, 0, wxEXPAND); + m_thicken_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(pick a solid face)")); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Face")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(m_thicken_face_label, 0, wxALIGN_CENTER_VERTICAL); + m_thicken_thickness = make_spin(m_cards, 2.0, 0.01, 100000.0); + m_thicken_thickness->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL); + tform->Add(spin_frame(m_thicken_thickness), 0, wxEXPAND); + m_box_thicken->Add(tform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_thicken_flip = new CheckBox(m_cards); + m_thicken_flip->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + auto* trow = new wxBoxSizer(wxHORIZONTAL); + trow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL); + trow->AddStretchSpacer(); + trow->Add(m_thicken_flip, 0, wxALIGN_CENTER_VERTICAL); + m_box_thicken->Add(trow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_thicken->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Pick a solid face and offset it into a new thin body. Not for sheet bodies — use Thicken Surface for those.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_thicken, 0, wxEXPAND); + + // --- Rib (thin wall from an open sketch line, fused to a body) --- + m_box_rib = new wxBoxSizer(wxVERTICAL); + m_box_rib->Add(card_header(m_cards, "design_rib", _L("Rib"), m_hdr_rib), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_rib->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* rform = two_col_form(); + m_rib_body = make_combo(m_cards); + m_rib_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target body")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_rib_body, 0, wxEXPAND); + m_rib_sketch = make_combo(m_cards); + m_rib_sketch->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sketch")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_rib_sketch, 0, wxEXPAND); + m_rib_entity = new wxSpinCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1), + wxSP_ARROW_KEYS | wxBORDER_SIMPLE); + m_rib_entity->SetRange(0, 999); + m_rib_entity->SetValue(0); + m_rib_entity->Bind(wxEVT_SPINCTRL, [this](wxSpinEvent&) { refresh_preview(); }); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Entity index")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(m_rib_entity, 0, wxEXPAND); + m_rib_thickness = make_spin(m_cards, 2.0, 0.01, 100000.0); + m_rib_thickness->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(spin_frame(m_rib_thickness), 0, wxEXPAND); + m_rib_depth = make_spin(m_cards, 10.0, 0.01, 100000.0); + m_rib_depth->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Depth")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(spin_frame(m_rib_depth), 0, wxEXPAND); + m_box_rib->Add(rform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_rib->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Grow a thin wall from an open sketch Line entity, fused to the target body. Entity index is the position of the open Line in the sketch.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_rib, 0, wxEXPAND); + + // --- Project (project body edges onto a plane as sketch entities) --- + m_box_project = new wxBoxSizer(wxVERTICAL); + m_box_project->Add(card_header(m_cards, "design_sketch", _L("Project"), m_hdr_project), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_project->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* pform = two_col_form(); + m_proj_source_body = make_combo(m_cards); + m_proj_source_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Source body")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(m_proj_source_body, 0, wxEXPAND); + m_proj_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(all edges)")); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Face")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(m_proj_face_label, 0, wxALIGN_CENTER_VERTICAL); + m_proj_plane = make_combo(m_cards); + populate_plane_choices(m_proj_plane); + m_proj_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target plane")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(m_proj_plane, 0, wxEXPAND); + m_box_project->Add(pform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_project->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Project the edges of a body onto a plane as sketch entities. Pick a face to project only its edges, or leave the face empty to project the whole body.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_project, 0, wxEXPAND); + + // --- Delete Face (remove faces, heal the solid) --- + m_box_delete_face = new wxBoxSizer(wxVERTICAL); + m_box_delete_face->Add(card_header(m_cards, "design_delete", _L("Delete Face"), m_hdr_delete_face), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_delete_face->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* dform = two_col_form(); + m_del_face_body = make_combo(m_cards); + m_del_face_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); + dform->Add(m_del_face_body, 0, wxEXPAND); + m_del_face_add_btn = new wxButton(m_cards, wxID_ANY, _L("Add picked face")); + m_del_face_add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + // Say why nothing happened. Clicking with no face picked used to be a silent no-op, + // which is indistinguishable from the button being broken. + if (m_sel_solid_face < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Click a face on the body first, then Add picked face")); + m_status->Refresh(); + return; + } + // Adding the same face twice puts a duplicate id in delete_faces, which the + // defeaturing algorithm has no reason to cope with. Re-clicking is a no-op, not an error. + if (std::find(m_del_faces.begin(), m_del_faces.end(), m_sel_solid_face) != m_del_faces.end()) { + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("Face %d is already in the list"), m_sel_solid_face)); + m_status->Refresh(); + return; + } + { + m_del_faces.push_back(m_sel_solid_face); + wxString s; + for (size_t i = 0; i < m_del_faces.size(); ++i) { + if (i > 0) s += ", "; + s += wxString::Format("Face %d", m_del_faces[i]); + } + m_del_face_list->SetLabel(s.empty() ? _L("(none)") : s); + m_del_face_list->GetParent()->Layout(); + m_del_face_list->Refresh(); + refresh_preview(); + } + }); + dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Faces to delete")), 0, wxALIGN_CENTER_VERTICAL); + dform->Add(m_del_face_add_btn, 0, wxALIGN_CENTER_VERTICAL); + m_del_face_list = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); + dform->Add(0, 0); + dform->Add(m_del_face_list, 0, wxALIGN_CENTER_VERTICAL); + m_box_delete_face->Add(dform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_box_delete_face->Add(new wxStaticText(m_cards, wxID_ANY, + _L("Pick a solid face, then click 'Add picked face' to accumulate faces. Confirm to remove all listed faces and heal the solid.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_delete_face, 0, wxEXPAND); + + // --- Helix (helical curve) --- + m_box_helix = new wxBoxSizer(wxVERTICAL); + m_box_helix->Add(card_header(m_cards, "design_thread", _L("Helix"), m_hdr_helix), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_helix->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* hform = two_col_form(); + m_helix_plane = make_combo(m_cards); + populate_plane_choices(m_helix_plane); + m_helix_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(m_helix_plane, 0, wxEXPAND); + m_helix_radius = make_spin(m_cards, 10.0, 0.01, 10000.0); + m_helix_radius->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Radius")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_helix_radius), 0, wxEXPAND); + m_helix_pitch = make_spin(m_cards, 5.0, 0.01, 10000.0); + m_helix_pitch->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pitch")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_helix_pitch), 0, wxEXPAND); + m_helix_height = make_spin(m_cards, 20.0, 0.01, 10000.0); + m_helix_height->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Height")), 0, wxALIGN_CENTER_VERTICAL); + hform->Add(spin_frame(m_helix_height), 0, wxEXPAND); + m_box_helix->Add(hform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_helix_left_handed = new CheckBox(m_cards); + m_helix_left_handed->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + auto* hrow = new wxBoxSizer(wxHORIZONTAL); + hrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Left-handed")), 0, wxALIGN_CENTER_VERTICAL); + hrow->AddStretchSpacer(); + hrow->Add(m_helix_left_handed, 0, wxALIGN_CENTER_VERTICAL); + m_box_helix->Add(hrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + m_helix_taper = make_spin(m_cards, 0.0, -89.0, 89.0); + m_helix_taper->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + auto* tvalform = two_col_form(); + tvalform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Taper (°)")), 0, wxALIGN_CENTER_VERTICAL); + tvalform->Add(spin_frame(m_helix_taper), 0, wxEXPAND); + m_box_helix->Add(tvalform, 0, wxEXPAND | wxLEFT | wxRIGHT, 12); + m_box_helix->Add(new wxStaticText(m_cards, wxID_ANY, + _L("A helical curve (spring path). Use as a sweep path to build springs, coils, or augers.")), + 0, wxLEFT | wxRIGHT, 12); + } + cards->Add(m_box_helix, 0, wxEXPAND); + + // --- Axis (datum axis: line through two points or derived from geometry) --- + m_box_axis = new wxBoxSizer(wxVERTICAL); + m_box_axis->Add(card_header(m_cards, "design_line", _L("Axis"), m_hdr_axis), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_axis->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + m_axis_type = make_combo(m_cards); + for (const wxString& t : { _L("Two points"), _L("Face normal"), _L("Cylinder centerline"), + _L("Plane intersection"), _L("Along edge") }) + m_axis_type->Append(t); + m_axis_type->SetSelection(0); + m_box_axis->Add(new wxStaticText(m_cards, wxID_ANY, _L("Axis type")), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_axis->Add(m_axis_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + + auto* axform = two_col_form(); + + auto ax_pick = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, AxisPick target) { + btn = new wxButton(m_cards, wxID_ANY, label); + lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); + btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_axis_pick(target); }); + axform->Add(btn); + axform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL); + }; + ax_pick(_L("Pick Face"), m_axis_pick_face, m_axis_face_lbl, AxisPick::Face); + ax_pick(_L("Pick Edge"), m_axis_pick_edge, m_axis_edge_lbl, AxisPick::Edge); + + m_axis_plane_a = make_combo(m_cards); + populate_plane_choices(m_axis_plane_a); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane A")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(m_axis_plane_a, 0, wxEXPAND); + m_axis_plane_b = make_combo(m_cards); + populate_plane_choices(m_axis_plane_b); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane B")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(m_axis_plane_b, 0, wxEXPAND); + + m_axis_p1x = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_axis_p1y = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_axis_p1z = make_spin(m_cards, 0.0, -100000.0, 100000.0); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 1 X")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(spin_frame(m_axis_p1x), 0, wxEXPAND); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 1 Y")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(spin_frame(m_axis_p1y), 0, wxEXPAND); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 1 Z")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(spin_frame(m_axis_p1z), 0, wxEXPAND); + + m_axis_p2x = make_spin(m_cards, 10.0, -100000.0, 100000.0); + m_axis_p2y = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_axis_p2z = make_spin(m_cards, 0.0, -100000.0, 100000.0); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 2 X")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(spin_frame(m_axis_p2x), 0, wxEXPAND); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 2 Y")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(spin_frame(m_axis_p2y), 0, wxEXPAND); + axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 2 Z")), 0, wxALIGN_CENTER_VERTICAL); + axform->Add(spin_frame(m_axis_p2z), 0, wxEXPAND); + + m_box_axis->Add(axform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_axis, 0, wxEXPAND); + + // --- CoordSys (datum coordinate system: point + orthonormal frame) --- + m_box_coordsys = new wxBoxSizer(wxVERTICAL); + m_box_coordsys->Add(card_header(m_cards, "design_point", _L("Coord Sys"), m_hdr_coordsys), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_coordsys->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + m_coordsys_type = make_combo(m_cards); + for (const wxString& t : { _L("Point (world)"), _L("Face + direction") }) + m_coordsys_type->Append(t); + m_coordsys_type->SetSelection(0); + m_box_coordsys->Add(new wxStaticText(m_cards, wxID_ANY, _L("Type")), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_coordsys->Add(m_coordsys_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + + m_cs_body = make_combo(m_cards); + m_cs_body->Append(_L("(all)")); + m_cs_body->SetSelection(0); + m_cs_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { + if (m_viewport) m_viewport->set_xray_focus(m_cs_body->GetSelection() - 1); + }); + m_box_coordsys->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_coordsys->Add(m_cs_body, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + auto* body_hint = new wxStaticText(m_cards, wxID_ANY, + _L("Restrict picking to one body — the others go see-through and stop catching clicks")); + body_hint->SetForegroundColour(dp_sec_text()); + m_box_coordsys->Add(body_hint, 0, wxLEFT | wxRIGHT | wxTOP, 12); + + auto* csform = two_col_form(); + + m_cs_x = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_cs_y = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_cs_z = make_spin(m_cards, 0.0, -100000.0, 100000.0); + csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X")), 0, wxALIGN_CENTER_VERTICAL); + csform->Add(spin_frame(m_cs_x), 0, wxEXPAND); + csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Y")), 0, wxALIGN_CENTER_VERTICAL); + csform->Add(spin_frame(m_cs_y), 0, wxEXPAND); + csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Z")), 0, wxALIGN_CENTER_VERTICAL); + csform->Add(spin_frame(m_cs_z), 0, wxEXPAND); + + auto cs_pick = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, CoordSysPick target) { + btn = new wxButton(m_cards, wxID_ANY, label); + lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); + btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_coordsys_pick(target); }); + csform->Add(btn); + csform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL); + }; + cs_pick(_L("Pick Face"), m_cs_pick_face, m_cs_face_lbl, CoordSysPick::Face); + + // ponytail: edge pick for CoordSys with rotation-direction hint + m_cs_pick_edge = new wxButton(m_cards, wxID_ANY, _L("Edge (sets in-plane direction)")); + m_cs_edge_lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); + m_cs_pick_edge->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { arm_coordsys_pick(CoordSysPick::Edge); }); + csform->Add(m_cs_pick_edge); + csform->Add(m_cs_edge_lbl, 0, wxALIGN_CENTER_VERTICAL); + auto* edge_hint = new wxStaticText(m_cards, wxID_ANY, + _L("Without an edge the frame takes X from the face's first edge — pick one to choose it yourself")); + edge_hint->SetForegroundColour(dp_sec_text()); + csform->Add(0, 0); // empty left column + csform->Add(edge_hint, 0, wxALIGN_CENTER_VERTICAL); + + m_cs_hx = make_spin(m_cards, 1.0, -100000.0, 100000.0); + m_cs_hy = make_spin(m_cards, 0.0, -100000.0, 100000.0); + m_cs_hz = make_spin(m_cards, 0.0, -100000.0, 100000.0); + csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X hint X")), 0, wxALIGN_CENTER_VERTICAL); + csform->Add(spin_frame(m_cs_hx), 0, wxEXPAND); + csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X hint Y")), 0, wxALIGN_CENTER_VERTICAL); + csform->Add(spin_frame(m_cs_hy), 0, wxEXPAND); + csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X hint Z")), 0, wxALIGN_CENTER_VERTICAL); + csform->Add(spin_frame(m_cs_hz), 0, wxEXPAND); + + m_box_coordsys->Add(csform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_coordsys, 0, wxEXPAND); + + // --- Mate (assembly: align two CoordSys features) --- + m_box_mate = new wxBoxSizer(wxVERTICAL); + m_box_mate->Add(card_header(m_cards, "design_c_coincident", _L("Mate"), m_hdr_mate), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_mate->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + auto* mform = two_col_form(); + + m_mate_kind = make_combo(m_cards); + m_mate_kind->Append(_L("Fastened — 6 DOF locked, B driven onto A exactly")); + m_mate_kind->Append(_L("Planar — z aligned, offset distance; free in-plane slide")); + m_mate_kind->Append(_L("Revolute — axes collinear; free rotation about z")); + m_mate_kind->Append(_L("Slider — orientation locked; free axial slide")); + m_mate_kind->Append(_L("Cylindrical — axes collinear; free spin + axial slide")); + m_mate_kind->SetSelection(0); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Kind")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(m_mate_kind, 0, wxEXPAND); + + // Populate the CoordSys pickers on open; show "A (fixed)" and "B (moves)" combos. + m_mate_cs_a = make_combo(m_cards); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("CS A (fixed)")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(m_mate_cs_a, 0, wxEXPAND); + + m_mate_cs_b = make_combo(m_cards); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("CS B (moves)")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(m_mate_cs_b, 0, wxEXPAND); + + m_offset_label = new wxStaticText(m_cards, wxID_ANY, _L("Offset")); + m_mate_offset = make_spin(m_cards, 0.0, -100000.0, 100000.0); + mform->Add(m_offset_label, 0, wxALIGN_CENTER_VERTICAL); + mform->Add(spin_frame(m_mate_offset), 0, wxEXPAND); + + m_angle_label = new wxStaticText(m_cards, wxID_ANY, _L("Angle \u00b0")); + m_mate_angle = make_spin(m_cards, 0.0, -360.0, 360.0); + mform->Add(m_angle_label, 0, wxALIGN_CENTER_VERTICAL); + mform->Add(spin_frame(m_mate_angle), 0, wxEXPAND); + + m_mate_flip = new CheckBox(m_cards); + auto* frow = new wxBoxSizer(wxHORIZONTAL); + frow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip (oppose z axes)")), 0, wxALIGN_CENTER_VERTICAL); + frow->AddStretchSpacer(); + frow->Add(m_mate_flip, 0, wxALIGN_CENTER_VERTICAL); + mform->Add(0, 0); + mform->Add(frow, 0, wxEXPAND); + + // Retitle offset/angle labels when the kind changes. + m_mate_kind->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { + const int kind = m_mate_kind->GetSelection(); + switch (kind) { + case 0: // Fastened + m_offset_label->SetLabel(_L("Offset")); + m_angle_label->SetLabel(_L("Angle \u00b0")); + break; + case 1: // Planar + m_offset_label->SetLabel(_L("Plane distance")); + m_angle_label->SetLabel(_L("Angle \u00b0")); + break; + case 2: // Revolute + m_offset_label->SetLabel(_L("Axial position")); + m_angle_label->SetLabel(_L("Angle \u00b0")); + break; + case 3: // Slider + m_offset_label->SetLabel(_L("Axial position")); + m_angle_label->SetLabel(_L("Angle \u00b0")); + break; + case 4: // Cylindrical + m_offset_label->SetLabel(_L("Axial position")); + m_angle_label->SetLabel(_L("Angle \u00b0")); + break; + } + m_cards->Layout(); m_form->Layout(); m_form->FitInside(); + refresh_preview(); + e.Skip(); + }); + + m_box_mate->Add(mform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_mate, 0, wxEXPAND); + // Refresh the confirm state whenever the mate inputs change. + m_mate_cs_a->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + m_mate_cs_b->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); + m_mate_offset->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + m_mate_angle->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); + m_mate_flip->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); }); + + // --- Expression binding card (visible during feature edit only) --- + // Lets the user bind a numeric field to a document-variable expression. Field-name + // combo is populated per feature type and is editable for power users. Set applies + // the binding with checkpoint+recompute+undo-on-failure; Clear removes it. + m_box_expr = new wxBoxSizer(wxVERTICAL); + { + wxStaticText* expr_hdr = nullptr; + m_box_expr->Add(card_header(m_cards, "design_constrain", _L("Expression"), expr_hdr), 0, + wxLEFT | wxRIGHT | wxTOP, 12); + m_box_expr->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + + auto* eform = two_col_form(); + + // The only editable combo in this panel, hence not make_combo(): that passes + // wxCB_READONLY, and Orca's ComboBox HIDES its text ctrl under that style + // (ComboBox.cpp:51), so there is nothing to type into and no SetEditable() to + // turn it back on. Constructed with style 0, the ctrl is shown with + // wxTE_PROCESS_ENTER and GetTextLabel() returns what the user typed — which is + // what makes the 21 unmapped feature types reachable at all. + m_expr_field = new ComboBox(m_cards, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(90), FromDIP(24)), 0, nullptr, 0); + m_expr_field->SetMinSize(wxSize(FromDIP(90), FromDIP(24))); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Field")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(m_expr_field, 0, wxEXPAND); + + m_expr_text = new wxTextCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, + wxSize(FromDIP(120), FromDIP(24)), + wxTE_PROCESS_ENTER | wxBORDER_SIMPLE); + m_expr_text->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { on_set_expr(); }); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Expr")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(m_expr_text, 0, wxEXPAND); + + m_box_expr->Add(eform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + + auto* brow = new wxBoxSizer(wxHORIZONTAL); + m_expr_set_btn = new wxButton(m_cards, wxID_ANY, _L("Set"), wxDefaultPosition, wxSize(50, 24)); + m_expr_clear_btn = new wxButton(m_cards, wxID_ANY, _L("Clear"), wxDefaultPosition, wxSize(50, 24)); + m_expr_set_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_expr(); }); + m_expr_clear_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_clear_expr(); }); + brow->Add(m_expr_set_btn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + brow->Add(m_expr_clear_btn, 0, wxALIGN_CENTER_VERTICAL); + brow->AddStretchSpacer(); + m_box_expr->Add(brow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + + m_expr_status = new wxStaticText(m_cards, wxID_ANY, _L("(no bindings)")); + m_expr_status->SetForegroundColour(dp_sec_text()); + m_box_expr->Add(m_expr_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); + } + cards->Add(m_box_expr, 0, wxEXPAND); + + // --- Docked value-entry card (Onshape Button->Dialog->Confirm for dimensions) --- + m_box_value = new wxBoxSizer(wxVERTICAL); + { + // Header title doubles as the operation label (set by request_value()). + m_box_value->Add(card_header(m_cards, "design_constrain", _L("Value"), m_value_label), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_value->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + auto* vrow = new wxBoxSizer(wxHORIZONTAL); + // wxTE_PROCESS_ENTER so the user can just type a value and press Enter to + // apply it (the natural CAD-dimension gesture), not only click Confirm. + // Plain text field (not a spin control): on wxGTK the native GtkSpinButton + // formats per the user locale (comma) with no clean override, so we own the + // formatting here to guarantee international '.' decimals. + m_value_input = new wxTextCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, + wxSize(90, -1), wxTE_PROCESS_ENTER); + m_value_input->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { confirm_value(); }); + vrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Value")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + vrow->Add(m_value_input, 0, wxALIGN_CENTER_VERTICAL); + m_box_value->Add(vrow, 0, wxLEFT | wxRIGHT | wxTOP, 12); + } + cards->Add(m_box_value, 0, wxEXPAND); + + // --- Sketch-entry card (Phase 3): plane/orientation, opens on "New sketch", + // persists until Finish. The toolbar holds only the drawing tools. --- + m_box_sketch_session = new wxBoxSizer(wxVERTICAL); + m_box_sketch_session->Add(card_header(m_cards, "design_sketch", _L("Sketch"), m_hdr_sketch_session), + 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_sketch_session->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + { + // NO plane dropdown. The sketch plane comes from what is picked in the VIEWPORT — a face + // on a solid, or one of the reference-plane ghosts — because that is where the user is + // looking and pointing. A combo duplicated that decision somewhere the geometry could not + // see it, and once a face could be picked it went further and displayed a stale row that + // contradicted the real target. e1p. + // Kept as a member, not a local: the card has to be able to STOP saying this. It asked + // for a plane even when one had just been picked, directly contradicting the status line + // two inches below it, which by then read "Sketching on XZ". + m_sketch_hint = new wxStaticText(m_cards, wxID_ANY, + _L("Click a face or a reference plane, then a sketch tool.")); + m_sketch_hint->SetForegroundColour(dp_sec_text()); + m_box_sketch_session->Add(m_sketch_hint, 0, wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 12); + } + cards->Add(m_box_sketch_session, 0, wxEXPAND); + + // --- Constraint-manager card (C3.4): list of the constrained sketch's + // entity-constraints; each row selects (highlights) + deletes. Shown only + // in Constrain mode; rebuilt by rebuild_constraint_list(). + m_box_constraints = new wxBoxSizer(wxVERTICAL); + m_box_constraints->Add(card_header(m_cards, "design_constrain", _L("Constraints"), m_hdr_constraints), + 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_constraints->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); + m_constraint_rows = new wxBoxSizer(wxVERTICAL); + m_box_constraints->Add(m_constraint_rows, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 12); + cards->Add(m_box_constraints, 0, wxEXPAND); + + // Wrap every card label now that the whole stack exists. wxStaticText never wraps on its + // own, so a one-sentence help line under a card sets that card's sizer min width to the + // width of the entire sentence — the same failure make_combo's explicit width fixes above, + // and with the same symptom: the card is wider than the sidebar, so every control in it is + // clipped at the panel's right edge. Found by click-testing Transform, Mirror and Coord Sys, + // whose hints are the longest. 240 px sits under m_form's 264 px minimum, so a wrapped hint + // always fits; the short two-column labels are far below it and Wrap() leaves them alone. + for (wxWindow* w : m_cards->GetChildren()) + if (auto* t = dynamic_cast(w)) + t->Wrap(240); + + // Feature tree card. Same idiom as Prepare's sections (icon + Head_14 title + rule) via the + // shared card_header helper, instead of the bare micro-label this used to be; the row-edit + // actions live in the header, as Prepare puts its section actions. + m_tree_box = make_card(m_form); + auto* tree_inner = new wxBoxSizer(wxVERTICAL); + m_tree_box->SetSizer(tree_inner); + root->Add(m_tree_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8); + + m_hdr_tree_row = new wxBoxSizer(wxHORIZONTAL); + m_hdr_tree_row->Add(card_header(m_tree_box, "design_sketch", _L("Feature tree"), m_hdr_tree), 0, + wxALIGN_CENTER_VERTICAL); + m_hdr_tree_row->AddStretchSpacer(); + tree_inner->Add(m_hdr_tree_row, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, + FromDIP(SidebarProps::ContentMargin())); + tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, + FromDIP(SidebarProps::TitlebarMargin())); + m_tree = new wxTreeCtrl(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 64), + wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | + wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS); + if (!dp_dark()) m_tree->SetBackgroundColour(dp_panel_bg()); + // Per-feature-type icons (indices match tree_icon_for): sketch/extrude/dressup/hole/thread. + m_tree_images = new wxImageList(16, 16); + m_tree_images->Add(create_scaled_bitmap("design_sketch", nullptr, 16)); // 0 Sketch + m_tree_images->Add(create_scaled_bitmap("design_extrude", nullptr, 16)); // 1 Extrude + m_tree_images->Add(create_scaled_bitmap("design_dressup", nullptr, 16)); // 2 Fillet/Chamfer + m_tree_images->Add(create_scaled_bitmap("design_hole", nullptr, 16)); // 3 Hole + m_tree_images->Add(create_scaled_bitmap("design_thread", nullptr, 16)); // 4 Thread + m_tree_images->Add(create_scaled_bitmap("design_shell", nullptr, 16)); // 5 Shell + m_tree->AssignImageList(m_tree_images); + tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12); + + // Selecting a body-producing feature (Extrude/Fillet/Chamfer/Hole/Thread) in the + // tree highlights the solid in the viewport; a Sketch row clears the highlight + // (its face is already shown via the persistent sketch overlay). + m_tree->Bind(wxEVT_TREE_SEL_CHANGED, [this](wxTreeEvent&) { + if (!m_viewport) return; + // Bodies live in the Parts list now; picking a feature here drops any body selection + // so the two lists can't both claim to be "the target". + // ONLY when this tree actually has a selection. These two lists clear each other's + // selection so that "the target" is never ambiguous, and that was harmless while both + // calls were UnselectAll() — a no-op on a wxTR_SINGLE tree. Now that Unselect() really + // clears, the pair became a loop: clicking a body row runs apply_body_row, which calls + // m_tree->Unselect(), which fires THIS handler, which cleared the body row the user had + // just clicked. The guard keeps the mutual-exclusion and drops the echo. + if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->Unselect(); + const int sel = tree_selection(); + const bool body = (sel >= 0 && sel < int(m_doc.features.size()) && + m_doc.features[sel].type != CadFeatureType::Sketch && + !m_doc.body.IsNull()); + m_viewport->set_body_highlight(body); + // A conflicting mate says WHY on selection, and names the one action that resolves it. + // Suppress is the generic per-feature enable toggle (the eye), so the answer is already + // one click away on a row the user has just selected — the message points at it instead + // of describing a problem with no way out. + if (const std::string* why = (sel >= 0) ? mate_conflict_reason(sel) : nullptr) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(wxString::FromUTF8(*why) + _L(" — the eye suppresses this mate")); + m_status->Refresh(); + } else if (sel >= 0 && sel < int(m_doc.features.size())) { + // Name the two gestures the row supports, because neither is visible on it. + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format( + _L("%s selected — F2 or right-click renames it, double-click edits it"), + wxString::FromUTF8(m_doc.features[sel].name))); + m_status->Refresh(); + } + }); + + // Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas. + // Without it the row only highlights and the feature looks dead until the user finds the + // Edit button in the section header. + m_tree->Bind(wxEVT_TREE_ITEM_ACTIVATED, [this](wxTreeEvent&) { on_edit_feature(); }); + + // Right-click a row: the three things a row can do. Renaming had no discoverable route at + // all — the header pencil is Edit, a double-click ACTIVATES the row and is also Edit (the + // "slow double-click renames" the old comment promised does not survive wxGTK, which fires + // ITEM_ACTIVATED first), none of the seven header icons renames, and F2 is a function key + // nothing announces. A user who wants to name a sketch tries the row, and now the row + // answers. rename. + m_tree->Bind(wxEVT_TREE_ITEM_RIGHT_CLICK, [this](wxTreeEvent& e) { + m_tree->SelectItem(e.GetItem()); // right-click targets what it points at + const int sel = tree_selection(); + if (sel == wxNOT_FOUND) return; + // EVERYTHING A ROW CAN DO, in one place. The header icons stay as a quick bar, but the + // menu is the reference: the element you click answers with what applies to it, and a + // menu grows without spending an icon nobody recognises. Split into what the row IS + // (name, contents), where it SITS (order, visibility) and what removes it. + wxMenu menu; + const int id_rename = wxWindow::NewControlId(); + const int id_edit = wxWindow::NewControlId(); + const int id_up = wxWindow::NewControlId(); + const int id_down = wxWindow::NewControlId(); + const int id_vis = wxWindow::NewControlId(); + const int id_art = wxWindow::NewControlId(); + const int id_del = wxWindow::NewControlId(); + menu.Append(id_rename, _L("Rename\tF2")); + menu.Append(id_edit, _L("Edit")); + // Scale artwork acts on THIS feature's imported outline, so it belongs to the row and + // is offered only where it means something. It used to hide inside the header's Move + // button, which otherwise moved a body — two different subjects on one icon. + const bool art = sel < int(m_doc.features.size()) && + !m_doc.features[sel].imported_regions.empty(); + if (art) menu.Append(id_art, _L("Scale artwork")); + menu.AppendSeparator(); + menu.Append(id_up, _L("Move up")); + menu.Append(id_down, _L("Move down")); + menu.Append(id_vis, _L("Show / hide")); + menu.AppendSeparator(); + menu.Append(id_del, _L("Delete")); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { + const int row = tree_selection(); + if (row != wxNOT_FOUND && row < int(m_tree_items.size())) + m_tree->EditLabel(m_tree_items[row]); + }, id_rename); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_edit_feature(); }, id_edit); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(-1); }, id_up); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(+1); }, id_down); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_toggle_visibility(); }, id_vis); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del); + if (art) + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { + const int row = tree_selection(); + if (row != wxNOT_FOUND) on_transform_imported(row); + }, id_art); + m_tree->PopupMenu(&menu); + }); + + // In-place rename of a feature row (slow double-click, the offer's Rename verb, or F2). + // The name is what makes a tree of eight sketches readable, and the tree row IS the object — + // so renaming belongs on the row, not in a side-panel field. Bodies are computed results, + // not named features, so a body row must never open an editor (it cannot, they live in the + // Parts list, but the guard keeps a future change from slipping a body into this tree). + auto item_index = [this](const wxTreeItemId& it) -> int { + for (size_t i = 0; i < m_tree_items.size(); ++i) + if (m_tree_items[i] == it) return int(i); + return wxNOT_FOUND; + }; + m_tree->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this, item_index](wxTreeEvent& e) { + // The event's item is the authority for WHAT is being edited; tree_selection() is not, + // because the editor can open on a row that is not the current selection. A row that is + // not a feature (a body, or a stray id) gets the edit vetoed before it can take a name. + if (tree_body_selection() >= 0 || item_index(e.GetItem()) == wxNOT_FOUND) { e.Veto(); return; } + e.Skip(); + }); + m_tree->Bind(wxEVT_TREE_END_LABEL_EDIT, [this, item_index](wxTreeEvent& e) { + if (e.IsEditCancelled()) return; + const int idx = item_index(e.GetItem()); + if (idx == wxNOT_FOUND) { e.Veto(); return; } + wxString label = e.GetLabel(); + label.Trim(true).Trim(false); + if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a badly named one + m_doc.features[idx].name = std::string(label.ToUTF8().data()); + sync_recipe_to_model(); // the name is part of the recipe, so the save path persists it + e.Skip(); // let wx finish applying the label to the item it is holding + // REBUILD LATER, NOT NOW. refresh_tree() deletes and re-creates every wxTreeItemId, and + // we are inside wx's own END_LABEL_EDIT dispatch for one of them — destroying it here + // frees the item the caller is still using and takes the process down. Measured: typing + // a name and pressing Enter killed the app outright, with the keystrokes traced and no + // trace for the Return. Deferring to the next event-loop turn lets wx finish first. + CallAfter([this] { refresh_tree(); }); + }); + + // Feature-tree edit actions: act on the selected feature (delete / reorder). These sit in the + // card header (Prepare puts its section actions there too) rather than on a loose row below. + { + wxBoxSizer* trow = m_hdr_tree_row; + auto edit_btn = [this](const char* icon, const wxString& tip) { + // Header-sized: reads as a section action, not a primary control. + auto* b = new ScalableButton(m_tree_box, wxID_ANY, icon, "", wxSize(24, 24), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20); + b->SetToolTip(tip); + return b; + }; + auto* edit = edit_btn("design_edit", _L("Edit")); + edit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_feature(); }); + // NO Move here. Moving a body is not a feature-row action — this header sits over the + // FEATURE tree, and the button had to guess its subject from whatever happened to be + // selected, answering a feature row with an instruction about bodies. It lives where a + // body lives: the Bodies card's own action row, and the offer for a selected body. + // Scaling imported artwork, which shared this button, moved to the row's own menu. + auto* vis = edit_btn("design_eye", _L("Show / hide")); + vis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); }); + auto* del = edit_btn("design_delete", _L("Delete")); + del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + // A body row deletes the feature that made it. on_delete_body() already resolves + // CadBody::source_feature and asks for confirmation by name; it was reachable only + // from the right-click offer, so this button answered a selected body row with + // "select the FEATURE that created this body" — an instruction the user cannot act + // on, since the tree does not say which feature that is. It does now. + if (tree_body_selection() >= 0) on_delete_body(); + else on_delete_feature(); + }); + auto* up = edit_btn("design_moveup", _L("Move up")); + up->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(-1); }); + auto* down = edit_btn("design_movedown", _L("Move down")); + down->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(+1); }); + m_btn_interfere = edit_btn("color_palette", _L("Check interference — find overlapping bodies")); + m_btn_interfere->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_check_interference(); }); + const int gap = FromDIP(SidebarProps::ElementSpacing()); + trow->Add(edit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); + trow->Add(vis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); + trow->Add(del, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); + trow->Add(up, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); + trow->Add(down, 0, wxALIGN_CENTER_VERTICAL); + // Interference check sits after a rule: it reports, it does not edit the recipe. + trow->AddSpacer(8); + trow->Add(new wxStaticLine(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(1, 22), wxLI_VERTICAL), + 0, wxALIGN_CENTER_VERTICAL); + trow->AddSpacer(8); + trow->Add(m_btn_interfere, 0, wxALIGN_CENTER_VERTICAL); + // trow IS the header sizer — already added to root above the tree. + } + + // Parts list (Onshape's Features + Parts split). Bodies used to be appended after the + // features INSIDE the feature tree, so they were pushed out of view as the history grew — + // with no way to select a body at all. Their own list keeps them reachable regardless. + m_parts_box = make_card(m_form); + auto* parts_inner = new wxBoxSizer(wxVERTICAL); + m_parts_box->SetSizer(parts_inner); + root->Add(m_parts_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8); + + m_parts_hdr = new wxBoxSizer(wxHORIZONTAL); + m_parts_hdr->Add(card_header(m_parts_box, "design_extrude", _L("Bodies"), m_parts_label), 0, + wxALIGN_CENTER_VERTICAL); + // The bodies card carries the actions that act on a BODY. Move came from the feature-tree + // header, where it had to guess whether its subject was a body or a feature; Show/hide, + // Delete and Colour are deliberate COPIES of feature-tree actions, because a body row is a + // different subject and a user working in this list should not have to travel to another + // card to hide or recolour what they have selected. Boolean is not a copy — it is the one + // body-body operation, gated through on_boolean_tool. Each one already resolves the body row + // itself (on_toggle_visibility, on_delete_body, on_set_body_color), so nothing here decides + // policy — the card only gives them a home next to the rows they act on. + { + auto body_btn = [this](const char* icon, const wxString& tip) { + auto* b = new ScalableButton(m_parts_box, wxID_ANY, icon, "", wxSize(24, 24), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20); + b->SetToolTip(tip); + return b; + }; + auto* bmove = body_btn("design_move", _L("Move body")); + bmove->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { + on_move_body(); + } else { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Select a body row first, then move it")); + m_status->Refresh(); + } + }); + // Boolean lives here as well as on the toolbar: combining two bodies is a body action, + // and a user working in the body list should not have to leave it to find this. + auto* bbool = body_btn("design_boolean", _L("Boolean — join, subtract or intersect with another body")); + bbool->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_boolean_tool(); }); + auto* bvis = body_btn("design_eye", _L("Show / hide")); + bvis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); }); + auto* bdel = body_btn("design_delete", _L("Delete")); + bdel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_delete_body(); }); + auto* bcol = body_btn("color_palette", _L("Colour")); + bcol->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); + const int bgap = FromDIP(SidebarProps::ElementSpacing()); + m_parts_hdr->AddStretchSpacer(1); + m_parts_hdr->Add(bmove, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); + m_parts_hdr->Add(bbool, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); + m_parts_hdr->Add(bvis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); + m_parts_hdr->Add(bdel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); + m_parts_hdr->Add(bcol, 0, wxALIGN_CENTER_VERTICAL); + } + parts_inner->Add(m_parts_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, + FromDIP(SidebarProps::ContentMargin())); + m_parts_rule = new wxStaticLine(m_parts_box); + parts_inner->Add(m_parts_rule, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, + FromDIP(SidebarProps::TitlebarMargin())); + m_parts_hdr->ShowItems(false); // no bodies yet on a fresh document + m_parts_rule->Hide(); + m_parts = new wxTreeCtrl(m_parts_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 48), + wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | + wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS); + if (!dp_dark()) m_parts->SetBackgroundColour(dp_panel_bg()); + parts_inner->Add(m_parts, 0, wxEXPAND | wxALL, 12); + // Start hidden: a fresh document has no bodies, and refresh_parts() only runs on the first + // tree rebuild — until then an empty box would sit under the header. + m_parts->Hide(); + m_parts_label->Hide(); + // Taking a body from the list means the SAME state change however it was asked for, so the + // normalisation lives here and not inside a selection handler. That distinction is not + // pedantry: SelectItem() on a row that is ALREADY selected fires no SEL_CHANGED at all, so a + // version of this that only ran on selection left a stale vertex/edge from an earlier + // viewport pick in place — and offer_selection_kind() tests vertex FIRST, so right-clicking + // the body row served the VERTEX offer (Fillet greyed, Mirror in place of Repeat) while the + // row sat highlighted. Measured on the rig 2026-08-02; it is invisible from the code alone. + auto apply_body_row = [this](int b) { + if (!m_viewport || b < 0) return; + // One selection at a time: a body row and a feature row mean different things to the + // op bar, so clear the feature tree's highlight when a body takes over. + if (m_tree) m_tree->Unselect(); // wxTR_SINGLE: UnselectAll() does nothing here + m_viewport->set_body_highlight(false); // the per-body overlay does the tint + m_viewport->select_body(b); // also drops the vertex/edge marker + m_sel_solid_body = b; + m_sel_solid_face = m_sel_solid_edge = -1; + m_sel_solid_vertex = false; + m_pick_face = m_pick_face_body = -1; // chosen from the list, no face was pointed at + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1)); + m_status->Refresh(); + }; + m_parts->Bind(wxEVT_TREE_SEL_CHANGED, [this, apply_body_row](wxTreeEvent&) { + apply_body_row(tree_body_selection()); + }); + // The third door onto the offer, after the viewport right-click and the Menu key. A body ROW + // is an unambiguous body, so the offer reports BodySolid and the body verbs act on the row you + // can see highlighted. That is the confirmation a face pick cannot give: pointing at a face + // lights the face, never the body the verb will actually change. The status line above has + // been promising this right-click since before it existed. + // Renaming a BODY names the body itself. It does NOT rename the feature that created it: + // an Extrude, a Cut and a Fillet all land on one body, so source_feature is one operation in + // its history and renaming that is renaming the wrong object — reported, correctly, as "you + // consider the extrusion = the body". CadBody::user_name is carried across recompute() by + // index and written into the recipe, so the name outlives both the rebuild and the save. + m_parts->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this](wxTreeEvent& e) { + if (tree_body_selection() < 0) { e.Veto(); return; } + e.Skip(); + }); + m_parts->Bind(wxEVT_TREE_END_LABEL_EDIT, [this](wxTreeEvent& e) { + if (e.IsEditCancelled()) return; + const int b = tree_body_selection(); + if (b < 0 || b >= int(m_doc.bodies.size())) { e.Veto(); return; } + wxString label = e.GetLabel(); + label.Trim(true).Trim(false); + if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a bad name + m_doc.bodies[b].has_user_name = true; + m_doc.bodies[b].user_name = std::string(label.ToUTF8().data()); + sync_recipe_to_model(); // the name is part of what gets saved + e.Skip(); + // Rebuild on the NEXT event-loop turn: refresh_parts() destroys every wxTreeItemId and + // we are inside wx's own END_LABEL_EDIT dispatch for one of them. The feature tree + // learned this the hard way — doing it here took the process down. + CallAfter([this] { refresh_parts(); }); + }); + + m_parts->Bind(wxEVT_TREE_ITEM_MENU, [this, apply_body_row](wxTreeEvent& e) { + if (e.GetItem().IsOk()) + m_parts->SelectItem(e.GetItem()); // the row under the cursor, never a stale one + apply_body_row(tree_body_selection()); // unconditional — see above, SelectItem on an + // already-selected row raises no event + // GetPoint() is tree-client; it is (-1,-1) when the KEYBOARD menu key raised this, so fall + // back to the shared anchor rather than popping the menu at a garbage coordinate. + const wxPoint p = e.GetPoint(); + const wxPoint screen = (p.x >= 0 && p.y >= 0) ? m_parts->ClientToScreen(p) : offer_anchor(); + // Let the modal menu take the loop after this handler returns — same CallAfter as the + // sketch path, which learned it the hard way. + CallAfter([this, screen] { show_offer_menu(screen); }); + }); + + // --- Variables (document-scope named expressions) --- + // Below the feature tree + parts, always visible. wxListCtrl in report mode with two + // columns (Name, Expression). Add / edit open a small dialog; delete removes the + // selected row. Every mutation goes through checkpoint+recompute+undo-on-failure. + m_var_box = make_card(m_form); + auto* var_inner = new wxBoxSizer(wxVERTICAL); + m_var_box->SetSizer(var_inner); + { + auto* var_hdr = new wxBoxSizer(wxHORIZONTAL); + wxStaticText* var_hdr_title = nullptr; + var_hdr->Add(card_header(m_var_box, "design_constrain", _L("Variables"), var_hdr_title), 0, + wxALIGN_CENTER_VERTICAL); + var_hdr->AddStretchSpacer(); + m_btn_add_var = new wxButton(m_var_box, wxID_ANY, _L("+"), wxDefaultPosition, wxSize(30, 24)); + m_btn_edit_var = new wxButton(m_var_box, wxID_ANY, _L("Edit"), wxDefaultPosition, wxSize(50, 24)); + m_btn_del_var = new wxButton(m_var_box, wxID_ANY, _L("Del"), wxDefaultPosition, wxSize(42, 24)); + m_btn_add_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_variable(); }); + m_btn_edit_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_variable(); }); + m_btn_del_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_remove_variable(); }); + const int vhgap = FromDIP(SidebarProps::ElementSpacing()); + var_hdr->Add(m_btn_add_var, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, vhgap); + var_hdr->Add(m_btn_edit_var, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, vhgap); + var_hdr->Add(m_btn_del_var, 0, wxALIGN_CENTER_VERTICAL); + var_inner->Add(var_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, + FromDIP(SidebarProps::ContentMargin())); + var_inner->Add(new wxStaticLine(m_var_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, + FromDIP(SidebarProps::TitlebarMargin())); + + m_var_list = new wxListCtrl(m_var_box, wxID_ANY, wxDefaultPosition, + wxSize(-1, FromDIP(64)), wxLC_REPORT | wxLC_SINGLE_SEL); + if (!dp_dark()) m_var_list->SetBackgroundColour(dp_panel_bg()); + m_var_list->AppendColumn(_L("Name"), wxLIST_FORMAT_LEFT, FromDIP(90)); + m_var_list->AppendColumn(_L("Expression"), wxLIST_FORMAT_LEFT, FromDIP(120)); + var_inner->Add(m_var_list, 0, wxEXPAND | wxALL, FromDIP(SidebarProps::ContentMargin())); + } + root->Add(m_var_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8); + + // Orca-styled full-width buttons (ButtonType::Expanded), so the Design sidebar reads like + // Prepare's instead of showing raw OS-default wxButtons. + const int cm = FromDIP(SidebarProps::ContentMargin()); + + m_status = new wxStaticText(m_form, wxID_ANY, ""); + m_status->Hide(); // storage only — the line is drawn over the viewport, see set_status() + m_status_default_fg = m_status->GetForegroundColour(); // capture BEFORE any caller writes + root->Add(m_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); + + // DoF / constraint-state readout (P3). Dedicated line so it never clobbers the + // tool hint in m_status; updated by the on_solve_state callback after each solve. + m_dof_status = new wxStaticText(m_form, wxID_ANY, ""); + { + wxFont f = m_dof_status->GetFont(); + f.SetWeight(wxFONTWEIGHT_BOLD); + m_dof_status->SetFont(f); + } + root->Add(m_dof_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); + + + m_shape->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { on_shape_changed(); e.Skip(); }); + on_shape_changed(); + + // Any parameter edit refreshes the translucent preview. Command events from the + // spin/choice/checkbox children propagate up to m_form, so one binding each suffices. + m_form->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent& e) { refresh_preview(); e.Skip(); }); + m_form->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); }); + m_form->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); }); + m_form->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); }); + + m_form->SetSizer(root); + + // Start with every tool dialog hidden (only the toolbar + tree + Commit show). + root->Show(m_parts_box, false, false); // no bodies on a fresh document + root->Show(m_cards, false, false); // no tool open yet: don't draw an empty frame + cards->Show(m_box_sketch, false, true); + cards->Show(m_box_move, false, true); + cards->Show(m_box_extrude, false, true); + cards->Show(m_box_revolve, false, true); + cards->Show(m_box_sweep, false, true); + cards->Show(m_box_pattern, false, true); + cards->Show(m_box_plane, false, true); + cards->Show(m_box_axis, false, true); + cards->Show(m_box_coordsys, false, true); + cards->Show(m_box_loft, false, true); + cards->Show(m_box_draft, false, true); + cards->Show(m_box_boolean, false, true); + cards->Show(m_box_cut, false, true); + cards->Show(m_box_insert, false, true); + cards->Show(m_box_dressup, false, true); + cards->Show(m_box_hole, false, true); + cards->Show(m_box_thread, false, true); + cards->Show(m_box_shell, false, true); + cards->Show(m_box_surf_extrude, false, true); + cards->Show(m_box_surf_revolve, false, true); + cards->Show(m_box_surf_loft, false, true); + cards->Show(m_box_surf_fill, false, true); + cards->Show(m_box_surf_offset, false, true); + cards->Show(m_box_surf_thicken, false, true); + cards->Show(m_box_value, false, true); + cards->Show(m_box_sketch_session, false, true); + cards->Show(m_box_constraints, false, true); + cards->Show(m_box_expr, false, true); + // A card added to the cards sizer is VISIBLE until something hides it. close_tool()'s + // hide-all only runs on a tool switch, so any card missing from THIS block renders + // stacked in the sidebar from the moment the tab opens. These eight were wired into + // close_tool() but not here, which is what bloated the panel. + cards->Show(m_box_transform, false, true); + cards->Show(m_box_mirror, false, true); + cards->Show(m_box_thicken, false, true); + cards->Show(m_box_rib, false, true); + cards->Show(m_box_project, false, true); + cards->Show(m_box_delete_face, false, true); + cards->Show(m_box_helix, false, true); + cards->Show(m_box_mate, false, true); + + m_form->FitInside(); + m_form->SetScrollRate(0, 10); // vertical only, like Prepare's sidebar: never scroll labels out + m_form->SetMinSize(wxSize(264, -1)); + + // Right column: a small view toolbar over the live 3D viewport that mirrors + // the CadDocument body. + m_viewport = new DesignCanvas(this); + + m_viewport->set_on_sketch_commit([this](const SketchProfile& prof, const SketchPlane& plane) { + m_doc.checkpoint(); // undo boundary: committing a sketch + m_feature_counter++; + m_doc.add_sketch_profile(prof, plane, "Sketch" + std::to_string(m_feature_counter)); + m_doc.recompute(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Sketch created — select it, then right-click to Extrude")); + refresh_tree(); + }); + + m_viewport->set_on_sketch_entities_commit( + [this](const std::vector& ents, + const std::vector& cons, + const SketchPlane& plane) { + if (ents.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Sketch empty — nothing committed")); + m_status->Refresh(); + return; + } + m_doc.checkpoint(); // undo boundary: committing / re-editing an entity sketch + // Re-edit of a committed entity sketch: REPLACE it in place (keep its name + + // tree position) instead of appending a duplicate. + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Sketch) { + CadFeature edited = m_doc.features[m_edit_index]; + edited.entities = ents; + edited.entity_constraints = cons; + edited.plane = plane; + if (m_doc.replace_feature(m_edit_index, edited)) { + if (!cons.empty()) m_doc.solve_sketch_feature(m_edit_index); + m_doc.recompute(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Sketch updated")); + m_edit_index = -1; + refresh_tree(); + sync_sketch_display(); + } + return; + } + m_feature_counter++; + const int sk = m_doc.add_sketch_entities(ents, plane, + "Sketch" + std::to_string(m_feature_counter), cons); + if (!cons.empty()) m_doc.solve_sketch_feature(sk); // enforce driving dimensions + m_doc.recompute(); + m_status->SetForegroundColour(wxNullColour); + set_status(cons.empty() + ? _L("Sketch created — select it, then right-click to Extrude") + : wxString::Format(_L("Sketch created (%zu driving dims) — select it, then right-click to Extrude"), + cons.size())); + refresh_tree(); + sync_sketch_display(); // keep the just-committed sketch visible as a face + }); + + // Live length/angle readout while drawing a Line/Polyline segment. + m_viewport->set_on_cursor_metrics([this](double len, double ang_deg, bool locked) { + double a = ang_deg; if (a < 0.0) a += 360.0; // show bearing 0..360 + m_status->SetForegroundColour(wxNullColour); + // APPENDED to the step guidance, never in place of it. This fires on every mouse move + // while a segment is being dragged, so replacing the line wiped the instruction for the + // step the user is in the middle of — one mouse move after the click that armed it. + const wxString metrics = wxString::Format(L"L %.2f mm %.1f°%s", + len, a, locked ? L" (locked)" : L""); + set_status(m_sketch_step.IsEmpty() ? metrics + : m_sketch_step + L" · " + metrics); + m_status->Refresh(); + }); + + // Live per-step guidance: the armed tool says which step it is on, we write the sentence. + m_viewport->set_on_sketch_step([this](DesignSketchTool::Mode mode, int step, int picks) { + on_sketch_step(int(mode), step, picks); + }); + + // A live constraint appeared or went away: refill the rows. CallAfter, not a direct call — + // one of the callers is the ✗ button's own click handler, and rebuild_constraint_list + // destroys those buttons; deleting the window whose handler is still on the stack is a + // use-after-free. Deferring to the next event-loop turn lets the handler return first. + m_viewport->set_on_sketch_constraints_changed([this]() { + CallAfter([this]() { rebuild_constraint_list(); }); + }); + + // DoF feedback (P3): after each live solve, report constraint state on its own + // line. Green = fully constrained, red = conflicting, neutral = N remaining DoF. + m_viewport->set_on_solve_state([this](int dof, bool ok, bool has_constraints) { + // Remembered as well as shown: the readout is fed ONLY by a live solve, and entering + // Constrain mode triggers none — so without a cache the very line the user goes to + // Constrain to read stays blank until they happen to change something. + m_dof_last = dof; m_dof_last_ok = ok; m_dof_last_has = has_constraints; + apply_dof_status(dof, ok, has_constraints); + }); + + // Selection no longer writes the status line: on_sketch_step owns it, says the same thing + // for Select mode and — unlike this callback, which also fired while an edit-op mirrored its + // picks into the selection — never claims "N selected, Delete removes them" in the middle of + // a Mirror gesture, where Delete does nothing of the sort. 1c0c. + + // Onshape flow: clicking inside a closed-loop face commits the sketch and opens + // the Extrude dialog (with a ghost preview) targeting that sketch. + m_viewport->set_on_sketch_face_selected([this](int region) { + if (!m_viewport) return; + m_viewport->finish_sketch(); // commit live sketch (synchronous) + m_extrude_sketch_ref = resolve_extrude_sketch(); + if (m_extrude_sketch_ref < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Could not resolve the sketch to extrude")); + m_status->Refresh(); + return; + } + set_ui_mode(UiMode::Feature); + open_tool(Tool::Extrude); + // AFTER open_tool, not before: opening the tool re-derives the selection state, so a + // region recorded ahead of it is wiped before Extrude ever reads it. + m_sel_sketch_feat = m_extrude_sketch_ref; + m_sel_sketch_region = region; + m_sel_solid_face = m_sel_solid_edge = -1; + m_pick_face = m_pick_face_body = -1; + m_viewport->set_loop_pick(m_extrude_sketch_ref, region); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Face selected — set the depth and Confirm")); + m_status->Refresh(); + }); + + // Clicking a committed sketch loop on the plate (no live session) selects THAT loop: + // the viewport highlights only it (cyan) and its Sketch feature's tree row is selected. + // The (feature, region) pair is remembered so Extrude builds just that one loop. + m_viewport->set_on_display_sketch_selected([this](int feat, int region, int entity) { + if (feat < 0 || feat >= int(m_doc.features.size())) return; + m_sel_sketch_feat = feat; + m_sel_sketch_region = region; + // Last pick wins (symmetric with the solid-pick handler): selecting a sketch loop drops + // any stale solid face/edge pick so Extrude treats this loop as the profile. + m_sel_solid_face = m_sel_solid_edge = -1; + m_pick_face = m_pick_face_body = -1; + // Rib card open: point at the LINE, do not type its index. 'Entity index' was a bare + // wxSpinCtrl ranged 0-999 standing in for a line sitting visible on screen, with nothing + // anywhere telling you which integer was which — the purest case of the thing this epic + // exists to remove. Only a stroke hit carries an entity (an interior click is a region, + // not a line), so a click inside a loop deliberately leaves the field alone rather than + // resetting it to something arbitrary. The sketch picker follows the same pick, so + // pointing at a line in a different sketch retargets both together. 3648. + if (m_active == Tool::Rib && entity >= 0) { + if (m_rib_sketch != nullptr) + for (unsigned i = 0; i < m_rib_sketch->GetCount(); ++i) + if (int(reinterpret_cast(m_rib_sketch->GetClientData(i))) == feat) { + m_rib_sketch->SetSelection(i); + break; + } + if (m_rib_entity != nullptr) m_rib_entity->SetValue(entity); + refresh_preview(); + } + // Sweep card open: the sketch you point at becomes the PATH. The profile is already + // settled selection-first — you pick a sketch and then invoke Sweep, which is the design + // law's own canonical example — so the path is the input that was still trapped in a + // combo. The picker excludes the profile, so pointing at the profile correctly does + // nothing. e1p item 6; same shape as the Boolean and Mirror fixes. + if (m_active == Tool::Sweep && m_sweep_path != nullptr) { + for (unsigned i = 0; i < m_sweep_path->GetCount(); ++i) { + if (int(reinterpret_cast(m_sweep_path->GetClientData(i))) != feat) continue; + m_sweep_path->SetSelection(i); + m_sweep_path_ref = feat; + refresh_preview(); + break; + } + } + // Loft card open: pointing at a sketch TOGGLES its membership, so a loft is built by + // clicking the profiles in the viewport instead of hunting rows in a check-list. The + // list stays visible as the typed half and still shows the order, which matters here — + // loft order is not commutative. + if (m_active == Tool::Loft && m_loft_list != nullptr) { + for (size_t i = 0; i < m_loft_sketch_idx.size() && i < m_loft_list->GetCount(); ++i) { + if (m_loft_sketch_idx[i] != feat) continue; + m_loft_list->Check(unsigned(i), !m_loft_list->IsChecked(unsigned(i))); + refresh_preview(); + break; + } + } + set_tree_selection(feat); + m_status->SetForegroundColour(wxNullColour); + set_status(region >= 0 + // "Region", not "Loop": what is selected — and what Extrude will consume — is the + // bounded area including any holes in it, not a single closed curve. + ? _L("Region selected — right-click to Extrude, or double-click to edit") + : _L("Sketch selected — right-click to Extrude, or double-click to edit")); + m_status->Refresh(); + }); + + // Double-click a committed sketch stroke: open THAT sketch for editing, where its entities + // are individually selectable and their quotes editable. on_edit_feature already does the + // whole job — it was only ever reachable from the tree, which is precisely the side-panel + // dependency being retired. The pick has already lit the right tree row by the time the + // double-click arrives, but set it again so the path does not depend on that ordering. + m_viewport->set_on_display_sketch_activated([this](int feat) { + if (feat < 0 || feat >= int(m_doc.features.size())) return; + set_tree_selection(feat); + on_edit_feature(); + }); + + // F key (Prepare's Place on Face): the tool forwards it here when the Design viewport + // has focus; we lay the selected body face on the bed. Returns false when no face is + // selected so the key can fall through to the default handler. + m_viewport->set_on_place_on_face([this]() { return place_on_face(); }); + + // Clicking a solid cycles whole -> face -> edge. The tool draws the cyan overlay for ALL + // levels now (per-body, so other bodies stay untinted) — no whole-compound set_body_highlight. + m_viewport->set_on_solid_selection_changed([this](int level, int body, int face, int edge) { + // A pick that fell through the move gizmo (clicked off the arrows) exits move mode. + if (m_viewport->moving_body()) m_viewport->clear_move_gizmo(); + // Remember which body + face/edge so Extrude / dress-up target the RIGHT body. + m_sel_solid_body = (level >= 1) ? body : -1; + m_sel_solid_face = (level == 2) ? face : -1; // 4 = Vertex: a corner is not its face + m_sel_solid_edge = (level == 3) ? edge : -1; + m_sel_solid_vertex = (level == 4); + // Keep the hit face even at whole-body level: the cycle's first click means "this body", + // but the user pointed AT a face and a sketch should be able to use it. 3a2. + m_pick_face_body = (level >= 1) ? body : -1; + m_pick_face = (level >= 1) ? face : -1; + // Last pick wins: selecting a solid drops any stale committed-sketch loop selection. + // Otherwise a leftover loop keeps `m_sel_sketch_region >= 0`, which blocks the face + // push/pull branch in Extrude (`m_sel_solid_face >= 0 && m_sel_sketch_region < 0`) and + // makes Extrude build a DETACHED new body from the last sketch instead of push/pulling + // the face the user just clicked. + if (level >= 1) { m_sel_sketch_region = -1; m_sel_sketch_feat = -1; } + // Say what got picked. Without this the ONLY feedback is the viewport highlight, so a + // pick that registers but draws faintly is indistinguishable from one that never + // happened — which is precisely how this failure was reported and why it resisted + // diagnosis. Cards that show their own labels still do. The label itself is written + // ONCE, at the end of this handler — a second writer here only ever produced text that + // the later one overwrote before a frame was drawn, and reading it as the live string + // is how a vertex pick came to be "fixed" in a branch that never reaches the screen. + // If the Fillet/Chamfer card is open, re-anchor (or drop) the radius arrow on the new pick + // and rebuild the ghost — once an edge is picked the preview-only mode hides the base body. + if (m_active == Tool::Dressup) { sync_dressup_target(); update_fillet_gizmo(); refresh_preview(); } + // Boolean card open: the VIEWPORT is how you choose the two operands. Until now they + // could only come from two combos — the one control the charter names for this tool + // (e1p item 4), and the same pair 7xx caught silently resolving every row to + // index 0. The highlight already flowed card -> viewport; this closes the loop the + // other way. First pick is the target (kept), second is the tool (consumed); the + // combos mirror both, so the typed half of L2 still works and still round-trips. + // A body cannot be both operands — that boolean is a no-op — but the resolution is to + // SWAP, never to refuse: the pick is the user pointing at a body, and it has to win. + // Refusing it was measurably wrong. Both combos are pre-filled when the card opens + // (target = body 0, tool = a different one), so the very first viewport pick usually + // names a body the other slot already holds; ignoring it left the defaults in place and + // the commit produced body0 - body1 (75000 mm3) when the picks asked for body1 - body0 + // (43000 mm3). Swapping keeps the pair distinct AND honours the click. + if (m_active == Tool::Boolean && m_sel_solid_body >= 0) { + ComboBox* dst = (m_bool_next_slot == 0) ? m_bool_target : m_bool_tool; + ComboBox* other = (m_bool_next_slot == 0) ? m_bool_tool : m_bool_target; + const int row = m_sel_solid_body; // selection index == body index + if (dst != nullptr && row < int(dst->GetCount())) { + const int prev = dst->GetSelection(); + dst->SetSelection(row); + if (other != nullptr && other->GetSelection() == row && prev >= 0 && prev != row) + other->SetSelection(prev); // displaced operand takes the old slot + m_bool_next_slot ^= 1; + refresh_preview(); // re-tints the operands + rebuilds the ghost + } + } + // Mirror card open: the body you point at is the body that gets mirrored. Same one-way + // flow Boolean had (310o) and the same fix — the combo stays as the typed half. + // Only one operand here, so there is no slot to alternate and no swap to do. + if (m_active == Tool::Mirror && m_sel_solid_body >= 0 && m_mirror_body != nullptr && + m_sel_solid_body < int(m_mirror_body->GetCount())) { + m_mirror_body->SetSelection(m_sel_solid_body); // selection index == body index + refresh_preview(); + } + // If the Shell card is open, a face pick chooses the open face: update the label + gizmo + // + ghost so the hollow updates live. + if (m_active == Tool::Shell) { + m_shell_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(all faces — closed hollow)")); + refresh_preview(); // rebuilds the shell ghost + re-anchors the thickness gizmo + } + // Thicken card open: the face pick IS the feature's input, and until now nothing here + // wrote its label — the value reached m_sel_solid_face and Confirm worked, but the card + // read "(pick a solid face)" however many faces you had picked. Invisible because the + // ghost did update, so the card looked wrong while behaving right. + if (m_active == Tool::Thicken) { + m_thicken_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(pick a solid face)")); + refresh_preview(); + } + // Draft card open: a face pick chooses the face to taper; update label + ghost live. + if (m_active == Tool::Draft) { + m_draft_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(pick a side face)")); + refresh_preview(); + } + // Hole card open: clicking a solid FACE re-targets the hole ONTO that face (Orca-style), + // so the hole lives on the object's face — not on a stale dropdown/datum plane. Uses the + // face under the cursor from the FIRST click (handle_solid_click reports it even at the + // Whole level), so no whole->face cycle is needed. + if (m_active == Tool::Hole && face >= 0 && body >= 0 && body < int(m_doc.bodies.size())) { + const TopoDS_Face fc = GeometryEngine::face_by_index(m_doc.bodies[body].shape, face); + if (!fc.IsNull()) { + m_hole_face_plane = face_plane_inward(fc); + m_hole_on_face = true; + m_hole_face_body = body; + set_hole_target_label(face); + m_hole_has_bounds = GeometryEngine::face_plane_bounds( + fc, m_hole_face_plane.origin, m_hole_face_plane.x_axis, + m_hole_face_plane.y_axis, m_hole_umin, m_hole_umax, m_hole_vmin, m_hole_vmax); + if (m_hole_x) m_hole_x->SetValue(0.0); // centre of the picked face + if (m_hole_y) m_hole_y->SetValue(0.0); + if (m_hole_plane) m_hole_plane->SetSelection(index_from_plane(m_hole_face_plane)); + refresh_preview(); // re-place the gizmo + ghost on the new face + } + } + // Thread card open: clicking a cylindrical face or circular edge re-derives the thread. + if (m_active == Tool::Thread && body >= 0 && body < int(m_doc.bodies.size())) { + const TopoDS_Shape& shape = m_doc.bodies[body].shape; + GeometryEngine::CylinderFace cf; + if (face >= 0) + cf = GeometryEngine::cylinder_of_face(GeometryEngine::face_by_index(shape, face)); + const bool from_face = cf.ok; // which of the two the cylinder actually came from + if (!cf.ok && edge >= 0) + cf = GeometryEngine::circle_of_edge(GeometryEngine::edge_by_index(shape, edge)); + if (cf.ok) { + SketchPlane p; p.origin = cf.base; p.normal = cf.axis; + const Vec3d ref = std::abs(cf.axis.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + p.x_axis = ref.cross(cf.axis).normalized(); + p.y_axis = cf.axis.cross(p.x_axis).normalized(); + m_thread_face_plane = p; + m_thread_on_face = true; + m_thread_face_body = m_sel_solid_body; + set_thread_target_label(from_face ? face : -1, from_face ? -1 : edge); + infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); + if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + refresh_preview(); + } + } + // Plane tool with a pick armed: capture the right kind of reference (face for Face A/B, + // edge for Edge A/B). If the click wasn't the right kind, stay armed so the user retries. + if (m_active == Tool::Plane && m_plane_pick != PlanePick::None) { + bool got = false; + switch (m_plane_pick) { + case PlanePick::FaceA: if (m_sel_solid_face >= 0) { m_pl_faceA_body = m_sel_solid_body; m_pl_faceA = m_sel_solid_face; got = true; } break; + case PlanePick::FaceB: if (m_sel_solid_face >= 0) { m_pl_faceB_body = m_sel_solid_body; m_pl_faceB = m_sel_solid_face; got = true; } break; + case PlanePick::EdgeA: if (m_sel_solid_edge >= 0) { m_pl_edgeA_body = m_sel_solid_body; m_pl_edgeA = m_sel_solid_edge; got = true; } break; + case PlanePick::EdgeB: if (m_sel_solid_edge >= 0) { m_pl_edgeB_body = m_sel_solid_body; m_pl_edgeB = m_sel_solid_edge; got = true; } break; + default: break; + } + if (got) { m_plane_pick = PlanePick::None; refresh_plane_labels(); } + if (got && m_viewport) m_viewport->set_escalate_on_repick(true); + } + // Axis tool with a pick armed: face or edge. + if (m_active == Tool::Axis && m_axis_pick != AxisPick::None) { + bool got = false; + switch (m_axis_pick) { + case AxisPick::Face: if (m_sel_solid_face >= 0) { m_ax_face_body = m_sel_solid_body; m_ax_face = m_sel_solid_face; got = true; } break; + case AxisPick::Edge: if (m_sel_solid_edge >= 0) { m_ax_face_body = m_sel_solid_body; m_ax_edge = m_sel_solid_edge; got = true; } break; + default: break; + } + if (got) { m_axis_pick = AxisPick::None; refresh_axis_labels(); } + if (got && m_viewport) m_viewport->set_escalate_on_repick(true); + } + // CoordSys tool with a pick armed: face or edge. + if (m_active == Tool::CoordSys && m_coordsys_pick != CoordSysPick::None) { + bool got = false; + switch (m_coordsys_pick) { + case CoordSysPick::Face: if (m_sel_solid_face >= 0) { m_cs_face_body = m_sel_solid_body; m_cs_face = m_sel_solid_face; got = true; } break; + case CoordSysPick::Edge: if (m_sel_solid_edge >= 0) { m_cs_face_body = m_sel_solid_body; m_cs_edge = m_sel_solid_edge; got = true; } break; + default: break; + } + if (got) { + // A picked face or edge is only meaningful to FaceAndDirection. Left on the + // default Point (world), datum_frame ignores the pick entirely and resolves the + // connector to coordsys_point — which is (0,0,0) unless the user typed + // otherwise. Two such connectors then share one frame, so a mate between them + // computes an IDENTITY transform: the feature commits, the recompute succeeds, + // and nothing moves. Picking a face IS the choice of a face-based frame, so make + // the type follow the pick rather than asking for it twice. + if (m_coordsys_type && m_coordsys_type->GetSelection() != (int)CoordSysType::FaceAndDirection) + m_coordsys_type->SetSelection((int)CoordSysType::FaceAndDirection); + m_coordsys_pick = CoordSysPick::None; + refresh_coordsys_labels(); + } + if (got && m_viewport) m_viewport->set_escalate_on_repick(true); + } + m_status->SetForegroundColour(wxNullColour); + const int nb = int(m_doc.bodies.size()); + const wxString bodytag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); + // Each sub-element line ends by naming the NEXT click (gem). Escalation to the + // whole body is a gesture nothing on screen would otherwise reveal, and the status line + // is the only surface that can teach it at the moment it applies. It REPLACES the old + // per-level verb hints ("right-click to push/pull it", "Fillet/Chamfer to dress it") + // rather than joining them: the line is clipped at the panel edge past ~55 characters + // (set_status's Wrap() does not take effect — 8cc), and those verbs are shown + // with their icons in the offer anyway, while this gesture is shown nowhere else. + // Both clauses fit now that the line is drawn over the viewport instead of squeezed + // into the panel. Say "what applies to it", never "verbs" — that is this codebase's + // word for a tool-offer entry, not a word the drawing office uses, and the plane and + // bodies-list lines already say it the right way. + set_status(level == 4 ? bodytag + _L("vertex selected — click again for the whole body") + : level == 1 ? bodytag + _L("selected (whole body) — right-click for what applies to it") + : level == 2 ? bodytag + wxString::Format(_L("face %d selected — right-click to push/pull it, or click again for the whole body"), face) + : level == 3 ? bodytag + wxString::Format(_L("edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body"), edge) + : _L("Nothing selected")); + m_status->Refresh(); + }); + + // Visual Extrude gizmo (C5b): dragging/editing the in-canvas depth arrow writes the + // matching spin field and re-previews (which re-feeds the gizmo with the new depth). + m_viewport->set_on_extrude_depth_changed([this](double depth, bool second) { + // One arrow, three tools: Extrude owns the two-sided pair, the other two have a single + // distance each. Routing here rather than arming three near-identical gizmos is the whole + // reason these two tools got a handle at all — the arrow was already written. + if (m_active == Tool::SurfaceExtrude) { + if (m_surf_extrude_distance) m_surf_extrude_distance->SetValue(depth); + } else if (m_active == Tool::Thicken) { + if (m_thicken_thickness) m_thicken_thickness->SetValue(depth); + } else if (m_active == Tool::Rib) { + if (m_rib_depth) m_rib_depth->SetValue(depth); // depth; thickness has its own in-plane pair + } else if (m_active == Tool::SurfaceOffset) { + if (m_surf_offset_distance) m_surf_offset_distance->SetValue(depth); + } else if (m_active == Tool::ThickenSurface) { + if (m_surf_thicken_thickness) m_surf_thicken_thickness->SetValue(depth); + } else if (second) { if (m_distance2) m_distance2->SetValue(depth); } + else { if (m_distance) m_distance->SetValue(depth); } + refresh_preview(); + }); + + // Datum-plane resize handles (C3): a handle drag reports the new u/v extent. Mirror it into the + // Size spins and, when editing a committed datum, into the feature so the rendered rectangle + // follows live. SetValue doesn't emit a command event, so no refresh_preview recursion. + m_viewport->set_on_datum_size_changed([this](double u, double v) { + if (m_plane_usize) m_plane_usize->SetValue(u); + if (m_plane_vsize) m_plane_vsize->SetValue(v); + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Plane) { + m_doc.features[m_edit_index].plane_u_size = u; + m_doc.features[m_edit_index].plane_v_size = v; + refresh_datum_planes(); // committed datum rectangle follows the drag + } + m_viewport->request_repaint(); + }); + + // Offset arrow drag: mirror the new offset into the spin + (when editing) the committed feature. + m_viewport->set_on_datum_offset_changed([this](double off) { + if (m_plane_offset) m_plane_offset->SetValue(off); + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Plane) { + m_doc.features[m_edit_index].plane_offset = off; + refresh_datum_planes(); + } + m_viewport->request_repaint(); + }); + + // Helix handles: a drag reports the whole triple, since pitch and height are coupled + // through the turn count and reading one without the others would show a stale curve. + m_viewport->set_on_helix_changed([this](double radius, double pitch, double height) { + if (m_helix_radius) m_helix_radius->SetValue(radius); + if (m_helix_pitch) m_helix_pitch->SetValue(pitch); + if (m_helix_height) m_helix_height->SetValue(height); + update_helix_gizmo(); // re-feed so the curve follows the drag + m_viewport->request_repaint(); + }); + + // Rib thickness handle: writes the spin and re-feeds the ghost, same as the depth arrow. + m_viewport->set_on_rib_thickness_changed([this](double thickness) { + if (m_rib_thickness) m_rib_thickness->SetValue(thickness); + refresh_preview(); // Rib HAS a solid ghost, so the full preview path is correct here + }); + + // Clicking a ghost base plane sets the base graphically (replaces the dropdown). A base pick + // drops any offset-from-face choice so the picked base plane wins, then re-resolves the preview. + m_viewport->set_on_datum_base_picked([this](int base) { + if (m_active == Tool::Plane) { + // Plane tool open: the click sets the datum's base plane (replaces the dropdown). + if (m_plane_base && base >= 0 && base < int(m_plane_base->GetCount())) + m_plane_base->SetSelection(base); + m_pl_faceA_body = m_pl_faceA = -1; + refresh_plane_labels(); + refresh_preview(); // re-resolve the frame + move the gizmo/ghosts to the new base + } else { + // Clicking a reference plane in 3D IS how a sketch plane is chosen now. Record it and, + // when a session is already live, re-plane it immediately: begin_sketch captures the + // plane at first-tool-pick, so without this the entities would stay on the old plane + // while the committed feature landed on the new one. + if (base >= 0) { + m_ref_plane = base; + m_plane_picked = true; // chosen, not merely defaulted to + m_pick_face = m_pick_face_body = -1; // last pick wins: a plane beats a stale face + if (m_viewport && m_viewport->is_sketching()) + m_viewport->set_sketch_plane(plane_from_choice(m_ref_plane)); + } + const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum"; + m_status->SetForegroundColour(wxColour(120, 210, 120)); + // Both halves named the TOOLBAR, which no longer carries either button: the tools + // moved to the offer. Name the gesture that actually works in each mode, and say + // what a plain click does, since the two are easy to confuse on a plane. + set_status(m_ui_mode == UiMode::Sketch + ? wxString::Format(_L("%s plane selected — right-click for the drawing tools"), nm) + : wxString::Format(_L("%s plane selected — right-click to sketch on it, " + "or click an object to select it"), nm)); + m_status->Refresh(); + } + }); + + // Move-body gizmo (M5): each drag/edit reports the body's new translation. Store it as a + // display-only per-body transform and re-feed the moved meshes (the OCCT shape is untouched, + // so face/edge ids the dress-up ops target stay valid). + m_viewport->set_on_body_move_changed([this](int body, const Transform3d& xform) { + sync_body_xform(); + if (body < 0 || body >= int(m_body_xform.size())) return; + m_body_xform[body] = xform; // full move+rotate transform, baked into the mesh at Commit + feed_bodies(); // rebuilds the transformed meshes in place + refreshes display + pick + // When the move gizmo is serving the Transform card, mirror the drag into the card's + // numeric fields (the card is the typed half, the gizmo the geometry-first half). + if (body == m_xf_gizmo_body && m_active == Tool::Transform) { + const Transform3d d = xform * m_xf_gizmo_base.inverse(); + const Vec3d t = d.translation(); + if (m_xf_dx) m_xf_dx->SetValue(t.x()); + if (m_xf_dy) m_xf_dy->SetValue(t.y()); + if (m_xf_dz) m_xf_dz->SetValue(t.z()); + const Eigen::AngleAxisd aa(Eigen::Quaterniond(d.linear()).normalized()); + // The gizmo's rings are per-world-axis, so a ring drag yields an exactly axial + // rotation and this is lossless for the normal interaction. A composed multi-ring + // pose is not axial; the card can only express one axis, so report the dominant one + // rather than refusing to answer. + int ax = 0; double best = std::abs(aa.axis().x()); + if (std::abs(aa.axis().y()) > best) { ax = 1; best = std::abs(aa.axis().y()); } + if (std::abs(aa.axis().z()) > best) { ax = 2; best = std::abs(aa.axis().z()); } + const double sign = (aa.axis()[ax] < 0.0) ? -1.0 : 1.0; + if (m_xf_axis) m_xf_axis->SetSelection(ax); + if (m_xf_angle) m_xf_angle->SetValue(sign * aa.angle() * 180.0 / M_PI); + } + const int nb = int(m_doc.bodies.size()); + const wxString tag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); + const Vec3d t = xform.translation(); + m_status->SetForegroundColour(wxNullColour); + set_status(tag + wxString::Format(_L("placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate"), + t.x(), t.y(), t.z())); + m_status->Refresh(); + }); + + // Fillet/Chamfer radius gizmo: dragging (or editing) the edge-anchored arrow writes the + // Dress-up size and refreshes the ghost. SetValue is silent in wx, so refresh explicitly. + m_viewport->set_on_fillet_radius_changed([this](double radius) { + if (m_dressup_size) m_dressup_size->SetValue(radius); + refresh_preview(); // rebuilds the candidate fillet ghost at the new radius + }); + + // Hole gizmo: dragging/editing the centre, diameter, or depth handle writes the four Hole-card + // spins and refreshes the ghost. SetValue is silent in wx, so refresh explicitly. + m_viewport->set_on_hole_changed([this](double x, double y, double diameter, double depth) { + if (m_hole_x) m_hole_x->SetValue(x); + if (m_hole_y) m_hole_y->SetValue(y); + if (m_hole_diameter) m_hole_diameter->SetValue(diameter); + if (m_hole_depth) m_hole_depth->SetValue(depth); + refresh_preview(); // rebuilds the candidate hole ghost at the new position/size + }); + + // Thread gizmo: dragging/editing the centre, radius, or length handle writes the Thread-card + // spins and refreshes the ghost (SetValue is silent in wx). + m_viewport->set_on_thread_changed([this](double x, double y, double radius, double height) { + if (m_thread_x) m_thread_x->SetValue(x); + if (m_thread_y) m_thread_y->SetValue(y); + if (m_thread_radius) m_thread_radius->SetValue(2.0 * radius); // gizmo reports radius; field = diameter + if (m_thread_height) m_thread_height->SetValue(height); + refresh_preview(); + }); + + // Shell gizmo: dragging/editing the inward thickness arrow writes the Shell-card thickness + // and refreshes the ghost (SetValue is silent in wx). + m_viewport->set_on_shell_thickness_changed([this](double thickness) { + if (m_shell_thickness) m_shell_thickness->SetValue(thickness); + refresh_preview(); + }); + + m_viewport->set_on_revolve_angle_changed([this](double angle) { + if (m_revolve_angle) m_revolve_angle->SetValue(angle); + refresh_preview(); + }); + + m_viewport->set_on_draft_angle_changed([this](double angle) { + if (m_draft_angle) m_draft_angle->SetValue(angle); + refresh_preview(); + }); + + // Cut gizmo: dragging the offset arrow writes the Cut-card offset and refreshes the ghost. + m_viewport->set_on_cut_offset_changed([this](double v) { + if (m_cut_offset) m_cut_offset->SetValue(v); + refresh_preview(); + }); + + m_viewport->set_on_pattern_changed([this](double value) { + // Linear drag feeds spacing; circular drag feeds angle. The card knows which is live. + if (m_pattern_type && m_pattern_type->GetSelection() == 1) { + if (m_pattern_angle) m_pattern_angle->SetValue(value); + } else if (m_pattern_spacing) { + m_pattern_spacing->SetValue(value); + } + refresh_preview(); + }); + + // Leaving an EMPTY sketch session: restore Feature mode + the committed-sketch overlay. + // request_exit() calls this only for an idle Select session that holds no geometry, so + // nothing a user drew can reach here — discarding drawn work goes through tool_cancel's + // confirmation instead. + m_viewport->set_on_sketch_exit([this]() { + // While placing imported Text/SVG art, right-click = Confirm (keep the art) — the + // Insert card is the explicit gate, this is the in-canvas shortcut to it. + if (m_active == Tool::Insert) { finalize_insert(); return; } + if (m_viewport) m_viewport->cancel_sketch(); + m_edit_index = -1; + set_ui_mode(UiMode::Feature); + sync_sketch_display(); + refresh_tree(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Tool exited")); + m_status->Refresh(); + }); + + // The tool declined to leave because the session holds geometry. There is no "press it again" + // any more — the answer is a deliberate Finish or Cancel — so this is a plain statement of + // where you are, not a warning shot. Only the STATUS LINE lives here; the tool reports via + // this callback instead of writing text itself. + m_viewport->set_on_sketch_exit_refused([this]() { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Sketch kept — Finish to commit it, Cancel to discard")); + m_status->Refresh(); + }); + + // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport → feature-history undo/redo. + m_viewport->set_on_undo_redo([this](bool redo) { do_undo_redo(redo); }); + + // Esc = the unified Cancel everywhere. Feature cards had no key exit (only the button); + // CHAR_HOOK on the panel catches Esc from the card or viewport and routes to tool_cancel. + // Sketch/Constrain keep the viewport's per-gesture Esc (abort the current point first), + // so we only intercept Esc here when a feature/insert card is the thing to dismiss. + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { + const int key = e.GetKeyCode(); + const bool ctrl = e.ControlDown() || e.CmdDown(); + const bool sketching = (m_ui_mode == UiMode::Sketch) && m_viewport && m_viewport->is_sketching(); + // Which key MAP applies is a question about the MODE, not about whether a session is + // already running. Gating the sketch map on is_sketching() made it unreachable by + // keyboard: is_sketching() only turns true inside select_tool()'s begin_sketch(), and + // select_tool() is what the sketch keys call — so the first letter after entering + // sketch mode fell through to the feature map, matched nothing (feature keys are + // Shift+letter), and did nothing. The mouse worked only because the toolbar flyout + // reaches select_tool() directly. That is why all 17 keys read as dead. 0ud. + const bool sketch_mode = (m_ui_mode == UiMode::Sketch); + // Never steal editing keys from a focused text field or an open in-canvas value field — + // Delete/Ctrl+Z there must edit the text, not the model. + const bool in_text = (dynamic_cast(wxWindow::FindFocus()) != nullptr) + || (m_viewport && m_viewport->inline_busy()); + if (getenv("ORCA_CAD_KEYTRACE")) { + wxWindow* fw = wxWindow::FindFocus(); + fprintf(stderr, "[KEYTRACE] key=%d ui_mode=%d is_sketching=%d in_text=%d inline_busy=%d focus=%s\n", + key, int(m_ui_mode), (m_viewport && m_viewport->is_sketching()) ? 1 : 0, in_text ? 1 : 0, + (m_viewport && m_viewport->inline_busy()) ? 1 : 0, + // wxString, not a cast: GetClassName() returns const wxChar* — wchar_t* in + // this build — and casting THAT to const char* and printing it with %s emits + // the first byte and stops at the padding NUL. Every focus= field this tracer + // has ever printed was a single letter: "wxGLCanvas" came out as "w", and so + // did "wxWindow". An instrument that silently truncates its most important + // field is worse than no instrument, and this one was trusted for a whole + // day's diagnosis. + fw ? wxString(fw->GetClassInfo()->GetClassName()).utf8_str().data() : "(none)"); + fflush(stderr); + } + + // NOTE the position: this sits AFTER the KEYTRACE block on purpose. It returns early, + // and putting it first made every forwarded key invisible to the tracer — the one + // instrument that diagnosed this bug in the first place. + // The in-canvas value field is a borderless, always-on-top top-level frame, so whether it + // may take keyboard focus is the platform's decision, not ours: macOS denies key status to a + // borderless window, mutter's focus-stealing prevention refuses a re-mapped window, and the + // rig never grants it. When focus is refused, Enter/Esc/Tab are delivered HERE (to the panel) + // instead of the field, its own wxEVT_TEXT_ENTER / WXK_ESCAPE bindings never fire, and the + // queued-dimension chain (a line queues Length then Angle) becomes unwalkable. Forwarding them + // makes the field behave the same everywhere WITHOUT fighting the window manager for focus, + // which is what seven earlier attempts did unsuccessfully. When the field DOES hold focus we + // deliberately do nothing here, so its own bindings run and typing keeps working. + if (m_viewport && m_viewport->inline_busy() && !m_viewport->inline_has_focus()) { + if (key == WXK_RETURN || key == WXK_NUMPAD_ENTER || key == WXK_TAB) { + m_viewport->inline_commit(); + return; + } + // NO forwarding here any more. The field is drawn INSIDE the GL canvas now, so it + // is fed the way every other ImGui widget in this app is fed: GLCanvas3D::on_char -> + // ImGuiWrapper::update_key_data -> io.AddInputCharacter. Re-adding a panel-side + // forwarder would also mask whether that path works, which is exactly what is being + // measured. + // Esc is NOT special-cased here any more: escape() routes it, and the open field is + // exactly what CadLevel::Transient means, so it closes the field and stops there. + } + + // ONE Esc, ONE route, whatever holds focus. Which widget has focus is an accident of where + // the user last clicked (a toolbar button, the Construction checkbox), and Esc must not + // depend on it. It used to be answered in four places — the inline field above, a sketch + // branch, a feature-card branch, and the canvas — none of which could see the others, so + // a press aimed at one of them fell through to the next and the SECOND press reached a + // layer that discarded the live sketch. escape() picks the single deepest live level and + // acts on that one only; see DesignInteraction.hpp for the ladder and its invariant. + if (key == WXK_ESCAPE) { escape(); return; } + + // The offer from the keyboard (charter 4.1): the Menu key, or Shift+F10 for keyboards that + // do not have one. Same menu the right-click opens — show_offer_menu already decides which + // half of the map applies via sketch_map_applies(), so nothing about the content is decided + // here. With no press to anchor it, offer_anchor() puts it on the viewport. + // WXK_MENU is the GTK code for the physical Menu key (GDK_KEY_Menu); wxMSW instead sends + // WXK_WINDOWS_MENU for VK_APPS and maps Alt to WXK_ALT, so accepting both is safe + // everywhere. CallAfter because the menu is modal: let the key event finish dispatching + // before a nested loop takes the queue, the same reason the Sketch entry does it. + if (!in_text && !ctrl + && (key == WXK_MENU || key == WXK_WINDOWS_MENU || (key == WXK_F10 && e.ShiftDown()))) { + CallAfter([this] { show_offer_menu(offer_anchor()); }); + return; + } + + // Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y — undo/redo handled here (not only in the GL canvas) so + // it works even when the canvas lost keyboard focus. In a sketch, undo drops the last entity. + if (!in_text && ctrl && (key == 'Z' || key == 'z' || key == WXK_CONTROL_Z || + key == 'Y' || key == 'y' || key == WXK_CONTROL_Y)) { + const bool redo = (key == 'Y' || key == 'y' || key == WXK_CONTROL_Y) || e.ShiftDown(); + if (sketching) { if (!redo) m_viewport->undo_last_sketch_entity(); } + else { do_undo_redo(redo); } + return; + } + // Delete — the selected sketch entities (or the last drawn one if none is selected), or the + // selected feature in Feature mode. Focus-independent, same reason as undo above. + // WXK_BACK too: on a keyboard whose Del is a chord (every laptop this runs on), Del is + // the one destructive key nobody can reach, and Backspace is what users press. oql1. + if (!in_text && (key == WXK_DELETE || (key == WXK_BACK && sketching))) { + if (sketching) { m_viewport->delete_selected_or_last_sketch_entity(); return; } + if (m_ui_mode == UiMode::Feature && m_active == Tool::None + && tree_selection() != wxNOT_FOUND) { on_delete_feature(); return; } + } + // Section view controls while it is on (Alt+Wheel is unreliable under remote desktops / is + // grabbed by GLCanvas3D, so the keyboard drives it): PageUp/PageDown move the plane, F flips + // which half is kept (so you can inspect the opposite part). + if (!in_text && !sketch_mode && m_section_on) { + if (key == WXK_PAGEUP || key == WXK_PAGEDOWN) { + m_section_cut_z += (key == WXK_PAGEUP ? 2.0 : -2.0); + if (m_viewport) m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); + return; + } + if (key == 'F' || key == 'f') { flip_section_view(); return; } + } + // Tool shortcuts (Onshape-style). While a sketch is open, single letters drive sketch + // tools; otherwise Shift+letter drives feature tools and single letters drive view + // toggles / section. Ctrl-combos and focused text fields are never intercepted. + // A SKETCH SHORTCUT MAY LEAVE AN OPEN VALUE FIELD. in_text is true while an inline + // dimension editor is up, and drawing a rectangle opens one automatically for its + // Width/Height — so after a rectangle every single-letter tool key was swallowed and the + // sketch could not be continued at all. A value field holds a NUMBER; a letter is never + // meant for it, so a letter that names a sketch tool is unambiguously a tool switch. + // set_tool() commits the field on the way through, so the typed value is kept. + // Deliberately narrow: sketch mode only, only keys that are actually bound, and only the + // in-canvas field — a focused wxTextCtrl elsewhere (the variables table, a card spin) + // still keeps every key. + const bool inline_field_only = (dynamic_cast(wxWindow::FindFocus()) == nullptr) + && m_viewport && m_viewport->inline_busy(); + if (in_text && inline_field_only && sketch_mode && !ctrl) { + const int up2 = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key; + auto it2 = m_keys_sketch.find(up2); + if (it2 != m_keys_sketch.end()) { it2->second(); return; } + } + // Ctrl+Shift first: the block below deliberately ignores every Ctrl-combo, which is + // exactly what makes this layer free to use. + if (!in_text && ctrl && e.ShiftDown()) { + const int up = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key; + auto it = m_keys_feature.find(up | SC_SHIFT | SC_CTRL); + if (it != m_keys_feature.end()) { it->second(); return; } + } + if (!in_text && !ctrl) { + const int up = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key; // normalise case + if (sketch_mode) { + auto it = m_keys_sketch.find(up); + if (it != m_keys_sketch.end()) { it->second(); return; } + } else { + auto it = m_keys_feature.find(up | (e.ShiftDown() ? SC_SHIFT : 0)); + if (it != m_keys_feature.end()) { it->second(); return; } + } + } + e.Skip(); + }); + + // Right-click finishes the move gizmo in the viewport; mirror that on the panel so the + // action bar (shown while moving) hides and the move state clears. + m_viewport->set_on_move_exit([this]() { m_move_body = -1; show_move_card(false); update_action_bar(); }); + + // The offer (§4.1): right-click the geometry, get the verbs that apply to it. Left-click + // still only selects, so pointing at things stays quiet. + m_viewport->set_on_context_menu([this](const wxPoint& p) { show_offer_menu(p); }); + + // The Line tool's length and the Dimension tool's value are both entered in-canvas now + // (live quote labels + the floating SketchInlineEditor), so the old docked-card + // callbacks (on_segment_drawn / on_dimension_pick_complete) are no longer wired. + + // Imported-art bbox transform streams the live offset/scale back here; write them to + // the feature and re-sync the overlay so the art tracks the drag. + m_viewport->set_on_imported_transform([this](int feat, Vec2d off, double sx, double sy) { + if (feat < 0 || feat >= int(m_doc.features.size())) return; + CadFeature& f = m_doc.features[feat]; + if (f.imported_regions.empty()) return; + f.import_offset = off; + f.import_scale_x = sx; + f.import_scale_y = sy; + m_doc.recompute(); + sync_sketch_display(); + }); + + auto* vcol = new wxBoxSizer(wxVERTICAL); + // The sketch banner sits ABOVE the viewport rather than floating inside it: a child window + // over a wxGLCanvas is a platform argument (it is a native window on GTK and does not + // reliably stack over GL), and the banner's job is to be unmissable, not to be clever. + m_sketch_banner = new wxPanel(this, wxID_ANY); + m_sketch_banner->SetBackgroundColour(wxColour(0, 122, 116)); // Orca teal: not a plate colour + m_sketch_banner_txt = new wxStaticText(m_sketch_banner, wxID_ANY, wxString()); + m_sketch_banner_txt->SetForegroundColour(*wxWHITE); + { + wxFont f = m_sketch_banner_txt->GetFont(); + f.SetWeight(wxFONTWEIGHT_BOLD); + m_sketch_banner_txt->SetFont(f); + auto* bs = new wxBoxSizer(wxHORIZONTAL); + bs->Add(m_sketch_banner_txt, 0, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(6)); + m_sketch_banner->SetSizer(bs); + } + m_sketch_banner->Hide(); + vcol->Add(m_sketch_banner, 0, wxEXPAND); + // The bottom 3D-navigator orb handles all view orientation, so no separate view buttons. + // Fit view is a double-click on the viewport (the tool intercepts it -> zoom_to_volumes). + vcol->Add(m_viewport, 1, wxEXPAND); + + // Onshape layout: top toolbar over [ slim left column | center viewport ]. + auto* body = new wxBoxSizer(wxHORIZONTAL); + body->Add(m_form, 0, wxEXPAND); + body->Add(vcol, 1, wxEXPAND); + + auto* outer = new wxBoxSizer(wxVERTICAL); + outer->Add(m_toolbar, 0, wxEXPAND); + outer->Add(new wxStaticLine(this, wxID_ANY), 0, wxEXPAND); + outer->Add(body, 1, wxEXPAND); + SetSizer(outer); + + // The atlas says a verb is wired; the registrations above say what it runs. Nothing checks + // that the two agree, and a broken join is INVISIBLE — the row renders, is enabled, and does + // nothing when picked. That defect has shipped three times (edit_feature and sk_move carrying + // action:null, then a whole flyout family the moment its widget stopped being built), and it + // cannot be seen by reading either side alone. Verify the join once, here, where every + // registration is complete. Cheap: 86 map lookups, once per panel. + { + std::vector dead; + for (int i = 0; i < kOfferVerbCount; ++i) { + const OfferVerb& v = kOfferVerbs[i]; + if (v.action == nullptr || *v.action == '\0') + continue; // kernel support, no GUI path — the row shows disabled + const std::string a(v.action); + bool ok = false; + if (a.rfind("key:", 0) == 0) { // resolved exactly as run_offer_action() resolves it + const std::string k = a.substr(4); + if (k.size() >= 3 && k[0] == 'S' && k[1] == '+') { + auto it = m_keys_feature.find(int(k[2]) | SC_SHIFT); + ok = it != m_keys_feature.end() && bool(it->second); + } else if (!k.empty()) { + auto it = m_keys_sketch.find(int(k[0])); + ok = it != m_keys_sketch.end() && bool(it->second); + } + } else { + auto it = m_verb_actions.find(a); + ok = it != m_verb_actions.end() && bool(it->second); + } + if (!ok) + dead.emplace_back(std::string(v.id) + " -> " + a); + } + for (const std::string& d : dead) + BOOST_LOG_TRIVIAL(error) << "Design offer: wired verb has no action: " << d; + assert(dead.empty()); // debug builds stop here; release ships the log line + } + + set_ui_mode(UiMode::Feature); +} + +void DesignPanel::set_active_tool_btn(ScalableButton* b) +{ + // Onshape-style: the active tool's button gets the Orca accent (teal); the + // rest revert to the ribbon surface. nullptr clears the whole strip. + m_active_tool_btn = b; + const wxColour bg = dp_ribbon_bg(), teal(0x00, 0x96, 0x88); + for (auto* btn : m_tool_btns) { + if (btn == nullptr) continue; + btn->SetBackgroundColour(btn == b ? teal : bg); + btn->Refresh(); + } +} + +// Paint the DoF line. Split out of the solve callback so entering Constrain can re-apply the +// last known state — see m_dof_last. +void DesignPanel::apply_dof_status(int dof, bool ok, bool has_constraints) +{ + if (!m_dof_status) return; + if (!has_constraints) { + m_dof_status->SetLabel(wxString()); + } else if (!ok) { + m_dof_status->SetForegroundColour(wxColour(235, 80, 80)); + m_dof_status->SetLabel(_L("✗ Conflicting constraints")); + } else if (dof == 0) { + m_dof_status->SetForegroundColour(wxColour(80, 200, 110)); + m_dof_status->SetLabel(_L("✓ Fully constrained")); + } else if (dof > 0) { + m_dof_status->SetForegroundColour(dp_ctl_text()); + m_dof_status->SetLabel(wxString::Format(_L("%d degrees of freedom"), dof)); + } else { + m_dof_status->SetLabel(wxString()); + } + m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty()); + m_dof_status->Refresh(); + update_cards_frame(); m_form->Layout(); +} + +void DesignPanel::set_ui_mode(UiMode m) +{ + m_ui_mode = m; + if (m != UiMode::Sketch) m_sketch_on.clear(); // no stale "on the picked face" on the next hint + // The DoF readout describes a SKETCH's constraint state, so it means nothing back in Feature + // mode — where it nonetheless stayed on screen after every Confirm, Cancel and Escape + // (752). Cleared here rather than at those three exits because this is the one place + // all of them pass through, and a fourth exit added later would otherwise reintroduce it. + // Constrain mode keeps it: that is where the number is the whole point. + if (m == UiMode::Feature && m_dof_status != nullptr) { + m_dof_status->SetLabel(wxString()); + m_dof_status->Show(false); + } + // Constrain is where the number is the whole point, and clearing it on the way out of + // Sketch left it blank on the way in — no solve fires merely because the mode changed. + if (m == UiMode::Constrain) + apply_dof_status(m_dof_last, m_dof_last_ok, m_dof_last_has); + wxSizer* s = m_toolbar->GetSizer(); + s->Show(m_tb_feature, m == UiMode::Feature, true); + s->Show(m_tb_sketch, m == UiMode::Sketch, true); + // Constraint buttons are live in BOTH Sketch and Constrain (Fase 4.2 live path); the + // Confirm/Cancel action bar (m_tb_action) is already mode-appropriate and stays untouched. + s->Show(m_tb_relations, m == UiMode::Sketch || m == UiMode::Constrain, true); + m_toolbar->Layout(); + m_toolbar->FitInside(); // refresh the horizontal scroll range for the new group widths + set_active_tool_btn(nullptr); // no tool selected right after a mode switch + // Phase 3: the docked Sketch card (plane/orientation) shows for the whole Sketch + // session and hides on Finish/Constrain. + if (m_box_sketch_session != nullptr && m_form != nullptr && m_cards->GetSizer() != nullptr) { + if (m == UiMode::Sketch && m_hdr_sketch_session != nullptr) + m_hdr_sketch_session->SetLabel(wxString::Format(_L("Sketch %d"), m_feature_counter + 1)); + m_cards->GetSizer()->Show(m_box_sketch_session, m == UiMode::Sketch, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + } + // Constraint-manager card follows Constrain mode; rebuilt from the active feature. + if (m_box_constraints != nullptr && m_form != nullptr && m_cards->GetSizer() != nullptr) { + if (m == UiMode::Constrain || m == UiMode::Sketch) + rebuild_constraint_list(); // Sketch too: a live session has its own constraints + else + m_cards->GetSizer()->Show(m_box_constraints, false, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + } + update_action_bar(); // Sketch/Constrain modes show the unified ✓/✗; Feature idle hides it + // The origin planes follow the mode: entering Sketch offers them even when a body exists, + // leaving it takes them back. Without this they would only refresh on the next tree + // rebuild, which is not an event that happens when you merely press Sketch. + update_reference_planes(); + + // Say where you are, in words, across the top of the viewport. + // + // THE BED IS NOT MUTED HERE, and it was: "a plate grid and a sketch grid are the same visual + // language" is true and still the wrong call, because there IS no sketch grid to replace it. + // Seen on the rig: pick XY, arm Line, and the viewport is an empty grey field — no bed, no + // grid, no origin, nothing to judge a length or a direction against. The plate grid was + // carrying the ground reference for the whole tab. The banner already says where you are; + // taking the floor away as well only made the sketch harder to draw. The Bed checkbox is the + // one thing that governs the bed, in every mode. + if (m_sketch_banner != nullptr) { + const bool sketching = (m == UiMode::Sketch); + if (sketching && m_sketch_banner_txt != nullptr) + m_sketch_banner_txt->SetLabel( + wxString::Format(_L("Editing: Sketch %d · N = look normal to the plane · " + "Finish or Cancel in the toolbar"), + m_feature_counter + 1)); + m_sketch_banner->Show(sketching); + m_sketch_banner->GetParent()->Layout(); + } +} + +void DesignPanel::on_shape_changed() +{ + bool rect = (m_shape->GetSelection() == 0); + m_width->Enable(rect); + m_height->Enable(rect); + m_radius->Enable(!rect); +} + +void DesignPanel::set_status_ok() +{ + set_status(wxString::Format(_L("OK — %zu triangles"), + m_doc.display_mesh.its.indices.size())); + if (m_viewport != nullptr) { + m_viewport->clear_move_gizmo(); // a recompute invalidates the gizmo's body centroid + rebuild_disp_meshes(); // apply per-body Move transforms to the display/pick meshes + // Point the solid-pick at the fresh body + TRANSFORMED pick mesh (stable address) + the + // per-body xform vector (for edge sampling). Resets the whole/face/edge selection, whose + // ids invalidate on every recompute. Null body is handled inside. + m_viewport->set_solid_pick(&m_doc.bodies, &m_disp_pick_mesh, + &m_doc.display_tri_face, &m_doc.display_tri_body, + &m_body_visible, &m_body_xform); + feed_bodies(); + } + sync_sketch_display(); +} + +// Draw every committed sketch that no enabled Extrude consumes, so a sketch stays +// visible (as a translucent face + outline) when it is not part of the solid — e.g. +// after its Extrude is removed, or right after Finish. +void DesignPanel::sync_sketch_display() +{ + if (m_viewport == nullptr) return; + const int n = int(m_doc.features.size()); + std::vector consumed(n, false); + // Per-loop extrudes (sketch_ref < 0) carry a verbatim copy of the one loop they + // consumed; collect those so that loop is hidden from its source sketch overlay. + std::vector> consumed_loops; + for (const CadFeature& f : m_doc.features) { + if (f.type != CadFeatureType::Extrude || !f.enabled) continue; + if (f.sketch_ref >= 0 && f.sketch_ref < n) consumed[f.sketch_ref] = true; + else if (f.sketch_ref < 0 && !f.entities.empty()) consumed_loops.push_back(f.entities); + } + // Two loops match when their entities are the same geometry in the same order — the + // per-loop extrude stored a verbatim copy, so this is an exact comparison. + auto same_loop = [](const std::vector& a, const std::vector& b) { + if (a.size() != b.size() || a.empty()) return false; + auto eq = [](const Vec2d& u, const Vec2d& v) { return (u - v).squaredNorm() < 1e-10; }; + for (size_t k = 0; k < a.size(); ++k) { + const SketchEntity& x = a[k]; const SketchEntity& y = b[k]; + if (x.type != y.type || !eq(x.p0, y.p0) || !eq(x.p1, y.p1) || + !eq(x.center, y.center) || std::abs(x.radius - y.radius) > 1e-7) return false; + } + return true; + }; + + std::vector ds; + for (int i = 0; i < n; ++i) { + const CadFeature& f = m_doc.features[i]; + if (f.type != CadFeatureType::Sketch || consumed[i] || !f.enabled) + continue; + if (!f.entities.empty()) { + if (consumed_loops.empty()) { + ds.push_back({ f.entities, f.plane, i }); + } else { + // Drop the entities of any loop already extruded; keep the rest (other + // loops + non-loop entities) so they stay visible and selectable. + std::vector drop(f.entities.size(), 0); + for (const std::vector& loop : m_viewport->region_entity_indices_with_holes(f.entities)) { + std::vector es; + for (int ei : loop) + if (ei >= 0 && ei < int(f.entities.size())) es.push_back(f.entities[ei]); + bool gone = false; + for (const std::vector& c : consumed_loops) + if (same_loop(es, c)) { gone = true; break; } + if (gone) + for (int ei : loop) + if (ei >= 0 && ei < int(drop.size())) drop[ei] = 1; + } + std::vector shown; + for (int ei = 0; ei < int(f.entities.size()); ++ei) + if (!drop[ei]) shown.push_back(f.entities[ei]); + if (!shown.empty()) ds.push_back({ std::move(shown), f.plane, i }); + } + } else if (!f.imported_regions.empty()) { + // Imported art (Text/SVG) carries no solver entities; synthesize + // closed line loops from each region contour so it shows as an + // outline overlay (display only — never stored on the feature). + // Apply the feature's placement transform so the overlay tracks + // moves / scales. + const auto regions = transform_regions(f.imported_regions, f.import_offset, + f.import_scale_x, f.import_scale_y); + std::vector lines; + for (const auto& region : regions) + for (const auto& contour : region) { + const int m = int(contour.size()); + for (int k = 0; k < m; ++k) { + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = contour[k]; + e.p1 = contour[(k + 1) % m]; + lines.push_back(e); + } + } + if (!lines.empty()) + ds.push_back({ std::move(lines), f.plane, i }); + } + } + m_viewport->set_display_sketches(std::move(ds)); +} + +void DesignPanel::on_add_text() +{ + wxTextEntryDialog dlg(this, _L("Text to insert:"), _L("Text"), wxEmptyString); + if (dlg.ShowModal() != wxID_OK) + return; + const wxString text = dlg.GetValue(); + if (text.empty()) + return; + const std::string utf8(text.ToUTF8().data()); + // Insert at a default height; resize in-canvas via the bbox handles (Move/Scale). + add_imported_sketch(text_to_regions(utf8, 10.0), _L("Text")); +} + +void DesignPanel::on_import_svg() +{ + wxFileDialog dlg(this, _L("Import SVG"), wxEmptyString, wxEmptyString, + "SVG files (*.svg)|*.svg|All files|*.*", + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dlg.ShowModal() != wxID_OK) + return; + const std::string path(dlg.GetPath().ToUTF8().data()); + // Import at 1:1; resize in-canvas via the bbox handles (Move/Scale). + add_imported_sketch(svg_to_regions(path, 1.0), _L("SVG")); +} + +// Run a long CAD computation off the UI thread. A big STEP costs tens of seconds in OCCT +// (parse + tessellate); running it inline froze the whole window — the compositor marked the +// app unresponsive and nothing repainted. The dialog is app-modal, so the document cannot be +// touched while the worker owns it. Exceptions must not escape the worker: `work` is expected +// to swallow them (OCCT throws Standard_Failure, which is not a std::exception). +static void run_off_ui_thread(wxWindow* parent, const wxString& message, const std::function& work) +{ + std::atomic done{false}; + std::thread worker([&work, &done]() { + work(); + done.store(true, std::memory_order_release); + }); + + // Input stays blocked for the whole operation: the worker owns the document, so nothing + // in the UI may mutate it meanwhile. The dialog only appears if the work is actually slow — + // a fillet on a small body finishes in milliseconds and must not flash a dialog. + wxWindowDisabler disabler; + std::unique_ptr dlg; + int elapsed_ms = 0; + while (!done.load(std::memory_order_acquire)) { + if (dlg == nullptr && elapsed_ms >= 300) + dlg = std::make_unique(_L("Design"), message, 100, parent, + wxPD_AUTO_HIDE | wxPD_SMOOTH); + if (dlg != nullptr) + dlg->Pulse(); + wxYield(); // keep the window painting instead of going unresponsive + wxMilliSleep(30); + elapsed_ms += 30; + } + worker.join(); +} + +// Rebuild the document off the UI thread. Every feature op (fillet, cut, shell, boolean, ...) +// goes through recompute(), and on a heavy imported solid that is seconds of OCCT work — inline +// it freezes the window. OCCT throws Standard_Failure, which is not a std::exception and would +// terminate the process if it escaped the worker, so both are caught here. +// Keep the Model's copy of the recipe in step with the document. +// +// This used to be written in exactly ONE place — on_commit(), as a side effect of Commit to +// Plate — so a user who modelled for an hour and pressed Ctrl+S saved a project containing no +// feature history at all, and the app reported success (vjk5). The 3MF exporter was +// never at fault: nothing had handed it a recipe. +// +// Every save path (Ctrl+S, Save As, autosave, crash recovery) reads model.cad_recipe, so +// keeping it current after each document change is what makes all of them correct at once, +// rather than teaching each one to ask the Design tab. Commit to Plate means "send this to the +// slicer"; making saving depend on it was the bug, not the cure. +// +// Cost is a serialization per recompute — tens of KB against an OCCT rebuild that just ran. +void DesignPanel::sync_recipe_to_model() +{ + Plater* plater = wxGetApp().plater(); + if (plater == nullptr) return; + // An empty document CLEARS it, so a non-CAD project never carries a stale recipe. + std::string recipe = m_doc.features.empty() ? std::string() : m_doc.serialize_recipe(); + if (recipe == plater->model().cad_recipe) + return; // no change — this is the rehydrate of a project that was just opened + plater->model().cad_recipe = std::move(recipe); + + // The plater's dirty flag rides its own undo/redo stack, which Design edits never touch, and + // a design that has not been committed to the plate has no ModelObjects either — so without + // this the project reads as clean: no autosave, and no "unsaved changes" prompt on quit. + // Same hook the auxiliary-files panel uses for project data that lives outside the model. + Slic3r::put_other_changes(); +} + +bool DesignPanel::recompute_guarded(const wxString& message) +{ + bool ok = false; + run_off_ui_thread(this, message, [this, &ok]() { + try { + ok = m_doc.recompute(); + } catch (const Standard_Failure& e) { + const char* what = e.GetMessageString(); + m_doc.error = (what != nullptr && *what != '\0') ? what : "OCCT failure"; + ok = false; + } catch (const std::exception& e) { + m_doc.error = e.what(); + ok = false; + } + }); + // Only on success: a failed recompute leaves the document mid-edit, and persisting that + // would save a model the user never had. + if (ok) sync_recipe_to_model(); + return ok; +} + +void DesignPanel::on_import_step() +{ + wxFileDialog dlg(this, _L("Import STEP"), wxEmptyString, wxEmptyString, + "STEP files (*.step;*.stp)|*.step;*.stp|All files|*.*", + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dlg.ShowModal() != wxID_OK) + return; + const std::string path(dlg.GetPath().ToUTF8().data()); + std::string err; + // Keep the OCCT B-rep (don't mesh it like the slicer importer): each top-level solid + // becomes a coexisting CadBody, fully editable by the on-face/edge feature tools. + std::vector solids; + run_off_ui_thread(this, _L("Reading STEP…"), [&]() { + try { + solids = GeometryEngine::read_step_solids(path, err); + } catch (const Standard_Failure& e) { + const char* what = e.GetMessageString(); + err = (what != nullptr && *what != '\0') ? what : "OCCT failure"; + } catch (const std::exception& e) { + err = e.what(); + } + }); + if (solids.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(err.empty() ? _L("No solids found in STEP") + : (_L("STEP import failed: ") + wxString::FromUTF8(err))); + m_status->Refresh(); + return; + } + m_doc.checkpoint(); // undo boundary: importing STEP solids + for (const TopoDS_Shape& s : solids) { + m_feature_counter++; + CadFeature f; + f.type = CadFeatureType::Import; + f.name = std::string("STEP") + std::to_string(m_feature_counter); + f.imported_solid = s; + f.mode = BooleanMode::New; // each solid is its own coexisting body + m_doc.features.push_back(f); + } + bool rebuilt = false; + run_off_ui_thread(this, _L("Rebuilding model…"), [&]() { + try { + rebuilt = m_doc.recompute(); + } catch (const Standard_Failure& e) { + const char* what = e.GetMessageString(); + m_doc.error = (what != nullptr && *what != '\0') ? what : "OCCT failure"; + } catch (const std::exception& e) { + m_doc.error = e.what(); + } + }); + if (!rebuilt) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("STEP import failed: ") + wxString::FromUTF8(m_doc.error)); + m_status->Refresh(); + return; + } + set_ui_mode(UiMode::Feature); // imported solids live in the feature timeline + refresh_tree(); + set_tree_selection(int(m_doc.features.size()) - 1); + set_status_ok(); // canonical post-recompute viewport/pick/parts refresh + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format( + _L("Imported %d solid(s) — pick a face or edge, then Fillet / Cut / Shell to modify"), + int(solids.size()))); + m_status->Refresh(); +} + +// Import a triangle mesh as a real B-rep body: the triangles are rebuilt into OCCT faces with +// shared topology, then coplanar neighbours are merged so the result has pickable CAD faces +// rather than one face per triangle. Lands in the same CadFeatureType::Import as a STEP, so +// every downstream feature tool (fillet / cut / shell / face-extrude) works on it unchanged. +void DesignPanel::on_import_mesh() +{ + wxFileDialog dlg(this, _L("Import mesh"), wxEmptyString, wxEmptyString, + "Mesh files (*.stl;*.obj)|*.stl;*.obj|All files|*.*", + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dlg.ShowModal() != wxID_OK) + return; + const std::string path(dlg.GetPath().ToUTF8().data()); + + auto fail = [this](const wxString& msg) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(msg); + m_status->Refresh(); + }; + + // Load the triangles with the slicer's own readers — no new mesh dependency. + TriangleMesh mesh; + const std::string ext = boost::algorithm::to_lower_copy( + boost::filesystem::path(path).extension().string()); + if (ext == ".stl") { + if (!mesh.ReadSTLFile(path.c_str())) { fail(_L("Could not read the STL file")); return; } + } else if (ext == ".obj") { + ObjInfo obj_info; + std::string obj_err; + if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) { + fail(_L("Could not read the OBJ file: ") + wxString::FromUTF8(obj_err)); + return; + } + } else { + fail(_L("Unsupported mesh format (STL and OBJ are supported)")); + return; + } + if (mesh.its.indices.empty()) { fail(_L("The mesh contains no triangles")); return; } + + // One planar face per triangle before merging, so the cost is driven by the triangle count. + // A dense organic scan has few coplanar neighbours to merge away and stays heavy afterwards; + // warn rather than silently freezing the CAD kernel on every subsequent recompute. + if (mesh.its.indices.size() > MESH_IMPORT_TRIANGLE_WARN) { + const wxString q = wxString::Format( + _L("This mesh has %d triangles. Every triangle becomes a B-rep face before coplanar " + "merging, so importing it may take a long time and leave a body that is slow to " + "edit. Decimating the mesh first is usually better.\n\nImport anyway?"), + int(mesh.its.indices.size())); + if (wxMessageBox(q, _L("Large mesh"), wxYES_NO | wxICON_WARNING, this) != wxYES) + return; + } + + wxBusyCursor busy; + GeometryEngine::MeshBrepStats stats; + TopoDS_Shape shape; + try { + shape = GeometryEngine::mesh_to_brep(mesh.its, MESH_IMPORT_TOLERANCE, + MESH_IMPORT_MERGE_ANGLE_DEG, stats); + } catch (const std::exception& e) { + fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(e.what())); + return; + } catch (const Standard_Failure& e) { // OCCT throws outside std::exception + fail(_L("Mesh conversion failed: ") + wxString::FromUTF8( + e.GetMessageString() ? e.GetMessageString() : "OCCT error")); + return; + } + if (shape.IsNull()) { fail(_L("Mesh conversion produced no geometry")); return; } + + m_doc.checkpoint(); // undo boundary: importing a mesh as a B-rep body + m_feature_counter++; + CadFeature f; + f.type = CadFeatureType::Import; + f.name = std::string("Mesh") + std::to_string(m_feature_counter); + f.imported_solid = shape; + f.mode = BooleanMode::New; // its own coexisting body, like a STEP solid + m_doc.features.push_back(f); + + if (!recompute_guarded(_L("Rebuilding model…"))) { + fail(_L("Mesh import failed: ") + wxString::FromUTF8(m_doc.error)); + return; + } + set_ui_mode(UiMode::Feature); + refresh_tree(); + set_tree_selection(int(m_doc.features.size()) - 1); + set_status_ok(); + + // Report what the mesh actually was, never dress an open shell up as a solid: if it is not + // watertight, say so and say why (boundary vs non-manifold edges) — that is a defect in the + // source mesh the user needs to know about before they start cutting features into it. + if (stats.is_solid) { + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format( + _L("Imported solid — %d triangles → %d faces, volume %.2f mm³. Pick a face or edge, " + "then Fillet / Cut / Shell to modify"), + stats.kept_tris, stats.faces_final, stats.volume)); + } else { + m_status->SetForegroundColour(wxColour(220, 160, 60)); // warning, not an error + set_status(wxString::Format( + _L("Imported as an open shell (not watertight): %d boundary edge(s), %d non-manifold " + "edge(s) — %d triangles → %d faces. The source mesh has holes or duplicated " + "geometry; boolean features may fail on it"), + stats.boundary_edges, stats.nonmanifold_edges, stats.kept_tris, stats.faces_final)); + } + m_status->Refresh(); +} + +void DesignPanel::add_imported_sketch( + const std::vector>>& regions, + const wxString& base_name) +{ + if (regions.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("No importable geometry found")); + m_status->Refresh(); + return; + } + // DRAWING, not importing: if a sketch is open, the art belongs IN it. The outlines become + // ordinary line entities, so they can be constrained, trimmed and extruded with everything + // else on that plane. Committing a separate Sketch feature while the user is mid-sketch put + // the text on its own plane-origin feature and left the sketch they were drawing untouched. + if (m_viewport && m_viewport->is_sketching() && m_viewport->add_sketch_regions(regions)) { + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("%s added to the sketch — Confirm to commit it"), + base_name)); + m_status->Refresh(); + return; + } + m_doc.checkpoint(); // undo boundary: importing Text/SVG art + m_feature_counter++; + CadFeature f; + f.type = CadFeatureType::Sketch; + f.name = std::string(base_name.ToUTF8().data()) + std::to_string(m_feature_counter); + f.imported_regions = regions; + + // #4: when a solid face is selected, drop the art ON that face, centred on it (ready to + // engrave). Otherwise place it on the draw-plane dropdown at the plane origin (legacy). + bool on_face = false; + if (m_sel_solid_face >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { + const TopoDS_Face face = + GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape, m_sel_solid_face); + if (!face.IsNull()) { + f.plane = SketchPlane::from_face(face); + const Vec3d cw = GeometryEngine::face_centroid_world(face); + const Vec2d c_uv = f.plane.project(cw, f.plane.normal); // face centre in plane (u,v) + Vec2d lo(1e30, 1e30), hi(-1e30, -1e30); // bbox of the imported art + for (const auto& reg : regions) + for (const auto& loop : reg) + for (const Vec2d& p : loop) { lo = lo.cwiseMin(p); hi = hi.cwiseMax(p); } + f.import_offset = c_uv - 0.5 * (lo + hi); // centre the art on the face + f.import_on_face = true; + f.import_face_body = m_sel_solid_body; + on_face = true; + } + } + if (!on_face) { + f.plane = plane_from_choice(m_ref_plane); + } + + // Drop the live face selection (its body is now remembered on import_face_body): otherwise + // the next Extrude would push/pull that face instead of extruding the placed art. + m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1; + + m_doc.features.push_back(f); + m_doc.recompute(); // a lone sketch yields an empty body; that is expected + refresh_tree(); + const int newidx = int(m_doc.features.size()) - 1; + set_tree_selection(newidx); // select the new art + sync_sketch_display(); + on_transform_imported(newidx); // in-canvas place/size gizmo ON + // The feature is provisional until the user explicitly Confirms (Onshape gate). The + // Insert card carries Confirm/Cancel; Cancel undoes this insert. + m_insert_feat = newidx; + open_insert_card(base_name); +} + +// Show the Insert Confirm/Cancel card while the imported art is being placed/sized. +void DesignPanel::open_insert_card(const wxString& base_name) +{ + m_active = Tool::Insert; + if (m_hdr_insert) m_hdr_insert->SetLabel(base_name); + wxSizer* s = m_cards->GetSizer(); + s->Show(m_box_insert, true, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + update_action_bar(); // surface the unified ✓/✗ + m_status->SetForegroundColour(wxNullColour); + set_status(base_name + _L(" — drag to place/size, then Confirm")); + m_status->Refresh(); +} + +// Confirm: keep the placed art and leave the placement gizmo. The feature is already in +// the timeline (added provisionally); we just tear down the transient tool/gizmo state. +void DesignPanel::finalize_insert() +{ + const int feat = m_insert_feat; + m_insert_feat = -1; + if (m_viewport) m_viewport->cancel_sketch(); // exit the TransformArt gizmo + close_tool(); // hides the Insert card, clears m_active + set_ui_mode(UiMode::Feature); // imported art lives in the feature timeline + if (feat >= 0 && feat < int(m_doc.features.size())) set_tree_selection(feat); + sync_sketch_display(); + refresh_tree(); + set_status_ok(); +} + +// Cancel: discard the provisional insert (undo restores the pre-insert feature list). +void DesignPanel::cancel_insert() +{ + m_insert_feat = -1; + if (m_viewport) m_viewport->cancel_sketch(); // exit the TransformArt gizmo + m_doc.undo(); // remove the just-added imported feature + close_tool(); + set_ui_mode(UiMode::Feature); + sync_sketch_display(); + refresh_tree(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Insert cancelled")); + m_status->Refresh(); +} + +void DesignPanel::on_transform_imported(int feat_idx) +{ + if (feat_idx < 0 || feat_idx >= int(m_doc.features.size()) || !m_viewport) + return; + const CadFeature& f = m_doc.features[feat_idx]; + if (f.imported_regions.empty()) + return; + // In-canvas bbox handles (replaces the Move/Scale dialog): drag a corner to scale, + // the centre to move. Values stream back via set_on_imported_transform. + m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, + f.import_offset, f.import_scale_x, f.import_scale_y); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Drag a corner to scale, the centre to move — right-click when done")); + m_status->Refresh(); +} + +void DesignPanel::on_add_sketch() +{ + SketchShape shape = (m_shape->GetSelection() == 1) ? SketchShape::Circle + : SketchShape::Rectangle; + wxString where; // named for the status line + SketchPlane plane = sketch_plane_from_selection(where); // picked face, else the 3D plane click + m_feature_counter++; + m_doc.add_sketch(shape, plane, m_width->GetValue(), m_height->GetValue(), + m_radius->GetValue(), "Sketch" + std::to_string(m_feature_counter)); + m_doc.recompute(); // a lone sketch yields an empty body; that is expected + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("Sketch added on %s — select it, then right-click to Extrude"), where)); + refresh_tree(); +} + +// Extrude should consume only the clicked loop when a specific region of the resolved +// sketch is selected and that loop actually has entities. +bool DesignPanel::extrude_uses_loop() const +{ + return m_viewport != nullptr + && m_sel_sketch_region >= 0 + && m_extrude_sketch_ref >= 0 + && m_extrude_sketch_ref == m_sel_sketch_feat + && m_extrude_sketch_ref < int(m_doc.features.size()) + && !m_viewport->selected_loop_entities().empty(); +} + +void DesignPanel::on_add_extrude() +{ + BooleanMode mode = static_cast(m_mode->GetSelection()); // New/Add/Cut/Intersect + m_feature_counter++; + const std::string name = "Extrude" + std::to_string(m_feature_counter); + int idx = -1; + if (m_extrude_face_src >= 0) { + // Onshape face-extrude: the picked solid face is the profile (no sketch wire). + idx = m_doc.add_extrude_face(m_extrude_face_src, m_distance->GetValue(), false, mode, name); + m_extrude_face_src = -1; // consume the face-profile selection + } else if (extrude_uses_loop()) { + // Extrude just the selected loop (its entity subset), leaving the source sketch's + // other loops intact and still selectable. + if (::getenv("ORCA_CAD_PICK_TRACE")) + std::fprintf(stderr, "[pick] on_add_extrude: feat=%d reg=%d ents=%zu\n", + m_extrude_sketch_ref, m_sel_sketch_region, + m_viewport->selected_loop_entities().size()); + idx = m_doc.add_extrude_entities(m_viewport->selected_loop_entities(), + m_doc.features[m_extrude_sketch_ref].plane, + m_distance->GetValue(), false, mode, name); + m_sel_sketch_region = -1; // consume the loop selection + m_viewport->clear_loop_pick(); // drop the now-stale loop highlight + } else { + idx = m_doc.add_extrude(m_extrude_sketch_ref, m_distance->GetValue(), false, mode, name); + } + // Carry the Onshape end-condition / taper / flip / up-to-face onto the new feature so the + // committed solid matches the preview (build_candidate sets the same fields). + if (idx >= 0 && idx < int(m_doc.features.size())) { + CadFeature& f = m_doc.features[idx]; + f.extrude_end = static_cast(m_extrude_end->GetSelection()); + f.distance2 = m_distance2->GetValue(); + f.taper_deg = m_taper->GetValue(); + f.flip = m_flip->GetValue(); + f.up_to_face = (f.extrude_end == ExtrudeEnd::UpToFace) ? m_sel_solid_face : -1; + f.target_body = m_sel_solid_body; // multi-body: act on the picked body (-1 = last) + // On-face Text/SVG remembers its host body even after the face pick was cleared by + // the placement recompute, so the engraving Cut hits the right solid. + if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) + && m_doc.features[m_extrude_sketch_ref].import_on_face) + f.target_body = m_doc.features[m_extrude_sketch_ref].import_face_body; + } + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_dressup() +{ + if (m_doc.body.IsNull()) { + set_status(_L("Add a solid (sketch + extrude) first")); + return; + } + FaceGroup fg = static_cast(m_face_group->GetSelection()); // Top=0..All=3 + double sz = m_dressup_size->GetValue(); + bool fillet = (m_dressup_type->GetSelection() == 0); + + m_feature_counter++; + // A click-selected solid edge targets THAT edge; otherwise dress the whole face-group. + int didx = -1; + if (m_sel_solid_edge >= 0) { + if (fillet) + didx = m_doc.add_fillet(sz, m_sel_solid_edge, "Fillet" + std::to_string(m_feature_counter)); + else + didx = m_doc.add_chamfer(sz, m_sel_solid_edge, "Chamfer" + std::to_string(m_feature_counter)); + } else if (fillet) + didx = m_doc.add_fillet(sz, fg, "Fillet" + std::to_string(m_feature_counter)); + else + didx = m_doc.add_chamfer(sz, fg, "Chamfer" + std::to_string(m_feature_counter)); + // Dress the picked body (its face/edge ids are body-local). -1 = last body. + if (didx >= 0 && didx < int(m_doc.features.size())) + m_doc.features[didx].target_body = m_sel_solid_body; + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +// Hole and Thread LATCH the geometry they were opened or picked on; Thicken / Shell / Draft read +// the live selection instead. Both models are right for what they are — a placement tool with its +// own plane state, versus an operation whose operand IS the selected face — and the latch is the +// kinder of the two now that a click on empty canvas clears the selection (od0): a stray +// click costs a Thicken pick, and costs a Hole nothing. What was missing is that nothing in the +// Hole/Thread card NAMED the latched face, so after such a click the only words on screen were +// the viewport's "Nothing selected" — over a ghost still drawn on the face Confirm would drill. +// That reads as a contradiction and was filed as one (200). The card now says what it +// holds, the way the other three cards already do. Pass -1 for "none, using the plane dropdown". +void DesignPanel::set_hole_target_label(int face) +{ + if (m_hole_target_label) + m_hole_target_label->SetLabel(face >= 0 ? wxString::Format(_L("Face %d"), face) + : _L("(none — uses Hole plane)")); +} + +// Thread also latches onto a circular EDGE (a cylinder's rim), so it has two kinds to name. +void DesignPanel::set_thread_target_label(int face, int edge) +{ + if (!m_thread_target_label) return; + m_thread_target_label->SetLabel( + face >= 0 ? wxString::Format(_L("Face %d"), face) + : edge >= 0 ? wxString::Format(_L("Edge %d"), edge) + : _L("(none — uses Thread plane)")); +} + +SketchPlane DesignPanel::hole_plane() const +{ + if (m_hole_on_face) return m_hole_face_plane; + SketchPlane p = plane_from_index(m_hole_plane->GetSelection()); + p.origin += m_doc.modeling_origin; + return p; +} + +void DesignPanel::on_add_hole() +{ + if (m_doc.body.IsNull()) { + set_status(_L("Add a solid (sketch + extrude) first")); + return; + } + SketchPlane plane = hole_plane(); + double dia = m_hole_diameter->GetValue(); + double depth = m_hole_depth->GetValue(); + bool through = m_hole_through->GetValue(); + double px = m_hole_x->GetValue(); + double py = m_hole_y->GetValue(); + + m_feature_counter++; + const int hidx = m_doc.add_hole(dia, depth, through, px, py, plane, + "Hole" + std::to_string(m_feature_counter)); + // On-face holes drill the body the face belongs to (even after the pick was cleared). + if (m_hole_on_face && hidx >= 0 && hidx < int(m_doc.features.size())) + m_doc.features[hidx].target_body = m_hole_face_body; + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +SketchPlane DesignPanel::thread_plane() const +{ + if (m_thread_on_face) return m_thread_face_plane; + SketchPlane p = plane_from_index(m_thread_plane->GetSelection()); + p.origin += m_doc.modeling_origin; + return p; +} + +void DesignPanel::apply_thread_standard() +{ + if (!m_thread_std) return; + const int sel = m_thread_std->GetSelection(); + if (sel <= 0) return; // 0 = "Custom" → leave the manual spins untouched + + const ThreadSpec* s = find_thread_standard( + m_thread_std->GetString(sel).utf8_string()); + if (!s) return; + + // Pitch and depth are the defining "measures" of the standard — always apply. + if (m_thread_pitch) m_thread_pitch->SetValue(s->pitch_mm); + if (m_thread_depth) m_thread_depth->SetValue(s->thread_depth_mm()); + + // Nominal diameter: external rod = major diameter; internal tapped bore = minor (tap-drill) + // diameter. On a picked cylindrical surface/edge the diameter comes from the real geometry, + // so don't override it there. (The field holds DIAMETER.) + if (!m_thread_on_face && m_thread_radius) { + const bool internal = m_thread_internal && m_thread_internal->GetValue(); + const double d = internal ? s->minor_diameter_mm() : s->major_diameter_mm; + m_thread_radius->SetValue(d); + } + + if (m_status) + set_status(wxString::Format(_L("Thread standard: %s (pitch %.3g mm)"), + m_thread_std->GetString(sel), s->pitch_mm)); +} + +void DesignPanel::infer_thread_spec(double diameter) +{ + // Snap to the nearest standard thread by nominal (major) diameter, so picking a Ø9.9 boss + // gives M10 — the M diameter, pitch AND depth all follow from the cylinder's base diameter. + const auto& stds = thread_standards(); + int best = -1; double bestErr = 1e30; + for (int i = 0; i < int(stds.size()); ++i) { + const double e = std::abs(stds[i].major_diameter_mm - diameter); + if (e < bestErr) { bestErr = e; best = i; } + } + if (best < 0) { if (m_thread_radius) m_thread_radius->SetValue(diameter); return; } + const ThreadSpec& s = stds[best]; + if (m_thread_std) m_thread_std->SetSelection(best + 1); // row 0 is "Custom" + if (m_thread_radius) m_thread_radius->SetValue(s.major_diameter_mm); // field = DIAMETER + if (m_thread_pitch) m_thread_pitch->SetValue(s.pitch_mm); + if (m_thread_depth) m_thread_depth->SetValue(s.thread_depth_mm()); +} + +void DesignPanel::on_add_thread() +{ + bool internal = m_thread_internal->GetValue(); + if (internal && m_doc.body.IsNull()) { + set_status(_L("Thread needs a solid body — add or import one first")); + return; + } + SketchPlane plane = thread_plane(); + + m_feature_counter++; + const int tidx = m_doc.add_thread(m_thread_radius->GetValue() * 0.5, m_thread_pitch->GetValue(), + m_thread_height->GetValue(), m_thread_depth->GetValue(), + internal, m_thread_x->GetValue(), m_thread_y->GetValue(), + plane, "Thread" + std::to_string(m_feature_counter)); + // On-surface internal thread taps the body the cylindrical face belongs to. + if (m_thread_on_face && tidx >= 0 && tidx < int(m_doc.features.size())) + m_doc.features[tidx].target_body = m_thread_face_body; + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +void DesignPanel::on_add_revolve() +{ + if (m_revolve_sketch_ref < 0 || m_revolve_sketch_ref >= int(m_doc.features.size())) { + set_status(_L("Pick a sketch profile to revolve first")); + return; + } + const BooleanMode mode = static_cast(m_revolve_mode->GetSelection()); + if (mode != BooleanMode::New && m_doc.body.IsNull()) { + set_status(_L("Revolve needs a solid body — add or import one first")); + return; + } + m_feature_counter++; + m_doc.add_revolve(m_revolve_sketch_ref, m_revolve_angle->GetValue(), + m_revolve_axis->GetSelection(), m_revolve_flip->GetValue(), + mode, "Revolve" + std::to_string(m_feature_counter)); + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +void DesignPanel::on_add_sweep() +{ + if (m_sweep_profile_ref < 0 || m_sweep_profile_ref >= int(m_doc.features.size())) { + set_status(_L("Pick a profile sketch to sweep first")); + return; + } + const int sel = m_sweep_path->GetSelection(); + const int path_ref = (sel != wxNOT_FOUND) + ? int(reinterpret_cast(m_sweep_path->GetClientData(sel))) : -1; + if (path_ref < 0) { + set_status(_L("Pick a path sketch for the sweep")); + return; + } + const BooleanMode mode = static_cast(m_sweep_mode->GetSelection()); + if (mode != BooleanMode::New && m_doc.body.IsNull()) { + set_status(_L("Sweep needs a solid body — add or import one first")); + return; + } + m_feature_counter++; + m_doc.add_sweep(m_sweep_profile_ref, path_ref, mode, + "Sweep" + std::to_string(m_feature_counter)); + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +void DesignPanel::on_add_loft() +{ + // Collect the checked profile sketches in list (recipe) order. + std::vector refs; + for (unsigned i = 0; i < m_loft_list->GetCount(); ++i) + if (m_loft_list->IsChecked(i) && i < m_loft_sketch_idx.size()) + refs.push_back(m_loft_sketch_idx[i]); + if (refs.size() < 2) { + set_status(_L("Check at least two profile sketches to loft")); + return; + } + const BooleanMode mode = static_cast(m_loft_mode->GetSelection()); + if (mode != BooleanMode::New && m_doc.body.IsNull()) { + set_status(_L("Loft needs a solid body — add or import one first")); + return; + } + m_feature_counter++; + m_doc.add_loft(refs, m_loft_ruled->GetValue(), mode, + "Loft" + std::to_string(m_feature_counter)); + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +void DesignPanel::on_add_surface_extrude() +{ + if (m_surf_extrude_sketch_ref < 0 || m_surf_extrude_sketch_ref >= int(m_doc.features.size())) { + set_status(_L("Pick a sketch profile to extrude first")); + return; + } + m_feature_counter++; + m_doc.add_surface_extrude(m_surf_extrude_sketch_ref, m_surf_extrude_distance->GetValue(), + "SurfaceExtrude" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_surface_revolve() +{ + if (m_surf_revolve_sketch_ref < 0 || m_surf_revolve_sketch_ref >= int(m_doc.features.size())) { + set_status(_L("Pick a sketch profile to revolve first")); + return; + } + m_feature_counter++; + m_doc.add_surface_revolve(m_surf_revolve_sketch_ref, m_surf_revolve_angle->GetValue(), + m_surf_revolve_axis->GetSelection(), + "SurfaceRevolve" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_surface_loft() +{ + std::vector refs; + for (unsigned i = 0; i < m_surf_loft_list->GetCount(); ++i) + if (m_surf_loft_list->IsChecked(i) && i < m_surf_loft_sketch_idx.size()) + refs.push_back(m_surf_loft_sketch_idx[i]); + if (refs.size() < 2) { + set_status(_L("Check at least two profile sketches to loft")); + return; + } + m_feature_counter++; + m_doc.add_surface_loft(refs, m_surf_loft_ruled->GetValue(), + "SurfaceLoft" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_surface_fill() +{ + if (m_surf_fill_sketch_ref < 0 || m_surf_fill_sketch_ref >= int(m_doc.features.size())) { + set_status(_L("Pick a sketch to fill first")); + return; + } + m_feature_counter++; + m_doc.add_surface_fill(m_surf_fill_sketch_ref, + "SurfaceFill" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_surface_offset() +{ + const int sel = sheet_choice_body(m_surf_offset_body); + if (sel < 0 || sel >= int(m_doc.bodies.size())) { + set_status(_L("Select a sheet body first")); + return; + } + m_feature_counter++; + m_doc.add_surface_offset(sel, m_surf_offset_distance->GetValue(), + "SurfaceOffset" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_thicken_surface() +{ + const int sel = sheet_choice_body(m_surf_thicken_body); + if (sel < 0 || sel >= int(m_doc.bodies.size())) { + set_status(_L("Select a sheet body first")); + return; + } + m_feature_counter++; + m_doc.add_thicken_surface(sel, m_surf_thicken_thickness->GetValue(), + m_surf_thicken_flip->GetValue(), + "ThickenSurface" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +// Compose the same matrix apply_transform() builds in the kernel: rotate about the pivot first, +// then translate. Kept here so the preview and the committed feature can never disagree. +static Transform3d xf_compose(const Vec3d& t, const Vec3d& axis, const Vec3d& pivot, double deg) +{ + Transform3d r = Transform3d::Identity(); + if (std::abs(deg) > 1e-12 && axis.norm() > 1e-9) + r = Transform3d(Eigen::Translation3d(pivot)) + * Transform3d(Eigen::AngleAxisd(deg * M_PI / 180.0, axis.normalized())) + * Transform3d(Eigen::Translation3d(-pivot)); + return Transform3d(Eigen::Translation3d(t)) * r; +} + +// Live preview for the Transform card. The gizmo already writes its drag into the body's display +// transform; the typed fields must use the SAME channel or the numbers and the geometry disagree +// until Confirm — which is what makes typing a distance look like it did nothing. In edit mode the +// committed transform is already baked into the kernel geometry, so undo it first: without that, +// typing 80 over a committed 34 would show the body at 114. +void DesignPanel::xf_live_preview() +{ + if (m_active != Tool::Transform || m_xf_dx == nullptr) return; + if (m_xf_copy && m_xf_copy->GetValue()) return; // a copy leaves the original where it is + sync_body_xform(); + const int sel = m_xf_body ? m_xf_body->GetSelection() : wxNOT_FOUND; + const int b = (sel != wxNOT_FOUND) ? sel : int(m_body_xform.size()) - 1; + if (b < 0 || b >= int(m_body_xform.size())) return; + + if (m_xf_prev_body != b) { + xf_clear_preview(); // the card retargeted: hand the old body back first + m_xf_prev_body = b; + m_xf_prev_base = (b == m_xf_gizmo_body) ? m_xf_gizmo_base : m_body_xform[b]; + } + const int ax = m_xf_axis->GetSelection(); + const Vec3d axis = (ax == 0) ? Vec3d::UnitX() : (ax == 1) ? Vec3d::UnitY() : Vec3d::UnitZ(); + const Vec3d pivot(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue()); + const Transform3d want = xf_compose(Vec3d(m_xf_dx->GetValue(), m_xf_dy->GetValue(), + m_xf_dz->GetValue()), + axis, pivot, m_xf_angle->GetValue()); + Transform3d undo = Transform3d::Identity(); + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())) { + const CadFeature& f = m_doc.features[m_edit_index]; + if (f.type == CadFeatureType::Transform && !f.xf_copy) + undo = xf_compose(f.xf_translate, f.xf_axis, f.xf_pivot, f.xf_angle_deg).inverse(); + } + m_body_xform[b] = want * undo * m_xf_prev_base; + feed_bodies(); +} + +// Hand a previewed body back to the pose it had before the card touched it. Every exit from the +// Transform card passes through here, so a preview can never survive into the committed document. +void DesignPanel::xf_clear_preview() +{ + if (m_xf_prev_body < 0) return; + sync_body_xform(); + if (m_xf_prev_body < int(m_body_xform.size())) + m_body_xform[m_xf_prev_body] = m_xf_prev_base; + m_xf_prev_body = -1; + feed_bodies(); +} + +void DesignPanel::on_add_transform() +{ + xf_clear_preview(); // the feature performs this motion parametrically; the preview must go + // The gizmo baked its drag into the display transform so the body followed the cursor. + // The feature about to be created performs that same motion parametrically, so hand the + // body back to its pre-drag pose first or it moves twice. + if (m_xf_gizmo_body >= 0) { + sync_body_xform(); + if (m_xf_gizmo_body < int(m_body_xform.size())) + m_body_xform[m_xf_gizmo_body] = m_xf_gizmo_base; + if (m_viewport) m_viewport->clear_move_gizmo(); + feed_bodies(); // set_status_ok() re-feeds on success, but the recompute-ERROR path + // below does not — without this the body would keep showing the + // dragged pose after a Transform that failed to build. + m_xf_gizmo_body = -1; + m_move_body = -1; + } + if (m_doc.bodies.empty()) { + set_status(_L("Transform needs a body — add or import one first")); + return; + } + const int sel = m_xf_body->GetSelection(); + const int target = (sel != wxNOT_FOUND) ? sel : -1; + const Vec3d trans(m_xf_dx->GetValue(), m_xf_dy->GetValue(), m_xf_dz->GetValue()); + const int ax = m_xf_axis->GetSelection(); + const Vec3d axis = (ax == 0) ? Vec3d(1, 0, 0) : (ax == 1) ? Vec3d(0, 1, 0) : Vec3d(0, 0, 1); + const Vec3d pivot(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue()); + m_feature_counter++; + m_doc.add_transform(target, trans, axis, pivot, m_xf_angle->GetValue(), m_xf_copy->GetValue(), + "Transform" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_mirror() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Mirror needs a body — add or import one first")); + return; + } + const int sel = m_mirror_body->GetSelection(); + const int target = (sel != wxNOT_FOUND) ? sel : -1; + const BooleanMode mode = m_mirror_keep->GetValue() ? BooleanMode::New : BooleanMode::Add; + m_feature_counter++; + m_doc.add_mirror(plane_from_choice(m_mirror_plane->GetSelection()), target, mode, + "Mirror" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_thicken() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Thicken needs a solid body — add or import one first")); + return; + } + if (m_sel_solid_face < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Pick a solid face to thicken first")); + m_status->Refresh(); + return; + } + const int sel = m_thicken_body->GetSelection(); + const int target = (sel != wxNOT_FOUND) ? sel : -1; + m_feature_counter++; + m_doc.add_thicken(target, m_sel_solid_face, m_thicken_thickness->GetValue(), + m_thicken_flip->GetValue(), "Thicken" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_rib() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Rib needs a solid body — add or import one first")); + return; + } + const int bsel = m_rib_body->GetSelection(); + const int target = (bsel != wxNOT_FOUND) ? bsel : -1; + const int ssel = m_rib_sketch->GetSelection(); + const int sketch_ref = (ssel != wxNOT_FOUND) + ? int(reinterpret_cast(m_rib_sketch->GetClientData(ssel))) : -1; + if (sketch_ref < 0) { + set_status(_L("Pick a sketch with an open line first")); + return; + } + m_feature_counter++; + m_doc.add_rib(sketch_ref, m_rib_entity->GetValue(), m_rib_thickness->GetValue(), + m_rib_depth->GetValue(), target, "Rib" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_project() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Project needs a body — add or import one first")); + return; + } + const int sel = m_proj_source_body->GetSelection(); + const int src_body = (sel != wxNOT_FOUND) ? sel : -1; + const int face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; + m_feature_counter++; + m_doc.add_project_edges(src_body, {}, face, + plane_from_choice(m_proj_plane->GetSelection()), + "Project" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_delete_face() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Delete Face needs a body — add or import one first")); + return; + } + if (m_del_faces.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Add at least one face to delete first")); + m_status->Refresh(); + return; + } + const int sel = m_del_face_body->GetSelection(); + const int target = (sel != wxNOT_FOUND) ? sel : -1; + m_feature_counter++; + m_doc.add_delete_face(target, m_del_faces, "DeleteFace" + std::to_string(m_feature_counter)); + m_del_faces.clear(); // consumed; fresh state for the next use + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_helix() +{ + m_feature_counter++; + m_doc.add_helix(plane_from_choice(m_helix_plane->GetSelection()), + m_helix_radius->GetValue(), m_helix_pitch->GetValue(), + m_helix_height->GetValue(), m_helix_left_handed->GetValue(), + m_helix_taper->GetValue(), "Helix" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_mate() +{ + const int sel_a = m_mate_cs_a->GetSelection(); + const int sel_b = m_mate_cs_b->GetSelection(); + if (sel_a == wxNOT_FOUND || sel_b == wxNOT_FOUND) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mate needs two CoordSys features — create them first")); + m_status->Refresh(); + return; + } + const int cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(sel_a))); + const int cs_b = int(reinterpret_cast(m_mate_cs_b->GetClientData(sel_b))); + if (cs_a == cs_b) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mate: CS A and CS B must be different CoordSys features")); + m_status->Refresh(); + return; + } + m_feature_counter++; + int idx = m_doc.add_mate(m_mate_kind->GetSelection(), cs_a, cs_b, + m_mate_offset->GetValue(), m_mate_angle->GetValue(), + m_mate_flip->GetValue(), + "Mate" + std::to_string(m_feature_counter)); + if (idx < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mate rejected")); + m_status->Refresh(); + return; + } + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_check_interference() +{ + if (m_doc.bodies.size() < 2) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("No interference — need at least two solid bodies to check")); + m_status->Refresh(); + return; + } + const auto pairs = m_doc.check_interference(); + if (pairs.empty()) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("No interference found")); + m_status->Refresh(); + return; + } + double worst = 0; + for (const auto& p : pairs) + if (p.volume > worst) worst = p.volume; + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("%zu interference pairs, worst %.2f mm³"), + pairs.size(), worst)); + m_status->Refresh(); + wxString msg = _L("Interference pairs:\n\n"); + for (const auto& p : pairs) { + // 1-based, like every other body label in this panel and in the parts tree: + // reporting "Body 1" for what the tree calls "Body 2" is worse than no name. + wxString na = wxString::Format(_L("Body %d"), p.body_a + 1); + wxString nb = wxString::Format(_L("Body %d"), p.body_b + 1); + if (p.body_a >= 0 && p.body_a < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_a].name.empty()) + na = wxString::FromUTF8(m_doc.bodies[p.body_a].name); + if (p.body_b >= 0 && p.body_b < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_b].name.empty()) + nb = wxString::FromUTF8(m_doc.bodies[p.body_b].name); + msg += wxString::Format("%s <-> %s: %.4f mm³\n", na, nb, p.volume); + } + wxMessageBox(msg, _L("Interference"), wxOK, this); +} + +// Mass properties of the selected solid. A report, not a feature: it never checkpoints, never +// recomputes and never opens a card, which is why it sits beside the interference check rather +// than in the on_add_* family. The caller only reaches us with m_sel_solid_body in range. +void DesignPanel::on_mass_properties() +{ + // This bounds check is not defensive padding — it is what makes the verb safe to fire from + // the socket, which has no offer menu to grey the row out. The menu-only route never reached + // here with nothing selected; run_verb does. Nothing selected is not an error, hence the + // neutral colour, not the error red. + if (m_sel_solid_body < 0 || m_sel_solid_body >= int(m_doc.bodies.size())) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Select a solid body first — its mass properties are what is reported")); + m_status->Refresh(); + return; + } + const auto mp = GeometryEngine::mass_properties(m_doc.bodies[m_sel_solid_body].shape); + if (!mp.valid) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mass properties could not be computed for this body")); + m_status->Refresh(); + return; + } + // 1-based, and the body's own name when it has one — the same wording the parts list uses. + wxString name = wxString::Format(_L("Body %d"), m_sel_solid_body + 1); + if (!m_doc.bodies[m_sel_solid_body].name.empty()) + name = wxString::FromUTF8(m_doc.bodies[m_sel_solid_body].name); + if (!mp.is_solid) { + // Sheet body: quoting a volume here would be inventing material that is not there. + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("%s: sheet body — %.2f cm² of surface, no volume"), + name, mp.surface_area / 100.0)); + m_status->Refresh(); + wxMessageBox(wxString::Format(_L("%s\n\nSheet body (open shell)\nSurface area: %.2f cm²\n\n" + "A sheet encloses no material, so it has no volume. " + "Thicken it into a solid to get one."), + name, mp.surface_area / 100.0), + _L("Mass properties"), wxOK, this); + return; + } + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("%s: %.3f cm³, %.2f cm²"), + name, mp.volume / 1000.0, mp.surface_area / 100.0)); + m_status->Refresh(); + wxMessageBox(wxString::Format(_L("%s\n\nVolume: %.3f cm³\nSurface area: %.2f cm²"), + name, mp.volume / 1000.0, mp.surface_area / 100.0), + _L("Mass properties"), wxOK, this); +} + +// The rows are only the SHEET bodies, so a row index is NOT a body index — with a solid at 0 +// and a sheet at 1 the single row is row 0 but body 1. Every caller must therefore read the +// real body index out of the client data (3-arg Append; the 2-arg form takes a bitmap), never +// GetSelection(). Getting this wrong targets a solid and the kernel rejects it with +// "target is not a sheet", which reads as a kernel bug rather than a picker bug. +void DesignPanel::populate_sheet_body_choices(ComboBox* c) const +{ + if (!c) return; + const int keep = c->GetSelection(); + c->Clear(); + for (size_t i = 0; i < m_doc.bodies.size(); ++i) { + if (!CadDocument::is_sheet_shape(m_doc.bodies[i].shape)) continue; + const std::string& n = m_doc.bodies[i].name; + combo_append_index(c, n.empty() ? wxString::Format(_L("Body %zu"), i + 1) + : wxString::FromUTF8(n), int(i)); + } + if (c->GetCount() > 0) + c->SetSelection(std::min(std::max(keep, 0), int(c->GetCount()) - 1)); +} + +// Real body index behind the current row of a sheet-filtered picker, or -1. +int DesignPanel::sheet_choice_body(ComboBox* c) +{ + if (!c) return -1; + const int sel = c->GetSelection(); + if (sel == wxNOT_FOUND) return -1; + return int(reinterpret_cast(c->GetClientData(sel))); +} + +// Select the row whose body index is `body`, so a re-edit restores the stored target rather +// than treating it as a row number. +void DesignPanel::select_sheet_choice(ComboBox* c, int body) +{ + if (!c) return; + for (unsigned i = 0; i < c->GetCount(); ++i) { + if (int(reinterpret_cast(c->GetClientData(i))) == body) { c->SetSelection(int(i)); return; } + } + if (c->GetCount() > 0) c->SetSelection(0); +} + +void DesignPanel::on_add_pattern() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Pattern needs a solid body — add or import one first")); + return; + } + const bool circular = (m_pattern_type->GetSelection() == 1); + const int target = (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) + ? m_sel_solid_body : -1; + m_feature_counter++; + m_doc.add_pattern(circular, int(m_pattern_count->GetValue()), + m_pattern_spacing->GetValue(), m_pattern_dir->GetSelection(), + m_pattern_angle->GetValue(), target, + "Pattern" + std::to_string(m_feature_counter)); + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +int DesignPanel::selected_body_default() const +{ + const int n = int(m_doc.bodies.size()); + if (n <= 0) return 0; + return (m_sel_solid_body >= 0 && m_sel_solid_body < n) ? m_sel_solid_body : 0; +} + +void DesignPanel::populate_body_choices(int as_of_feature) +{ + // Re-editing a Boolean: list the bodies as they existed just before it ran, so a tool body + // it consumed still shows and the saved target/tool selections round-trip. Replay a copy of + // the recipe truncated to [0, as_of_feature). Fall back to the live bodies if the replay is + // degenerate (e.g. fewer than the saved refs need). + const std::vector* src = &m_doc.bodies; + std::vector as_of; + if (as_of_feature >= 0 && as_of_feature <= int(m_doc.features.size())) { + CadDocument tmp = m_doc; + tmp.features.resize(as_of_feature); + if (tmp.recompute() && !tmp.bodies.empty()) { as_of = tmp.bodies; src = &as_of; } + } + auto fill = [&](ComboBox* c, int def) { + if (!c) return; + c->Clear(); + for (size_t i = 0; i < src->size(); ++i) { + const std::string& n = (*src)[i].name; + c->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (c->GetCount() > 0) + c->SetSelection(std::min(def, int(c->GetCount()) - 1)); // selection index == body index + }; + const int picked = selected_body_default(); + fill(m_bool_target, picked); + // …and the tool body is a DIFFERENT one: defaulting both to the same body is a no-op the + // user has to notice and undo. Fall back to the neighbour of whatever was picked. + fill(m_bool_tool, picked == 0 ? 1 : 0); + fill(m_cut_target, picked); +} + +void DesignPanel::fill_body_choice(ComboBox* c, int as_of_feature, int want) +{ + if (!c) return; + // Same replay as populate_body_choices, for the single-combo tools. A stored target_body + // indexes the body list AS IT WAS just before that feature ran; listing the final bodies + // instead makes the saved index select whatever now sits at that position, which is a + // different body as soon as a later Cut splits one (indices shift up) or a later Boolean + // consumes its tool body (indices shift down). + const std::vector* src = &m_doc.bodies; + std::vector as_of; + if (as_of_feature >= 0 && as_of_feature <= int(m_doc.features.size())) { + CadDocument tmp = m_doc; + tmp.features.resize(as_of_feature); + if (tmp.recompute() && !tmp.bodies.empty()) { as_of = tmp.bodies; src = &as_of; } + } + c->Clear(); + for (size_t i = 0; i < src->size(); ++i) { + const std::string& n = (*src)[i].name; + c->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); + } + if (want >= 0 && want < int(c->GetCount())) c->SetSelection(want); + else if (c->GetCount() > 0) c->SetSelection(0); +} + +void DesignPanel::on_add_boolean() +{ + if (m_doc.bodies.size() < 2) { + set_status(_L("Boolean needs two solid bodies — add or import a second one")); + return; + } + const int sel = m_bool_op->GetSelection(); + const BooleanMode op = (sel == 1) ? BooleanMode::Cut + : (sel == 2) ? BooleanMode::Intersect + : BooleanMode::Add; // 0 = Union + m_feature_counter++; + m_doc.add_boolean(op, m_bool_target->GetSelection(), m_bool_tool->GetSelection(), + m_bool_keep->GetValue(), m_bool_tol->GetValue(), -1, -1, + "Boolean" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::on_add_cut() +{ + if (m_doc.bodies.empty()) { + set_status(_L("Cut needs a solid body — add or import one first")); + return; + } + m_feature_counter++; + m_doc.add_cut(plane_from_choice(m_cut_plane->GetSelection()), m_cut_offset->GetValue(), + /*flip*/ false, /*keep_upper*/ true, /*keep_lower*/ true, + m_cut_target->GetSelection(), "Cut" + std::to_string(m_feature_counter)); + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); +} + +void DesignPanel::populate_plane_choices(ComboBox* c) const +{ + if (!c) return; + const int keep = c->GetSelection(); + c->Clear(); + c->Append(_L("XY")); c->Append(_L("XZ")); c->Append(_L("YZ")); + for (const auto& dp : m_doc.resolve_datum_planes()) + c->Append(wxString::FromUTF8(dp.first)); + c->SetSelection((keep >= 0 && keep < int(c->GetCount())) ? keep : 0); +} + +wxString DesignPanel::ref_plane_name(int row) const +{ + if (row == 1) return "XZ"; + if (row == 2) return "YZ"; + if (row >= 3) { + const auto datums = m_doc.resolve_datum_planes(); + const int di = row - 3; + if (di < int(datums.size())) return wxString::FromUTF8(datums[di].first); + } + return "XY"; +} + +SketchPlane DesignPanel::plane_from_choice(int row) const +{ + if (row < 3) { // 0=XY,1=XZ,2=YZ through the modeling origin + SketchPlane p = plane_from_index(row); + p.origin += m_doc.modeling_origin; + return p; + } + // Datums are already in world coords (resolve_datum_planes applied the origin to their base). + auto datums = m_doc.resolve_datum_planes(); + const int di = row - 3; + if (di >= 0 && di < int(datums.size())) return datums[di].second; + SketchPlane p = SketchPlane::XY(); p.origin += m_doc.modeling_origin; return p; +} + +// A sketch goes where the user pointed. A planar face picked in the viewport wins outright; only +// when nothing is picked do we fall back to the reference plane, which itself is normally set by +// clicking one of the ghost planes in 3D (on_datum_base_picked) rather than by opening the combo. +// Before this, a picked face was ignored and the only way onto it was to build a Coincident datum +// plane first and then find it in a dropdown — three steps and a junk feature in the tree for the +// most common gesture in solid modelling. 3a2. +SketchPlane DesignPanel::sketch_plane_from_selection(wxString& what) const +{ + SketchPlane p; + // An explicitly face-level selection first, then the face merely CLICKED ON while the whole + // body was selected. The second case is the common one: one click on a face is what a user + // means by "select this face", and requiring the cycle's second click to make it count is the + // whole reason this looked unfixed. + const int fb = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_body : m_pick_face_body; + const int fi = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_face : m_pick_face; + if (fb >= 0 && fi >= 0 && m_doc.plane_of_face(fb, fi, p)) { + what = (m_doc.bodies.size() > 1) + ? wxString::Format(_L("the picked face of Body %d"), fb + 1) + : _L("the picked face"); + return p; + } + what = ref_plane_name(m_ref_plane); + return plane_from_choice(m_ref_plane); +} + +// Is there a sketch target the USER chose, and what is it called? Distinct from +// sketch_plane_from_selection, which always answers because it falls back to m_ref_plane — +// a caller that wants to say "sketching on X" needs to know whether there is anything to fall +// back FROM, so it can ask for a plane instead of claiming one the user never picked. +bool DesignPanel::sketch_plane_target(wxString& what) const +{ + const int fb = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_body : m_pick_face_body; + const int fi = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_face : m_pick_face; + SketchPlane p; + if (fb >= 0 && fi >= 0 && m_doc.plane_of_face(fb, fi, p)) { + what = (m_doc.bodies.size() > 1) + ? wxString::Format(_L("the picked face of Body %d"), fb + 1) + : _L("the picked face"); + return true; + } + if (m_plane_picked) { what = ref_plane_name(m_ref_plane); return true; } + return false; +} + +// --------------------------------------------------------------------------------------------- +// The object-driven offer (charter §4.1). Right-click the geometry and get a vertical list in the +// ratified row order, with the verbs that do not apply DISABLED IN PLACE carrying their reason. +// The rows come from DesignOffer.hpp, generated from docs/ux/tool_atlas.json — the same file the +// mockups are drawn from, so a drawing and the product cannot drift apart. +// --------------------------------------------------------------------------------------------- + +bool DesignPanel::sketch_map_applies() const +{ + return m_ui_mode == UiMode::Sketch || (m_viewport && m_viewport->is_sketching()); +} + +// Which kind of thing is selected, as an OfferSel. Classified by the level the pick cycle has +// actually REACHED, so the menu describes what is highlighted — a header that names a face while +// the whole body is lit would be lying, and this menu's whole value is that it tells the truth +// about the selection. (Sketching on the face you merely clicked is unaffected: that path is +// sketch_plane_from_selection, which deliberately uses m_pick_face. 3a2.) +int DesignPanel::offer_selection_kind() const +{ + if (sketch_map_applies()) { + // Classify WHAT is selected in the sketch, rather than collapsing every sketch state to + // SkNone. The offer table has always described verbs for a selected line, arc, point or + // pair — Trim, Extend, Fillet, Chamfer, Offset, Mirror, the arrays, Move/Rotate/Scale, + // Constrain, Delete — but nothing ever RETURNED those kinds, so all thirteen were + // unreachable from the menu and the sketch-editing vocabulary did not exist in the one + // place this app tells users to look. A user comparing against Onshape reported exactly + // that about constraints (OrcaSlicer PR #15238). + const int n = m_viewport ? m_viewport->sketch_selection_count() : 0; + if (n >= 2) return int(OfferSel::Sk2Ent); + if (n == 1) { + SketchEntity::Type t = SketchEntity::Type::Line; + if (m_viewport && m_viewport->sketch_first_selected_type(t)) { + switch (t) { + case SketchEntity::Type::Line: return int(OfferSel::SkLine); + case SketchEntity::Type::Point: return int(OfferSel::SkPoint); + // Arc, Circle, Ellipse, EllipseArc, BSpline all take the curve vocabulary. + default: return int(OfferSel::SkArc); + } + } + } + return int(OfferSel::SkNone); + } + + const int nb = int(m_doc.bodies.size()); + if (m_sel_solid_vertex && m_sel_solid_body >= 0 && m_sel_solid_body < nb) + return int(OfferSel::Vertex); + if (m_sel_solid_edge >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < nb) { + const TopoDS_Edge e = GeometryEngine::edge_by_index(m_doc.bodies[m_sel_solid_body].shape, + m_sel_solid_edge); + return int(GeometryEngine::circle_of_edge(e).ok ? OfferSel::EdgeCirc : OfferSel::EdgeStr); + } + if (m_sel_solid_face >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < nb) { + SketchPlane p; + if (m_doc.plane_of_face(m_sel_solid_body, m_sel_solid_face, p)) + return int(OfferSel::FacePlanar); + const TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape, + m_sel_solid_face); + return int(GeometryEngine::cylinder_of_face(f).ok ? OfferSel::FaceCyl : OfferSel::FaceOther); + } + if (m_sel_sketch_region >= 0) + return int(OfferSel::SkLoop); + if (m_sel_solid_body >= 0 && m_sel_solid_body < nb) + return int(CadDocument::is_sheet_shape(m_doc.bodies[m_sel_solid_body].shape) + ? OfferSel::BodySheet : OfferSel::BodySolid); + return int(OfferSel::None); +} + +// Route a row to the code that already implements the verb. Nothing here re-implements a tool: +// the offer is a second door onto the same room, which is what keeps the toolbar, the shortcuts +// and the menu from drifting into three behaviours. +void DesignPanel::run_offer_action(const char* action) +{ + if (!action || !*action) + return; + const std::string a(action); + if (a.rfind("key:", 0) == 0) { + const std::string k = a.substr(4); + if (k.size() >= 3 && k[0] == 'S' && k[1] == '+') { // "S+E" -> Shift+E, model mode + auto it = m_keys_feature.find(int(k[2]) | SC_SHIFT); + if (it != m_keys_feature.end() && it->second) it->second(); + } else if (!k.empty()) { // "L" -> sketch-mode letter + auto it = m_keys_sketch.find(int(k[0])); + if (it != m_keys_sketch.end() && it->second) it->second(); + } + return; + } + auto it = m_verb_actions.find(a); + if (it != m_verb_actions.end() && it->second) + it->second(); +} + +// Look a verb up by its offer id and dispatch it — the "run_verb" half of the MCP offer surface. +// Unknown ids and rows whose action string is null (kernel support, no GUI route yet) return +// false without touching anything, so the caller can tell "no such verb" from "not wired yet". +bool DesignPanel::mcp_run_verb(const char* verb_id) +{ + if (!verb_id) return false; + for (int i = 0; i < kOfferVerbCount; ++i) { + const OfferVerb& v = kOfferVerbs[i]; + if (std::string(v.id) == verb_id) { + if (v.action == nullptr) return false; + run_offer_action(v.action); + return true; + } + } + return false; +} + +wxPoint DesignPanel::offer_anchor() const +{ + const wxPoint mouse = wxGetMousePosition(); + if (!m_viewport) + return mouse; + const wxRect r = m_viewport->GetScreenRect(); + if (r.Contains(mouse)) + return mouse; + return wxPoint(r.x + r.width / 2, r.y + r.height / 2); +} + +// Append a verb row carrying the same glyph its toolbar button shows. The icon name comes from +// the atlas, so a verb and its icon are declared in one place; a null or unknown name leaves +// the row text-only rather than drawing a blank square. +// +// THE BITMAP MUST BE SET BEFORE Append(). wxGTK builds the GtkMenuItem inside Append and reads +// GetBitmap() right there (gtk/menu.cpp) — it makes a gtk_image_menu_item only if a bitmap is +// already present, and a plain one otherwise. Setting it on the item Append() returns is too +// late and silently does nothing, which is exactly how the first attempt failed. This is why +// Orca's own append_menu_item() constructs, sets, then appends. +// One place that writes the status line, so every hint wraps instead of clipping at the panel +// edge. wxStaticText::Wrap() is destructive, which is fine here: the label is replaced whole +// each time, never appended to. +void DesignPanel::set_status(const wxString& text) +{ + if (m_status == nullptr) return; + m_status->SetLabel(text); // the ONE place that may call SetLabel directly + // m_status is HIDDEN and kept only as the owner of the text and its colour — every caller + // sets the colour on it just before calling here, so this stays the one place that knows + // both. What the user reads is drawn along the BASE OF THE VIEWPORT: in the panel the line + // was clipped at ~73 characters with no warning and no wrap (Wrap() never took effect — + // 8cc), which silently length-limited every hint in the tab. The viewport's bottom + // margin has the whole window width, so a sentence can be a sentence. + if (m_viewport != nullptr) { + // wxNullColour means "no opinion", and the dark default text colour is nearly invisible + // on the dark HUD; only a colour a caller actually chose (the error red, the plane-pick + // green) is carried over. Compared against the colour the label was CREATED with — + // comparing against the parent's foreground instead reported "chosen" for every line, + // and the neutral text came out the panel's grey. + const wxColour fg = m_status->GetForegroundColour(); + m_viewport->set_status_text(text, fg != m_status_default_fg ? fg + : wxColour(0xDD, 0xE1, 0xE6)); + } +} + + +// The sentence for the step the armed sketch tool is on (1c0c). One table, so a tool's +// gesture is described in one place and the description cannot drift from the code that reads the +// clicks: the step counts here are the ones DesignSketchTool::render previews and on_mouse +// consumes. `step` = anchors already placed (edit-ops: 0 none, 1 first pick down, 2 ready to +// apply); `picks` = the size of the set the gesture accumulates. +// +// It is deliberately explicit about the gesture that ENDS each tool, because none of them is +// discoverable: a click on empty space applies an edit-op or a transform, right-click cancels it, +// and Esc downgrades an armed tool to Select before it ever exits the sketch. +static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks) +{ + using Mode = DesignSketchTool::Mode; + auto pick_more = [](const wxString& lead, int n) { + return n <= 0 ? lead + : lead + wxString::Format(_L(" · %d picked"), n); + }; + switch (m) { + case Mode::Select: + return picks > 0 + ? wxString::Format(_L("%d selected · Del removes them · Shift-click adds · " + "double-click takes the whole loop"), picks) + : _L("Select — click an entity to pick it · drag an endpoint or centre to move it · " + "Shift-click adds · Del removes"); + case Mode::Constrain: + return picks > 0 + ? wxString::Format(_L("%d picked · now choose a constraint (Horizontal, Parallel, " + "Tangent, Equal…)"), picks) + : _L("Constrain — click one or two entities, then choose a constraint"); + case Mode::Dimension: + return step == 0 ? _L("Dimension — click an entity, or the first of two points") + : _L("Dimension — click the second point"); + case Mode::Line: + return step == 0 ? _L("Line — click the start point") + : _L("Line — click the end point, or type the length"); + case Mode::Polyline: + return step == 0 ? _L("Polyline — click the first point") + : pick_more(_L("Polyline — click the next point · click the start point " + "to close it · right-click to end the chain"), step); + case Mode::CornerRect: + return step == 0 ? _L("Rectangle — click one corner") + : _L("Rectangle — click the opposite corner"); + case Mode::CenterRect: + return step == 0 ? _L("Centre rectangle — click the centre") + : _L("Centre rectangle — click a corner"); + case Mode::ObliqueRect: + return step == 0 ? _L("Oblique rectangle — click the start of the base edge") + : step == 1 ? _L("Oblique rectangle — click the end of the base edge (this sets the angle)") + : _L("Oblique rectangle — click to set the width"); + case Mode::RoundedRect: + return step == 0 ? _L("Rounded rectangle — click one corner") + : step == 1 ? _L("Rounded rectangle — click the opposite corner") + : _L("Rounded rectangle — click to set the corner radius"); + case Mode::CenterCircle: + return step == 0 ? _L("Circle — click the centre") + : _L("Circle — click to set the radius, or type it"); + case Mode::TwoPointCircle: + return step == 0 ? _L("Circle (2 points) — click one end of the diameter") + : _L("Circle (2 points) — click the other end of the diameter"); + case Mode::ThreePointCircle: + return step == 0 ? _L("Circle (3 points) — click the first point on the circle") + : step == 1 ? _L("Circle (3 points) — click the second point") + : _L("Circle (3 points) — click the third point"); + case Mode::ThreePointArc: + return step == 0 ? _L("Arc — click the start point") + : step == 1 ? _L("Arc — click the end point") + : _L("Arc — click a point the arc passes through"); + case Mode::TangentArc: + return step == 0 ? _L("Tangent arc — click the endpoint it leaves from") + : _L("Tangent arc — click its far end"); + case Mode::CenterArc: + return step == 0 ? _L("Centre arc — click the centre") + : step == 1 ? _L("Centre arc — click the start point (this sets the radius)") + : _L("Centre arc — click the end point"); + case Mode::Slot: + return step == 0 ? _L("Slot — click one end of the centreline") + : step == 1 ? _L("Slot — click the other end of the centreline") + : _L("Slot — click to set the width"); + case Mode::ArcSlot: + return step == 0 ? _L("Arc slot — click the centre the slot curves about") + : step == 1 ? _L("Arc slot — click the start of the centreline (this sets the radius)") + : step == 2 ? _L("Arc slot — click the end of the centreline") + : _L("Arc slot — click to set the width"); + case Mode::Polygon: + return step == 0 ? _L("Polygon — click the centre") + : _L("Polygon — click a vertex (this sets size and orientation)"); + case Mode::Ellipse: + return step == 0 ? _L("Ellipse — click the centre") + : step == 1 ? _L("Ellipse — click the end of the major axis") + : _L("Ellipse — click a point on the minor axis"); + case Mode::EllipseArc: + return step == 0 ? _L("Elliptical arc — click the centre") + : step == 1 ? _L("Elliptical arc — click the end of the major axis") + : step == 2 ? _L("Elliptical arc — click a point on the minor axis") + : step == 3 ? _L("Elliptical arc — click where the arc starts") + : _L("Elliptical arc — click where the arc ends"); + case Mode::BSpline: + return step == 0 ? _L("Spline — click the first control point") + : pick_more(_L("Spline — click the next control point · right-click to " + "finish the curve"), step); + case Mode::Point: + return _L("Point — click to place one; the tool stays armed for more"); + case Mode::Trim: + return _L("Trim — click a segment where it crosses another entity · right-click to exit"); + case Mode::Extend: + return _L("Extend — click a line or arc to grow it out to the nearest entity · " + "right-click to exit"); + case Mode::Fillet: + return step == 0 ? _L("Fillet — click the first of two lines that meet") + : step == 1 ? _L("Fillet — click the second line") + : _L("Fillet — drag the arrow, or click the number to type the radius · " + "click empty space to apply · right-click cancels"); + case Mode::Chamfer: + return step == 0 ? _L("Chamfer — click the first of two lines that meet") + : step == 1 ? _L("Chamfer — click the second line") + : _L("Chamfer — drag the arrow, or click the number to type the setback · " + "click empty space to apply · right-click cancels"); + case Mode::Offset: + return step == 0 ? _L("Offset — click the entity to offset") + : _L("Offset — drag the arrow to either side, or click the number to type " + "the distance · click empty space to apply · right-click cancels"); + case Mode::Mirror: + // The two-phase pick is the one gesture users reported as unguided: nothing said the + // AXIS comes first, and nothing said an empty click is what applies it. + return step == 0 + ? _L("Mirror — first click the LINE to mirror about (a construction line works)") + : picks == 0 + ? _L("Mirror — axis set · now click the entities to mirror · right-click cancels") + : wxString::Format(_L("Mirror — axis set · %d to mirror · click another to add or " + "remove it · click empty space to apply"), picks); + case Mode::Move: + return step == 0 ? _L("Move — click the entities to move") + : pick_more(_L("Move — drag the handle, or click the number to type the " + "distance · click empty space to apply"), picks); + case Mode::Rotate: + return step == 0 ? _L("Rotate — click the entities to rotate") + : pick_more(_L("Rotate — drag the handle, or click the number to type the " + "angle · click empty space to apply"), picks); + case Mode::Scale: + return step == 0 ? _L("Scale — click the entities to scale") + : pick_more(_L("Scale — drag the handle, or click the number to type the " + "factor · click empty space to apply"), picks); + case Mode::Array: + return step == 0 ? _L("Array — click the entities to repeat") + : pick_more(_L("Array — drag the handle to set the step, click the count to " + "type it · click empty space to apply"), picks); + case Mode::PolarArray: + return step == 0 ? _L("Polar array — click the entities to repeat") + : pick_more(_L("Polar array — drag the handle to set the sweep, click the " + "count to type it · click empty space to apply"), picks); + case Mode::TransformArt: + return _L("Drag a corner to scale, the centre to move · right-click when done"); + } + return wxString(); +} + +void DesignPanel::on_sketch_step(int mode, int step, int picks) +{ + wxString text = sketch_step_prompt(DesignSketchTool::Mode(mode), step, picks); + // Esc is layered (DesignSketchTool::request_exit): it drops the anchors down, then downgrades + // the armed tool to Select, and only then leaves the sketch. Nothing in the UI said so, so + // the route back to selecting existing geometry was invisible. Said once, on the step where + // the gesture has not started yet, so it does not crowd the instruction that matters. + if (step == 0 && picks == 0 && DesignSketchTool::Mode(mode) != DesignSketchTool::Mode::Select + && !text.IsEmpty()) + text += _L(" · Esc goes back to Select"); + // Badges are clickable and nothing said so — a glyph reads as decoration until something + // tells you it is a target. Only in Select mode (the only mode where the click is wired) and + // only once a constraint exists, so it never advertises a badge that is not on screen. + if (DesignSketchTool::Mode(mode) == DesignSketchTool::Mode::Select && m_viewport != nullptr && + m_viewport->sketch_constraint_count() > 0 && !text.IsEmpty()) + text += _L(" · click a constraint badge to remove it"); + m_sketch_step = text; + if (text.IsEmpty() || m_status == nullptr) return; + m_status->SetForegroundColour(wxNullColour); + set_status(text); + m_status->Refresh(); +} + +wxMenuItem* DesignPanel::append_offer_item(wxMenu* menu, int id, const wxString& text, + const OfferVerb& v) +{ + auto* item = new wxMenuItem(menu, id, text); + if (v.icon != nullptr && *v.icon != '\0') { + const wxBitmap bmp = create_scaled_bitmap(v.icon, this, 16); + if (bmp.IsOk()) + item->SetBitmap(bmp); + } + menu->Append(item); + return item; +} + +// Diagnostic only: what the offer is about to show, line per row, on stderr. Costs one getenv +// per menu when off. The ladder that drives right-click needs to assert the ROW SET, and the only +// honest source for that is the loop that builds the rows. +static void offer_trace(const char* fmt, ...) +{ + static const bool on = std::getenv("ORCA_CAD_KEYTRACE") != nullptr; + if (!on) return; + va_list ap; + va_start(ap, fmt); + fprintf(stderr, "[OFFER] "); + vfprintf(stderr, fmt, ap); + fprintf(stderr, "\n"); + va_end(ap); + fflush(stderr); +} + +void DesignPanel::show_offer_menu(const wxPoint& screen_pos) +{ + const int kind = offer_selection_kind(); + const uint32_t bit = offer_bit(OfferSel(kind)); + // Which verb MAP applies is a question about the MODE, not about whether a session is + // running — the same distinction the keyboard already had to learn (0ud). Gated on + // is_sketching() the offer opened on entering a sketch showing the FEATURE rows, every one + // of them refusing the sketch selection, so it read as a menu of nine dead entries. + const bool sketching = sketch_map_applies(); + // The offer ladder reads THIS, not the pixels: the trace is emitted from the same loop that + // builds the menu, so it cannot drift from what the user is shown. Gated on the existing + // ORCA_CAD_KEYTRACE so a rig run needs one env var, not two. . + offer_trace("open kind=%d sketching=%d bodies=%d", kind, sketching ? 1 : 0, + int(m_doc.bodies.size())); + + const int bodies = int(m_doc.bodies.size()); + int sketches = 0; + for (const auto& f : m_doc.features) + if (f.type == CadFeatureType::Sketch) ++sketches; + bool sheet = false; + for (const auto& b : m_doc.bodies) + if (CadDocument::is_sheet_shape(b.shape)) { sheet = true; break; } + + auto applies = [&](const OfferVerb& v) { + return (v.accepts & bit) && v.need_bodies <= bodies && v.need_sketches <= sketches + && (!v.need_sheet || sheet); + }; + // Names and reasons live in the generated table as plain literals; they are the same strings + // the toolbar already ships, so the catalogue already carries their translations. + // Scope the lookup to the APPLICATION catalog. A bare wxGetTranslation() searches every + // loaded catalog, wxWidgets' own wxstd included — so on a non-English system the two row + // names that happen to be wx standard strings came back translated while the other six, + // which wx does not know, stayed English. On an Italian desktop the offer read + // "Create / Add material / Rimuovi / Fillet / chamfer / draft / Repeat / Transform / + // Reference / Modify": one menu, two languages, and not because anything was mistranslated. + // The same trap is waiting for any locale — Supprimer, Löschen, Eliminar. Naming the domain + // means these strings are translated by OUR catalogue or not at all, which is consistent + // either way. + auto tr = [](const char* s) { + return wxGetTranslation(wxString::FromUTF8(s), SLIC3R_APP_KEY); + }; + auto label = [&](const OfferVerb& v) { + wxString s = tr(v.name); + if (v.key && *v.key) s += "\t" + wxString::FromUTF8(v.key); + return s; + }; + + wxMenu menu; + std::vector bound; // menu id offset -> verb + const int base = wxID_HIGHEST + 4200; + + for (int row = 0; row < kOfferRowCount; ++row) { + std::vector live, family; + for (int i = 0; i < kOfferVerbCount; ++i) { + const OfferVerb& v = kOfferVerbs[i]; + if (v.row != row || v.sketch_mode != sketching) continue; + family.push_back(&v); + if (applies(v)) live.push_back(&v); + } + if (family.empty()) + continue; // no verb of this family in this mode + const wxString fam = tr(kOfferRowNames[row]); + + if (live.empty()) { + // DISABLED IN PLACE, with the reason. This is the row that makes the list worth + // having: a control that cannot be used still says what it is and what you would + // have to do first, in the product's own words (L7). + // + // The reason must be TRUE for the situation in front of the user. Taking the first + // refusal in the family printed "Transform needs a body — add or import one first" + // on a document that has a body, because the real obstacle was that nothing was + // selected. So: prefer the refusal of a verb that accepts THIS selection and fails + // only on document state — that message is about the actual blocker. If no verb in + // the family accepts this selection at all, the honest thing is to say so, or say + // nothing. + const char* why = nullptr; + for (const OfferVerb* v : family) + if ((v->accepts & bit) && v->refusal) { why = v->refusal; break; } + wxString s = fam; + if (why) + s += wxString::FromUTF8(" — ") + tr(why); + else if (OfferSel(kind) == OfferSel::None) + s += wxString::FromUTF8(" — ") + _L("select something first"); + offer_trace("row=%d %s DISABLED (%s)", row, kOfferRowNames[row], + why ? why : "no verb accepts this selection"); + menu.Append(base + int(bound.size()), s)->Enable(false); + bound.push_back(nullptr); + } else if (live.size() == 1) { + offer_trace("row=%d %s -> %s%s", row, kOfferRowNames[row], live[0]->id, + live[0]->action ? "" : " (no GUI route)"); + append_offer_item(&menu, base + int(bound.size()), label(*live[0]), *live[0]) + ->Enable(live[0]->action != nullptr); + bound.push_back(live[0]); + } else { + // Two levels, not one. A verb with a `family` joins a nested submenu of that name + // (Rectangle -> corner / centre / oblique / rounded); one without sits directly in + // the row. Families keep the order of their first member, so the row's layout is + // stable across selections — the whole point of a fixed address. + auto* sub = new wxMenu(); + std::vector> groups; // insertion-ordered + for (const OfferVerb* v : live) { + wxMenu* target = sub; + if (v->family && *v->family) { + auto it = std::find_if(groups.begin(), groups.end(), + [&](const auto& g) { return g.first == v->family; }); + if (it == groups.end()) { + auto* g = new wxMenu(); + groups.emplace_back(v->family, g); + sub->AppendSubMenu(g, tr(v->family)); + target = g; + } else { + target = it->second; + } + } + offer_trace("row=%d %s > %s%s%s%s", row, kOfferRowNames[row], + (v->family && *v->family) ? v->family : "", + (v->family && *v->family) ? " > " : "", v->id, + v->action ? "" : " (no GUI route)"); + append_offer_item(target, base + int(bound.size()), label(*v), *v) + ->Enable(v->action != nullptr); + bound.push_back(v); + } + menu.AppendSubMenu(sub, fam); + } + } + + // --- Mate palette section (lukg part B) --- + // Fed by CadDocument::mate_options() so the offer can never disagree with the kernel about + // which assembly mates a connector pair admits. Shown only when the document holds at least + // two ENABLED CoordSys features: below that the whole section would be one permanently dead + // row, which is noise rather than information. + // Set while a hovered mate row is showing its ghost, so the cleanup after PopupMenu can tell + // "I put that there" from "some other tool's preview was already on screen". + bool mate_ghost = false; + std::vector cs_features; + for (int i = 0; i < int(m_doc.features.size()); ++i) + if (m_doc.features[i].type == CadFeatureType::CoordSys && m_doc.features[i].enabled) + cs_features.push_back(i); + if (cs_features.size() >= 2) { + // Which two connectors the types would apply to. The Mate card's combos when it is open — + // so the offer and the card can never disagree about the pair — otherwise the first two + // enabled CoordSys features, which is a defensible default precisely because the header row + // below NAMES it. An offer that acts on an unnamed pair would be worse than no offer. + int cs_a = -1, cs_b = -1; + if (m_active == Tool::Mate && m_mate_cs_a && m_mate_cs_b + && m_mate_cs_a->GetSelection() != wxNOT_FOUND + && m_mate_cs_b->GetSelection() != wxNOT_FOUND) { + cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(m_mate_cs_a->GetSelection()))); + cs_b = int(reinterpret_cast(m_mate_cs_b->GetClientData(m_mate_cs_b->GetSelection()))); + } + if (cs_a < 0 || cs_b < 0) { cs_a = cs_features[0]; cs_b = cs_features[1]; } + + // The generated verb rows own [base, base + bound.size()); kOfferVerbCount is 87, so a + // gap of 500 keeps this section's ids clear of that range and lets its own handler index + // by a distinct offset. + const int mate_base = base + 500; + + menu.AppendSeparator(); + // B is the connector on the body that MOVES (CadDocument.hpp:317), so B is the arrow's + // destination — "A first" invites the opposite guess. + const wxString name_a = wxString::FromUTF8(m_doc.features[cs_a].name); + const wxString name_b = wxString::FromUTF8(m_doc.features[cs_b].name); + menu.Append(mate_base, wxString::Format(_L("Mate: %s → %s"), name_a, name_b))->Enable(false); + + // A switch of literal _L() calls, not an array indexed by kind. _L is a gettext macro: + // the extractor scans the SOURCE for literals, so _L(table[i]) compiles fine and then + // silently ships five strings that are never in the catalogue and can never be + // translated. These names appear nowhere else in the tree, so the array version would + // have been their only occurrence. + auto mate_kind_name = [](int kind) -> wxString { + switch (kind) { + case 0: return _L("Fastened"); + case 1: return _L("Planar"); + case 2: return _L("Revolute"); + case 3: return _L("Slider"); + default: return _L("Cylindrical"); + } + }; + // Five rows, always, in kind order. Never reordered, never filtered — a menu that changes + // shape between invocations destroys the motor memory experts rely on. + const std::vector opts = m_doc.mate_options(cs_a, cs_b); + for (int i = 0; i < int(opts.size()); ++i) { + const CadDocument::MateOption& o = opts[i]; + wxString label = mate_kind_name(o.kind); + if (!o.viable) + label += wxString::FromUTF8(" — ") + wxString::FromUTF8(o.reason); + menu.Append(mate_base + 1 + i, label)->Enable(o.viable); + } + + // Hovering a row shows the RESULT, not a description of it (epic gap G3). preview() moves + // the body on a throwaway copy of the document, so nothing is written until the click — + // the "commit nothing until you choose" behaviour the palette was asked for. + auto drop_ghost = [this, &mate_ghost]() { + if (!mate_ghost) return; + m_viewport->clear_preview(); + m_viewport->set_body_hidden(false); + m_viewport->repaint_now(); + mate_ghost = false; + }; + menu.Bind(wxEVT_MENU_HIGHLIGHT, + [this, cs_a, cs_b, opts, mate_base, &mate_ghost, drop_ghost](wxMenuEvent& e) { + const int i = e.GetMenuId() - (mate_base + 1); + // Off the palette (a verb row, the header, or nothing) — a stale ghost from the row + // you just left is worse than none, so it goes as soon as the cursor does. + if (i < 0 || i >= int(opts.size()) || !opts[i].viable) { drop_ghost(); return; } + std::string err; + mate_ghost = show_mate_ghost(opts[i].kind, cs_a, cs_b, 0.0, 0.0, false, err); + m_viewport->repaint_now(); // synchronous: the popup owns the loop, a queued repaint is never serviced + }); + + menu.Bind(wxEVT_MENU, [this, cs_a, cs_b, opts, mate_base, drop_ghost](wxCommandEvent& e) { + const int i = e.GetId() - (mate_base + 1); + if (i < 0 || i >= int(opts.size()) || !opts[i].viable) return; + drop_ghost(); // the real bodies are about to become the ghost's pose + m_doc.checkpoint(); // undo boundary: committing a mate from the offer + int idx = m_doc.add_mate(opts[i].kind, cs_a, cs_b, 0.0, 0.0, false, + "Mate" + std::to_string(++m_feature_counter)); + if (idx < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mate rejected")); + m_status->Refresh(); + return; + } + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + refresh_tree(); + }); + } + + // Hovering a row explains it. The offer is the only door to these tools now, so a bare + // name is not enough — and the hint arrives while you are still choosing. + // BOUND TO THE VERB ID RANGE, NOT THE WHOLE MENU. These two used to be unfiltered, and + // wxWidgets pushes dynamic entries to the FRONT of the handler list, so being bound LAST made + // them run FIRST for every id — including the mate rows at mate_base+1 (base+501) above. Both + // fall out of `bound`'s range there and return WITHOUT e.Skip(), which wx reads as "handled", + // so the mate handlers never ran: hovering a mate kind showed no ghost and left the previous + // status message on screen, and clicking one created nothing at all. The whole mate palette + // enumerated perfectly and fired nothing. Restricting the range keeps each half to its own ids + // regardless of bind order. + menu.Bind(wxEVT_MENU_HIGHLIGHT, [this, &bound, base](wxMenuEvent& e) { + const int i = e.GetMenuId() - base; + if (i < 0 || i >= int(bound.size()) || bound[i] == nullptr || bound[i]->hint == nullptr) + return; + m_status->SetForegroundColour(wxNullColour); + set_status(wxGetTranslation(wxString::FromUTF8(bound[i]->hint))); + m_status->Update(); // the popup owns the loop; without this the line repaints late + }, base, base + 499); // 499: the mate section starts at base + 500 (see mate_base) + menu.Bind(wxEVT_MENU, [this, &bound, base](wxCommandEvent& e) { + const int i = e.GetId() - base; + if (i >= 0 && i < int(bound.size()) && bound[i]) + run_offer_action(bound[i]->action); + }, base, base + 499); // 499: the mate section starts at base + 500 (see mate_base) + PopupMenu(&menu, ScreenToClient(screen_pos)); + // PopupMenu is modal, so by here the menu is gone and any command it raised has already run. + // A hover ghost that outlived the menu it belonged to would leave the committed bodies hidden + // behind a preview nothing can dismiss — bind nothing to the close event, just clean up here. + if (mate_ghost) { + m_viewport->clear_preview(); + m_viewport->set_body_hidden(false); + m_viewport->request_repaint(); + } +} + +void DesignPanel::apply_plane_refs(CadFeature& f) const +{ + f.plane_type = (PlaneType)m_plane_type->GetSelection(); + f.plane_face_body = m_pl_faceA_body; f.plane_face = m_pl_faceA; + f.plane_face2_body = m_pl_faceB_body; f.plane_face2 = m_pl_faceB; + f.plane_edge_body = m_pl_edgeA_body; f.plane_edge = m_pl_edgeA; + f.plane_edge2_body = m_pl_edgeB_body; f.plane_edge2 = m_pl_edgeB; + f.plane_u_size = m_plane_usize->GetValue(); + f.plane_v_size = m_plane_vsize->GetValue(); +} + +void DesignPanel::refresh_plane_labels() +{ + auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); }; + if (m_plane_faceA_lbl) m_plane_faceA_lbl->SetLabel(txt(m_pl_faceA)); + if (m_plane_faceB_lbl) m_plane_faceB_lbl->SetLabel(txt(m_pl_faceB)); + if (m_plane_edgeA_lbl) m_plane_edgeA_lbl->SetLabel(txt(m_pl_edgeA)); + if (m_plane_edgeB_lbl) m_plane_edgeB_lbl->SetLabel(txt(m_pl_edgeB)); +} + +void DesignPanel::reset_plane_refs() +{ + m_pl_faceA_body = m_pl_faceA = -1; m_pl_faceB_body = m_pl_faceB = -1; + m_pl_edgeA_body = m_pl_edgeA = -1; m_pl_edgeB_body = m_pl_edgeB = -1; + m_plane_pick = PlanePick::None; + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + refresh_plane_labels(); +} + +void DesignPanel::arm_plane_pick(PlanePick target) +{ + m_plane_pick = target; + // While a pick is armed, a click must CAPTURE the face under the cursor, never escalate to + // the whole body. Without this, clicking a face that already happens to be selected reads as + // a repeat pick, selects the body, and the capture is silently lost — the label stays + // "(none)" and the user has no idea why. The capture path below already restores the flag, + // and reset_plane_refs() restores it when the pick is abandoned; only the arm side was missing. + if (m_viewport) m_viewport->set_escalate_on_repick(false); + const bool face = (target == PlanePick::FaceA || target == PlanePick::FaceB); + m_status->SetForegroundColour(wxNullColour); + set_status(face ? _L("Click a solid FACE in the viewport") + : _L("Click a solid EDGE in the viewport")); + m_status->Refresh(); +} + +// --- Axis helpers --- + +void DesignPanel::apply_axis_refs(CadFeature& f) const +{ + f.axis_type = (AxisType)m_axis_type->GetSelection(); + f.axis_face = m_ax_face; + f.axis_edge = m_ax_edge; + f.axis_plane_a = m_axis_plane_a->GetSelection(); + f.axis_plane_b = m_axis_plane_b->GetSelection(); + f.axis_p1 = Vec3d(m_axis_p1x->GetValue(), m_axis_p1y->GetValue(), m_axis_p1z->GetValue()); + f.axis_p2 = Vec3d(m_axis_p2x->GetValue(), m_axis_p2y->GetValue(), m_axis_p2z->GetValue()); + f.axis_body = m_ax_face_body; +} + +void DesignPanel::refresh_axis_labels() +{ + auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); }; + if (m_axis_face_lbl) m_axis_face_lbl->SetLabel(txt(m_ax_face)); + if (m_axis_edge_lbl) m_axis_edge_lbl->SetLabel(txt(m_ax_edge)); +} + +void DesignPanel::reset_axis_refs() +{ + m_ax_face_body = m_ax_face = -1; + m_ax_edge = -1; + m_axis_pick = AxisPick::None; + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + refresh_axis_labels(); +} + +void DesignPanel::arm_axis_pick(AxisPick target) +{ + m_axis_pick = target; + if (m_viewport) m_viewport->set_escalate_on_repick(false); // same as arm_plane_pick + m_status->SetForegroundColour(wxNullColour); + set_status(target == AxisPick::Face ? _L("Click a solid FACE in the viewport") + : _L("Click a solid EDGE in the viewport")); + m_status->Refresh(); +} + +// --- CoordSys helpers --- + +void DesignPanel::apply_coordsys_refs(CadFeature& f) const +{ + f.coordsys_type = (CoordSysType)m_coordsys_type->GetSelection(); + f.coordsys_point = Vec3d(m_cs_x->GetValue(), m_cs_y->GetValue(), m_cs_z->GetValue()); + f.coordsys_body = m_cs_face_body; + f.coordsys_face = m_cs_face; + f.coordsys_edge = m_cs_edge; + f.coordsys_x_hint = Vec3d(m_cs_hx->GetValue(), m_cs_hy->GetValue(), m_cs_hz->GetValue()); +} + +void DesignPanel::refresh_coordsys_labels() +{ + auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); }; + if (m_cs_face_lbl) m_cs_face_lbl->SetLabel(txt(m_cs_face)); + if (m_cs_edge_lbl) m_cs_edge_lbl->SetLabel(txt(m_cs_edge)); +} + +void DesignPanel::refresh_cs_body_choice() +{ + if (!m_cs_body) return; + const int keep = m_cs_body->GetSelection(); + m_cs_body->Clear(); + m_cs_body->Append(_L("(all)")); + for (size_t b = 0; b < m_doc.bodies.size(); ++b) + m_cs_body->Append(wxString::Format(_L("Body %d"), int(b) + 1)); + const int sel = (keep > 0 && keep < int(m_cs_body->GetCount())) ? keep : 0; + m_cs_body->SetSelection(sel); + // The combo and the viewport focus are ONE state, so they must not be written separately. + // When the body list shrinks, `keep` falls out of range and the selection silently drops to + // "(all)" — while the viewport stayed focused on the old index, leaving every other body at + // 25% alpha and picking locked to a body that may no longer exist. That is the exact mirror + // of the open_tool ordering bug (combo says Body N, viewport opaque); this one says "(all)" + // and stays dimmed. + // + // Guarded on the CoordSys card being the ACTIVE tool because it is the only card that owns + // this focus. In the edit path this function runs BEFORE open_tool, with the previous tool + // still active, so the guard is false and the caller's explicit set_xray_focus still wins. + if (m_viewport != nullptr && m_active == Tool::CoordSys) + m_viewport->set_xray_focus(sel - 1); +} + +void DesignPanel::reset_coordsys_refs() +{ + m_cs_face_body = m_cs_face = -1; + m_cs_edge = -1; + m_coordsys_pick = CoordSysPick::None; + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + refresh_coordsys_labels(); + if (m_cs_body) m_cs_body->SetSelection(0); + if (m_viewport) m_viewport->set_xray_focus(-1); + refresh_cs_body_choice(); +} + +void DesignPanel::arm_coordsys_pick(CoordSysPick target) +{ + m_coordsys_pick = target; + // While this pick is armed the click the card asked for must reach it, so the whole-body + // escalation is off: clicking the face the card is pointing at is the ANSWER here, not a + // request for its body. + if (m_viewport) m_viewport->set_escalate_on_repick(false); + m_status->SetForegroundColour(wxNullColour); + set_status(target == CoordSysPick::Face ? _L("Click a solid FACE in the viewport") + : _L("Click a solid EDGE in the viewport")); + m_status->Refresh(); +} + +bool DesignPanel::on_add_plane() +{ + // Refuse here rather than let the kernel substitute. Every method in CadDocument's plane + // dispatch falls back to offset_angle_plane() when its references are missing, so picking + // Tangent and confirming with nothing selected used to produce an offset plane reported as + // a success — the user asks for one construction and silently receives another. The kernel + // keeps its fallback (it must return SOMETHING), but no user gesture should reach it. + auto refuse = [this](const wxString& why) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(why); + m_status->Refresh(); + }; + switch ((PlaneType)m_plane_type->GetSelection()) { + case PlaneType::Angle: + if (m_pl_edgeA < 0) { refuse(_L("An angled plane needs an edge to tilt about — pick Edge A")); return false; } + break; + case PlaneType::Midplane: + if (m_pl_faceA < 0 || m_pl_faceB < 0) { refuse(_L("A midplane needs two faces — pick Face A and Face B")); return false; } + if (m_pl_faceA == m_pl_faceB && m_pl_faceA_body == m_pl_faceB_body) { + // Well-defined but useless: the midplane of a face with itself is that same face. + refuse(_L("Face A and Face B are the same face — a midplane needs two different faces")); + return false; + } + break; + case PlaneType::Tangent: + // The face must also be cylindrical; that check stays in the kernel, which has the geometry. + if (m_pl_faceA < 0) { refuse(_L("A tangent plane needs a cylindrical face — pick Face A")); return false; } + break; + case PlaneType::TwoEdges: + if (m_pl_edgeA < 0 || m_pl_edgeB < 0) { refuse(_L("This plane needs two edges — pick Edge A and Edge B")); return false; } + break; + default: + break; // Offset and Coincident are meaningful with no reference: they use the base plane + } + + m_feature_counter++; + int idx = m_doc.add_plane(m_plane_base->GetSelection(), m_plane_offset->GetValue(), + m_plane_tilt->GetValue(), m_plane_tilt_axis->GetSelection(), + "Plane" + std::to_string(m_feature_counter)); + if (idx >= 0 && idx < int(m_doc.features.size())) apply_plane_refs(m_doc.features[idx]); + m_doc.recompute(); // datum-only docs yield no body; that is expected/benign + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Plane added — pick it as a sketch plane")); + refresh_tree(); + return true; +} + +void DesignPanel::on_add_axis() +{ + m_feature_counter++; + int idx = m_doc.add_axis((AxisType)m_axis_type->GetSelection(), + "Axis" + std::to_string(m_feature_counter)); + if (idx >= 0 && idx < int(m_doc.features.size())) apply_axis_refs(m_doc.features[idx]); + m_doc.recompute(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Axis added")); + refresh_tree(); +} + +void DesignPanel::on_add_coordsys() +{ + m_feature_counter++; + Vec3d pt(m_cs_x->GetValue(), m_cs_y->GetValue(), m_cs_z->GetValue()); + int idx = m_doc.add_coordsys((CoordSysType)m_coordsys_type->GetSelection(), pt, + "Coord" + std::to_string(m_feature_counter)); + if (idx >= 0 && idx < int(m_doc.features.size())) apply_coordsys_refs(m_doc.features[idx]); + m_doc.recompute(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Coord Sys added")); + refresh_tree(); +} + +void DesignPanel::on_add_shell() +{ + if (m_doc.body.IsNull()) { + set_status(_L("Shell needs a solid body — add or import one first")); + return; + } + const int face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; + + m_feature_counter++; + m_doc.add_shell(m_shell_thickness->GetValue(), face, m_sel_solid_body, + "Shell" + std::to_string(m_feature_counter)); + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +void DesignPanel::on_add_draft() +{ + if (m_doc.body.IsNull()) { + set_status(_L("Draft needs a solid body — add or import one first")); + return; + } + if (m_sel_solid_face < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Draft needs a picked face — click a side face first")); + m_status->Refresh(); + return; + } + + m_feature_counter++; + m_doc.add_draft(m_draft_angle->GetValue(), m_sel_solid_face, m_sel_solid_body, + "Draft" + std::to_string(m_feature_counter)); + + if (!recompute_guarded(_L("Rebuilding model…"))) + set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + else + set_status_ok(); + + refresh_tree(); +} + +int DesignPanel::tree_icon_for(CadFeatureType t) +{ + switch (t) { + case CadFeatureType::Sketch: return 0; + case CadFeatureType::Extrude: return 1; + case CadFeatureType::Fillet: + case CadFeatureType::Chamfer: return 2; + case CadFeatureType::Hole: return 3; + case CadFeatureType::Thread: return 4; + case CadFeatureType::Shell: return 5; + case CadFeatureType::Revolve: return 1; + case CadFeatureType::Sweep: return 1; + case CadFeatureType::Pattern: return 1; + case CadFeatureType::Plane: return 0; // datum plane: sketch-family icon + case CadFeatureType::Loft: return 1; + case CadFeatureType::Draft: return 5; // dressup-family icon + case CadFeatureType::Import: return 1; // imported solid: solid-family icon + case CadFeatureType::Boolean: return 1; // body-body combine: solid-family icon + case CadFeatureType::Cut: return 1; // plane split: solid-family icon + case CadFeatureType::Axis: return 0; // datum axis: sketch-family icon + case CadFeatureType::CoordSys: return 0; // datum coord sys: sketch-family icon + case CadFeatureType::SurfaceExtrude: return 1; + case CadFeatureType::SurfaceRevolve: return 1; + case CadFeatureType::SurfaceLoft: return 1; + case CadFeatureType::SurfaceFill: return 1; + case CadFeatureType::ThickenSurface: return 1; + case CadFeatureType::SurfaceOffset: return 1; + case CadFeatureType::Transform: return 1; // solid-family icon + case CadFeatureType::Mirror: return 1; // solid-family icon + case CadFeatureType::Thicken: return 1; // solid-family icon + case CadFeatureType::Rib: return 1; // solid-family icon + case CadFeatureType::Project: return 0; // sketch-family icon (produces sketch) + case CadFeatureType::DeleteFace: return 5; // dressup-family icon + case CadFeatureType::Helix: return 4; // thread-family icon (curve) + case CadFeatureType::Mate: return 2; // dressup-family icon (assembly) + } + return 0; +} + +// The reason detect_mate_conflicts() recorded for this feature, or nullptr. A linear scan: an +// assembly has a handful of mates and at most that many conflicts, so a map would cost more to +// build than the scans it saves. +const std::string* DesignPanel::mate_conflict_reason(int feature) const +{ + for (const auto& c : m_doc.mate_conflicts) + if (c.first == feature) return &c.second; + return nullptr; +} + +// What to say when nothing is selected and no tool is open. Lives in one place because it is +// needed from two: after an edit empties the document, and at startup — where after_tree_edit() +// has never run, which is exactly why a fresh tab used to show a blank line. +wxString DesignPanel::idle_hint() const +{ + return m_doc.features.empty() + ? _L("Nothing yet — import a STEP or a mesh from the toolbar,\n" + "or click a reference plane and right-click it to start a sketch.") + : _L("No solid yet — select a sketch and right-click it to Extrude."); +} + +// The Design tab is no longer the visible page (dlj). The status line is a popup floating +// over the GL canvas, so it does NOT go away when this page does — it stayed up over Prepare and +// over the home screen, still reading like a live Design selection ("selected (whole body) — +// right-click for what applies to it") on a tab that has no such selection and no such menu. +// Nothing else in the panel needs to know: the popup keeps its text and comes straight back. +void DesignPanel::on_tab_hidden() +{ + if (m_viewport) { + m_viewport->show_status_hud(false); + m_viewport->leave_viewport(); // hand the shared camera back to the editor tabs + } +} + +void DesignPanel::on_tab_shown() +{ + if (m_viewport) { + m_viewport->show_status_hud(true); // ...and back on the way in + m_viewport->enter_viewport(); // borrow the shared camera; on_tab_hidden gives it back + } + + if (m_active == Tool::None && m_doc.display_mesh.its.indices.empty()) + set_status(idle_hint()); // first paint: the tab has never been edited + + if (m_viewport) m_viewport->refresh_bed(); + + // Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and datums + // land in the middle of the bed (not the bed corner = world 0). + if (Plater* pl = wxGetApp().plater()) { + const Vec2d bc = pl->build_volume().bed_center(); + m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); + } + + // Rehydrate the parametric model from a freshly loaded project (the 3MF carried the + // recipe in Metadata/orca_cad.bin). Only when nothing is in progress here, so we + // never clobber an active design when the user just toggles back to the Design tab. + if (m_doc.features.empty()) { + if (Plater* plater = wxGetApp().plater()) { + const std::string& blob = plater->model().cad_recipe; + if (!blob.empty()) load_recipe(blob); + } + } + update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet + sync_sidebar_width(); // keep the panel as wide as Prepare's so the canvas edge doesn't jump + if (m_viewport) m_viewport->force_repaint(); // the page was just re-shown: paint it for real +} + +// Application close / language switch, from the plater's canvas teardown. +void DesignPanel::unbind_canvas_event_handlers() +{ + if (m_viewport) m_viewport->unbind_canvas_event_handlers(); +} + +void DesignPanel::reset_canvas_volumes() +{ + if (m_viewport) m_viewport->reset_canvas_volumes(); +} + +// Match Prepare's sidebar width instead of hardcoding one. Design used a fixed 264 px against +// Prepare's ~467, so the canvas edge jumped sideways on every tab switch; reading the live width +// also means the two stay aligned if Orca ever changes its sidebar. +void DesignPanel::sync_sidebar_width() +{ + if (m_form == nullptr) return; + Plater* pl = wxGetApp().plater(); + if (pl == nullptr) return; + const int w = pl->sidebar().GetSize().GetWidth(); + if (w < 200) return; // sidebar not laid out yet — keep what we have + if (m_form->GetMinSize().GetWidth() == w) return; + m_form->SetMinSize(wxSize(w, -1)); + Layout(); +} + +void DesignPanel::load_recipe(const std::string& blob) +{ + if (blob.empty()) return; + if (!m_doc.deserialize_recipe(blob)) { + // Carry the kernel's reason. deserialize_recipe distinguishes three cases that matter + // very differently to the person reading this — saved by a NEWER build, saved by an + // OLDER one, or genuinely unreadable — and replacing all three with one sentence left + // the user unable to tell "update OrcaSlicer" from "your file is damaged". Same + // error-loss class as the 31 McpControl sites (1de72de9ed): the message exists, it was + // simply not passed on. + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(m_doc.error.empty() + ? _L("Could not restore the CAD model from this project") + : _L("Could not restore the CAD model: ") + wxString::FromUTF8(m_doc.error)); + m_status->Refresh(); + return; + } + m_feature_counter = int(m_doc.features.size()); + feed_bodies(); // push the restored bodies into the viewport + refresh_tree(); // rebuild the feature tree from the restored recipe + set_status_ok(); +} + +void DesignPanel::refresh_tree() +{ + // The recipe mirrors the FEATURE LIST, and this is the moment the feature list changed — + // every add, delete, reorder, rename and suppression ends here to redraw the tree. Putting + // the sync in recompute_guarded instead tied it to "a solid was built", and CadDocument:: + // recompute() returns FALSE for a document that has no solid ("no solid-producing features", + // CadDocument.cpp) — which is precisely a document the user has only drawn sketches in. So + // drawing a profile, pressing Confirm and saving wrote a 3MF with no orca_cad.bin in it + // at all, and the app reported success: the whole design was gone on reopen (mtav). + // The three sites that say "a lone sketch yields an empty body; that is expected" call + // m_doc.recompute() directly and so never reached the sync either. One hook here covers all + // of them, including the live sketch tool's own commit path. + // + // ONLY when the document has something in it. sync_recipe_to_model() CLEARS the blob for an + // empty document, and the tree is also refreshed while the Design tab is still empty — before + // the deferred load at on_show() has had the chance to read the blob the project arrived + // with. Clearing there would destroy the recipe of every project being opened. Deleting the + // last feature still clears it, through the tree-edit call site that always did. + if (!m_doc.features.empty()) sync_recipe_to_model(); + + // Preserve the selected row across the rebuild — wxTreeCtrl::DeleteAllItems + // drops the selection, which made every edit/add feel like it "lost" the + // selection (and broke Edit/Move/Delete on the just-touched feature). + const int keep = tree_selection(); + + m_tree->DeleteAllItems(); + m_tree_items.clear(); + m_tree_body_items.clear(); + wxTreeItemId root = m_tree->AddRoot("root"); + // Datum/reference planes carry no solid; feed them to the viewport so they render as + // translucent rectangles (otherwise a Plane feature is invisible in the canvas). + refresh_datum_planes(); + update_reference_planes(); // body added/removed -> show/hide the XY/XZ/YZ origin planes + for (size_t fi = 0; fi < m_doc.features.size(); ++fi) { + const CadFeature& f = m_doc.features[fi]; + const int img = tree_icon_for(f.type); + wxTreeItemId id = m_tree->AppendItem(root, wxString::FromUTF8(f.name), img, img); + // Three states, in this order of precedence: + // disabled -> dim. A SUPPRESSED mate is the user's answer to a conflict, so it must + // read as suppressed rather than keep shouting about the conflict. + // conflict -> warn. detect_mate_conflicts() refills m_doc.mate_conflicts on every + // recompute; the mark goes on the row that carries it, because the tree + // is where the user is already looking for which feature to change. + // otherwise -> normal. + // A conflict is NOT a document error — the document still evaluates — so the row is + // marked and never hidden, and the reason goes to the status line on selection rather + // than into a modal that interrupts without offering an action. + m_tree->SetItemTextColour(id, !f.enabled ? dp_item_dim() + : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110) + : dp_item_text()); + m_tree_items.push_back(id); + } + refresh_parts(); // bodies live in their own list below the tree, never clipped by history + if (keep >= 0 && keep < int(m_tree_items.size())) + m_tree->SelectItem(m_tree_items[keep]); + + // Size the tree to its content (clamped) so it doesn't waste a fixed-height block when + // there are few features, and scrolls internally past ~9 rows instead of growing forever. + const int rows = int(m_tree_items.size()); // bodies are in their own list now + const int rowH = std::max(m_tree->GetCharHeight() + 8, 20); + const int shown = std::min(std::max(rows, 1), 9); + const wxSize ts(-1, shown * rowH + 8); + m_tree->SetMinSize(ts); + m_tree->SetMaxSize(ts); + if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); } +} + +// Rebuild the Parts list from the document's bodies, preserving the selected row so a +// recompute (fillet, hole, ...) doesn't drop the user's body selection under them. +void DesignPanel::refresh_parts() +{ + if (m_parts == nullptr) return; + const int keep = tree_body_selection(); + + m_parts->DeleteAllItems(); + m_tree_body_items.clear(); + wxTreeItemId proot = m_parts->AddRoot("root"); + + sync_body_visible(); // keep flags parallel before reading them for the row colour + for (size_t b = 0; b < m_doc.bodies.size(); ++b) { + // "Body N" keeps the positional identity every status line and message uses ("Body 2 + // selected", the interference report), and the NAME follows it because that is the part + // a rename can change: a body's name is derived from the feature that produced it, so + // renaming through this row and seeing the label sit unchanged at "Body 1" would read as + // a rename that did nothing. + const bool vis = b >= m_body_visible.size() || m_body_visible[b]; + // The user's name wins over the derived one (the maker's name, restamped every + // recompute); "Body N" still leads, because every status line, the interference report + // and the mate errors identify a body by its number. + const wxString bname = m_doc.bodies[b].has_user_name + ? wxString::FromUTF8(m_doc.bodies[b].user_name) + : wxString::FromUTF8(m_doc.bodies[b].name); + wxTreeItemId id = m_parts->AppendItem(proot, + bname.IsEmpty() ? wxString::Format(_L("Body %zu"), b + 1) + : wxString::Format(_L("Body %zu — %s"), b + 1, bname)); + // Hidden bodies are greyed so the show/hide state reads at a glance (eye toggle). + m_parts->SetItemTextColour(id, vis ? dp_item_text() : dp_item_dim()); + m_tree_body_items.push_back(id); + } + + // Hide the whole block until there is something to list, so an empty document doesn't + // show a stray empty box. + const bool any = !m_tree_body_items.empty(); + m_parts->Show(any); + if (m_parts_label) m_parts_label->Show(any); + if (m_parts_hdr) m_parts_hdr->ShowItems(any); // icon + title live in this sizer + if (m_parts_rule) m_parts_rule->Show(any); + // ...and the frame with it, or an empty bordered box floats there. + if (m_parts_box && m_form && m_form->GetSizer()) m_form->GetSizer()->Show(m_parts_box, any, false); + + if (any) { + const int rowH = std::max(m_parts->GetCharHeight() + 8, 20); + const int shown = std::min(int(m_tree_body_items.size()), 6); // scrolls past 6 + const wxSize ps(-1, shown * rowH + 8); + m_parts->SetMinSize(ps); + m_parts->SetMaxSize(ps); + if (keep >= 0 && keep < int(m_tree_body_items.size())) + m_parts->SelectItem(m_tree_body_items[keep]); + } + if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); } +} + +int DesignPanel::tree_body_selection() const +{ + if (m_parts == nullptr) return -1; + const wxTreeItemId sel = m_parts->GetSelection(); + if (!sel.IsOk()) return -1; + for (size_t i = 0; i < m_tree_body_items.size(); ++i) + if (m_tree_body_items[i] == sel) return int(i); + return -1; +} + +void DesignPanel::update_section_flip_btn() +{ + if (m_section_flip_btn) m_section_flip_btn->Enable(m_section_on); +} + +void DesignPanel::toggle_section_view() +{ + if (!m_viewport) return; + m_section_on = !m_section_on; + m_status->SetForegroundColour(wxNullColour); + if (m_section_on) { + m_section_cut_z = m_viewport->model_mid_z(); // start at the model's mid-height + m_section_upper = false; // keep the lower half by default + m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); + set_status(_L("Section view on — hides half the model to see inside; " + "PageUp / PageDown move the plane, Flip shows the other half")); + } else { + m_viewport->set_section_plane(false, 0.0); + set_status(_L("Section view off")); + } + m_status->Refresh(); + update_section_flip_btn(); +} + +void DesignPanel::flip_section_view() +{ + if (!m_viewport || !m_section_on) return; + m_section_upper = !m_section_upper; + m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("Section view — showing the %s half"), + m_section_upper ? _L("upper") : _L("lower"))); + m_status->Refresh(); +} + +void DesignPanel::sync_body_visible() +{ + // Keep the visibility vector parallel to bodies; newly-created bodies default visible. + // Bodies are appended in feature order, so existing indices keep their flag on resize. + m_body_visible.resize(m_doc.bodies.size(), true); +} + +void DesignPanel::sync_body_xform() +{ + // Parallel to bodies; new bodies default to identity (no move). Stable on resize. + m_body_xform.resize(m_doc.bodies.size(), Transform3d::Identity()); +} + +// Build the display + pick meshes with each body's Move transform applied. The pick mesh is +// re-merged from the transformed per-body meshes IN THE SAME body order as tessellate_bodies, +// so display_tri_face/display_tri_body stay aligned. m_disp_pick_mesh keeps a stable address — +// the tool holds a pointer to it, so an in-place rebuild updates picking without re-pointing. +void DesignPanel::rebuild_disp_meshes() +{ + sync_body_visible(); + sync_body_xform(); + const std::vector& src = m_doc.display_body_meshes; + + bool any = false; + for (const Transform3d& t : m_body_xform) + if (!t.isApprox(Transform3d::Identity())) { any = true; break; } + + if (!any) { // no body moved: identical to the untransformed meshes + m_disp_body_meshes = src; + m_disp_pick_mesh = m_doc.display_mesh; + return; + } + + m_disp_body_meshes.clear(); + m_disp_body_meshes.reserve(src.size()); + m_disp_pick_mesh = TriangleMesh{}; + for (size_t b = 0; b < src.size(); ++b) { + TriangleMesh m = src[b]; + if (b < m_body_xform.size()) m.transform(m_body_xform[b]); + m_disp_pick_mesh.merge(m); // same order as tessellate_bodies -> tri_* stay aligned + m_disp_body_meshes.push_back(std::move(m)); + } +} + +void DesignPanel::feed_bodies() +{ + // The body count just changed, so the tools that consume a body may have become reachable or + // unreachable. Before the early return below: the gating is about the toolbar, not the canvas. + update_body_gates(); + // Rebuild the transformed meshes first so every display-refresh path (recompute, tint, + // visibility, live move) shows the bodies at their current Move offsets. The solid-pick + // keeps a STABLE pointer to m_disp_pick_mesh / m_body_visible / m_body_xform (rebuilt in + // place), so it needs no re-call here — the whole/face/edge selection survives a move drag. + if (m_viewport == nullptr) return; + rebuild_disp_meshes(); + m_viewport->set_bodies(m_disp_body_meshes, m_body_visible); +} + +// Boolean (combine bodies) — one gate for every door onto the tool. A body-body operation +// needs two solids, and saying so in one place means the toolbar button, its Shift+B binding +// and the Bodies card cannot drift apart on what "available" means. +void DesignPanel::on_boolean_tool() +{ + if (m_doc.bodies.size() < 2) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Boolean needs two bodies — create or import a second solid")); + m_status->Refresh(); + return; + } + populate_body_choices(); + open_tool(Tool::Boolean); +} + +void DesignPanel::on_move_body() +{ + const int b = m_sel_solid_body; + if (m_viewport == nullptr) return; + if (b < 0 || b >= int(m_doc.display_body_meshes.size())) { + // Never fail silently here: the caller gates on bodies.size() while this needs a + // tessellated per-body mesh, and when those disagreed the click did nothing at all. + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(b < 0 ? _L("Select a body first — click it in the viewport or the Bodies list") + : _L("That body has no display mesh yet — recompute first")); + m_status->Refresh(); + return; + } + sync_body_xform(); + // Delta gizmo: pivot at the body's CURRENT world centroid; the tool composes the drag deltas + // onto its current pose, so move + rotate both work (incl. on an already place-on-face'd body). + const Transform3d base = m_body_xform[b]; + const BoundingBoxf3 bb = m_doc.display_body_meshes[b].bounding_box(); + const Vec3d pivot = base * bb.center(); + // Bounding-sphere radius (world): the gizmo scales with it so the rotation rings sit clear of + // the body instead of collapsing into a tangle inside it — same sizing rule as Orca's Prepare + // gizmos. The scale part of `base` is applied so a scaled body still gets a correct radius. + const double radius = (base.linear() * (bb.size() * 0.5)).norm(); + m_viewport->begin_move_body(b, pivot, base, radius); + m_move_body = b; // for the action bar: Cancel reverts to this pose + m_move_prev = base; + // Reset the numeric fields to "no change" and show the card beside the drag gizmo. + if (m_move_dx) m_move_dx->SetValue(0.0); + if (m_move_dy) m_move_dy->SetValue(0.0); + if (m_move_dz) m_move_dz->SetValue(0.0); + if (m_move_angle) m_move_angle->SetValue(0.0); + show_move_card(true); + update_action_bar(); // surface the unified ✓/✗ while moving + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); + m_status->Refresh(); +} + +// Transform card (add mode): arm the same move gizmo on the card's body so the geometry-first +// control is what the user drags, while the card's numeric fields mirror the result. The card +// stays visible as the typed half — the gizmo owns the drag, the fields own the numbers. +void DesignPanel::arm_transform_gizmo() +{ + int b = tree_body_selection(); + if (b < 0) b = m_sel_solid_body; + if (b < 0 && m_xf_body) b = m_xf_body->GetSelection(); + if (b < 0 || b >= int(m_doc.display_body_meshes.size())) return; // card alone still works + if (m_xf_body && b < int(m_xf_body->GetCount())) m_xf_body->SetSelection(b); // feature must be created against the gizmo's body + sync_body_xform(); + const Transform3d base = m_body_xform[b]; + const BoundingBoxf3 bb = m_doc.display_body_meshes[b].bounding_box(); + const Vec3d pivot = base * bb.center(); + const double radius = (base.linear() * (bb.size() * 0.5)).norm(); + if (m_viewport) m_viewport->begin_move_body(b, pivot, base, radius); + m_xf_gizmo_body = b; + m_xf_gizmo_base = base; + m_move_body = b; // the existing revert paths key off these two + m_move_prev = base; + // Write the pivot into the card so the parametric feature reproduces what was dragged; + // dx/dy/dz and angle start at zero (the gizmo reports deltas from this pose). + if (m_xf_pivot_x) m_xf_pivot_x->SetValue(pivot.x()); + if (m_xf_pivot_y) m_xf_pivot_y->SetValue(pivot.y()); + if (m_xf_pivot_z) m_xf_pivot_z->SetValue(pivot.z()); + if (m_xf_dx) m_xf_dx->SetValue(0.0); + if (m_xf_dy) m_xf_dy->SetValue(0.0); + if (m_xf_dz) m_xf_dz->SetValue(0.0); + if (m_xf_angle) m_xf_angle->SetValue(0.0); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Drag the arrows to move, the rings to rotate — the numbers follow")); + m_status->Refresh(); +} + +// Color tool: open a colour picker on the selected body and store a per-body display-colour +// override on its CadBody. The override is carried across recompute() by body index and is +// read back by DesignCanvas::body_color()/reload(), so the body keeps its colour through edits. +void DesignPanel::on_set_body_color() +{ + // Same body-selection source Move / visibility use: the Parts-list row first, falling + // back to the in-canvas picked solid so either selection path works. + int b = tree_body_selection(); + if (b < 0) b = m_sel_solid_body; + if (b < 0 || b >= int(m_doc.bodies.size())) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Select a body first")); + m_status->Refresh(); + return; + } + + // Seed the picker with the body's current effective colour (override or auto palette). + const ColorRGBA cur = (m_viewport != nullptr) ? m_viewport->body_color(b) + : m_doc.bodies[b].color; + wxColourData data; + data.SetColour(wxColour(cur.r_uchar(), cur.g_uchar(), cur.b_uchar())); + wxColourDialog dlg(this, &data); + if (dlg.ShowModal() != wxID_OK) return; + + const wxColour picked = dlg.GetColourData().GetColour(); + m_doc.bodies[b].has_color = true; + m_doc.bodies[b].color = ColorRGBA((unsigned char)picked.Red(), (unsigned char)picked.Green(), + (unsigned char)picked.Blue(), (unsigned char)255); + feed_bodies(); // same refresh path the visibility toggle uses → viewport updates immediately + + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("Body %d colour set"), b + 1)); + m_status->Refresh(); +} + +// Prepare's "Place on Face" (F), ported to Design. Pick a body face, then this rotates the +// body so that face's outward normal points straight down (-Z) and drops it onto the bed — +// Orca's exact math (Selection::flattening_rotate). Writes the per-body display transform +// m_body_xform (baked into the mesh at Commit), like the Move gizmo; no shape mutation. +// Returns false (with a hint) when no body face is selected, so the F key can fall through. +bool DesignPanel::place_on_face() +{ + const int b = m_sel_solid_body; + if (b < 0 || b >= int(m_doc.bodies.size()) || m_sel_solid_face < 0 + || b >= int(m_doc.display_body_meshes.size())) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Click a face on the solid, then press F")); + m_status->Refresh(); + return false; + } + const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); + if (face.IsNull()) return false; + sync_body_xform(); + const Transform3d old_x = m_body_xform[b]; + // Outward face normal in the body's CURRENT displayed orientation. + const Vec3d n = (old_x.linear() * GeometryEngine::face_normal_world(face)).normalized(); + if (!n.allFinite() || n.norm() < 0.5) return false; + // Align that normal with the down vector (-Z): the face ends up on the bed. + const Transform3d R(Eigen::Quaterniond().setFromTwoVectors(n, -Vec3d::UnitZ())); + // Rotate about the body's current world centroid so it spins in place, not about the origin. + const Vec3d c = old_x * m_doc.display_body_meshes[b].bounding_box().center(); + Transform3d x = Eigen::Translation3d(c) * R * Eigen::Translation3d(-c) * old_x; + // Drop the re-oriented body so its lowest point sits on the bed (min Z -> 0). + TriangleMesh probe = m_doc.display_body_meshes[b]; + probe.transform(x); + x = Transform3d(Eigen::Translation3d(0.0, 0.0, -probe.bounding_box().min.z())) * x; + m_body_xform[b] = x; + set_status_ok(); // rebuild display/pick meshes, re-point picking; resets face selection + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Placed on face — body laid flat on the bed")); + m_status->Refresh(); + return true; +} + +int DesignPanel::tree_selection() const +{ + const wxTreeItemId sel = m_tree->GetSelection(); + if (!sel.IsOk()) return wxNOT_FOUND; + for (size_t i = 0; i < m_tree_items.size(); ++i) + if (m_tree_items[i] == sel) return int(i); + return wxNOT_FOUND; +} + +void DesignPanel::set_tree_selection(int row) +{ + if (row >= 0 && row < int(m_tree_items.size())) + m_tree->SelectItem(m_tree_items[row]); +} + +void DesignPanel::after_tree_edit(bool ok) +{ + update_undo_redo_buttons(); + refresh_tree(); + refresh_variables(); + if (!ok) { + // The edit was rolled back (recompute failed); the body is unchanged. + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Edit rejected: ") + wxString::FromUTF8(m_doc.error)); + m_status->Refresh(); + return; + } + sync_recipe_to_model(); // deletes, reorders and suppressions change the document too + m_status->SetForegroundColour(wxNullColour); + if (m_doc.display_mesh.its.indices.empty()) { + if (m_viewport != nullptr) m_viewport->clear_mesh(); + sync_sketch_display(); // empty body: show any un-consumed committed sketch + // An empty document used to blank this line — no guidance at the one moment a newcomer + // has none. Name the three real ways in, in the order they are reachable on screen. + set_status(idle_hint()); + } else { + // nde #19/20: a delete/reorder that leaves bodies behind must re-feed the per-body + // GLVolumes — otherwise the viewport keeps showing the pre-edit solid (the deleted + // feature's artifact lingered). feed_bodies() is idempotent for the edit/replace path. + if (m_viewport != nullptr) feed_bodies(); + set_status_ok(); + } + // Force a frame: under software GL (llvmpipe on the :10 test box) reload()'s scheduled + // Refresh() is dropped, so a deleted solid stayed on screen until the next orbit. + if (m_viewport != nullptr) m_viewport->request_repaint(); + m_status->Refresh(); +} + +// Erase the whole document (every feature + body) and start fresh. The single "wipe" the +// feature tree's per-row Delete can't give you — also the way out when a body has no +// removable owning feature. +void DesignPanel::on_new_design() +{ + if (m_doc.features.empty() && m_doc.bodies.empty()) { set_status_ok(); return; } + wxMessageDialog dlg(this, + _L("Erase all features and bodies and start a new design? This cannot be undone."), + _L("New Design"), wxYES_NO | wxICON_EXCLAMATION); + if (dlg.ShowModal() != wxID_YES) return; + clear_document(); +} + +// The teardown behind New Design, without the confirmation. Also what New Project / Open +// Project run through Plater::priv::reset: the document lives here rather than in the Model, +// so without this it survives the project that produced it and the next Design edit writes +// the previous project's feature tree into the new one. +void DesignPanel::clear_document() +{ + tool_cancel(); // leave any active tool / sketch / constrain cleanly + m_doc.clear(); // features + bodies + meshes + history + m_edit_index = -1; + m_move_body = -1; + show_move_card(false); + m_body_xform.clear(); + if (m_viewport) { m_viewport->clear_move_gizmo(); m_viewport->clear_mesh(); } + after_tree_edit(true); // rebuild the (now empty) tree + clear the viewport + update_action_bar(); + set_status_ok(); +} + +// The verb the offer names when you point at a body, or at any face/edge/vertex of one. A body +// is a recomputed RESULT, so what actually gets deleted is the feature that created it +// (CadBody::source_feature). That is an edit to the recipe and can take other features with it, +// so it asks first and NAMES the feature: a body disappearing from the viewport is not by itself +// evidence of which feature went, and this is the one action here that cannot be eyeballed. +void DesignPanel::on_delete_body() +{ + const int nb = int(m_doc.bodies.size()); + if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Select a body first")); + m_status->Refresh(); + return; + } + const int src = m_doc.bodies[m_sel_solid_body].source_feature; + if (src < 0 || src >= int(m_doc.features.size())) { + // Only reachable for a body no feature claims — a stale recipe, or a feature type that + // broke the "never replace a whole CadBody" invariant recompute() relies on. + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("This body has no feature to delete — use New Design to start over")); + m_status->Refresh(); + return; + } + const std::string& raw = m_doc.features[src].name; + const wxString fname = raw.empty() ? wxString::Format(_L("feature %d"), src + 1) + : wxString::FromUTF8(raw); + if (wxMessageBox(wxString::Format( + _L("Delete %s?\n\nThat is the feature this body was made from. " + "Features built on it may be removed or stop working."), fname), + _L("Delete body"), wxYES_NO | wxNO_DEFAULT | wxICON_QUESTION, this) != wxYES) + return; + // A card left open over a feature that is about to vanish goes stale — same reason + // on_delete_feature() closes it. + if (m_active != Tool::None || m_edit_index >= 0) { + reset_edit_state(); + close_tool(); + } + m_doc.checkpoint(); // undo boundary: deleting a body's feature + m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; // the selection is about to + m_sel_solid_vertex = false; // name a body that is gone + after_tree_edit(m_doc.remove_feature(src)); +} + +void DesignPanel::on_delete_feature() +{ + // A Body row has no directly-removable feature (bodies are recomputed results); guide the + // user to delete the feature that created it, or use New Design to wipe everything. + if (tree_body_selection() >= 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Select the FEATURE that created this body (or use New Design)")); + m_status->Refresh(); + return; + } + int sel = tree_selection(); + if (sel == wxNOT_FOUND) { + set_status(_L("Select a feature in the tree first")); + m_status->Refresh(); + return; + } + // If a feature dialog is open (e.g. the feature is being edited), dismiss it first — + // otherwise the deleted feature's settings card lingers in the left panel, out of sync + // with the tree. reset_edit_state() drops the stale m_edit_index; close_tool() hides the card. + if (m_active != Tool::None || m_edit_index >= 0) { + reset_edit_state(); + close_tool(); + } + m_doc.checkpoint(); // undo boundary: deleting a feature + after_tree_edit(m_doc.remove_feature(sel)); +} + +void DesignPanel::on_toggle_visibility() +{ + // A selected Body row toggles that body's visibility (per-body show/hide). The solid + // stays in the document; only its GLVolume + pickability flip. Falls through to the + // feature-level toggle below when a feature row (not a body row) is selected. + const int bsel = tree_body_selection(); + if (bsel >= 0) { + sync_body_visible(); + if (bsel < int(m_body_visible.size())) { + const bool now_visible = !m_body_visible[bsel]; + m_body_visible[bsel] = now_visible; + if (m_viewport != nullptr) { + feed_bodies(); // flips is_active; m_solid_visible is a stable pointer (live) + m_viewport->set_solid_pick(&m_doc.bodies, &m_disp_pick_mesh, + &m_doc.display_tri_face, &m_doc.display_tri_body, + &m_body_visible, &m_body_xform); + } + refresh_tree(); + // Keep the row selected for repeat toggles. m_parts, NOT m_tree: these ids belong + // to the Bodies list, and handing a foreign item to the feature tree left the row + // unselected — so the second press of the eye found tree_body_selection() == -1 and + // fell through to the FEATURE-level branch below instead of un-hiding the body. + if (m_parts != nullptr && bsel < int(m_tree_body_items.size())) + m_parts->SelectItem(m_tree_body_items[bsel]); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(now_visible ? _L("Body %d shown") + : _L("Body %d hidden"), bsel + 1)); + m_status->Refresh(); + } + return; + } + + int sel = tree_selection(); + if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Select a feature in the tree first")); + m_status->Refresh(); + return; + } + const bool shown = !m_doc.features[sel].enabled; + m_doc.features[sel].enabled = shown; + + // recompute() reports an all-hidden / sketch-only document as false (no + // solid to build), but that is a VALID state for hide — so clear the body + // explicitly instead of letting after_tree_edit treat it as a rejected edit + // (which would skip the overlay refresh, leaving hidden art on screen). + if (!recompute_guarded(_L("Rebuilding model…"))) { + m_doc.body = TopoDS_Shape(); + m_doc.display_mesh = TriangleMesh{}; + m_doc.error.clear(); + } + refresh_tree(); // greys the row + set_tree_selection(sel); // keep the toggled feature selected + if (m_viewport != nullptr) { + if (m_doc.display_mesh.its.indices.empty()) m_viewport->clear_mesh(); + else feed_bodies(); + } + sync_sketch_display(); // skips the hidden sketch + direct-renders + m_status->SetForegroundColour(wxNullColour); + set_status(shown ? _L("Feature shown") : _L("Feature hidden")); + m_status->Refresh(); +} + +void DesignPanel::on_move_feature(int delta) +{ + int sel = tree_selection(); + if (sel == wxNOT_FOUND) { + set_status(_L("Select a feature in the tree first")); + m_status->Refresh(); + return; + } + int target = sel + delta; + if (target < 0 || target >= int(m_doc.features.size())) + return; // already at the end + m_doc.checkpoint(); // undo boundary: reordering a feature + if (m_doc.move_feature(sel, delta)) { + after_tree_edit(true); + set_tree_selection(target); // keep the moved feature selected + } else { + after_tree_edit(false); + } +} + +// Commit the live sketch in place, then enter Constrain mode on the just-committed sketch. +// One-click bridge from the SKETCH toolbar: removes the "Finish -> find in tree -> select -> +// Constrain" friction, so the constraint palette + Trim/Extend are reachable mid-sketch. +bool DesignPanel::enter_constrain_inline() +{ + if (m_viewport && m_viewport->is_sketching()) + m_viewport->finish_sketch(); // synchronous: packages live entities+constraints -> Sketch + const int sk = resolve_extrude_sketch(); // last/selected Sketch feature + if (sk < 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Draw a sketch first, then Constrain")); + m_status->Refresh(); + return false; + } + set_tree_selection(sk); // tree drives on_begin_constrain / the constraint manager + on_begin_constrain(sk); + const bool entered = m_viewport && + (m_viewport->is_constraining() || m_viewport->is_constraining_entities()); + if (entered) set_ui_mode(UiMode::Constrain); + return entered; +} + +void DesignPanel::on_begin_constrain(int sel_override) +{ + int sel = (sel_override >= 0) ? sel_override : tree_selection(); + // Fall back to the sketch owning the region picked in the viewport. The offer menu reaches + // this verb from a SkLoop selection (a region clicked on screen), which carries no tree + // selection — without this, choosing "Constrain sketch" from the offer would answer + // "Select a sketch in the tree first" about a sketch the user has visibly selected. + if ((sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) && m_sel_sketch_feat >= 0 + && m_sel_sketch_feat < int(m_doc.features.size())) { + sel = m_sel_sketch_feat; + set_tree_selection(sel); // keep the tree in step with what the viewport says + } + if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Select a sketch in the tree first")); + m_status->Refresh(); + return; + } + CadFeature& f = m_doc.features[sel]; + if (f.type != CadFeatureType::Sketch) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Selected feature is not a sketch")); + m_status->Refresh(); + return; + } + + // Entity sketches (Fase 4.2): pick Line entities; constraints solve against + // entity endpoints in the kernel. + if (!f.entities.empty()) { + m_constrain_feat = sel; + if (m_viewport) m_viewport->begin_constrain_entities(f.entities, f.plane); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Pick 1-2 lines, then a constraint; right-click exits")); + m_status->Refresh(); + return; + } + + // Legacy profile path (Fase 3). + if (f.profile.points.size() < 3) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Selected feature is not a sketch")); + m_status->Refresh(); + return; + } + m_constrain_feat = sel; + // Anchor the first profile point so H/V constraints don't let the sketch + // float freely; fix_point captures the point's current position in the solver. + if (f.constraints.empty()) + f.constraints.push_back(SketchConstraintDef{SketchConstraintType::Fix, 0, -1, -1, -1, 0.0}); + if (m_viewport) m_viewport->begin_constrain(f.profile, f.plane); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Pick 1-2 entities, then a constraint or dimension; right-click exits")); + m_status->Refresh(); +} + +// Why an entity-constraint pick was refused, as a localized string. The kernel's +// ConstraintReject is coarse on purpose (one reason covers several constraint types whose +// BUTTONS wear different words), so `type` disambiguates the wording without touching the +// kernel. Reuses today's exact strings so nothing regresses for a user or the ladder. +static wxString constraint_reject_text(ConstraintReject reason, SketchConstraintType type) +{ + using T = SketchConstraintType; + switch (reason) { + case ConstraintReject::NeedOneEntity: return _L("Pick an entity first"); + case ConstraintReject::NeedTwoEntities: return _L("Pick two entities first"); + case ConstraintReject::NeedALine: return _L("Horizontal and Vertical apply to a line"); + case ConstraintReject::NeedTwoLines: + if (type == T::Angle) return _L("Angle applies between two lines"); + if (type == T::Collinear) return _L("Collinear needs two lines"); + return _L("Parallel, perpendicular and equal length apply to two lines"); + case ConstraintReject::NeedTwoRounds: + if (type == T::EqualRadius) return _L("Equal radius needs two circles or arcs"); + return _L("Concentric needs two circles or arcs"); + case ConstraintReject::NeedTangentPair: return _L("Tangent needs a line and a circle/arc, or two circles/arcs"); + case ConstraintReject::NeedJoinablePoints: return _L("This constraint needs two entities with a point to join"); + case ConstraintReject::NeedMeasurablePoints: return _L("This dimension needs two entities with a point to measure between"); + case ConstraintReject::NeedPointAndLine: return _L("Midpoint needs a point and a line"); + case ConstraintReject::NeedTwoPointsOrLines: + if (type == T::Symmetric) return _L("Symmetric needs two points or two lines + an axis"); + return _L("Symmetric needs two points or two lines"); + case ConstraintReject::NeedAxisLine: return _L("Symmetric: pick two entities, then an axis line"); + case ConstraintReject::NeedRound: return _L("Radius/Diameter needs a circle or arc"); + case ConstraintReject::Unsupported: return _L("Unsupported constraint"); + case ConstraintReject::None: + default: return wxString(); + } +} + +// Write the typed value into every planned def. The planner returns the prefill in DISPLAY +// units and leaves def.value = 0; Angle is the one type whose stored value is not the number +// the user sees (it is radians), so the conversion lives here, exactly as the old +// apply_entity_constraint did on its Angle branch. +static void write_plan_value(std::vector& defs, double v) +{ + for (auto& d : defs) + d.value = (d.type == SketchConstraintType::Angle) ? v * M_PI / 180.0 : v; +} + +void DesignPanel::apply_entity_constraint(SketchConstraintType type) +{ + int e0 = -1, e1 = -1; + m_viewport->selected_constrain_entities(e0, e1); + const int e2 = m_viewport->selected_constrain_axis(); // Symmetric axis pick, -1 otherwise + + CadFeature& feat = m_doc.features[m_constrain_feat]; + const ConstraintPlan plan = plan_entity_constraint(feat.entities, e0, e1, e2, type); + + auto fail = [this](const wxString& msg) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(msg); + m_status->Refresh(); + }; + + switch (plan.kind) { + case ConstraintPlan::Kind::Reject: + fail(constraint_reject_text(plan.reason, type)); + return; + case ConstraintPlan::Kind::AskValue: + m_viewport->open_inline_value(plan.prefill, [this, plan](double v) { + std::vector defs = plan.defs; + write_plan_value(defs, v); + commit_entity_constraints(defs); + }); + return; // deferred: commit runs on the typed value + case ConstraintPlan::Kind::Apply: + commit_entity_constraints(plan.defs); + return; + } +} + +void DesignPanel::apply_live_constraint(SketchConstraintType type) +{ + // The in-session selection is the pick: first three indices, e2 being the axis Symmetric + // needs. Fewer than the type needs are left at -1 so plan_entity_constraint — the ONE + // place that knows how many picks a type takes — rejects them rather than this site + // guessing. Known limitation (comment, not solved): a constraint added to a LIVE sketch + // is not on the document undo stack — that stack holds committed features — so Ctrl+Z + // will not take it back until the sketch is committed. + const std::vector& sel = m_viewport->sketch_selection(); + const int e0 = sel.size() > 0 ? sel[0] : -1; + const int e1 = sel.size() > 1 ? sel[1] : -1; + const int e2 = sel.size() > 2 ? sel[2] : -1; + + const ConstraintPlan plan = plan_entity_constraint( + m_viewport->sketch_entities(), e0, e1, e2, type); + + auto fail = [this](const wxString& msg) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(msg); + m_status->Refresh(); + }; + // Shared commit: try_add_constraints appends→solves→keeps-or-rolls-back and leaves the + // geometry untouched on failure, so the same over-constrained message the committed path + // uses is the correct failure report here too. + auto commit = [this, fail](std::vector defs, double v) { + write_plan_value(defs, v); + if (!m_viewport->try_add_sketch_constraints(defs)) { + fail(_L("Constraint rejected (over-constrained)")); + return; + } + m_viewport->request_repaint(); + m_status->SetForegroundColour(wxNullColour); + // Say where it went and how to undo it, here at the moment of applying: the hint line + // only refreshes when the step tuple changes, which applying a constraint does not. + set_status(_L("Applied constraint · its badge is on the sketch — click the badge to remove it")); + m_status->Refresh(); + }; + + switch (plan.kind) { + case ConstraintPlan::Kind::Reject: + // "I applied Parallel and NOTHING HAPPENS" is this branch. The text named the + // REQUIREMENT ("applies to two lines") but never the CURRENT PICK, and a creation + // tool auto-selects only the entity it just drew — so after drawing two lines exactly + // one is selected and the button correctly refuses. Say how many are picked, or the + // refusal is indistinguishable from a dead button. + fail(wxString::Format(_L("%s — %d selected. Press Esc for the Select tool, click one " + "line, then Shift- or Ctrl-click the other."), + constraint_reject_text(plan.reason, type), int(sel.size()))); + return; + case ConstraintPlan::Kind::AskValue: + m_viewport->open_inline_value(plan.prefill, [this, plan, commit](double v) { + commit(plan.defs, v); + }); + return; + case ConstraintPlan::Kind::Apply: + commit(plan.defs, 0.0); + return; + } +} + +void DesignPanel::commit_entity_constraints(const std::vector& defs) +{ + if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || + !m_viewport || defs.empty()) + return; + CadFeature& feat = m_doc.features[m_constrain_feat]; + // solve_sketch_feature rewrites entity coords even on failure, so snapshot + // to roll back a rejected (over-constrained) addition cleanly. Multiple defs + // (Symmetric on two lines) must solve together, so push all then resize back. + const std::vector saved = feat.entities; + const size_t before = feat.entity_constraints.size(); + // Undo boundary: adding a constraint. Without it Ctrl+Z reached PAST this edit to the + // previous boundary and threw away whatever happened in between — a constraint was the + // one document mutation the user could not take back on its own. + m_doc.checkpoint(); + for (const auto& d : defs) feat.entity_constraints.push_back(d); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) { + feat.entity_constraints.resize(before); + feat.entities = saved; + m_doc.abandon_checkpoint(); // fully restored above: nothing happened, so nothing to undo + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Constraint rejected (over-constrained)")); + m_status->Refresh(); + return; + } + m_doc.recompute(); + // The constraint lives in the recipe, so the save path has to be told the recipe moved; + // otherwise saving after a constraint edit wrote the blob from before it. + sync_recipe_to_model(); + update_undo_redo_buttons(); + m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities); + if (!m_doc.display_mesh.its.indices.empty()) + feed_bodies(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Applied constraint")); + m_status->Refresh(); + + refresh_constrain_dof(); // P3 DoF readout for the Constrain path + rebuild_constraint_list(); // C3.4 manager: a row appeared +} + +// Re-derive the solve state of the constrained feature and mirror it into the same +// DoF readout the in-session path uses, so "✓ Fully constrained" is reachable here. +void DesignPanel::refresh_constrain_dof() +{ + if (!m_dof_status || m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size())) + return; + const CadFeature& feat = m_doc.features[m_constrain_feat]; + std::vector ents = feat.entities; // already solved; re-solve is a cheap no-op + const SketchSolveResult r = sketch_solve(ents, feat.entity_constraints); + if (!r.ok) { + m_dof_status->SetForegroundColour(wxColour(235, 80, 80)); + m_dof_status->SetLabel(_L("✗ Conflicting constraints")); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty()); + } else if (r.dof == 0) { + m_dof_status->SetForegroundColour(wxColour(80, 200, 110)); + m_dof_status->SetLabel(_L("✓ Fully constrained")); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty()); + } else if (r.dof > 0) { + m_dof_status->SetForegroundColour(dp_ctl_text()); + m_dof_status->SetLabel(wxString::Format(_L("%d degrees of freedom"), r.dof)); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty()); + } else { + m_dof_status->SetLabel(wxString()); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty()); + } + m_dof_status->Refresh(); + update_cards_frame(); m_form->Layout(); +} + +// Human-readable label for a constraint row, e.g. "Coincident L0·P1 — L1·P0", +// "Horizontal L2", "Radius C3 = 7.00". Entities are tagged by type letter + index. +wxString DesignPanel::constraint_label(const SketchEntityConstraintDef& d) const +{ + using T = SketchConstraintType; + const CadFeature* feat = (m_constrain_feat >= 0 && m_constrain_feat < int(m_doc.features.size())) + ? &m_doc.features[m_constrain_feat] : nullptr; + auto tag = [&](int ei, SketchPointRole r) -> wxString { + if (ei < 0) return wxString(); + char c = 'E'; + if (feat && ei < int(feat->entities.size())) { + switch (feat->entities[ei].type) { + case SketchEntity::Type::Line: c = 'L'; break; + case SketchEntity::Type::Circle: c = 'C'; break; + case SketchEntity::Type::Arc: c = 'A'; break; + case SketchEntity::Type::Point: c = 'P'; break; + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: c = 'E'; break; + case SketchEntity::Type::BSpline: c = 'B'; break; + } + } + wxString s; s << wxUniChar(c) << ei; // avoid %c assert in Unicode build + if (r == SketchPointRole::P1) s += "·P1"; + else if (r == SketchPointRole::Center) s += "·Ctr"; + else if (r == SketchPointRole::P0) s += "·P0"; + return s; + }; + auto two = [&](const wxString& name) { + return d.eb >= 0 ? wxString::Format("%s %s — %s", name, tag(d.ea, d.ra), tag(d.eb, d.rb)) + : wxString::Format("%s %s", name, tag(d.ea, d.ra)); + }; + switch (d.type) { + case T::Fix: return wxString::Format(_L("Fix %s"), tag(d.ea, d.ra)); + case T::Coincident: return two(_L("Coincident")); + case T::Horizontal: return two(_L("Horizontal")); + case T::Vertical: return two(_L("Vertical")); + case T::Distance: return wxString::Format("%s = %s", two(_L("Distance")), en_format(d.value)); + case T::LockX: return wxString::Format(_L("Lock X %s"), tag(d.ea, d.ra)); + case T::LockY: return wxString::Format(_L("Lock Y %s"), tag(d.ea, d.ra)); + case T::EqualLength: return two(_L("Equal")); + case T::Parallel: return two(_L("Parallel")); + case T::Perpendicular: return two(_L("Perpendicular")); + case T::Concentric: return two(_L("Concentric")); + case T::Tangent: return two(_L("Tangent")); + case T::Midpoint: return two(_L("Midpoint")); + case T::Symmetric: return wxString::Format(_L("Symmetric %s — %s / %s"), + tag(d.ea, d.ra), tag(d.eb, d.rb), tag(d.ec, d.rc)); + case T::SymmetricAboutY: return wxString::Format(_L("Symmetric about Y axis %s — %s"), + tag(d.ea, d.ra), tag(d.eb, d.rb)); + case T::SymmetricAboutX: return wxString::Format(_L("Symmetric about X axis %s — %s"), + tag(d.ea, d.ra), tag(d.eb, d.rb)); + case T::Angle: return wxString::Format("%s = %s°", two(_L("Angle")), en_format(d.value * 180.0 / M_PI, 1)); + case T::Radius: return wxString::Format("%s %s = %s", _L("Radius"), tag(d.ea, d.ra), en_format(d.value)); + case T::Diameter: return wxString::Format("%s %s = %s", _L("Diameter"), tag(d.ea, d.ra), en_format(d.value)); + case T::PointOnLine: return two(_L("On line")); + case T::PointOnObject: return two(_L("On edge")); + } + return _L("Constraint"); +} + +bool DesignPanel::live_constraint_scope() const +{ + return m_viewport != nullptr && m_viewport->is_sketching() && + !m_viewport->is_constraining() && !m_viewport->is_constraining_entities(); +} + +// Rebuild the constraint-row list. Source depends on scope: a LIVE sketch session owns its +// constraints inside the sketch tool and has no committed feature yet, so the list read only +// the feature's and stayed empty for the whole session — constraints applied while drawing had +// no row, no ✗, and no name anywhere in the UI. +void DesignPanel::rebuild_constraint_list() +{ + if (m_constraint_rows == nullptr || m_form == nullptr) + return; + m_constraint_rows->Clear(true /* delete windows */); + m_constraint_sel = -1; + + const bool live = live_constraint_scope(); + const bool active = (m_constrain_feat >= 0 && m_constrain_feat < int(m_doc.features.size())); + const std::vector empty; + const std::vector& cons = + live ? m_viewport->sketch_constraints() + : active ? m_doc.features[m_constrain_feat].entity_constraints : empty; + + if (m_hdr_constraints) + m_hdr_constraints->SetLabel(wxString::Format(_L("Constraints (%d)"), int(cons.size()))); + + if (cons.empty()) { + auto* none = new wxStaticText(m_cards, wxID_ANY, _L("No constraints yet")); + none->SetForegroundColour(dp_sec_text()); + m_constraint_rows->Add(none, 0, wxTOP, 4); + } + for (int i = 0; i < int(cons.size()); ++i) { + auto* row = new wxBoxSizer(wxHORIZONTAL); + // Delete button first (fixed left position, always visible — long labels can + // horizontally scroll but ✗ stays put and clickable). BMP-safe ✗ glyph. + auto* del = new wxButton(m_cards, wxID_ANY, wxString::FromUTF8("✗"), + wxDefaultPosition, wxSize(26, -1)); + del->SetToolTip(_L("Delete constraint")); + del->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { delete_constraint(i); }); + // Clickable label: selecting it highlights the referenced entities. + auto* lbl = new wxButton(m_cards, wxID_ANY, constraint_label(cons[i]), + wxDefaultPosition, wxDefaultSize, wxBU_LEFT | wxBORDER_NONE); + lbl->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { highlight_constraint_entities(i); }); + row->Add(del, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + row->Add(lbl, 1, wxALIGN_CENTER_VERTICAL); + m_constraint_rows->Add(row, 0, wxEXPAND | wxTOP, 2); + } + + // Feed the same list to the viewport for the on-sketch glyph badges (C3.4b). + if (m_viewport) + m_viewport->set_constraint_glyphs(cons); + + m_cards->GetSizer()->Show(m_box_constraints, + m_ui_mode == UiMode::Constrain || live, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); +} + +// Push the entities referenced by constraint `idx` to the viewport as a yellow +// highlight (toggle off if the same row is clicked again). +void DesignPanel::highlight_constraint_entities(int idx) +{ + if (!m_viewport) + return; + const bool live = live_constraint_scope(); + if (!live && (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()))) + return; + const auto& cons = live ? m_viewport->sketch_constraints() + : m_doc.features[m_constrain_feat].entity_constraints; + if (idx < 0 || idx >= int(cons.size())) + return; + if (m_constraint_sel == idx) { // second click clears + m_constraint_sel = -1; + m_viewport->set_constraint_highlight({}); + return; + } + m_constraint_sel = idx; + const SketchEntityConstraintDef& d = cons[idx]; + std::vector ents; + for (int e : { d.ea, d.eb, d.ec }) + if (e >= 0) ents.push_back(e); + m_viewport->set_constraint_highlight(std::move(ents)); +} + +// Drop constraint `idx`, re-solve the feature, and refresh viewport + list + DoF. +void DesignPanel::delete_constraint(int idx) +{ + // Live session: the constraint lives in the sketch tool, not in any feature. Removing it + // re-solves and fires on_constraints_changed, which rebuilds these rows — so this branch + // deliberately does NOT call rebuild_constraint_list() itself (it would run twice, and the + // second run would delete the wxButton whose click handler is still on the stack). + if (live_constraint_scope()) { + if (!m_viewport->remove_sketch_constraint(idx)) + return; + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Constraint deleted")); + m_status->Refresh(); + return; + } + if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport) + return; + CadFeature& feat = m_doc.features[m_constrain_feat]; + if (idx < 0 || idx >= int(feat.entity_constraints.size())) + return; + m_doc.checkpoint(); // undo boundary: deleting a constraint + feat.entity_constraints.erase(feat.entity_constraints.begin() + idx); + // Re-solve the remaining system (deleting a constraint can only free DoF, so it + // cannot fail for over-constraint; ignore the bool and refresh either way). + m_doc.solve_sketch_feature(m_constrain_feat); + m_doc.recompute(); + sync_recipe_to_model(); // the removal is part of the recipe + update_undo_redo_buttons(); + m_viewport->set_constraint_highlight({}); + m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities); + if (!m_doc.display_mesh.its.indices.empty()) + feed_bodies(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Constraint deleted")); + m_status->Refresh(); + refresh_constrain_dof(); + rebuild_constraint_list(); +} + +void DesignPanel::apply_edit_op(EditOp op) +{ + if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport || + !m_viewport->is_constraining_entities()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Press Constrain on a sketch first")); + m_status->Refresh(); + return; + } + auto fail = [this](const wxString& msg) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(msg); + m_status->Refresh(); + }; + + int e0 = -1, e1 = -1; + m_viewport->selected_constrain_entities(e0, e1); + CadFeature& feat = m_doc.features[m_constrain_feat]; + const int n = int(feat.entities.size()); + if (e0 < 0 || e0 >= n) { fail(_L("Pick an entity first")); return; } + using Type = SketchEntity::Type; + + switch (op) { + case EditOp::Mirror: { + if (e1 < 0 || e1 >= n) { fail(_L("Pick the entity, then a mirror-axis line")); return; } + const SketchEntity& axis = feat.entities[e1]; + if (axis.type != Type::Line) { fail(_L("Mirror axis must be a line")); return; } + auto out = SketchEngine::mirror_entities({ feat.entities[e0] }, axis.p0, axis.p1); + if (out.empty()) { fail(_L("Mirror produced nothing")); return; } + const int mi = n; // index the single mirrored copy lands at (n entities before push) + for (auto& m : out) feat.entities.push_back(m); + + // C4a: bind the mirror to its source with Symmetric constraints about the + // axis, so the pair stays mirror-symmetric under later solves and drags. + // mirror_entities preserves P0/P1/Center ordering, so the constraints are + // satisfied by construction; if the solver still rejects them (degenerate + // axis, redundancy) keep the geometry and drop only the binding. + { + using R = SketchPointRole; + using CT = SketchConstraintType; + const std::vector saved_ents = feat.entities; + const size_t cons_before = feat.entity_constraints.size(); + SketchEntityConstraintDef d; d.type = CT::Symmetric; d.ea = e0; d.eb = mi; d.ec = e1; + const Type st = feat.entities[e0].type; + if (st == Type::Line) { + d.ra = R::P0; d.rb = R::P0; feat.entity_constraints.push_back(d); + d.ra = R::P1; d.rb = R::P1; feat.entity_constraints.push_back(d); + } else if (st == Type::Arc || st == Type::Circle) { + d.ra = R::Center; d.rb = R::Center; feat.entity_constraints.push_back(d); + } else if (st == Type::Point) { + d.ra = R::P0; d.rb = R::P0; feat.entity_constraints.push_back(d); + } + if (feat.entity_constraints.size() != cons_before && + !m_doc.solve_sketch_feature(m_constrain_feat)) { + feat.entity_constraints.resize(cons_before); + feat.entities = saved_ents; + } + } + break; + } + case EditOp::Offset: { + const int a = e0; + request_value(_L("Offset distance (+left / -right of direction)"), 1.0, -100000.0, 100000.0, + [this, a](double d) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size())) return; + auto out = SketchEngine::offset_entities({ f.entities[a] }, d); + if (out.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Offset collapsed the entity")); m_status->Refresh(); return; + } + const int ni = int(f.entities.size()); // offset copy lands here + for (auto& o : out) f.entities.push_back(o); + + // C4c: bind the offset copy to its source. Offset only ADDS geometry + // (the source is untouched), so unlike trim/fillet there are no stale + // constraints to drop — just glue the pair. A line offset stays + // Parallel to its source; an arc/circle offset stays Concentric (same + // centre). Single constraint, so no degradation ladder; solve and roll + // the binding back if the solver rejects it (keep the geometry). + { + using CT = SketchConstraintType; + const Type st = f.entities[a].type; + SketchEntityConstraintDef d2; d2.ea = a; d2.eb = ni; + bool emit = true; + if (st == Type::Line) d2.type = CT::Parallel; + else if (st == Type::Arc || st == Type::Circle) d2.type = CT::Concentric; + else emit = false; + if (emit) { + const size_t cbefore = f.entity_constraints.size(); + f.entity_constraints.push_back(d2); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) + f.entity_constraints.resize(cbefore); + } + } + after_edit_op(); + }); + return; // deferred: edit runs on Confirm + } + case EditOp::Fillet: { + if (e1 < 0 || e1 >= n) { fail(_L("Pick two lines to fillet")); return; } + if (feat.entities[e0].type != Type::Line || feat.entities[e1].type != Type::Line) { + fail(_L("Fillet needs two lines")); return; + } + const int a = e0, b = e1; + request_value(_L("Fillet radius"), 1.0, 0.001, 100000.0, [this, a, b](double r) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size()) || b >= int(f.entities.size())) return; + SketchEntity a_out, b_out, arc_out; + if (!SketchEngine::fillet_lines(f.entities[a], f.entities[b], r, a_out, b_out, arc_out)) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Fillet failed (parallel lines or radius too large)")); + m_status->Refresh(); return; + } + f.entities[a] = a_out; + f.entities[b] = b_out; + const int arc = int(f.entities.size()); + f.entities.push_back(arc_out); + + // C4b: glue the fillet arc to the two trimmed lines so it survives a + // re-solve instead of floating free. arc.p0 sits on line a's moved + // endpoint, arc.p1 on line b's; recover the exact endpoint roles by + // nearest match, then emit Coincident (essential — keeps the corner + // joined) + Tangent (smoothness). The full set can be redundant for the + // arc, so try it first and drop tangents progressively until the solver + // accepts it; the Coincident pins survive even if tangency is rejected. + { + using R = SketchPointRole; + using CT = SketchConstraintType; + auto role_near = [](const SketchEntity& ln, const Vec2d& p) -> R { + return ((ln.p0 - p).squaredNorm() <= (ln.p1 - p).squaredNorm()) ? R::P0 : R::P1; + }; + const R ra = role_near(f.entities[a], arc_out.p0); + const R rb = role_near(f.entities[b], arc_out.p1); + + // Fillet trims both lines back from the shared corner, so any + // constraint anchored to a trimmed endpoint is now stale: the corner + // Coincident that joined (a,ra)·(b,rb), and each line's own length + // Distance (its length just changed). Drop them before re-binding — + // leaving them would fight the new arc geometry and reject every + // binding below. + auto refs = [](const SketchEntityConstraintDef& d, int e, R r) { + return (d.ea == e && d.ra == r) || (d.eb == e && d.rb == r); + }; + auto self_len = [](const SketchEntityConstraintDef& d, int e) { + return d.type == CT::Distance && d.ea == e && d.eb == e; + }; + auto& cs = f.entity_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [&](const SketchEntityConstraintDef& d) { + return (d.type == CT::Coincident && refs(d, a, ra) && refs(d, b, rb)) + || self_len(d, a) || self_len(d, b); + }), cs.end()); + + auto coin = [&](R arc_role, int ln, R ln_role) { + SketchEntityConstraintDef d; d.type = CT::Coincident; + d.ea = arc; d.ra = arc_role; d.eb = ln; d.rb = ln_role; return d; + }; + auto tang = [&](int ln) { + SketchEntityConstraintDef d; d.type = CT::Tangent; d.ea = arc; d.eb = ln; return d; + }; + const std::vector> ladder = { + { coin(R::P0, a, ra), coin(R::P1, b, rb), tang(a), tang(b) }, + { coin(R::P0, a, ra), coin(R::P1, b, rb), tang(a) }, + { coin(R::P0, a, ra), coin(R::P1, b, rb) }, + }; + const std::vector saved = f.entities; + const size_t cbefore = f.entity_constraints.size(); + for (const auto& set : ladder) { + for (const auto& d : set) f.entity_constraints.push_back(d); + if (m_doc.solve_sketch_feature(m_constrain_feat)) break; // accepted + f.entity_constraints.resize(cbefore); + f.entities = saved; + } + } + after_edit_op(); + }); + return; // deferred: edit runs on Confirm + } + + case EditOp::Chamfer: { + if (e1 < 0 || e1 >= n) { fail(_L("Pick two lines to chamfer")); return; } + if (feat.entities[e0].type != Type::Line || feat.entities[e1].type != Type::Line) { + fail(_L("Chamfer needs two lines")); return; + } + const int a = e0, b = e1; + request_value(_L("Chamfer distance"), 1.0, 0.001, 100000.0, [this, a, b](double d) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size()) || b >= int(f.entities.size())) return; + SketchEntity a_out, b_out, seg_out; + if (!SketchEngine::chamfer_lines(f.entities[a], f.entities[b], d, a_out, b_out, seg_out)) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Chamfer failed (parallel lines or distance too large)")); + m_status->Refresh(); return; + } + f.entities[a] = a_out; + f.entities[b] = b_out; + const int seg = int(f.entities.size()); + f.entities.push_back(seg_out); + + // C4.6: like fillet, chamfer trims both lines back from the shared corner + // and inserts a connecting segment. MUTATING op: drop the stale corner + // Coincident that joined the two trimmed endpoints and each line's own + // length Distance (lengths just changed), then pin the new segment's ends + // onto the trimmed line endpoints with Coincident so it survives re-solve. + { + using R = SketchPointRole; + using CT = SketchConstraintType; + auto role_near = [](const SketchEntity& ln, const Vec2d& p) -> R { + return ((ln.p0 - p).squaredNorm() <= (ln.p1 - p).squaredNorm()) ? R::P0 : R::P1; + }; + const R ra = role_near(f.entities[a], seg_out.p0); + const R rb = role_near(f.entities[b], seg_out.p1); + + auto refs = [](const SketchEntityConstraintDef& dd, int e, R r) { + return (dd.ea == e && dd.ra == r) || (dd.eb == e && dd.rb == r); + }; + auto self_len = [](const SketchEntityConstraintDef& dd, int e) { + return dd.type == CT::Distance && dd.ea == e && dd.eb == e; + }; + auto& cs = f.entity_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [&](const SketchEntityConstraintDef& dd) { + return (dd.type == CT::Coincident && refs(dd, a, ra) && refs(dd, b, rb)) + || self_len(dd, a) || self_len(dd, b); + }), cs.end()); + + auto coin = [&](R seg_role, int ln, R ln_role) { + SketchEntityConstraintDef dd; dd.type = CT::Coincident; + dd.ea = seg; dd.ra = seg_role; dd.eb = ln; dd.rb = ln_role; return dd; + }; + const std::vector saved = f.entities; + const size_t cbefore = f.entity_constraints.size(); + f.entity_constraints.push_back(coin(R::P0, a, ra)); + f.entity_constraints.push_back(coin(R::P1, b, rb)); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) { + f.entity_constraints.resize(cbefore); // keep geometry, drop pins + f.entities = saved; + } + } + after_edit_op(); + }); + return; // deferred: edit runs on Confirm + } + + case EditOp::Trim: + case EditOp::Extend: { + // Trim accepts Line/Arc/Circle subjects; Extend accepts Line/Arc (a Circle + // is already closed, so there is nothing to extend). + const Type st = feat.entities[e0].type; + const bool subject_ok = (op == EditOp::Trim) + ? (st == Type::Line || st == Type::Arc || st == Type::Circle) + : (st == Type::Line || st == Type::Arc); + if (!subject_ok) { + fail(op == EditOp::Trim ? _L("Trim works on lines, arcs and circles") + : _L("Extend works on lines and arcs")); + return; + } + Vec2d pick; + if (!m_viewport->pick0_point(pick)) { fail(_L("Pick the edge to trim/extend")); return; } + std::vector others; + others.reserve(n > 0 ? n - 1 : 0); + for (int i = 0; i < n; ++i) + if (i != e0) others.push_back(feat.entities[i]); + const SketchEntity before = feat.entities[e0]; // C4.1: detect the moved endpoint + const bool ok = (op == EditOp::Trim) + ? SketchEngine::trim_entity(feat.entities[e0], others, pick) + : SketchEngine::extend_entity(feat.entities[e0], others, pick); + if (!ok) { fail(op == EditOp::Trim ? _L("Nothing to trim at the pick") + : _L("No edge to extend to")); return; } + + // C4.1: trim/extend slides ONE endpoint of the subject along its own + // direction (line) or sweep (arc). That (a) kills the subject's + // self-length Distance dim and (b) detaches the moved endpoint from any + // corner Coincident/PointOn* it used to hold. Drop both stale classes, + // then re-anchor the moved endpoint onto the entity it now lands on with a + // PointOnObject (the bridge picks PT_ON_LINE / PT_ON_CIRCLE). A Circle + // subject restructures into an Arc (both endpoints new) — skip the + // re-anchor there; its self constraints (Radius/Concentric) survive, so + // there is nothing stale to drop either. + if (st == Type::Line || st == Type::Arc) { + using R = SketchPointRole; + using CT = SketchConstraintType; + const SketchEntity& aft = feat.entities[e0]; + R moved = R::P0; + Vec2d P; + if (st == Type::Line) { + const bool p0_moved = (before.p0 - aft.p0).squaredNorm() + > (before.p1 - aft.p1).squaredNorm(); + moved = p0_moved ? R::P0 : R::P1; + P = p0_moved ? aft.p0 : aft.p1; + } else { + const bool start_moved = std::abs(before.start_angle - aft.start_angle) + > std::abs(before.end_angle - aft.end_angle); + moved = start_moved ? R::P0 : R::P1; + const double ang = start_moved ? aft.start_angle : aft.end_angle; + P = aft.center + aft.radius * Vec2d(std::cos(ang), std::sin(ang)); + } + + // Drop stale: subject self-length Distance + any Coincident/PointOn* + // pinning the moved endpoint to its old corner. + auto refs = [&](const SketchEntityConstraintDef& d, R r) { + return (d.ea == e0 && d.ra == r) || (d.eb == e0 && d.rb == r); + }; + auto& cs = feat.entity_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [&](const SketchEntityConstraintDef& d) { + if (d.type == CT::Distance && d.ea == e0 && d.eb == e0) return true; + return (d.type == CT::Coincident || d.type == CT::PointOnLine + || d.type == CT::PointOnObject) && refs(d, moved); + }), cs.end()); + + // Find which other entity the moved endpoint now lies on (Line/Circle + // cutters only — PT_ON_* needs a line or circle primitive). + int cutter = -1; + const double tol = 1e-5; + for (int i = 0; i < int(feat.entities.size()); ++i) { + if (i == e0) continue; + const SketchEntity& o = feat.entities[i]; + if (o.type == Type::Line) { + Vec2d dv = o.p1 - o.p0; + const double L2 = dv.dot(dv); + if (L2 < 1e-18) continue; + const double t = (P - o.p0).dot(dv) / L2; + if (t < -1e-6 || t > 1.0 + 1e-6) continue; + if ((o.p0 + t * dv - P).norm() < tol) { cutter = i; break; } + } else if (o.type == Type::Circle) { + if (std::abs((P - o.center).norm() - o.radius) < tol) { cutter = i; break; } + } + } + + // Re-anchor with PointOnObject; keep geometry + the stale-drop even if + // the solver rejects the new (possibly redundant) binding. + if (cutter >= 0) { + const size_t cbefore = feat.entity_constraints.size(); + SketchEntityConstraintDef d; d.type = CT::PointOnObject; + d.ea = e0; d.ra = moved; d.eb = cutter; + feat.entity_constraints.push_back(d); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) + feat.entity_constraints.resize(cbefore); + } + } + break; + } + case EditOp::Array: { + // C4.4 linear array. Additive op (originals untouched, copies appended) -> + // no stale constraints to drop. Collect count, then spacing; the array runs + // along the subject line's own direction (or +X for non-lines). Copies are + // pure translates, so for lines they are Parallel + EqualLength to the + // source by construction -> bind each copy to the source in a star web; for + // arc/circle subjects the translate preserves radius (but not the centre), + // so the web is a per-copy Radius dimension instead (see below). + const int a = e0; + request_value(_L("Array count (incl. original)"), 3.0, 2.0, 200.0, + [this, a](double cnt_d) { + const int count = std::max(2, int(cnt_d + 0.5)); + request_value(_L("Spacing (mm)"), 20.0, -100000.0, 100000.0, + [this, a, count](double sp) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size())) return; + using Type = SketchEntity::Type; + // Snapshot everything we need from the source BEFORE pushing the + // copies: push_back can reallocate f.entities and dangle any + // reference into it. Copy the subject by value. + const SketchEntity src = f.entities[a]; + const Type src_type = src.type; + // Default direction: perpendicular to a line (so copies stack + // into a visible, non-overlapping parallel pattern rather than + // extending collinearly); +X for non-line subjects. + Vec2d dir(1.0, 0.0); + if (src_type == Type::Line) { + const Vec2d t = src.p1 - src.p0; + if (t.norm() > 1e-9) { + const Vec2d u = t.normalized(); + dir = Vec2d(-u.y(), u.x()); + } + } + auto copies = SketchEngine::array_entities( + { src }, count, sp * dir, 0.0, Vec2d(0, 0)); + if (copies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Array produced nothing")); m_status->Refresh(); return; + } + const int base = int(f.entities.size()); // first copy index + for (auto& c : copies) f.entities.push_back(c); + + if (src_type == Type::Line) { + using CT = SketchConstraintType; + // Bind every copy to the source in a star web. Emit the + // WHOLE web before solving (a per-constraint solve would run + // while later copies are still unconstrained, which the + // solver rejects), then degrade as a set: try Parallel + + // EqualLength, fall back to Parallel only (EqualLength can be + // rank-deficient on exact congruent copies), then to bare + // geometry. Keep the geometry regardless. + const size_t cb = f.entity_constraints.size(); + auto build_web = [&](bool with_equal) { + f.entity_constraints.resize(cb); + for (int k = 0; k < int(copies.size()); ++k) { + SketchEntityConstraintDef dp; dp.type = CT::Parallel; + dp.ea = a; dp.eb = base + k; + f.entity_constraints.push_back(dp); + if (with_equal) { + SketchEntityConstraintDef de; de.type = CT::EqualLength; + de.ea = a; de.eb = base + k; + f.entity_constraints.push_back(de); + } + } + }; + build_web(true); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) { + build_web(false); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) + f.entity_constraints.resize(cb); + } + } else if (src_type == Type::Arc || src_type == Type::Circle) { + // Curved subject: translation preserves the radius but + // marches the centres apart, so the copies are NOT + // concentric. There is no EQUAL_RADIUS in the constraint + // enum, so pin each copy's radius to the source value with + // a per-copy Radius dimension (keeps the array equal-radius + // and documents intent, mirroring the line web). Solve and + // roll the whole web back if the solver rejects it. + using CT = SketchConstraintType; + const size_t cb = f.entity_constraints.size(); + for (int k = 0; k < int(copies.size()); ++k) { + SketchEntityConstraintDef dr; dr.type = CT::Radius; + dr.ea = base + k; dr.value = src.radius; + f.entity_constraints.push_back(dr); + } + if (!m_doc.solve_sketch_feature(m_constrain_feat)) + f.entity_constraints.resize(cb); + } + after_edit_op(); + }); + }); + return; // deferred: edit runs on the two Confirms + } + case EditOp::Move: { + // C4.5 Transform (move). MUTATING op: the subject is translated in place + // (kernel transform_entities with angle=0, scale=1). Pure translation + // PRESERVES orientation and length, so intrinsic + orientation constraints + // survive (Horizontal/Vertical/Parallel/Perpendicular/EqualLength/Angle, + // self-length Distance, Radius/Diameter); it BREAKS position-coupling ones + // (Coincident/PointOn*/Concentric/Symmetric/Midpoint/Fix/LockX/LockY, and + // any Distance tying the subject to a *different* entity). Per the governing + // P4 insight, drop those before re-solving — otherwise the solver drags the + // subject straight back to satisfy them and the move never sticks. + const int a = e0; + request_value(_L("Move dX (mm)"), 20.0, -100000.0, 100000.0, + [this, a](double dx) { + request_value(_L("Move dY (mm)"), 0.0, -100000.0, 100000.0, + [this, a, dx](double dy) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size())) return; + auto out = SketchEngine::transform_entities( + { f.entities[a] }, Vec2d(dx, dy), 0.0, 1.0, Vec2d(0, 0)); + if (out.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Move produced nothing")); m_status->Refresh(); return; + } + f.entities[a] = out[0]; + + using CT = SketchConstraintType; + auto refs_a = [&](const SketchEntityConstraintDef& d) { + return d.ea == a || d.eb == a || d.ec == a; + }; + auto& cs = f.entity_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [&](const SketchEntityConstraintDef& d) { + if (!refs_a(d)) return false; + switch (d.type) { + case CT::Coincident: case CT::PointOnLine: case CT::PointOnObject: + case CT::Concentric: case CT::Symmetric: case CT::Midpoint: + case CT::Fix: case CT::LockX: case CT::LockY: + return true; // position-coupling: broken by translation + case CT::Distance: + // self-length (ea==eb==a) survives translation; a + // distance to a *different* entity does not. + return !(d.ea == a && d.eb == a); + default: + return false; // orientation/length: preserved + } + }), cs.end()); + + // The surviving constraints are satisfied by construction + // (translation preserves them); re-solve to fold the new + // position in, keep the geometry even if the solver balks. + m_doc.solve_sketch_feature(m_constrain_feat); + after_edit_op(); + }); + }); + return; // deferred: edit runs on the two Confirms + } + case EditOp::Rotate: { + // C4.5b Transform (rotate-in-place about the subject centroid). MUTATING op. + // Rotation PRESERVES intrinsic size (length/radius) but changes the subject's + // ORIENTATION and the POSITION of its points. So only the size constraints + // survive (EqualLength/Radius/Diameter + self-length Distance); every + // position- or orientation-coupling constraint is broken and must be dropped + // before re-solving, otherwise the solver spins the subject back to satisfy + // them and the rotation never sticks (governing P4 insight). + const int a = e0; + request_value(_L("Rotate angle (deg)"), 45.0, -360.0, 360.0, + [this, a](double deg) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size())) return; + auto centroid_of = [](const SketchEntity& e) -> Vec2d { + using T = SketchEntity::Type; + switch (e.type) { + case T::Line: return 0.5 * (e.p0 + e.p1); + case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: + return e.center; + case T::BSpline: + if (!e.ctrl.empty()) { + Vec2d s(0, 0); for (auto& p : e.ctrl) s += p; + return s / double(e.ctrl.size()); + } + return 0.5 * (e.p0 + e.p1); + default: return e.p0; // Point + } + }; + const Vec2d piv = centroid_of(f.entities[a]); + auto out = SketchEngine::transform_entities( + { f.entities[a] }, Vec2d(0, 0), deg * M_PI / 180.0, 1.0, piv); + if (out.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Rotate produced nothing")); m_status->Refresh(); return; + } + f.entities[a] = out[0]; + + using CT = SketchConstraintType; + auto refs_a = [&](const SketchEntityConstraintDef& d) { + return d.ea == a || d.eb == a || d.ec == a; + }; + auto& cs = f.entity_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [&](const SketchEntityConstraintDef& d) { + if (!refs_a(d)) return false; + switch (d.type) { + case CT::EqualLength: case CT::Radius: case CT::Diameter: + return false; // intrinsic size: preserved by rotation + case CT::Distance: + return !(d.ea == a && d.eb == a); // self-length survives + default: + return true; // position/orientation-coupling: broken + } + }), cs.end()); + + m_doc.solve_sketch_feature(m_constrain_feat); + after_edit_op(); + }); + return; // deferred: edit runs on Confirm + } + case EditOp::Scale: { + // C4.5c Transform (uniform scale-in-place about the subject centroid). MUTATING + // op. Uniform scaling PRESERVES orientation and angles (Horizontal/Vertical/ + // Parallel/Perpendicular/Angle survive) but changes SIZE and point POSITIONS: + // drop every size constraint (Radius/Diameter/EqualLength/any Distance) and + // every position-coupling constraint before re-solving, else the solver + // rescales the subject back to satisfy them. + const int a = e0; + request_value(_L("Scale factor"), 2.0, 0.01, 1000.0, + [this, a](double sf) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size())) return; + auto centroid_of = [](const SketchEntity& e) -> Vec2d { + using T = SketchEntity::Type; + switch (e.type) { + case T::Line: return 0.5 * (e.p0 + e.p1); + case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: + return e.center; + case T::BSpline: + if (!e.ctrl.empty()) { + Vec2d s(0, 0); for (auto& p : e.ctrl) s += p; + return s / double(e.ctrl.size()); + } + return 0.5 * (e.p0 + e.p1); + default: return e.p0; // Point + } + }; + const Vec2d piv = centroid_of(f.entities[a]); + auto out = SketchEngine::transform_entities( + { f.entities[a] }, Vec2d(0, 0), 0.0, sf, piv); + if (out.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Scale produced nothing")); m_status->Refresh(); return; + } + f.entities[a] = out[0]; + + using CT = SketchConstraintType; + auto refs_a = [&](const SketchEntityConstraintDef& d) { + return d.ea == a || d.eb == a || d.ec == a; + }; + auto& cs = f.entity_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [&](const SketchEntityConstraintDef& d) { + if (!refs_a(d)) return false; + switch (d.type) { + case CT::Horizontal: case CT::Vertical: case CT::Parallel: + case CT::Perpendicular: case CT::Angle: + return false; // orientation/angle: preserved by uniform scale + default: + return true; // size + position-coupling: broken + } + }), cs.end()); + + m_doc.solve_sketch_feature(m_constrain_feat); + after_edit_op(); + }); + return; // deferred: edit runs on Confirm + } + case EditOp::PolarArray: { + // Polar array about the subject centroid. ADDITIVE op (originals untouched, + // count-1 rotated copies appended) -> no stale constraints to drop. Copies + // are rigid rotations of the source, so they preserve LENGTH but NOT + // orientation: bind each copy to the source with EqualLength only (Parallel + // does NOT hold under rotation, unlike the linear-array web). Arc/circle + // subjects rotate about their own centre, so their copies stay Concentric + + // equal-radius instead (see below). Dialogs: count + // then total sweep; angle_step = sweep/count spreads them evenly (last copy + // lands just shy of the original on a full 360). + const int a = e0; + request_value(_L("Polar count (incl. original)"), 6.0, 2.0, 200.0, + [this, a](double cnt_d) { + const int count = std::max(2, int(cnt_d + 0.5)); + request_value(_L("Total sweep (deg)"), 360.0, -360.0, 360.0, + [this, a, count](double sweep_deg) { + CadFeature& f = m_doc.features[m_constrain_feat]; + if (a >= int(f.entities.size())) return; + using Type = SketchEntity::Type; + // Snapshot the subject by value BEFORE pushing copies: push_back + // can reallocate f.entities and dangle a reference into it. + const SketchEntity src = f.entities[a]; + const Type src_type = src.type; + auto centroid_of = [](const SketchEntity& e) -> Vec2d { + using T = SketchEntity::Type; + switch (e.type) { + case T::Line: return 0.5 * (e.p0 + e.p1); + case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: + return e.center; + case T::BSpline: + if (!e.ctrl.empty()) { + Vec2d s(0, 0); for (auto& p : e.ctrl) s += p; + return s / double(e.ctrl.size()); + } + return 0.5 * (e.p0 + e.p1); + default: return e.p0; // Point + } + }; + const Vec2d piv = centroid_of(src); + const double angle_step = (sweep_deg * M_PI / 180.0) / double(count); + auto copies = SketchEngine::array_entities( + { src }, count, Vec2d(0, 0), angle_step, piv); + if (copies.empty()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Polar array produced nothing")); m_status->Refresh(); return; + } + const int base = int(f.entities.size()); // first copy index + for (auto& c : copies) f.entities.push_back(c); + + if (src_type == Type::Line) { + using CT = SketchConstraintType; + // Rotational web: each copy is EqualLength to the source + // (rotation preserves length; orientation differs so NO + // Parallel). Emit the whole web before solving, then fall + // back to bare geometry if it is rank-deficient. + const size_t cb = f.entity_constraints.size(); + for (int k = 0; k < int(copies.size()); ++k) { + SketchEntityConstraintDef de; de.type = CT::EqualLength; + de.ea = a; de.eb = base + k; + f.entity_constraints.push_back(de); + } + if (!m_doc.solve_sketch_feature(m_constrain_feat)) + f.entity_constraints.resize(cb); + } else if (src_type == Type::Arc || src_type == Type::Circle) { + // Curved subject: the polar pivot is the subject centroid, + // which for an arc/circle IS its own centre. Rotating about + // that centre keeps every copy CONCENTRIC with the source and + // at the same radius (only the angular position shifts). Bind + // each copy with Concentric + a per-copy Radius dimension; + // degrade to Radius-only, then to bare geometry, keeping the + // geometry regardless. + using CT = SketchConstraintType; + const size_t cb = f.entity_constraints.size(); + auto build_web = [&](bool with_concentric) { + f.entity_constraints.resize(cb); + for (int k = 0; k < int(copies.size()); ++k) { + if (with_concentric) { + SketchEntityConstraintDef dc; dc.type = CT::Concentric; + dc.ea = a; dc.eb = base + k; + f.entity_constraints.push_back(dc); + } + SketchEntityConstraintDef dr; dr.type = CT::Radius; + dr.ea = base + k; dr.value = src.radius; + f.entity_constraints.push_back(dr); + } + }; + build_web(true); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) { + build_web(false); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) + f.entity_constraints.resize(cb); + } + } + after_edit_op(); + }); + }); + return; // deferred: edit runs on the two Confirms + } + } + + after_edit_op(); +} + +void DesignPanel::after_edit_op() +{ + if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport) + return; + m_doc.recompute(); + m_viewport->set_constraint_highlight({}); // entity indices may have shifted + m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities); + // Refresh the committed-sketch overlay too: it caches the entity list at + // constrain-entry, so a relocating edit (Move/Trim/Extend) would otherwise + // leave a stale ghost of the pre-edit geometry beside the new position. + sync_sketch_display(); + if (!m_doc.display_mesh.its.indices.empty()) + feed_bodies(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Applied edit")); + m_status->Refresh(); + refresh_constrain_dof(); + rebuild_constraint_list(); +} + +void DesignPanel::request_value(const wxString& label, double def, double mn, double mx, + std::function cont, + std::function on_cancel) +{ + m_value_cont = std::move(cont); + m_value_cancel = std::move(on_cancel); + m_value_min = mn; + m_value_max = mx; + m_value_label->SetLabel(label); + m_value_input->ChangeValue(en_format(def)); // '.' decimals, no EVT_TEXT feedback + m_cards->GetSizer()->Show(m_box_value, true, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + m_value_input->SetFocus(); + m_value_input->SetSelection(-1, -1); // select all so typing replaces the value + m_status->SetForegroundColour(wxNullColour); + set_status(label + _L(" — type a value, press Enter (or Confirm)")); + m_status->Refresh(); +} + +void DesignPanel::confirm_value() +{ + if (!m_value_cont) { cancel_value(); return; } + double v = 0.0; + if (!en_parse(m_value_input->GetValue(), v)) { m_value_input->SetFocus(); return; } + v = std::min(std::max(v, m_value_min), m_value_max); // clamp to range + auto cont = m_value_cont; // copy, then clear before running so a + m_value_cont = nullptr; // re-entrant request_value can re-arm cleanly + m_value_cancel = nullptr; // confirmed: drop the cancel action + m_cards->GetSizer()->Show(m_box_value, false, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + cont(v); // run the deferred constraint / edit-op apply +} + +void DesignPanel::cancel_value() +{ + const bool was_open = (m_value_cont != nullptr); + m_value_cont = nullptr; + auto on_cancel = m_value_cancel; // copy, clear, then run (re-entrancy safe) + m_value_cancel = nullptr; + if (m_box_value) + m_cards->GetSizer()->Show(m_box_value, false, true); + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + if (was_open) { + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); + } + if (on_cancel) + on_cancel(); // e.g. keep a pending line segment as drawn +} + +void DesignPanel::apply_constraint(SketchConstraintType type) +{ + // 1. LIVE sketch session: constrain the in-session selection while drawing. The + // discriminator must exclude BOTH constrain modes — begin_constrain_entities AND + // begin_constrain both set m_active, so is_sketching() alone is true during a committed + // Constrain session; without the guards this new path hijacks those and breaks every + // existing constraint rung. !is_constraining() also covers is_constraining_entities() + // (both require Mode::Constrain); it is spelled out for the two reasons a reader expects. + if (m_viewport && m_viewport->is_sketching() && + !m_viewport->is_constraining() && !m_viewport->is_constraining_entities()) { + apply_live_constraint(type); + return; + } + + if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || + m_viewport == nullptr) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Press Constrain on a sketch first")); + m_status->Refresh(); + return; + } + + // 2. Entity sketches (Fase 4.2) route through the entity-constraint path. + if (m_viewport->is_constraining_entities()) { + apply_entity_constraint(type); + return; + } + + // 3. Legacy profile path. + if (!m_viewport->is_constraining()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Press Constrain on a sketch first")); + m_status->Refresh(); + return; + } + int a = -1, b = -1; + if (!m_viewport->selected_segment(a, b)) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Pick a segment in the viewport first")); + m_status->Refresh(); + return; + } + CadFeature& feat = m_doc.features[m_constrain_feat]; + // solve_sketch_feature rewrites profile.points even on failure, so snapshot + // the geometry to roll back a rejected constraint cleanly. + const std::vector saved_pts = feat.profile.points; + m_doc.checkpoint(); // undo boundary: adding a constraint (profile sketches) + feat.constraints.push_back(SketchConstraintDef{type, a, b, -1, -1, 0.0}); + if (!m_doc.solve_sketch_feature(m_constrain_feat)) { + feat.constraints.pop_back(); // reject the non-converging addition + feat.profile.points = saved_pts; // and restore the pre-solve geometry + m_doc.abandon_checkpoint(); // restored: no state change, so no undo step + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Constraint rejected (over-constrained)")); + m_status->Refresh(); + return; + } + m_doc.recompute(); + sync_recipe_to_model(); + update_undo_redo_buttons(); + m_viewport->update_constrain_profile(m_doc.features[m_constrain_feat].profile.points); + if (!m_doc.display_mesh.its.indices.empty()) + feed_bodies(); + m_status->SetForegroundColour(wxNullColour); + set_status(type == SketchConstraintType::Horizontal ? _L("Applied Horizontal") + : _L("Applied Vertical")); + m_status->Refresh(); +} + +void DesignPanel::reset_edit_state() +{ + m_edit_index = -1; +} + +int DesignPanel::resolve_extrude_sketch() const +{ + int sel = tree_selection(); + if (sel != wxNOT_FOUND && sel < int(m_doc.features.size()) && + m_doc.features[sel].type == CadFeatureType::Sketch) + return sel; + for (int i = int(m_doc.features.size()) - 1; i >= 0; --i) + if (m_doc.features[i].type == CadFeatureType::Sketch) return i; + return -1; +} + +void DesignPanel::load_feature_into_dialog(const CadFeature& f) +{ + switch (f.type) { + case CadFeatureType::Sketch: + m_shape->SetSelection(f.shape == SketchShape::Circle ? 1 : 0); + m_width->SetValue(f.width); + m_height->SetValue(f.height); + m_radius->SetValue(f.radius); + break; + case CadFeatureType::Extrude: + m_distance->SetValue(f.distance); + m_mode->SetSelection(static_cast(f.mode)); // New=0,Add=1,Cut=2,Intersect=3 + m_extrude_end->SetSelection(static_cast(f.extrude_end)); + m_distance2->SetValue(f.distance2); + m_taper->SetValue(f.taper_deg); + m_flip->SetValue(f.flip); + m_extrude_sketch_ref = f.sketch_ref; + m_sel_solid_body = f.target_body; // preserve which body on re-edit + if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size())) + m_extrude_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_extrude_sketch_ref].name)); + break; + case CadFeatureType::Fillet: + case CadFeatureType::Chamfer: + m_dressup_type->SetSelection(f.type == CadFeatureType::Fillet ? 0 : 1); + m_dressup_size->SetValue(f.dressup_size); + m_face_group->SetSelection(static_cast(f.face_group)); + m_sel_solid_edge = f.dressup_edge; // preserve edge-targeting on re-edit + m_sel_solid_body = f.target_body; // preserve which body on re-edit + sync_dressup_target(); // and say which of the two the re-edit is targeting + break; + case CadFeatureType::Hole: + m_hole_plane->SetSelection(index_from_plane(f.plane)); + m_hole_diameter->SetValue(f.hole_diameter); + m_hole_depth->SetValue(f.hole_depth); + m_hole_through->SetValue(f.hole_through); + m_hole_x->SetValue(f.hole_x); + m_hole_y->SetValue(f.hole_y); + // Re-latch the on-face state FROM THE STORED FEATURE (uif9). m_hole_on_face is + // only ever cleared by the Hole flyout and by the plane combo, so after any on-face hole + // it stays true — and a re-edit then drilled on whatever face was latched last, which may + // be a different face, a different body, or a body since rebuilt. f is the only source + // guaranteed to describe THIS hole. Not the dropdown row just set above: index_from_plane + // snaps an arbitrary face plane to the nearest XY/XZ/YZ, so driving the re-edit from the + // row would MOVE a hole drilled on a slanted or offset face. + m_hole_on_face = !is_base_plane(f.plane, m_doc.modeling_origin); + m_hole_face_plane = f.plane; + m_hole_face_body = m_hole_on_face ? f.target_body : -1; + // The face's (u,v) extent is not serialized, so the gizmo's footprint clamp has nothing + // to stand on. Unbounded is the honest state; the stale bounds of another face are not. + m_hole_has_bounds = false; + if (m_hole_target_label) + m_hole_target_label->SetLabel(m_hole_on_face + // No face index survives in the feature, and inventing one would be worse than + // saying so. "(none — uses Hole plane)" is the one thing that is definitely false. + ? (f.target_body >= 0 ? wxString::Format(_L("On a face of Body %d"), f.target_body + 1) + : _L("On a stored face")) + : _L("(none — uses Hole plane)")); + break; + case CadFeatureType::Thread: + m_thread_plane->SetSelection(index_from_plane(f.plane)); + m_thread_radius->SetValue(f.thread_radius * 2.0); // field = diameter + m_thread_pitch->SetValue(f.thread_pitch); + m_thread_height->SetValue(f.thread_height); + m_thread_depth->SetValue(f.thread_depth); + m_thread_internal->SetValue(f.thread_internal); + m_thread_x->SetValue(f.thread_x); + m_thread_y->SetValue(f.thread_y); + if (m_thread_std) m_thread_std->SetSelection(0); // Custom: spins reflect the stored feature + // Same latch, same failure, same fix as Hole above (uif9). + m_thread_on_face = !is_base_plane(f.plane, m_doc.modeling_origin); + m_thread_face_plane = f.plane; + m_thread_face_body = m_thread_on_face ? f.target_body : -1; + if (m_thread_target_label) + m_thread_target_label->SetLabel(m_thread_on_face + ? (f.target_body >= 0 ? wxString::Format(_L("On a face of Body %d"), f.target_body + 1) + : _L("On a stored face")) + : _L("(none — uses Thread plane)")); + break; + case CadFeatureType::Shell: + m_shell_thickness->SetValue(f.shell_thickness); + m_sel_solid_face = f.shell_face; + m_shell_face_label->SetLabel(f.shell_face >= 0 + ? wxString::Format(_L("Face %d"), f.shell_face) + : _L("(all faces — closed hollow)")); + break; + case CadFeatureType::Revolve: + m_revolve_angle->SetValue(f.revolve_angle); + m_revolve_axis->SetSelection(f.revolve_axis); + m_revolve_mode->SetSelection(static_cast(f.mode)); + m_revolve_flip->SetValue(f.flip); + m_revolve_sketch_ref = f.sketch_ref; + break; + case CadFeatureType::Sweep: + m_sweep_profile_ref = f.sketch_ref; + m_sweep_path_ref = f.sweep_path_ref; // show_tool pre-selects this in the picker + m_sweep_mode->SetSelection(static_cast(f.mode)); + break; + case CadFeatureType::Pattern: + m_pattern_type->SetSelection(f.pattern_circular ? 1 : 0); + m_pattern_count->SetValue(f.pattern_count); + m_pattern_spacing->SetValue(f.pattern_spacing); + m_pattern_dir->SetSelection(f.pattern_dir); + m_pattern_angle->SetValue(f.pattern_angle); + break; + case CadFeatureType::Plane: + populate_plane_choices(m_plane_base); + m_plane_base->SetSelection(f.plane_base); + m_plane_offset->SetValue(f.plane_offset); + m_plane_tilt->SetValue(f.plane_angle_tilt); + m_plane_tilt_axis->SetSelection(f.plane_axis); + m_plane_type->SetSelection((int)f.plane_type); + m_pl_faceA_body = f.plane_face_body; m_pl_faceA = f.plane_face; + m_pl_faceB_body = f.plane_face2_body; m_pl_faceB = f.plane_face2; + m_pl_edgeA_body = f.plane_edge_body; m_pl_edgeA = f.plane_edge; + m_pl_edgeB_body = f.plane_edge2_body; m_pl_edgeB = f.plane_edge2; + m_plane_usize->SetValue(f.plane_u_size); + m_plane_vsize->SetValue(f.plane_v_size); + m_plane_pick = PlanePick::None; + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + refresh_plane_labels(); + break; + case CadFeatureType::Loft: + m_loft_refs = f.loft_profile_refs; // show_tool re-checks these in the list + m_loft_ruled->SetValue(f.loft_ruled); + m_loft_mode->SetSelection(static_cast(f.mode)); + break; + case CadFeatureType::Draft: + m_draft_angle->SetValue(f.draft_angle); + m_sel_solid_face = f.draft_face; + m_draft_face_label->SetLabel(f.draft_face >= 0 + ? wxString::Format(_L("Face %d"), f.draft_face) + : _L("(pick a side face)")); + break; + case CadFeatureType::Axis: + m_axis_type->SetSelection((int)f.axis_type); + m_ax_face_body = f.axis_body; m_ax_face = f.axis_face; + m_ax_edge = f.axis_edge; + populate_plane_choices(m_axis_plane_a); + populate_plane_choices(m_axis_plane_b); + m_axis_plane_a->SetSelection(f.axis_plane_a >= 0 ? f.axis_plane_a : 0); + m_axis_plane_b->SetSelection(f.axis_plane_b >= 0 ? f.axis_plane_b : 0); + m_axis_p1x->SetValue(f.axis_p1.x()); m_axis_p1y->SetValue(f.axis_p1.y()); m_axis_p1z->SetValue(f.axis_p1.z()); + m_axis_p2x->SetValue(f.axis_p2.x()); m_axis_p2y->SetValue(f.axis_p2.y()); m_axis_p2z->SetValue(f.axis_p2.z()); + m_axis_pick = AxisPick::None; + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + refresh_axis_labels(); + break; + case CadFeatureType::CoordSys: + m_coordsys_type->SetSelection((int)f.coordsys_type); + m_cs_x->SetValue(f.coordsys_point.x()); + m_cs_y->SetValue(f.coordsys_point.y()); + m_cs_z->SetValue(f.coordsys_point.z()); + m_cs_face_body = f.coordsys_body; m_cs_face = f.coordsys_face; + m_cs_edge = f.coordsys_edge; + m_cs_hx->SetValue(f.coordsys_x_hint.x()); + m_cs_hy->SetValue(f.coordsys_x_hint.y()); + m_cs_hz->SetValue(f.coordsys_x_hint.z()); + m_coordsys_pick = CoordSysPick::None; + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned + refresh_coordsys_labels(); + refresh_cs_body_choice(); + if (m_cs_body && f.coordsys_body >= 0) { + m_cs_body->SetSelection(f.coordsys_body + 1); + if (m_viewport) m_viewport->set_xray_focus(f.coordsys_body); + } + break; + case CadFeatureType::Boolean: + // List the bodies available to the boolean (as-of its timeline slot), so the consumed + // tool body still appears and the saved selections below land on the right entries. + populate_body_choices(m_edit_index); + m_bool_op->SetSelection(f.mode == BooleanMode::Cut ? 1 + : f.mode == BooleanMode::Intersect ? 2 : 0); // 0 = Union/Add + if (f.target_body >= 0 && f.target_body < int(m_bool_target->GetCount())) + m_bool_target->SetSelection(f.target_body); + if (f.bool_tool_body >= 0 && f.bool_tool_body < int(m_bool_tool->GetCount())) + m_bool_tool->SetSelection(f.bool_tool_body); + m_bool_keep->SetValue(f.bool_keep_tool); + m_bool_tol->SetValue(f.bool_tolerance); + break; + case CadFeatureType::Cut: + // Same as-of-timeline reasoning as Boolean: a Cut splits one body into two, so the + // live body list does not match the one this feature's target index was recorded + // against. Replay to just before it and the stored index lands on the right entry. + populate_body_choices(m_edit_index); + if (f.target_body >= 0 && f.target_body < int(m_cut_target->GetCount())) + m_cut_target->SetSelection(f.target_body); + else if (m_cut_target->GetCount() > 0) + m_cut_target->SetSelection(0); + populate_plane_choices(m_cut_plane); + m_cut_plane->SetSelection(index_from_plane(f.plane)); + m_cut_offset->SetValue(f.cut_offset); + break; + case CadFeatureType::SurfaceExtrude: + m_surf_extrude_distance->SetValue(f.distance); + m_surf_extrude_sketch_ref = f.sketch_ref; + if (m_surf_extrude_sketch_ref >= 0 && m_surf_extrude_sketch_ref < int(m_doc.features.size())) + m_surf_extrude_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_surf_extrude_sketch_ref].name)); + break; + case CadFeatureType::SurfaceRevolve: + m_surf_revolve_angle->SetValue(f.revolve_angle); + m_surf_revolve_axis->SetSelection(f.revolve_axis); + m_surf_revolve_flip->SetValue(f.flip); + m_surf_revolve_sketch_ref = f.sketch_ref; + if (m_surf_revolve_sketch_ref >= 0 && m_surf_revolve_sketch_ref < int(m_doc.features.size())) + m_surf_revolve_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_surf_revolve_sketch_ref].name)); + break; + case CadFeatureType::SurfaceLoft: + m_surf_loft_refs = f.loft_profile_refs; + m_surf_loft_ruled->SetValue(f.loft_ruled); + break; + case CadFeatureType::SurfaceFill: + m_surf_fill_sketch_ref = f.sketch_ref; + if (m_surf_fill_sketch_ref >= 0 && m_surf_fill_sketch_ref < int(m_doc.features.size())) + m_surf_fill_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_surf_fill_sketch_ref].name)); + break; + case CadFeatureType::SurfaceOffset: + populate_sheet_body_choices(m_surf_offset_body); + m_surf_offset_distance->SetValue(f.plane_offset); + // target_body is a BODY index; the rows are sheets only, so match, don't index. + select_sheet_choice(m_surf_offset_body, f.target_body); + break; + case CadFeatureType::ThickenSurface: + populate_sheet_body_choices(m_surf_thicken_body); + m_surf_thicken_thickness->SetValue(f.thicken_thickness); + m_surf_thicken_flip->SetValue(f.thicken_flip); + select_sheet_choice(m_surf_thicken_body, f.target_body); + break; + case CadFeatureType::Transform: { + fill_body_choice(m_xf_body, m_edit_index, f.target_body); + m_xf_dx->SetValue(f.xf_translate.x()); + m_xf_dy->SetValue(f.xf_translate.y()); + m_xf_dz->SetValue(f.xf_translate.z()); + const int ax = (std::abs(f.xf_axis.x()) > 0.5) ? 0 : (std::abs(f.xf_axis.y()) > 0.5) ? 1 : 2; + m_xf_axis->SetSelection(ax); + m_xf_angle->SetValue(f.xf_angle_deg); + m_xf_pivot_x->SetValue(f.xf_pivot.x()); + m_xf_pivot_y->SetValue(f.xf_pivot.y()); + m_xf_pivot_z->SetValue(f.xf_pivot.z()); + m_xf_copy->SetValue(f.xf_copy); + break; + } + case CadFeatureType::Mirror: { + fill_body_choice(m_mirror_body, m_edit_index, f.target_body); + populate_plane_choices(m_mirror_plane); + m_mirror_plane->SetSelection(index_from_plane(f.plane)); + m_mirror_keep->SetValue(f.mirror_keep_original); + break; + } + case CadFeatureType::Thicken: { + fill_body_choice(m_thicken_body, m_edit_index, f.target_body); + m_sel_solid_face = f.thicken_face; + m_thicken_face_label->SetLabel(f.thicken_face >= 0 + ? wxString::Format(_L("Face %d"), f.thicken_face) + : _L("(pick a solid face)")); + m_thicken_thickness->SetValue(f.thicken_thickness); + m_thicken_flip->SetValue(f.thicken_flip); + break; + } + case CadFeatureType::Rib: { + fill_body_choice(m_rib_body, m_edit_index, f.target_body); + { + m_rib_sketch->Clear(); + int pre_sel = wxNOT_FOUND; + for (int i = 0; i < int(m_doc.features.size()); ++i) { + if (m_doc.features[i].type == CadFeatureType::Sketch) { + const int pos = combo_append_index(m_rib_sketch, + wxString::FromUTF8(m_doc.features[i].name), i); + if (i == f.rib_sketch_ref) pre_sel = pos; + } + } + if (pre_sel != wxNOT_FOUND) m_rib_sketch->SetSelection(pre_sel); + else if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0); + } + m_rib_entity->SetValue(f.rib_entity); + m_rib_thickness->SetValue(f.rib_thickness); + m_rib_depth->SetValue(f.rib_depth); + break; + } + case CadFeatureType::Project: { + fill_body_choice(m_proj_source_body, m_edit_index, f.project_source_body); + populate_plane_choices(m_proj_plane); + m_proj_plane->SetSelection(index_from_plane(f.plane)); + m_sel_solid_face = f.project_face; + m_proj_face_label->SetLabel(f.project_face >= 0 + ? wxString::Format(_L("Face %d"), f.project_face) + : _L("(all edges)")); + break; + } + case CadFeatureType::DeleteFace: { + fill_body_choice(m_del_face_body, m_edit_index, f.target_body); + m_del_faces = f.delete_faces; + { + wxString s; + for (size_t i = 0; i < m_del_faces.size(); ++i) { + if (i > 0) s += ", "; + s += wxString::Format("Face %d", m_del_faces[i]); + } + m_del_face_list->SetLabel(s.empty() ? _L("(none)") : s); + } + break; + } + case CadFeatureType::Helix: + populate_plane_choices(m_helix_plane); + m_helix_plane->SetSelection(index_from_plane(f.plane)); + m_helix_radius->SetValue(f.helix_radius); + m_helix_pitch->SetValue(f.helix_pitch); + m_helix_height->SetValue(f.helix_height); + m_helix_left_handed->SetValue(f.helix_left_handed); + m_helix_taper->SetValue(f.helix_taper_deg); + break; + case CadFeatureType::Mate: + m_mate_kind->SetSelection(f.mate_kind); + // CoordSys pickers are repopulated by open_tool(Mate); pre-selection + // is done there too via the stored feature index. + m_mate_offset->SetValue(f.mate_offset); + m_mate_angle->SetValue(f.mate_angle); + m_mate_flip->SetValue(f.mate_flip); + break; + default: break; + } +} + +void DesignPanel::on_edit_feature() +{ + int sel = tree_selection(); + if (sel == wxNOT_FOUND) { + set_status(_L("Select a feature in the tree first")); + m_status->Refresh(); + return; + } + const CadFeature& f = m_doc.features[sel]; + reset_edit_state(); + + switch (f.type) { + case CadFeatureType::Sketch: + // Imported Text/SVG art has no editable sketch dialog — edit means + // move / scale its placement instead, behind the same Confirm/Cancel gate as + // the initial insert (Cancel = undo restores the prior placement). + if (!f.imported_regions.empty()) { + m_doc.checkpoint(); // undo boundary: re-placing imported art + on_transform_imported(sel); + m_insert_feat = sel; + open_insert_card(wxString::FromUTF8(f.name)); + break; + } + m_edit_index = sel; + if (!f.entities.empty()) { + // Entity sketcher: re-open the geometry for full in-canvas editing (handles, + // live quotes, regular-polygon drag) in the ENTITY sketch UI (the top sketch + // toolbar + session card), NOT the legacy parametric card. The commit handler + // replaces this feature in place (see m_edit_index). Hide its display overlay + // so the live tool is the only copy drawn. + set_ui_mode(UiMode::Sketch); + if (m_viewport) { + m_viewport->set_display_sketches({}); + m_viewport->edit_sketch(f.entities, f.entity_constraints, f.plane); + } + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Editing sketch — drag a handle or click a quote to edit")); + m_status->Refresh(); + } else { + load_feature_into_dialog(f); + open_tool(Tool::Sketch); + } + break; + case CadFeatureType::Extrude: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Extrude); + break; + case CadFeatureType::Fillet: + case CadFeatureType::Chamfer: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Dressup); + break; + case CadFeatureType::Hole: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Hole); + break; + case CadFeatureType::Thread: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Thread); + break; + case CadFeatureType::Shell: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Shell); + break; + case CadFeatureType::Revolve: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Revolve); + break; + case CadFeatureType::Sweep: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Sweep); + break; + case CadFeatureType::Pattern: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Pattern); + break; + case CadFeatureType::Plane: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Plane); + break; + case CadFeatureType::Axis: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Axis); + break; + case CadFeatureType::CoordSys: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::CoordSys); + break; + case CadFeatureType::Loft: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Loft); + break; + case CadFeatureType::Draft: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Draft); + break; + case CadFeatureType::Boolean: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Boolean); + break; + case CadFeatureType::SurfaceExtrude: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::SurfaceExtrude); + break; + case CadFeatureType::SurfaceRevolve: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::SurfaceRevolve); + break; + case CadFeatureType::SurfaceLoft: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::SurfaceLoft); + break; + case CadFeatureType::SurfaceFill: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::SurfaceFill); + break; + case CadFeatureType::SurfaceOffset: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::SurfaceOffset); + break; + case CadFeatureType::ThickenSurface: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::ThickenSurface); + break; + case CadFeatureType::Transform: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Transform); + break; + case CadFeatureType::Mirror: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Mirror); + break; + case CadFeatureType::Thicken: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Thicken); + break; + case CadFeatureType::Rib: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Rib); + break; + case CadFeatureType::Project: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Project); + break; + case CadFeatureType::DeleteFace: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::DeleteFace); + break; + case CadFeatureType::Helix: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Helix); + break; + case CadFeatureType::Mate: + m_edit_index = sel; + m_mate_kind->SetSelection(f.mate_kind); + m_mate_offset->SetValue(f.mate_offset); + m_mate_angle->SetValue(f.mate_angle); + m_mate_flip->SetValue(f.mate_flip); + open_tool(Tool::Mate); // populates CS pickers; pre-selects from current selection + // Now set the re-edit cs_a/cs_b after populating (override the open_tool default) + { + int pre_a = wxNOT_FOUND, pre_b = wxNOT_FOUND; + for (unsigned i = 0; i < m_mate_cs_a->GetCount(); ++i) { + const auto* cd = m_mate_cs_a->GetClientData(i); + if (cd && int(reinterpret_cast(cd)) == f.mate_cs_a) pre_a = int(i); + } + for (unsigned i = 0; i < m_mate_cs_b->GetCount(); ++i) { + const auto* cd = m_mate_cs_b->GetClientData(i); + if (cd && int(reinterpret_cast(cd)) == f.mate_cs_b) pre_b = int(i); + } + if (pre_a != wxNOT_FOUND) m_mate_cs_a->SetSelection(pre_a); + if (pre_b != wxNOT_FOUND) m_mate_cs_b->SetSelection(pre_b); + } + break; + case CadFeatureType::Cut: + m_edit_index = sel; + load_feature_into_dialog(f); + open_tool(Tool::Cut); + break; + case CadFeatureType::Import: + // An imported solid has no parameters to re-edit: its geometry is rigid data read + // from the file, not something rebuilt from numbers. Repositioning it is Transform's + // job, and that feature already exists — so point there rather than invent a dialog + // that would only duplicate it. (Imported 2D Text/SVG art is different and IS + // re-editable; it arrives as a Sketch feature with imported_regions, handled above.) + m_status->SetForegroundColour(wxNullColour); + set_status(_L("An imported solid has no parameters — use Transform to move or rotate it")); + m_status->Refresh(); + break; + default: + // Every CadFeatureType now has a case. Kept as a guard so a type added later + // announces itself instead of silently swallowing the Edit click. + m_status->SetForegroundColour(wxNullColour); + set_status(_L("This feature type can't be edited yet")); + m_status->Refresh(); + break; + } +} + +void DesignPanel::on_export_step() +{ + // Bake any open feature preview first, so the STEP matches what is shown (mirrors on_commit). + if (m_active != Tool::None) + confirm_tool(); + if (m_doc.bodies.empty()) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Nothing to export — add a feature first")); + m_status->Refresh(); + return; + } + wxFileDialog dlg(this, _L("Export STEP"), wxEmptyString, "model.step", + "STEP files (*.step;*.stp)|*.step;*.stp", + wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + if (dlg.ShowModal() != wxID_OK) + return; + sync_body_xform(); // export bodies at their displayed Move-gizmo positions + std::string err; + const bool ok = m_doc.export_step(dlg.GetPath().ToUTF8().data(), m_body_xform, err); + m_status->SetForegroundColour(ok ? wxColour(120, 210, 120) : wxColour(235, 110, 110)); + set_status(ok ? _L("Exported STEP") + : _L("STEP export failed: ") + wxString::FromUTF8(err)); + m_status->Refresh(); +} + +void DesignPanel::on_commit() +{ + // A feature tool open with a live preview ghost (e.g. a fillet being previewed) is + // NOT yet part of the body. Apply it first so "Commit to Plate" ships exactly what + // is shown on screen, not the pre-feature solid. (confirm_tool() applies + closes.) + if (m_active != Tool::None) + confirm_tool(); + + if (m_doc.display_mesh.its.indices.empty()) { + set_status(_L("Nothing to commit — add a feature first")); + return; + } + ObjectList* obj_list = wxGetApp().obj_list(); + if (obj_list == nullptr) + return; + + // Multi-body: ship each (visible) body as its own plate object so they arrive on the + // slicer plate as independent, separately-arrangeable parts (Onshape "Commit all parts"). + // Hidden bodies are skipped — what you see on the Design plate is what gets committed. + sync_body_visible(); + rebuild_disp_meshes(); // ship moved bodies at their Move-gizmo positions + if (m_disp_body_meshes.size() > 1) { + int committed = 0; + for (size_t b = 0; b < m_disp_body_meshes.size(); ++b) { + if (b < m_body_visible.size() && !m_body_visible[b]) continue; // skip hidden + if (m_disp_body_meshes[b].its.indices.empty()) continue; + obj_list->load_mesh_object(m_disp_body_meshes[b], + "Design Body " + std::to_string(b + 1)); + ++committed; + } + if (committed == 0) { // every body hidden — nothing to ship + set_status(_L("All bodies hidden — show one before committing")); + return; + } + } else { + obj_list->load_mesh_object(m_disp_pick_mesh, "Design Body"); + } + + // Persist the editable parametric recipe alongside the committed meshes so the + // saved 3MF reopens with the full feature tree, not just the baked solid. An empty + // doc clears it, keeping non-CAD projects clean. + sync_recipe_to_model(); + + if (wxGetApp().mainframe != nullptr) + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); +} + +CadFeature DesignPanel::build_candidate(Tool t) const +{ + CadFeature f; + // EDIT MODE: a feature card edits only scalar parameters. The feature's structural + // identity — profile source (sketch_ref / entities / picked face), its plane, the + // up-to-face target and the target body — must be preserved from the feature being + // edited, NOT re-derived from the live tool state (which still reflects the last *add* + // flow). GUI extrudes carry their profile as `entities` with sketch_ref = -1, which the + // card never restores, so a fresh rebuild produced an empty profile -> a misplaced new + // box. Seed from the original; the cases below overlay card scalars and skip the + // structural assignments while editing. + const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())); + if (editing) + f = m_doc.features[m_edit_index]; + switch (t) { + case Tool::Sketch: + f.type = CadFeatureType::Sketch; + f.shape = (m_shape->GetSelection() == 0) ? SketchShape::Rectangle : SketchShape::Circle; + // The plane is STRUCTURAL, like Extrude's profile source. While EDITING it is preserved + // from the seeded original — the card carries no plane control and the old combo silently + // collapsed a face plane to a base plane through the modeling origin. While ADDING it + // comes from what is picked in the viewport. e1p. + if (!editing) { wxString where; f.plane = sketch_plane_from_selection(where); } + f.width = m_width->GetValue(); + f.height = m_height->GetValue(); + f.radius = m_radius->GetValue(); + break; + case Tool::Extrude: + f.type = CadFeatureType::Extrude; + f.distance = m_distance->GetValue(); + f.symmetric = false; + f.extrude_end = static_cast(m_extrude_end->GetSelection()); + f.distance2 = m_distance2->GetValue(); + f.taper_deg = m_taper->GetValue(); + f.flip = m_flip->GetValue(); + f.mode = (m_mode->GetSelection() == 0) ? BooleanMode::New + : (m_mode->GetSelection() == 1) ? BooleanMode::Add + : (m_mode->GetSelection() == 2) ? BooleanMode::Cut + : BooleanMode::Intersect; + // Profile source + up-to-face target are structural: re-derive them from the live + // tool state only when ADDING. While editing they are preserved from the seeded + // original (the card changed only depth/taper/flip/mode). + if (!editing) { + f.up_to_face = (f.extrude_end == ExtrudeEnd::UpToFace) ? m_sel_solid_face : -1; + if (m_extrude_face_src >= 0) { + // Face-as-profile extrude: the kernel grabs the body face by id. + f.extrude_src_face = m_extrude_face_src; + f.sketch_ref = -1; + } else if (extrude_uses_loop()) { + // Just the click-selected loop: carry its entity subset on the feature + // (sketch_ref = -1 -> build_sketch_wire uses f.entities). + f.sketch_ref = -1; + f.entities = m_viewport->selected_loop_entities(); + f.plane = m_doc.features[m_extrude_sketch_ref].plane; + } else { + f.sketch_ref = m_extrude_sketch_ref; + // On-face engraving Cut against the host body (matches the commit). + if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) + && m_doc.features[m_extrude_sketch_ref].import_on_face) + f.target_body = m_doc.features[m_extrude_sketch_ref].import_face_body; + } + } + break; + case Tool::Dressup: + f.type = (m_dressup_type->GetSelection() == 0) ? CadFeatureType::Fillet + : CadFeatureType::Chamfer; + f.dressup_size = m_dressup_size->GetValue(); + f.face_group = static_cast(m_face_group->GetSelection()); + // A click-selected solid edge overrides the face-group: dress THAT edge. + f.dressup_edge = m_sel_solid_edge; // -1 when no edge picked + break; + case Tool::Hole: + f.type = CadFeatureType::Hole; + f.plane = hole_plane(); + f.hole_diameter = m_hole_diameter->GetValue(); + f.hole_depth = m_hole_depth->GetValue(); + f.hole_through = m_hole_through->GetValue(); + f.hole_x = m_hole_x->GetValue(); + f.hole_y = m_hole_y->GetValue(); + if (m_hole_on_face) f.target_body = m_hole_face_body; // preview the right body + break; + case Tool::Thread: + f.type = CadFeatureType::Thread; + f.plane = thread_plane(); + f.thread_radius = m_thread_radius->GetValue() * 0.5; // field = diameter -> kernel radius + f.thread_pitch = m_thread_pitch->GetValue(); + f.thread_height = m_thread_height->GetValue(); + f.thread_depth = m_thread_depth->GetValue(); + f.thread_internal = m_thread_internal->GetValue(); + f.thread_x = m_thread_x->GetValue(); + f.thread_y = m_thread_y->GetValue(); + if (m_thread_on_face) f.target_body = m_thread_face_body; // tap the right body + break; + case Tool::Shell: + f.type = CadFeatureType::Shell; + f.shell_thickness = m_shell_thickness->GetValue(); + // A picked solid face opens the shell there; -1 = closed hollow. + f.shell_face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; + break; + case Tool::Draft: + f.type = CadFeatureType::Draft; + f.draft_angle = m_draft_angle->GetValue(); + f.draft_face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; + break; + case Tool::Revolve: + f.type = CadFeatureType::Revolve; + f.sketch_ref = m_revolve_sketch_ref; + f.revolve_angle = m_revolve_angle->GetValue(); + f.revolve_axis = m_revolve_axis->GetSelection(); + f.flip = m_revolve_flip->GetValue(); + f.mode = static_cast(m_revolve_mode->GetSelection()); + break; + case Tool::Sweep: { + f.type = CadFeatureType::Sweep; + f.sketch_ref = m_sweep_profile_ref; + const int sel = m_sweep_path ? m_sweep_path->GetSelection() : wxNOT_FOUND; + f.sweep_path_ref = (sel != wxNOT_FOUND) + ? int(reinterpret_cast(m_sweep_path->GetClientData(sel))) : -1; + f.mode = static_cast(m_sweep_mode->GetSelection()); + break; + } + case Tool::Pattern: + f.type = CadFeatureType::Pattern; + f.pattern_circular = (m_pattern_type->GetSelection() == 1); + f.pattern_count = int(m_pattern_count->GetValue()); + f.pattern_spacing = m_pattern_spacing->GetValue(); + f.pattern_dir = m_pattern_dir->GetSelection(); + f.pattern_angle = m_pattern_angle->GetValue(); + break; + case Tool::Plane: + f.type = CadFeatureType::Plane; + f.plane_base = m_plane_base->GetSelection(); + f.plane_offset = m_plane_offset->GetValue(); + f.plane_angle_tilt = m_plane_tilt->GetValue(); + f.plane_axis = m_plane_tilt_axis->GetSelection(); + apply_plane_refs(f); // plane_type + face/edge refs + u/v size from the card + break; + case Tool::Axis: + f.type = CadFeatureType::Axis; + apply_axis_refs(f); + break; + case Tool::CoordSys: + f.type = CadFeatureType::CoordSys; + apply_coordsys_refs(f); + break; + case Tool::Loft: { + f.type = CadFeatureType::Loft; + f.loft_ruled = m_loft_ruled->GetValue(); + f.mode = static_cast(m_loft_mode->GetSelection()); + f.loft_profile_refs.clear(); + for (unsigned i = 0; i < m_loft_list->GetCount(); ++i) + if (m_loft_list->IsChecked(i) && i < m_loft_sketch_idx.size()) + f.loft_profile_refs.push_back(m_loft_sketch_idx[i]); + break; + } + case Tool::Boolean: { + f.type = CadFeatureType::Boolean; + const int sel = m_bool_op->GetSelection(); + f.mode = (sel == 1) ? BooleanMode::Cut + : (sel == 2) ? BooleanMode::Intersect + : BooleanMode::Add; // 0 = Union + f.target_body = m_bool_target->GetSelection(); + f.bool_tool_body = m_bool_tool->GetSelection(); + f.bool_keep_tool = m_bool_keep->GetValue(); + f.bool_tolerance = m_bool_tol->GetValue(); // OCCT fuzzy: robust cut on near-coincident faces + break; + } + case Tool::Cut: + f.type = CadFeatureType::Cut; + f.plane = plane_from_choice(m_cut_plane->GetSelection()); + f.cut_offset = m_cut_offset->GetValue(); + f.cut_flip = false; + f.cut_keep_upper = true; // always split: keep both pieces as separate bodies + f.cut_keep_lower = true; + f.target_body = m_cut_target->GetSelection(); + break; + case Tool::SurfaceExtrude: + f.type = CadFeatureType::SurfaceExtrude; + f.sketch_ref = m_surf_extrude_sketch_ref; + f.distance = m_surf_extrude_distance->GetValue(); + break; + case Tool::SurfaceRevolve: + f.type = CadFeatureType::SurfaceRevolve; + f.sketch_ref = m_surf_revolve_sketch_ref; + f.revolve_angle = m_surf_revolve_angle->GetValue(); + f.revolve_axis = m_surf_revolve_axis->GetSelection(); + f.flip = m_surf_revolve_flip->GetValue(); + break; + case Tool::SurfaceLoft: { + f.type = CadFeatureType::SurfaceLoft; + f.loft_ruled = m_surf_loft_ruled->GetValue(); + f.loft_profile_refs.clear(); + for (unsigned i = 0; i < m_surf_loft_list->GetCount(); ++i) + if (m_surf_loft_list->IsChecked(i) && i < m_surf_loft_sketch_idx.size()) + f.loft_profile_refs.push_back(m_surf_loft_sketch_idx[i]); + break; + } + case Tool::SurfaceFill: + f.type = CadFeatureType::SurfaceFill; + f.sketch_ref = m_surf_fill_sketch_ref; + break; + case Tool::SurfaceOffset: { + f.type = CadFeatureType::SurfaceOffset; + f.target_body = sheet_choice_body(m_surf_offset_body); + f.plane_offset = m_surf_offset_distance->GetValue(); + break; + } + case Tool::ThickenSurface: { + f.type = CadFeatureType::ThickenSurface; + f.target_body = sheet_choice_body(m_surf_thicken_body); + f.thicken_thickness = m_surf_thicken_thickness->GetValue(); + f.thicken_flip = m_surf_thicken_flip->GetValue(); + break; + } + case Tool::Transform: { + f.type = CadFeatureType::Transform; + const int sel = m_xf_body->GetSelection(); + f.target_body = (sel != wxNOT_FOUND) ? sel : -1; + f.xf_translate = Vec3d(m_xf_dx->GetValue(), m_xf_dy->GetValue(), m_xf_dz->GetValue()); + const int ax = m_xf_axis->GetSelection(); + f.xf_axis = (ax == 0) ? Vec3d(1, 0, 0) : (ax == 1) ? Vec3d(0, 1, 0) : Vec3d(0, 0, 1); + f.xf_pivot = Vec3d(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue()); + f.xf_angle_deg = m_xf_angle->GetValue(); + f.xf_copy = m_xf_copy->GetValue(); + break; + } + case Tool::Mirror: { + f.type = CadFeatureType::Mirror; + const int sel = m_mirror_body->GetSelection(); + f.target_body = (sel != wxNOT_FOUND) ? sel : -1; + f.plane = plane_from_choice(m_mirror_plane->GetSelection()); + f.mirror_keep_original = m_mirror_keep->GetValue(); + f.mode = f.mirror_keep_original ? BooleanMode::New : BooleanMode::Add; + break; + } + case Tool::Thicken: { + f.type = CadFeatureType::Thicken; + const int sel = m_thicken_body->GetSelection(); + f.target_body = (sel != wxNOT_FOUND) ? sel : -1; + f.thicken_face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; + f.thicken_thickness = m_thicken_thickness->GetValue(); + f.thicken_flip = m_thicken_flip->GetValue(); + break; + } + case Tool::Rib: { + f.type = CadFeatureType::Rib; + const int bsel = m_rib_body->GetSelection(); + f.target_body = (bsel != wxNOT_FOUND) ? bsel : -1; + const int ssel = m_rib_sketch->GetSelection(); + f.rib_sketch_ref = (ssel != wxNOT_FOUND) + ? int(reinterpret_cast(m_rib_sketch->GetClientData(ssel))) : -1; + f.rib_entity = m_rib_entity->GetValue(); + f.rib_thickness = m_rib_thickness->GetValue(); + f.rib_depth = m_rib_depth->GetValue(); + break; + } + case Tool::Project: { + f.type = CadFeatureType::Project; + const int sel = m_proj_source_body->GetSelection(); + f.project_source_body = (sel != wxNOT_FOUND) ? sel : -1; + f.plane = plane_from_choice(m_proj_plane->GetSelection()); + if (m_sel_solid_face >= 0) { + f.project_face = m_sel_solid_face; + } else { + f.project_face = -1; + } + f.project_edges.clear(); // empty => use project_face + break; + } + case Tool::DeleteFace: { + f.type = CadFeatureType::DeleteFace; + const int sel = m_del_face_body->GetSelection(); + f.target_body = (sel != wxNOT_FOUND) ? sel : -1; + f.delete_faces = m_del_faces; + break; + } + case Tool::Helix: { + f.type = CadFeatureType::Helix; + f.plane = plane_from_choice(m_helix_plane->GetSelection()); + f.helix_radius = m_helix_radius->GetValue(); + f.helix_pitch = m_helix_pitch->GetValue(); + f.helix_height = m_helix_height->GetValue(); + f.helix_left_handed = m_helix_left_handed->GetValue(); + f.helix_taper_deg = m_helix_taper->GetValue(); + break; + } + case Tool::Mate: { + f.type = CadFeatureType::Mate; + f.mate_kind = m_mate_kind->GetSelection(); + f.mate_cs_a = m_mate_cs_a->GetSelection() != wxNOT_FOUND + ? int(reinterpret_cast(m_mate_cs_a->GetClientData(m_mate_cs_a->GetSelection()))) : -1; + f.mate_cs_b = m_mate_cs_b->GetSelection() != wxNOT_FOUND + ? int(reinterpret_cast(m_mate_cs_b->GetClientData(m_mate_cs_b->GetSelection()))) : -1; + f.mate_offset = m_mate_offset->GetValue(); + f.mate_angle = m_mate_angle->GetValue(); + f.mate_flip = m_mate_flip->GetValue(); + break; + } + case Tool::Insert: // imported art is committed by add_imported_sketch, not build_candidate + case Tool::None: + break; + } + // Boolean drives its own target/tool body from the card; every other tool targets the + // picked body (face-extrude reads its source face there, dress-up / hole / boolean-mode + // extrude mutate it). -1 when nothing is picked => auto (last body). + // Targeting the picked body is an ADD-time concern; while editing, the original feature's + // target_body is preserved from the seed (the card did not re-pick a body). + // HAZARD: this is a negative list, so a tool that picks its own body is broken by OMISSION — + // the card computes target_body and this line then throws it away. SurfaceOffset and + // ThickenSurface were missing: both read a SHEET body from their own combo and both had it + // overwritten with the picked SOLID's index. Any new card that picks a body belongs here. + if (!editing && m_active != Tool::Boolean && m_active != Tool::Cut + && m_active != Tool::Transform && m_active != Tool::Mirror && m_active != Tool::Thicken + && m_active != Tool::DeleteFace && m_active != Tool::Rib && m_active != Tool::Project + && m_active != Tool::Helix && m_active != Tool::SurfaceOffset + && m_active != Tool::ThickenSurface) + f.target_body = m_sel_solid_body; + return f; +} + +// Resolve the active Extrude's profile plane + a representative 2D centroid (arrow anchor) +// and push them to the viewport gizmo. Self-gates: clears the gizmo unless Extrude is open. +// Keep the Dress-up card honest about what Confirm will actually round: the single edge the +// user picked in the viewport, or the face-group. build_dressup reads m_sel_solid_edge first and +// only falls back to the group, so when an edge is picked the group combo is inert — grey it out +// rather than leave it showing a value it will not use. +void DesignPanel::sync_dressup_target() +{ + if (m_dressup_edge_label == nullptr) return; + const bool have_edge = (m_sel_solid_edge >= 0); + m_dressup_edge_label->SetLabel(have_edge + ? wxString::Format(_L("Edge %d"), m_sel_solid_edge) + : _L("(no edge picked — group below)")); + if (m_face_group != nullptr) m_face_group->Enable(!have_edge); + m_dressup_edge_label->Refresh(); +} + +void DesignPanel::update_fillet_gizmo() +{ + if (!m_viewport) return; + // Only while the Fillet/Chamfer card is open AND a solid EDGE is the target. Face-group + // dress-up (no picked edge) keeps the docked card with no in-canvas handle. The body centroid + // comes from the transformed display mesh so it matches the (transformed) edge sample points. + const bool ok = (m_active == Tool::Dressup) && m_sel_solid_edge >= 0 + && m_sel_solid_body >= 0 && m_sel_solid_body < int(m_disp_body_meshes.size()); + if (!ok) { m_viewport->clear_fillet_gizmo(); return; } + const Vec3d centroid = m_disp_body_meshes[m_sel_solid_body].bounding_box().center(); + m_viewport->begin_fillet_gizmo(centroid, m_dressup_size->GetValue()); +} + +// Push the active Hole card's plane + position + diameter/depth to the viewport gizmo. +// Grey the FEATURE buttons whose tool cannot run yet, and say why in the tooltip (o9j). +// Tommaso reported the array controls as MISSING; they were not, but Pattern with no body +// accepted the click, opened nothing, and wrote its refusal somewhere other than where the click +// happened — from the user's seat that is indistinguishable from a dead button. A control that +// cannot act should look like it cannot act, before it is pressed. +// +// Only the three top-level buttons that carry a body-count guard are gated. The same guard also +// appears on rows INSIDE the flyouts, and those stay live: a drawer holds sketch-only entries too, +// so disabling the drawer would hide tools that are perfectly usable. Their refusal message is +// still written, and now next to the geometry. +// +// The keyboard shortcuts (Shift+N / Shift+X / Shift+B) deliberately keep running the guarded +// action rather than being gated: a key press has no greyed-out state to see, so the sentence is +// the only feedback there is. +void DesignPanel::update_body_gates() +{ + const int n = int(m_doc.bodies.size()); + for (const BodyGate& g : m_body_gates) { + if (g.btn == nullptr) continue; + const bool live = n >= g.min_bodies; + g.btn->Enable(live); + g.btn->SetToolTip(live ? g.tip_live : g.tip_gated); + } +} + +// Self-gates: clears the gizmo unless the Hole card is open. +void DesignPanel::update_hole_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Hole) { m_viewport->clear_hole_gizmo(); return; } + const SketchPlane plane = hole_plane(); + m_viewport->set_hole_face_bounds(m_hole_has_bounds, m_hole_umin, m_hole_umax, + m_hole_vmin, m_hole_vmax); + m_viewport->begin_hole_gizmo(plane, + m_hole_x->GetValue(), m_hole_y->GetValue(), + m_hole_diameter->GetValue(), m_hole_depth->GetValue(), + m_hole_through->GetValue()); +} + +// Push the active Thread card's plane + position + radius/length to the viewport gizmo. +// Self-gates: clears the gizmo unless the Thread card is open. +void DesignPanel::update_thread_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Thread) { m_viewport->clear_thread_gizmo(); return; } + const SketchPlane plane = thread_plane(); + m_viewport->begin_thread_gizmo(plane, + m_thread_x->GetValue(), m_thread_y->GetValue(), + m_thread_radius->GetValue() * 0.5, m_thread_height->GetValue()); +} + +// Anchor an inward thickness arrow at the picked open face's centroid (along -outward-normal). +// Self-gates: clears unless the Shell card is open AND a face is picked. The face centroid/normal +// come from the kernel shape, then carry the body's display-only Move transform. +void DesignPanel::update_shell_gizmo() +{ + if (!m_viewport) return; + const int b = m_sel_solid_body; + const bool ok = (m_active == Tool::Shell) && m_sel_solid_face >= 0 + && b >= 0 && b < int(m_doc.bodies.size()); + if (!ok) { m_viewport->clear_shell_gizmo(); return; } + const TopoDS_Face fc = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); + if (fc.IsNull()) { m_viewport->clear_shell_gizmo(); return; } + Vec3d c = GeometryEngine::face_centroid_world(fc); + Vec3d n = GeometryEngine::face_normal_world(fc); + sync_body_xform(); + if (b < int(m_body_xform.size())) { + c = m_body_xform[b] * c; + n = m_body_xform[b].linear() * n; + } + if (n.norm() < 1e-9) { m_viewport->clear_shell_gizmo(); return; } + // Arrow points inward (into the wall): -outward normal. + m_viewport->begin_shell_gizmo(c, (-n).normalized(), m_shell_thickness->GetValue()); +} + +void DesignPanel::update_revolve_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Revolve + || m_revolve_sketch_ref < 0 || m_revolve_sketch_ref >= int(m_doc.features.size())) { + m_viewport->clear_revolve_gizmo(); + return; + } + const CadFeature& sk = m_doc.features[m_revolve_sketch_ref]; + // Profile centroid in sketch coords (same rule as the Extrude gizmo: average entity centres, + // else profile points, else the plane origin for primitive shapes). + Vec2d centroid(0, 0); + if (!sk.entities.empty()) { + Vec2d acc(0, 0); int n = 0; + for (const SketchEntity& e : sk.entities) { + switch (e.type) { + case SketchEntity::Type::Line: acc += 0.5 * (e.p0 + e.p1); ++n; break; + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: + case SketchEntity::Type::Circle: + case SketchEntity::Type::Ellipse: acc += e.center; ++n; break; + case SketchEntity::Type::Point: acc += e.p0; ++n; break; + case SketchEntity::Type::BSpline: + if (!e.ctrl.empty()) { + Vec2d s(0, 0); for (const Vec2d& q : e.ctrl) s += q; + acc += s / double(e.ctrl.size()); ++n; + } + break; + } + } + if (n > 0) centroid = acc / double(n); + } else if (!sk.profile.points.empty()) { + for (const Vec2d& p : sk.profile.points) centroid += p; + centroid /= double(sk.profile.points.size()); + } + m_viewport->begin_revolve_gizmo(sk.plane, centroid, m_revolve_axis->GetSelection(), + m_revolve_angle->GetValue(), m_revolve_flip->GetValue()); +} + +void DesignPanel::update_draft_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Draft || m_sel_solid_face < 0 || m_sel_solid_body < 0 + || m_sel_solid_body >= int(m_doc.bodies.size())) { + m_viewport->clear_draft_gizmo(); + return; + } + const TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape, m_sel_solid_face); + const Vec3d c = GeometryEngine::face_centroid_world(f); + const Vec3d n = GeometryEngine::face_normal_world(f); + m_viewport->set_draft_gizmo(c, n, m_draft_angle->GetValue()); +} + +void DesignPanel::update_cut_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Cut) { m_viewport->clear_cut_gizmo(); return; } + const int bi = m_cut_target ? m_cut_target->GetSelection() : -1; + if (bi < 0 || bi >= int(m_doc.bodies.size()) || bi >= int(m_doc.display_body_meshes.size())) { + m_viewport->clear_cut_gizmo(); + return; + } + const SketchPlane plane = plane_from_choice(m_cut_plane->GetSelection()); + const BoundingBoxf3 bb = m_doc.display_body_meshes[bi].bounding_box(); + const Vec3d center = bb.center(); + const double half = std::max(0.5 * (bb.max - bb.min).norm(), 10.0); + m_viewport->set_cut_gizmo(plane, m_cut_offset->GetValue(), center, half); +} + +void DesignPanel::update_operand_highlight() +{ + if (!m_viewport) return; + // default: nothing highlighted + int bt = -1, bl = -1; + std::vector> sk; + if (m_active == Tool::Boolean) { + if (m_bool_target) bt = m_bool_target->GetSelection(); + if (m_bool_tool) bl = m_bool_tool->GetSelection(); + } else if (m_active == Tool::Sweep) { + if (m_sweep_profile_ref >= 0) sk.emplace_back(m_sweep_profile_ref, ColorRGBA(0.30f, 0.85f, 1.0f, 1.0f)); // profile = cyan + if (m_sweep_path_ref >= 0) sk.emplace_back(m_sweep_path_ref, ColorRGBA(1.00f, 0.40f, 0.90f, 1.0f)); // path = magenta + } else if (m_active == Tool::Loft) { + // checked rows of m_loft_list map to feature indices via m_loft_sketch_idx + // (exactly as build_candidate(Tool::Loft) reads them). + if (m_loft_list) + for (unsigned i = 0; i < m_loft_list->GetCount(); ++i) + if (m_loft_list->IsChecked(i) && i < m_loft_sketch_idx.size()) + sk.emplace_back(m_loft_sketch_idx[i], ColorRGBA(0.40f, 0.90f, 0.50f, 1.0f)); // profiles = green + } + m_viewport->set_operand_bodies(bt, bl); + m_viewport->set_highlight_sketches(std::move(sk)); +} + +void DesignPanel::update_pattern_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Pattern || m_doc.display_body_meshes.empty()) { + m_viewport->clear_pattern_gizmo(); + return; + } + // Pattern operates in the default world XY plane (matches the kernel: linear along world X/Y, + // circular about world Z through the origin). Anchor on the target body's bbox centre; if that + // body carries a display-only Move transform, shift the plane origin + anchor by it so the + // gizmo sits on the body where the ghost copies actually appear. + const int b = (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.display_body_meshes.size())) + ? m_sel_solid_body : int(m_doc.display_body_meshes.size()) - 1; + SketchPlane plane; // world XY axes by default + Vec3d base = m_doc.display_body_meshes[b].bounding_box().center(); + if (b < int(m_body_xform.size())) { + plane.origin = m_body_xform[b].translation(); + base = m_body_xform[b] * base; + } + m_viewport->begin_pattern_gizmo(plane, base, m_pattern_type->GetSelection() == 1, + int(m_pattern_count->GetValue()), m_pattern_dir->GetSelection(), + m_pattern_spacing->GetValue(), m_pattern_angle->GetValue()); +} + +void DesignPanel::update_extrude_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Extrude && m_active != Tool::SurfaceExtrude + && m_active != Tool::Thicken && m_active != Tool::Rib + && m_active != Tool::SurfaceOffset && m_active != Tool::ThickenSurface) { + m_viewport->clear_extrude_gizmo(); return; + } + + // SurfaceOffset and ThickenSurface: one distance each, applied to a whole SHEET body chosen + // from a combo. A sheet has no single normal, so there is no frame to anchor an arrow on — + // which is why these were the two tools left with no handle at all. + // + // The way out is not to change what the tool operates on. Both still offset or thicken the + // ENTIRE sheet named in the combo; the picked face only says WHERE TO STAND THE ARROW. So + // the arrow appears whenever the user has a face of that sheet under selection, and its + // absence costs nothing — the card alone still works exactly as before. Purely additive, so + // no existing flow changes and there is no new precondition to learn. + if (m_active == Tool::SurfaceOffset || m_active == Tool::ThickenSurface) { + const bool off = (m_active == Tool::SurfaceOffset); + const int sheet = sheet_choice_body(off ? m_surf_offset_body : m_surf_thicken_body); + // Only when the picked face belongs to the sheet the tool will actually act on: + // an arrow standing on a different body would name the wrong thing. + if (sheet < 0 || sheet >= int(m_doc.bodies.size()) + || m_sel_solid_body != sheet || m_sel_solid_face < 0) { + m_viewport->clear_extrude_gizmo(); return; + } + TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[sheet].shape, m_sel_solid_face); + if (f.IsNull()) { m_viewport->clear_extrude_gizmo(); return; } + SketchPlane plane = SketchPlane::from_face(f); + Vec3d c = GeometryEngine::face_centroid_world(f); + Vec3d n = GeometryEngine::face_normal_world(f); + if (f.Orientation() == TopAbs_REVERSED) n = -n; + sync_body_xform(); + if (sheet < int(m_body_xform.size())) { + c = m_body_xform[sheet] * c; + n = m_body_xform[sheet].linear() * n; + } + plane.origin = c; + if (n.norm() > 1e-9) plane.normal = n.normalized(); + wxSpinCtrlDouble* spin = off ? m_surf_offset_distance : m_surf_thicken_thickness; + m_viewport->set_extrude_gizmo(plane, Vec2d(0, 0), spin ? spin->GetValue() : 0.0, + 0.0, false, + !off && m_surf_thicken_flip && m_surf_thicken_flip->GetValue()); + return; + } + + if (m_active == Tool::Rib) { + // Rib's DEPTH is a distance along the sketch plane normal — the same arrow again, + // anchored at the midpoint of the line the rib is built on rather than at a profile + // centroid, because a rib's line is the whole profile. + // + // This covers only half of L2 for Rib: the THICKNESS is an in-plane offset either side + // of that line and has no handle, which needs an affordance that does not exist yet. + // Half a tool made draggable is still strictly better than none — the remaining half is + // filed separately rather than left implied. + const int ssel = m_rib_sketch ? m_rib_sketch->GetSelection() : wxNOT_FOUND; + const int ref = (ssel != wxNOT_FOUND) + ? int(reinterpret_cast(m_rib_sketch->GetClientData(ssel))) : -1; + if (ref < 0 || ref >= int(m_doc.features.size())) { m_viewport->clear_extrude_gizmo(); return; } + const CadFeature& sk = m_doc.features[ref]; + const int ei = m_rib_entity ? m_rib_entity->GetValue() : 0; + if (ei < 0 || ei >= int(sk.entities.size())) { m_viewport->clear_extrude_gizmo(); return; } + const SketchEntity& e = sk.entities[ei]; + const Vec2d mid = (e.type == SketchEntity::Type::Line) ? Vec2d(0.5 * (e.p0 + e.p1)) + : Vec2d(e.center); + m_viewport->set_extrude_gizmo(sk.plane, mid, + m_rib_depth ? m_rib_depth->GetValue() : 0.0, + 0.0, false, false); + return; + } + + if (m_active == Tool::SurfaceExtrude) { + const int ref = m_surf_extrude_sketch_ref; + if (ref < 0 || ref >= int(m_doc.features.size())) { m_viewport->clear_extrude_gizmo(); return; } + const CadFeature& sk = m_doc.features[ref]; + Vec2d c(0, 0); + if (!sk.profile.points.empty()) { + for (const Vec2d& p : sk.profile.points) c += p; + c /= double(sk.profile.points.size()); + } + m_viewport->set_extrude_gizmo(sk.plane, c, + m_surf_extrude_distance ? m_surf_extrude_distance->GetValue() : 0.0, + 0.0, false, false); + return; + } + + if (m_active == Tool::Thicken) { + const int b = m_sel_solid_body; + if (b < 0 || b >= int(m_doc.bodies.size()) || m_sel_solid_face < 0) { + m_viewport->clear_extrude_gizmo(); return; + } + TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); + if (f.IsNull()) { m_viewport->clear_extrude_gizmo(); return; } + SketchPlane plane = SketchPlane::from_face(f); + Vec3d c = GeometryEngine::face_centroid_world(f); + Vec3d n = GeometryEngine::face_normal_world(f); + if (f.Orientation() == TopAbs_REVERSED) n = -n; // outward + sync_body_xform(); + if (b < int(m_body_xform.size())) { c = m_body_xform[b] * c; n = m_body_xform[b].linear() * n; } + plane.origin = c; + if (n.norm() > 1e-9) plane.normal = n.normalized(); + m_viewport->set_extrude_gizmo(plane, Vec2d(0, 0), + m_thicken_thickness ? m_thicken_thickness->GetValue() : 0.0, + 0.0, false, + m_thicken_flip && m_thicken_flip->GetValue()); + return; + } + + SketchPlane plane; + std::vector ents; + Vec2d centroid(0, 0); + bool have = false, have_centroid = false; + if (extrude_uses_loop()) { + plane = m_doc.features[m_extrude_sketch_ref].plane; + ents = m_viewport->selected_loop_entities(); + have = true; + } else if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size())) { + const CadFeature& sk = m_doc.features[m_extrude_sketch_ref]; + plane = sk.plane; + ents = sk.entities; + have = true; + if (ents.empty() && !sk.profile.points.empty()) { + for (const Vec2d& p : sk.profile.points) centroid += p; + centroid /= double(sk.profile.points.size()); + have_centroid = true; + } + // Primitive shape sketches (no entities/profile) are centred at the plane origin -> (0,0). + } else if (m_extrude_face_src >= 0 && !m_doc.bodies.empty()) { + // Face-as-profile (push/pull): anchor the arrow at the picked face's CENTROID, pointing + // along its outward normal. The face id is LOCAL to the owner body — route_feature reads + // it from `context` (the target, else the last body) — so look it up on that SAME body, + // never the whole-document compound (m_doc.body). from_face's plane origin sits at the + // plane's canonical point near the world origin, NOT on the face, which is why the arrow + // used to land on the bed. Carry the body's display Move transform so the arrow sits where + // the body is actually shown. + const int b = int(m_doc.bodies.size()) - 1; // matches route_feature's default context + TopoDS_Face srcf = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_extrude_face_src); + if (!srcf.IsNull()) { + plane = SketchPlane::from_face(srcf); + Vec3d c = GeometryEngine::face_centroid_world(srcf); + Vec3d n = GeometryEngine::face_normal_world(srcf); + if (srcf.Orientation() == TopAbs_REVERSED) n = -n; // outward, matches the kernel push + sync_body_xform(); + if (b < int(m_body_xform.size())) { c = m_body_xform[b] * c; n = m_body_xform[b].linear() * n; } + plane.origin = c; + if (n.norm() > 1e-9) plane.normal = n.normalized(); + have = true; + } + } + if (!have) { m_viewport->clear_extrude_gizmo(); return; } + + if (!ents.empty()) { + Vec2d acc(0, 0); int n = 0; + for (const SketchEntity& e : ents) { + switch (e.type) { + case SketchEntity::Type::Line: acc += 0.5 * (e.p0 + e.p1); ++n; break; + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: + case SketchEntity::Type::Circle: + case SketchEntity::Type::Ellipse: acc += e.center; ++n; break; + case SketchEntity::Type::Point: acc += e.p0; ++n; break; + case SketchEntity::Type::BSpline: + if (!e.ctrl.empty()) { + Vec2d s(0, 0); for (const Vec2d& q : e.ctrl) s += q; + acc += s / double(e.ctrl.size()); ++n; + } + break; + } + } + if (n > 0) { centroid = acc / double(n); have_centroid = true; } + } + (void)have_centroid; // centroid defaults to (0,0) for primitive sketches + + const ExtrudeEnd end = static_cast(m_extrude_end->GetSelection()); + m_viewport->set_extrude_gizmo(plane, centroid, + m_distance->GetValue(), m_distance2->GetValue(), + end == ExtrudeEnd::TwoSided, m_flip->GetValue()); +} + +void DesignPanel::refresh_datum_planes() +{ + if (!m_viewport) return; + std::vector dplanes; + for (const auto& dp : m_doc.resolve_datum_planes()) dplanes.push_back(dp.second); + // Parallel per-plane extents, in the SAME order resolve_datum_planes emits + // (enabled Plane features, document order), so each datum draws at its u/v size. + std::vector dsizes; + for (const auto& f : m_doc.features) + if (f.type == CadFeatureType::Plane && f.enabled) + dsizes.emplace_back(f.plane_u_size, f.plane_v_size); + m_viewport->set_datum_planes(std::move(dplanes), std::move(dsizes)); + refresh_mate_connectors(); +} + +// Feed the connector frames to the viewport. resolve_datum_coordsys() emits them in document order +// over enabled CoordSys features, so the feature index can be recovered by walking the same filter — +// which is what tells us whether a given frame is the one the open Mate card will DRIVE. +void DesignPanel::refresh_mate_connectors() +{ + if (!m_viewport) return; + // Polarity is a property of the MATE, not of an open dialog: a connector that some committed + // mate drives must read as driven whenever it is on screen, or the glyph only tells the truth + // while a card happens to be open. The card, when open, wins — it is the live intent. + std::map role_by_feature; // feature index -> 1 fixed, 2 driven + for (const CadFeature& mf : m_doc.features) { + if (mf.type != CadFeatureType::Mate || !mf.enabled) continue; + if (mf.mate_cs_a >= 0) role_by_feature[mf.mate_cs_a] = 1; + if (mf.mate_cs_b >= 0) role_by_feature[mf.mate_cs_b] = 2; + } + const int cs_a = (m_active == Tool::Mate && m_mate_cs_a) ? m_mate_cs_a->GetSelection() : -1; + const int cs_b = (m_active == Tool::Mate && m_mate_cs_b) ? m_mate_cs_b->GetSelection() : -1; + + std::vector out; + // Origins, keyed both ways: a committed mate names its connectors by FEATURE index, the open + // card's combos by ordinal. Only resolved frames land here, so an unresolved end simply draws + // no pair line rather than a line to the origin of the world. + std::map origin_by_feature, origin_by_ordinal; + const auto frames = m_doc.resolve_datum_coordsys(); + size_t k = 0; + for (size_t i = 0; i < m_doc.features.size() && k < frames.size(); ++i) { + const CadFeature& f = m_doc.features[i]; + if (f.type != CadFeatureType::CoordSys || !f.enabled) continue; + const auto& fr = frames[k]; + const int ordinal = int(k); + ++k; + if (!fr.error.empty()) continue; // unresolved: draw nothing, never a guess + DesignSketchTool::MateConnectorGlyph g; + g.origin = fr.origin; + g.x = fr.x; + g.y = fr.y; + const auto it = role_by_feature.find(int(i)); + g.role = (ordinal == cs_b) ? 2 : (ordinal == cs_a ? 1 + : (it != role_by_feature.end() ? it->second : 0)); + // A face-only connector whose face gave no usable in-plane edge fell back to the hint; that + // is the case the glyph has to confess rather than absorb. + g.roll_undefined = (f.coordsys_type == CoordSysType::FaceAndDirection + && f.coordsys_edge < 0); + origin_by_feature[int(i)] = g.origin; + origin_by_ordinal[ordinal] = g.origin; + out.push_back(g); + } + + std::vector> links; + auto add_link = [&links](const std::map& m, int a, int b) { + const auto ia = m.find(a), ib = m.find(b); + if (ia != m.end() && ib != m.end()) links.emplace_back(ia->second, ib->second); + }; + for (const CadFeature& mf : m_doc.features) + if (mf.type == CadFeatureType::Mate && mf.enabled) + add_link(origin_by_feature, mf.mate_cs_a, mf.mate_cs_b); + if (cs_a >= 0 && cs_b >= 0 && cs_a != cs_b) + add_link(origin_by_ordinal, cs_a, cs_b); // the live pick, not yet committed + + m_viewport->set_mate_connectors(std::move(out)); + m_viewport->set_mate_links(std::move(links)); +} + +void DesignPanel::update_datum_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Plane) { m_viewport->clear_datum_gizmo(); update_reference_planes(); return; } + + // Resolve the candidate plane's FRAME against the doc. resolve_datum_planes() is const and + // doesn't rebuild bodies, so transiently swap/append the candidate to read its resolved frame, + // then restore — works for both a fresh (uncommitted) plane and an edit of a committed one. + CadFeature f = build_candidate(Tool::Plane); + const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Plane); + CadFeature saved; + int slot; + if (editing) { saved = m_doc.features[m_edit_index]; m_doc.features[m_edit_index] = f; slot = m_edit_index; } + else { m_doc.features.push_back(f); slot = int(m_doc.features.size()) - 1; } + + auto datums = m_doc.resolve_datum_planes(); + // Ordinal of `slot` among enabled Plane features = its index in the resolved list. + int ord = -1; + for (int i = 0; i <= slot; ++i) + if (m_doc.features[i].type == CadFeatureType::Plane && m_doc.features[i].enabled) ++ord; + const bool ok = (ord >= 0 && ord < int(datums.size())); + SketchPlane frame; if (ok) frame = datums[ord].second; + + if (editing) m_doc.features[m_edit_index] = saved; else m_doc.features.pop_back(); + + if (!ok) { m_viewport->clear_datum_gizmo(); update_reference_planes(); return; } + + // Offset arrow anchor = the base/face origin (datum origin walked back along its normal by the + // offset). Works for both Offset-from-base (no tilt) and Offset-from-face exactly. + const Vec3d anchor = frame.origin - frame.normal * f.plane_offset; + const bool offset_on = (f.plane_type == PlaneType::Offset); + m_viewport->set_datum_gizmo(frame, f.plane_u_size, f.plane_v_size, + anchor, frame.normal, f.plane_offset, offset_on); + update_reference_planes(); // base ghosts (origins + datums) follow the tool/model state +} + +void DesignPanel::update_helix_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Helix) { m_viewport->clear_helix_gizmo(); return; } + m_viewport->set_helix_gizmo(plane_from_choice(m_helix_plane->GetSelection()), + m_helix_radius ? m_helix_radius->GetValue() : 0.0, + m_helix_pitch ? m_helix_pitch->GetValue() : 1.0, + m_helix_height ? m_helix_height->GetValue() : 0.0, + m_helix_taper ? m_helix_taper->GetValue() : 0.0, + m_helix_left_handed && m_helix_left_handed->GetValue()); +} + +void DesignPanel::update_rib_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Rib) { m_viewport->clear_rib_gizmo(); return; } + // Same resolution the Tool::Rib branch of update_extrude_gizmo() uses — the depth arrow and + // this share one sketch and one entity, and must never disagree about which line that is. + const int ssel = m_rib_sketch ? m_rib_sketch->GetSelection() : wxNOT_FOUND; + const int ref = (ssel != wxNOT_FOUND) + ? int(reinterpret_cast(m_rib_sketch->GetClientData(ssel))) : -1; + if (ref < 0 || ref >= int(m_doc.features.size())) { m_viewport->clear_rib_gizmo(); return; } + const CadFeature& sk = m_doc.features[ref]; + const int ei = m_rib_entity ? m_rib_entity->GetValue() : 0; + if (ei < 0 || ei >= int(sk.entities.size())) { m_viewport->clear_rib_gizmo(); return; } + const SketchEntity& e = sk.entities[ei]; + if (e.type != SketchEntity::Type::Line) { m_viewport->clear_rib_gizmo(); return; } // rib is line-only + m_viewport->set_rib_gizmo(sk.plane, e.p0, e.p1, + m_rib_thickness ? m_rib_thickness->GetValue() : 0.0); +} + +// Onshape default planes: the XY/XZ/YZ reference planes are persistent, transparent, labelled, and +// larger than the bed — shown as the FALLBACK when there is no object yet. When the Plane tool is +// open they additionally surface existing datums so a base can be picked. Single authority for the +// reference-plane overlay (set/clear_base_pick). +void DesignPanel::update_reference_planes() +{ + if (!m_viewport) return; + // Default planes pass through the modeling origin (bed centre) — same point the kernel uses to + // resolve XY/XZ/YZ datum bases, so the ghosts, the datums and new sketches all coincide. + const Vec3d o = m_doc.modeling_origin; + SketchPlane xy = SketchPlane::XY(); xy.origin += o; + SketchPlane xz = SketchPlane::XZ(); xz.origin += o; + SketchPlane yz = SketchPlane::YZ(); yz.origin += o; + std::vector bp = { xy, xz, yz }; + std::vector bi = { 0, 1, 2 }; + std::vector bl = { "XY", "XZ", "YZ" }; + + if (m_active == Tool::Plane) { + // Base picking only makes sense for the Offset method (others reference faces/edges). + if (m_plane_type && (PlaneType)m_plane_type->GetSelection() == PlaneType::Offset) { + auto datums = m_doc.resolve_datum_planes(); // already in world coords (origin applied) + for (int i = 0; i < int(datums.size()); ++i) { + bp.push_back(datums[i].second); bi.push_back(3 + i); bl.push_back(datums[i].first); + } + m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); + } else { + m_viewport->clear_base_pick(); + } + return; + } + // Fallback (Onshape default planes): show the 3 reference planes while there is no SOLID body + // yet — so they persist through the 2D-sketch phase and reappear after a sketch is confirmed + // (a sketch creates no body). They no longer block selection: clicking existing geometry wins, + // a base-plane pick only fires on a click that hit nothing else (see on_mouse fall-through). + // Available while there is no solid yet OR while the user is actually choosing a sketch + // plane. The second half fixes a dead end: delete a sketch on a document that still has a + // body, press Sketch, and act_sketch says "click a face or a reference plane" — with the + // reference planes already taken away, because a body existed. The instruction was + // impossible to follow and there was no way to start a sketch at all short of finding a + // face to click. + // + // Not simply always-on: m_dbp_active both RENDERS and picks, so three translucent planes + // would otherwise float over every finished model. Tying them to Sketch mode shows them + // exactly when they are the thing being chosen, and hides them again on Finish. + if (m_doc.bodies.empty() || m_ui_mode == UiMode::Sketch) + m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); + else + m_viewport->clear_base_pick(); +} + +TriangleMesh DesignPanel::ghost_from_bodies(const std::vector& per_body) const +{ + TriangleMesh out; + for (size_t b = 0; b < per_body.size(); ++b) { + TriangleMesh m = per_body[b]; + if (b < m_body_xform.size()) m.transform(m_body_xform[b]); + out.merge(m); + } + return out; +} + +bool DesignPanel::show_mate_ghost(int kind, int cs_a, int cs_b, + double offset, double angle_deg, bool flip, std::string& err) +{ + CadFeature cand; + cand.type = CadFeatureType::Mate; + cand.mate_kind = kind; + cand.mate_cs_a = cs_a; + cand.mate_cs_b = cs_b; + cand.mate_offset = offset; + cand.mate_angle = angle_deg; + cand.mate_flip = flip; + + TriangleMesh mesh; + std::vector pbm; + if (!m_doc.preview(cand, mesh, pbm, err)) { + m_viewport->clear_preview(); + m_viewport->set_body_hidden(false); + return false; + } + m_viewport->set_preview_mesh(ghost_from_bodies(pbm)); + // The ghost is the WHOLE assembly in its post-mate pose, not an added lump, so the committed + // bodies have to go: left visible they would draw the mated body in both places at once and + // z-fight everything else against its own copy. Same reason Dressup and Draft hide them. + m_viewport->set_body_hidden(true); + return true; +} + +void DesignPanel::refresh_preview() +{ + if (m_active == Tool::None) { m_viewport->clear_preview(); return; } + + // Transform has no ghost: it moves an existing body rather than building a new one, so its + // preview IS the body, shown through the display transform the gizmo already drives. + if (m_active == Tool::Transform) { xf_live_preview(); return; } + + // Features that produce NO solid: a sketch, the three datums, a helix curve, and Project + // (which emits sketch entities). They have no ghost to build, so they must not go through + // the solid-preview path below — it finds nothing and reports "invalid: preview produced + // no geometry", which is what Axis / CoordSys / Helix / Project did on an empty document. + // route_feature() skips these for the same reason, so the two agree on what is not a solid. + if (m_active == Tool::Sketch || m_active == Tool::Plane || m_active == Tool::Axis || + m_active == Tool::CoordSys || m_active == Tool::Helix || m_active == Tool::Project) { + m_viewport->clear_preview(); + m_status->SetForegroundColour(wxColour(120, 210, 120)); + wxString ready; + switch (m_active) { + case Tool::Plane: ready = _L("Plane ready"); break; + case Tool::Axis: ready = _L("Axis ready"); break; + case Tool::CoordSys: ready = _L("Coord Sys ready"); break; + case Tool::Helix: ready = _L("Helix ready"); break; + case Tool::Project: ready = _L("Project ready"); break; + default: ready = _L("Sketch ready"); break; + } + set_status(ready); + for (wxButton* b : m_confirm_btns) if (b) b->Enable(true); + m_status->Refresh(); + update_datum_gizmo(); // Plane card: show/refresh the in-canvas resize handles + update_helix_gizmo(); // Helix card: draw the live curve + drag handles (no solid ghost) + return; + } + + if (m_active == Tool::Mate) { + // A mate produces no NEW geometry, but it MOVES a body — and the moved assembly is + // exactly the ghost worth showing. This branch used to clear the preview outright, + // saying a mate has no 3D ghost; the kernel had no such opinion (preview() routes a + // Mate candidate through apply_mate on a throwaway copy), so that one line was the + // whole of gap G3. The card's own gate stays: Confirm is disabled when <2 CoordSys + // exist or when A == B, because neither of those is a kernel error, only an unfinished + // form, and the kernel's message for them would be worse than these. + const bool has_two = m_mate_cs_a && m_mate_cs_a->GetCount() >= 2; + const int sel_a = has_two ? m_mate_cs_a->GetSelection() : wxNOT_FOUND; + const int sel_b = has_two ? m_mate_cs_b->GetSelection() : wxNOT_FOUND; + const bool same = has_two && sel_a != wxNOT_FOUND && sel_b != wxNOT_FOUND && sel_a == sel_b; + bool ok = has_two && !same; + if (!has_two) { + m_viewport->clear_preview(); + m_viewport->set_body_hidden(false); // the ghost replaced them; give them back + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mate needs at least two CoordSys features")); + } else if (same) { + m_viewport->clear_preview(); + m_viewport->set_body_hidden(false); + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Mate: CS A and CS B must be different")); + } else { + sync_body_xform(); // same reason as the solid path below: drop stale per-body poses + const int cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(sel_a))); + const int cs_b = int(reinterpret_cast(m_mate_cs_b->GetClientData(sel_b))); + std::string err; + ok = show_mate_ghost(m_mate_kind ? m_mate_kind->GetSelection() : 0, cs_a, cs_b, + m_mate_offset ? m_mate_offset->GetValue() : 0.0, + m_mate_angle ? m_mate_angle->GetValue() : 0.0, + m_mate_flip && m_mate_flip->GetValue(), err); + if (ok) { + m_status->SetForegroundColour(wxColour(120, 210, 120)); + set_status(_L("Mate ready")); + } else { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Invalid: ") + wxString::FromUTF8(err)); + } + } + for (wxButton* b : m_confirm_btns) if (b) b->Enable(ok); + m_status->Refresh(); + return; + } + + // Trim m_body_xform to the LIVE committed bodies before building the ghost. A move + // writes a per-body display transform keyed by index; if a moved body is later deleted + // or consumed, its stale transform must not survive and get re-applied to whatever new + // body lands at that index — that was painting fresh extrudes as a moved+rotated ghost + // far from the sketch. resize() drops entries beyond the current body count. + sync_body_xform(); + + CadFeature cand = build_candidate(m_active); + TriangleMesh mesh; + std::string err; + bool ok = false; + + // The body is displayed through its per-body Move transform (m_body_xform); the ghost is + // built from the untransformed kernel, so without this it floats back at the origin once a + // body has been moved. Re-merge the per-body ghost meshes with the same transforms applied. + auto ghost_from = [this](const std::vector& pbm) { return ghost_from_bodies(pbm); }; + + const bool editing_single = (m_edit_index >= 0); + if (editing_single) { + // Edit-mode preview: stacking the candidate on top of the live body would + // re-apply the feature being edited (fillet-on-fillet) — wrong, and a + // source of OCCT failures. Instead evaluate the *replace* on a throwaway + // copy so the ghost is the true post-edit body. + CadDocument tmp = m_doc; + ok = tmp.replace_feature(m_edit_index, cand); + if (ok) mesh = ghost_from(tmp.display_body_meshes); else err = tmp.error; + } else { + std::vector pbm; + ok = m_doc.preview(cand, mesh, pbm, err); + if (ok) mesh = ghost_from(pbm); + } + + if (ok) { + m_viewport->set_preview_mesh(mesh); + m_status->SetForegroundColour(wxColour(120, 210, 120)); // ok = green + set_status(wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size())); + } else { + m_viewport->clear_preview(); + m_status->SetForegroundColour(wxColour(235, 110, 110)); // invalid = red + set_status(_L("Invalid: ") + wxString::FromUTF8(err)); + } + // Onshape parity: a broken candidate cannot be committed. Grey the active dialog's + // Confirm so the user sees the gate before clicking; the red status says why. + for (wxButton* b : m_confirm_btns) + if (b != nullptr) b->Enable(ok); + // Fillet/Chamfer/Draft: once the target edge/face yields a valid result, show ONLY the + // preview (hide the base bodies) so the user sees the finished shape, not the old solid + // doubled with the ghost. Before a valid pick the body stays visible so it can be picked. + m_viewport->set_body_hidden((m_active == Tool::Dressup || m_active == Tool::Draft) && ok); + m_status->Refresh(); + + // Refresh the in-canvas Extrude depth arrow (self-gates: only while the Extrude card is open). + update_extrude_gizmo(); + // Same for the Fillet/Chamfer radius arrow (self-gates: Dressup card + a picked edge). + update_fillet_gizmo(); + // Same for the Hole footprint circle + diameter/depth arrows (self-gates: Hole card). + update_hole_gizmo(); + // Same for the Thread footprint circle + radius/length arrows (self-gates: Thread card). + update_thread_gizmo(); + // Same for the Shell thickness arrow on the picked face (self-gates: Shell card + a face). + update_shell_gizmo(); + // Same for the Revolve angle-arc around the axis (self-gates: only while the Revolve card is open). + update_revolve_gizmo(); + // Same for the Draft angle-arc around the face centroid (self-gates: only while the Draft card is open). + update_draft_gizmo(); + // Same for the Cut plane-rectangle + offset arrow (self-gates: only while the Cut card is open). + update_cut_gizmo(); + // Same for the Pattern spacing arrow / angle-arc (self-gates: only while the Pattern card is open). + update_pattern_gizmo(); + // Datum-plane resize handles (self-gates: only while the Plane card is open). + update_datum_gizmo(); + // Helix curve + handles (self-gates: only while the Helix card is open). + update_helix_gizmo(); + // Rib slab footprint + thickness handles (self-gates: only while the Rib card is open). + update_rib_gizmo(); + update_operand_highlight(); +} + +// The tool-card frame is only worth drawing when a card is actually inside it — +// otherwise an empty bordered box floats above the feature tree. +void DesignPanel::update_cards_frame() +{ + if (m_cards == nullptr || m_cards->GetSizer() == nullptr) return; + wxSizer* root = m_form ? m_form->GetSizer() : nullptr; + if (root == nullptr) return; + bool any = false; + for (const wxSizerItem* it : m_cards->GetSizer()->GetChildren()) + if (it->IsShown()) { any = true; break; } + root->Show(m_cards, any, false); // non-recursive: don't re-show the hidden cards inside +} + +// Move/Rotate card: numeric entry that composes the same transform the drag gizmo builds — +// translation in world mm plus a rotation about the body's centroid, applied onto the pose the +// body had when Move opened (m_move_prev), so typing and dragging cannot fight each other. +void DesignPanel::apply_move_card() +{ + const int b = m_move_body; + if (b < 0 || b >= int(m_body_xform.size()) || b >= int(m_doc.display_body_meshes.size())) return; + + const double ang = m_move_angle ? m_move_angle->GetValue() * M_PI / 180.0 : 0.0; + const int ax = m_move_axis ? m_move_axis->GetSelection() : 2; + const Vec3d axis = (ax == 0) ? Vec3d::UnitX() : (ax == 1) ? Vec3d::UnitY() : Vec3d::UnitZ(); + const Vec3d d(m_move_dx ? m_move_dx->GetValue() : 0.0, + m_move_dy ? m_move_dy->GetValue() : 0.0, + m_move_dz ? m_move_dz->GetValue() : 0.0); + + // Rotate about the body's own centre, not the world origin. + const Vec3d pivot = m_move_prev * m_doc.display_body_meshes[b].bounding_box().center(); + Transform3d x = Transform3d::Identity(); + x.translate(d + pivot); + x.rotate(Eigen::AngleAxisd(ang, axis)); + x.translate(-pivot); + + m_body_xform[b] = x * m_move_prev; + feed_bodies(); + if (m_viewport) m_viewport->request_repaint(); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(_L("Body %d — moved (%.1f, %.1f, %.1f) mm, rotated %.1f°"), + b + 1, d.x(), d.y(), d.z(), + m_move_angle ? m_move_angle->GetValue() : 0.0)); + m_status->Refresh(); +} + +void DesignPanel::show_move_card(bool show) +{ + if (m_box_move == nullptr || m_cards == nullptr || m_cards->GetSizer() == nullptr) return; + m_cards->GetSizer()->Show(m_box_move, show, true); + update_cards_frame(); + if (m_form) { m_form->Layout(); m_form->FitInside(); } +} + +// Push the tool's current parameters into the live sketch tool before Polygon starts. They +// come from the offer's Polygon submenu (or from the last choice made there), never from a card. +void DesignPanel::push_polygon_params() +{ + if (m_viewport == nullptr) return; + m_viewport->set_sketch_polygon_sides(m_poly_sides); + m_viewport->set_sketch_polygon_circumscribed(m_poly_circumscribed); +} + +void DesignPanel::open_tool(Tool t) +{ + m_active = t; + // Fillet/Chamfer/Draft no longer fade the body see-through; instead, once a valid target + // is picked, refresh_preview hides the base bodies entirely (preview-only). Keep it opaque + // here so the body is fully visible for picking the edge/face. + // Body focus follows the CoordSys card: entering any other tool drops it. Read it back + // from the combo rather than clearing outright — editing a CoordSys feature loads the + // card (and its body) BEFORE open_tool runs, and a blind clear would strand the viewport + // showing "Body N" while picking stayed unrestricted. + if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); + m_viewport->set_xray_focus(t == Tool::CoordSys && m_cs_body ? m_cs_body->GetSelection() - 1 : -1); } + wxSizer* s = m_cards->GetSizer(); + s->Show(m_box_sketch, t == Tool::Sketch, true); + s->Show(m_box_extrude, t == Tool::Extrude, true); + s->Show(m_box_dressup, t == Tool::Dressup, true); + s->Show(m_box_hole, t == Tool::Hole, true); + s->Show(m_box_thread, t == Tool::Thread, true); + s->Show(m_box_shell, t == Tool::Shell, true); + s->Show(m_box_revolve, t == Tool::Revolve, true); + s->Show(m_box_sweep, t == Tool::Sweep, true); + s->Show(m_box_pattern, t == Tool::Pattern, true); + s->Show(m_box_plane, t == Tool::Plane, true); + s->Show(m_box_axis, t == Tool::Axis, true); + s->Show(m_box_coordsys, t == Tool::CoordSys, true); + s->Show(m_box_loft, t == Tool::Loft, true); + s->Show(m_box_boolean, t == Tool::Boolean, true); + s->Show(m_box_cut, t == Tool::Cut, true); + s->Show(m_box_draft, t == Tool::Draft, true); + s->Show(m_box_surf_extrude, t == Tool::SurfaceExtrude, true); + s->Show(m_box_surf_revolve, t == Tool::SurfaceRevolve, true); + s->Show(m_box_surf_loft, t == Tool::SurfaceLoft, true); + s->Show(m_box_surf_fill, t == Tool::SurfaceFill, true); + s->Show(m_box_surf_offset, t == Tool::SurfaceOffset, true); + s->Show(m_box_surf_thicken, t == Tool::ThickenSurface, true); + s->Show(m_box_transform, t == Tool::Transform, true); + s->Show(m_box_mirror, t == Tool::Mirror, true); + s->Show(m_box_thicken, t == Tool::Thicken, true); + s->Show(m_box_rib, t == Tool::Rib, true); + s->Show(m_box_project, t == Tool::Project, true); + s->Show(m_box_delete_face, t == Tool::DeleteFace, true); + s->Show(m_box_helix, t == Tool::Helix, true); + s->Show(m_box_mate, t == Tool::Mate, true); + s->Show(m_box_insert, t == Tool::Insert, true); + s->Show(m_box_expr, m_edit_index >= 0, true); // expression binding available during edit only + + if (t == Tool::Mate) { + // Populate both CoordSys pickers with every CoordSys feature; store the real + // feature index in client data. Keep prior selection when re-editing. + int keep_a = -1, keep_b = -1; + if (m_mate_cs_a->GetCount() > 0 && m_mate_cs_a->GetSelection() != wxNOT_FOUND) { + const auto* cl = m_mate_cs_a->GetClientData(m_mate_cs_a->GetSelection()); + if (cl) keep_a = int(reinterpret_cast(cl)); + } + if (m_mate_cs_b->GetCount() > 0 && m_mate_cs_b->GetSelection() != wxNOT_FOUND) { + const auto* cl = m_mate_cs_b->GetClientData(m_mate_cs_b->GetSelection()); + if (cl) keep_b = int(reinterpret_cast(cl)); + } + m_mate_cs_a->Clear(); + m_mate_cs_b->Clear(); + for (int i = 0; i < int(m_doc.features.size()); ++i) { + if (m_doc.features[i].type != CadFeatureType::CoordSys) continue; + const wxString nm = wxString::FromUTF8(m_doc.features[i].name); + const int pos_a = combo_append_index(m_mate_cs_a, nm, i); + const int pos_b = combo_append_index(m_mate_cs_b, nm, i); + if (i == keep_a) m_mate_cs_a->SetSelection(pos_a); + if (i == keep_b) m_mate_cs_b->SetSelection(pos_b); + } + if (keep_a < 0 && m_mate_cs_a->GetCount() > 0) m_mate_cs_a->SetSelection(0); + if (keep_b < 0 && m_mate_cs_b->GetCount() > 0) m_mate_cs_b->SetSelection(0); + } + + if (t == Tool::Revolve && m_revolve_sketch_ref >= 0 + && m_revolve_sketch_ref < int(m_doc.features.size())) + m_revolve_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_revolve_sketch_ref].name)); + + if (t == Tool::Sweep) { + if (m_sweep_profile_ref >= 0 && m_sweep_profile_ref < int(m_doc.features.size())) + m_sweep_profile_label->SetLabel(_L("Profile: ") + + wxString::FromUTF8(m_doc.features[m_sweep_profile_ref].name)); + // Populate the path picker with every Sketch feature except the profile itself; + // the feature index rides in the entry's client data. Pre-select the stored path + // (re-edit), else the first available sketch. + m_sweep_path->Clear(); + int sel_idx = wxNOT_FOUND; + for (int i = 0; i < int(m_doc.features.size()); ++i) { + const CadFeature& sf = m_doc.features[i]; + if (sf.type != CadFeatureType::Sketch || i == m_sweep_profile_ref) continue; + const int pos = combo_append_index(m_sweep_path, wxString::FromUTF8(sf.name), i); + if (i == m_sweep_path_ref) sel_idx = pos; + } + if (sel_idx != wxNOT_FOUND) m_sweep_path->SetSelection(sel_idx); + else if (m_sweep_path->GetCount() > 0) m_sweep_path->SetSelection(0); + } + + if (t == Tool::Loft) { + // List every Sketch feature; the feature index for each row rides in + // m_loft_sketch_idx. Re-check the stored profile refs (re-edit). + m_loft_list->Clear(); + m_loft_sketch_idx.clear(); + for (int i = 0; i < int(m_doc.features.size()); ++i) { + const CadFeature& sf = m_doc.features[i]; + if (sf.type != CadFeatureType::Sketch) continue; + const int row = m_loft_list->Append(wxString::FromUTF8(sf.name)); + m_loft_sketch_idx.push_back(i); + if (std::find(m_loft_refs.begin(), m_loft_refs.end(), i) != m_loft_refs.end()) + m_loft_list->Check(row, true); + } + } + + if (t == Tool::SurfaceExtrude) { + if (m_surf_extrude_sketch_ref >= 0 && m_surf_extrude_sketch_ref < int(m_doc.features.size())) + m_surf_extrude_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_surf_extrude_sketch_ref].name)); + } + + if (t == Tool::SurfaceRevolve) { + if (m_surf_revolve_sketch_ref >= 0 && m_surf_revolve_sketch_ref < int(m_doc.features.size())) + m_surf_revolve_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_surf_revolve_sketch_ref].name)); + } + + if (t == Tool::SurfaceLoft) { + m_surf_loft_list->Clear(); + m_surf_loft_sketch_idx.clear(); + for (int i = 0; i < int(m_doc.features.size()); ++i) { + const CadFeature& sf = m_doc.features[i]; + if (sf.type != CadFeatureType::Sketch) continue; + const int row = m_surf_loft_list->Append(wxString::FromUTF8(sf.name)); + m_surf_loft_sketch_idx.push_back(i); + if (std::find(m_surf_loft_refs.begin(), m_surf_loft_refs.end(), i) != m_surf_loft_refs.end()) + m_surf_loft_list->Check(row, true); + } + } + + if (t == Tool::SurfaceFill) { + if (m_surf_fill_sketch_ref >= 0 && m_surf_fill_sketch_ref < int(m_doc.features.size())) + m_surf_fill_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_surf_fill_sketch_ref].name)); + } + + // Shell and Draft both take their face from the live pick at Confirm time, but until now + // only the PICK handler wrote their labels. So the natural order — pick the face, then open + // the card — left Shell reading "(all faces — closed hollow)" and Draft "(pick a side face)" + // while Confirm went on to use m_sel_solid_face regardless: the card described one operation + // and performed another. Initialise from the current selection here, exactly as Extrude does + // with m_extrude_face_src below. Skipped while re-editing, because load_feature_into_dialog + // has already written the label from the feature's own stored face and runs before this. + // Same reasoning for Dress-up, whose target is the picked EDGE (falling back to the group). + if (t == Tool::Dressup && m_edit_index < 0) + sync_dressup_target(); + + if (t == Tool::Shell && m_edit_index < 0) + m_shell_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(all faces — closed hollow)")); + + if (t == Tool::Draft && m_edit_index < 0) + m_draft_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(pick a side face)")); + + if (t == Tool::Extrude) { + if (m_extrude_face_src >= 0) + m_extrude_sketch_label->SetLabel( + wxString::Format(_L("Face %d (push/pull)"), m_extrude_face_src)); + else if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size())) + m_extrude_sketch_label->SetLabel(_L("Sketch: ") + + wxString::FromUTF8(m_doc.features[m_extrude_sketch_ref].name)); + // A fresh extrude defaults to New body — even when other bodies exist — so + // overlapping extrudes stay SEPARATE solids instead of silently fusing. Joining + // is opt-in (pick "Join"). Engraving art onto a face still defaults to Cut. + // (Edit-mode keeps the feature's stored mode, set below.) + if (m_edit_index < 0) { + const bool on_face_import = + m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) + && m_doc.features[m_extrude_sketch_ref].import_on_face; + if (on_face_import) { + m_mode->SetSelection(2); // Cut — engrave into the face + m_flip->SetValue(true); // extrude inward (the face normal points out) + } else { + m_mode->SetSelection(0); // New body (was: Add when a body already existed) + } + } + } + + // Retitle the active card's header: edit-mode shows the feature's real name, + // add-mode previews the type + next feature number (Onshape "Extrude 1"). + const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())); + auto title = [&](const wxString& base) -> wxString { + return editing ? wxString::FromUTF8(m_doc.features[m_edit_index].name) + : base + wxString::Format(" %d", m_feature_counter + 1); + }; + switch (t) { + case Tool::Sketch: m_hdr_sketch->SetLabel(title(_L("Sketch"))); break; + case Tool::Extrude: m_hdr_extrude->SetLabel(title(_L("Extrude"))); break; + case Tool::Dressup: m_hdr_dressup->SetLabel(title( + m_dressup_type->GetSelection() == 0 ? _L("Fillet") : _L("Chamfer"))); break; + case Tool::Hole: m_hdr_hole->SetLabel(title(_L("Hole"))); break; + case Tool::Thread: m_hdr_thread->SetLabel(title(_L("Thread"))); break; + case Tool::Shell: m_hdr_shell->SetLabel(title(_L("Shell"))); break; + case Tool::Revolve: m_hdr_revolve->SetLabel(title(_L("Revolve"))); break; + case Tool::Sweep: m_hdr_sweep->SetLabel(title(_L("Sweep"))); break; + case Tool::Pattern: m_hdr_pattern->SetLabel(title(_L("Pattern"))); break; + case Tool::Plane: m_hdr_plane->SetLabel(title(_L("Plane"))); break; + case Tool::Loft: m_hdr_loft->SetLabel(title(_L("Loft"))); break; + case Tool::Draft: m_hdr_draft->SetLabel(title(_L("Draft"))); break; + case Tool::Boolean: m_hdr_boolean->SetLabel(title(_L("Boolean"))); break; + case Tool::Cut: m_hdr_cut->SetLabel(title(_L("Cut"))); break; + case Tool::Axis: m_hdr_axis->SetLabel(title(_L("Axis"))); break; + case Tool::CoordSys: m_hdr_coordsys->SetLabel(title(_L("Coord Sys"))); break; + case Tool::SurfaceExtrude: m_hdr_surf_extrude->SetLabel(title(_L("Surface Extrude"))); break; + case Tool::SurfaceRevolve: m_hdr_surf_revolve->SetLabel(title(_L("Surface Revolve"))); break; + case Tool::SurfaceLoft: m_hdr_surf_loft->SetLabel(title(_L("Surface Loft"))); break; + case Tool::SurfaceFill: m_hdr_surf_fill->SetLabel(title(_L("Surface Fill"))); break; + case Tool::SurfaceOffset: m_hdr_surf_offset->SetLabel(title(_L("Surface Offset"))); break; + case Tool::ThickenSurface: m_hdr_surf_thicken->SetLabel(title(_L("Thicken Surface"))); break; + case Tool::Transform: m_hdr_transform->SetLabel(title(_L("Transform"))); break; + case Tool::Mirror: m_hdr_mirror->SetLabel(title(_L("Mirror"))); break; + case Tool::Thicken: m_hdr_thicken->SetLabel(title(_L("Thicken"))); break; + case Tool::Rib: m_hdr_rib->SetLabel(title(_L("Rib"))); break; + case Tool::Project: m_hdr_project->SetLabel(title(_L("Project"))); break; + case Tool::DeleteFace: m_hdr_delete_face->SetLabel(title(_L("Delete Face"))); break; + case Tool::Helix: m_hdr_helix->SetLabel(title(_L("Helix"))); break; + case Tool::Mate: m_hdr_mate->SetLabel(title(_L("Mate"))); break; + case Tool::Insert: break; // header set by open_insert_card() + case Tool::None: break; + } + + // A card that consumes a face must say WHICH face the moment it appears. These labels used to + // be written only by the pick handler, which runs while a card is already open — so a card + // opened FROM a selection (the offer's whole premise) showed the previous pick, or the "(pick + // one)" placeholder over a face it was in fact about to use. Same law as the Construction + // toggle: a control that carries state has to show it. One writer, on open, for all three. + { + const bool have = m_sel_solid_face >= 0; + const wxString face_name = have ? wxString::Format(_L("Face %d"), m_sel_solid_face) : wxString(); + if (t == Tool::Thicken && m_thicken_face_label) + m_thicken_face_label->SetLabel(have ? face_name : _L("(pick a solid face)")); + if (t == Tool::Shell && m_shell_face_label) + m_shell_face_label->SetLabel(have ? face_name : _L("(all faces — closed hollow)")); + if (t == Tool::Draft && m_draft_face_label) + m_draft_face_label->SetLabel(have ? face_name : _L("(pick a side face)")); + } + + if (editing) { + populate_expr_fields(t); // field-name combo for this feature type + // Display current expression bindings on the edited feature + const CadFeature& ef = m_doc.features[m_edit_index]; + if (ef.expr.empty()) { + m_expr_status->SetLabel(_L("(no bindings)")); + m_expr_status->SetForegroundColour(dp_sec_text()); + } else { + wxString s; + for (const auto& [field, e] : ef.expr) { + if (!s.IsEmpty()) s += "; "; + s += wxString::FromUTF8(field) + " = " + wxString::FromUTF8(e); + } + m_expr_status->SetLabel(s); + m_expr_status->SetForegroundColour(dp_ctl_text()); + } + } + + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + update_action_bar(); // a tool is now active -> show the unified ✓/✗ + refresh_preview(); + + // Add-mode Transform arms the move gizmo on the card's body so the prominent verb is the + // geometry-first control; the card mirrors the drag. Edit mode keeps today's card-only path. + if (t == Tool::Transform && m_edit_index < 0) + arm_transform_gizmo(); + // Opening Boolean re-aims the viewport picks at the target slot, so the first click after + // the card appears always means "keep this one" regardless of what the last session did. + if (t == Tool::Boolean) + m_bool_next_slot = 0; +} + +void DesignPanel::close_tool() +{ + m_active = Tool::None; + set_active_tool_btn(nullptr); // clear the active-tool teal highlight + // Single revert point for the Transform gizmo: Esc, switching tools, and Cancel all pass + // through here, so the body is never left displaced by a Transform that wasn't committed. + xf_clear_preview(); + if (m_xf_gizmo_body >= 0) { + sync_body_xform(); + if (m_xf_gizmo_body < int(m_body_xform.size())) + m_body_xform[m_xf_gizmo_body] = m_xf_gizmo_base; + if (m_viewport) m_viewport->clear_move_gizmo(); + feed_bodies(); + m_xf_gizmo_body = -1; + m_move_body = -1; + } + if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); m_viewport->set_xray_focus(-1); } // restore the opaque solid + wxSizer* s = m_cards->GetSizer(); + s->Show(m_box_sketch, false, true); + s->Show(m_box_extrude, false, true); + s->Show(m_box_dressup, false, true); + s->Show(m_box_hole, false, true); + s->Show(m_box_thread, false, true); + s->Show(m_box_shell, false, true); + s->Show(m_box_revolve, false, true); + s->Show(m_box_sweep, false, true); + s->Show(m_box_pattern, false, true); + s->Show(m_box_plane, false, true); + s->Show(m_box_axis, false, true); + s->Show(m_box_coordsys, false, true); + s->Show(m_box_loft, false, true); + s->Show(m_box_boolean, false, true); + s->Show(m_box_cut, false, true); + s->Show(m_box_draft, false, true); + s->Show(m_box_surf_extrude, false, true); + s->Show(m_box_surf_revolve, false, true); + s->Show(m_box_surf_loft, false, true); + s->Show(m_box_surf_fill, false, true); + s->Show(m_box_surf_offset, false, true); + s->Show(m_box_surf_thicken, false, true); + s->Show(m_box_transform, false, true); + s->Show(m_box_mirror, false, true); + s->Show(m_box_thicken, false, true); + s->Show(m_box_rib, false, true); + s->Show(m_box_project, false, true); + s->Show(m_box_delete_face, false, true); + s->Show(m_box_helix, false, true); + s->Show(m_box_mate, false, true); + s->Show(m_box_insert, false, true); + s->Show(m_box_expr, false, true); + m_viewport->clear_preview(); + m_viewport->clear_extrude_gizmo(); + m_viewport->clear_fillet_gizmo(); + m_viewport->clear_hole_gizmo(); + m_viewport->clear_thread_gizmo(); + m_viewport->clear_shell_gizmo(); + m_viewport->clear_revolve_gizmo(); + m_viewport->clear_draft_gizmo(); + m_viewport->clear_cut_gizmo(); + m_viewport->clear_pattern_gizmo(); + m_viewport->clear_datum_gizmo(); + m_viewport->set_operand_bodies(-1, -1); + m_viewport->set_highlight_sketches({}); + update_reference_planes(); // back to no-tool: show the origin planes if there is no object yet + update_cards_frame(); m_form->Layout(); + m_form->FitInside(); + update_action_bar(); // no feature tool active -> hide the bar (unless a mode keeps it) +} + +void DesignPanel::confirm_tool() +{ + // One undo boundary per committed feature (Extrude/Dressup/Hole/Thread/Shell, the + // legacy Sketch card via on_add_sketch, and edit-mode replace all funnel here). + m_doc.checkpoint(); + const bool editing_single = (m_edit_index >= 0); + + if (editing_single) { + // Edit mode: overwrite the existing feature instead of appending. + CadFeature cand = build_candidate(m_active); + bool ok = m_doc.replace_feature(m_edit_index, cand); + reset_edit_state(); + close_tool(); // clears the preview ghost + after_tree_edit(ok); // refresh tree/viewport/status (or "Edit rejected") + return; + } + + switch (m_active) { + case Tool::Sketch: on_add_sketch(); break; + case Tool::Extrude: on_add_extrude(); break; + case Tool::Dressup: on_add_dressup(); break; + case Tool::Hole: on_add_hole(); break; + case Tool::Thread: on_add_thread(); break; + case Tool::Shell: on_add_shell(); break; + case Tool::Revolve: on_add_revolve(); break; + case Tool::Sweep: on_add_sweep(); break; + case Tool::Pattern: on_add_pattern(); break; + case Tool::Plane: if (!on_add_plane()) return; break; // refused: keep the card and its picks + case Tool::Loft: on_add_loft(); break; + case Tool::Draft: on_add_draft(); break; + case Tool::Boolean: on_add_boolean(); break; + case Tool::Cut: on_add_cut(); break; + case Tool::Axis: on_add_axis(); break; + case Tool::CoordSys: on_add_coordsys(); break; + case Tool::Mate: on_add_mate(); break; + case Tool::SurfaceExtrude: on_add_surface_extrude(); break; + case Tool::SurfaceRevolve: on_add_surface_revolve(); break; + case Tool::SurfaceLoft: on_add_surface_loft(); break; + case Tool::SurfaceFill: on_add_surface_fill(); break; + case Tool::SurfaceOffset: on_add_surface_offset(); break; + case Tool::ThickenSurface: on_add_thicken_surface(); break; + case Tool::Transform: on_add_transform(); break; + case Tool::Mirror: on_add_mirror(); break; + case Tool::Thicken: on_add_thicken(); break; + case Tool::Rib: on_add_rib(); break; + case Tool::Project: on_add_project(); break; + case Tool::DeleteFace: on_add_delete_face(); break; + case Tool::Helix: on_add_helix(); break; + case Tool::Insert: return; // committed via finalize_insert(), never here + case Tool::None: return; + } + close_tool(); // also clears the preview ghost; the committed body is now shown +} + +void DesignPanel::cancel_tool() +{ + reset_edit_state(); // abort an in-progress edit: back to add-mode + close_tool(); + // Cancel discards the candidate: clear the stale "Preview …"/"Invalid …" + // label and restore the neutral idle colour (Confirm keeps its "OK" status). + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); +} + +// One Confirm surface for the whole tab. Routes to the right commit by current context: +// a feature card, the Insert placement, the Sketch session, or the Constrain session. +void DesignPanel::tool_confirm() +{ + if (m_value_cont) { confirm_value(); return; } // value card owns ribbon ✓ while a value is pending + if (m_active == Tool::None && m_viewport && m_viewport->moving_body()) { // keep the placement, drop the gizmo + m_viewport->clear_move_gizmo(); + m_move_body = -1; + show_move_card(false); + update_action_bar(); + set_status_ok(); + return; + } + if (m_active == Tool::Insert) { finalize_insert(); return; } + if (m_active != Tool::None) { confirm_tool(); return; } + if (m_ui_mode == UiMode::Sketch) { + if (m_viewport && m_viewport->is_sketching()) m_viewport->finish_sketch(); + set_ui_mode(UiMode::Feature); + return; + } + if (m_ui_mode == UiMode::Constrain) { + cancel_value(); + if (m_viewport) m_viewport->end_constrain(); + m_constrain_feat = -1; + set_ui_mode(UiMode::Feature); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); + } +} + +// One Cancel/exit surface (also bound to Esc). Discards the active feature/insert, or a +// drawn-but-uncommitted Sketch, or exits Constrain. +void DesignPanel::tool_cancel() +{ + if (m_value_cont) { cancel_value(); return; } // value card owns ribbon ✗ while a value is pending + if (m_active == Tool::None && m_viewport && m_viewport->moving_body()) { // revert to the pose at move-start + sync_body_xform(); + if (m_move_body >= 0 && m_move_body < int(m_body_xform.size())) + m_body_xform[m_move_body] = m_move_prev; + m_viewport->clear_move_gizmo(); + m_move_body = -1; + show_move_card(false); + feed_bodies(); // re-render the reverted placement + update_action_bar(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Move cancelled")); + m_status->Refresh(); + return; + } + if (m_active == Tool::Insert) { cancel_insert(); return; } + if (m_active != Tool::None) { cancel_tool(); return; } + if (m_ui_mode == UiMode::Sketch) { + // THE explicit discard. Esc no longer arrives here at all (it routes through escape(), + // which cannot destroy anything), so this button is now the only way a drawn sketch is + // thrown away — and being the only way, it has to ask. It used to refuse instead, telling + // the user to press the very button they had just pressed: a sketch could be kept but + // never discarded. + if (m_viewport && m_viewport->live_sketch_has_work()) { + wxMessageDialog dlg(this, + _L("Discard this sketch and everything drawn in it?"), + _L("Discard sketch"), + wxYES_NO | wxNO_DEFAULT | wxICON_EXCLAMATION); + dlg.SetYesNoLabels(_L("Discard"), _L("Keep drawing")); + if (dlg.ShowModal() != wxID_YES) return; + } + if (m_viewport) m_viewport->cancel_sketch(); // drop the live session (committed art stays) + m_edit_index = -1; + set_ui_mode(UiMode::Feature); + sync_sketch_display(); + refresh_tree(); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); + return; + } + if (m_ui_mode == UiMode::Constrain) { + cancel_value(); + if (m_viewport) m_viewport->end_constrain(); + m_constrain_feat = -1; + set_ui_mode(UiMode::Feature); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); + } +} + +// Which level of the interaction stack one Esc press belongs to. The rule itself lives in +// DesignInteraction.hpp, decidable without a window; this only answers the four questions it +// asks about THIS panel. +CadLevel DesignPanel::escape_level() const +{ + CadInteractionState st; + // A value being typed is the deepest thing on screen, whether it is the in-canvas floating + // field or the panel's value card: both are "a number you are in the middle of entering". + st.value_field_open = (m_value_cont != nullptr) + || (m_viewport && m_viewport->inline_busy()); + // An uncommitted delta: clicks are down on an entity that does not exist yet, or a body is + // being moved by a gizmo that has not been confirmed. + st.gesture_active = m_viewport + && (m_viewport->drawing_in_progress() + || (m_active == Tool::None && m_viewport->moving_body())); + // Something is armed and waiting for input: a feature card, a sketch draw tool, Constrain. + // A sketch SESSION is deliberately not in this list — see escape(). + st.tool_armed = (m_active != Tool::None) + || m_ui_mode == UiMode::Constrain + || (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->sketch_is_selecting()); + st.has_selection = m_viewport && m_viewport->has_any_selection(); + return cad_escape_level(st); +} + +// Esc: unwind exactly one level. Nothing here deletes a feature, discards geometry or touches +// history — those need Delete/Backspace on a selection, the sketch banner's Cancel, or Ctrl+Z. +void DesignPanel::escape() +{ + switch (escape_level()) { + case CadLevel::Transient: + // Close just the field. The tool stays armed and the geometry is untouched, which is the + // whole reason this level exists: dismissing a number people press Esc for reflexively + // used to cascade down the stack and take the sketch with it. + if (m_value_cont) { cancel_value(); return; } + if (m_viewport) { m_viewport->inline_cancel(); return; } + return; + + case CadLevel::Gesture: + // Restore the state from before the uncommitted delta. Drawing: drop the clicks, keep the + // tool armed so the next click starts a fresh entity. Moving: tool_cancel's move branch + // puts the body back at the pose it had when the gizmo appeared. + if (m_viewport && m_viewport->drawing_in_progress()) { + m_viewport->sketch_abort_gesture(); + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); + return; + } + tool_cancel(); + return; + + case CadLevel::Tool: + // Back to the idle state of whatever environment we are in. A feature card discards its + // CANDIDATE (never a committed feature — in edit mode reset_edit_state only forgets which + // feature was being edited, the feature itself is untouched); an armed sketch tool falls + // back to Select, leaving every entity already drawn exactly where it is. + if (m_active != Tool::None || m_ui_mode == UiMode::Constrain) { tool_cancel(); return; } + if (m_viewport && m_viewport->sketch_disarm_tool()) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Select")); + m_status->Refresh(); + } + return; + + case CadLevel::Idle: + // Deselect. In a sketch this is the floor: the session is left through Finish or Cancel, + // both of which say which one they are, and never through a key pressed on the way out of + // something else. + if (m_viewport && m_viewport->clear_any_selection()) { + m_sel_sketch_region = -1; + m_sel_sketch_feat = -1; + m_status->SetForegroundColour(wxNullColour); + set_status(wxString()); + m_status->Refresh(); + return; + } + // Nothing selected and nothing to unwind. An EMPTY sketch session may as well close — + // there is no work to lose, so this cannot be the destructive case, and being unable to + // leave a sketch you have not drawn in yet is its own small trap. + if (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->live_sketch_has_work()) { + m_viewport->request_sketch_exit(); + return; + } + if (m_ui_mode == UiMode::Sketch) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Sketch kept — Finish to commit it, Cancel to discard")); + m_status->Refresh(); + } + return; + } +} + +void DesignPanel::update_undo_redo_buttons() +{ + // Grey Undo/Redo to mirror exactly what do_undo_redo will do: it acts only in Feature + // mode with no tool/dialog open (otherwise Esc is the way out), so reflect that gate here + // as well as the document's available history. + if (m_btn_undo == nullptr || m_btn_redo == nullptr) return; + const bool gated = (m_ui_mode != UiMode::Feature) || (m_active != Tool::None); + m_btn_undo->Enable(!gated && m_doc.can_undo()); + m_btn_redo->Enable(!gated && m_doc.can_redo()); +} + +void DesignPanel::update_action_bar() +{ + update_undo_redo_buttons(); // mode/tool changes flip the do_undo_redo gate -> refresh greying + if (m_tb_action == nullptr || m_toolbar == nullptr) return; + wxSizer* s = m_toolbar->GetSizer(); + if (s == nullptr) return; + const bool active = (m_active != Tool::None) + || m_ui_mode == UiMode::Sketch + || m_ui_mode == UiMode::Constrain + || (m_viewport && m_viewport->moving_body()); + s->Show(m_tb_action, active, true); + // HIDING THE BAR ORPHANS THE KEYBOARD, and that is the "app does not consent to sketch" + // report. The ✓/✗ live in this bar, so the click that confirms a feature leaves focus on a + // button that this very call then hides. wx does not hand that focus anywhere useful, and + // wxEVT_CHAR_HOOK is bound on THIS PANEL — it is delivered to the focused window and + // propagates up the parent chain, so with focus outside the panel every Design shortcut + // stops arriving. Measured on the rig: after Confirm, shift+S produced ZERO CHAR_HOOK lines + // (not even the modifier), while X kept focus on the main window throughout — so it was + // never a window-manager problem. One bare canvas click restored it, which is exactly the + // workaround users found and reported as "shift+s works but it is not intuitive". + // The canvas is the right owner of the keyboard in this tab, so give it back explicitly. + if (!active && m_viewport != nullptr) { + wxWindow* f = wxWindow::FindFocus(); + bool in_bar = (f == nullptr); + for (wxWindow* w = f; w != nullptr && !in_bar; w = w->GetParent()) + if (w == m_toolbar) in_bar = true; // the bar is a sizer; its buttons parent to m_toolbar + if (in_bar) m_viewport->SetFocus(); + } + m_toolbar->Layout(); + m_toolbar->FitInside(); // refresh scroll range when the action bar shows/hides +} + +void DesignPanel::do_undo_redo(bool redo) +{ + // v1: act only in Feature mode. While authoring/constraining a sketch (m_ui_mode) or + // with a feature dialog open (m_active), Esc/Cancel is the way out — popping committed + // history mid-tool would be ambiguous (and could orphan the tool's referenced feature). + if (m_ui_mode != UiMode::Feature || m_active != Tool::None) { + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Finish or cancel the current tool first (Esc)")); + m_status->Refresh(); + return; + } + const bool ok = redo ? m_doc.redo() : m_doc.undo(); + if (!ok) { + m_status->SetForegroundColour(wxNullColour); + set_status(redo ? _L("Nothing to redo") : _L("Nothing to undo")); + m_status->Refresh(); + return; + } + // The solid whole/face/edge pick and any in-place edit reference ids that recompute() + // invalidates — drop them before refreshing from the restored document. + m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; + m_pick_face = m_pick_face_body = -1; // recompute() invalidated the face ids too + reset_edit_state(); + after_tree_edit(true); // refresh tree + viewport meshes + status from the restored doc + m_status->SetForegroundColour(wxNullColour); + set_status(wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"), + redo ? m_doc.redo_depth() : m_doc.undo_depth())); + m_status->Refresh(); +} + +// --- Document variables panel --------------------------------------------------------- + +void DesignPanel::refresh_variables() +{ + if (!m_var_list) return; + m_var_list->DeleteAllItems(); + int row = 0; + for (const auto& [name, expr] : m_doc.variables) { + m_var_list->InsertItem(row, wxString::FromUTF8(name)); + m_var_list->SetItem(row, 1, wxString::FromUTF8(expr)); + ++row; + } +} + +void DesignPanel::on_add_variable() +{ + wxString name = ::wxGetTextFromUser(_L("Variable name:"), _L("Add Variable"), "", this); + if (name.IsEmpty()) return; + name.Trim(true).Trim(false); + if (name.Contains(' ')) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Variable name must not contain spaces")); + m_status->Refresh(); + return; + } + wxString expr = ::wxGetTextFromUser( + wxString::Format(_L("Expression for '%s':"), name), + _L("Add Variable"), "0", this); + if (expr.IsEmpty()) return; + + const std::string name_str = name.ToUTF8().data(); + const std::string expr_str = expr.ToUTF8().data(); + + m_doc.checkpoint(); + m_doc.variables[name_str] = expr_str; + bool ok = m_doc.recompute(); + // undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was + // rejected is gone before anything can display it. Carry it across the rollback. + if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; } + after_tree_edit(ok); + refresh_variables(); +} + +void DesignPanel::on_edit_variable() +{ + if (!m_var_list) return; + const long sel = m_var_list->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED); + if (sel < 0) { + set_status(_L("Select a variable first")); + m_status->Refresh(); + return; + } + const std::string name_str = m_var_list->GetItemText(sel, 0).ToUTF8().data(); + const std::string old_expr = m_var_list->GetItemText(sel, 1).ToUTF8().data(); + wxString expr = ::wxGetTextFromUser( + wxString::Format(_L("Expression for '%s':"), m_var_list->GetItemText(sel, 0)), + _L("Edit Variable"), wxString::FromUTF8(old_expr), this); + if (expr.IsEmpty()) return; + + const std::string expr_str = expr.ToUTF8().data(); + m_doc.checkpoint(); + m_doc.variables[name_str] = expr_str; + bool ok = m_doc.recompute(); + // undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was + // rejected is gone before anything can display it. Carry it across the rollback. + if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; } + after_tree_edit(ok); + refresh_variables(); +} + +void DesignPanel::on_remove_variable() +{ + if (!m_var_list) return; + const long sel = m_var_list->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED); + if (sel < 0) { + set_status(_L("Select a variable first")); + m_status->Refresh(); + return; + } + const std::string name_str = m_var_list->GetItemText(sel, 0).ToUTF8().data(); + + m_doc.checkpoint(); + m_doc.variables.erase(name_str); + bool ok = m_doc.recompute(); + if (!ok) { + // Capture before undo(): its recompute succeeds and clears doc.error. + const std::string why = m_doc.error; + m_doc.undo(); + // Almost always a feature expr still referencing the variable — but say so as the + // likely cause and keep the real message, rather than asserting a diagnosis that + // would be wrong for any other failure. + m_doc.error = wxString::Format( + _L("Variable '%s' could not be removed (likely still referenced by a feature " + "expression): %s"), + m_var_list->GetItemText(sel, 0), wxString::FromUTF8(why)).ToUTF8().data(); + } + after_tree_edit(ok); + refresh_variables(); +} + +// --- Expression binding --------------------------------------------------------------- + +// Field-name lists per feature type. The combo is editable so a power user can type any +// valid field name; these lists pre-populate the picker for the common operations. +std::vector DesignPanel::fields_for_tool(Tool t) +{ + using T = DesignPanel::Tool; + switch (t) { + case T::Sketch: return {"width", "height", "radius"}; + case T::Extrude: return {"distance", "distance2", "taper_deg"}; + case T::Dressup: return {"dressup_size"}; + case T::Hole: return {"hole_diameter", "hole_depth", "hole_x", "hole_y"}; + case T::Thread: return {"thread_radius", "thread_pitch", "thread_height", "thread_depth", "thread_x", "thread_y"}; + case T::Shell: return {"shell_thickness"}; + case T::Revolve: return {"revolve_angle"}; + case T::Sweep: return {}; + case T::Pattern: return {"pattern_count", "pattern_spacing", "pattern_angle"}; + case T::Plane: return {"plane_offset", "plane_angle_tilt"}; + case T::Loft: return {}; + case T::Draft: return {"draft_angle"}; + case T::Boolean: return {}; + case T::Cut: return {}; + case T::SurfaceExtrude: return {"distance"}; + case T::SurfaceRevolve: return {"revolve_angle"}; + case T::SurfaceLoft: return {}; + case T::SurfaceFill: return {}; + case T::SurfaceOffset: return {"plane_offset"}; + case T::ThickenSurface: return {"thicken_thickness"}; + case T::Transform: return {}; + case T::Mirror: return {}; + case T::Thicken: return {"thicken_thickness"}; + case T::Rib: return {"rib_thickness", "rib_depth"}; + case T::Project: return {}; + case T::DeleteFace: return {}; + case T::Helix: return {"helix_radius", "helix_pitch", "helix_height", "helix_taper_deg"}; + case T::Axis: return {}; + case T::CoordSys: return {}; + case T::Mate: return {}; + case T::Insert: return {}; + case T::None: return {}; + } + return {}; +} + +void DesignPanel::populate_expr_fields(Tool t) +{ + if (!m_expr_field) return; + m_expr_field->Clear(); + for (const std::string& f : fields_for_tool(t)) + m_expr_field->Append(wxString::FromUTF8(f)); + if (m_expr_field->GetCount() > 0) + m_expr_field->SetSelection(0); +} + +void DesignPanel::on_set_expr() +{ + if (m_edit_index < 0 || m_edit_index >= int(m_doc.features.size())) return; + if (!m_expr_field || !m_expr_text) return; + + const wxString fwx = m_expr_field->GetValue(); + const wxString ewx = m_expr_text->GetValue(); + if (fwx.IsEmpty()) return; + + const std::string field = fwx.ToUTF8().data(); + const std::string expr = ewx.ToUTF8().data(); + + m_doc.checkpoint(); + m_doc.features[m_edit_index].expr[field] = expr; + bool ok = m_doc.recompute(); + // undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was + // rejected is gone before anything can display it. Carry it across the rollback. + if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; } + after_tree_edit(ok); + if (ok) m_expr_text->Clear(); + + // Refresh the status line showing current bindings + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())) { + const CadFeature& ef = m_doc.features[m_edit_index]; + if (ef.expr.empty()) { + m_expr_status->SetLabel(_L("(no bindings)")); + m_expr_status->SetForegroundColour(dp_sec_text()); + } else { + wxString s; + for (const auto& [f, e] : ef.expr) { + if (!s.IsEmpty()) s += "; "; + s += wxString::FromUTF8(f) + " = " + wxString::FromUTF8(e); + } + m_expr_status->SetLabel(s); + m_expr_status->SetForegroundColour(dp_ctl_text()); + } + } +} + +void DesignPanel::on_clear_expr() +{ + if (m_edit_index < 0 || m_edit_index >= int(m_doc.features.size())) return; + if (!m_expr_field) return; + + const wxString fwx = m_expr_field->GetValue(); + if (fwx.IsEmpty()) return; + + const std::string field = fwx.ToUTF8().data(); + auto& feat_expr = m_doc.features[m_edit_index].expr; + if (feat_expr.find(field) == feat_expr.end()) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("No binding for that field")); + m_status->Refresh(); + return; + } + + m_doc.checkpoint(); + feat_expr.erase(field); + bool ok = m_doc.recompute(); + // undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was + // rejected is gone before anything can display it. Carry it across the rollback. + if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; } + after_tree_edit(ok); + + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())) { + const CadFeature& ef = m_doc.features[m_edit_index]; + if (ef.expr.empty()) { + m_expr_status->SetLabel(_L("(no bindings)")); + m_expr_status->SetForegroundColour(dp_sec_text()); + } else { + wxString s; + for (const auto& [f, e] : ef.expr) { + if (!s.IsEmpty()) s += "; "; + s += wxString::FromUTF8(f) + " = " + wxString::FromUTF8(e); + } + m_expr_status->SetLabel(s); + m_expr_status->SetForegroundColour(dp_ctl_text()); + } + } +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp new file mode 100644 index 0000000000..88b445534d --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -0,0 +1,919 @@ +#ifndef slic3r_DesignPanel_hpp_ +#define slic3r_DesignPanel_hpp_ + +#include +#include +#include // wxTreeItemId + +#include +#include +#include +#include + +#include "libslic3r/CAD/CadDocument.hpp" +#include "slic3r/GUI/CAD/DesignInteraction.hpp" // CadLevel: what one Esc press means + +class ComboBox; // Orca dropdown (Widgets/ComboBox.hpp) — replaces wxChoice everywhere here +class StaticBox; // Orca rounded card frame (Widgets/StaticBox.hpp) +class wxCheckBox; +class wxCheckListBox; +class wxSpinCtrl; +class wxSpinCtrlDouble; +class wxTreeCtrl; +class wxImageList; +class wxStaticText; +class wxStaticLine; +class Button; // Orca-styled button (Widgets/Button.hpp) +class CheckBox; // Orca teal checkbox (Widgets/CheckBox.hpp) +class wxSizer; +// wxBoxSizer, wxTextCtrl and wxListCtrl are used here as pointers only, so a forward +// declaration is enough — but they must be declared. Every ordinary build happened to pull +// them in transitively through the wx/panel.h + wx/scrolwin.h chain. The Snapmaker fork's +// Flatpak build does not, and it failed to compile this header with "'wxTextCtrl' does not +// name a type; did you mean 'wxTreeCtrl'?". Declaring them keeps the header self-contained +// instead of relying on whatever a particular wx configuration happens to include. +class wxBoxSizer; +class wxTextCtrl; +class wxListCtrl; +class wxButton; +class wxPanel; +class ScalableButton; + +namespace Slic3r { namespace GUI { + +class DesignCanvas; + +// Design (CAD) tab: a sketch-first, Onshape-style form-driven CAD panel. +// Sketch and Extrude are independent tools: the user creates a Sketch first, +// then selects it and Extrudes to produce a solid. +class DesignPanel : public wxPanel +{ +public: + explicit DesignPanel(wxWindow* parent); + void on_tab_shown(); // re-sync bed to the active printer when the Design tab is activated + void on_tab_hidden(); // another tab took over: take the viewport status line down with us + void unbind_canvas_event_handlers(); // app close / language switch, from the plater's teardown + void reset_canvas_volumes(); + void clear_document(); // New Project / Open Project: drop the document with the project + // Rebuild off the UI thread (progress dialog only if it turns out to be slow), so a feature + // op on a heavy imported solid does not freeze the window. Returns m_doc.recompute()'s result. + // Push the document's recipe into the Model so ANY save path persists it (vjk5). + void sync_recipe_to_model(); + bool recompute_guarded(const wxString& message); + + // MCP control hooks: let the external control server (McpControl.cpp) drive and + // perceive the SAME kernel the GUI uses. Called only on the wx main thread. + CadDocument& mcp_doc() { return m_doc; } // live document (read + mutate) + void mcp_after_change() { after_tree_edit(true); } // refresh tree + viewport + status + DesignCanvas* mcp_viewport() { return m_viewport; } // live sketch + 3D view + // Put the PANEL into (or out of) sketch mode, not just the canvas tool. Measured on the + // rig: a sketch started straight through DesignCanvas::begin_sketch leaves m_ui_mode at + // Feature, and the keyboard map is dispatched on `m_ui_mode == UiMode::Sketch` while the + // offer menu is dispatched on the looser sketch_map_applies() — so the menu offered the + // line's verbs while every sketch shortcut was dead (KEYTRACE: key=81 ui_mode=0 + // is_sketching=1). Half-entering a mode is worse than not entering it. + void mcp_set_sketch_mode(bool on) + { + set_ui_mode(on ? UiMode::Sketch : UiMode::Feature); + update_action_bar(); + } + // The offer-table vocabulary without a right-click: the external controller asks which verbs + // exist (and which apply to the current selection) and fires one by id, so a deck key names a + // verb instead of spending a letter and every verb is reachable — including the rows with no + // keyboard shortcut, which are otherwise invisible to anything that parses key tables. + int mcp_offer_selection_kind() const { return offer_selection_kind(); } // OfferSel as int + void mcp_run_action(const char* action) { run_offer_action(action); } // dispatch an action string + // Defined out of line in DesignPanel.cpp: it needs kOfferVerbs, which this header deliberately + // does not include (the table is generated and belongs to the offer-menu code). + bool mcp_run_verb(const char* verb_id); + +private: + enum class Tool { None, Sketch, Extrude, Dressup, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Boolean, Cut, Insert, Axis, CoordSys, SurfaceExtrude, SurfaceRevolve, SurfaceLoft, SurfaceFill, SurfaceOffset, ThickenSurface, Transform, Mirror, Thicken, Rib, Project, DeleteFace, Helix, Mate }; + // Which numeric fields an expression can be bound to, per feature type. A member rather + // than a file-static helper so Tool — 32 values of purely internal card state — does not + // have to become part of this panel's public API just to be named in a signature. + static std::vector fields_for_tool(Tool t); + // Plane tool: which datum reference the next solid pick fills (declared early so the + // method decls + card lambdas below can name it). + enum class PlanePick { None, FaceA, FaceB, EdgeA, EdgeB }; + enum class AxisPick { None, Face, Edge }; + enum class CoordSysPick { None, Face, Edge }; + + // Onshape-style contextual top toolbar: only the active mode's tool group is + // shown (Feature = sketch/extrude/dress/hole/thread; Sketch = entity tools; + // Constrain = constraints + edit ops). Replaces the old always-visible wall. + enum class UiMode { Feature, Sketch, Constrain }; + void set_ui_mode(UiMode m); + void apply_dof_status(int dof, bool ok, bool has_constraints); + // Unified action-bar dispatch: one Confirm / one Cancel for every tool and mode. + void tool_confirm(); // ✓ : commit the active feature / sketch / constrain session + void tool_cancel(); // ✗ : cancel the active feature / discard / exit + // Esc. ONE press unwinds ONE level of the interaction stack (DesignInteraction.hpp), and no + // level of it destroys committed work. escape_level() answers which level the press belongs + // to; escape() acts on exactly that one. Every Esc in the tab routes through here — the key + // used to be handled in four places that could not see each other, and that is how two + // presses in a row reached past a tool and discarded the sketch under it. + CadLevel escape_level() const; + void escape(); + void update_action_bar(); // show the ✓/✗ bar iff a tool or mode is active + + void on_shape_changed(); + void on_add_sketch(); + void on_add_extrude(); + void on_add_dressup(); + void on_add_hole(); + void on_add_thread(); + void apply_thread_standard(); // fill pitch/depth/radius from m_thread_std selection + void infer_thread_spec(double diameter); // nearest M-standard from a picked cylinder diameter + void on_add_revolve(); + void on_add_sweep(); + void on_add_loft(); + void on_add_pattern(); + bool on_add_plane(); // false = refused, card stays open + void arm_plane_pick(PlanePick target); // Plane tool: next solid pick fills this reference + void apply_plane_refs(CadFeature& f) const; // copy type + face/edge refs + sizes from the card + void refresh_plane_labels(); // update the 4 pick labels from the captured refs + void reset_plane_refs(); // clear captured refs (fresh Plane add) + void on_add_shell(); + void on_add_draft(); + void on_add_boolean(); + void on_add_cut(); // commit a plane Cut (split-by-plane) + void on_add_axis(); + void arm_axis_pick(AxisPick target); + void apply_axis_refs(CadFeature& f) const; + void refresh_axis_labels(); + void reset_axis_refs(); + void on_add_coordsys(); + void arm_coordsys_pick(CoordSysPick target); + void apply_coordsys_refs(CadFeature& f) const; + void refresh_coordsys_labels(); + void refresh_cs_body_choice(); // fill the CoordSys body chooser from current document + void reset_coordsys_refs(); + void on_add_surface_extrude(); + void on_add_surface_revolve(); + void on_add_surface_loft(); + void on_add_surface_fill(); + void on_add_surface_offset(); + void on_add_thicken_surface(); + void on_add_transform(); + void xf_live_preview(); // typed Transform fields -> body display transform (live) + void xf_clear_preview(); // hand a previewed body back to its pre-card pose + void on_add_mirror(); + void on_add_thicken(); + void on_add_rib(); + void on_add_project(); + void on_add_delete_face(); + void on_add_helix(); + void on_add_mate(); + void on_check_interference(); + void on_mass_properties(); // read-only report on the selected solid; edits nothing + // Fill m_bool_target / m_bool_tool / m_cut_target. as_of_feature < 0 = current bodies (add); + // >= 0 = the bodies as they existed just before that feature index (Boolean re-edit, so a + // consumed tool body still appears and its saved selection round-trips). + // Which body a tool should act on when it opens: the one picked in the VIEWPORT, else + // the first. Selection comes first and the tool consumes it — every body combo used to + // default to index 0, so picking body 3 and opening Mirror silently mirrored body 1. + // Clamped to the list, so it is safe to hand straight to SetSelection. e1p. + int selected_body_default() const; + void populate_body_choices(int as_of_feature = -1); + // Fill `c` with the bodies as they existed just before `as_of_feature` and select + // `want`. Re-editing any feature that stores a body index needs this: the index was + // recorded against the body list at that point in the timeline, not the final one. + void fill_body_choice(ComboBox* c, int as_of_feature, int want); + void populate_sheet_body_choices(ComboBox* c) const; // bodies where is_sheet_shape() is true + // Rows of a sheet-filtered picker are not body indices; go through these two, never + // GetSelection()/SetSelection() directly. + static int sheet_choice_body(ComboBox* c); // real body index of the current row, or -1 + static void select_sheet_choice(ComboBox* c, int body);// select the row holding this body index + // Import rigid 2D art (Text / SVG) as a new Sketch feature carrying + // imported_regions (no solver entities). on_add_text/on_import_svg gather + // input; add_imported_sketch builds the feature, refreshes tree + display. + void on_add_text(); + void on_import_svg(); + void on_import_step(); // STEP -> editable B-rep body (keeps the OCCT solid, not a mesh) + void on_import_mesh(); // STL/OBJ -> B-rep body via GeometryEngine::mesh_to_brep + bool place_on_face(); // Prepare's Place on Face (F): lay the selected body face on the bed + void add_imported_sketch(const std::vector>>& regions, + const wxString& base_name); + // Imported Text/SVG art is placed/sized in-canvas then explicitly committed via a + // small Confirm/Cancel card (Onshape Button->Dialog->Preview->Confirm). The feature + // is added provisionally by add_imported_sketch; Confirm keeps it, Cancel undoes it. + void open_insert_card(const wxString& base_name); + void finalize_insert(); // Confirm: keep the placed art, leave the placement gizmo + void cancel_insert(); // Cancel: undo the provisional insert + // Move / enlarge / stretch (independent X/Y) an imported Text/SVG sketch: + // a modal dialog editing the feature's placement transform in place. + void on_transform_imported(int feat_idx); + void on_commit(); + void on_export_step(); // write all bodies to a .step file (native B-rep) + // Rehydrate the parametric model from a project's saved recipe (3MF + // Metadata/orca_cad.bin): deserialize -> recompute -> refresh viewport + tree. + void load_recipe(const std::string& blob); + void refresh_tree(); + void set_status_ok(); + + // Feature-tree editing (Onshape-style): act on the selected tree row. + void on_delete_feature(); + // "Delete Body" — the geometry-first counterpart, reached by pointing at a body or any of + // its faces. Resolves the body to the feature that created it and removes THAT, because a + // body is a recomputed result and has nothing else to delete. + void on_delete_body(); + void on_new_design(); + void on_move_feature(int delta); // -1 = up, +1 = down + void on_toggle_visibility(); // show/hide the selected feature (CadFeature::enabled) + + // Constrain mode: enter on the tree-selected sketch, then apply a geometric + // constraint to the in-canvas picked segment and re-solve in the kernel. + void on_begin_constrain(int sel_override = -1); + // Sketch-toolbar Constrain entry: commit the live sketch in place, then enter Constrain + // mode on it (so the constraint palette + Trim/Extend are reachable without leaving the + // sketch flow). Returns true if constrain mode was entered. + bool enter_constrain_inline(); + void apply_constraint(SketchConstraintType type); + void apply_entity_constraint(SketchConstraintType type); // Fase 4.2 entity path + void apply_live_constraint(SketchConstraintType type); // Fase 4.2 live-sketch path (no commit needed) + enum class EditOp { Mirror, Offset, Fillet, Trim, Extend, Array, Move, Chamfer, Rotate, Scale, PolarArray }; // Fase 4.4/4.5/4.6 sketch edit ops + void apply_edit_op(EditOp op); // mutate selected sketch entities + // Onshape-style docked value entry (replaces wxGetTextFromUser popups for + // Angle/Radius/Diameter constraints + Offset/Fillet edit ops). request_value + // shows the card and stows a continuation run by confirm_value(). + void request_value(const wxString& label, double def, double mn, double mx, + std::function cont, + std::function on_cancel = nullptr); + void confirm_value(); + void cancel_value(); + void commit_entity_constraints(const std::vector& defs); // multi-def (Symmetric) + + // Constraint manager (C3.4): a docked list of the constrained sketch's + // entity-constraints with per-row select (highlight the referenced entities in + // the viewport) and delete (drop the constraint + re-solve). Shown in Constrain + // mode only; operates on m_doc.features[m_constrain_feat].entity_constraints. + // True when the constraint UI must address the LIVE sketch session rather than a committed + // feature. Same discriminator apply_constraint uses to choose apply_live_constraint: both + // Constrain modes set m_active too, so is_sketching() alone would claim the live scope while + // the committed manager is open. + bool live_constraint_scope() const; + void rebuild_constraint_list(); // refill m_constraint_rows + void delete_constraint(int idx); // erase + re-solve + refresh + void highlight_constraint_entities(int idx); // push referenced entities to viewport + void refresh_constrain_dof(); // re-solve feature, mirror DoF readout + wxString constraint_label(const SketchEntityConstraintDef& d) const; // human-readable row text + void after_edit_op(); // shared edit-op refresh tail + void on_edit_feature(); // reopen the selected feature's dialog populated + void after_tree_edit(bool ok); // shared post-op refresh of tree/viewport/status + void load_feature_into_dialog(const CadFeature& f); + void reset_edit_state(); // back to add-mode (m_edit_index = -1) + + // Onshape loop: Button -> open_tool (show dialog) -> refresh_preview (ghost) -> + // confirm_tool (commit) / cancel_tool (abort). + void open_tool(Tool t); + void close_tool(); + void refresh_preview(); + void confirm_tool(); + void cancel_tool(); + // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport. With a tool/dialog open it + // cancels that (Esc-like); otherwise it undoes/redoes the committed feature history. + void do_undo_redo(bool redo); + // The plane the Hole tool drills on: a picked face (inward, centred) or the dropdown. + SketchPlane hole_plane() const; + // The plane the Thread tool builds on: a picked cylindrical face (axis) or the dropdown. + SketchPlane thread_plane() const; + // Name the geometry the card has LATCHED, so it never has to be inferred from the viewport. + // Pass -1 for "none, falling back to the plane dropdown". See 200. + void set_hole_target_label(int face); + void set_thread_target_label(int face, int edge); + CadFeature build_candidate(Tool t) const; + // Merge per-body ghost meshes with the per-body display transforms applied. The kernel builds + // a ghost from the untransformed bodies, so without this it floats back at the origin once a + // body has been moved. + TriangleMesh ghost_from_bodies(const std::vector& per_body) const; + // A mate makes no new geometry but it MOVES a body, and the moved assembly is the ghost worth + // showing. Used both by the Mate card and by hovering a row of the offer's mate palette. + bool show_mate_ghost(int kind, int cs_a, int cs_b, + double offset, double angle_deg, bool flip, std::string& err); + int resolve_extrude_sketch() const; + // Plane pickers: fill a choice with XY/XZ/YZ + the document's datum planes, and + // map a choice row back to the actual SketchPlane (rows 0-2 base, 3+ datum). + void populate_plane_choices(ComboBox* c) const; + wxString ref_plane_name(int row) const; // "XY" / a datum's name, for the on-geometry hint + SketchPlane plane_from_choice(int row) const; + // Where a new sketch goes, resolved from what is SELECTED IN THE VIEWPORT rather than from a + // list: a picked planar face wins, otherwise the reference plane last clicked in 3D. `what` + // comes back as something to show the user, so the choice is visible without a combo. + SketchPlane sketch_plane_from_selection(wxString& what) const; + // Whether that resolution has anything the USER picked behind it, rather than the default + // reference plane. Lets a caller say "sketching on XZ" only when it is actually true. + bool sketch_plane_target(wxString& what) const; + // True when Extrude should build only the click-selected loop (a region of the + // resolved sketch is selected and it carries entities). + bool extrude_uses_loop() const; + void sync_sketch_display(); // push un-consumed committed sketches to the viewport + // Feed the viewport's visual Extrude depth-arrow gizmo (C5b) with the current profile + // plane + centroid + live depths while the Extrude card is open (self-gates on m_active). + void update_extrude_gizmo(); + void update_fillet_gizmo(); // edge-anchored radius arrow (Dressup card) + void sync_dressup_target(); // Dressup card: show picked edge vs group, gate the combo + void update_hole_gizmo(); // footprint circle + diameter/depth arrows (Hole card) + // A FEATURE button whose tool needs bodies it may not have yet. Greyed with an explanatory + // tooltip below min_bodies, rather than accepting the click and refusing afterwards. + struct BodyGate { wxWindow* btn{nullptr}; int min_bodies{1}; wxString tip_live, tip_gated; }; + std::vector m_body_gates; + void update_body_gates(); // re-evaluate them against the current body count + void update_thread_gizmo(); // footprint circle + radius/length arrows (Thread card) + void update_shell_gizmo(); // inward thickness arrow on the picked face (Shell card) + void update_revolve_gizmo(); // angle-arc around the axis (Revolve card) + void update_draft_gizmo(); // angle-arc around the face centroid (Draft card) + void update_cut_gizmo(); // plane-rectangle + offset arrow (Cut card) + void update_operand_highlight(); // Boolean/Sweep/Loft operand tinting on the canvas + void update_pattern_gizmo(); // linear spacing arrow / circular angle-arc (Pattern card) + void update_datum_gizmo(); // resize handles on the datum plane being created/edited (C3) + void update_helix_gizmo(); // live helix curve + radius/height/pitch handles (Helix card) + void update_rib_gizmo(); // in-plane slab footprint + thickness handles (Rib card) + void refresh_datum_planes(); // push resolved datum frames + per-plane u/v extents to viewport + void refresh_mate_connectors(); // push connector frames so verse + polarity are visible + void update_reference_planes(); // persistent XY/XZ/YZ reference planes (fallback when no object) + + CadDocument m_doc; + + Tool m_active{Tool::None}; + + // Keyboard shortcuts (Onshape-style, three scoped layers). Keys are encoded as the + // upper-cased letter, OR'd with 0x10000 when Shift is required. m_keys_sketch fires only + // while a sketch is open (single letters = sketch tools); m_keys_feature fires only when + // no sketch is open (Shift+letter = feature tools; single letters = view toggles/section). + static constexpr int SC_SHIFT = 0x10000; + // ...and with 0x20000 when Ctrl is required too. The Shift+letter space is full, so an + // action that arrives late lives on Ctrl+Shift; plain Ctrl-combos are still passed + // straight through, which is what leaves this layer free. + static constexpr int SC_CTRL = 0x20000; + std::map> m_keys_sketch; + std::map> m_keys_feature; + + StaticBox* m_tree_box{nullptr}; // framed feature-tree section + StaticBox* m_parts_box{nullptr}; // framed bodies section (hidden while empty) + StaticBox* m_cards{nullptr}; // one framed panel holding every tool dialog (one visible at a time) + void update_cards_frame(); // show that frame iff some card inside it is visible + void show_move_card(bool show); + void apply_move_card(); // numeric move/rotate -> same xform the gizmo builds + void push_polygon_params(); + wxSizer* m_tb_commit{nullptr}; // far-right Commit to Plate, beside Confirm/Cancel + wxSizer* m_tb_doc{nullptr}; // toolbar document/view actions (new, commit, export, section, place) + CheckBox* m_show_bed{nullptr}; // view option: draw the printer bed + plate grid, or not + wxSizer* m_box_move{nullptr}; // Move/Rotate numeric options (distance, axis, angle) + wxSizer* m_box_sketch{nullptr}; + wxSizer* m_box_extrude{nullptr}; + wxSizer* m_box_dressup{nullptr}; + wxSizer* m_box_hole{nullptr}; + wxSizer* m_box_thread{nullptr}; + wxSizer* m_box_shell{nullptr}; + wxSizer* m_box_revolve{nullptr}; + wxSizer* m_box_sweep{nullptr}; + wxSizer* m_box_pattern{nullptr}; + wxSizer* m_box_plane{nullptr}; + wxSizer* m_box_loft{nullptr}; + wxSizer* m_box_draft{nullptr}; + wxSizer* m_box_boolean{nullptr}; + wxSizer* m_box_cut{nullptr}; + wxSizer* m_box_axis{nullptr}; + wxSizer* m_box_coordsys{nullptr}; + wxSizer* m_box_surf_extrude{nullptr}; + wxSizer* m_box_surf_revolve{nullptr}; + wxSizer* m_box_surf_loft{nullptr}; + wxSizer* m_box_surf_fill{nullptr}; + wxSizer* m_box_surf_offset{nullptr}; + wxSizer* m_box_surf_thicken{nullptr}; + wxSizer* m_box_transform{nullptr}; + wxSizer* m_box_mirror{nullptr}; + wxSizer* m_box_thicken{nullptr}; + wxSizer* m_box_rib{nullptr}; + wxSizer* m_box_project{nullptr}; + wxSizer* m_box_delete_face{nullptr}; + wxSizer* m_box_helix{nullptr}; + wxSizer* m_box_mate{nullptr}; + wxSizer* m_box_insert{nullptr}; // Confirm/Cancel card for placing Text/SVG art + wxSizer* m_box_expr{nullptr}; // expression binding card (visible during edit only) + int m_insert_feat{-1}; // provisional imported-art feature awaiting Confirm + // Move-body gizmo runs through the unified action bar too: Confirm keeps the placement, + // Cancel reverts to the pose captured when the move started. + int m_move_body{-1}; + Transform3d m_move_prev{Transform3d::Identity()}; + // Set while the move gizmo is serving the Transform CARD rather than the Move button. + // Both use the same gizmo; only this says which card owns the numbers it reports. + int m_xf_gizmo_body{-1}; + Transform3d m_xf_gizmo_base{Transform3d::Identity()}; // pose when Transform armed it + // Which body the Transform card's typed fields are currently previewing on, and the pose to + // hand it back to. Separate from the gizmo pair because the card can retarget its Body combo. + int m_xf_prev_body{-1}; + Transform3d m_xf_prev_base{Transform3d::Identity()}; + + // Onshape-style dialog-card title rows (icon + bold feature name), retitled + // per tool in open_tool() (edit-mode shows the feature's actual name). + wxStaticText* m_hdr_move{nullptr}; + wxStaticText* m_hdr_sketch{nullptr}; + // Onshape sketch-entry card (plane/orientation) that opens on "New sketch" and + // persists until Finish (Phase 3). + wxSizer* m_box_sketch_session{nullptr}; + wxStaticText* m_hdr_sketch_session{nullptr}; + wxStaticText* m_sketch_hint{nullptr}; // "click a plane" / "drawing on X" — must match the status + wxStaticText* m_hdr_extrude{nullptr}; + wxStaticText* m_hdr_dressup{nullptr}; + wxStaticText* m_hdr_hole{nullptr}; + wxStaticText* m_hdr_thread{nullptr}; + wxStaticText* m_hdr_shell{nullptr}; + wxStaticText* m_hdr_revolve{nullptr}; + wxStaticText* m_hdr_sweep{nullptr}; + wxStaticText* m_hdr_pattern{nullptr}; + wxStaticText* m_hdr_plane{nullptr}; + wxStaticText* m_hdr_loft{nullptr}; + wxStaticText* m_hdr_draft{nullptr}; + wxStaticText* m_hdr_boolean{nullptr}; + wxStaticText* m_hdr_cut{nullptr}; + wxStaticText* m_hdr_axis{nullptr}; + wxStaticText* m_hdr_coordsys{nullptr}; + wxStaticText* m_hdr_surf_extrude{nullptr}; + wxStaticText* m_hdr_surf_revolve{nullptr}; + wxStaticText* m_hdr_surf_loft{nullptr}; + wxStaticText* m_hdr_surf_fill{nullptr}; + wxStaticText* m_hdr_surf_offset{nullptr}; + wxStaticText* m_hdr_surf_thicken{nullptr}; + wxStaticText* m_hdr_transform{nullptr}; + wxStaticText* m_hdr_mirror{nullptr}; + wxStaticText* m_hdr_thicken{nullptr}; + wxStaticText* m_hdr_rib{nullptr}; + wxStaticText* m_hdr_project{nullptr}; + wxStaticText* m_hdr_delete_face{nullptr}; + wxStaticText* m_hdr_helix{nullptr}; + wxStaticText* m_hdr_mate{nullptr}; + wxStaticText* m_hdr_insert{nullptr}; + + wxScrolledWindow* m_form{nullptr}; + DesignCanvas* m_viewport{nullptr}; + + // Top contextual toolbar (parented to the panel, above the form/viewport row). + UiMode m_ui_mode{UiMode::Feature}; + // Sketch environment banner: a strip across the top of the viewport saying, in words, that + // this is a sketch and which one. The mode used to be legible only from the toolbar and the + // left card — both of which look like the rest of the app — so a sketch session and plate + // preparation were one glance apart. Indicator only: Finish/Cancel stay on the ONE ribbon + // action bar (the Design UX contract), and the banner never grows a second pair. + wxPanel* m_sketch_banner{nullptr}; + wxStaticText* m_sketch_banner_txt{nullptr}; + wxScrolledWindow* m_toolbar{nullptr}; // horizontally scrollable so the action bar stays reachable on narrow windows + wxSizer* m_tb_feature{nullptr}; + wxSizer* m_tb_sketch{nullptr}; + // The 20 constraint icon buttons, shown during BOTH Sketch and Constrain (Fase 4.2 live + // path: a constraint must be applicable while drawing, not only after committing). + wxSizer* m_tb_relations{nullptr}; + // Unified Confirm/Cancel action bar (right end of the ribbon). Shown whenever any + // tool or mode is active; the single confirm/cancel surface for the whole tab. + wxSizer* m_tb_action{nullptr}; + // Persistent Undo/Redo group at the left of the ribbon — always visible, independent + // of the mode-gated tool groups. The buttons are greyed per the document history and + // the do_undo_redo gate (see update_undo_redo_buttons). + wxSizer* m_tb_history{nullptr}; + ScalableButton* m_btn_undo{nullptr}; + ScalableButton* m_btn_redo{nullptr}; + void update_undo_redo_buttons(); // enable/disable Undo/Redo from can_undo/can_redo + gate + // All tool buttons, for the active-tool teal highlight (Onshape-style). + std::vector m_tool_btns; + ScalableButton* m_active_tool_btn{nullptr}; + void set_active_tool_btn(ScalableButton* b); // nullptr clears the highlight + // Owns the themed DropDown flyouts (and the item vectors they hold by ref). + std::vector> m_flyout_keepalive; + wxCheckBox* m_construction{nullptr}; // sketch-mode construction toggle + wxSpinCtrlDouble* m_move_dx{nullptr}; // Move/Rotate card: world translation + wxSpinCtrlDouble* m_move_dy{nullptr}; + wxSpinCtrlDouble* m_move_dz{nullptr}; + ComboBox* m_move_axis{nullptr}; // rotation axis: X/Y/Z + wxSpinCtrlDouble* m_move_angle{nullptr}; // rotation angle (deg) + // Polygon's two parameters are chosen FROM THE TOOL, in the offer's Polygon submenu, not + // from a card on the left: the side count cannot be edited after drawing (the inline editor + // offers Side and Angle only), so it has to be settled at the moment the tool is armed — + // which is exactly where the offer already is. e1p. + int m_poly_sides{6}; // 3..64; the submenu names the common ones + bool m_poly_circumscribed{false}; + + // Which reference plane a sketch falls back to when no face is picked: 0/1/2 = XY/XZ/YZ, + // >=3 indexes resolve_datum_planes(). Set by CLICKING a ghost plane in the viewport — there is + // deliberately no dropdown for it. e1p. + int m_ref_plane{0}; + // m_ref_plane is always a VALID plane, so it cannot itself distinguish "the user chose XY" + // from "nobody has chosen anything yet". This does. + bool m_plane_picked{false}; + ComboBox* m_shape{nullptr}; + ComboBox* m_mode{nullptr}; + wxSpinCtrlDouble* m_width{nullptr}; + wxSpinCtrlDouble* m_height{nullptr}; + wxSpinCtrlDouble* m_radius{nullptr}; + wxSpinCtrlDouble* m_distance{nullptr}; + ComboBox* m_extrude_end{nullptr}; // Blind/Symmetric/TwoSided/ThroughAll/UpTo* + wxSpinCtrlDouble* m_distance2{nullptr}; // second-side depth (Two-sided) + wxSpinCtrlDouble* m_taper{nullptr}; // draft angle (deg) + CheckBox* m_flip{nullptr}; // reverse extrude direction + + wxStaticText* m_extrude_sketch_label{nullptr}; + int m_extrude_sketch_ref{-1}; + + // Revolve controls (sweep a sketch profile about an in-plane axis). + wxStaticText* m_revolve_sketch_label{nullptr}; + wxSpinCtrlDouble* m_revolve_angle{nullptr}; + ComboBox* m_revolve_axis{nullptr}; // 0 = plane X, 1 = plane Y + ComboBox* m_revolve_mode{nullptr}; // New/Add/Cut/Intersect + CheckBox* m_revolve_flip{nullptr}; + int m_revolve_sketch_ref{-1}; + + // Sweep controls (sweep a profile sketch along a path sketch). + wxStaticText* m_sweep_profile_label{nullptr}; + ComboBox* m_sweep_path{nullptr}; // path Sketch picker (feature index in client data) + ComboBox* m_sweep_mode{nullptr}; // New/Add/Cut/Intersect + int m_sweep_profile_ref{-1}; + int m_sweep_path_ref{-1}; // path Sketch feature index (for re-edit pre-select) + + // Loft controls (skin a solid through 2+ ordered profile Sketches). + wxCheckListBox* m_loft_list{nullptr}; // every Sketch; check 2+ in list order = profiles + CheckBox* m_loft_ruled{nullptr}; // ruled (straight) vs smooth sections + ComboBox* m_loft_mode{nullptr}; // New/Add/Cut/Intersect + std::vector m_loft_sketch_idx; // feature index for each row in m_loft_list + std::vector m_loft_refs; // chosen profile refs (for re-edit pre-check) + + // Surface Extrude controls (sheet from sketch profile). + wxStaticText* m_surf_extrude_sketch_label{nullptr}; + wxSpinCtrlDouble* m_surf_extrude_distance{nullptr}; + int m_surf_extrude_sketch_ref{-1}; + + // Surface Revolve controls (sheet from sketch about axis). + wxStaticText* m_surf_revolve_sketch_label{nullptr}; + wxSpinCtrlDouble* m_surf_revolve_angle{nullptr}; + ComboBox* m_surf_revolve_axis{nullptr}; // 0 = plane X, 1 = plane Y + CheckBox* m_surf_revolve_flip{nullptr}; + int m_surf_revolve_sketch_ref{-1}; + + // Surface Loft controls (skin a sheet through 2+ ordered profile sketches). + wxCheckListBox* m_surf_loft_list{nullptr}; // every Sketch; check 2+ in list order = profiles + CheckBox* m_surf_loft_ruled{nullptr}; // ruled (straight) vs smooth sections + std::vector m_surf_loft_sketch_idx; // feature index for each row + std::vector m_surf_loft_refs; // chosen profile refs (for re-edit pre-check) + + // Surface Fill controls (one-face sheet from a sketch boundary). + wxStaticText* m_surf_fill_sketch_label{nullptr}; + int m_surf_fill_sketch_ref{-1}; + + // Surface Offset controls (offset a SHEET body). + ComboBox* m_surf_offset_body{nullptr}; // sheet-body picker + wxSpinCtrlDouble* m_surf_offset_distance{nullptr}; + + // Thicken Surface controls (thicken a SHEET body into a solid). + ComboBox* m_surf_thicken_body{nullptr}; // sheet-body picker + wxSpinCtrlDouble* m_surf_thicken_thickness{nullptr}; + CheckBox* m_surf_thicken_flip{nullptr}; + + // Transform controls (rigid move/rotate of a body). + ComboBox* m_xf_body{nullptr}; // body to transform + wxSpinCtrlDouble* m_xf_dx{nullptr}; // translate X + wxSpinCtrlDouble* m_xf_dy{nullptr}; // translate Y + wxSpinCtrlDouble* m_xf_dz{nullptr}; // translate Z + ComboBox* m_xf_axis{nullptr}; // rotation axis: X/Y/Z + wxSpinCtrlDouble* m_xf_angle{nullptr}; // rotation angle (deg) + wxSpinCtrlDouble* m_xf_pivot_x{nullptr}; // pivot X + wxSpinCtrlDouble* m_xf_pivot_y{nullptr}; // pivot Y + wxSpinCtrlDouble* m_xf_pivot_z{nullptr}; // pivot Z + CheckBox* m_xf_copy{nullptr}; // keep original (make a copy) + + // Mirror controls (reflect a body about a plane). + ComboBox* m_mirror_body{nullptr}; // body to mirror + ComboBox* m_mirror_plane{nullptr}; // mirror plane (XY/XZ/YZ + datums) + CheckBox* m_mirror_keep{nullptr}; // keep original body + + // Thicken controls (offset one solid face into a thin plate). + ComboBox* m_thicken_body{nullptr}; // source body + wxStaticText* m_thicken_face_label{nullptr}; // picked face + wxSpinCtrlDouble* m_thicken_thickness{nullptr}; + CheckBox* m_thicken_flip{nullptr}; // flip direction + + // Rib controls (thin wall from an open sketch line). + ComboBox* m_rib_body{nullptr}; // target body + ComboBox* m_rib_sketch{nullptr}; // sketch holding the open line (feature index in client data) + wxSpinCtrl* m_rib_entity{nullptr}; // entity index within the sketch + wxSpinCtrlDouble* m_rib_thickness{nullptr}; + wxSpinCtrlDouble* m_rib_depth{nullptr}; + + // Project controls (project body edges onto a plane as sketch entities). + ComboBox* m_proj_source_body{nullptr}; // source body + wxStaticText* m_proj_face_label{nullptr}; // picked face (or "all edges") + ComboBox* m_proj_plane{nullptr}; // target plane + + // Delete Face controls (remove faces, heal the solid). + ComboBox* m_del_face_body{nullptr}; // target body + wxButton* m_del_face_add_btn{nullptr}; // "Add picked face" button + wxStaticText* m_del_face_list{nullptr}; // shows the accumulated face ids + std::vector m_del_faces; // accumulated face list + + // Helix controls (helical curve). + ComboBox* m_helix_plane{nullptr}; // axis plane (XY/XZ/YZ + datums) + wxSpinCtrlDouble* m_helix_radius{nullptr}; + wxSpinCtrlDouble* m_helix_pitch{nullptr}; + wxSpinCtrlDouble* m_helix_height{nullptr}; + CheckBox* m_helix_left_handed{nullptr}; + wxSpinCtrlDouble* m_helix_taper{nullptr}; + + // Mate (assembly) controls + ComboBox* m_mate_kind{nullptr}; + ComboBox* m_mate_cs_a{nullptr}; + ComboBox* m_mate_cs_b{nullptr}; + wxSpinCtrlDouble* m_mate_offset{nullptr}; + wxSpinCtrlDouble* m_mate_angle{nullptr}; + CheckBox* m_mate_flip{nullptr}; + wxStaticText* m_offset_label{nullptr}; + wxStaticText* m_angle_label{nullptr}; + + // Expression binding (per-feature, visible during edit only) + ComboBox* m_expr_field{nullptr}; // field-name picker (editable) + wxTextCtrl* m_expr_text{nullptr}; // expression string + wxButton* m_expr_set_btn{nullptr}; // Apply / bind + wxButton* m_expr_clear_btn{nullptr}; // Remove binding + wxStaticText* m_expr_status{nullptr}; // shows current bindings for the edited feature + void populate_expr_fields(Tool t); // fill m_expr_field from feature-type fields + void on_set_expr(); // checkpoint + write -> recompute -> undo on fail + void on_clear_expr(); // remove selected binding + + // Document variables panel (below the feature tree / parts) + StaticBox* m_var_box{nullptr}; + wxListCtrl* m_var_list{nullptr}; + wxButton* m_btn_add_var{nullptr}; + wxButton* m_btn_edit_var{nullptr}; + wxButton* m_btn_del_var{nullptr}; + void refresh_variables(); // rebuild m_var_list from m_doc.variables + void on_add_variable(); + void on_edit_variable(); + void on_remove_variable(); + + // Feature-tree button + ScalableButton* m_btn_interfere{nullptr}; + + // Pattern controls (replicate the target body: linear or circular). + ComboBox* m_pattern_type{nullptr}; // 0 = Linear, 1 = Circular + wxSpinCtrlDouble* m_pattern_count{nullptr}; // total instances incl. seed + wxSpinCtrlDouble* m_pattern_spacing{nullptr}; // linear step (mm) + ComboBox* m_pattern_dir{nullptr}; // linear direction: 0 = plane X, 1 = plane Y + wxSpinCtrlDouble* m_pattern_angle{nullptr}; // circular total angle (deg) + // Boolean controls (combine two existing bodies). + ComboBox* m_bool_op{nullptr}; // 0 = Union, 1 = Subtract, 2 = Intersect + ComboBox* m_bool_target{nullptr}; // body that survives (selection == body index) + ComboBox* m_bool_tool{nullptr}; // body consumed (selection == body index) + // Which operand the NEXT viewport body pick fills: 0 = target, 1 = tool. Reset when the + // card opens, so the first two clicks in the viewport always mean "keep this, cut with + // that" in that order. The combos remain the typed half and mirror whatever is picked. + int m_bool_next_slot{0}; + CheckBox* m_bool_keep{nullptr}; // keep the tool body after the op + wxSpinCtrlDouble* m_bool_tol{nullptr}; // OCCT fuzzy tolerance (mm); robust cut on near-coincident faces + + // Plane Cut (split-by-plane): a reference plane + offset splits the target body into + // two separate bodies (both pieces kept). + ComboBox* m_cut_plane{nullptr}; // XY/XZ/YZ + datum planes (cut plane) + ComboBox* m_cut_target{nullptr}; // body to cut (selection == body index) + wxSpinCtrlDouble* m_cut_offset{nullptr}; // offset along the plane normal (mm) + // Datum plane controls (derive a selectable sketch plane: offset + tilt from a base). + ComboBox* m_plane_base{nullptr}; // 0=XY,1=XZ,2=YZ, 3+N = Nth datum plane + wxSpinCtrlDouble* m_plane_offset{nullptr}; // offset along base normal (mm) + wxSpinCtrlDouble* m_plane_tilt{nullptr}; // tilt about a base axis (deg) / Angle / Tangent angle + ComboBox* m_plane_tilt_axis{nullptr}; // 0 = base X, 1 = base Y + // Plane construction method + contextual face/edge reference picks (Onshape/Fusion parity). + ComboBox* m_plane_type{nullptr}; // PlaneType: Offset/Angle/Midplane/Tangent/TwoEdges/Coincident + wxButton* m_plane_pick_faceA{nullptr}; wxStaticText* m_plane_faceA_lbl{nullptr}; + wxButton* m_plane_pick_faceB{nullptr}; wxStaticText* m_plane_faceB_lbl{nullptr}; + wxButton* m_plane_pick_edgeA{nullptr}; wxStaticText* m_plane_edgeA_lbl{nullptr}; + wxButton* m_plane_pick_edgeB{nullptr}; wxStaticText* m_plane_edgeB_lbl{nullptr}; + wxSpinCtrlDouble* m_plane_usize{nullptr}; // datum rectangle extent u (mm) — also driven by drag handles + wxSpinCtrlDouble* m_plane_vsize{nullptr}; // datum rectangle extent v (mm) + // Captured references for the candidate datum (body index + face/edge index, -1 = none). + int m_pl_faceA_body{-1}, m_pl_faceA{-1}; + int m_pl_faceB_body{-1}, m_pl_faceB{-1}; + int m_pl_edgeA_body{-1}, m_pl_edgeA{-1}; + int m_pl_edgeB_body{-1}, m_pl_edgeB{-1}; + PlanePick m_plane_pick{PlanePick::None}; // which ref the next solid pick fills + // Plate loop selection (click a committed sketch loop): the Sketch feature + the + // clicked closed-region index, so Extrude builds just that one loop. -1 = none. + int m_sel_sketch_feat{-1}; + int m_sel_sketch_region{-1}; + // Click-selected solid topology (whole/face/edge cycle): face id for up-to-face / dress-up. + int m_sel_solid_body{-1}; // which body the face/edge selection is on + int m_sel_solid_face{-1}; + int m_sel_solid_edge{-1}; + bool m_sel_solid_vertex{false}; // a corner is picked (body+point, no face/edge) + // The face actually under the last solid click, INDEPENDENT of the whole/face/edge cycle level. + // The first click on a solid selects the WHOLE body, but the ray has already resolved which face + // it hit and the callback passes it. "Sketch on the face I clicked" must not require discovering + // that a second click refines the selection, so keep it instead of throwing it away. 3a2. + int m_pick_face_body{-1}; + int m_pick_face{-1}; + // What the live sketch was actually opened on ("the picked face", "XY", a datum's name), so the + // hint can say it. Resolved from the selection at begin_sketch, not read back from a combo. + wxString m_sketch_on; + // --- the object-driven offer (charter 4.1) --------------------------------------------- + // Right-click the geometry -> a vertical list in ratified row order, verbs that do not + // apply disabled IN PLACE with their reason. The rows come from the generated table in + // DesignOffer.hpp; this map is how a row reaches the code that already implements it, for + // the verbs that have no keyboard shortcut to route through. + std::map> m_verb_actions; + // Append an offer row with its toolbar glyph. The bitmap must be set BEFORE Append — + // wxGTK builds the GtkMenuItem there and only makes an image item if one is present. + // Every status write goes through here so long hints wrap instead of clipping. + void set_status(const wxString& text); + wxString idle_hint() const; // what to say when nothing is selected + // Reason detect_mate_conflicts() recorded for a feature, or nullptr. Marks the tree row and + // feeds the status line; a conflict is a diagnostic, not a document error. + const std::string* mate_conflict_reason(int feature) const; + + wxMenuItem* append_offer_item(wxMenu* menu, int id, const wxString& text, + const struct OfferVerb& v); + void show_offer_menu(const wxPoint& screen_pos); + // Where the offer opens when no mouse press anchors it: the keyboard route, and the automatic + // open on entering Sketch. The pointer if it is over the viewport, else the viewport's centre. + // A raw wxGetMousePosition() can be sitting on the toolbar, on the card column or on another + // monitor, and the menu would map there — detached from the geometry it is about. + wxPoint offer_anchor() const; + int offer_selection_kind() const; // an OfferSel, as int to keep the header light + // Does the SKETCH half of the map apply? A mode question, not a session one: begin_sketch + // does not run until the first tool is armed, so between "press Sketch" and "pick a tool" + // is_sketching() is still false — precisely when the drawing tools must be on offer. The + // is_sketching() arm covers re-opening a committed sketch, which enters the session first. + bool sketch_map_applies() const; + void run_offer_action(const char* action); + // Face-as-profile extrude (Onshape): when Extrude is opened on a picked solid face with + // no sketch source, this carries that global face id so the kernel extrudes the face. + // -1 = ordinary sketch/loop extrude. Set when opening the Extrude card, consumed on add. + int m_extrude_face_src{-1}; + + ComboBox* m_dressup_type{nullptr}; + ComboBox* m_face_group{nullptr}; + wxSpinCtrlDouble* m_dressup_size{nullptr}; + wxStaticText* m_dressup_edge_label{nullptr}; // shows the picked edge, or the group fallback + + ComboBox* m_hole_plane{nullptr}; + wxSpinCtrlDouble* m_hole_diameter{nullptr}; + wxSpinCtrlDouble* m_hole_depth{nullptr}; + CheckBox* m_hole_through{nullptr}; + wxSpinCtrlDouble* m_hole_x{nullptr}; + wxSpinCtrlDouble* m_hole_y{nullptr}; + // #2: when the Hole tool is opened on a picked solid face, drill on that face centred + // on it (origin = face centroid, normal = inward). m_hole_x/y then read as the offset + // from the face centre. Falls back to the m_hole_plane dropdown when no face is picked. + bool m_hole_on_face{false}; + SketchPlane m_hole_face_plane; + int m_hole_face_body{-1}; + // #2 Part B: the picked face's (u,v) bounds in m_hole_face_plane, so the hole's construction + // dims read as distance from the face sides (umin/vmin edges) rather than from the centre. + bool m_hole_has_bounds{false}; + double m_hole_umin{0}, m_hole_umax{0}, m_hole_vmin{0}, m_hole_vmax{0}; + // Says which face the latch above is holding. Thicken/Shell/Draft show theirs because their + // face IS the live selection; this one has to be shown precisely BECAUSE it is not, and the + // status line goes on saying "Nothing selected" while the ghost keeps drilling. 200. + wxStaticText* m_hole_target_label{nullptr}; + + ComboBox* m_thread_plane{nullptr}; + ComboBox* m_thread_std{nullptr}; // standard designation (M6, 1/4-20 UNC, ...) + wxSpinCtrlDouble* m_thread_radius{nullptr}; + wxSpinCtrlDouble* m_thread_pitch{nullptr}; + wxSpinCtrlDouble* m_thread_height{nullptr}; + wxSpinCtrlDouble* m_thread_depth{nullptr}; + CheckBox* m_thread_internal{nullptr}; + wxSpinCtrlDouble* m_thread_x{nullptr}; + wxSpinCtrlDouble* m_thread_y{nullptr}; + // #3: when the Thread tool is opened on a picked cylindrical face (a hole bore or a + // cylinder), thread that surface — plane on its axis, radius/internal derived from it. + bool m_thread_on_face{false}; + SketchPlane m_thread_face_plane; + int m_thread_face_body{-1}; + wxStaticText* m_thread_target_label{nullptr}; // the latched face/edge — see m_hole_target_label + + wxSpinCtrlDouble* m_shell_thickness{nullptr}; + wxStaticText* m_shell_face_label{nullptr}; // shows the picked face to remove + + // Draft controls (taper a single picked solid face about the body bottom). + wxSpinCtrlDouble* m_draft_angle{nullptr}; + wxStaticText* m_draft_face_label{nullptr}; // shows the picked face to draft + + // Axis controls (datum axis: line through two points or derived from geometry). + ComboBox* m_axis_type{nullptr}; // AxisType: TwoPoints/FaceNormal/CylinderCenterline/PlaneIntersection/AlongEdge + wxButton* m_axis_pick_face{nullptr}; wxStaticText* m_axis_face_lbl{nullptr}; + wxButton* m_axis_pick_edge{nullptr}; wxStaticText* m_axis_edge_lbl{nullptr}; + ComboBox* m_axis_plane_a{nullptr}; + ComboBox* m_axis_plane_b{nullptr}; + wxSpinCtrlDouble* m_axis_p1x{nullptr}; wxSpinCtrlDouble* m_axis_p1y{nullptr}; wxSpinCtrlDouble* m_axis_p1z{nullptr}; + wxSpinCtrlDouble* m_axis_p2x{nullptr}; wxSpinCtrlDouble* m_axis_p2y{nullptr}; wxSpinCtrlDouble* m_axis_p2z{nullptr}; + int m_ax_face_body{-1}, m_ax_face{-1}; + int m_ax_edge_body{-1}, m_ax_edge{-1}; + AxisPick m_axis_pick{AxisPick::None}; + + // CoordSys controls (datum coordinate system: point + orthonormal frame). + ComboBox* m_coordsys_type{nullptr}; // CoordSysType: PointWorld/FaceAndDirection + ComboBox* m_cs_body{nullptr}; // body-focus chooser: restrict picking to one body + wxSpinCtrlDouble* m_cs_x{nullptr}; wxSpinCtrlDouble* m_cs_y{nullptr}; wxSpinCtrlDouble* m_cs_z{nullptr}; + wxButton* m_cs_pick_face{nullptr}; wxStaticText* m_cs_face_lbl{nullptr}; + wxButton* m_cs_pick_edge{nullptr}; wxStaticText* m_cs_edge_lbl{nullptr}; + wxSpinCtrlDouble* m_cs_hx{nullptr}; wxSpinCtrlDouble* m_cs_hy{nullptr}; wxSpinCtrlDouble* m_cs_hz{nullptr}; + int m_cs_face_body{-1}, m_cs_face{-1}; + int m_cs_edge_body{-1}, m_cs_edge{-1}; + CoordSysPick m_coordsys_pick{CoordSysPick::None}; + + // Onshape-style docked value-entry card (Angle/Radius/Diameter/Offset/Fillet). + wxSizer* m_box_value{nullptr}; + wxStaticText* m_value_label{nullptr}; + wxTextCtrl* m_value_input{nullptr}; // plain text field: forces en ('.') decimals + double m_value_min{0.0}; // range for confirm-time clamping + double m_value_max{0.0}; + std::function m_value_cont; // deferred apply, run on Confirm + std::function m_value_cancel; // optional action when the card is cancelled + + // Feature tree: a wxTreeCtrl with per-feature-type icons. Callers keep using + // integer row indices via tree_selection()/set_tree_selection(); m_tree_items + // maps feature order -> tree node, rebuilt by refresh_tree(). + wxTreeCtrl* m_tree{nullptr}; + wxTreeCtrl* m_parts{nullptr}; // Bodies list under the feature tree + wxStaticText* m_parts_label{nullptr}; // its "Bodies" caption (hidden when empty) + wxBoxSizer* m_parts_hdr{nullptr}; // Bodies card header (icon + title) + wxStaticLine* m_parts_rule{nullptr}; // rule under that header + wxBoxSizer* m_hdr_tree_row{nullptr}; // Feature tree header: title + row actions + wxStaticText* m_hdr_tree{nullptr}; // its title label + wxImageList* m_tree_images{nullptr}; + std::vector m_tree_items; + // Parts list: tree rows for each body (parallel to m_doc.bodies). Selecting one + // highlights that body and makes it the target for the next op. + std::vector m_tree_body_items; + + // Section views (non-destructive): named "Section View N" entries listed in the tree, each a + // horizontal clip height. View-only — NOT bodies/features, never serialized. Key X adds one; + // clicking a row activates it (again = off); Delete removes it; Alt+Wheel moves the active one. + // Section view (single, non-destructive): ONE horizontal clip that hides half the model to + // inspect inside — solid, no ghost of the hidden half. Toggled on/off; Flip shows the other + // half. Never a body, no tree entry. + bool m_section_on{false}; + double m_section_cut_z{0.0}; + bool m_section_upper{false}; // false = keep lower half, true = upper + ScalableButton* m_section_flip_btn{nullptr}; // toolbar action; enabled only while the section is on + void toggle_section_view(); // Section View button / X: on <-> off + void flip_section_view(); // Flip button / F: opposite half + void update_section_flip_btn(); // enable the Flip button iff the section is on + // Per-body visibility (parallel to m_doc.bodies; index stable across recompute since + // bodies are appended in feature order). Empty/grown to all-visible by sync_body_visible(). + std::vector m_body_visible; + void sync_body_visible(); // grow/shrink m_body_visible to bodies.size() + // Per-body display translation (Move-body, M5). Parallel to m_doc.bodies; default + // identity. Applied to the display/pick meshes only — the OCCT shape (and face/edge + // global ids) is never touched, so dress-up targeting stays stable across a move. + std::vector m_body_xform; + std::vector m_disp_body_meshes; // display_body_meshes with m_body_xform applied + TriangleMesh m_disp_pick_mesh; // combined pick mesh with m_body_xform applied + void sync_body_xform(); // grow m_body_xform to bodies.size() (identity) + void rebuild_disp_meshes(); // recompute m_disp_* from m_doc + m_body_xform + void feed_bodies(); // push m_disp_* + visibility/xform to the viewport + void on_move_body(); // start the move gizmo on the selected body + void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) + void on_set_body_color(); // Color tool: pick a per-body display colour override + void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool + int tree_selection() const; // selected feature row, or wxNOT_FOUND + int tree_body_selection() const; // selected Parts-list body index, or -1 + void refresh_parts(); // rebuild the Bodies list under the feature tree + void sync_sidebar_width(); // keep the panel as wide as Prepare's sidebar + void set_tree_selection(int row); + static int tree_icon_for(CadFeatureType t); + + wxStaticText* m_status{nullptr}; + // m_status's foreground as created, captured before any caller touches it. Callers signal + // "no opinion" by setting wxNullColour, which restores exactly this — so it is the only + // reliable way to tell a chosen colour (the error red) from the default. See set_status(). + wxColour m_status_default_fg; + // The guidance sentence for the step the armed sketch tool is on, kept so a transient + // readout (the live length/angle while a segment is being dragged) can be appended to it + // instead of replacing it — the guidance used to vanish on the first mouse move after a + // click, which is precisely when it is needed. 1c0c. + wxString m_sketch_step; + // mode is a DesignSketchTool::Mode; passed as an int because this header deliberately does + // not include the tool's, and the .cpp (which does) casts it back. + void on_sketch_step(int mode, int step, int picks); + wxStaticText* m_dof_status{nullptr}; // DoF / constraint-state readout (P3) + // Last live-solve result, so entering Constrain can restore the readout without a solve. + int m_dof_last{-1}; + bool m_dof_last_ok{true}; + bool m_dof_last_has{false}; + int m_feature_counter{0}; + + std::vector m_confirm_btns; + + // Edit-in-place state: add-mode is m_edit_index == -1. Single-feature edit + // (Sketch or Extrude independently) uses only m_edit_index as the row to replace. + int m_edit_index{-1}; + + // Tree row of the sketch currently being constrained (-1 = not constraining). + int m_constrain_feat{-1}; + + // Constraint-manager card (C3.4): header + a rebuildable list of constraint rows. + wxSizer* m_box_constraints{nullptr}; + wxStaticText* m_hdr_constraints{nullptr}; + wxSizer* m_constraint_rows{nullptr}; + int m_constraint_sel{-1}; // highlighted constraint row, or -1 +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_DesignPanel_hpp_ diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp new file mode 100644 index 0000000000..716df658ed --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -0,0 +1,11154 @@ +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" +#include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/ImGuiWrapper.hpp" +#include "slic3r/GUI/CAD/SketchInlineEditor.hpp" +#include "slic3r/GUI/Plater.hpp" + +#include +#include +#include "libslic3r/BuildVolume.hpp" +#include "slic3r/GUI/Camera.hpp" +#include "slic3r/GUI/3DScene.hpp" +#include "slic3r/GUI/GLShader.hpp" +#include "libslic3r/CAD/GeometryEngine.hpp" +#include "libslic3r/TriangleMesh.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +// How many chords an arc is drawn with. loop_report corrects each arc's area back to the true +// curve using this exact number, so the two MUST agree — changing it here without updating the +// correction silently biases every reported area. +static constexpr int kArcFacets = 24; + +// Positioning helpers (defined lower down, used by the dimension methods above them). +static bool entity_ref_point(const SketchEntity& e, Vec2d& out); +static void translate_entity(SketchEntity& e, const Vec2d& d); +// Pick-distance helpers (defined lower down; used earlier by the edit-op gizmo). +static double point_segment_dist(const Vec2d& p, const Vec2d& a, const Vec2d& b); +static double entity_pick_dist(const Vec2d& p, const SketchEntity& e); +static bool point_in_poly(const Vec2d& q, const std::vector& poly); +static bool ray_triangle(const Vec3d& ro, const Vec3d& rd, const Vec3d& v0, const Vec3d& v1, + const Vec3d& v2, double& t); +static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a, const Vec3d& b); + +// Project a world-space point to canvas screen pixels (device px, GL viewport units; +// origin top-left after the GL y-flip). Mirrors GLCanvas3D's world->screen pattern: +// projection * view, perspective divide, NDC -> viewport. Returns (-1,-1) if behind. +// (Retained for auto-emitted dimensions in Phase B, which have no click to anchor to; +// needs the design canvas's own camera/viewport, not the plater's.) +[[maybe_unused]] static wxPoint world_to_screen_px(const Camera& cam, const Vec3d& world) +{ + // NB: multiply the raw 4x4 matrices, NOT the Transform3d objects — the projection is not + // affine, so Transform*Transform mangles it (Eigen assumes affine) and yields garbage w. + const Eigen::Matrix4d m = cam.get_projection_matrix().matrix() * cam.get_view_matrix().matrix(); + const Eigen::Vector4d clip = m * world.homogeneous(); + if (std::abs(clip.w()) < 1e-9) return wxPoint(-1, -1); + const Vec3d ndc = clip.head<3>() / clip.w(); + const std::array& vp = cam.get_viewport(); + const double sx = vp[0] + (ndc.x() * 0.5 + 0.5) * vp[2]; + const double sy = vp[1] + (1.0 - (ndc.y() * 0.5 + 0.5)) * vp[3]; // GL y-up -> wx y-down + return wxPoint(int(sx + 0.5), int(sy + 0.5)); +} + +// The kernel's weld tolerance follows the app preference, and it must be pushed at EVERY +// point that starts a sketch session: a Constrain session never passes through begin(), and +// it uses region_loops()/connected_loop(), which read the same tolerance. Pushing in one +// place only would leave those sessions on whatever the previous session set. +static void push_auto_close_pref() +{ + Slic3r::set_sketch_auto_close(wxGetApp().is_auto_close_sketch_loops()); +} + +void DesignSketchTool::begin(const SketchPlane& plane, Mode mode) +{ + push_auto_close_pref(); + + m_plane = plane; + m_mode = mode; + m_step_mode_last = -1; // a new session re-announces its step, even if it repeats the last + m_points.clear(); + m_entities.clear(); + m_construction = false; + m_has_cursor = false; + m_sel_a = m_sel_b = -1; + m_constrain_entities = false; + m_pick0 = m_pick1 = m_pick2 = -1; + m_constraint_hl.clear(); + m_constrain_cons.clear(); + m_awaiting_length = false; + m_autoedit_seen = 0; // baseline: no entities yet; first commit triggers edit + m_autoedit_pending = false; + m_selection.clear(); + m_point_sel.clear(); + m_constraints.clear(); + m_dimensions.clear(); + m_dim_has0 = false; + m_pending_dim = -1; + m_dof = -1; m_solve_ok = true; m_entity_conflict.clear(); + m_features.clear(); + m_open_feature = -1; + m_active = true; +} + +// Re-open a committed entity sketch for editing: mirror begin() (fresh Select session), +// then load the geometry + driving constraints, re-detect feature groups, and live-solve +// so handles/quotes/regular-drag work exactly as in the original draw session. +void DesignSketchTool::begin_edit(const std::vector& entities, + const std::vector& constraints, + const SketchPlane& plane) +{ + begin(plane, Mode::Select); + m_entities = entities; + m_constraints = constraints; + rebuild_features_from_entities(); + resolve_live(); +} + +// Walk the entity list grouping each consecutive CLOSED chain (entities are stored in +// gesture order, so a polygon/rect/slot's members are contiguous and end-to-end linked) +// and classify it: L,A,L,A -> Slot; 4 arcs (2 concentric + 2 caps) -> ArcSlot; +// L,A×4 (4 equal-radius corner arcs) -> RoundedRect; 4 right-angled lines -> Rect; +// N equal-length lines -> Polygon. Single/irregular entities stay ungrouped (they fall +// back to per-entity quotes). This reconstructs m_features for a re-opened sketch. +void DesignSketchTool::rebuild_features_from_entities() +{ + m_features.clear(); + m_open_feature = -1; + const int n = int(m_entities.size()); + using T = SketchEntity::Type; + auto start = [&](int i) { return m_entities[i].p0; }; + auto end = [&](int i) { const SketchEntity& e = m_entities[i]; + return (e.type == T::Line || e.type == T::Arc) ? e.p1 : e.p0; }; + auto is_near = [](const Vec2d& a, const Vec2d& b) { + return (a - b).norm() <= 0.05 + 1e-3 * std::max(a.norm(), b.norm()); }; + + int i = 0; + while (i < n) { + int j = i; bool closed = false; // greedily extend a consecutive chain + while (j + 1 < n && is_near(end(j), start(j + 1))) { + ++j; + if ((j - i) >= 2 && is_near(end(j), start(i))) { closed = true; break; } + } + const int cnt = j - i + 1; + if (!closed || cnt < 3) { ++i; continue; } + + int arcs = 0; bool all_line = true; + for (int k = i; k <= j; ++k) { + if (m_entities[k].type == T::Arc) ++arcs; + if (m_entities[k].type != T::Line) all_line = false; + } + Vec2d c(0, 0); for (int k = i; k <= j; ++k) c += start(k); c /= double(cnt); + Feature f; f.begin = i; f.end = j + 1; f.c0 = c; + + if (cnt == 4 && arcs == 2 && + m_entities[i].type == T::Line && m_entities[i + 1].type == T::Arc && + m_entities[i + 2].type == T::Line && m_entities[i + 3].type == T::Arc) { + f.kind = FeatureKind::Slot; // make_slot: top, cap@c1, bottom, cap@c0 + f.c0 = m_entities[i + 3].center; + f.c1 = m_entities[i + 1].center; + f.param = m_entities[i + 1].radius; + m_features.push_back(f); + } else if (cnt == 4 && arcs == 4 && + is_near(m_entities[i].center, m_entities[i + 2].center) && + std::abs(m_entities[i + 1].radius - m_entities[i + 3].radius) <= + 0.02 * std::max(m_entities[i + 1].radius, 1e-6) && + m_entities[i].radius > m_entities[i + 2].radius) { + // make_arc_slot: [0]outer(Rc+w), [1]cap@E(w), [2]inner(Rc-w), [3]cap@S(w). + // c0=main centre, c1=centreline start (cap@S centre = Sc), param=half-width. + f.kind = FeatureKind::ArcSlot; + f.c0 = m_entities[i].center; + f.c1 = m_entities[i + 3].center; + f.param = m_entities[i + 3].radius; + m_features.push_back(f); + } else if (cnt == 8 && arcs == 4 && + m_entities[i].type == T::Line && m_entities[i + 1].type == T::Arc && + m_entities[i + 2].type == T::Line && m_entities[i + 3].type == T::Arc && + m_entities[i + 4].type == T::Line && m_entities[i + 5].type == T::Arc && + m_entities[i + 6].type == T::Line && m_entities[i + 7].type == T::Arc) { + // rounded_rect_entities: 4 corner arcs (equal radius r) at the inset corners. + // Recover the axis-aligned bounds from the arc centres ± r. + const double r = m_entities[i + 1].radius; + bool equal_r = true; + for (int k : {3, 5, 7}) + if (std::abs(m_entities[i + k].radius - r) > 0.02 * std::max(r, 1e-6)) + equal_r = false; + if (equal_r && r > 1e-6) { + double cxmin = 1e18, cxmax = -1e18, cymin = 1e18, cymax = -1e18; + for (int k : {1, 3, 5, 7}) { + const Vec2d& o = m_entities[i + k].center; + cxmin = std::min(cxmin, o.x()); cxmax = std::max(cxmax, o.x()); + cymin = std::min(cymin, o.y()); cymax = std::max(cymax, o.y()); + } + f.kind = FeatureKind::RoundedRect; + f.c0 = Vec2d(cxmin - r, cymin - r); // (xmin,ymin) + f.c1 = Vec2d(cxmax + r, cymax + r); // (xmax,ymax) + f.param = r; + m_features.push_back(f); + } + } else if (all_line) { + std::vector sidelen(cnt); + for (int k = 0; k < cnt; ++k) sidelen[k] = (m_entities[i + k].p1 - m_entities[i + k].p0).norm(); + const double lmin = *std::min_element(sidelen.begin(), sidelen.end()); + const double lmax = *std::max_element(sidelen.begin(), sidelen.end()); + const bool equal_sides = lmin > 1e-6 && (lmax - lmin) / lmax < 0.02; + bool all_right = true; + for (int k = 0; k < cnt && all_right; ++k) { + Vec2d u = m_entities[i + k].p1 - m_entities[i + k].p0; + Vec2d v = m_entities[i + (k + 1) % cnt].p1 - m_entities[i + (k + 1) % cnt].p0; + if (u.norm() < 1e-9 || v.norm() < 1e-9) { all_right = false; break; } + if (std::abs(u.normalized().dot(v.normalized())) > 0.06) all_right = false; // ~3.4° + } + if (cnt == 4 && all_right) { + f.kind = FeatureKind::CornerRect; f.c0 = start(i); f.c1 = start(i + 2); + m_features.push_back(f); + } else if (equal_sides) { + f.kind = FeatureKind::Polygon; f.c0 = c; f.c1 = start(i); + f.sides = cnt; f.param = (start(i) - c).norm(); + m_features.push_back(f); + } + } + i = j + 1; + } +} + +void DesignSketchTool::set_tool(Mode mode) +{ + // A READY edit-op carries the user's typed or dragged value, so switching tools commits it + // rather than dropping it — the same rule Tab follows in the dimension editor. Discarding it + // here is most of why Fillet looked like it simply did not work: every documented route (type + // the radius, or drag the arrow) left the op ready-but-pending, and the next tool click threw + // the value away and reverted the corner to sharp, with nothing on screen saying so. + // This MUST run before m_mode is reassigned: op_ready() and confirm_op() both switch on + // m_mode, so after the assignment they would test the tool being switched TO. That read + // op_ready()==0 with a=0 b=3 val=28.205 sitting right there — picked, valued, and dropped. + if (op_ready()) confirm_op(); + + // An OPEN inline value field freezes the canvas (on_mouse_impl returns early while + // m_awaiting_length) and blocks every keyboard shortcut (in_text includes inline_busy()). + // Drawing a rectangle opens one automatically for its Width/Height, so after a rectangle the + // sketch was STUCK: pressing C did nothing because the key never reached this function, and + // clicking on the canvas did nothing because the canvas was frozen. Committing here accepts + // the typed value and closes the field, which is the same rule the ready-edit-op above + // follows — leaving a tool must not silently discard what the user entered. + if (on_inline_commit) on_inline_commit(); + + // Switch the active drawing tool without dropping accumulated entities. + m_mode = mode; + m_points.clear(); + m_has_cursor = false; + // DRAIN THE QUEUED DIMENSIONS, do not just resync the baseline. on_inline_commit() above + // commits the field that is open, and the commit callback installed by + // open_next_autoedit_dim does `++m_autoedit_dim_idx; CallAfter(open_next_autoedit_dim)` — + // so the switch's OWN commit schedules the next queued field, which then opens on top of the + // tool the user just armed. Measured: draw a rectangle, its Width field opens, arm Line from + // the offer; Width commits, the status line reads "Line — click start, then end", and the + // rectangle's Height field is sitting over it. (The keyboard cannot reproduce it: in_text + // includes inline_busy(), so letters are swallowed while a field is open — the switch has to + // come from the menu, the toolbar or the MCP socket.) + // + // reset_autoedit() is the existing helper for exactly this and its comment already says it + // "mirrors set_tool's resync" — the three lines that used to be here were that mirror drifting + // out of step, missing the two members that matter. The already-queued CallAfter then finds + // m_autoedit_dim_idx == -1 and returns through the guard at the top of open_next_autoedit_dim. + // + // The current value is COMMITTED rather than cancelled, matching the ready-edit-op rule a few + // lines above and Tab in the dimension editor. Esc is the gesture that means keep-as-drawn; a + // tool switch is not Esc. + reset_autoedit(); + + // An abandoned gesture must not leave its half-open Feature behind. begin_feature() pushes a + // Feature with end == begin immediately and end_feature() is what fills it in — and pops it + // when the gesture appended nothing. Switching tools mid-gesture skips end_feature entirely, + // so a zero-span Feature stays in m_features for good and m_open_feature stays set, which + // also gates the auto-edit trigger for whatever is drawn next. + if (m_open_feature >= 0 && m_open_feature < int(m_features.size()) + && m_features[m_open_feature].end <= m_features[m_open_feature].begin) + m_features.pop_back(); // always the last one: begin_feature pushed it + m_open_feature = -1; + + // Constrain-mode picks and the imported-art transform outlived the tool that made them. + // Neither can misfire while another mode is active, which is why this is quieter than the + // dimension picks — but coming BACK to Constrain resurrected picks made before leaving, + // possibly against entities deleted in between. Reset them the way begin_constrain does. + m_sel_a = m_sel_b = -1; + m_constrain_entities = false; + reset_xform(); // cancel() already does this; a tool switch is just as much a leave + // A READY edit-op carries the user's typed or dragged value, so switching tools commits it + // rather than dropping it — the same rule Tab follows in the dimension editor. Discarding it + // here is most of why Fillet looked like it simply did not work: every documented route (type + // the radius, or drag the arrow) left the op ready-but-pending, and the next tool click threw + // the value away and reverted the corner to sharp, with nothing on screen saying so. + reset_op(); // drop any in-progress (not yet ready) edit-op gizmo + reset_tf(); // drop any in-progress transform gizmo + // A pick that survives the tool that made it is invisible, and the first sign of it is a + // constraint or a dimension landing on geometry the user did not choose. Drop the modal + // picks left armed by the tool we are leaving: the Dimension tool's first pick, the + // Constrain tool's picks, and the individual point selection. + m_dim_has0 = false; + m_dim_e0 = -1; + m_dim_r0 = SketchPointRole::P0; + m_pick0 = m_pick1 = m_pick2 = -1; + m_point_sel.clear(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); +} + +void DesignSketchTool::cancel() +{ + close_session_chrome(); // same orphaned-field freeze as finish() — see yce + m_active = false; + m_step_mode_last = -1; + m_points.clear(); + m_entities.clear(); + m_construction = false; + m_has_cursor = false; + m_sel_a = m_sel_b = -1; + m_constrain_entities = false; + m_pick0 = m_pick1 = m_pick2 = -1; + m_constraint_hl.clear(); + m_constrain_cons.clear(); + m_awaiting_length = false; + m_selection.clear(); + m_point_sel.clear(); + m_constraints.clear(); + m_dimensions.clear(); + m_dim_has0 = false; + m_pending_dim = -1; + m_dof = -1; m_solve_ok = true; m_entity_conflict.clear(); + m_features.clear(); + m_open_feature = -1; + reset_op(); + reset_xform(); + reset_tf(); +} + +// CadLevel::Gesture inside a sketch: drop the entity being drawn, keep the tool armed. +bool DesignSketchTool::abort_gesture() +{ + if (m_points.empty()) return false; + m_points.clear(); + m_has_cursor = false; + return true; +} + +// CadLevel::Tool inside a sketch: an armed draw/edit tool falls back to Select. +// Drop any pending edit-op BEFORE the downgrade: set_tool commits a ready one, and Esc must +// cancel it, never apply it. Right-click already discards it through its own branch. +bool DesignSketchTool::disarm_tool() +{ + if (m_mode == Mode::Select) return false; + reset_op(); + set_tool(Mode::Select); + return true; +} + +// Esc / right-click while active: layered exit (Onshape-like), and layered is where it stops. +// Abort an in-progress entity first, then drop a draw tool back to Select, then — only if the +// session holds NOTHING a user could mourn — leave it. +// +// It used to have a fourth layer: refuse once, and let the SECOND consecutive request destroy a +// drawn sketch. That is the "I pressed Esc twice and my rectangle was gone" report, and no +// warning makes it acceptable, because the two presses are never deliberate — the first is aimed +// at a value field or a tool and the second at the tool underneath it. A session holding geometry +// is now left ONLY through Finish (keep) or Cancel (discard), both of which say which they are. +// This is the single decision point for every caller, keyboard and mouse alike, so the guarantee +// cannot be re-opened by adding a route. +void DesignSketchTool::request_exit() +{ + if (abort_gesture()) return; + if (disarm_tool()) return; + if (live_sketch_has_work()) { if (on_exit_refused) on_exit_refused(); return; } + if (on_exit) on_exit(); + else cancel(); +} + +void DesignSketchTool::request_undo_redo(bool redo) +{ + if (on_undo_redo) on_undo_redo(redo); +} + +void DesignSketchTool::clear_selection() +{ + if (m_selection.empty() && m_point_sel.empty()) return; + m_selection.clear(); + m_point_sel.clear(); + if (on_selection_changed) on_selection_changed(0); +} + +void DesignSketchTool::delete_selected() +{ + if (m_selection.empty()) return; + const int n = int(m_entities.size()); + std::vector del(n, false); + for (int i : m_selection) + if (i >= 0 && i < n) del[i] = true; + // old index -> new index (or -1 if deleted), to fix up constraint references. + std::vector remap(n, -1); + int next = 0; + for (int i = 0; i < n; ++i) + if (!del[i]) remap[i] = next++; + for (int i = n - 1; i >= 0; --i) + if (del[i]) m_entities.erase(m_entities.begin() + i); + // Drop constraints touching a deleted entity; remap the survivors. + std::vector kept; + auto live = [&](int e) { return e < 0 || (e < n && remap[e] >= 0); }; + auto map = [&](int e) { return e < 0 ? -1 : remap[e]; }; + for (SketchEntityConstraintDef c : m_constraints) { + if (!live(c.ea) || !live(c.eb) || !live(c.ec)) continue; + c.ea = map(c.ea); c.eb = map(c.eb); c.ec = map(c.ec); + kept.push_back(c); + } + m_constraints.swap(kept); + m_selection.clear(); + m_point_sel.clear(); + // v1: placed quotes reference entity indices that have shifted; drop them rather + // than risk a dangling reference (the driving constraints survive, reindexed). + m_dimensions.clear(); + m_dim_has0 = false; + m_pending_dim = -1; + // The Dimension tool's pending FIRST pick is the same dangling-reference hazard as the placed + // quotes just cleared above: it references an entity index that has shifted or gone away, and + // a stale m_dim_e0 would dereference out of range on the next click. Drop it too. + m_dim_e0 = -1; + m_dim_r0 = SketchPointRole::P0; + + // FEATURE GROUPS hold [begin,end) ranges into m_entities, and every index past a deletion has + // just moved. Left alone they point at other people's geometry: feature_of() then answers with + // a group the user never drew, and the rect/slot/polygon handles and live quotes follow it. + // Survivors are remapped (a contiguous range stays contiguous, since the remap preserves + // order); a group that lost any member is dropped, the same rule the placed quotes above + // already follow — dangling is worse than absent. + { + std::vector kept_f; + for (const Feature& f : m_features) { + if (f.begin < 0 || f.end > n || f.end <= f.begin) continue; + bool whole = true; + for (int k = f.begin; k < f.end; ++k) + if (del[k]) { whole = false; break; } + if (!whole) continue; + Feature g = f; + g.begin = remap[f.begin]; + g.end = remap[f.end - 1] + 1; + kept_f.push_back(g); + } + m_features.swap(kept_f); + m_open_feature = -1; + } + + // The draw-then-edit QUEUE outlives the entities it was queued for. Its own helper says so: + // "Removing an entity that still has a deferred auto-edit would otherwise open a field on a + // now-deleted entity and freeze the flow" — it was simply never called from here. Measured: + // delete a rectangle whose Width/Height were still queued, draw a circle, type its radius — + // the field opens, the digits go in, and the radius does not move, because the field belongs + // to a rectangle that no longer exists. ua9g. + reset_autoedit(); + + // And re-solve, so the sketch's reported degrees of freedom describe the sketch that is + // actually there. Without this, sketch_describe answered dof=16 for a document holding one + // circle — the DoF of the geometry that had just been deleted. + resolve_live(); + if (on_selection_changed) on_selection_changed(0); +} + +// Convert the selection to/from construction geometry (6zic). The Construction +// checkbox only ever set the mode for what you draw NEXT, so a line drawn as real geometry +// could never become a guide, nor a guide become real. Whole Feature groups flip together: +// a rectangle is four Line entities and converting three of them is never what was meant. +int DesignSketchTool::toggle_selection_construction() +{ + if (!selection_valid() || m_selection.empty()) return 0; + std::vector hit(m_entities.size(), false); + for (int i : m_selection) { + const int f = feature_of(i); + if (f >= 0) + for (int k = m_features[f].begin; k < m_features[f].end; ++k) hit[k] = true; + else + hit[i] = true; + } + // One direction for the whole batch: any real geometry in it -> all become construction. + bool any_real = false; + for (size_t i = 0; i < hit.size(); ++i) + if (hit[i] && !m_entities[i].construction) { any_real = true; break; } + int n = 0; + for (size_t i = 0; i < hit.size(); ++i) + if (hit[i] && m_entities[i].construction != any_real) { m_entities[i].construction = any_real; ++n; } + return n; +} + +bool DesignSketchTool::selection_valid() const +{ + for (int i : m_selection) + if (i < 0 || i >= int(m_entities.size())) return false; + return true; +} + +DesignSketchTool::DimType DesignSketchTool::dimension_kind() const +{ + if (!selection_valid()) return DimType::None; + if (m_selection.size() == 1) { + switch (m_entities[m_selection[0]].type) { + case SketchEntity::Type::Line: return DimType::Length; + case SketchEntity::Type::Circle: return DimType::Diameter; + case SketchEntity::Type::Arc: return DimType::Radius; + default: return DimType::None; + } + } + if (m_selection.size() == 2) { + const SketchEntity& a = m_entities[m_selection[0]]; + const SketchEntity& b = m_entities[m_selection[1]]; + const bool aLine = (a.type == SketchEntity::Type::Line); + const bool bLine = (b.type == SketchEntity::Type::Line); + Vec2d tmp(0, 0); + if (aLine && bLine) return DimType::Angle; + // one line + one point-like (point / circle-centre / arc-centre) + if (aLine && entity_ref_point(b, tmp)) return DimType::DistanceToLine; + if (bLine && entity_ref_point(a, tmp)) return DimType::DistanceToLine; + // two point-likes -> centre/point distance (0 = coincident/concentric) + if (entity_ref_point(a, tmp) && entity_ref_point(b, tmp)) return DimType::Distance; + } + return DimType::None; +} + +double DesignSketchTool::dimension_current() const +{ + switch (dimension_kind()) { + case DimType::Length: { const auto& e = m_entities[m_selection[0]]; return (e.p1 - e.p0).norm(); } + case DimType::Diameter: return 2.0 * m_entities[m_selection[0]].radius; + case DimType::Radius: return m_entities[m_selection[0]].radius; + case DimType::Angle: { + const auto& a = m_entities[m_selection[0]]; + const auto& b = m_entities[m_selection[1]]; + const Vec2d da = a.p1 - a.p0, db = b.p1 - b.p0; + const double na = da.norm(), nb = db.norm(); + if (na < 1e-9 || nb < 1e-9) return 0.0; + const double c = std::max(-1.0, std::min(1.0, da.dot(db) / (na * nb))); + return std::acos(c) * 180.0 / M_PI; + } + case DimType::Distance: { + Vec2d ra(0, 0), rb(0, 0); + entity_ref_point(m_entities[m_selection[0]], ra); + entity_ref_point(m_entities[m_selection[1]], rb); + return (rb - ra).norm(); + } + case DimType::DistanceToLine: { + const SketchEntity& a = m_entities[m_selection[0]]; + const SketchEntity& b = m_entities[m_selection[1]]; + const bool aLine = (a.type == SketchEntity::Type::Line); + const SketchEntity& L = aLine ? a : b; + const SketchEntity& P = aLine ? b : a; + Vec2d rp(0, 0); entity_ref_point(P, rp); + Vec2d dir = L.p1 - L.p0; + const double n = dir.norm(); + if (n < 1e-9) return 0.0; + const Vec2d nrm(-dir.y() / n, dir.x() / n); // unit normal to the line + return std::abs((rp - L.p0).dot(nrm)); + } + default: return 0.0; + } +} + +void DesignSketchTool::apply_angle_between(int ia, int ib, double deg) +{ + SketchEntity& A = m_entities[ia]; + SketchEntity& B = m_entities[ib]; + const Vec2d aE[2] = { A.p0, A.p1 }; + const Vec2d bE[2] = { B.p0, B.p1 }; + int si = -1, sj = -1; + double best = 1e-6; + for (int i = 0; i < 2; ++i) + for (int j = 0; j < 2; ++j) { + const double d = (aE[i] - bE[j]).squaredNorm(); + if (d < best) { best = d; si = i; sj = j; } + } + Vec2d pivot, refDir, bMoving; + bool moveP1; + if (si >= 0) { // shared vertex: pivot there, A's arm is the reference + pivot = aE[si]; + refDir = aE[1 - si] - pivot; + moveP1 = (sj == 0); // move the B end that is NOT at the pivot + bMoving = moveP1 ? B.p1 : B.p0; + } else { // no shared vertex: pivot B.p0, A's direction is reference + pivot = B.p0; + refDir = A.p1 - A.p0; + moveP1 = true; + bMoving = B.p1; + } + double nr = refDir.norm(); + if (nr < 1e-9) return; + refDir /= nr; + const Vec2d db = bMoving - pivot; + const double Lb = db.norm(); + if (Lb < 1e-9) return; + const double cross = refDir.x() * db.y() - refDir.y() * db.x(); + const double sign = (cross >= 0.0) ? 1.0 : -1.0; // keep B on its current side + const double rad = sign * deg * M_PI / 180.0; + const Vec2d ndir(refDir.x() * std::cos(rad) - refDir.y() * std::sin(rad), + refDir.x() * std::sin(rad) + refDir.y() * std::cos(rad)); + const Vec2d nb = pivot + Lb * ndir; + if (moveP1) B.p1 = nb; else B.p0 = nb; +} + +void DesignSketchTool::apply_dimension(double v) +{ + switch (dimension_kind()) { + case DimType::Length: { + SketchEntity& e = m_entities[m_selection[0]]; + const Vec2d d = e.p1 - e.p0; + const double r = d.norm(); + if (r > 1e-9 && v > 1e-9) e.p1 = e.p0 + (v / r) * d; + break; + } + case DimType::Diameter: if (v > 1e-9) m_entities[m_selection[0]].radius = 0.5 * v; break; + case DimType::Radius: if (v > 1e-9) m_entities[m_selection[0]].radius = v; break; + case DimType::Angle: apply_angle_between(m_selection[0], m_selection[1], v); break; + case DimType::Distance: { + // Move the 2nd selection so its reference point sits at distance v from the + // 1st (v == 0 -> coincident / concentric). Translate the whole entity. + if (v < 0.0) break; + Vec2d ra(0, 0), rb(0, 0); + entity_ref_point(m_entities[m_selection[0]], ra); + SketchEntity& b = m_entities[m_selection[1]]; + entity_ref_point(b, rb); + const Vec2d d = rb - ra; + const double r = d.norm(); + Vec2d target = ra; + if (r > 1e-9) target = ra + (v / r) * d; + else target = ra + Vec2d(v, 0.0); + translate_entity(b, target - rb); + break; + } + case DimType::DistanceToLine: { + // Move the point-like selection perpendicular to the line so its reference + // point is at distance v (v == 0 -> on the line / on the axis). + if (v < 0.0) break; + const bool aLine = (m_entities[m_selection[0]].type == SketchEntity::Type::Line); + const SketchEntity& L = m_entities[m_selection[aLine ? 0 : 1]]; + SketchEntity& P = m_entities[m_selection[aLine ? 1 : 0]]; + Vec2d rp(0, 0); entity_ref_point(P, rp); + Vec2d dir = L.p1 - L.p0; + const double n = dir.norm(); + if (n < 1e-9) break; + const Vec2d nrm(-dir.y() / n, dir.x() / n); + const double d0 = (rp - L.p0).dot(nrm); // current signed distance + const double sign = (d0 >= 0.0) ? 1.0 : -1.0; // keep the point on its side + translate_entity(P, (sign * v - d0) * nrm); + break; + } + default: break; + } + record_dimension_constraint(v); // store a driving constraint for this dimension + resolve_live(); // live-solve so the viewport shows the solved sketch + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); +} + +// Append the SketchEntityConstraintDef that makes the just-applied dimension a +// driving constraint (enforced by the kernel live and at commit). DistanceToLine +// records a PointOnLine constraint so "centre onto axis" persists through re-solve. +void DesignSketchTool::record_dimension_constraint(double v) +{ + const DimType k = dimension_kind(); + auto role = [&](int i) { + return (m_entities[i].type == SketchEntity::Type::Point) ? SketchPointRole::P0 + : SketchPointRole::Center; + }; + SketchEntityConstraintDef c; + switch (k) { + case DimType::Length: + c.type = SketchConstraintType::Distance; + c.ea = m_selection[0]; c.ra = SketchPointRole::P0; + c.eb = m_selection[0]; c.rb = SketchPointRole::P1; + c.value = v; m_constraints.push_back(c); break; + case DimType::Diameter: + c.type = SketchConstraintType::Diameter; c.ea = m_selection[0]; c.value = v; + m_constraints.push_back(c); break; + case DimType::Radius: + c.type = SketchConstraintType::Radius; c.ea = m_selection[0]; c.value = v; + m_constraints.push_back(c); break; + case DimType::Angle: + c.type = SketchConstraintType::Angle; + c.ea = m_selection[0]; c.eb = m_selection[1]; c.value = v; + m_constraints.push_back(c); break; + case DimType::Distance: { + const int ia = m_selection[0], ib = m_selection[1]; + if (v < 1e-9) c.type = SketchConstraintType::Coincident; + else { c.type = SketchConstraintType::Distance; c.value = v; } + c.ea = ia; c.ra = role(ia); c.eb = ib; c.rb = role(ib); + m_constraints.push_back(c); break; + } + case DimType::DistanceToLine: { + // Point-on-line driving constraint: hold the point-like entity at unsigned + // perpendicular distance v from the line (v == 0 -> on the axis). + const bool aLine = (m_entities[m_selection[0]].type == SketchEntity::Type::Line); + const int ip = m_selection[aLine ? 1 : 0]; // point-like (Point/Circle/Arc) + const int il = m_selection[aLine ? 0 : 1]; // line + c.type = SketchConstraintType::PointOnLine; + c.ea = ip; c.ra = role(ip); c.eb = il; c.value = v; + m_constraints.push_back(c); break; + } + default: break; // None: no driving constraint recorded + } +} + +// Onshape-style live solve: enforce all accumulated driving constraints on the +// in-session entities immediately, so the viewport reflects the solved sketch as +// each dimension/constraint is added (not only at commit). The pre-edit geometry +// is the solver's initial guess, keeping convergence local and side-preserving. +void DesignSketchTool::resolve_live() +{ + resolve_live_drag(-1, SketchPointRole::P0); +} + +void DesignSketchTool::resolve_live_drag(int dragged_ei, SketchPointRole dragged_role) +{ + const bool has = !m_constraints.empty(); + m_entity_conflict.assign(m_entities.size(), 0); + if (has) { + // Dragging a line endpoint must move ONLY that endpoint (changing the line's angle + + // length), anchoring the other end — Onshape behaviour. Without this, a constraint on + // the line (e.g. a length dimension or an inferred H/V) lets the solver relocate the + // un-dragged endpoint too, so the whole line appears to shift. Temporarily Fix the + // opposite endpoint for this drag-solve only (set_point already moved just the grabbed + // point, so the other end's current coord is its anchor). + std::vector cons = m_constraints; + const bool line_end = dragged_ei >= 0 && dragged_ei < int(m_entities.size()) && + m_entities[dragged_ei].type == SketchEntity::Type::Line && + (dragged_role == SketchPointRole::P0 || dragged_role == SketchPointRole::P1); + if (line_end) { + SketchEntityConstraintDef fix; + fix.type = SketchConstraintType::Fix; + fix.ea = dragged_ei; + fix.ra = (dragged_role == SketchPointRole::P0) ? SketchPointRole::P1 + : SketchPointRole::P0; + cons.push_back(fix); + } + const SketchSolveResult r = (dragged_ei >= 0) + ? sketch_solve_drag(m_entities, cons, dragged_ei, dragged_role) + : sketch_solve(m_entities, cons); + m_dof = r.dof; + m_solve_ok = r.ok; + // Flag every entity referenced by a conflicting constraint so render() can + // tint it red (Onshape/SolveSpace over-constrained feedback). + for (int bi : r.bad) { + if (bi < 0 || bi >= int(m_constraints.size())) continue; + const SketchEntityConstraintDef& c = m_constraints[bi]; + for (int e : {c.ea, c.eb, c.ec}) + if (e >= 0 && e < int(m_entity_conflict.size())) m_entity_conflict[e] = 1; + } + } else { + m_dof = -1; m_solve_ok = true; + } + if (on_solve_state) on_solve_state(m_dof, m_solve_ok, has); +} + +// ---- Onshape-style visual editing: feature grouping + handles ----------------- + +// Index of the Feature whose entity span contains ei, or -1 (last match wins so a +// later, tighter gesture shadows an earlier one if they ever overlap). +int DesignSketchTool::feature_of(int ei) const +{ + for (int i = int(m_features.size()) - 1; i >= 0; --i) + if (ei >= m_features[i].begin && ei < m_features[i].end) return i; + return -1; +} + +// Open a Feature spanning the entities a single gesture is about to append. The +// [begin,end) range is closed in end_feature() once the gesture's entities are in. +void DesignSketchTool::begin_feature(FeatureKind kind) +{ + Feature f; + f.kind = kind; + f.begin = int(m_entities.size()); + f.end = f.begin; + m_features.push_back(f); + m_open_feature = int(m_features.size()) - 1; +} + +// Close the open Feature: record its entity span end + the gesture's parametric +// anchors (centres / corners / half-width / sides) for later handle + dim rebuild. +void DesignSketchTool::end_feature(const Vec2d& c0, const Vec2d& c1, double param, int sides) +{ + if (m_open_feature < 0 || m_open_feature >= int(m_features.size())) return; + Feature& f = m_features[m_open_feature]; + f.end = int(m_entities.size()); + f.c0 = c0; + f.c1 = c1; + f.param = param; + f.sides = sides; + // Drop a degenerate feature (gesture appended nothing). + if (f.end <= f.begin) m_features.pop_back(); + m_open_feature = -1; +} + +// Forward decls: these ellipse helpers are defined further down but used by the +// handle/drag code above their definition. +static Vec2d ellipse_point(const Vec2d& c, double a, double b, double phi, double t); +static double ellipse_param_of(const Vec2d& center, double a, double b, double phi, const Vec2d& q); + +// Live handle set (A3: Line + Circle). Recomputed from solved geometry every frame, +// never persisted — so handles always track the current solve. Derived roles (here +// the circle RadiusHandle, which is NOT a serialized SketchPointRole) are what let a +// tool expose a parametric control its raw entity points don't carry. A4 routes a +// Select-mode drag through hit_test_handle + set_handle; later phases add the +// slot/rect/polygon/ellipse roles off the Feature groups. +std::vector DesignSketchTool::build_handles() const +{ + std::vector hs; + hs.reserve(m_entities.size() * 2); + for (size_t i = 0; i < m_entities.size(); ++i) { + const SketchEntity& e = m_entities[i]; + switch (e.type) { + case SketchEntity::Type::Line: { + Handle a; a.role = HandleRole::P0; a.ei = int(i); a.pos = e.p0; hs.push_back(a); + Handle b; b.role = HandleRole::P1; b.ei = int(i); b.pos = e.p1; hs.push_back(b); + break; + } + case SketchEntity::Type::Circle: { + Handle c; c.role = HandleRole::Center; c.ei = int(i); c.pos = e.center; hs.push_back(c); + // RadiusHandle sits on the circle to the +x side of its centre; dragging it + // (A4) edits the radius. Pure geometry, no constraint of its own. + Handle r; r.role = HandleRole::RadiusHandle; r.ei = int(i); + r.pos = e.center + Vec2d(e.radius, 0.0); hs.push_back(r); + break; + } + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: { + // 3 grips: centre (translate), major-axis end (semi-major a + orientation phi), + // minor-axis end (semi-minor b). a=e.radius, b=e.rminor, phi=e.rotation. + // (EllipseArc also exposes its two endpoints via hit_test_point for sweep.) + const Vec2d um(std::cos(e.rotation), std::sin(e.rotation)); // major dir + const Vec2d un(-um.y(), um.x()); // minor dir + Handle c; c.role = HandleRole::Center; c.ei = int(i); c.pos = e.center; hs.push_back(c); + Handle ma; ma.role = HandleRole::MajorAxis; ma.ei = int(i); + ma.pos = e.center + um * e.radius; hs.push_back(ma); + Handle mi; mi.role = HandleRole::MinorAxis; mi.ei = int(i); + mi.pos = e.center + un * e.rminor; hs.push_back(mi); + break; + } + case SketchEntity::Type::BSpline: { + // One draggable grip per control pole; dragging a pole reshapes the curve. + for (size_t k = 0; k < e.ctrl.size(); ++k) { + Handle h; h.role = HandleRole::BSplineCtrl; h.ei = int(i); + h.ctrl_index = int(k); h.pos = e.ctrl[k]; hs.push_back(h); + } + break; + } + default: break; // Arc derived handles land in later chunks + } + } + return hs; +} + +// Nearest handle to plane-point p within tol. Ties broken by smallest distance. +bool DesignSketchTool::hit_test_handle(const Vec2d& p, double tol, Handle& out) const +{ + bool found = false; + double best = tol; + for (const Handle& h : build_handles()) { + const double d = (h.pos - p).norm(); + if (d <= best) { best = d; out = h; out.hovered = true; found = true; } + } + return found; +} + +// Recompute the hovered handle on a plain (no-button) move. Returns true ONLY when the +// hovered handle changes, so on_mouse forces a single repaint per transition rather than +// re-rendering on every motion event. A no-op (false) for non-Moving events. +bool DesignSketchTool::update_hover(GLCanvas3D& canvas, wxMouseEvent& evt) +{ + if (!evt.Moving()) return false; + Vec2d p; + screen_to_plane(canvas, evt, p); + // Zoom-aware pick tolerance: project a point a few px away and measure in plane units. + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 6, evt.GetY())); + const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + const bool had = m_has_hover_handle; + const Handle prev = m_hover_handle; + Handle h; + m_has_hover_handle = hit_test_handle(p, tol, h); + if (m_has_hover_handle) m_hover_handle = h; + return (had != m_has_hover_handle) || + (m_has_hover_handle && (prev.ei != h.ei || prev.role != h.role)); +} + +// Apply a handle drag (A4). Point handles (P0/P1/Center) move the entity point and +// pin it in the drag-solve so constraints settle around the cursor. The derived +// RadiusHandle isn't a solver point — it edits the circle radius directly, then a +// full re-solve lets any driving Radius/Diameter constraint reassert (an unconstrained +// radius is a free DoF, so the solver keeps the new value). Slot/rect/polygon/ellipse +// roles land in later phases (they rebuild their Feature group). +void DesignSketchTool::set_handle(const Handle& h, const Vec2d& target) +{ + if (h.ei < 0 || h.ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[h.ei]; + switch (h.role) { + case HandleRole::P0: + set_point(h.ei, SketchPointRole::P0, target); resolve_live_drag(h.ei, SketchPointRole::P0); break; + case HandleRole::P1: + set_point(h.ei, SketchPointRole::P1, target); resolve_live_drag(h.ei, SketchPointRole::P1); break; + case HandleRole::Center: + set_point(h.ei, SketchPointRole::Center, target); resolve_live_drag(h.ei, SketchPointRole::Center); break; + case HandleRole::RadiusHandle: { + const double r = (target - e.center).norm(); + if (r > 1e-6) e.radius = r; + resolve_live(); + break; + } + case HandleRole::MajorAxis: { + // The major grip defines the major-axis vector: sets semi-major a + orientation phi. + const Vec2d d = target - e.center; + const double a = d.norm(); + if (a > 1e-6) { + e.rotation = std::atan2(d.y(), d.x()); + e.radius = std::max(a, e.rminor); // keep OCCT invariant a >= b + } + if (e.type == SketchEntity::Type::EllipseArc) { // endpoints ride the reshaped frame + e.p0 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.start_angle); + e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); + } + resolve_live(); + break; + } + case HandleRole::MinorAxis: { + // The minor grip sets semi-minor b = perpendicular distance to the major axis. + const Vec2d um(std::cos(e.rotation), std::sin(e.rotation)); + const Vec2d un(-um.y(), um.x()); + const double b = std::abs((target - e.center).dot(un)); + if (b > 1e-6) e.rminor = std::min(b, e.radius); + if (e.type == SketchEntity::Type::EllipseArc) { + e.p0 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.start_angle); + e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); + } + resolve_live(); + break; + } + case HandleRole::BSplineCtrl: { + // Move one control pole; the end poles mirror p0/p1 (kept in sync for picking). + const int k = h.ctrl_index; + if (k >= 0 && k < int(e.ctrl.size())) { + e.ctrl[k] = target; + if (k == 0) e.p0 = target; + if (k == int(e.ctrl.size()) - 1) e.p1 = target; + } + resolve_live(); + break; + } + default: break; + } +} + +// ---- Dimension tool (Mode::Dimension): click-to-place driving quotes ---------- + +bool DesignSketchTool::point_at(int ei, SketchPointRole role, Vec2d& out) const +{ + if (ei < 0 || ei >= int(m_entities.size())) return false; + const SketchEntity& e = m_entities[ei]; + switch (role) { + case SketchPointRole::P0: out = e.p0; return true; + case SketchPointRole::P1: out = e.p1; return true; + case SketchPointRole::Center: out = e.center; return true; + } + return false; +} + +// Move an entity point to v. Lines/Points set the coordinate directly; a circle/ +// arc centre translates the whole entity (arc endpoints ride along). Dragging an +// arc's endpoint is intentionally a no-op (it would redefine radius + angles). +void DesignSketchTool::set_point(int ei, SketchPointRole role, const Vec2d& v) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[ei]; + switch (e.type) { + case SketchEntity::Type::Line: + if (role == SketchPointRole::P0) e.p0 = v; + else if (role == SketchPointRole::P1) e.p1 = v; + break; + case SketchEntity::Type::Point: + e.p0 = v; + break; + case SketchEntity::Type::Circle: + // p0 mirrors the centre for circles, which is the convention the solver both writes + // (SketchSolver.cpp:110) and restores after every solve (:421). Moving only e.center + // left the two disagreeing for the whole duration of a live drag, so anything reading + // p0 in that window saw the pre-drag position. + if (role == SketchPointRole::Center) { e.center = v; e.p0 = v; } + break; + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: + if (role == SketchPointRole::Center) { + const Vec2d d = v - e.center; // rigid translate, keep radius/angles + e.center = v; e.p0 += d; e.p1 += d; + } + break; + case SketchEntity::Type::Ellipse: + if (role == SketchPointRole::Center) { e.center = v; e.p0 = v; } + break; + case SketchEntity::Type::BSpline: + // P0/P1 drag the end poles; Center rigidly translates the whole curve. + if (role == SketchPointRole::P0 && !e.ctrl.empty()) { e.ctrl.front() = v; e.p0 = v; } + else if (role == SketchPointRole::P1 && !e.ctrl.empty()) { e.ctrl.back() = v; e.p1 = v; } + else if (role == SketchPointRole::Center) { + const Vec2d d = v - (e.ctrl.empty() ? e.p0 : e.ctrl.front()); + for (auto& cp : e.ctrl) cp += d; + e.p0 += d; e.p1 += d; + } + break; + } +} + +// Nearest entity *point* (endpoint / centre) within tol, with its role. +bool DesignSketchTool::hit_test_point(const Vec2d& p, double tol, int& ei, SketchPointRole& role) const +{ + double best = tol; + bool found = false; + auto consider = [&](int i, SketchPointRole r, const Vec2d& q) { + const double d = (q - p).norm(); + if (d < best) { best = d; ei = i; role = r; found = true; } + }; + for (size_t i = 0; i < m_entities.size(); ++i) { + const SketchEntity& e = m_entities[i]; + switch (e.type) { + case SketchEntity::Type::Line: + consider(int(i), SketchPointRole::P0, e.p0); + consider(int(i), SketchPointRole::P1, e.p1); + break; + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: + consider(int(i), SketchPointRole::P0, e.p0); + consider(int(i), SketchPointRole::P1, e.p1); + consider(int(i), SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::Circle: + case SketchEntity::Type::Ellipse: + consider(int(i), SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::BSpline: + consider(int(i), SketchPointRole::P0, e.p0); + consider(int(i), SketchPointRole::P1, e.p1); + break; + case SketchEntity::Type::Point: + consider(int(i), SketchPointRole::P0, e.p0); + break; + } + } + return found; +} + +double DesignSketchTool::measure_dim(const DimAnnot& a) const +{ + Vec2d pa, pb; + switch (a.kind) { + case DimType::Length: + return (a.ea >= 0 && a.ea < int(m_entities.size())) + ? (m_entities[a.ea].p1 - m_entities[a.ea].p0).norm() : 0.0; + case DimType::Diameter: + return (a.ea >= 0 && a.ea < int(m_entities.size())) ? 2.0 * m_entities[a.ea].radius : 0.0; + case DimType::Radius: + return (a.ea >= 0 && a.ea < int(m_entities.size())) ? m_entities[a.ea].radius : 0.0; + case DimType::Angle: { + // Single line: angle to the +X axis, normalised to [0,360). (Line-to-line angle + // dimensions use the legacy dialog path.) + if (a.ea < 0 || a.ea >= int(m_entities.size())) return 0.0; + const SketchEntity& e = m_entities[a.ea]; + if (e.type != SketchEntity::Type::Line) return 0.0; + const Vec2d d = e.p1 - e.p0; + if (d.squaredNorm() < 1e-18) return 0.0; + double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; + if (deg < 0.0) deg += 360.0; + return deg; + } + case DimType::Distance: + return (point_at(a.ea, a.ra, pa) && point_at(a.eb, a.rb, pb)) ? (pb - pa).norm() : 0.0; + case DimType::DistanceToLine: { + if (!point_at(a.ea, a.ra, pa) || a.eb < 0 || a.eb >= int(m_entities.size())) return 0.0; + const SketchEntity& L = m_entities[a.eb]; + const Vec2d d = L.p1 - L.p0; + const double n = d.norm(); + if (n < 1e-9) return 0.0; + const Vec2d nrm(-d.y() / n, d.x() / n); + return std::abs((pa - L.p0).dot(nrm)); + } + default: return 0.0; + } +} + +// One driving constraint per (kind, operands) — update it in place rather than appending a +// second one every time its value is edited. +// +// Re-typing a quote used to push a duplicate alongside the original: draw a line and accept its +// length, and you get Distance(P0,P1)=64.9; click the quote later and type 30, and you get a +// SECOND Distance on the same two points asking for 30. That is over-constrained by +// construction, so the solver reported "Conflicting constraints" and refused to move anything — +// the number on screen changed and the geometry did not, which reads as the edit being ignored. +// A line carrying no dimension yet was unaffected, which is what made it look intermittent. +// +// Operand order is ignored: a Distance from A to B is the same constraint as B to A, and so is +// an Angle. Returns the index, so callers can keep the annotation's `con` link pointing at the +// constraint that is actually live — which is what makes the next edit an update too. +int DesignSketchTool::upsert_constraint(const SketchEntityConstraintDef& c) +{ + auto same_operands = [&](const SketchEntityConstraintDef& o) { + if (o.ea == c.ea && o.ra == c.ra && o.eb == c.eb && o.rb == c.rb) return true; + return o.ea == c.eb && o.ra == c.rb && o.eb == c.ea && o.rb == c.ra; + }; + for (int i = 0; i < int(m_constraints.size()); ++i) + if (m_constraints[i].type == c.type && same_operands(m_constraints[i])) { + m_constraints[i] = c; + return i; + } + m_constraints.push_back(c); + return int(m_constraints.size()) - 1; +} + +// The same rule for the visible annotation: one quote per (kind, operands), so repeated edits +// do not stack labels on top of each other reading different values. +int DesignSketchTool::upsert_dimension(const DimAnnot& a) +{ + for (int i = 0; i < int(m_dimensions.size()); ++i) { + const DimAnnot& o = m_dimensions[i]; + if (o.kind == a.kind && ((o.ea == a.ea && o.eb == a.eb) || (o.ea == a.eb && o.eb == a.ea))) { + const Vec2d keep = m_dimensions[i].label_pos; // don't teleport a placed label + m_dimensions[i] = a; + m_dimensions[i].label_pos = keep; + return i; + } + } + m_dimensions.push_back(a); + return int(m_dimensions.size()) - 1; +} + +SketchEntityConstraintDef DesignSketchTool::constraint_for(const DimAnnot& a) const +{ + SketchEntityConstraintDef c; + switch (a.kind) { + case DimType::Length: + c.type = SketchConstraintType::Distance; + c.ea = a.ea; c.ra = SketchPointRole::P0; + c.eb = a.ea; c.rb = SketchPointRole::P1; c.value = a.value; + break; + case DimType::Diameter: c.type = SketchConstraintType::Diameter; c.ea = a.ea; c.value = a.value; break; + case DimType::Radius: c.type = SketchConstraintType::Radius; c.ea = a.ea; c.value = a.value; break; + case DimType::Distance: + if (a.value < 1e-9) c.type = SketchConstraintType::Coincident; + else { c.type = SketchConstraintType::Distance; c.value = a.value; } + c.ea = a.ea; c.ra = a.ra; c.eb = a.eb; c.rb = a.rb; + break; + case DimType::DistanceToLine: + c.type = SketchConstraintType::PointOnLine; + c.ea = a.ea; c.ra = a.ra; c.eb = a.eb; c.value = a.value; + break; + default: break; + } + return c; +} + +// Measure the just-picked dimension, append its driving constraint, live-solve, and +// fire the value-card callback so the user can override the value. +int DesignSketchTool::place_dimension(DimAnnot a) +{ + a.value = measure_dim(a); + a.con = upsert_constraint(constraint_for(a)); + const int di = upsert_dimension(a); + resolve_live(); + open_value_editor(di); + return m_pending_dim; +} + +// Nearest placed-dimension label within tol (uses the centre cached by render). +int DesignSketchTool::hit_test_dimension(const Vec2d& p, double tol) const +{ + double best = tol; + int bi = -1; + for (size_t i = 0; i < m_dimensions.size(); ++i) { + const double d = (m_dimensions[i].label_pos - p).norm(); + if (d < best) { best = d; bi = int(i); } + } + return bi; +} + +// Reopen the value editor on an existing dimension (click/double-click its label). +void DesignSketchTool::edit_dimension(int di) +{ + if (di < 0 || di >= int(m_dimensions.size())) return; + open_value_editor(di); +} + +// Representative plane anchor for a dimension's value editor: the cached label centre +// once render has computed it, otherwise a geometric midpoint (length/distance) or the +// entity centre (radius/diameter). +Vec2d DesignSketchTool::dim_anchor(const DimAnnot& a) const +{ + if (a.label_pos.squaredNorm() > 1e-12) return a.label_pos; + Vec2d pa, pb; + if ((a.kind == DimType::Radius || a.kind == DimType::Diameter) && + point_at(a.ea, SketchPointRole::Center, pa)) + return pa; + const bool ga = point_at(a.ea, a.ra, pa); + const bool gb = point_at(a.eb, a.rb, pb); + if (ga && gb) return 0.5 * (pa + pb); + if (ga) return pa; + if (gb) return pb; + return Vec2d(0, 0); +} + +// Open the in-canvas value editor on dimension `di`. Projects the dimension's anchor +// to screen pixels and hands the host (DesignCanvas) a commit/cancel pair that drive +// the value through the existing set/cancel_dimension_value path. Falls back to the +// modal pick-complete callback when no inline-edit host is wired. +void DesignSketchTool::open_value_editor(int di) +{ + if (di < 0 || di >= int(m_dimensions.size())) return; + m_pending_dim = di; + if (!on_inline_edit) { + if (on_dimension_pick_complete) on_dimension_pick_complete(m_dimensions[di].value); + return; + } + const DimAnnot& a = m_dimensions[di]; + // Anchor the field OVER the dimension (project its label/anchor to the viewport), same as + // the draw-then-edit tools and Constrain mode; fall back to the click point if it projects + // off-screen. + wxPoint px(m_last_mouse_x, m_last_mouse_y); + const Camera& cam = wxGetApp().plater()->get_camera(); + const wxPoint lp = world_to_screen_px(cam, m_plane.to_world(dim_anchor(a))); + const std::array& vp = cam.get_viewport(); + if (lp.x >= vp[0] && lp.y >= vp[1] && lp.x <= vp[0] + vp[2] && lp.y <= vp[1] + vp[3]) + px = lp; + on_inline_edit(px, a.value, dimtype_title(a.kind), + [this](double v) { set_dimension_value(v); }, + [this]() { cancel_dimension_value(); }); +} + +// In-canvas editor for a line's angle-to-horizontal. Unlike length/radius, a single +// line's angle has no libslvs constraint here (SLVS_C_ANGLE is line-to-line), so the +// commit rotates the segment GEOMETRICALLY about P0 to the typed degrees, then re-solves +// — the angle is a free DoF, so the solver keeps the new orientation (mirrors the radius +// handle). Length-constrained lines keep their length. +void DesignSketchTool::open_angle_editor(int ei) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + if (m_entities[ei].type != SketchEntity::Type::Line) return; + if (!on_inline_edit) return; + DimAnnot a; a.kind = DimType::Angle; a.ea = ei; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, measure_dim(a), "Angle", + [this, ei](double deg) { set_line_angle(ei, deg); }, + []() {}); +} + +// Type the defining number of whatever is selected. One entry point for every 2D element, so +// the gesture is the same whichever tool drew it: point at it, right-click, type the value. +// +// This is what a sketch element was missing. Its endpoints could be dragged and its handles +// grabbed, but its own quantities — a line's LENGTH, an arc's RADIUS, a circle's DIAMETER, the +// ANGLE between two lines — were reachable only by arming the Dimension tool and re-picking the +// geometry that was already selected. The machinery was all here (dimension_kind / dimension_ +// current / apply_dimension); the way in was not. +bool DesignSketchTool::open_selection_dimension_editor() +{ + if (!on_inline_edit || !selection_valid()) return false; + const DimType k = dimension_kind(); + if (k == DimType::None) return false; + const char* title = "Value"; + switch (k) { + case DimType::Length: title = "Length"; break; + case DimType::Radius: title = "Radius"; break; + case DimType::Diameter: title = "Diameter"; break; + case DimType::Angle: title = "Angle"; break; + case DimType::Distance: title = "Distance"; break; + case DimType::DistanceToLine: title = "Distance"; break; + default: break; + } + // Anchor over the geometry it belongs to, not the panel: the value belongs to the element. + DimAnnot a; a.kind = k; + a.ea = m_selection.empty() ? -1 : m_selection[0]; + if (m_selection.size() > 1) a.eb = m_selection[1]; + const Vec2d at = dim_anchor(a); + const Camera& cam = wxGetApp().plater()->get_camera(); + wxPoint px = world_to_screen_px(cam, m_plane.to_world(at)); + if (px.x < 0 || px.y < 0) px = wxPoint(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, dimension_current(), title, + [this](double v) { apply_dimension(v); }, + []() {}); + return true; +} + +void DesignSketchTool::set_line_angle(int ei, double deg) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Line) return; + const double L = (e.p1 - e.p0).norm(); + if (L < 1e-9) return; + drop_orientation_constraints(ei, ei + 1); // a typed angle overrides an inferred H/V + const double r = deg * M_PI / 180.0; + e.p1 = e.p0 + Vec2d(std::cos(r), std::sin(r)) * L; // rotate about P0, keep length + resolve_live(); +} + +// Open the queued scalar quote at m_autoedit_dim_idx. Commit appends the driving dimension +// AND advances to the next queued quote (deferred via CallAfter so the single SketchInlineEditor +// fully unwinds its Enter handler before being reopened). Cancel (Esc) aborts the whole chain — +// the shape is kept as drawn. This is what lets a rectangle edit Width THEN Height, a slot its +// centre-distance THEN width, etc., instead of only the first dimension. +void DesignSketchTool::open_next_autoedit_dim() +{ + if (!on_inline_edit || !m_active) { m_autoedit_dim_idx = -1; return; } + if (m_autoedit_dim_idx < 0 || m_autoedit_dim_idx >= int(m_autoedit_dims.size())) { + m_autoedit_dim_idx = -1; + return; + } + const AutoEditStep step = m_autoedit_dims[m_autoedit_dim_idx]; + // Anchor the field OVER this dimension's label (project its plane-coords centre to the + // viewport), not at the last cursor spot — otherwise each field pops up in an unrelated + // screen position. Fall back to the cursor if the label projects off-screen. + // Anchor the field OVER this dimension's label (project its plane-coords centre to the + // viewport). A label can project OFF-screen (near-degenerate perspective divide when the + // sketch plane is viewed at a grazing angle), in which case we fall back to the cursor — + // but staggered by step index, so a shape's successive fields (rrect W/H/R) don't all + // stack on the exact same pixel and hide each other. + const Camera& cam = wxGetApp().plater()->get_camera(); + const wxPoint lp = world_to_screen_px(cam, m_plane.to_world(step.label)); + const std::array& vp = cam.get_viewport(); + wxPoint px(m_last_mouse_x, m_last_mouse_y + m_autoedit_dim_idx * 34); + if (lp.x >= vp[0] && lp.y >= vp[1] && lp.x <= vp[0] + vp[2] && lp.y <= vp[1] + vp[3]) + px = lp; + on_inline_edit(px, step.value, step.title, + [this, step](double v) { // commit: apply this dimension, then next + if (step.apply) step.apply(v); + ++m_autoedit_dim_idx; + wxGetApp().CallAfter([this] { open_next_autoedit_dim(); }); + }, + [this]() { m_autoedit_dim_idx = -1; }); // cancel: keep as drawn, stop the chain +} + +// Polyline draw-then-edit: after each click places a chain vertex, refine THAT segment's +// Length then Angle through the same AutoEditStep queue. The polyline batch-creates its +// entities only when the chain ends, so here we edit the pending m_points vertex directly +// (geometric): Length rescales it along the segment, Angle rotates it about the previous +// vertex. The next click continues from the adjusted vertex. Same field/anchor/focus path as +// every other tool — Enter advances Length->Angle, Esc keeps the segment as clicked. +void DesignSketchTool::arm_polyline_segment_edit() +{ + const int k = int(m_points.size()) - 1; // index of the just-placed vertex + if (k < 1 || !on_inline_edit) return; + const Vec2d a = m_points[k - 1]; // segment anchor (previous vertex) + const Vec2d mid = 0.5 * (a + m_points[k]); // label anchor = segment midpoint + const Vec2d d = m_points[k] - a; + const double L = d.norm(); + if (L < 1e-9) return; + double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; + + m_autoedit_dims.clear(); + m_autoedit_dims.push_back({ mid, L, [this, k, a](double len) { // Length + if (k < int(m_points.size())) { + Vec2d dd = m_points[k] - a; const double n = dd.norm(); + if (n > 1e-9 && len > 1e-9) m_points[k] = a + (len / n) * dd; + } + }, {}, "Length" }); + m_autoedit_dims.push_back({ mid, deg, [this, k, a](double dg) { // Angle + if (k < int(m_points.size())) { + const double len = (m_points[k] - a).norm(); + const double r = dg * M_PI / 180.0; + m_points[k] = a + len * Vec2d(std::cos(r), std::sin(r)); + } + }, {}, "Angle" }); + m_autoedit_dim_idx = 0; + wxGetApp().CallAfter([this] { open_next_autoedit_dim(); }); +} + +std::string DesignSketchTool::dimtype_title(DimType k) const { + switch (k) { + case DimType::Length: return "Length"; + case DimType::Diameter: return "Diameter"; + case DimType::Radius: return "Radius"; + case DimType::Angle: return "Angle"; + case DimType::Distance: return "Distance"; + case DimType::DistanceToLine: return "Distance"; + default: return "Value"; + } +} + +// Draw-then-edit dispatcher: mirror the Select-mode quote-click logic, but target the +// freshly-drawn selection's PRIMARY value and use the tentative (clean-cancel) path for +// scalar quotes. Runs after render_live_quotes, so the live-quote state is populated. +// Why a draw-then-edit chain did not start. Four early returns can swallow it, and from outside +// they are indistinguishable: the shape appears, no field opens, and nothing says which guard +// fired. check-gui-click-edit.py reports that as "a value field opened (nothing did)" for every +// tool at once, which reads like a total product failure and is not necessarily one. +static void trace_autoedit(const char* why, size_t n) +{ + if (!std::getenv("ORCA_CAD_UXTRACE")) return; + fprintf(stderr, "[UX] autoedit %s steps=%zu\n", why, n); + fflush(stderr); +} + +void DesignSketchTool::open_primary_autoedit() +{ + if (!on_inline_edit) { trace_autoedit("skip: no on_inline_edit host", 0); return; } + if (m_awaiting_length) { trace_autoedit("skip: a field is already open", 0); return; } + if (!m_active) { trace_autoedit("skip: session ended before the deferred tick", 0); return; } + + // Build ONE ordered list of edit steps covering EVERY characteristic dimension of the + // freshly-drawn shape — scalar quotes (constraint-based) AND geometric editors — so every + // 2D tool behaves like the rectangle: a linear sequence of value fields, each over its own + // label, Enter advances to the next, Esc keeps the shape as drawn. (Line keeps its own + // dedicated length field; Polyline/BSpline/Point have no two-click dimension set.) + m_autoedit_dims.clear(); + + // (1) Scalar quotes: rect Width+Height, slot Distance+Radius, circle/arc Radius, line + // Length. The lone Angle quote (only a single Line emits one) becomes a GEOMETRIC + // orientation step (set_line_angle, like the polygon angle) — so the Line tool gets + // Length THEN Angle, same as the rectangle gets W then H. + for (const DimAnnot& q : m_live_quotes) { + if (q.kind == DimType::Angle) { + const int ei = q.ea; + m_autoedit_dims.push_back({ q.label_pos, measure_dim(q), + [this, ei](double v) { set_line_angle(ei, v); }, { ei }, "Angle" }); + continue; + } + DimAnnot a = q; + a.value = measure_dim(a); + m_autoedit_dims.push_back({ a.label_pos, a.value, + [this, a](double v) mutable { + a.value = v; + a.con = upsert_constraint(constraint_for(a)); + upsert_dimension(a); + resolve_live(); + }, { a.ea, a.eb }, dimtype_title(a.kind) }); + } + + // (2) Geometric editors (mutate geometry directly, no constraint). Each reads the CURRENT + // feature/entity state inside apply(), so sequential edits compose correctly. + // Entities to highlight = the feature's whole [begin,end) span, so editing any of its + // characteristic dims lights up the shape it drives. + auto span = [this](int fi) { + std::vector v; + if (fi >= 0 && fi < int(m_features.size())) + for (int k = m_features[fi].begin; k < m_features[fi].end; ++k) v.push_back(k); + return v; + }; + if (m_live_poly_fi >= 0) { + const Feature& f = m_features[m_live_poly_fi]; + const int fi = m_live_poly_fi; + if (f.begin >= 0 && f.begin < int(m_entities.size())) { + const double side = (m_entities[f.begin].p1 - m_entities[f.begin].p0).norm(); + const Vec2d sp = m_entities[f.begin].p0 - f.c0; + double deg = std::atan2(sp.y(), sp.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; + m_autoedit_dims.push_back({ m_live_poly_side_label, side, [this, fi](double v){ set_polygon_side(fi, v); }, span(fi), "Side" }); + m_autoedit_dims.push_back({ m_live_poly_angle_label, deg, [this, fi](double v){ set_polygon_angle(fi, v); }, span(fi), "Angle" }); + } + } + if (m_live_rrect_fi >= 0) { + const Feature& f = m_features[m_live_rrect_fi]; + const int fi = m_live_rrect_fi; + const double w = std::abs(f.c1.x() - f.c0.x()), h = std::abs(f.c1.y() - f.c0.y()), r = f.param; + auto rr_w = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, v, std::abs(g.c1.y()-g.c0.y()), g.param); }; + auto rr_h = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, std::abs(g.c1.x()-g.c0.x()), v, g.param); }; + auto rr_r = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, std::abs(g.c1.x()-g.c0.x()), std::abs(g.c1.y()-g.c0.y()), v); }; + m_autoedit_dims.push_back({ m_live_rrect_w_label, w, rr_w, span(fi), "Width" }); + m_autoedit_dims.push_back({ m_live_rrect_h_label, h, rr_h, span(fi), "Height" }); + m_autoedit_dims.push_back({ m_live_rrect_r_label, r, rr_r, span(fi), "Radius" }); + } + if (m_live_aslot_fi >= 0) { + const Feature& f = m_features[m_live_aslot_fi]; + const int fi = m_live_aslot_fi; + const double Rc = (f.c1 - f.c0).norm(), fw = 2.0 * f.param; + m_autoedit_dims.push_back({ m_live_aslot_r_label, Rc, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, v, g.param); }, span(fi), "Radius" }); + m_autoedit_dims.push_back({ m_live_aslot_w_label, fw, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v*0.5)); }, span(fi), "Width" }); + } + if (m_live_slot_fi >= 0) { + const Feature& f = m_features[m_live_slot_fi]; + const int fi = m_live_slot_fi; + // Slot dims, in order: (1) inter-centre distance, (2) radius (= half-width), (3) angle. + const Vec2d d = f.c1 - f.c0; + const double Lc = d.norm(); + double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; + m_autoedit_dims.push_back({ m_live_slot_len_label, Lc, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, v, g.param); }, span(fi), "Length" }); + m_autoedit_dims.push_back({ m_live_slot_w_label, f.param, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v)); }, span(fi), "Radius" }); + m_autoedit_dims.push_back({ m_live_slot_angle_label, deg, [this, fi](double v){ set_slot_angle(fi, v); }, span(fi), "Angle" }); + } + if (m_live_arc_ei >= 0) { // arc Radius is already a scalar step above; add its sweep angle + const int ei = m_live_arc_ei; + const SketchEntity& e = m_entities[ei]; + const double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; + m_autoedit_dims.push_back({ m_live_arc_angle_label, swdeg, [this, ei](double v){ set_arc_sweep(ei, v); }, { ei }, "Angle" }); + } + if (m_live_ellipse_ei >= 0) { + const int ei = m_live_ellipse_ei; + const SketchEntity& e = m_entities[ei]; + m_autoedit_dims.push_back({ m_live_ellipse_major_label, e.radius, [this, ei](double v){ set_ellipse_axis(ei, true, v); }, { ei }, "Major" }); + m_autoedit_dims.push_back({ m_live_ellipse_minor_label, e.rminor, [this, ei](double v){ set_ellipse_axis(ei, false, v); }, { ei }, "Minor" }); + if (e.type == SketchEntity::Type::EllipseArc) { // + included sweep + const double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; + m_autoedit_dims.push_back({ m_live_ellipsearc_sweep_label, swdeg, + [this, ei](double v){ set_ellipsearc_sweep(ei, v); }, { ei }, "Angle" }); + } + } + if (m_live_obrect_fi >= 0) { // oblique rect: W,H already added as scalars; + orientation + const int fi = m_live_obrect_fi; + const Feature& f = m_features[fi]; + const SketchEntity& e0 = m_entities[f.begin]; + double adeg = std::atan2(e0.p1.y() - e0.p0.y(), e0.p1.x() - e0.p0.x()) * 180.0 / M_PI; + if (adeg < 0.0) adeg += 360.0; + m_autoedit_dims.push_back({ m_live_obrect_angle_label, adeg, + [this, fi](double v){ set_rect_angle(fi, v); }, span(fi), "Angle" }); + } + + trace_autoedit(m_autoedit_dims.empty() ? "built NO steps (no live quote matched)" : "opening", + m_autoedit_dims.size()); + if (!m_autoedit_dims.empty()) { + m_autoedit_dim_idx = 0; + open_next_autoedit_dim(); + } +} + +// A regular polygon is N raw lines with no centre entity, so (like the line angle) its +// side and orientation edits transform the whole loop GEOMETRICALLY about its centre. +void DesignSketchTool::open_polygon_side_editor(int fi) +{ + if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; + const Feature& f = m_features[fi]; + if (f.begin < 0 || f.begin >= int(m_entities.size())) return; + const double side = (m_entities[f.begin].p1 - m_entities[f.begin].p0).norm(); + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, side, "Side", + [this, fi](double v) { set_polygon_side(fi, v); }, + []() {}); +} + +void DesignSketchTool::open_polygon_angle_editor(int fi) +{ + if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; + const Feature& f = m_features[fi]; + if (f.begin < 0 || f.begin >= int(m_entities.size())) return; + const Vec2d sp = m_entities[f.begin].p0 - f.c0; // centre -> vertex0 spoke + double deg = std::atan2(sp.y(), sp.x()) * 180.0 / M_PI; + if (deg < 0.0) deg += 360.0; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, deg, "Angle", + [this, fi](double v) { set_polygon_angle(fi, v); }, + []() {}); +} + +// Scale the loop uniformly about its centre so an edge equals `side`. For a regular +// n-gon, circumradius R = side / (2 sin(pi/n)). +void DesignSketchTool::set_polygon_side(int fi, double side) +{ + if (fi < 0 || fi >= int(m_features.size()) || side < 1e-6) return; + const int n = std::max(3, m_features[fi].sides); + const double R = side / (2.0 * std::sin(M_PI / double(n))); + set_polygon_radius(fi, R); +} + +// Remove orientation constraints touching [begin,end). A pure rotation makes inferred +// per-edge Horizontal/Vertical (and Parallel/Perp/Angle/Lock) inconsistent, so leaving +// them in would make resolve_live collapse the shape to satisfy them. +void DesignSketchTool::drop_orientation_constraints(int begin, int end) +{ + using CT = SketchConstraintType; + auto orient = [](CT t) { + return t == CT::Horizontal || t == CT::Vertical || t == CT::Parallel || + t == CT::Perpendicular || t == CT::Angle || t == CT::LockX || t == CT::LockY; + }; + auto in = [&](int e) { return e >= begin && e < end; }; + std::vector remap(m_constraints.size(), -1); + std::vector kept; + kept.reserve(m_constraints.size()); + for (int i = 0; i < int(m_constraints.size()); ++i) { + const SketchEntityConstraintDef& c = m_constraints[i]; + if (orient(c.type) && (in(c.ea) || in(c.eb))) continue; // drop + remap[i] = int(kept.size()); + kept.push_back(c); + } + if (kept.size() == m_constraints.size()) return; // nothing dropped + m_constraints.swap(kept); + for (DimAnnot& a : m_dimensions) // fix cached con indices + if (a.con >= 0) a.con = (a.con < int(remap.size())) ? remap[a.con] : -1; +} + +void DesignSketchTool::drop_constraints_referencing(int ei) +{ + std::vector remap(m_constraints.size(), -1); + std::vector kept; + kept.reserve(m_constraints.size()); + for (int i = 0; i < int(m_constraints.size()); ++i) { + const SketchEntityConstraintDef& c = m_constraints[i]; + if (c.ea == ei || c.eb == ei || c.ec == ei) continue; // drop refs to the cut entity + remap[i] = int(kept.size()); + kept.push_back(c); + } + if (kept.size() == m_constraints.size()) return; + m_constraints.swap(kept); + for (DimAnnot& a : m_dimensions) + if (a.con >= 0) a.con = (a.con < int(remap.size())) ? remap[a.con] : -1; +} + +// Onshape scissors on the live sketch: cut the picked entity at its nearest intersection. +// trim_entity/extend_entity mutate the subject in place (slide one endpoint) given the other +// entities + the pick point — no entity is added/removed, so indices stay stable. +bool DesignSketchTool::apply_live_trim(const Vec2d& p, double tol, bool extend) +{ + double best = 1e30; int bi = -1; + for (size_t i = 0; i < m_entities.size(); ++i) { + const double d = entity_pick_dist(p, m_entities[i]); + if (d < best) { best = d; bi = int(i); } + } + if (bi < 0 || best > tol) return false; + using Ty = SketchEntity::Type; + const Ty st = m_entities[bi].type; + const bool subject_ok = extend ? (st == Ty::Line || st == Ty::Arc) + : (st == Ty::Line || st == Ty::Arc || st == Ty::Circle); + if (!subject_ok) return false; + std::vector others; + others.reserve(m_entities.size()); + for (size_t i = 0; i < m_entities.size(); ++i) + if (int(i) != bi) others.push_back(m_entities[i]); + const bool ok = extend ? SketchEngine::extend_entity(m_entities[bi], others, p) + : SketchEngine::trim_entity(m_entities[bi], others, p); + if (!ok) return false; + drop_constraints_referencing(bi); // the slid endpoint invalidates this entity's constraints + return true; +} + +// Hover preview for the Trim/Extend scissors: replay apply_live_trim's pick and the engine +// cut on a COPY of the subject, then diff the copy against the original to recover the exact +// sub-portion a click would remove (Trim) / add (Extend). Pure computation, mutates nothing. +bool DesignSketchTool::compute_trim_preview(const Vec2d& p, double tol, bool extend, + int& subject_ei, std::vector& removed_poly) const +{ + subject_ei = -1; + removed_poly.clear(); + + double best = 1e30; int bi = -1; + for (size_t i = 0; i < m_entities.size(); ++i) { + const double d = entity_pick_dist(p, m_entities[i]); + if (d < best) { best = d; bi = int(i); } + } + if (bi < 0 || best > tol) return false; + + using Ty = SketchEntity::Type; + const Ty st = m_entities[bi].type; + const bool subject_ok = extend ? (st == Ty::Line || st == Ty::Arc) + : (st == Ty::Line || st == Ty::Arc || st == Ty::Circle); + if (!subject_ok) return false; + + std::vector others; + others.reserve(m_entities.size()); + for (size_t i = 0; i < m_entities.size(); ++i) + if (int(i) != bi) others.push_back(m_entities[i]); + + const SketchEntity& orig = m_entities[bi]; + SketchEntity trimmed = orig; // cut on the copy, never the live entity + const bool ok = extend ? SketchEngine::extend_entity(trimmed, others, p) + : SketchEngine::trim_entity(trimmed, others, p); + if (!ok) return false; + + // The highlighted portion is where `trimmed` differs from `orig`: the dropped sub-segment + // (Trim) or the grown one (Extend). Rebuild it as a temp entity and sample its polyline. + const double EPS2 = 1e-14; // squared plane-unit endpoint tolerance + const double AEPS = 1e-7; // radian tolerance + bool closed = false; + + if (orig.type == Ty::Line) { + SketchEntity seg = orig; + if ((trimmed.p0 - orig.p0).squaredNorm() > EPS2) { + seg.p0 = orig.p0; seg.p1 = trimmed.p0; // start endpoint moved + } else if ((trimmed.p1 - orig.p1).squaredNorm() > EPS2) { + seg.p0 = trimmed.p1; seg.p1 = orig.p1; // end endpoint moved + } else { + return false; // nothing changed + } + removed_poly = entity_polyline(seg, closed); + } else if (orig.type == Ty::Arc) { + SketchEntity arc = orig; // same centre/radius + if (std::abs(trimmed.start_angle - orig.start_angle) > AEPS) { + arc.start_angle = orig.start_angle; arc.end_angle = trimmed.start_angle; + } else if (std::abs(trimmed.end_angle - orig.end_angle) > AEPS) { + arc.start_angle = trimmed.end_angle; arc.end_angle = orig.end_angle; + } else { + return false; + } + removed_poly = entity_polyline(arc, closed); + } else if (orig.type == Ty::Circle) { + // Trim opens the Circle into the kept Arc [start,end]; the removed gap is its + // complement, swept from the kept arc's end round to its start. + if (trimmed.type != Ty::Arc) return false; + SketchEntity gap = orig; + gap.type = Ty::Arc; + gap.start_angle = trimmed.end_angle; + gap.end_angle = trimmed.start_angle + 2.0 * M_PI; + removed_poly = entity_polyline(gap, closed); + } else { + return false; + } + + if (removed_poly.size() < 2) return false; + subject_ei = bi; + return true; +} + +// Rotate the whole loop about its centre so the centre->vertex0 spoke points at `deg` +// (degrees from +X). +void DesignSketchTool::set_polygon_angle(int fi, double deg) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.begin >= int(m_entities.size())) return; + const Vec2d c = f.c0; + const Vec2d sp = m_entities[f.begin].p0 - c; // centre -> vertex0 + if (sp.squaredNorm() < 1e-12) return; + const double cur = std::atan2(sp.y(), sp.x()); + const double da = deg * M_PI / 180.0 - cur; + drop_orientation_constraints(f.begin, f.end); // rotation invalidates edge H/V + const double ca = std::cos(da), sa = std::sin(da); + // -> Vec2d is REQUIRED: an auto return deduces an Eigen expression template that holds + // a reference to the destroyed `c + Vec2d(...)` temporary (dangling -> garbage coords). + auto rot = [&](const Vec2d& pt) -> Vec2d { const Vec2d r = pt - c; + return c + Vec2d(r.x() * ca - r.y() * sa, r.x() * sa + r.y() * ca); }; + for (int i = f.begin; i < f.end && i < int(m_entities.size()); ++i) { + SketchEntity& e = m_entities[i]; + if (e.type != SketchEntity::Type::Line) continue; + e.p0 = rot(e.p0); e.p1 = rot(e.p1); + } + // Do NOT re-solve: the rotated geometry is already a correct regular polygon, and the + // inferred loop (redundant Coincident, now with no H/V anchor) collapses to a point + // under libslvs. We only drop the stale edge H/V (above) so a later commit-solve stays + // sane; the display renders the mutated entities directly. +} + +// Drag a polygon vertex keeping the loop regular: scale + rotate the whole polygon +// about its centroid so the grabbed vertex lands on `target`. This adjusts both the +// circumradius (|target-centroid|) and the orientation (its direction) at once. +void DesignSketchTool::drag_polygon_vertex(int fi, int ei, SketchPointRole role, const Vec2d& target) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.end > int(m_entities.size())) return; + // Centroid of the loop = the regular polygon's centre (robust if it was moved). + Vec2d c(0, 0); int n = 0; + for (int i = f.begin; i < f.end; ++i) + if (m_entities[i].type == SketchEntity::Type::Line) { c += m_entities[i].p0; ++n; } + if (n == 0) return; + c /= double(n); + Vec2d vpos; + if (!point_at(ei, role, vpos)) return; + const Vec2d cur = vpos - c; // current grabbed-vertex spoke + const Vec2d tgt = target - c; // desired spoke + const double curR = cur.norm(), newR = tgt.norm(); + if (curR < 1e-9 || newR < 1e-6) return; + const double s = newR / curR; + const double da = std::atan2(tgt.y(), tgt.x()) - std::atan2(cur.y(), cur.x()); + const double ca = std::cos(da), sa = std::sin(da); + auto tf = [&](const Vec2d& p) -> Vec2d { const Vec2d r = (p - c) * s; // -> Vec2d: avoid + return c + Vec2d(r.x() * ca - r.y() * sa, r.x() * sa + r.y() * ca); }; // Eigen dangling + + for (int i = f.begin; i < f.end; ++i) { + SketchEntity& e = m_entities[i]; + if (e.type != SketchEntity::Type::Line) continue; + e.p0 = tf(e.p0); e.p1 = tf(e.p1); + } + f.c0 = c; f.param = newR; + drop_orientation_constraints(f.begin, f.end); // the drag rotates -> edge H/V invalid + // No re-solve (see set_polygon_angle): the transformed geometry is already a correct + // regular polygon; solving the anchorless redundant loop would collapse it. +} + +void DesignSketchTool::set_polygon_radius(int fi, double R) +{ + if (fi < 0 || fi >= int(m_features.size()) || R < 1e-6) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.begin >= int(m_entities.size())) return; + const Vec2d c = f.c0; + const double curR = (m_entities[f.begin].p0 - c).norm(); + if (curR < 1e-9) return; + const double s = R / curR; // uniform scale about the centre + for (int i = f.begin; i < f.end && i < int(m_entities.size()); ++i) { + SketchEntity& e = m_entities[i]; + if (e.type != SketchEntity::Type::Line) continue; + e.p0 = c + (e.p0 - c) * s; + e.p1 = c + (e.p1 - c) * s; + } + f.param = R; + // Geometric only (no solve): consistent with the rotation edits, and avoids collapsing + // the loop if its H/V anchors were already dropped by a prior rotation. +} + +void DesignSketchTool::open_arc_angle_editor(int ei) +{ + if (ei < 0 || ei >= int(m_entities.size()) || !on_inline_edit) return; + const SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Arc) return; + double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, swdeg, "Angle", + [this, ei](double v) { set_arc_sweep(ei, v); }, + []() {}); +} + +// Set the arc's included (sweep) angle to `deg`, keeping the start point and radius fixed +// and rotating the end point about the centre. Geometric (SLVS angle is line-to-line), so +// no re-solve; the mutated entity renders directly. Direction (CCW/CW) of the original +// sweep is preserved. +void DesignSketchTool::set_arc_sweep(int ei, double deg) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Arc || e.radius < 1e-6) return; + double sweep = std::max(1e-3, std::min(deg, 359.999)) * M_PI / 180.0; + const double sign = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; + e.end_angle = e.start_angle + sign * sweep; + e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + resolve_live(); +} + +// Drag one of the arc's three handles. Roles are split so each grip changes ONE property +// (Onshape-like): Center -> translate; START point (P0) -> radius only; END point (P1) -> +// sweep angle only. Geometric (mutates the entity directly), then re-solve for any +// coincident constraints on the arc endpoints. +void DesignSketchTool::drag_arc_handle(int ei, SketchPointRole role, const Vec2d& target) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Arc) return; + if (role == SketchPointRole::Center) { + const Vec2d d = target - e.center; // rigid translate, keep R + angles + e.center = target; e.p0 += d; e.p1 += d; + } else if (role == SketchPointRole::P0) { // start = RADIUS handle (keep angles) + const double R = (target - e.center).norm(); + if (R < 1e-6) return; + e.radius = R; + e.p0 = e.center + R * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); + e.p1 = e.center + R * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + } else if (role == SketchPointRole::P1) { // end = ANGLE handle (keep radius) + const Vec2d d = target - e.center; + if (d.squaredNorm() < 1e-12) return; + // Keep the CCW sweep continuous (0,2pi) so the arc never flips to its complement. + double da = std::atan2(d.y(), d.x()) - e.start_angle; + while (da < 0.0) da += 2.0 * M_PI; + while (da >= 2.0 * M_PI) da -= 2.0 * M_PI; + e.end_angle = e.start_angle + da; + e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + } + resolve_live(); +} + +// Drag an elliptical-arc grip. Center rigidly translates (endpoints + frame move with it); +// P0/P1 set the sweep start/end to the cursor's parametric angle on the ellipse, keeping +// the ellipse shape (a/b/phi). Geometric, then resolve_live(). +void DesignSketchTool::drag_ellipsearc_handle(int ei, SketchPointRole role, const Vec2d& target) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::EllipseArc) return; + if (role == SketchPointRole::Center) { + const Vec2d d = target - e.center; + e.center = target; e.p0 += d; e.p1 += d; + } else if (role == SketchPointRole::P0 || role == SketchPointRole::P1) { + const double t = ellipse_param_of(e.center, e.radius, e.rminor, e.rotation, target); + if (role == SketchPointRole::P0) { + e.start_angle = t; + e.p0 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, t); + } else { + // Keep the CCW sweep (end strictly after start) so the arc never inverts. + double t1 = t; while (t1 <= e.start_angle) t1 += 2.0 * M_PI; + e.end_angle = t1; + e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, t1); + } + } + resolve_live(); +} + +void DesignSketchTool::open_ellipse_axis_editor(int ei, bool major) +{ + if (ei < 0 || ei >= int(m_entities.size()) || !on_inline_edit) return; + const SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Ellipse && e.type != SketchEntity::Type::EllipseArc) return; + const double v = major ? e.radius : e.rminor; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, v, major ? "Major" : "Minor", + [this, ei, major](double nv) { set_ellipse_axis(ei, major, nv); }, + []() {}); +} + +// Set a semi-axis to `v`: major -> e.radius, minor -> e.rminor; keep OCCT a >= b. +void DesignSketchTool::set_ellipse_axis(int ei, bool major, double v) +{ + if (ei < 0 || ei >= int(m_entities.size()) || v < 1e-6) return; + SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Ellipse && e.type != SketchEntity::Type::EllipseArc) return; + if (major) e.radius = std::max(v, e.rminor); + else e.rminor = std::min(v, e.radius); + if (e.type == SketchEntity::Type::EllipseArc) { // endpoints ride the reshaped frame + e.p0 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.start_angle); + e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); + } + resolve_live(); +} + +// Set an elliptical arc's included (parametric) sweep, keeping the start fixed and moving the +// end. Geometric (mirrors set_arc_sweep), then resolve_live for any endpoint coincidences. +void DesignSketchTool::set_ellipsearc_sweep(int ei, double deg) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::EllipseArc) return; + const double sweep = std::max(1e-3, std::min(deg, 359.999)) * M_PI / 180.0; + const double sign = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; + e.end_angle = e.start_angle + sign * sweep; + e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); + resolve_live(); +} + +// Rotate an oblique rectangle to an absolute orientation (angle of edge0 to +X), pivoting on +// its anchor corner f.c0. Geometric, mirrors set_polygon_angle: drop the now-inconsistent edge +// H/V first, rotate every member point + the opposite corner, and DON'T re-solve (the rotated +// loop is already consistent; a length Distance the user may have set is rotation-invariant). +void DesignSketchTool::set_rect_angle(int fi, double deg) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.end > int(m_entities.size()) || f.end <= f.begin) return; + const SketchEntity& e0 = m_entities[f.begin]; + const Vec2d d0 = e0.p1 - e0.p0; + if (d0.squaredNorm() < 1e-12) return; + const double da = deg * M_PI / 180.0 - std::atan2(d0.y(), d0.x()); + drop_orientation_constraints(f.begin, f.end); + const Vec2d pivot = f.c0; + const double ca = std::cos(da), sa = std::sin(da); + // -> Vec2d REQUIRED (see set_polygon_angle): an auto return deduces an Eigen expression + // template referencing the destroyed temporary -> dangling. + auto rot = [&](const Vec2d& pt) -> Vec2d { const Vec2d r = pt - pivot; + return pivot + Vec2d(r.x() * ca - r.y() * sa, r.x() * sa + r.y() * ca); }; + for (int i = f.begin; i < f.end && i < int(m_entities.size()); ++i) { + SketchEntity& e = m_entities[i]; + e.p0 = rot(e.p0); e.p1 = rot(e.p1); + } + f.c1 = rot(f.c1); // keep the opposite corner consistent for later W/H quotes +} + +// Screen anchor for a Constrain-mode value field: over the picked geometry (its representative +// point — circle/arc centre, else segment midpoint; averaged when two entities are picked), +// projected to the viewport. Lets a dimensional constraint's field open ON the geometry like +// the draw-then-edit tools, instead of floating at viewport centre. False if no valid pick or +// it projects off-screen (caller falls back to centre). +bool DesignSketchTool::constrain_value_anchor(wxPoint& out) const +{ + if (m_pick0 < 0 || m_pick0 >= int(m_entities.size())) return false; + auto rep = [](const SketchEntity& e) -> Vec2d { + using T = SketchEntity::Type; + if (e.type == T::Circle || e.type == T::Arc || + e.type == T::Ellipse || e.type == T::EllipseArc) return e.center; + return 0.5 * (e.p0 + e.p1); + }; + Vec2d p = rep(m_entities[m_pick0]); + if (m_pick1 >= 0 && m_pick1 < int(m_entities.size())) + p = 0.5 * (p + rep(m_entities[m_pick1])); + const Camera& cam = wxGetApp().plater()->get_camera(); + const wxPoint sp = world_to_screen_px(cam, m_plane.to_world(p)); + const std::array& vp = cam.get_viewport(); + if (sp.x < vp[0] || sp.y < vp[1] || sp.x > vp[0] + vp[2] || sp.y > vp[1] + vp[3]) return false; + out = sp; + return true; +} + +void DesignSketchTool::open_rounded_rect_editor(int fi, int which) +{ + if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; + const Feature& f = m_features[fi]; + const double w = std::abs(f.c1.x() - f.c0.x()); + const double h = std::abs(f.c1.y() - f.c0.y()); + const double r = f.param; + const double v = (which == 0) ? w : (which == 1) ? h : r; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, v, which == 0 ? "Width" : which == 1 ? "Height" : "Radius", + [this, fi, which](double nv) { + const Feature& g = m_features[fi]; + double gw = std::abs(g.c1.x() - g.c0.x()); + double gh = std::abs(g.c1.y() - g.c0.y()); + double gr = g.param; + if (which == 0) gw = nv; else if (which == 1) gh = nv; else gr = nv; + set_rounded_rect(fi, gw, gh, gr); + }, + []() {}); +} + +// Rebuild the rounded-rect's 8 entities in place for a new width/height/fillet radius, +// keeping the min corner (c0) fixed. Geometric (entity order/count preserved so constraint +// refs stay valid); fillet clamped to (0, min(w,h)/2]. +void DesignSketchTool::set_rounded_rect(int fi, double w, double h, double r) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.end > int(m_entities.size()) || f.end <= f.begin) return; + w = std::max(w, 1e-3); h = std::max(h, 1e-3); + r = std::max(1e-3, std::min(r, std::min(w, h) * 0.5 - 1e-4)); + const double xmin = std::min(f.c0.x(), f.c1.x()), ymin = std::min(f.c0.y(), f.c1.y()); + const double xmax = xmin + w, ymax = ymin + h; + std::vector rebuilt = rounded_rect_entities(xmin, ymin, xmax, ymax, r); + if (int(rebuilt.size()) != f.end - f.begin) return; // count must match to keep con refs + for (int i = 0; i < int(rebuilt.size()); ++i) { + rebuilt[i].construction = m_entities[f.begin + i].construction; // preserve flag + m_entities[f.begin + i] = rebuilt[i]; + } + f.c0 = Vec2d(xmin, ymin); f.c1 = Vec2d(xmax, ymax); f.param = r; + resolve_live(); +} + +void DesignSketchTool::open_arc_slot_editor(int fi, bool radius) +{ + if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; + const Feature& f = m_features[fi]; + const double Rc = (f.c1 - f.c0).norm(); + const double v = radius ? Rc : (2.0 * f.param); // width quote shows the FULL width + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, v, radius ? "Radius" : "Width", + [this, fi, radius](double nv) { + const Feature& g = m_features[fi]; + const double gRc = (g.c1 - g.c0).norm(); + if (radius) set_arc_slot(fi, nv, g.param); + else set_arc_slot(fi, gRc, std::max(1e-3, nv * 0.5)); // full width -> half + }, + []() {}); +} + +// Rebuild the arc-slot's 4 arcs in place for a new centreline radius / half-width. Centre +// + the two centreline directions are kept (the end direction is recovered from the cap@E +// arc centre). Geometric; entity count preserved so constraint refs stay valid. +void DesignSketchTool::set_arc_slot(int fi, double Rc, double w) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.end > int(m_entities.size()) || f.end - f.begin != 4) return; + const Vec2d center = f.c0; + Vec2d dirS = f.c1 - center; + const Vec2d Ec = m_entities[f.begin + 1].center; // cap@E centre = centreline end + Vec2d dirE = Ec - center; + if (dirS.squaredNorm() < 1e-12 || dirE.squaredNorm() < 1e-12) return; + dirS.normalize(); dirE.normalize(); + Rc = std::max(Rc, 2e-3); + w = std::max(1e-3, std::min(w, Rc - 1e-3)); // make_arc_slot needs w < Rc + std::vector rebuilt = + make_arc_slot(center, center + Rc * dirS, center + Rc * dirE, w); + if (int(rebuilt.size()) != 4) return; + for (int i = 0; i < 4; ++i) { + rebuilt[i].construction = m_entities[f.begin + i].construction; + m_entities[f.begin + i] = rebuilt[i]; + } + f.c1 = center + Rc * dirS; f.param = w; + resolve_live(); +} + +// Open the inline editor for a straight slot's dimension: which 0 = inter-centre distance, +// 1 = radius (half-width), 2 = centreline angle. Drives set_slot / set_slot_angle geometrically. +void DesignSketchTool::open_slot_editor(int fi, int which) +{ + if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; + const Feature& f = m_features[fi]; + const Vec2d d = f.c1 - f.c0; + double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; + const double v = (which == 0) ? d.norm() : (which == 1) ? f.param : deg; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, v, which == 0 ? "Length" : which == 1 ? "Radius" : "Angle", + [this, fi, which](double nv) { + const Feature& g = m_features[fi]; + if (which == 0) set_slot(fi, nv, g.param); + else if (which == 1) set_slot(fi, (g.c1 - g.c0).norm(), std::max(1e-3, nv)); + else set_slot_angle(fi, nv); + }, + []() {}); +} + +// Rebuild the straight slot's 4 entities in place for a new centreline length / half-width. +// Centre c0 and the centreline direction are kept; only c1 (length) or param (width) change. +// Geometric; entity count preserved so constraint refs stay valid. +void DesignSketchTool::set_slot(int fi, double length, double w) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.end > int(m_entities.size()) || f.end - f.begin != 4) return; + Vec2d dir = f.c1 - f.c0; + if (dir.squaredNorm() < 1e-12) return; + dir.normalize(); + length = std::max(length, 2e-3); + w = std::max(1e-3, w); + const Vec2d c0 = f.c0, c1 = f.c0 + length * dir; + std::vector rebuilt = make_slot(c0, c1, w); + if (int(rebuilt.size()) != 4) return; + for (int i = 0; i < 4; ++i) { + rebuilt[i].construction = m_entities[f.begin + i].construction; + m_entities[f.begin + i] = rebuilt[i]; + } + f.c1 = c1; f.param = w; + resolve_live(); +} + +// Rotate a straight slot about c0 to a new centreline angle (degrees), keeping length + radius. +void DesignSketchTool::set_slot_angle(int fi, double deg) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.begin < 0 || f.end > int(m_entities.size()) || f.end - f.begin != 4) return; + const double L = (f.c1 - f.c0).norm(); + if (L < 1e-9) return; + const double a = deg * M_PI / 180.0; + const Vec2d c1 = f.c0 + L * Vec2d(std::cos(a), std::sin(a)); + std::vector rebuilt = make_slot(f.c0, c1, f.param); + if (int(rebuilt.size()) != 4) return; + for (int i = 0; i < 4; ++i) { + rebuilt[i].construction = m_entities[f.begin + i].construction; + m_entities[f.begin + i] = rebuilt[i]; + } + f.c1 = c1; + resolve_live(); +} + +// Resize an axis-aligned rectangle by dragging a corner: the diagonally-opposite corner +// (captured at grab as m_drag_rect_anchor) stays fixed; the box becomes [anchor, cursor]. +// Geometric rebuild in place (4 lines, same order) — edges stay axis-aligned so the +// inferred H/V + corner-coincident constraints remain satisfied (no re-solve needed). +void DesignSketchTool::drag_rect_corner(int fi, const Vec2d& cursor) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.end - f.begin != 4) return; + const Vec2d A = m_drag_rect_anchor, B = cursor; + if (std::abs(B.x() - A.x()) < 1e-4 || std::abs(B.y() - A.y()) < 1e-4) return; // degenerate + const Vec2d corners[4] = { A, Vec2d(B.x(), A.y()), B, Vec2d(A.x(), B.y()) }; + for (int i = 0; i < 4; ++i) { + SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = corners[i]; e.p1 = corners[(i + 1) % 4]; + e.construction = m_entities[f.begin + i].construction; + m_entities[f.begin + i] = e; + } + f.c0 = A; f.c1 = B; +} + +// Move one end of a slot by dragging its cap centre (which cap captured at grab); the other +// centre + half-width are kept. Rebuilds the 4-entity span via make_slot. Geometric. +void DesignSketchTool::drag_slot_handle(int fi, const Vec2d& cursor) +{ + if (fi < 0 || fi >= int(m_features.size())) return; + Feature& f = m_features[fi]; + if (f.end - f.begin != 4) return; + const Vec2d c0 = m_drag_slot_c1 ? f.c0 : cursor; + const Vec2d c1 = m_drag_slot_c1 ? cursor : f.c1; + std::vector rebuilt = make_slot(c0, c1, f.param); + if (int(rebuilt.size()) != 4) return; + for (int i = 0; i < 4; ++i) { + rebuilt[i].construction = m_entities[f.begin + i].construction; + m_entities[f.begin + i] = rebuilt[i]; + } + f.c0 = c0; f.c1 = c1; +} + +DesignSketchTool::DimType DesignSketchTool::pending_dimension_type() const +{ + return (m_pending_dim >= 0 && m_pending_dim < int(m_dimensions.size())) + ? m_dimensions[m_pending_dim].kind : DimType::None; +} + +void DesignSketchTool::set_dimension_value(double v) +{ + if (m_pending_dim < 0 || m_pending_dim >= int(m_dimensions.size())) return; + DimAnnot& a = m_dimensions[m_pending_dim]; + a.value = v; + if (a.con >= 0 && a.con < int(m_constraints.size())) + m_constraints[a.con] = constraint_for(a); + resolve_live(); + m_pending_dim = -1; +} + +void DesignSketchTool::cancel_dimension_value() +{ + m_pending_dim = -1; // keep the placed dimension at its measured value +} + +std::string DesignSketchTool::dim_text(const DimAnnot& a) const +{ + char buf[32]; + const char* prefix = (a.kind == DimType::Diameter) ? "\xC3\x98" // 'Ø' + : (a.kind == DimType::Radius) ? "R" : ""; + const char* suffix = (a.kind == DimType::Angle) ? "\xC2\xB0" : ""; // '°' + std::snprintf(buf, sizeof(buf), "%s%.1f%s", prefix, a.value, suffix); + // Force the international (en) decimal point: wx sets LC_NUMERIC to the user + // locale at startup, so snprintf("%.1f") can emit a comma. Normalise it. + for (char& ch : buf) + if (ch == ',') ch = '.'; + std::string out(buf); + if (a.kind != DimType::Angle) { + const bool use_in = wxGetApp().app_config->get_bool("use_inches"); + out += use_in ? " in" : " mm"; + } + return out; +} + +void DesignSketchTool::apply_segment_length(double len) +{ + if (m_points.size() == 2 && len > 1e-9) { + const Vec2d d = m_points[1] - m_points[0]; + const double r = d.norm(); + if (r > 1e-9) + m_points[1] = m_points[0] + (len / r) * d; + } + keep_segment_as_drawn(); + // Driving length constraint on the just-committed Line entity. + if (len > 1e-9 && !m_entities.empty()) { + const int i = int(m_entities.size()) - 1; + if (m_entities[i].type == SketchEntity::Type::Line) { + SketchEntityConstraintDef c; + c.type = SketchConstraintType::Distance; + c.ea = i; c.ra = SketchPointRole::P0; + c.eb = i; c.rb = SketchPointRole::P1; + c.value = len; + m_constraints.push_back(c); + } + } + resolve_live(); // live-solve the in-session sketch +} + +void DesignSketchTool::keep_segment_as_drawn() +{ + if (m_points.size() == 2) { + const int base = int(m_entities.size()); + push_line(m_points[0], m_points[1]); // accrues into the session's entities + infer_auto_constraints(base); // auto Coincident at snapped ends + H/V + } + m_points.clear(); + m_has_cursor = false; + m_awaiting_length = false; +} + +void DesignSketchTool::finish() +{ + // A value field still open at commit time outlives the session — the editor is a top-level + // frame — and inline_busy stays set, so every later click is swallowed at on_mouse_impl's + // first branch and the viewport reads as dead. Dismiss it first (keep-as-drawn, the same + // contract as the polyline terminators) and drop any queued field with it. + close_session_chrome(); + if (op_ready()) confirm_op(); // apply a pending edit-op gizmo before committing + if (tf_ready()) confirm_transform(); // apply a pending transform gizmo before committing + auto cb = on_commit_entities; + std::vector ents = m_entities; + std::vector cons = m_constraints; + SketchPlane pl = m_plane; + m_active = false; + m_points.clear(); + m_entities.clear(); + m_constraints.clear(); + m_dimensions.clear(); + m_point_sel.clear(); + m_dim_has0 = false; + m_pending_dim = -1; + m_has_cursor = false; + m_features.clear(); + m_open_feature = -1; + if (cb) + cb(ents, cons, pl); +} + +void DesignSketchTool::begin_constrain(const SketchProfile& prof, const SketchPlane& plane) +{ + push_auto_close_pref(); + m_plane = plane; + m_mode = Mode::Constrain; + m_points = prof.points; + m_entities.clear(); + m_has_cursor = false; + m_sel_a = m_sel_b = -1; + m_constrain_entities = false; + m_pick0 = m_pick1 = m_pick2 = -1; + m_active = true; +} + +void DesignSketchTool::begin_constrain_entities(const std::vector& ents, + const SketchPlane& plane) +{ + push_auto_close_pref(); + m_plane = plane; + m_mode = Mode::Constrain; + m_constrain_entities = true; + m_points.clear(); + m_entities = ents; + m_has_cursor = false; + m_sel_a = m_sel_b = -1; + m_pick0 = m_pick1 = m_pick2 = -1; + m_active = true; +} + +bool DesignSketchTool::selected_segment(int& a, int& b) const +{ + if (m_sel_a < 0 || m_sel_b < 0) + return false; + a = m_sel_a; + b = m_sel_b; + return true; +} + +bool DesignSketchTool::screen_to_plane(GLCanvas3D& canvas, const wxMouseEvent& evt, Vec2d& out) const +{ + Point pos(evt.GetX(), evt.GetY()); + Linef3 r = canvas.mouse_ray(pos); + out = m_plane.project(r.a, r.vector()); + return true; +} + +// Closing the loop is THE outcome this tab exists for — a sketch that is not closed cannot be +// extruded — so it must be as easy and as visible as any other snap, and it must behave the +// same at every zoom. This used to be a bare `squaredNorm() < 4.0`, i.e. a fixed 2 mm bubble in +// plane units: a pixel hunt zoomed out, an over-eager magnet zoomed in, and invisible either +// way — nothing told the user their next click would close the loop rather than place another +// vertex. Now the chain's first point is a real snap target on the same 8 px screen tolerance +// as every endpoint snap: hovering it moves the cursor exactly onto it, which makes the rubber +// band draw the closing segment as a preview and lights the existing snap marker. The click +// then closes only because it was SNAPPED, never because it was merely nearby. +bool DesignSketchTool::snap_chain_start(GLCanvas3D& canvas, const wxMouseEvent& evt, Vec2d& p) const +{ + if (m_mode != Mode::Polyline || m_points.size() < 3) return false; + if ((p - m_points[0]).norm() > screen_tol(canvas, evt, p)) return false; + p = m_points[0]; + InferenceSnap s; + s.kind = InferenceSnap::Kind::Endpoint; // renders the same hint as any endpoint snap + s.point = m_points[0]; + const_cast(this)->m_cursor_snap = s; + return true; +} + +Vec2d DesignSketchTool::snap_dir(const Vec2d& anchor, const Vec2d& raw, bool& locked) const +{ + locked = false; + if (m_snap_off) return raw; + + const Vec2d d = raw - anchor; + const double r = d.norm(); + if (r < 1e-9) return raw; + + const double tol_deg = 5.0; // inference half-window + double ang = std::atan2(d.y(), d.x()) * 180.0 / M_PI; // (-180,180] + if (ang < 0.0) ang += 360.0; // [0,360) + + // Base angles within one quadrant, replicated every 90 deg up to 360. + static const double base[] = {0.0, 30.0, 45.0, 60.0}; + double best_cand = ang, best_diff = 1e30; + for (int q = 0; q < 4; ++q) { + for (double b : base) { + const double cand = b + 90.0 * q; + double diff = std::abs(ang - cand); + if (diff > 180.0) diff = 360.0 - diff; + if (diff < best_diff) { best_diff = diff; best_cand = cand; } + } + } + if (best_diff > tol_deg) return raw; + + locked = true; + const double rad = best_cand * M_PI / 180.0; + return anchor + r * Vec2d(std::cos(rad), std::sin(rad)); +} + +double DesignSketchTool::screen_tol(GLCanvas3D& canvas, const wxMouseEvent& evt, + const Vec2d& at, double px) const +{ + // Project a point `px` screen pixels away and measure the gap in plane units. + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + int(px), evt.GetY())); + const Vec2d p2 = m_plane.project(r2.a, r2.vector()); + return std::max(1e-3, (p2 - at).norm()); +} + +InferenceSnap DesignSketchTool::infer_at(GLCanvas3D& canvas, const wxMouseEvent& evt, + const Vec2d& raw) const +{ + if (evt.ShiftDown()) { InferenceSnap s; s.point = raw; return s; } // Shift suppresses + const double tol = screen_tol(canvas, evt, raw); + return infer_point_snap(m_entities, raw, tol); +} + +Vec2d DesignSketchTool::snap_vertex(GLCanvas3D& canvas, const wxMouseEvent& evt, + const Vec2d& raw, bool& snapped) const +{ + const InferenceSnap s = infer_at(canvas, evt, raw); + // Cache the target so render() can draw a snap hint; only endpoint/centre/origin + // count as a "vertex" snap for the callers that gate angle inference on it. + const_cast(this)->m_cursor_snap = s; + snapped = s.snapped(); // any hard snap moves the cursor + suppresses angle lock + return s.point; +} + +bool DesignSketchTool::has_coincident(int ea, SketchPointRole ra, int eb, SketchPointRole rb) const +{ + for (const auto& c : m_constraints) { + if (c.type != SketchConstraintType::Coincident) continue; + if ((c.ea == ea && c.ra == ra && c.eb == eb && c.rb == rb) || + (c.ea == eb && c.ra == rb && c.eb == ea && c.rb == ra)) + return true; + } + return false; +} + +bool DesignSketchTool::try_add_constraints(const std::vector& cands) +{ + if (cands.empty()) return true; + const size_t mark = m_constraints.size(); + for (const auto& c : cands) m_constraints.push_back(c); + if (solve_sketch_entities(m_entities, m_constraints)) { + if (on_constraints_changed) on_constraints_changed(); + return true; + } + m_constraints.resize(mark); // roll back the conflicting batch + // No re-solve to "restore": a failed solve no longer touches the geometry + // (SketchSolver.cpp only writes back on success), so m_entities still holds the + // prior solved state exactly. pl5. + return false; +} + +// Delete the constraint whose badge is under `p`. Removing a constraint can only FREE degrees +// of freedom, so the re-solve cannot fail for over-constraint -- but it is still run, because +// the geometry must relax back to what the remaining system allows. +bool DesignSketchTool::remove_constraint_near(const Vec2d& p) +{ + if (m_glyph_hits.empty() || m_glyph_r <= 0.0) return false; + int best = -1; + double best_d = m_glyph_r; + for (const GlyphHit& g : m_glyph_hits) { + const double d = (g.c - p).norm(); + if (d < best_d && g.con >= 0 && g.con < int(m_constraints.size())) { best_d = d; best = g.con; } + } + if (best < 0) return false; + return remove_constraint(best); +} + +bool DesignSketchTool::remove_constraint(int idx) +{ + if (idx < 0 || idx >= int(m_constraints.size())) return false; + m_constraints.erase(m_constraints.begin() + idx); + // resolve_live(), not a bare solve: it is the path that recomputes the DoF, clears the + // per-entity conflict flags and fires on_solve_state. Solving directly would relax the + // geometry while leaving the DoF readout and any red over-constrained tint stale — the + // readout would still describe the constraint that was just deleted. + resolve_live(); + m_glyph_hits.clear(); // stale until the next render rebuilds them + if (on_constraints_changed) on_constraints_changed(); + return true; +} + +void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad, double weld_tol) +{ + const int n = int(m_entities.size()); + if (base < 0 || base >= n) return; + + // Endpoint roles an entity exposes for coincidence matching. + auto roles_of = [](const SketchEntity& e, SketchPointRole out[2]) -> int { + switch (e.type) { + case SketchEntity::Type::Line: out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + case SketchEntity::Type::Arc: out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + // EllipseArc was missing here while the otherwise identical roles_of in + // heal_coincidences (below) has it, so an ellipse arc's endpoints could be WELDED by the + // healer but never auto-inferred coincident at draw time -- the same gesture behaved + // differently depending on which path ran. Two copies of one rule is how that happens. + case SketchEntity::Type::EllipseArc: + out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + case SketchEntity::Type::BSpline:out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + case SketchEntity::Type::Point: out[0] = SketchPointRole::P0; return 1; + default: return 0; // circle: centre coincidence handled by Concentric, not here + } + }; + + // 1) Coincident between a new endpoint and any (co-located) endpoint of another + // entity. snap_vertex already drove the coordinates together; this records it + // so a re-solve keeps the loop closed. + std::vector coincs; + for (int i = base; i < n; ++i) { + SketchPointRole ir[2]; const int ni = roles_of(m_entities[i], ir); + for (int a = 0; a < ni; ++a) { + Vec2d pa; if (!point_at(i, ir[a], pa)) continue; + for (int j = 0; j < n; ++j) { + if (j == i) continue; + SketchPointRole jr[2]; const int nj = roles_of(m_entities[j], jr); + for (int b = 0; b < nj; ++b) { + if (j >= base && j < i) continue; // avoid duplicate (i,j)/(j,i) + Vec2d pb; if (!point_at(j, jr[b], pb)) continue; + if ((pa - pb).squaredNorm() > weld_tol * weld_tol) continue; + if (has_coincident(i, ir[a], j, jr[b])) continue; + SketchEntityConstraintDef c; + c.type = SketchConstraintType::Coincident; + c.ea = i; c.ra = ir[a]; c.eb = j; c.rb = jr[b]; + coincs.push_back(c); + } + } + } + } + try_add_constraints(coincs); // co-located points: consistent by construction + + // 2) Horizontal / Vertical on axis-aligned new line segments. Tried as ONE batch first and + // only then one at a time, which is the same outcome — a single conflict never drops the + // others — for one solve instead of n. That matters now that large sketches actually + // solve: a bulk add of 1200 axis-aligned segments used to be fast only because every + // solve failed instantly on the unknown limit, and once they started succeeding the + // per-constraint loop turned into 1200 solves and blew the MCP main-thread budget. + std::vector axes; + for (int i = base; i < n; ++i) { + if (m_entities[i].type != SketchEntity::Type::Line) continue; + auto ax = infer_axis_constraint(m_entities[i].p0, m_entities[i].p1, ang_tol_rad); + if (!ax) continue; + SketchEntityConstraintDef c; + c.type = *ax; + c.ea = i; c.ra = SketchPointRole::P0; + c.eb = i; c.rb = SketchPointRole::P1; + axes.push_back(c); + } + if (!try_add_constraints(axes)) + for (const auto& c : axes) try_add_constraints({ c }); + + // 3) Relational constraints on the new entities (parallel/perpendicular on connected + // lines, equal radius, tangent). Same batch-then-one-at-a-time fallback as above: + // try_add_constraints rolls a conflicting batch back, so a single rejected relation + // never costs the others. Every rule only pins a relation that is ALREADY true, so + // nothing the user drew is moved by this. + // A SCRIPTED ADD IS NOT A DRAWN GESTURE — the rule this function already states at its + // bulk call site, which passes zero tolerances for exactly that reason (8xg1). + // Relational inference must obey it too, and for a second reason beyond tolerance: + // EqualRadius couples entities that are geometrically far apart, so on a real drawing it + // merges independent connected components into one huge system and defeats the + // component partitioning that makes large sketches solvable at all (yww4). + // Measured 2026-08-31 on the corpus rung: geometry stayed correct (32/32 sheets clean) + // but seven of the largest sheets hit main-thread timeout — MPD681 among them, the very + // sheet named in the comment at the bulk call site. Exact-equality would not save it + // either: patterned holes in real drawings ARE exactly equal. + // So: relations only for batches the size of a human gesture. A polyline segment is 1, a + // rectangle 4, a polygon a dozen; a scripted or imported add is hundreds. + constexpr int kRelInferMaxBatch = 16; + std::vector rels; + if (n - base <= kRelInferMaxBatch) { + const double rel_len_tol = ang_tol_rad > 0.0 ? 0.01 : 0.0; // exact-only when the caller asked for exact + for (int i = base; i < n; ++i) { + auto r = infer_relations(m_entities, i, ang_tol_rad, rel_len_tol); + rels.insert(rels.end(), r.begin(), r.end()); + } + } + // NO one-at-a-time fallback here, unlike the two batches above. Relations are a + // convenience: nothing is incorrect without them. The fallback costs one SOLVE PER + // CONSTRAINT, which is what the warning on the axes batch is about, and paying it for + // optional constraints is how a bulk add pins the app at 100% CPU with the MCP socket + // unresponsive (measured 2026-08-31). If the batch conflicts, drop the batch. + if (!rels.empty()) try_add_constraints(rels); + + resolve_live(); +} + +static std::vector circle_polygon(const Vec2d& c, double r, int n = 48) +{ + std::vector v; v.reserve(n); + for (int i = 0; i < n; ++i) { + const double a = 2.0 * M_PI * double(i) / double(n); + v.push_back(Vec2d(c.x() + r * std::cos(a), c.y() + r * std::sin(a))); + } + return v; +} + +// Point on an ellipse at parametric angle theta: center + R(phi)*(a cos t, b sin t). +static Vec2d ellipse_point(const Vec2d& c, double a, double b, double phi, double t) +{ + const double cu = std::cos(phi), su = std::sin(phi); + const double x = a * std::cos(t), y = b * std::sin(t); + return Vec2d(c.x() + x * cu - y * su, c.y() + x * su + y * cu); +} + +// Tessellate an ellipse arc parametric range [t0,t1] into a polyline. +static std::vector ellipse_polyline(const Vec2d& c, double a, double b, double phi, + double t0, double t1, int n = 48) +{ + std::vector v; v.reserve(n + 1); + for (int i = 0; i <= n; ++i) + v.push_back(ellipse_point(c, a, b, phi, t0 + (t1 - t0) * double(i) / double(n))); + return v; +} + +// Clamped uniform B-spline (degree min(3, n-1)) through control poles. The knot +// construction mirrors SketchEngine::entities_to_wire's OCCT Geom_BSplineCurve so +// the previewed/extruded curve match. de Boor evaluation. +static int bspline_degree(int n) { return n >= 4 ? 3 : (n >= 2 ? n - 1 : 0); } + +static std::vector bspline_knots(int n, int p) +{ + std::vector U; // full knot vector, length n+p+1 + const int interior = n - p - 1; + for (int i = 0; i <= p; ++i) U.push_back(0.0); + for (int i = 1; i <= interior; ++i) U.push_back(double(i)); + const double last = double(interior + 1); + for (int i = 0; i <= p; ++i) U.push_back(last); + return U; +} + +static Vec2d bspline_eval(const std::vector& P, const std::vector& U, int p, double u) +{ + const int n = int(P.size()); + if (u <= U[p]) return P.front(); + if (u >= U[n]) return P.back(); // U[n] == domain max (clamped) + int k = p; + while (k < n - 1 && U[k + 1] <= u) ++k; // span: U[k] <= u < U[k+1] + std::vector d(p + 1); + for (int j = 0; j <= p; ++j) d[j] = P[j + k - p]; + for (int r = 1; r <= p; ++r) + for (int j = p; j >= r; --j) { + const double denom = U[j + 1 + k - r] - U[j + k - p]; + const double a = denom > 1e-12 ? (u - U[j + k - p]) / denom : 0.0; + d[j] = (1.0 - a) * d[j - 1] + a * d[j]; + } + return d[p]; +} + +static std::vector bspline_polyline(const std::vector& ctrl, int samples = 0) +{ + const int n = int(ctrl.size()); + if (n < 2) return ctrl; + const int p = bspline_degree(n); + const std::vector U = bspline_knots(n, p); + const double umax = double(n - p); + if (samples <= 0) samples = std::max(24, 14 * (n - 1)); + std::vector out; out.reserve(samples + 1); + for (int i = 0; i <= samples; ++i) + out.push_back(bspline_eval(ctrl, U, p, umax * double(i) / double(samples))); + return out; +} + +// ---- entity builders -------------------------------------------------------- + +void DesignSketchTool::push_line(const Vec2d& a, const Vec2d& b) +{ + if ((b - a).squaredNorm() < 1e-9) + return; + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = a; + e.p1 = b; + e.construction = m_construction; + m_entities.push_back(e); +} + +bool DesignSketchTool::add_imported_regions( + const std::vector>>& regions) +{ + if (!m_active) return false; // no session to draw into; caller makes a feature + const bool saved = m_construction; + m_construction = false; // art is real geometry, never construction lines + size_t before = m_entities.size(); + for (const auto& region : regions) + for (const auto& loop : region) + push_closed_lines(loop); // every glyph contour, holes included + m_construction = saved; + if (m_entities.size() == before) return false; // nothing importable — say so, do not lie + // Art is not "just drawn", so it must NOT enter the draw-then-edit queue. Without this the + // glyph contours are treated as fresh entities and a Length field opens on the first of + // them — on a word, that is one value editor per segment, and an open field freezes the + // canvas (yce). reset_autoedit() marks every entity as already seen. + reset_autoedit(); + // The new lines carry no constraints, so the solver has nothing to move; resolve anyway so + // the degrees-of-freedom readout counts them instead of going stale. + resolve_live(); + return true; +} + +void DesignSketchTool::push_closed_lines(const std::vector& corners) +{ + const size_t n = corners.size(); + if (n < 2) return; + for (size_t i = 0; i < n; ++i) + push_line(corners[i], corners[(i + 1) % n]); +} + +void DesignSketchTool::push_open_chain(const std::vector& pts) +{ + for (size_t i = 0; i + 1 < pts.size(); ++i) + push_line(pts[i], pts[i + 1]); +} + +void DesignSketchTool::push_circle(const Vec2d& center, double radius) +{ + if (radius < 1e-3) return; + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = center; + e.p0 = center; + e.radius = radius; + e.construction = m_construction; + m_entities.push_back(e); +} + +void DesignSketchTool::push_point(const Vec2d& p) +{ + SketchEntity e; + e.type = SketchEntity::Type::Point; + e.p0 = p; + e.center = p; + e.construction = m_construction; + m_entities.push_back(e); +} + +void DesignSketchTool::append_entities(const std::vector& ents) +{ + for (const SketchEntity& e : ents) + m_entities.push_back(e); +} + +static double wrap_2pi(double a) +{ + while (a < 0.0) a += 2.0 * M_PI; + while (a >= 2.0 * M_PI) a -= 2.0 * M_PI; + return a; +} + +// Circumcircle of 3 points. Returns false if (nearly) collinear. +static bool circumcircle(const Vec2d& a, const Vec2d& b, const Vec2d& c, + Vec2d& center, double& radius) +{ + const double d = 2.0 * (a.x() * (b.y() - c.y()) + + b.x() * (c.y() - a.y()) + + c.x() * (a.y() - b.y())); + if (std::abs(d) < 1e-9) + return false; + const double a2 = a.squaredNorm(), b2 = b.squaredNorm(), c2 = c.squaredNorm(); + const double ux = (a2 * (b.y() - c.y()) + b2 * (c.y() - a.y()) + c2 * (a.y() - b.y())) / d; + const double uy = (a2 * (c.x() - b.x()) + b2 * (a.x() - c.x()) + c2 * (b.x() - a.x())) / d; + center = Vec2d(ux, uy); + radius = (a - center).norm(); + return true; +} + +// Build an Arc entity that sweeps start -> end passing through `through`. The +// kernel reconstructs the mid from (start_angle+end_angle)/2, so the angle pair +// must bracket `through` on the correct side of the circle. +static SketchEntity make_arc_through(const Vec2d& center, double radius, + const Vec2d& start, const Vec2d& end, + const Vec2d& through, bool construction) +{ + const double a_start = std::atan2(start.y() - center.y(), start.x() - center.x()); + const double a_end = std::atan2(end.y() - center.y(), end.x() - center.x()); + const double a_thru = std::atan2(through.y() - center.y(), through.x() - center.x()); + const double de = wrap_2pi(a_end - a_start); // CCW sweep to end (0,2π) + const double d3 = wrap_2pi(a_thru - a_start); // CCW position of through + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = center; + e.radius = radius; + e.p0 = start; + e.p1 = end; + e.start_angle = a_start; + e.end_angle = (d3 <= de) ? (a_start + de) : (a_start + de - 2.0 * M_PI); + e.construction = construction; + return e; +} + +std::vector DesignSketchTool::make_three_point_circle(const Vec2d& a, const Vec2d& b, const Vec2d& c) const +{ + Vec2d center; double radius; + if (!circumcircle(a, b, c, center, radius)) + return {}; + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = center; + e.p0 = center; + e.radius = radius; + e.construction = m_construction; + return { e }; +} + +std::vector DesignSketchTool::make_three_point_arc(const Vec2d& start, const Vec2d& end, const Vec2d& on_arc) const +{ + Vec2d center; double radius; + if (!circumcircle(start, end, on_arc, center, radius)) + return {}; + return { make_arc_through(center, radius, start, end, on_arc, m_construction) }; +} + +std::vector DesignSketchTool::make_tangent_arc(const Vec2d& start, const Vec2d& end) const +{ + // Tangent direction at `start` = exit direction of the previous entity. + Vec2d t(1, 0); + bool have_t = false; + if (!m_entities.empty()) { + const SketchEntity& prev = m_entities.back(); + if (prev.type == SketchEntity::Type::Line) { + t = prev.p1 - prev.p0; have_t = (t.squaredNorm() > 1e-12); + } else if (prev.type == SketchEntity::Type::Arc) { + // Tangent at the arc end p1 is perpendicular to its radius, in the + // sweep direction. + const Vec2d r = prev.p1 - prev.center; + const double sweep = prev.end_angle - prev.start_angle; + t = (sweep >= 0.0) ? Vec2d(-r.y(), r.x()) : Vec2d(r.y(), -r.x()); + have_t = (r.squaredNorm() > 1e-12); + } + } + const Vec2d se = end - start; + if (!have_t || se.squaredNorm() < 1e-12) { + // No tangent reference or zero length: fall back to a straight line. + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = start; e.p1 = end; + e.construction = m_construction; + return { e }; + } + t.normalize(); + const Vec2d n(-t.y(), t.x()); // unit normal to the tangent + const double denom = 2.0 * n.dot(se); + if (std::abs(denom) < 1e-9) { // end lies along the tangent: line + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = start; e.p1 = end; + e.construction = m_construction; + return { e }; + } + const double R = se.squaredNorm() / denom; // signed radius along n + const Vec2d center = start + n * R; + const double radius = std::abs(R); + // Mid of the tangent arc: project the chord midpoint outward onto the circle. + const Vec2d chord_mid = (start + end) * 0.5; + Vec2d to_mid = chord_mid - center; + if (to_mid.squaredNorm() < 1e-12) to_mid = n; + to_mid.normalize(); + const Vec2d through = center + to_mid * radius; + return { make_arc_through(center, radius, start, end, through, m_construction) }; +} + +std::vector DesignSketchTool::make_center_arc(const Vec2d& center, const Vec2d& start, const Vec2d& end_dir) const +{ + const double radius = (start - center).norm(); + if (radius < 1e-9) + return {}; + const double a_start = std::atan2(start.y() - center.y(), start.x() - center.x()); + const double a_end = std::atan2(end_dir.y() - center.y(), end_dir.x() - center.x()); + const double de = wrap_2pi(a_end - a_start); // CCW sweep start -> end (0,2π) + const Vec2d end = center + radius * Vec2d(std::cos(a_end), std::sin(a_end)); + const double a_mid = a_start + de * 0.5; // bisector brackets the sweep + const Vec2d through = center + radius * Vec2d(std::cos(a_mid), std::sin(a_mid)); + return { make_arc_through(center, radius, start, end, through, m_construction) }; +} + +std::vector DesignSketchTool::make_slot(const Vec2d& c0, const Vec2d& c1, double half_width) const +{ + std::vector out; + Vec2d u = c1 - c0; + if (u.squaredNorm() < 1e-12 || half_width < 1e-6) + return out; + u.normalize(); + const Vec2d nrm(-u.y(), u.x()); + const double w = half_width; + // Names avoid termios macros (B0 is a baud-rate #define pulled in transitively). + const Vec2d top0 = c0 + nrm * w, top1 = c1 + nrm * w; // upper side (c0 -> c1) + const Vec2d bot1 = c1 - nrm * w, bot0 = c0 - nrm * w; // lower side (c1 -> c0) + + auto line = [&](const Vec2d& p0, const Vec2d& p1) { + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = p0; e.p1 = p1; + e.construction = m_construction; return e; + }; + out.push_back(line(top0, top1)); // top + out.push_back(make_arc_through(c1, w, top1, bot1, c1 + u * w, m_construction)); // cap @c1 (+u) + out.push_back(line(bot1, bot0)); // bottom + out.push_back(make_arc_through(c0, w, bot0, top0, c0 - u * w, m_construction)); // cap @c0 (-u) + return out; +} + +// Arc slot: a slot whose centerline is a circular arc (center, start, end_dir on +// the same radius). Bounded by an outer arc (Rc+w), an inner arc (Rc-w) and two +// semicircular end caps. CCW closed loop, mirroring make_slot's structure. +std::vector DesignSketchTool::make_arc_slot(const Vec2d& center, const Vec2d& start, + const Vec2d& end_dir, double half_width) const +{ + std::vector out; + const double Rc = (start - center).norm(); + const double w = half_width; + if (Rc < 1e-6 || w < 1e-6 || w >= Rc) return out; + const Vec2d dirS = (start - center) / Rc; + Vec2d de = end_dir - center; + if (de.squaredNorm() < 1e-12) return out; + const Vec2d dirE = de.normalized(); + const double aS = std::atan2(dirS.y(), dirS.x()); + const double aE = std::atan2(dirE.y(), dirE.x()); + const double sweep = wrap_2pi(aE - aS); // CCW start -> end + const double aMid = aS + sweep * 0.5; + const Vec2d uMid(std::cos(aMid), std::sin(aMid)); + const Vec2d Sc = center + Rc * dirS; // centerline start point (cap centre) + const Vec2d Ec = center + Rc * dirE; // centerline end point (cap centre) + const Vec2d S_out = center + (Rc + w) * dirS, S_in = center + (Rc - w) * dirS; + const Vec2d E_out = center + (Rc + w) * dirE, E_in = center + (Rc - w) * dirE; + const Vec2d tE(-dirE.y(), dirE.x()); // CCW travel-forward tangent at E + const Vec2d tS(-dirS.y(), dirS.x()); // CCW travel-forward tangent at S + out.push_back(make_arc_through(center, Rc + w, S_out, E_out, center + (Rc + w) * uMid, m_construction)); // outer + out.push_back(make_arc_through(Ec, w, E_out, E_in, Ec + w * tE, m_construction)); // cap @E (forward) + out.push_back(make_arc_through(center, Rc - w, E_in, S_in, center + (Rc - w) * uMid, m_construction)); // inner + out.push_back(make_arc_through(Sc, w, S_in, S_out, Sc - w * tS, m_construction)); // cap @S (backward) + return out; +} + +// Rounded rectangle: axis-aligned box (a,b opposite corners) with filleted +// corners. radius_pt's distance to the nearest corner sets the fillet radius. +// 4 straight edges + 4 quarter arcs, CCW. +// Build the 8 entities (4 lines + 4 corner arcs, CCW) of an axis-aligned rounded box +// from explicit bounds + fillet radius. Shared by make_rounded_rect (gesture) and +// set_rounded_rect (label/handle edit) so the entity order/count is identical → a rebuild +// in place keeps constraint indices into the feature span valid. +std::vector DesignSketchTool::rounded_rect_entities(double xmin, double ymin, + double xmax, double ymax, double r) const +{ + std::vector out; + auto line = [&](const Vec2d& p0, const Vec2d& p1) { + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = p0; e.p1 = p1; + e.construction = m_construction; return e; }; + auto corner = [&](const Vec2d& O, const Vec2d& sharp, const Vec2d& start, const Vec2d& end) { + const Vec2d thr = O + r * (sharp - O).normalized(); + return make_arc_through(O, r, start, end, thr, m_construction); }; + out.push_back(line({xmin + r, ymin}, {xmax - r, ymin})); // bottom + out.push_back(corner({xmax - r, ymin + r}, {xmax, ymin}, {xmax - r, ymin}, {xmax, ymin + r})); // BR + out.push_back(line({xmax, ymin + r}, {xmax, ymax - r})); // right + out.push_back(corner({xmax - r, ymax - r}, {xmax, ymax}, {xmax, ymax - r}, {xmax - r, ymax})); // TR + out.push_back(line({xmax - r, ymax}, {xmin + r, ymax})); // top + out.push_back(corner({xmin + r, ymax - r}, {xmin, ymax}, {xmin + r, ymax}, {xmin, ymax - r})); // TL + out.push_back(line({xmin, ymax - r}, {xmin, ymin + r})); // left + out.push_back(corner({xmin + r, ymin + r}, {xmin, ymin}, {xmin, ymin + r}, {xmin + r, ymin})); // BL + return out; +} + +std::vector DesignSketchTool::make_rounded_rect(const Vec2d& a, const Vec2d& b, const Vec2d& radius_pt) const +{ + std::vector out; + const double xmin = std::min(a.x(), b.x()), xmax = std::max(a.x(), b.x()); + const double ymin = std::min(a.y(), b.y()), ymax = std::max(a.y(), b.y()); + const double bw = xmax - xmin, bh = ymax - ymin; + if (bw < 1e-6 || bh < 1e-6) return out; + const Vec2d cs[4] = { {xmin,ymin}, {xmax,ymin}, {xmax,ymax}, {xmin,ymax} }; + double r = 1e18; + for (const Vec2d& c : cs) r = std::min(r, (radius_pt - c).norm()); + r = std::min(r, std::min(bw, bh) * 0.5); + if (r < 1e-6) { // degenerate -> plain rectangle + auto line = [&](const Vec2d& p0, const Vec2d& p1) { + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = p0; e.p1 = p1; + e.construction = m_construction; return e; }; + for (int i = 0; i < 4; ++i) out.push_back(line(cs[i], cs[(i + 1) % 4])); + return out; + } + return rounded_rect_entities(xmin, ymin, xmax, ymax, r); +} + +std::vector DesignSketchTool::make_polygon(const Vec2d& center, const Vec2d& vertex, int sides) const +{ + std::vector out; + if (sides < 3) sides = 3; + const Vec2d rv = vertex - center; + const double d = rv.norm(); + if (d < 1e-6) return out; + // Inscribed: cursor is a vertex (circumradius = d). Circumscribed: cursor is an + // edge midpoint (apothem = d) → circumradius R = d / cos(pi/n), rotated by half + // a step so an edge midpoint points at the cursor. + double R = d, a0 = std::atan2(rv.y(), rv.x()); + if (m_polygon_circumscribed) { + R = d / std::cos(M_PI / double(sides)); + a0 = std::atan2(rv.y(), rv.x()) - M_PI / double(sides); + } + std::vector verts; verts.reserve(sides); + for (int i = 0; i < sides; ++i) { + const double a = a0 + 2.0 * M_PI * double(i) / double(sides); + verts.push_back(Vec2d(center.x() + R * std::cos(a), center.y() + R * std::sin(a))); + } + for (int i = 0; i < sides; ++i) { + SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = verts[i]; e.p1 = verts[(i + 1) % sides]; + e.construction = m_construction; + out.push_back(e); + } + return out; +} + +// Derive (a, b, phi) of an ellipse from the 3 defining clicks. First axis click = +// major (a, phi); the minor point's perpendicular distance to the major axis = b, +// clamped to a so OCCT's a >= b holds. +static void ellipse_axes(const Vec2d& center, const Vec2d& major_end, const Vec2d& minor_pt, + double& a, double& b, double& phi) +{ + const Vec2d maj = major_end - center; + a = std::max(maj.norm(), 1e-6); + phi = std::atan2(maj.y(), maj.x()); + const Vec2d n(-std::sin(phi), std::cos(phi)); // minor-axis direction + b = std::min(std::abs((minor_pt - center).dot(n)), a); +} + +// Parametric angle on an ellipse of the point nearest `q` (q projected onto the frame). +static double ellipse_param_of(const Vec2d& center, double a, double b, double phi, const Vec2d& q) +{ + const Vec2d d = q - center; + const double cu = std::cos(phi), su = std::sin(phi); + const double u = d.x() * cu + d.y() * su; // along major + const double v = -d.x() * su + d.y() * cu; // along minor + return std::atan2(v / std::max(b, 1e-9), u / std::max(a, 1e-9)); +} + +std::vector DesignSketchTool::make_ellipse(const Vec2d& center, const Vec2d& major_end, + const Vec2d& minor_pt) const +{ + double a, b, phi; + ellipse_axes(center, major_end, minor_pt, a, b, phi); + if (b < 1e-6) return {}; + SketchEntity e; + e.type = SketchEntity::Type::Ellipse; + e.center = center; e.p0 = center; + e.radius = a; e.rminor = b; e.rotation = phi; + e.start_angle = 0.0; e.end_angle = 2.0 * M_PI; + e.construction = m_construction; + return { e }; +} + +std::vector DesignSketchTool::make_ellipse_arc(const Vec2d& center, const Vec2d& major_end, + const Vec2d& minor_pt, const Vec2d& start_pt, + const Vec2d& end_pt) const +{ + double a, b, phi; + ellipse_axes(center, major_end, minor_pt, a, b, phi); + if (b < 1e-6) return {}; + double t0 = ellipse_param_of(center, a, b, phi, start_pt); + double t1 = ellipse_param_of(center, a, b, phi, end_pt); + // CCW sweep from t0 to t1. + while (t1 <= t0) t1 += 2.0 * M_PI; + SketchEntity e; + e.type = SketchEntity::Type::EllipseArc; + e.center = center; + e.radius = a; e.rminor = b; e.rotation = phi; + e.start_angle = t0; e.end_angle = t1; + e.p0 = ellipse_point(center, a, b, phi, t0); + e.p1 = ellipse_point(center, a, b, phi, t1); + e.construction = m_construction; + return { e }; +} + +std::vector DesignSketchTool::make_bspline(const std::vector& ctrl) const +{ + if (ctrl.size() < 2) return {}; + SketchEntity e; + e.type = SketchEntity::Type::BSpline; + e.ctrl = ctrl; + e.p0 = ctrl.front(); + e.p1 = ctrl.back(); + e.construction = m_construction; + return { e }; +} + +std::vector DesignSketchTool::entity_polyline(const SketchEntity& e, bool& closed) const +{ + closed = false; + switch (e.type) { + case SketchEntity::Type::Line: + return { e.p0, e.p1 }; + case SketchEntity::Type::Circle: + closed = true; + return circle_polygon(e.center, e.radius); + case SketchEntity::Type::Arc: { + const int n = kArcFacets; + std::vector pts; pts.reserve(n + 1); + for (int i = 0; i <= n; ++i) { + const double a = e.start_angle + (e.end_angle - e.start_angle) * double(i) / double(n); + pts.push_back(Vec2d(e.center.x() + e.radius * std::cos(a), + e.center.y() + e.radius * std::sin(a))); + } + return pts; + } + case SketchEntity::Type::Ellipse: + closed = true; + return ellipse_polyline(e.center, e.radius, e.rminor, e.rotation, 0.0, 2.0 * M_PI); + case SketchEntity::Type::EllipseArc: + return ellipse_polyline(e.center, e.radius, e.rminor, e.rotation, e.start_angle, e.end_angle); + case SketchEntity::Type::BSpline: + return bspline_polyline(e.ctrl); + case SketchEntity::Type::Point: + return { e.p0 }; + } + return {}; +} + +std::vector> DesignSketchTool::closed_regions() const +{ + return closed_regions(m_entities); +} + +std::vector> DesignSketchTool::closed_regions(const std::vector& ents) const +{ + std::vector> out; + for (RegionLoop& r : region_loops(ents)) out.push_back(std::move(r.poly)); + return out; +} + +std::vector> +DesignSketchTool::region_entity_indices(const std::vector& ents) const +{ + std::vector> out; + for (RegionLoop& r : region_loops(ents)) out.push_back(std::move(r.ents)); + return out; +} + +std::vector> +DesignSketchTool::region_entity_indices_with_holes(const std::vector& ents) const +{ + const std::vector loops = region_loops(ents); + std::vector> out; + out.reserve(loops.size()); + for (const RegionLoop& r : loops) { + std::vector ids = r.ents; + for (int h : r.holes) + if (h >= 0 && h < int(loops.size())) + ids.insert(ids.end(), loops[h].ents.begin(), loops[h].ents.end()); + out.push_back(std::move(ids)); + } + return out; +} + +// Nearest stroke, and the enclosing region if the cursor is inside one, for ONE committed +// sketch. Factored out so the single-click and double-click paths cannot drift apart: they must +// agree about what is under the pointer or one of them will act on something else. +// +// region_loops exists to find EXTRUDABLE regions, and by design it discards open chains — its +// own walk comment says so. Using it as the pick index meant a committed sketch of open lines +// had no pickable geometry whatsoever: the strokes drew, and not one of them could be clicked, +// so there was no way to select it and therefore none to edit or delete it. Whether a stroke +// bounds a region has nothing to do with whether the user can point at it. Region membership +// decides what a hit REPORTS, not whether the hit can happen. +static void dp_pick_trace(const char* fmt, ...); // defined below; used by the diagnostics here +static bool dp_pick_trace_on(); // ditto — lets callers skip building a message +void DesignSketchTool::hit_display_sketch(const DisplaySketch& d, const Vec2d& p, double tol, + int& edge_feat, int& edge_reg, int& edge_ent, + double& edge_d, int& face_feat, int& face_reg) const +{ + const std::vector loops = region_loops(d.entities); + // What did the sketch decompose into, and what is under the click? This is the trace that + // settled txp8 — it prints the loop table with each loop's hole count, so + // "containment is wrong" and "the click landed elsewhere" stop being indistinguishable. + // Guarded rather than merely silent: hit_display_sketch runs on every pick, and the message + // costs a string build and a heap allocation per loop even when nothing consumes it. + if (dp_pick_trace_on()) { + std::string h; + for (size_t r = 0; r < loops.size(); ++r) { + h += " loop" + std::to_string(r) + "(ents=" + std::to_string(loops[r].ents.size()) + + ",poly=" + std::to_string(loops[r].poly.size()) + + ",holes=" + std::to_string(loops[r].holes.size()) + ")"; + } + dp_pick_trace("sketch feat=%d entities=%zu loops=%zu:%s", d.feature, d.entities.size(), + loops.size(), h.c_str()); + } + std::vector ent_region(d.entities.size(), -1); + for (int r = 0; r < int(loops.size()); ++r) + for (int ei : loops[r].ents) + if (ei >= 0 && ei < int(ent_region.size())) ent_region[ei] = r; + + for (int ei = 0; ei < int(d.entities.size()); ++ei) { + if (d.entities[ei].construction) continue; // as region_loops filters it + const double ed = entity_pick_dist(p, d.entities[ei]); + if (ed <= tol * 3.0 && ed < edge_d) { + edge_d = ed; edge_feat = d.feature; edge_reg = ent_region[ei]; edge_ent = ei; + } + } + // Pick the region the point is REALLY in: inside its boundary and not inside any of its + // holes. Clicking the middle of a plate-with-hole must select the plate; clicking inside + // the hole must select the disc, not the plate. Previously the first containing polygon + // won, so a click inside the circle selected the rectangle. + for (int r = 0; r < int(loops.size()); ++r) { + if (face_feat >= 0) break; + if (!point_in_poly(p, loops[r].poly)) continue; + bool in_hole = false; + for (int h : loops[r].holes) + if (h >= 0 && h < int(loops.size()) && point_in_poly(p, loops[h].poly)) { in_hole = true; break; } + if (!in_hole) { face_feat = d.feature; face_reg = r; } + } + // edge_ent is printed because it is now DELIVERED (3648) — a tool can ask for the + // line you pointed at, not just its loop, and "which entity did that click resolve to" is + // otherwise unanswerable from outside. + dp_pick_trace("region hit -> feat=%d reg=%d (edge_feat=%d edge_reg=%d edge_ent=%d)", + face_feat, face_reg, edge_feat, edge_reg, edge_ent); +} + +std::vector DesignSketchTool::selected_loop_entities() const +{ + if (m_display_pick < 0 || m_display_pick_region < 0) return {}; + for (const DisplaySketch& d : m_display_sketches) { + if (d.feature != m_display_pick) continue; + const std::vector loops = region_loops(d.entities); + if (m_display_pick_region >= int(loops.size())) return {}; + // The region's OWN boundary plus every loop nested in it. Handing over only the outer + // loop is what made a rectangle-with-a-circle extrude to a plain box: the circle was + // never passed to the kernel, so build_sketch_face had one loop to work with and the + // multi-loop path never ran. + std::vector out; + auto take = [&](int region) { + if (region < 0 || region >= int(loops.size())) return; + for (int ei : loops[region].ents) + if (ei >= 0 && ei < int(d.entities.size())) out.push_back(d.entities[ei]); + }; + take(m_display_pick_region); + for (int h : loops[m_display_pick_region].holes) take(h); + return out; + } + return {}; +} + +// ---- Solid topology selection (whole -> face -> edge cycle) ---- + +void DesignSketchTool::set_solid_pick(const std::vector* bodies, const TriangleMesh* mesh, + const std::vector* tri_face, const std::vector* tri_body, + const std::vector* visible, + const std::vector* xform) +{ + // Treat no bodies or an empty mesh as "no solid" so has_display()/picking stay off. + if (bodies == nullptr || bodies->empty() || mesh == nullptr || mesh->its.indices.empty()) { + m_solid_bodies = nullptr; m_solid_mesh = nullptr; m_solid_tri_face = nullptr; m_solid_tri_body = nullptr; + m_solid_visible = nullptr; m_solid_xform = nullptr; + } else { + m_solid_bodies = bodies; m_solid_mesh = mesh; m_solid_tri_face = tri_face; m_solid_tri_body = tri_body; + m_solid_visible = visible; m_solid_xform = xform; + } + clear_solid_selection(); +} + +// Map a point sampled from the (untransformed) OCCT body shape through the body's display +// transform, so edge picking/highlight track a moved body. The pick MESH is already +// transformed by the host; only OCCT-sampled edges need this. +Vec3d DesignSketchTool::body_xform_pt(int body, const Vec3d& p) const +{ + if (m_solid_xform != nullptr && body >= 0 && body < int(m_solid_xform->size())) + return (*m_solid_xform)[body] * p; + return p; +} + +// A body is pickable unless an explicit visibility vector marks it hidden. +bool DesignSketchTool::body_pickable(int b) const +{ + if (b < 0) return false; + // Body-focus mode. The focus is an INDEX held by the panel across recomputes, so it can + // outlive the body it names — delete a body and the stored index may point past the end. + // A restriction to a body that no longer exists rejects EVERY body, which is a viewport + // that silently accepts no clicks at all: the worst possible failure for a picking mode, + // because nothing on screen says why. Out of range therefore means NO restriction — fail + // open, never dead. + const bool focus_live = m_pick_only_body >= 0 && m_solid_bodies != nullptr + && m_pick_only_body < int(m_solid_bodies->size()); + if (focus_live && b != m_pick_only_body) return false; + if (m_solid_visible == nullptr || b >= int(m_solid_visible->size())) return true; + return (*m_solid_visible)[b]; +} + +void DesignSketchTool::clear_solid_selection() +{ + m_solid_sel = SolidSel::None; + m_sel_body = m_sel_face = m_sel_edge = -1; + m_sel_edge_pts.clear(); + // The pre-highlight names a face/edge/vertex by index into a shape that a recompute has just + // rebuilt, so it expires with the selection it was a promise about. Left behind it would keep + // glowing on whatever now sits at those indices — a real entity, but not the one meant. + m_pre = SolidPick{}; +} + +void DesignSketchTool::select_body(int body) +{ + // Hidden bodies aren't highlighted (the tint overlay would otherwise draw over a + // body whose GLVolume is off, leaving a ghost after a hide). + if (m_solid_bodies == nullptr || body < 0 || body >= int(m_solid_bodies->size()) + || !body_pickable(body)) { + clear_solid_selection(); + return; + } + m_sel_body = body; + m_sel_face = m_sel_edge = -1; + m_sel_edge_pts.clear(); + m_solid_sel = SolidSel::Whole; // render_solid_highlight tints just this body +} + +// Pick tracing. Selection failures on a real desktop have repeatedly turned out to be an +// event that never arrived rather than a ray that missed, and the two look identical from +// the UI. Set ORCA_CAD_PICK_TRACE=1 and the whole press->release->ray path narrates itself +// on stderr. Off by default: no cost, no noise. +static bool dp_pick_trace_on() +{ + static const bool on = ::getenv("ORCA_CAD_PICK_TRACE") != nullptr; + return on; +} + +static void dp_pick_trace(const char* fmt, ...) +{ + if (!dp_pick_trace_on()) return; + va_list ap; va_start(ap, fmt); + std::fputs("[pick] ", stderr); + std::vfprintf(stderr, fmt, ap); + std::fputc('\n', stderr); + va_end(ap); + std::fflush(stderr); +} + +// Resolve a swept rubber band into a whole-body selection. +// +// Sample points are the display mesh's triangle vertices plus each triangle's centroid. That +// mesh is already in world coordinates — the very points the ray pick tests — so no per-body +// transform is needed here. A body counts as swept when any of its samples lands inside the +// rectangle (crossing semantics: touching selects, which is the forgiving reading of a sweep), +// and the body with the most samples inside wins because the selection callback downstream +// carries exactly one body. +// +// ponytail: crossing over a triangle sample set. A rectangle small enough to sit entirely +// inside one flat triangle selects nothing — drag a bigger one, or click. Real multi-body +// selection (and the homogeneous-set rule that goes with it) is 9xw. +void DesignSketchTool::pick_bodies_in_rectangle() +{ + if (m_solid_mesh == nullptr || m_solid_tri_body == nullptr || m_solid_bodies == nullptr) + return; + const indexed_triangle_set& its = m_solid_mesh->its; + std::vector pts; + std::vector owner; + pts.reserve(its.indices.size() * 4); + owner.reserve(its.indices.size() * 4); + for (size_t i = 0; i < its.indices.size(); ++i) { + const int b = (i < m_solid_tri_body->size()) ? (*m_solid_tri_body)[i] : -1; + if (!body_pickable(b)) continue; // hidden bodies aren't swept either + const auto& idx = its.indices[i]; + Vec3d c = Vec3d::Zero(); + for (int k = 0; k < 3; ++k) { + const Vec3d p = its.vertices[idx(k)].cast(); + pts.push_back(p); owner.push_back(b); c += p; + } + pts.push_back(c / 3.0); owner.push_back(b); + } + + std::vector hits(m_solid_bodies->size(), 0); + if (!pts.empty()) + for (unsigned int i : m_rubber.contains(pts)) + if (i < owner.size() && owner[i] >= 0 && owner[i] < int(hits.size())) + ++hits[owner[i]]; + + int best = -1, best_n = 0; + for (int b = 0; b < int(hits.size()); ++b) + if (hits[b] > best_n) { best_n = hits[b]; best = b; } + + if (best < 0) clear_solid_selection(); // swept empty space -> drop the selection + else select_body(best); + dp_pick_trace("rubber band -> body=%d (%d samples)", best, best_n); + if (on_solid_selection_changed) + on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); +} + +// Resolve what a pick at (mx,my) would take. CONST, and it writes only into `out`: the hover +// path calls this many times a second and must not disturb the committed selection by doing so. +bool DesignSketchTool::resolve_solid_pick(GLCanvas3D& canvas, int mx, int my, SolidPick& out) const +{ + out = SolidPick{}; + if (m_solid_bodies == nullptr || m_solid_mesh == nullptr) { + dp_pick_trace("no solid data (bodies=%p mesh=%p)", + (const void*) m_solid_bodies, (const void*) m_solid_mesh); + return false; + } + const Linef3 r = canvas.mouse_ray(Point(mx, my)); + const Vec3d ro = r.a, rd = r.b - r.a; + + // 1) nearest solid face under the cursor (ray vs display-mesh triangles). Resolve WHICH + // body and which face-within-that-body via the per-triangle (tri_body, tri_face) tags. + const indexed_triangle_set& its = m_solid_mesh->its; + int best_face = -1, best_body = -1; double best_t = 1e30; + for (size_t i = 0; i < its.indices.size(); ++i) { + const auto& idx = its.indices[i]; + const Vec3d v0 = its.vertices[idx(0)].cast(); + const Vec3d v1 = its.vertices[idx(1)].cast(); + const Vec3d v2 = its.vertices[idx(2)].cast(); + double t; + if (ray_triangle(ro, rd, v0, v1, v2, t) && t < best_t) { + const int cand_body = (m_solid_tri_body && i < m_solid_tri_body->size()) ? (*m_solid_tri_body)[i] : -1; + if (!body_pickable(cand_body)) continue; // hidden bodies don't catch clicks + best_t = t; + best_face = (m_solid_tri_face && i < m_solid_tri_face->size()) ? (*m_solid_tri_face)[i] : -1; + best_body = cand_body; + } + } + dp_pick_trace("ray tris=%zu -> body=%d face=%d t=%.3f", its.indices.size(), + best_body, best_face, best_t); + if (best_face < 0 || best_body < 0 || best_body >= int(m_solid_bodies->size())) + return false; // missed the solid + + // ---- one click, one deterministic result --------------------------------------------- + // NO CYCLE. A click selects the SMALLEST thing under the cursor: the edge if the pointer is + // within tolerance of one, otherwise the face. The WHOLE body is taken by a left-drag + // rubber band (pick_bodies_in_rectangle) — a different gesture for a different scale. + // + // What this replaces: click 1 = whole body, click 2 = face, click 3 = nearest edge, click 4 + // = back to whole. That made "click a face" a two-click gesture and "click an edge" a + // three-click one, neither discoverable — the L5 violation §10 of the charter already + // listed, and the real reason sketching on a face kept reading as broken however often the + // plane resolution was fixed. Selection is the foundation the tool offer stands on: the + // offer can only ever be as truthful as the selection beneath it. + // + // Tolerance is measured in SCREEN PIXELS. The old edge step compared a ray-to-segment + // distance in millimetres, so the same gesture meant different things at different zooms — + // the pointer is a screen object and its tolerance has to be one too. + const Camera& cam = wxGetApp().plater()->get_camera(); + const wxPoint cursor(mx, my); + // Vertex beats edge beats face, and the vertex tolerance is the larger of the two: a corner + // sits ON its edges, so an equal radius would make vertices unreachable — every click near + // one would resolve to the edge it lies on. + const double kVertexTolPx = 11.0; + const double kEdgeTolPx = 8.0; + + auto seg_px = [](const wxPoint& p, const wxPoint& a, const wxPoint& b) { // 2D point→segment, px + const double vx = b.x - a.x, vy = b.y - a.y; + const double wx = p.x - a.x, wy = p.y - a.y; + const double L2 = vx * vx + vy * vy; + double t = (L2 > 1e-12) ? (wx * vx + wy * vy) / L2 : 0.0; + t = std::max(0.0, std::min(1.0, t)); + return std::hypot(wx - t * vx, wy - t * vy); + }; + + out.body = best_body; + out.face = best_face; + + { + const TopoDS_Shape& bshape = (*m_solid_bodies)[out.body].shape; + const TopoDS_Face face = GeometryEngine::face_by_index(bshape, out.face); + double best_ed = 1e30; std::vector ed_pts; TopoDS_Edge ed_edge; bool have_edge = false; + double best_vd = 1e30; Vec3d vtx = Vec3d::Zero(); bool have_vtx = false; + // Screen extent of the face, accumulated from the same edge samples the loop already + // takes. A FIXED edge tolerance makes a narrow face unreachable: a 3 mm-wide plate is + // barely wider on screen than the 8 px budget, so every point on it is "on an edge" and + // the face level can never be picked — which also blocks the face-based Coord Sys that a + // mate needs. The tolerances below shrink with the face so its middle stays its own. + int fx0 = INT_MAX, fy0 = INT_MAX, fx1 = INT_MIN, fy1 = INT_MIN; + if (!face.IsNull()) { + for (const TopoDS_Edge& e : GeometryEngine::edges_of_face(face)) { + std::vector pts = GeometryEngine::sample_edge_world(e); + for (Vec3d& q : pts) q = body_xform_pt(out.body, q); // follow a moved body + if (pts.size() < 2) continue; + // The polyline ends ARE the edge's vertices; every corner of the face is the + // end of one of its edges, so this covers them without a separate topology walk. + for (const Vec3d& v : {pts.front(), pts.back()}) { + const wxPoint sp = world_to_screen_px(cam, v); + if (sp.x < 0) continue; + const double d = std::hypot(double(sp.x - cursor.x), double(sp.y - cursor.y)); + if (d < best_vd) { best_vd = d; vtx = v; have_vtx = true; } + } + double d = 1e30; + for (size_t s = 1; s < pts.size(); ++s) { + const wxPoint a = world_to_screen_px(cam, pts[s - 1]); + const wxPoint b = world_to_screen_px(cam, pts[s]); + if (a.x < 0 || b.x < 0) continue; // behind the camera + fx0 = std::min({fx0, a.x, b.x}); fx1 = std::max({fx1, a.x, b.x}); + fy0 = std::min({fy0, a.y, b.y}); fy1 = std::max({fy1, a.y, b.y}); + d = std::min(d, seg_px(cursor, a, b)); + } + if (d < best_ed) { best_ed = d; ed_pts = pts; ed_edge = e; have_edge = true; } + } + } + // A third of the face's SHORTER on-screen side, so the two tolerances can never meet in + // the middle. Only ever shrinks: a face big enough keeps the full budget. + double vtol = kVertexTolPx, etol = kEdgeTolPx; + if (fx1 > fx0 && fy1 > fy0) { + const double narrow = double(std::min(fx1 - fx0, fy1 - fy0)) / 3.0; + vtol = std::min(vtol, narrow); + etol = std::min(etol, narrow); + } + if (have_vtx && best_vd <= vtol) { + out.vertex_pt = vtx; + out.kind = SolidSel::Vertex; + } else if (have_edge && best_ed <= etol) { + // Promote the face-relative pick to a STABLE GLOBAL edge id so dress-up ops + // (fillet/chamfer) can target this exact edge across recomputes. + out.edge = GeometryEngine::edge_index_of(bshape, ed_edge); + out.edge_pts = std::move(ed_pts); + out.kind = SolidSel::Edge; + } else { + out.kind = SolidSel::Face; + } + } + return true; +} + +// A click on the solid takes the smallest thing under the cursor (vertex/edge/face). Returns +// true if the click hit the solid (consumed); false otherwise so the caller can try +// committed-sketch loop picking. Whole bodies are taken by the rubber band, not by clicking. +bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + SolidPick p; + if (!resolve_solid_pick(canvas, evt.GetX(), evt.GetY(), p)) + return false; // missed the solid, or no solid data — same two exits as before + + // What was selected BEFORE this pick — the escalation below is the only thing that reads + // it, and everything from here on overwrites it. + const SolidSel prev_kind = m_solid_sel; + const int prev_body = m_sel_body, prev_face = m_sel_face, prev_edge = m_sel_edge; + const Vec3d prev_vtx = m_sel_vertex_pt; + + m_sel_body = p.body; + m_sel_face = p.face; + m_sel_edge = p.edge; + m_sel_edge_pts = std::move(p.edge_pts); + m_sel_vertex_pt = p.vertex_pt; + m_solid_sel = p.kind; + + // CLICK AGAIN ON THE SAME THING -> THE WHOLE BODY (gem). Pointing at a face and + // pointing at its body are different intents, and until now only the rubber band could + // express the second one — so the status line said "face 0 selected" while the user + // believed they had taken the body, and every body verb had to opt into the face kinds to + // stay reachable. One more click on the SAME sub-element escalates. + // + // This is not the pick cycle that was removed (bc2b741ce9). That one was silent and three + // deep, so no click had a predictable meaning. Here the escalation is announced by the + // status line BEFORE you make the click, and a further click just takes the face under the + // cursor again — the ordinary meaning of clicking a face, which needs no teaching. + // + // Double-click is safe: wx sends Down/Up/DClick/Up, and only the first Up carries a + // pending press, so a fast double-click zooms to fit and picks ONCE. Escalation needs two + // separate clicks, the same "click, pause, click" distinction a file manager uses. + // + // "The same thing" is compared AT THE LEVEL THAT WAS PICKED, and nothing else. Requiring + // every field to match looked stricter and was simply wrong: an edge pick leaves m_sel_face + // set to whichever face the ray happened to hit, and a shared edge is reached through a + // different face depending on which side of the body you are looking from. So picking an + // edge, orbiting, and clicking that same edge from the other side left m_sel_edge equal and + // m_sel_face different, and the escalation the status line had just promised did not happen. + // The edge id here is already the STABLE GLOBAL one (edge_index_of, a few lines up) — it + // identifies the edge on its own and does not need the face to disambiguate it. + const bool same_pick = m_solid_sel == prev_kind && m_sel_body == prev_body + && (m_solid_sel == SolidSel::Vertex ? (m_sel_vertex_pt - prev_vtx).norm() < 1e-9 + : m_solid_sel == SolidSel::Edge ? m_sel_edge == prev_edge + : m_solid_sel == SolidSel::Face ? m_sel_face == prev_face + : true); + if (same_pick && m_escalate_repick) { + select_body(m_sel_body); // clears face/edge/vertex, tints the whole body + dp_pick_trace("re-pick -> escalated to whole body %d", m_sel_body); + } + dp_pick_trace("pick -> sel=%d body=%d face=%d edge=%d", + int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); + if (on_solid_selection_changed) + on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); + return true; +} + +// Recompute what the pointer is over. Returns true only when the answer CHANGED — this runs on +// every motion event, and a repaint per event would cost far more than the pick itself. +bool DesignSketchTool::update_solid_hover(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + SolidPick p; + if (!resolve_solid_pick(canvas, evt.GetX(), evt.GetY(), p)) + p = SolidPick{}; // off the solid: no promise to make + // Compared AT THE LEVEL THAT WAS PICKED, for the same reason the click's escalation is + // (see same_pick above): an edge pick carries whichever face the ray happened to cross, and + // that face changes as the pointer slides along the edge without the answer changing at all. + const bool same = p.kind == m_pre.kind && p.body == m_pre.body + && (p.kind == SolidSel::Vertex ? (p.vertex_pt - m_pre.vertex_pt).norm() < 1e-9 + : p.kind == SolidSel::Edge ? p.edge == m_pre.edge + : p.kind == SolidSel::Face ? p.face == m_pre.face + : true); + if (same) return false; + m_pre = std::move(p); + return true; +} + +// One highlight, drawn from explicit arguments rather than from the selection members, so the +// committed selection and the hover pre-highlight cannot drift apart in how they look. alpha_mul +// scales every layer at once: the pre-highlight is the same shape in the same place, quieter. +void DesignSketchTool::render_solid_sel(SolidSel kind, int body, int face, + const std::vector& edge_pts, + const Vec3d& vertex_pt, + const ColorRGBA& rgb, float alpha_mul) +{ + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + // Opaque: the edge ribbon and the vertex square render with GL_BLEND OFF, so an alpha below 1 + // here would be silently ignored. Those two are quietened by a MUTED rgb from the caller + // instead; alpha_mul only reaches the face fill, which is the one layer that is blended. + const ColorRGBA cyan(rgb.r(), rgb.g(), rgb.b(), 1.0f); + + // Whole tints the picked BODY (all its triangles, lighter alpha); Face tints just the + // picked face on that body. Both filter by `body` so other bodies stay untinted. + if ((kind == SolidSel::Face || kind == SolidSel::Whole) + && m_solid_mesh != nullptr && m_solid_tri_body != nullptr && body >= 0) { + const bool face_only = (kind == SolidSel::Face); + const indexed_triangle_set& its = m_solid_mesh->its; + GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; + unsigned int base = 0; + for (size_t i = 0; i < its.indices.size(); ++i) { + if (i >= m_solid_tri_body->size() || (*m_solid_tri_body)[i] != body) continue; + if (face_only && (m_solid_tri_face == nullptr || i >= m_solid_tri_face->size() + || (*m_solid_tri_face)[i] != face)) continue; + const auto& idx = its.indices[i]; + for (int j = 0; j < 3; ++j) g.add_vertex(its.vertices[idx(j)]); + g.add_triangle(base, base + 1, base + 2); base += 3; + } + if (base > 0) { + glsafe(::glEnable(GL_DEPTH_TEST)); + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-2.0f, -2.0f)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + m_solid_face_model.reset(); + m_solid_face_model.init_from(std::move(g)); + m_solid_face_model.set_color(ColorRGBA(rgb.r(), rgb.g(), rgb.b(), + (face_only ? 0.40f : 0.22f) * alpha_mul)); + m_solid_face_model.render(); + glsafe(::glDisable(GL_BLEND)); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glDisable(GL_DEPTH_TEST)); + } + } else if (kind == SolidSel::Edge && edge_pts.size() >= 2) { + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double hw = 2.0 / std::max(cam.get_zoom(), 1e-6); // ~2 px ribbon half-width + GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; + unsigned int base = 0; + for (size_t s = 1; s < edge_pts.size(); ++s) { + const Vec3d a = edge_pts[s - 1], b = edge_pts[s]; + Vec3d dir = b - a; if (dir.norm() < 1e-9) continue; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(cam.get_dir_up()); + if (off.norm() < 1e-9) continue; + off.normalize(); off *= hw; + g.add_vertex((Vec3f)(a + off).cast()); + g.add_vertex((Vec3f)(b + off).cast()); + g.add_vertex((Vec3f)(b - off).cast()); + g.add_vertex((Vec3f)(a - off).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); base += 4; + } + if (base > 0) { + glsafe(::glDisable(GL_DEPTH_TEST)); + m_solid_edge_model.reset(); + m_solid_edge_model.init_from(std::move(g)); + m_solid_edge_model.set_color(cyan); + m_solid_edge_model.render(); + } + } else if (kind == SolidSel::Vertex) { + // A camera-facing square at the picked corner, sized in screen terms (the same + // 1/zoom trick the edge ribbon uses) so it stays a constant dot at every zoom. A + // selection you cannot see is not a selection (L5), which is why vertex picking waited + // for this rather than shipping without a highlight. + const Camera& cam = wxGetApp().plater()->get_camera(); + Vec3d right = cam.get_dir_right(), up = cam.get_dir_up(); + const double h = 4.5 / std::max(cam.get_zoom(), 1e-6); // ~4.5 px half-size + right *= h; up *= h; + const Vec3d c = vertex_pt; + GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; + g.add_vertex((Vec3f)(c - right - up).cast()); + g.add_vertex((Vec3f)(c + right - up).cast()); + g.add_vertex((Vec3f)(c + right + up).cast()); + g.add_vertex((Vec3f)(c - right + up).cast()); + g.add_triangle(0, 1, 2); + g.add_triangle(0, 2, 3); + glsafe(::glDisable(GL_DEPTH_TEST)); + m_solid_vertex_model.reset(); + m_solid_vertex_model.init_from(std::move(g)); + m_solid_vertex_model.set_color(cyan); + m_solid_vertex_model.render(); + } +} + +// Cyan overlay for the picked face / edge / vertex, plus the quieter pre-highlight of whatever +// the pointer is currently over. Whole-solid tint is the panel's job (set_body_highlight). +// Called from render() while no sketch session is active. +void DesignSketchTool::render_solid_highlight() +{ + const ColorRGBA sel_cyan = design_selection_color(); + + // The pre-highlight goes FIRST so the committed selection paints over it where the two + // overlap — what you HAVE outranks what you would get. Suppressed entirely when they are the + // same thing: two coats of the same colour on the same face reads as a rendering fault, and + // a promise about a click that would change nothing is not worth making. + const bool pre_is_sel = m_pre.kind == m_solid_sel && m_pre.body == m_sel_body + && (m_pre.kind == SolidSel::Vertex ? (m_pre.vertex_pt - m_sel_vertex_pt).norm() < 1e-9 + : m_pre.kind == SolidSel::Edge ? m_pre.edge == m_sel_edge + : m_pre.kind == SolidSel::Face ? m_pre.face == m_sel_face + : true); + if (m_pre.kind != SolidSel::None && !pre_is_sel) + // Desaturated toward white rather than a second hue: a distinct colour would read as a + // distinct KIND of selection, when it is the same selection one moment earlier. + render_solid_sel(m_pre.kind, m_pre.body, m_pre.face, m_pre.edge_pts, m_pre.vertex_pt, + design_selection_color(), 0.45f); // hover = the same colour, quieter + + render_solid_sel(m_solid_sel, m_sel_body, m_sel_face, m_sel_edge_pts, m_sel_vertex_pt, + sel_cyan, 1.0f); +} + +// Datum/reference planes (Plane feature) have no solid; draw each as a translucent indigo +// rectangle + border so it is visible in the viewport (Onshape-style finite plane). World +// space, depth-test off so it reads over the bed; indigo to stay distinct from the cyan +// solid-selection tint, orange sketches and amber feature ghosts. +void DesignSketchTool::render_view_helpers() +{ + if (!m_show_planes && !m_show_axes) return; + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double hw = 1.5 / std::max(cam.get_zoom(), 1e-6); // billboard ribbon half-width (px) + + glsafe(::glDisable(GL_DEPTH_TEST)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + + if (m_show_planes) { + const double H = 40.0; // half-extent (mm) + SketchPlane pl[3]; // XY / XZ / YZ through the world origin + pl[0].origin = Vec3d(0,0,0); pl[0].x_axis = Vec3d(1,0,0); pl[0].y_axis = Vec3d(0,1,0); pl[0].normal = Vec3d(0,0,1); + pl[1].origin = Vec3d(0,0,0); pl[1].x_axis = Vec3d(1,0,0); pl[1].y_axis = Vec3d(0,0,1); pl[1].normal = Vec3d(0,1,0); + pl[2].origin = Vec3d(0,0,0); pl[2].x_axis = Vec3d(0,1,0); pl[2].y_axis = Vec3d(0,0,1); pl[2].normal = Vec3d(1,0,0); + GLModel::Geometry fill; fill.format = { EPT::Triangles, EVL::P3 }; + unsigned int fb = 0; + for (const SketchPlane& p : pl) { + const Vec3d c[4] = { p.to_world(Vec2d(-H,-H)), p.to_world(Vec2d(H,-H)), + p.to_world(Vec2d(H,H)), p.to_world(Vec2d(-H,H)) }; + fill.add_vertex((Vec3f)c[0].cast()); fill.add_vertex((Vec3f)c[1].cast()); + fill.add_vertex((Vec3f)c[2].cast()); fill.add_triangle(fb, fb+1, fb+2); fb += 3; + fill.add_vertex((Vec3f)c[0].cast()); fill.add_vertex((Vec3f)c[2].cast()); + fill.add_vertex((Vec3f)c[3].cast()); fill.add_triangle(fb, fb+1, fb+2); fb += 3; + } + if (fb > 0) { GLModel fm; fm.init_from(std::move(fill)); + fm.set_color(ColorRGBA(0.42f, 0.52f, 0.78f, 0.20f)); fm.render(); } + } + + if (m_show_axes) { + const double L = 60.0; // axis length (mm) + struct Ax { Vec3d dir; ColorRGBA col; }; + const Ax axes[3] = { { Vec3d(1,0,0), ColorRGBA(0.92f, 0.28f, 0.28f, 0.9f) }, + { Vec3d(0,1,0), ColorRGBA(0.30f, 0.80f, 0.34f, 0.9f) }, + { Vec3d(0,0,1), ColorRGBA(0.32f, 0.55f, 0.95f, 0.9f) } }; + for (const Ax& ax : axes) { + // Lines don't rasterise under the reused software GL context, so each axis is a + // thin view-facing ribbon (two triangles), like the datum-plane border. + const Vec3d a = Vec3d(0,0,0), b = ax.dir * L; + Vec3d dir = (b - a).normalized(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(cam.get_dir_up()); + if (off.norm() < 1e-9) continue; + off.normalize(); off *= hw * 1.5; + GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; + g.add_vertex((Vec3f)(a + off).cast()); g.add_vertex((Vec3f)(b + off).cast()); + g.add_vertex((Vec3f)(b - off).cast()); g.add_vertex((Vec3f)(a - off).cast()); + g.add_triangle(0, 1, 2); g.add_triangle(0, 2, 3); + GLModel m; m.init_from(std::move(g)); m.set_color(ax.col); m.render(); + } + } + glsafe(::glDisable(GL_BLEND)); +} + +void DesignSketchTool::render_datum_planes() +{ + if (m_datum_planes.empty()) return; + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const double H = 40.0; // default half-extent when no per-plane size is given (mm) + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double hw = 1.5 / std::max(cam.get_zoom(), 1e-6); // ~1.5 px border ribbon + + GLModel::Geometry fill; fill.format = { EPT::Triangles, EVL::P3 }; + GLModel::Geometry border; border.format = { EPT::Triangles, EVL::P3 }; + unsigned int fb = 0, bb = 0; + for (size_t pi = 0; pi < m_datum_planes.size(); ++pi) { + const SketchPlane& p = m_datum_planes[pi]; + // Per-plane u/v half-extent (the GUI Size U/V + drag handles drive these); fall + // back to the square default when no size was supplied. + const double hu = (pi < m_datum_sizes.size() && m_datum_sizes[pi].x() > 1e-6) + ? m_datum_sizes[pi].x() * 0.5 : H; + const double hv = (pi < m_datum_sizes.size() && m_datum_sizes[pi].y() > 1e-6) + ? m_datum_sizes[pi].y() * 0.5 : H; + const Vec3d c[4] = { p.to_world(Vec2d(-hu, -hv)), p.to_world(Vec2d(hu, -hv)), + p.to_world(Vec2d(hu, hv)), p.to_world(Vec2d(-hu, hv)) }; + fill.add_vertex((Vec3f)c[0].cast()); fill.add_vertex((Vec3f)c[1].cast()); + fill.add_vertex((Vec3f)c[2].cast()); fill.add_triangle(fb, fb + 1, fb + 2); fb += 3; + fill.add_vertex((Vec3f)c[0].cast()); fill.add_vertex((Vec3f)c[2].cast()); + fill.add_vertex((Vec3f)c[3].cast()); fill.add_triangle(fb, fb + 1, fb + 2); fb += 3; + for (int s = 0; s < 4; ++s) { + const Vec3d a = c[s], b = c[(s + 1) & 3]; + Vec3d dir = b - a; if (dir.norm() < 1e-9) continue; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(cam.get_dir_up()); + if (off.norm() < 1e-9) continue; + off.normalize(); off *= hw; + border.add_vertex((Vec3f)(a + off).cast()); + border.add_vertex((Vec3f)(b + off).cast()); + border.add_vertex((Vec3f)(b - off).cast()); + border.add_vertex((Vec3f)(a - off).cast()); + border.add_triangle(bb, bb + 1, bb + 2); + border.add_triangle(bb, bb + 2, bb + 3); bb += 4; + } + } + glsafe(::glDisable(GL_DEPTH_TEST)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + if (fb > 0) { + GLModel fm; fm.init_from(std::move(fill)); + fm.set_color(ColorRGBA(0.62f, 0.52f, 0.95f, 0.10f)); + fm.render(); + } + if (bb > 0) { + GLModel bm; bm.init_from(std::move(border)); + bm.set_color(ColorRGBA(0.70f, 0.60f, 1.0f, 0.85f)); + bm.render(); + } + glsafe(::glDisable(GL_BLEND)); +} + +// ---- Visual Extrude gizmo (C5b) ------------------------------------------------------- +void DesignSketchTool::set_extrude_gizmo(const SketchPlane& plane, const Vec2d& centroid, + double depth, double depth2, bool two_sided, bool flip) +{ + m_ex_active = true; + m_ex_plane = plane; + m_ex_centroid = centroid; + m_ex_depth = std::max(0.0, depth); + m_ex_depth2 = std::max(0.0, depth2); + m_ex_two_sided = two_sided; + m_ex_flip = flip; +} + +void DesignSketchTool::clear_extrude_gizmo() +{ + m_ex_active = false; + m_ex_drag = -1; +} + +// Camera-billboarded depth arrow(s) along the profile normal, drawn in WORLD via a billboard +// SketchPlane at the centroid (draw_strokes/draw_text lift 2D coords through m_plane.to_world, +// so swapping m_plane to a screen-facing frame renders a flat, screen-aligned arrow + label). +// The mate-connector glyph. Three elements, each answering one question, and every dimension is in +// SCREEN PIXELS via upp (= 1/zoom) like every other gizmo here — a connector is a symbol, not a part, +// so it must not shrink with the model. +// +// disc the XY plane "I am a frame, and this is the plane I sit in" +// quadrant the +x/+y sector "this is where X is" -- the roll, otherwise invisible +// Z arrow +Z only, never -Z "this is the way I point" -- the VERSE +// +// POLARITY (which one is anchored, which one is about to move) is carried by the head: a filled +// cone travels, an open collar receives. No surveyed CAD system encodes this at all; both ends of +// their mates are drawn identically, which is why "which part moves?" is a standing complaint. +// +// ORCA_CAD_GLYPH=A|B selects the treatment while this is being judged on the rig: +// A three short axis arms, no head differentiation (the Onshape baseline) +// B one-sided Z arrow, filled vs open head (the proposal) -- default +void DesignSketchTool::render_mate_connectors() +{ + if (m_mate_connectors.empty()) return; + static const bool style_A = [] { + const char* s = ::getenv("ORCA_CAD_GLYPH"); + return s && (*s == 'A' || *s == 'a'); + }(); + // The face treatment, on by default. Read every frame rather than latched in a static, so + // toggling the preference takes effect on the next repaint instead of at the next launch — + // it is a look, and a look you cannot A/B without restarting will not get compared. + // ORCA_CAD_GLYPH=D forces the disc regardless, which is how the rig drives the other branch. + const bool face_style = !style_A + && wxGetApp().app_config->get_bool("design_connector_face_glyph") + && [] { const char* s = ::getenv("ORCA_CAD_GLYPH"); + return !(s && (*s == 'D' || *s == 'd')); }(); + + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double R = 22.0 * upp; // disc radius, ~22 px + const double lw = std::max(0.9 * upp, 1e-4); + + const ColorRGBA gold (0.93f, 0.66f, 0.09f, 1.0f); + const ColorRGBA blue (0.18f, 0.44f, 0.93f, 1.0f); + const ColorRGBA grey (0.42f, 0.46f, 0.52f, 1.0f); + // F5: roll-undefined was a loud red — the strongest colour in the viewport spent on the LEAST + // important connector, which pulled the eye away from the mate being made. It is a "this one + // could not be derived" mark, not an error: muted amber says look-here without shouting. + const ColorRGBA warn (0.72f, 0.55f, 0.22f, 1.0f); + + const SketchPlane saved = m_plane; + + for (const MateConnectorGlyph& g : m_mate_connectors) { + const Vec3d X = g.x.normalized(); + const Vec3d Y = g.y.normalized(); + const Vec3d Z = X.cross(Y).normalized(); + const ColorRGBA body = (g.role == 2) ? blue : grey; + + // ---- disc + quadrant, drawn IN the connector's own plane. This is the part that must not + // be billboarded: a disc that always faces the camera cannot show the frame's orientation, + // which is the only reason it is a disc and not a dot. + // Lifted off the surface by a sub-pixel epsilon. A connector's disc is EXACTLY coplanar with + // the face it sits on, so depth-testing it z-fights: on the rig the disc came out as a broken + // dotted arc that flickered with the camera. Depth off floats it through solids, depth on and + // coplanar tears it — the lift is what buys both. Scaled by upp so it stays sub-pixel at any + // zoom instead of becoming a visible gap when you zoom in. + // A face asserts a definite roll. When the roll could NOT be derived, drawing one would be + // a confident lie about the very thing that is unknown — the same objection that rejected + // billboarding the quadrant — so an underived connector keeps the disc treatment and its + // hatched quadrant, whatever the preference says. + if (face_style && !g.roll_undefined) { + render_mate_face(g.origin, X, Y, Z, R, body); + } else { + + SketchPlane cp; cp.origin = g.origin + Z * (0.7 * upp); + cp.x_axis = X; cp.y_axis = Y; cp.normal = Z; + m_plane = cp; + { + std::vector> segs; + const int N = 40; + for (int i = 0; i < N; ++i) { + const double a0 = (2.0 * M_PI * i) / N, a1 = (2.0 * M_PI * (i + 1)) / N; + segs.emplace_back(Vec2d(R * std::cos(a0), R * std::sin(a0)), + Vec2d(R * std::cos(a1), R * std::sin(a1))); + } + // Depth test ON for the disc, deliberately. With it off, a connector on a face pointing + // AWAY from the camera still drew its disc over the solid, so the part looked covered in + // frames that were really on its back — and a disc floating over an edge read as + // detached rather than planted. render_hole_gizmo learned the same thing for its cube. + glsafe(::glEnable(GL_DEPTH_TEST)); + draw_strokes(m_mc_stroke_model, segs, lw, g.roll_undefined ? warn : body); + + // The quadrant: hatched when the roll could not be derived (a full circular face or a + // seam offers no in-plane direction), so the fallback is a mark you cannot miss rather + // than a silent guess. + std::vector> q; + const double qr = R * 0.84; + const int QN = 10; + for (int i = 0; i < QN; ++i) { + const double a0 = (M_PI_2 * i) / QN, a1 = (M_PI_2 * (i + 1)) / QN; + q.emplace_back(Vec2d(qr * std::cos(a0), qr * std::sin(a0)), + Vec2d(qr * std::cos(a1), qr * std::sin(a1))); + } + q.emplace_back(Vec2d(0, 0), Vec2d(qr, 0)); + q.emplace_back(Vec2d(0, 0), Vec2d(0, qr)); + if (!g.roll_undefined) { + for (int i = 1; i < 5; ++i) { // fan lines read as "filled" at any angle + const double a = (M_PI_2 * i) / 5.0; + q.emplace_back(Vec2d(0, 0), Vec2d(qr * std::cos(a), qr * std::sin(a))); + } + // F4: the quadrant collapses to a blob at grazing angles — exactly when the roll + // is hardest to read. A radial tick along +X, extending PAST the disc rim, is what + // survives that: as the disc flattens to a line the sector loses all area, but a + // radial spoke keeps its length and its direction along the one axis that still + // projects. + // + // The alternative on the issue was billboarding the quadrant. Rejected, and not + // on taste: at true grazing the view direction lies IN the connector's plane, so + // every in-plane direction projects onto the same screen line and the roll is + // geometrically unrecoverable. A billboarded quadrant would not recover it — it + // would face the camera and read as a definite orientation that is not the frame's. + // Better to degrade to a direction you can still trust than to draw a confident + // lie. Judge the tick on the rig at a true grazing view before calling F4 closed. + q.emplace_back(Vec2d(R, 0), Vec2d(R * 1.28, 0)); + } + draw_strokes(m_mc_stroke_model, q, lw, g.roll_undefined ? warn : gold); + } + } // end of the disc treatment + + // ---- the axes. Billboarded at the origin: a 3D direction is projected onto the screen + // frame, which is the only way an arrow keeps a readable head at any viewing angle. + SketchPlane bb; bb.origin = g.origin; bb.x_axis = right; bb.y_axis = up; + bb.normal = cam.get_dir_forward().normalized(); + m_plane = bb; + + auto arrow = [&](const Vec3d& dir, double len, const ColorRGBA& col, bool filled_head) { + const Vec3d tipw = g.origin + dir * len; + const Vec2d tip2((tipw - g.origin).dot(right), (tipw - g.origin).dot(up)); + std::vector> segs; + // Foreshortening: when the axis points at (or away from) the camera it projects to + // nothing and an arrow degenerates into a dot. Draw a ring instead — "pointing at you" + // — rather than silently vanishing, which is what a naive projection does. + if (tip2.norm() < R * 0.28) { + const int N = 24; + const double rr = R * 0.30; + for (int i = 0; i < N; ++i) { + const double a0 = (2.0 * M_PI * i) / N, a1 = (2.0 * M_PI * (i + 1)) / N; + segs.emplace_back(Vec2d(rr * std::cos(a0), rr * std::sin(a0)), + Vec2d(rr * std::cos(a1), rr * std::sin(a1))); + } + draw_strokes(m_mc_stroke_model, segs, lw, col); + return; + } + const Vec2d u = tip2.normalized(); + const Vec2d n(-u.y(), u.x()); + const double as = R * 0.42; + const Vec2d back = tip2 - u * as; + segs.emplace_back(Vec2d(0, 0), back); + segs.emplace_back(tip2, back + n * (as * 0.45)); + segs.emplace_back(tip2, back - n * (as * 0.45)); + if (filled_head) { + // A closed head reads solid; the open one is left as two barbs. At 20-odd pixels + // this is the difference that says "this body travels". + segs.emplace_back(back + n * (as * 0.45), back - n * (as * 0.45)); + segs.emplace_back(back + n * (as * 0.22), tip2); + segs.emplace_back(back - n * (as * 0.22), tip2); + } else { + segs.emplace_back(back + n * (as * 0.45), back - n * (as * 0.45)); + } + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_mc_stroke_model, segs, lw, col); + }; + + if (style_A) { + arrow(X, R * 1.15, ColorRGBA(0.85f, 0.29f, 0.24f, 1.0f), true); + arrow(Y, R * 1.15, ColorRGBA(0.23f, 0.65f, 0.35f, 1.0f), true); + arrow(Z, R * 1.60, blue, true); + } else { + arrow(Z, R * 2.10, body, g.role == 2); // +Z only. Nothing is ever drawn on -Z. + } + } + + // ---- the pair line. Dashed, drawn IN WORLD along the segment joining the two origins, so it + // foreshortens with the model and its length is the gap the mate has still to close. Screen- + // constant dashes like everything else here; the dash pitch opens up on a long span so a mate + // across a large assembly cannot emit thousands of segments. + for (const auto& lnk : m_mate_links) { + const Vec3d d = lnk.second - lnk.first; + const double L = d.norm(); + if (L < 1e-9) continue; + SketchPlane lp; + lp.origin = lnk.first; + lp.x_axis = d / L; + lp.y_axis = lp.x_axis.cross(cam.get_dir_forward().normalized()); + if (lp.y_axis.norm() < 1e-6) lp.y_axis = lp.x_axis.cross(up); // link along the view axis + lp.y_axis.normalize(); + lp.normal = lp.x_axis.cross(lp.y_axis); + m_plane = lp; + + std::vector> segs; + const double dash = 6.0 * upp; + const double step = std::max(dash + 4.0 * upp, L / 400.0); + for (double t = 0.0; t < L; t += step) + segs.emplace_back(Vec2d(t, 0.0), Vec2d(std::min(t + dash, L), 0.0)); + // Depth off: the line's job is to say "these two belong together", and it has to say it + // even when the parts it spans are between the camera and one of the ends. + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_mc_stroke_model, segs, lw, grey); + } + + m_plane = saved; +} + +// --------------------------------------------------------------------------------------------- +// THE FACE TREATMENT of the mate connector (x0kd). The disc + roll quadrant answers +// "where is X" with a shape that has to be learned; a face does not. Face orientation is +// hardwired perception -- a toddler reads a face's roll and verse with no instruction at all -- +// and that is the whole reason this exists. Default ON, switchable in Preferences for users who +// expect the conventional CAD representation. +// +// WHY A RELIEF AND NOT A FLAT DRAWING, which is the non-obvious half. A flat face drawn in the +// connector's plane foreshortens by sin(elevation) and collapses at a grazing view exactly like +// the quadrant it replaces -- measured on the rig, the quadrant falls from 89 lit pixels at 47 +// degrees to 3 at 10 and 0 edge-on. A relief does not: at a grazing angle its SILHOUETTE carries +// the information. The same bear rendered flat vs in relief gives 164 vs 210 lit pixels at 16 +// degrees and 66 vs 120 at 6. So the glyph is a small shaded solid, not an outline. +// +// The geometry is EMITTED from the real part by doc/design/mate-connectors/emit_glyph_table.py, +// not hand-drawn, so the glyph and the printed connector cannot drift apart. Two vertices stand +// above the 3 mm plate in the actual B-rep, which is why the snout here is a tent with one crest +// edge and four flanks drafted at 20 degrees rather than anything more elaborate. +// +// The cheek dot is the handedness mark. Without it the glyph differs from its own mirror by only +// 5-9 % of its lit pixels, which is not enough to read; the dot roughly doubles that and, unlike +// making the eyes uneven, identifies the side from that cheek alone instead of by comparison. +// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit. +// Normalised to the part's bounding span and centred: the renderer scales by one radius. +static const Vec2d kBearOutline[] = { // 12 verts, RDP eps 0.030, CCW + {+0.3842, +0.3294}, {+0.3156, +0.4002}, {+0.2424, +0.3294}, + {-0.2524, +0.3294}, {-0.3377, +0.3877}, {-0.3693, +0.3298}, + {-0.3256, +0.2631}, {-0.4893, -0.3337}, {-0.3960, -0.4002}, + {+0.4151, -0.4002}, {+0.5000, -0.3154}, {+0.3156, +0.2631}, +}; +static const Vec2d kBearChin[] = { // the CHIN BAR, flat. The muzzle is relief — see kBearCrest. + {-0.2682, -0.3578}, {+0.2628, -0.3578}, {+0.2237, -0.1786}, +}; +// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (wi3z). +static const Vec3d kBearMarks[] = { + {-0.1997, +0.1760, +0.0590}, + {+0.1947, +0.1760, +0.0590}, + {+0.2797, +0.0760, +0.0380}, +}; +// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets. +// This is the only feature standing along +Z and the only one still legible edge-on. +static const double kBearPlateZ = +0.0360; +static const Vec2d kBearSnoutBase[] = { // CCW from the nose end + {-0.0727, -0.2417}, + {+0.0630, -0.2417}, + {+0.0259, +0.1939}, + {-0.0356, +0.1939}, +}; +static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short) + {-0.0048, -0.1793, +0.2073}, + {-0.0048, +0.1605, +0.1279}, +}; + +void DesignSketchTool::render_mate_face(const Vec3d& origin, const Vec3d& X, const Vec3d& Y, + const Vec3d& Z, double R, const ColorRGBA& body) +{ + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d fwd = cam.get_dir_forward().normalized(); + const double S = 2.0 * R; // the table spans 1.0, the disc spans 2R + + // Light fixed in CAMERA space, so orbiting the model does not swing the shading around and + // turn a stable symbol into a flickering one. + const Vec3d light = (-0.35 * right + 0.55 * up - 0.76 * fwd).normalized(); + + auto to_world = [&](const Vec3d& p) { + return origin + X * (p.x() * S) + Y * (p.y() * S) + Z * (p.z() * S); + }; + + struct Facet { std::vector w; ColorRGBA c; double depth; }; + std::vector facets; + auto emit = [&](std::vector pts, const ColorRGBA& base, bool shade) { + if (pts.size() < 3) return; + Facet f; f.w.reserve(pts.size()); + for (const Vec3d& p : pts) f.w.push_back(to_world(p)); + const Vec3d n0 = (f.w[1] - f.w[0]).cross(f.w[2] - f.w[0]); + Vec3d n = Z; + if (n0.norm() > 1e-12) n = n0.normalized(); + if (n.dot(fwd) > 0.0) n = -n; // always take the camera-facing side + double k = 1.0; + if (shade) { + // Ambient floor so a facet turned away still reads as part of the same object rather + // than as a hole punched in it. + k = 0.42 + 0.58 * std::max(0.0, n.dot(light)); + } + f.c = ColorRGBA(float(base.r() * k), float(base.g() * k), float(base.b() * k), base.a()); + double d = 0.0; + for (const Vec3d& p : f.w) d += p.dot(fwd); + f.depth = d / double(f.w.size()); + facets.push_back(std::move(f)); + }; + + const int NO = int(sizeof(kBearOutline) / sizeof(kBearOutline[0])); + const double zp = kBearPlateZ; + + // The plate: sides first so the silhouette exists at a grazing view, then the top. + for (int i = 0; i < NO; ++i) { + const Vec2d& a = kBearOutline[i]; + const Vec2d& b = kBearOutline[(i + 1) % NO]; + emit({ Vec3d(a.x(), a.y(), 0.0), Vec3d(b.x(), b.y(), 0.0), + Vec3d(b.x(), b.y(), zp), Vec3d(a.x(), a.y(), zp) }, body, true); + } + { + std::vector top; + top.reserve(NO); + for (int i = 0; i < NO; ++i) top.emplace_back(kBearOutline[i].x(), kBearOutline[i].y(), zp); + emit(std::move(top), body, true); + } + + // The marks, a hair above the plate so they cannot z-fight it: two eyes then the cheek dot. + const ColorRGBA mark(body.r() * 0.30f, body.g() * 0.30f, body.b() * 0.30f, 1.0f); + const double zm = zp + 0.004; + for (const Vec3d& m : kBearMarks) { + std::vector disc; + const int N = 12; + for (int i = 0; i < N; ++i) { + const double a = (2.0 * M_PI * i) / N; + disc.emplace_back(m.x() + m.z() * std::cos(a), m.y() + m.z() * std::sin(a), zm); + } + emit(std::move(disc), mark, false); + } + { + std::vector chin; + for (const Vec2d& p : kBearChin) chin.emplace_back(p.x(), p.y(), zm); + emit(std::move(chin), mark, false); + } + + // THE MUZZLE, and the two decisions that make it legible rather than merely present. + // + // It is the one feature standing along +Z, so it says which way the connector points, and it + // is all that survives edge-on where a drawing in the plane has nothing left. Its geometry is + // the part's own ridge: crest 29.0 mm, 6.58 mm drop, 13.1 deg, against the 28.3 / 6.61 / 13.1 + // the review measured on the B-rep. Base taken from the mesh, NOT recomputed from + // height*tan(draft) -- that produced a needle, because the real base overhangs the crest at + // both ends and it is the overhang that makes this a wedge rather than a blade. + // + // BUT FIDELITY ALONE FAILS. Scaled honestly the ridge is 11.3 mm on an 83.3 mm face, 13.6 % + // of the width, and at 22-48 px that reads as a scratch -- Tommaso looked at the faithful + // version and could not find the muzzle at all, which is the only test that counts. A glyph + // is a symbol, not a scale model, so it gets two deliberate exaggerations: + // + // COLOUR does the work. In the body tone the muzzle is a grey sliver whichever way it is + // lit; in the accent it is the first thing the eye lands on at every elevation, and at 6 + // degrees it is the ONLY structured thing above the flat line. Measured share of lit + // pixels at 90/16/6 deg: body 14.8/11.3/17.5 %, accent 18.3/19.2/23.9 %. + // WIDTH 1.8x on top of that: 23.5/25.2/31.2 %, and it stops reading as a needle. + // + // The accent is the same gold the disc treatment spends on its roll quadrant, which is + // consistent -- it is this tab's "here is the direction that matters" colour. Polarity is + // still carried by the Z arrow's head, so nothing collides. + { + const ColorRGBA gold(0.93f, 0.66f, 0.09f, 1.0f); + const double widen = 1.8; + const Vec3d& A = kBearCrest[0]; + const Vec3d& B = kBearCrest[1]; + auto base = [&](int i) { + return Vec3d(kBearSnoutBase[i].x() * widen, kBearSnoutBase[i].y(), zp); + }; + const Vec3d nl = base(0), nr = base(1), tr = base(2), tl = base(3); + emit({ nl, tl, B, A }, gold, true); // left flank + emit({ nr, A, B, tr }, gold, true); // right flank + emit({ nl, A, nr }, gold, true); // nose cap, sloped by the base overhang + emit({ tr, B, tl }, gold, true); // tail cap + } + + // Painter's algorithm: depth testing is off for this overlay, so draw order IS the depth. + std::sort(facets.begin(), facets.end(), + [](const Facet& a, const Facet& b) { return a.depth > b.depth; }); + + // draw_fill works in m_plane, so project into a screen-aligned frame at the connector origin + // and hand it flat polygons. The relief survives because the PROJECTION is 3D, not the plane. + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = origin; bb.x_axis = right; bb.y_axis = up; bb.normal = fwd; + m_plane = bb; + glsafe(::glDisable(GL_DEPTH_TEST)); + for (const Facet& f : facets) { + std::vector poly; + poly.reserve(f.w.size()); + for (const Vec3d& p : f.w) poly.emplace_back((p - origin).dot(right), (p - origin).dot(up)); + draw_fill(m_mc_fill_model, poly, f.c); + } + m_plane = saved; +} + +void DesignSketchTool::render_extrude_gizmo() +{ + if (!m_ex_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d fwd = cam.get_dir_forward().normalized(); + const Vec3d base = m_ex_plane.to_world(m_ex_centroid); + const Vec3d ndir = (m_ex_flip ? -1.0 : 1.0) * m_ex_plane.normal.normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + + // Express everything in the billboard frame (origin=base) so it always faces the camera. + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = base; bb.x_axis = right; bb.y_axis = up; bb.normal = fwd; + m_plane = bb; + const ColorRGBA arrowc(1.0f, 0.62f, 0.16f, 1.0f); // CAD amber + + auto draw_arrow = [&](double depth, bool flip_side) { + if (depth <= 1e-6) return; + const Vec3d dirw = (flip_side ? -1.0 : 1.0) * ndir; // world arrow direction + const Vec3d tipw = base + dirw * depth; + const Vec2d tip2((tipw - base).dot(right), (tipw - base).dot(up)); + if (tip2.norm() < 1e-6) return; // axis ~ parallel to view: no arrow + const Vec2d u = tip2.normalized(); + const Vec2d nrm(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.18, th * 0.9); // arrowhead size + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm * (as * 0.5)); + segs.emplace_back(tip2, back - nrm * (as * 0.5)); + draw_strokes(m_ex_arrow_model, segs, std::max(0.7 * upp, 1e-4), arrowc); + DimAnnot a; a.kind = DimType::Distance; a.value = depth; + draw_text(m_line_model, dim_text(a), tip2 + u * (th * 1.4), th, arrowc); + }; + + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_arrow(m_ex_depth, false); + if (m_ex_two_sided) draw_arrow(m_ex_depth2, true); + m_plane = saved; +} + +// Ray vs the arrow segment(s) in world space; `which` = 0 primary, 1 second side. +bool DesignSketchTool::hit_test_extrude_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const +{ + if (!m_ex_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double tol = 7.0 / std::max(cam.get_zoom(), 1e-6); // ~7 px in world units + const Vec3d ndir = (m_ex_flip ? -1.0 : 1.0) * m_ex_plane.normal.normalized(); + const Vec3d base = m_ex_plane.to_world(m_ex_centroid); + double dA = 1e30, dB = 1e30; + if (m_ex_depth > 1e-6) dA = ray_segment_dist3(ro, rd, base, base + ndir * m_ex_depth); + if (m_ex_two_sided && m_ex_depth2 > 1e-6) + dB = ray_segment_dist3(ro, rd, base, base - ndir * m_ex_depth2); + if (dA <= tol && dA <= dB) { which = 0; return true; } + if (dB <= tol) { which = 1; return true; } + return false; +} + +// Drag the arrow handle: closest point on the world arrow axis to the mouse ray (skew-line +// closest-point), projected onto the axis direction -> signed depth (clamped positive). +void DesignSketchTool::drag_extrude_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Vec3d nd = (m_ex_flip ? -1.0 : 1.0) * m_ex_plane.normal.normalized(); + const Vec3d e = (which == 1 ? -1.0 : 1.0) * nd; // axis dir for this arrow + const Vec3d base = m_ex_plane.to_world(m_ex_centroid); + const Vec3d w0 = base - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave depth as-is + const double depth = std::max(0.01, (b * ee - c * dd) / denom); + if (which == 1) m_ex_depth2 = depth; else m_ex_depth = depth; + if (on_extrude_depth_changed) on_extrude_depth_changed(depth, which == 1); +} + +void DesignSketchTool::open_extrude_editor(int which) +{ + if (!on_inline_edit) return; + const double cur = (which == 1) ? m_ex_depth2 : m_ex_depth; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, cur, "", + [this, which](double v) { + const double d = std::max(0.01, v); + if (which == 1) m_ex_depth2 = d; else m_ex_depth = d; + if (on_extrude_depth_changed) on_extrude_depth_changed(d, which == 1); + }, + []() {}); +} + +// ---- Datum-plane resize gizmo (C3) ---------------------------------------------------- +void DesignSketchTool::set_datum_gizmo(const SketchPlane& plane, double usize, double vsize, + const Vec3d& base_origin, const Vec3d& base_normal, + double offset, bool offset_on) +{ + m_dz_active = true; + m_dz_plane = plane; + m_dz_usize = std::max(1.0, usize); + m_dz_vsize = std::max(1.0, vsize); + m_dz_anchor = base_origin; + m_dz_normal = base_normal.normalized(); + m_dz_offset = offset; + m_dz_offset_on = offset_on; +} + +void DesignSketchTool::clear_datum_gizmo() +{ + m_dz_active = false; + m_dz_drag = -1; +} + +// Draw the datum rectangle outline + 4 camera-billboarded edge-midpoint handles. Self-contained +// so it shows even for an uncommitted (not-yet-Confirmed) datum that render_datum_planes can't draw. +void DesignSketchTool::render_datum_gizmo() +{ + if (!m_dz_active) return; + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d vd = cam.get_dir_forward(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double hs = 6.0 * upp; // handle half-size (~6 px) + const double hw = 1.5 * upp; // outline ribbon half-width + const double hu = m_dz_usize * 0.5, hv = m_dz_vsize * 0.5; + const SketchPlane& p = m_dz_plane; + + // Rectangle outline (4 thin camera-facing ribbons). + const Vec3d c[4] = { p.to_world(Vec2d(-hu, -hv)), p.to_world(Vec2d(hu, -hv)), + p.to_world(Vec2d(hu, hv)), p.to_world(Vec2d(-hu, hv)) }; + GLModel::Geometry border; border.format = { EPT::Triangles, EVL::P3 }; + unsigned int bb = 0; + for (int s = 0; s < 4; ++s) { + const Vec3d a = c[s], b = c[(s + 1) & 3]; + Vec3d dir = b - a; if (dir.norm() < 1e-9) continue; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(up); + if (off.norm() < 1e-9) continue; + off.normalize(); off *= hw; + border.add_vertex((Vec3f)(a + off).cast()); border.add_vertex((Vec3f)(b + off).cast()); + border.add_vertex((Vec3f)(b - off).cast()); border.add_vertex((Vec3f)(a - off).cast()); + border.add_triangle(bb, bb + 1, bb + 2); border.add_triangle(bb, bb + 2, bb + 3); bb += 4; + } + + // 4 edge-midpoint handle squares. + const Vec2d hpos[4] = { Vec2d(hu, 0), Vec2d(-hu, 0), Vec2d(0, hv), Vec2d(0, -hv) }; + GLModel::Geometry handles; handles.format = { EPT::Triangles, EVL::P3 }; + unsigned int hb = 0; + for (int i = 0; i < 4; ++i) { + const Vec3d ctr = p.to_world(hpos[i]); + const Vec3d q0 = ctr - right * hs - up * hs, q1 = ctr + right * hs - up * hs, + q2 = ctr + right * hs + up * hs, q3 = ctr - right * hs + up * hs; + handles.add_vertex((Vec3f)q0.cast()); handles.add_vertex((Vec3f)q1.cast()); + handles.add_vertex((Vec3f)q2.cast()); handles.add_vertex((Vec3f)q3.cast()); + handles.add_triangle(hb, hb + 1, hb + 2); handles.add_triangle(hb, hb + 2, hb + 3); hb += 4; + } + + glsafe(::glDisable(GL_DEPTH_TEST)); + if (bb > 0) { + GLModel m; m.init_from(std::move(border)); + m.set_color(ColorRGBA(1.0f, 0.62f, 0.16f, 0.9f)); // CAD amber + m.render(); + } + if (hb > 0) { + GLModel m; m.init_from(std::move(handles)); + m.set_color(ColorRGBA(1.0f, 0.72f, 0.28f, 1.0f)); + m.render(); + } + + // Offset arrow: a camera-facing ribbon from the base origin along the base normal to the + // datum origin, with a grabbable square at the tip. Drag the tip to set the offset distance. + if (m_dz_offset_on) { + const Vec3d tip = m_dz_anchor + m_dz_normal * m_dz_offset; + Vec3d off = m_dz_normal.cross(vd); + if (off.norm() < 1e-9) off = m_dz_normal.cross(up); + GLModel::Geometry shaft; shaft.format = { EPT::Triangles, EVL::P3 }; + if (off.norm() > 1e-9 && (tip - m_dz_anchor).norm() > 1e-9) { + off.normalize(); off *= hw; + shaft.add_vertex((Vec3f)(m_dz_anchor + off).cast()); + shaft.add_vertex((Vec3f)(tip + off).cast()); + shaft.add_vertex((Vec3f)(tip - off).cast()); + shaft.add_vertex((Vec3f)(m_dz_anchor - off).cast()); + shaft.add_triangle(0, 1, 2); shaft.add_triangle(0, 2, 3); + GLModel sm; sm.init_from(std::move(shaft)); + sm.set_color(ColorRGBA(0.30f, 0.78f, 1.0f, 0.95f)); // cyan offset axis + sm.render(); + } + GLModel::Geometry tipsq; tipsq.format = { EPT::Triangles, EVL::P3 }; + const Vec3d t0 = tip - right * hs - up * hs, t1 = tip + right * hs - up * hs, + t2 = tip + right * hs + up * hs, t3 = tip - right * hs + up * hs; + tipsq.add_vertex((Vec3f)t0.cast()); tipsq.add_vertex((Vec3f)t1.cast()); + tipsq.add_vertex((Vec3f)t2.cast()); tipsq.add_vertex((Vec3f)t3.cast()); + tipsq.add_triangle(0, 1, 2); tipsq.add_triangle(0, 2, 3); + GLModel tm; tm.init_from(std::move(tipsq)); + tm.set_color(ColorRGBA(0.45f, 0.86f, 1.0f, 1.0f)); + tm.render(); + } +} + +bool DesignSketchTool::hit_test_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const +{ + if (!m_dz_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double tol = 9.0 / std::max(cam.get_zoom(), 1e-6); // ~9 px in world units + const double hu = m_dz_usize * 0.5, hv = m_dz_vsize * 0.5; + const Vec2d hpos[4] = { Vec2d(hu, 0), Vec2d(-hu, 0), Vec2d(0, hv), Vec2d(0, -hv) }; + double best = tol; which = -1; + for (int i = 0; i < 4; ++i) { + const Vec3d pt = m_dz_plane.to_world(hpos[i]); + const Vec3d w = pt - ro; + const double t = w.dot(rd) / std::max(rd.dot(rd), 1e-12); + const double d = (w - rd * t).norm(); + if (d < best) { best = d; which = i; } + } + if (m_dz_offset_on) { // offset arrow tip = handle 4 + const Vec3d pt = m_dz_anchor + m_dz_normal * m_dz_offset; + const Vec3d w = pt - ro; + const double t = w.dot(rd) / std::max(rd.dot(rd), 1e-12); + const double d = (w - rd * t).norm(); + if (d < best) { best = d; which = 4; } + } + return which >= 0; +} + +// Drag a handle: closest point of the mouse ray to the plane axis (u or v) through the origin, +// |param| -> new half-extent, doubled to the full size. +void DesignSketchTool::drag_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + if (which == 4) { // offset arrow: drag along base normal + const Vec3d e = m_dz_normal; // signed offset, may go negative + const Vec3d w0 = m_dz_anchor - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return; // camera ∥ normal: leave offset as-is + m_dz_offset = (b * ee - c * dd) / denom; // signed distance along the normal + m_dz_plane.origin = m_dz_anchor + m_dz_normal * m_dz_offset; // rectangle follows live + if (on_datum_offset_changed) on_datum_offset_changed(m_dz_offset); + return; + } + const bool uaxis = (which == 0 || which == 1); + const Vec3d e = (uaxis ? m_dz_plane.x_axis : m_dz_plane.y_axis).normalized(); + const Vec3d base = m_dz_plane.origin; + const Vec3d w0 = base - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave size as-is + const double s = (b * ee - c * dd) / denom; // signed coord along e of closest pt + const double size = std::max(2.0, 2.0 * std::abs(s)); + if (uaxis) m_dz_usize = size; else m_dz_vsize = size; + if (on_datum_size_changed) on_datum_size_changed(m_dz_usize, m_dz_vsize); +} + +// ---- Helix gizmo (plane-anchored curve + 3 drag handles) -------------------------------- +void DesignSketchTool::set_helix_gizmo(const SketchPlane& plane, double radius, double pitch, + double height, double taper, bool left_handed) +{ + m_hx_active = true; + m_hx_plane = plane; + m_hx_radius = radius; + m_hx_pitch = pitch; + m_hx_height = height; + m_hx_taper = taper; + m_hx_left = left_handed; +} + +void DesignSketchTool::clear_helix_gizmo() +{ + m_hx_active = false; + m_hx_drag = -1; +} + +// Curve point at parameter t (t in turns): angle 2*pi*t, negated when left-handed, rising one +// pitch per turn along the plane normal. +// +// Taper is an ANGLE IN DEGREES and must be read the way the kernel reads it. CadDocument's +// helix_spine() builds a Geom_ConicalSurface of half-angle taper and computes the top radius as +// R + H*tan(taper), so the radius grows with the HEIGHT RISEN, not as a fraction of R consumed +// over the turn count. Getting that wrong draws a preview that collapses to a point for any +// non-zero taper while the committed feature is fine — a preview that lies is worse than none. +Vec3d DesignSketchTool::helix_point(double t) const +{ + const Vec3d O = m_hx_plane.origin; + const Vec3d n = m_hx_plane.normal.normalized(); + const Vec3d u = m_hx_plane.x_axis.normalized(); + const Vec3d v = m_hx_plane.y_axis.normalized(); + const double a = (m_hx_left ? -1.0 : 1.0) * 2.0 * M_PI * t; + const double z = m_hx_pitch * t; // height risen at this parameter + double rt = m_hx_radius + z * std::tan(m_hx_taper * M_PI / 180.0); + // The kernel REFUSES a taper that drives the radius negative before the full height; the + // preview shows it collapsing instead, so the user can see which value did it. + if (rt < 0.0) rt = 0.0; + return O + u * (rt * std::cos(a)) + v * (rt * std::sin(a)) + n * z; +} + +// Draw the live helix as a connected camera-facing ribbon, a dim axis line, and three square +// handles (radius on the base circle, height on the axis top, pitch at the end of the first turn). +void DesignSketchTool::render_helix_gizmo() +{ + if (!m_hx_active) return; + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d vd = cam.get_dir_forward(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double hs = 6.0 * upp; // handle half-size (~6 px) + const double hw = 1.5 * upp; // ribbon half-width + const Vec3d O = m_hx_plane.origin; + const Vec3d n = m_hx_plane.normal.normalized(); + const double turns = m_hx_pitch > 1e-9 ? m_hx_height / m_hx_pitch : 0.0; + + // Curve ribbon: connected thin camera-facing quads over t in [0, turns]. + const int segs = std::min(2048, std::max(48, int(turns * 32.0))); + GLModel::Geometry curve; curve.format = { EPT::Triangles, EVL::P3 }; + unsigned int cb = 0; + auto add_seg = [&](const Vec3d& a, const Vec3d& b) { + Vec3d dir = b - a; if (dir.norm() < 1e-9) return; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(up); + if (off.norm() < 1e-9) return; + off.normalize(); off *= hw; + curve.add_vertex((Vec3f)(a + off).cast()); curve.add_vertex((Vec3f)(b + off).cast()); + curve.add_vertex((Vec3f)(b - off).cast()); curve.add_vertex((Vec3f)(a - off).cast()); + curve.add_triangle(cb, cb + 1, cb + 2); curve.add_triangle(cb, cb + 2, cb + 3); cb += 4; + }; + for (int i = 0; i < segs; ++i) + add_seg(helix_point(turns * i / segs), helix_point(turns * (i + 1) / segs)); + + // Axis: a dim line from the plane origin to the top of the helix. + const Vec3d top = O + n * m_hx_height; + GLModel::Geometry axis; axis.format = { EPT::Triangles, EVL::P3 }; + { + Vec3d dir = top - O; + if (dir.norm() > 1e-9) { + dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(up); + if (off.norm() > 1e-9) { + off.normalize(); off *= hw * 0.6; + axis.add_vertex((Vec3f)(O + off).cast()); axis.add_vertex((Vec3f)(top + off).cast()); + axis.add_vertex((Vec3f)(top - off).cast()); axis.add_vertex((Vec3f)(O - off).cast()); + axis.add_triangle(0, 1, 2); axis.add_triangle(0, 2, 3); + } + } + } + + // Three handles: 0=radius (t=0), 1=height (axis top), 2=pitch (end of first turn, or the + // whole curve if shorter than one turn so the handle never floats off a missing curve). + const Vec3d hpts[3] = { helix_point(0.0), top, + helix_point(m_hx_height < m_hx_pitch ? turns : 1.0) }; + const ColorRGBA hcol[3] = { ColorRGBA(1.0f, 0.72f, 0.28f, 1.0f), // radius — amber + ColorRGBA(0.45f, 0.86f, 1.0f, 1.0f), // height — cyan + ColorRGBA(0.30f, 0.80f, 0.34f, 1.0f) }; // pitch — green + const ColorRGBA hot(1.0f, 0.85f, 0.2f, 1.0f); + + glsafe(::glDisable(GL_DEPTH_TEST)); + if (cb > 0) { + GLModel m; m.init_from(std::move(curve)); + m.set_color(ColorRGBA(1.0f, 0.62f, 0.16f, 0.9f)); // CAD amber helix curve + m.render(); + } + if (!axis.vertices.empty()) { + GLModel m; m.init_from(std::move(axis)); + m.set_color(ColorRGBA(0.42f, 0.46f, 0.52f, 0.55f)); // dim grey axis + m.render(); + } + for (int i = 0; i < 3; ++i) { + const Vec3d ctr = hpts[i]; + const Vec3d q0 = ctr - right * hs - up * hs, q1 = ctr + right * hs - up * hs, + q2 = ctr + right * hs + up * hs, q3 = ctr - right * hs + up * hs; + GLModel::Geometry sq; sq.format = { EPT::Triangles, EVL::P3 }; + sq.add_vertex((Vec3f)q0.cast()); sq.add_vertex((Vec3f)q1.cast()); + sq.add_vertex((Vec3f)q2.cast()); sq.add_vertex((Vec3f)q3.cast()); + sq.add_triangle(0, 1, 2); sq.add_triangle(0, 2, 3); + GLModel m; m.init_from(std::move(sq)); + m.set_color(m_hx_drag == i ? hot : hcol[i]); + m.render(); + } +} + +bool DesignSketchTool::hit_test_helix_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const +{ + if (!m_hx_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double tol = 9.0 / std::max(cam.get_zoom(), 1e-6); // ~9 px in world units + const Vec3d O = m_hx_plane.origin; + const Vec3d n = m_hx_plane.normal.normalized(); + const double turns = m_hx_pitch > 1e-9 ? m_hx_height / m_hx_pitch : 0.0; + const Vec3d hpts[3] = { helix_point(0.0), O + n * m_hx_height, + helix_point(m_hx_height < m_hx_pitch ? turns : 1.0) }; + double best = tol; which = -1; + for (int i = 0; i < 3; ++i) { + const Vec3d w = hpts[i] - ro; + const double t = w.dot(rd) / std::max(rd.dot(rd), 1e-12); + const double d = (w - rd * t).norm(); + if (d < best) { best = d; which = i; } + } + return which >= 0; +} + +// Drag a handle: radius = cursor's in-plane distance from the origin, height/pitch = the signed +// distance of the cursor's closest axis point. All fire the full (radius, pitch, height) triple. +void DesignSketchTool::drag_helix_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const SketchPlane& p = m_hx_plane; + const Vec3d O = p.origin, n = p.normal.normalized(); + + if (which == 0) { // radius: in-plane distance from O + const Vec2d lp = p.project(ro, rd); + m_hx_radius = std::max(0.01, lp.norm()); + } else { // height/pitch: signed distance along n + const Vec3d e = n; + const Vec3d w0 = O - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave value as-is + const double s = (b * ee - c * dd) / denom; // signed distance along the axis + if (which == 1) m_hx_height = std::max(0.0, s); + else m_hx_pitch = std::max(0.01, s); // zero/negative pitch divides by zero + } + if (on_helix_changed) on_helix_changed(m_hx_radius, m_hx_pitch, m_hx_height); +} + +// ---- Rib thickness gizmo (in-plane slab footprint + 2 symmetric handles) ----------------- +void DesignSketchTool::set_rib_gizmo(const SketchPlane& plane, const Vec2d& p0, const Vec2d& p1, + double thickness) +{ + m_rb_active = true; + m_rb_plane = plane; + m_rb_p0 = p0; + m_rb_p1 = p1; + m_rb_thickness = thickness; +} + +void DesignSketchTool::clear_rib_gizmo() +{ + m_rb_active = false; + m_rb_drag = -1; +} + +// Resolve the rib line's in-plane frame: unit direction d, perpendicular perp, midpoint, and +// half-thickness. A degenerate line (zero length) has no direction to grow a slab perpendicular +// to — return false so the caller draws nothing and never divides by zero. +static bool rib_frame(const Vec2d& p0, const Vec2d& p1, double thickness, + Vec2d& d, Vec2d& perp, Vec2d& mid, double& half) +{ + const Vec2d seg = p1 - p0; + const double len = seg.norm(); + if (len < 1e-9) return false; + d = seg / len; + perp = Vec2d(-d.y(), d.x()); + mid = 0.5 * (p0 + p1); + half = thickness * 0.5; + return true; +} + +// Draw the rib slab's actual footprint (the rectangle p0±perp·half, p1±perp·half) as a thin +// closed ribbon plus two square handles at mid ± perp·half. Both handles sit at half-thickness, +// so a drag on either expresses the full thickness symmetrically. +void DesignSketchTool::render_rib_gizmo() +{ + if (!m_rb_active) return; + Vec2d d, perp, mid; double half; + if (!rib_frame(m_rb_p0, m_rb_p1, m_rb_thickness, d, perp, mid, half)) return; + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d vd = cam.get_dir_forward(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double hs = 6.0 * upp; // handle half-size (~6 px) + const double hw = 1.5 * upp; // ribbon half-width + + // Slab footprint outline (4 thin camera-facing ribbons). + const Vec3d c[4] = { m_rb_plane.to_world(m_rb_p0 + perp * half), + m_rb_plane.to_world(m_rb_p1 + perp * half), + m_rb_plane.to_world(m_rb_p1 - perp * half), + m_rb_plane.to_world(m_rb_p0 - perp * half) }; + GLModel::Geometry border; border.format = { EPT::Triangles, EVL::P3 }; + unsigned int bb = 0; + for (int s = 0; s < 4; ++s) { + const Vec3d a = c[s], b = c[(s + 1) & 3]; + Vec3d dir = b - a; if (dir.norm() < 1e-9) continue; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) off = dir.cross(up); + if (off.norm() < 1e-9) continue; + off.normalize(); off *= hw; + border.add_vertex((Vec3f)(a + off).cast()); border.add_vertex((Vec3f)(b + off).cast()); + border.add_vertex((Vec3f)(b - off).cast()); border.add_vertex((Vec3f)(a - off).cast()); + border.add_triangle(bb, bb + 1, bb + 2); border.add_triangle(bb, bb + 2, bb + 3); bb += 4; + } + + // Two handles: 0 = +perp side, 1 = -perp side (both at half-thickness). + const Vec3d hpts[2] = { m_rb_plane.to_world(mid + perp * half), + m_rb_plane.to_world(mid - perp * half) }; + const ColorRGBA hot(1.0f, 0.85f, 0.2f, 1.0f); + + glsafe(::glDisable(GL_DEPTH_TEST)); + if (bb > 0) { + GLModel m; m.init_from(std::move(border)); + m.set_color(ColorRGBA(1.0f, 0.62f, 0.16f, 0.9f)); // CAD amber slab footprint + m.render(); + } + for (int i = 0; i < 2; ++i) { + const Vec3d ctr = hpts[i]; + const Vec3d q0 = ctr - right * hs - up * hs, q1 = ctr + right * hs - up * hs, + q2 = ctr + right * hs + up * hs, q3 = ctr - right * hs + up * hs; + GLModel::Geometry sq; sq.format = { EPT::Triangles, EVL::P3 }; + sq.add_vertex((Vec3f)q0.cast()); sq.add_vertex((Vec3f)q1.cast()); + sq.add_vertex((Vec3f)q2.cast()); sq.add_vertex((Vec3f)q3.cast()); + sq.add_triangle(0, 1, 2); sq.add_triangle(0, 2, 3); + GLModel m; m.init_from(std::move(sq)); + m.set_color(m_rb_drag == i ? hot : ColorRGBA(0.30f, 0.80f, 0.34f, 1.0f)); // green + m.render(); + } +} + +bool DesignSketchTool::hit_test_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const +{ + if (!m_rb_active) return false; + Vec2d d, perp, mid; double half; + if (!rib_frame(m_rb_p0, m_rb_p1, m_rb_thickness, d, perp, mid, half)) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double tol = 9.0 / std::max(cam.get_zoom(), 1e-6); // ~9 px in world units + const Vec3d hpts[2] = { m_rb_plane.to_world(mid + perp * half), + m_rb_plane.to_world(mid - perp * half) }; + double best = tol; which = -1; + for (int i = 0; i < 2; ++i) { + const Vec3d w = hpts[i] - ro; + const double t = w.dot(rd) / std::max(rd.dot(rd), 1e-12); + const double dd = (w - rd * t).norm(); + if (dd < best) { best = dd; which = i; } + } + return which >= 0; +} + +// Drag a handle: project the cursor ray onto the rib plane (the same call the helix radius drag +// uses), take the perpendicular distance from the rib LINE to that point, and set the thickness +// to twice that distance — the slab is centred on the line and the handle sits at half-thickness. +void DesignSketchTool::drag_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) +{ + (void)which; // both handles behave identically: a drag on either sets the full thickness + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + Vec2d d, perp, mid; double half; + if (!rib_frame(m_rb_p0, m_rb_p1, m_rb_thickness, d, perp, mid, half)) return; + const Vec2d lp = m_rb_plane.project(ro, rd); + const Vec2d w = lp - m_rb_p0; + const double dist = std::abs(w.x() * d.y() - w.y() * d.x()); // |cross(w, d)| = perp distance + m_rb_thickness = std::max(0.01, 2.0 * dist); // ×2: centred slab, handle at half + if (on_rib_thickness_changed) on_rib_thickness_changed(m_rb_thickness); +} + +// ---- Reference/base planes (Onshape-style default planes) ----------------------------- +void DesignSketchTool::set_base_pick(std::vector planes, std::vector bases, + std::vector labels) +{ + m_dbp_active = !planes.empty(); + m_dbp_planes = std::move(planes); + m_dbp_base = std::move(bases); + m_dbp_labels = std::move(labels); + if (m_dbp_hover >= int(m_dbp_planes.size())) m_dbp_hover = -1; +} + +void DesignSketchTool::clear_base_pick() +{ + m_dbp_active = false; + m_dbp_planes.clear(); + m_dbp_base.clear(); + m_dbp_labels.clear(); + m_dbp_hover = -1; +} + +// Reference planes sit INSIDE the bed. They used to be 0.6 * the bed's larger side, i.e. a square +// 1.2x the plate, and three of them are drawn with depth testing off — so they painted over the +// plate grid from edge to edge and the bed simply was not readable any more. "The planes hide the +// bed", reported exactly that way. Small enough to leave the grid legible around them is also the +// Onshape look this was reaching for: a modest square at the origin, not a tablecloth. +double DesignSketchTool::dbp_half_extent() const +{ + double half = 75.0; + if (auto* pl = wxGetApp().plater()) { + const BoundingBoxf bb = pl->build_volume().bounding_volume2d(); + const double w = bb.max.x() - bb.min.x(), d = bb.max.y() - bb.min.y(); + if (w > 1.0 && d > 1.0) half = 0.3 * std::max(w, d); + } + return half; +} + +// Draw the reference planes as large translucent labelled squares; the hovered one brightens. +void DesignSketchTool::render_base_pick() +{ + if (!m_dbp_active || m_dbp_planes.empty()) return; + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const double H = dbp_half_extent(); + // Onshape-ish per-plane tints: XY blue, XZ green, YZ red (keyed by base index 0/1/2; datums grey). + auto tint = [](int base, bool hot) -> ColorRGBA { + float a = hot ? 0.10f : 0.047f; // base planes kept faint (reduced ~2/3 from 0.30/0.14) + if (base == 0) return ColorRGBA(0.30f, 0.55f, 0.95f, a); + if (base == 1) return ColorRGBA(0.35f, 0.80f, 0.45f, a); + if (base == 2) return ColorRGBA(0.92f, 0.42f, 0.42f, a); + return ColorRGBA(0.70f, 0.72f, 0.78f, a); + }; + glsafe(::glDisable(GL_DEPTH_TEST)); + glsafe(::glDisable(GL_CULL_FACE)); + glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + const SketchPlane saved_plane = m_plane; + for (size_t i = 0; i < m_dbp_planes.size(); ++i) { + const SketchPlane& p = m_dbp_planes[i]; + const Vec3d q0 = p.to_world(Vec2d(-H, -H)), q1 = p.to_world(Vec2d(H, -H)), + q2 = p.to_world(Vec2d(H, H)), q3 = p.to_world(Vec2d(-H, H)); + GLModel::Geometry quad; quad.format = { EPT::Triangles, EVL::P3 }; + quad.add_vertex((Vec3f)q0.cast()); quad.add_vertex((Vec3f)q1.cast()); + quad.add_vertex((Vec3f)q2.cast()); quad.add_vertex((Vec3f)q3.cast()); + quad.add_triangle(0, 1, 2); quad.add_triangle(0, 2, 3); + GLModel m; m.init_from(std::move(quad)); + const bool hot = (int(i) == m_dbp_hover); + const int base = (i < m_dbp_base.size()) ? m_dbp_base[i] : -1; + m.set_color(tint(base, hot)); + m.render(); + + // Label near the top-left corner, drawn in the plane (draw_text lifts through m_plane). + if (i < m_dbp_labels.size() && !m_dbp_labels[i].empty()) { + m_plane = p; + const double th = H * 0.10; + const ColorRGBA lc = tint(base, true); ColorRGBA lcs(lc.r(), lc.g(), lc.b(), 1.0f); + draw_text(m_line_model, m_dbp_labels[i], Vec2d(-H + th * 2.0, H - th * 1.6), th, lcs); + } + } + m_plane = saved_plane; // draw_text renders each label immediately (draw_strokes self-renders) + glsafe(::glDisable(GL_BLEND)); +} + +// Ray-pick the reference planes: intersect the mouse ray with each plane, keep hits inside the +// square, return the index of the nearest by |t|. -1 on miss. +int DesignSketchTool::hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_dbp_active) return -1; + const double H = dbp_half_extent(); + + // THE LABEL WINS, and it has to. Each plane's name is a screen-space chip centred on its + // own in-plane anchor, and it is the one part of a base plane a user aims at deliberately — + // the quads are near-transparent and overlap everywhere. Ray-casting the quads alone made + // the labels pure decoration: on a fresh document at 1920x1060, clicking "XY" reported + // "XZ plane selected", because the XZ quad happens to sit in front at that pixel. Nothing + // about the click was ambiguous to the user; they clicked the word XY. + // Anchor and text height must track render_base_pick's, which is where they are drawn. + const Camera& cam = wxGetApp().plater()->get_camera(); + const double th = H * 0.10; + const Vec2d anchor(-H + th * 2.0, H - th * 1.6); + int lbest = -1; double lbest_d = 1e30; + for (size_t i = 0; i < m_dbp_planes.size(); ++i) { + if (i >= m_dbp_labels.size() || m_dbp_labels[i].empty()) continue; + const wxPoint sp = world_to_screen_px(cam, m_dbp_planes[i].to_world(anchor)); + if (sp.x < 0 && sp.y < 0) continue; // behind the camera + const double dx = std::abs(double(evt.GetX() - sp.x)); + const double dy = std::abs(double(evt.GetY() - sp.y)); + // Chip half-extents in px, scaled like the label itself. Generous rather than tight: + // missing the text and silently selecting a different plane is the failure being fixed. + const double hw = (9.0 + 5.0 * double(m_dbp_labels[i].size())) * double(m_render_scale); + const double hh = 11.0 * double(m_render_scale); + if (dx > hw || dy > hh) continue; + const double d = dx * dx + dy * dy; // nearest label if chips overlap + if (d < lbest_d) { lbest_d = d; lbest = int(i); } + } + if (lbest >= 0) return lbest; + + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + int best = -1; double best_t = 1e30; + for (size_t i = 0; i < m_dbp_planes.size(); ++i) { + const SketchPlane& p = m_dbp_planes[i]; + const double dn = rd.dot(p.normal); + if (std::abs(dn) < 1e-9) continue; // ray parallel to plane + const double t = (p.origin - ro).dot(p.normal) / dn; + if (t < 0) continue; // behind the camera + const Vec3d hit = ro + rd * t; + const Vec3d d = hit - p.origin; + if (std::abs(d.dot(p.x_axis)) > H || std::abs(d.dot(p.y_axis)) > H) continue; + if (t < best_t) { best_t = t; best = int(i); } + } + return best; +} + +// ---- Move-body gizmo (M5) ------------------------------------------------------------- +void DesignSketchTool::set_move_gizmo(int body, const Vec3d& pivot, const Transform3d& base_xform, + double body_radius) +{ + m_mv_active = true; + m_mv_body = body; + m_mv_base = pivot; // body's world centroid at Move-open = rotation pivot + m_mv_base_xform = base_xform; // pose the deltas compose onto + m_mv_offset = Vec3d::Zero(); + m_mv_rot = Eigen::Matrix3d::Identity(); + m_mv_drag = -1; + m_mv_radius = std::max(body_radius, 0.0); +} + +// Gizmo arm length in world mm. Orca's Prepare gizmos size themselves from the selection's +// bounding sphere (GLGizmoRotate3D: m_radius = Offset + sphere radius) so the handles always sit +// clear of the object; a fixed screen-size arm instead collapsed into a tangle buried inside a +// large solid, which is why the rotation rings read as "missing". Same idea here, with a +// screen-space floor so the gizmo stays grabbable on a tiny body or when zoomed far out. +double DesignSketchTool::move_gizmo_arm(const Camera& cam) const +{ + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double screen_min = 70.0 * upp; // never smaller than the old fixed size + return std::max(screen_min, m_mv_radius * 1.25); // 25% clear of the body surface +} + +void DesignSketchTool::clear_move_gizmo() +{ + m_mv_active = false; + m_mv_drag = -1; + m_mv_body = -1; + m_mv_offset = Vec3d::Zero(); + m_mv_rot = Eigen::Matrix3d::Identity(); +} + +// Final body transform = translate(delta) then rotate(delta, about pivot) on the open pose. +Transform3d DesignSketchTool::compose_move_xform() const +{ + const Vec3d p = m_mv_base; + Transform3d R = Transform3d::Identity(); + R.linear() = m_mv_rot; + const Transform3d rot_about_pivot = + Transform3d(Eigen::Translation3d(p)) * R * Transform3d(Eigen::Translation3d(-p)); + return Transform3d(Eigen::Translation3d(m_mv_offset)) * rot_about_pivot * m_mv_base_xform; +} + +// World axis `e` of ring `axis` plus an in-plane orthonormal basis (u,v). +void DesignSketchTool::ring_basis(int axis, Vec3d& e, Vec3d& u, Vec3d& v) const +{ + switch (axis) { + case 0: e = Vec3d::UnitX(); u = Vec3d::UnitY(); v = Vec3d::UnitZ(); break; + case 1: e = Vec3d::UnitY(); u = Vec3d::UnitZ(); v = Vec3d::UnitX(); break; + default:e = Vec3d::UnitZ(); u = Vec3d::UnitX(); v = Vec3d::UnitY(); break; + } +} + +// Three world-axis arrows (X red / Y green / Z blue) from the body centroid + current +// offset, billboarded into a screen-facing frame like the extrude depth arrow. +void DesignSketchTool::render_move_gizmo() +{ + if (!m_mv_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d fwd = cam.get_dir_forward().normalized(); + const Vec3d anchor = m_mv_base + m_mv_offset; + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const double L = move_gizmo_arm(cam); // scales with the body (see move_gizmo_arm) + + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = anchor; bb.x_axis = right; bb.y_axis = up; bb.normal = fwd; + m_plane = bb; + + const Vec3d axes[3] = { Vec3d::UnitX(), Vec3d::UnitY(), Vec3d::UnitZ() }; + const ColorRGBA cols[3] = { ColorRGBA(0.92f, 0.28f, 0.28f, 1.0f), // X red + ColorRGBA(0.30f, 0.80f, 0.34f, 1.0f), // Y green + ColorRGBA(0.32f, 0.55f, 0.95f, 1.0f) }; // Z blue + + glsafe(::glDisable(GL_DEPTH_TEST)); + for (int a = 0; a < 3; ++a) { + const Vec3d tipw = anchor + axes[a] * L; + const Vec2d tip2((tipw - anchor).dot(right), (tipw - anchor).dot(up)); + if (tip2.norm() < 1e-6) continue; // axis ~parallel to view: skip + const Vec2d u = tip2.normalized(); + const Vec2d nrm(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.20, th * 0.9); + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm * (as * 0.5)); + segs.emplace_back(tip2, back - nrm * (as * 0.5)); + draw_strokes(m_mv_arrow_model, segs, std::max(0.7 * upp, 1e-4), cols[a]); + const double off = m_mv_offset[a]; + if (std::abs(off) > 1e-4) { + DimAnnot da; da.kind = DimType::Distance; da.value = std::abs(off); + draw_text(m_line_model, dim_text(da), tip2 + u * (th * 1.4), th, cols[a]); + } + } + m_plane = saved; + + // Three world-axis rotation rings (X/Y/Z), each a circle in the plane perpendicular to + // its axis through the gizmo anchor — drag a ring to rotate the body about that axis. + const double R = 0.83 * move_gizmo_arm(cam); // rings just inside the arrow tips + for (int a = 0; a < 3; ++a) { + Vec3d e, u, v; ring_basis(a, e, u, v); + SketchPlane rp; rp.origin = anchor; rp.x_axis = u; rp.y_axis = v; rp.normal = e; + m_plane = rp; + std::vector> segs; + const int N = 48; + Vec2d prev(R, 0.0); + for (int i = 1; i <= N; ++i) { + const double t = 2.0 * M_PI * double(i) / double(N); + const Vec2d cur(R * std::cos(t), R * std::sin(t)); + segs.emplace_back(prev, cur); prev = cur; + } + draw_strokes(m_mv_arrow_model, segs, std::max(0.55 * upp, 1e-4), cols[a]); + } + m_plane = saved; +} + +// Ray vs each world-axis arrow segment; nearest within ~7 px wins. +bool DesignSketchTool::hit_test_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const +{ + if (!m_mv_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double L = move_gizmo_arm(cam); // must match render_move_gizmo + const Vec3d anchor = m_mv_base + m_mv_offset; + const Vec3d axes[3] = { Vec3d::UnitX(), Vec3d::UnitY(), Vec3d::UnitZ() }; + int best = -1; double bestd = 7.0 * upp; // ~7 px tolerance + for (int a = 0; a < 3; ++a) { + const double d = ray_segment_dist3(ro, rd, anchor, anchor + axes[a] * L); + if (d <= bestd) { bestd = d; best = a; } + } + if (best < 0) return false; + axis = best; return true; +} + +// Skew-line closest point of the mouse ray to the axis line through the ORIGINAL centroid +// -> signed offset along that axis (no clamp; a body can move either way). +void DesignSketchTool::drag_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis) +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Vec3d axes[3] = { Vec3d::UnitX(), Vec3d::UnitY(), Vec3d::UnitZ() }; + const Vec3d e = axes[axis]; + const Vec3d w0 = m_mv_base - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave offset as-is + m_mv_offset[axis] = (b * ee - c * dd) / denom; + if (on_body_move_changed) on_body_move_changed(m_mv_body, compose_move_xform()); +} + +void DesignSketchTool::open_move_editor(int axis) +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, m_mv_offset[axis], "", + [this, axis](double v) { + m_mv_offset[axis] = v; + if (on_body_move_changed) on_body_move_changed(m_mv_body, compose_move_xform()); + }, + []() {}); +} + +// Ray vs each rotation ring (sampled polyline); nearest within ~7 px wins -> axis 0/1/2. +bool DesignSketchTool::hit_test_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const +{ + if (!m_mv_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double R = 0.83 * move_gizmo_arm(cam); // rings just inside the arrow tips + const Vec3d anchor = m_mv_base + m_mv_offset; + int best = -1; double bestd = 7.0 * upp; + const int N = 48; + for (int a = 0; a < 3; ++a) { + Vec3d e, u, v; ring_basis(a, e, u, v); + Vec3d prev = anchor + R * u; + for (int i = 1; i <= N; ++i) { + const double t = 2.0 * M_PI * double(i) / double(N); + const Vec3d cur = anchor + R * (std::cos(t) * u + std::sin(t) * v); + const double d = ray_segment_dist3(ro, rd, prev, cur); + if (d <= bestd) { bestd = d; best = a; } + prev = cur; + } + } + if (best < 0) return false; + axis = best; return true; +} + +// Intersect the mouse ray with ring `axis`'s plane through the anchor -> in-plane angle. +bool DesignSketchTool::arc_mouse_angle(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis, double& ang) const +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + Vec3d e, u, v; ring_basis(axis, e, u, v); + const Vec3d p = m_mv_base + m_mv_offset; + const double denom = rd.dot(e); + if (std::abs(denom) < 1e-9) return false; // ray ∥ ring plane + const Vec3d hit = ro + ((p - ro).dot(e) / denom) * rd; + const Vec3d d = hit - p; + ang = std::atan2(d.dot(v), d.dot(u)); + return true; +} + +// Drag a ring -> rotate the body about that world axis by (mouse angle - grab angle). +void DesignSketchTool::drag_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis) +{ + double ang; + if (!arc_mouse_angle(canvas, evt, axis, ang)) return; + Vec3d e, u, v; ring_basis(axis, e, u, v); + const double delta = ang - m_mv_arc_a0; + m_mv_rot = Eigen::AngleAxisd(delta, e).toRotationMatrix() * m_mv_rot_start; + if (on_body_move_changed) on_body_move_changed(m_mv_body, compose_move_xform()); +} + +// ---- Fillet/Chamfer radius gizmo ------------------------------------------------------ +// Anchor a single radius arrow at the picked edge midpoint (m_sel_edge_pts is already in world +// space, body-transformed), perpendicular to the edge and pointing away from the body centroid — +// the natural outward direction a fillet/chamfer grows. +bool DesignSketchTool::set_fillet_gizmo(const Vec3d& body_centroid, double radius) +{ + if (m_sel_edge_pts.size() < 2) { m_fl_active = false; return false; } + const size_t n = m_sel_edge_pts.size(); + // True geometric midpoint along the edge: a straight edge often samples to just its two + // endpoints, so the middle INDEX would land on an end. Walk the polyline to its half-length. + double total = 0.0; + for (size_t i = 1; i < n; ++i) total += (m_sel_edge_pts[i] - m_sel_edge_pts[i - 1]).norm(); + m_fl_anchor = m_sel_edge_pts[0]; + Vec3d t = m_sel_edge_pts[n - 1] - m_sel_edge_pts[0]; + const double half = 0.5 * total; + double acc = 0.0; + for (size_t i = 1; i < n; ++i) { + const Vec3d seg = m_sel_edge_pts[i] - m_sel_edge_pts[i - 1]; + const double L = seg.norm(); + if (acc + L >= half && L > 1e-12) { + m_fl_anchor = m_sel_edge_pts[i - 1] + seg * ((half - acc) / L); + t = seg; + break; + } + acc += L; + } + if (t.norm() < 1e-9) return false; + t.normalize(); + Vec3d r = m_fl_anchor - body_centroid; // radial offset from the body centre + r -= r.dot(t) * t; // strip the along-edge component + if (r.norm() < 1e-6) { // edge passes through the centroid + r = t.cross(Vec3d::UnitZ()); + if (r.norm() < 1e-6) r = t.cross(Vec3d::UnitX()); + } + m_fl_dir = r.normalized(); + m_fl_radius = std::max(0.01, radius); + if (!m_fl_active) m_fl_drag = false; // re-anchored every preview: preserve an in-progress drag + m_fl_active = true; + return true; +} + +void DesignSketchTool::clear_fillet_gizmo() +{ + m_fl_active = false; + m_fl_drag = false; +} + +// Single billboarded radius arrow from the edge midpoint along m_fl_dir; length = radius (world), +// floored to a grabbable screen size. Label shows the true radius (R-prefixed). +void DesignSketchTool::render_fillet_gizmo() +{ + if (!m_fl_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const double L = std::max(m_fl_radius, 40.0 * upp); // WYSIWYG, floored to a comfortable handle + const Vec3d tipw = m_fl_anchor + m_fl_dir * L; + + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = m_fl_anchor; bb.x_axis = right; bb.y_axis = up; bb.normal = cam.get_dir_forward().normalized(); + m_plane = bb; + const ColorRGBA arrowc(1.0f, 0.62f, 0.16f, 1.0f); // CAD amber + + const Vec2d tip2((tipw - m_fl_anchor).dot(right), (tipw - m_fl_anchor).dot(up)); + if (tip2.norm() > 1e-6) { + const Vec2d u = tip2.normalized(); + const Vec2d nrm(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.20, th * 0.9); + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm * (as * 0.5)); + segs.emplace_back(tip2, back - nrm * (as * 0.5)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_fl_arrow_model, segs, std::max(0.7 * upp, 1e-4), arrowc); + DimAnnot da; da.kind = DimType::Radius; da.value = m_fl_radius; + draw_text(m_line_model, dim_text(da), tip2 + u * (th * 1.4), th, arrowc); + } + m_plane = saved; +} + +// Ray vs the radius arrow segment; ~12 px tolerance over the (floored) handle length. +bool DesignSketchTool::hit_test_fillet_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_fl_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double L = std::max(m_fl_radius, 40.0 * upp); + return ray_segment_dist3(ro, rd, m_fl_anchor, m_fl_anchor + m_fl_dir * L) <= 12.0 * upp; +} + +// Skew-line closest point of the mouse ray to the radius axis -> signed distance along m_fl_dir +// from the anchor. NaN when the camera is ~parallel to the axis (no meaningful projection). +double DesignSketchTool::fillet_axis_proj(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Vec3d e = m_fl_dir; + const Vec3d w0 = m_fl_anchor - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return std::nan(""); + return (b * ee - c * dd) / denom; +} + +// Record the grab reference so the drag is RELATIVE (grab anywhere on the handle without the +// radius snapping to the grab point — important since the handle is floored to a min size). +void DesignSketchTool::start_fillet_drag(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + m_fl_drag = true; + m_fl_press_x = evt.GetX(); + m_fl_press_y = evt.GetY(); + m_fl_grab_radius = m_fl_radius; + const double p = fillet_axis_proj(canvas, evt); + m_fl_grab_proj = std::isnan(p) ? 0.0 : p; +} + +void DesignSketchTool::drag_fillet_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + const double proj = fillet_axis_proj(canvas, evt); + if (std::isnan(proj)) return; // camera ∥ axis: leave radius as-is + m_fl_radius = std::max(0.01, m_fl_grab_radius + (proj - m_fl_grab_proj)); + if (on_fillet_radius_changed) on_fillet_radius_changed(m_fl_radius); +} + +void DesignSketchTool::open_fillet_editor() +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, m_fl_radius, "", + [this](double v) { + m_fl_radius = std::max(0.01, v); + if (on_fillet_radius_changed) on_fillet_radius_changed(m_fl_radius); + }, + []() {}); +} + +// ---- Hole gizmo ----------------------------------------------------------------------------- +// Positioned circular cut: footprint circle on the plane + a radial diameter arrow (plane u-axis) +// + a normal-axis depth arrow (drawn only when !through, matching the kernel's blind cut) + a +// draggable centre marker. The panel re-pushes this every preview, so (like the fillet gizmo) we +// must NOT reset an in-progress drag when already active. +void DesignSketchTool::set_hole_gizmo(const SketchPlane& plane, double x, double y, + double diameter, double depth, bool through) +{ + m_hl_plane = plane; + m_hl_x = x; + m_hl_y = y; + m_hl_diameter = std::max(0.01, diameter); + m_hl_depth = std::max(0.01, depth); + m_hl_through = through; + if (!m_hl_active) m_hl_drag = -1; // re-pushed every preview: preserve an in-progress drag + m_hl_active = true; +} + +void DesignSketchTool::clear_hole_gizmo() +{ + m_hl_active = false; + m_hl_drag = -1; +} + +void DesignSketchTool::set_hole_face_bounds(bool has, double umin, double umax, double vmin, double vmax) +{ + m_hl_has_bounds = has; + m_hl_umin = umin; m_hl_umax = umax; m_hl_vmin = vmin; m_hl_vmax = vmax; +} + +void DesignSketchTool::render_hole_gizmo() +{ + if (!m_hl_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + + const Vec3d centre = m_hl_plane.to_world(Vec2d(m_hl_x, m_hl_y)); + const Vec3d nrm = m_hl_plane.normal.normalized(); + const Vec3d ddir = m_hl_plane.x_axis.normalized(); // diameter arrow runs along plane u + const double r = std::max(0.01, m_hl_diameter * 0.5); + + const SketchPlane saved = m_plane; + + // (1) Footprint circle drawn ON the plane (lifts plane u/v -> world through to_world). + { + m_plane = m_hl_plane; + std::vector> segs; + const int N = 48; + for (int i = 0; i < N; ++i) { + const double a0 = (2.0 * M_PI * i) / N, a1 = (2.0 * M_PI * (i + 1)) / N; + segs.emplace_back(Vec2d(m_hl_x + r * std::cos(a0), m_hl_y + r * std::sin(a0)), + Vec2d(m_hl_x + r * std::cos(a1), m_hl_y + r * std::sin(a1))); + } + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_hl_stroke_model, segs, std::max(0.6 * upp, 1e-4), ColorRGBA(1.0f, 0.62f, 0.16f, 1.0f)); + m_plane = saved; + } + + // (2) Billboarded handles (centre marker + diameter arrow + depth arrow), screen-facing frame + // at the centre so draw_strokes/draw_text read on top regardless of orientation. + SketchPlane bb; bb.origin = centre; bb.x_axis = right; bb.y_axis = up; bb.normal = cam.get_dir_forward().normalized(); + m_plane = bb; + const ColorRGBA amber(1.0f, 0.62f, 0.16f, 1.0f); + const ColorRGBA blue (0.30f, 0.55f, 1.0f, 1.0f); + + auto arrow_to = [&](const Vec3d& tipw, const ColorRGBA& col, const DimAnnot& da) { + const Vec2d tip2((tipw - centre).dot(right), (tipw - centre).dot(up)); + if (tip2.norm() <= 1e-6) return; + const Vec2d u = tip2.normalized(); + const Vec2d nrm2(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.20, th * 0.9); + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm2 * (as * 0.5)); + segs.emplace_back(tip2, back - nrm2 * (as * 0.5)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_hl_stroke_model, segs, std::max(0.7 * upp, 1e-4), col); + draw_text(m_line_model, dim_text(da), tip2 + u * (th * 1.4), th, col); + }; + + // Diameter arrow: WYSIWYG radius, floored to a grabbable handle; label shows Ø (full diameter). + { + const double L = std::max(r, 40.0 * upp); + DimAnnot da; da.kind = DimType::Diameter; da.value = m_hl_diameter; + arrow_to(centre + ddir * L, amber, da); + } + // Depth arrow along +normal (blind cut). Through cuts ignore depth, so skip the arrow then. + if (!m_hl_through) { + const double L = std::max(m_hl_depth, 40.0 * upp); + DimAnnot da; da.kind = DimType::Distance; da.value = m_hl_depth; + arrow_to(centre + nrm * L, blue, da); + } + + m_plane = saved; + + // Move handle: a small 3D CUBE at the hole centre (Orca text/SVG-on-face feel) — grab and drag + // it to slide the hole across the face. Built from the plane axes so it sits flat on the face. + { + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const double hs = 9.0 * upp; // cube half-size (~9 px); hit-test uses the same below + const Vec3d U = ddir * hs, V = m_hl_plane.y_axis.normalized() * hs, Nn = nrm * hs; + // Cube centred EXACTLY on the surface point (= the hole). Depth-test ON occludes the inner + // half inside the solid, so the visible half-cube reads as planted at the hole — no depth-off + // float that looked offset from the on-surface footprint. Hit-test targets the same `centre`. + Vec3d c8[8]; + for (int i = 0; i < 8; ++i) + c8[i] = centre + ((i & 1) ? U : -U) + ((i & 2) ? V : -V) + ((i & 4) ? Nn : -Nn); + // 6 faces (CCW), each as 2 triangles, lightly shaded so the box reads as a cube. + const int faces[6][4] = { {0,1,3,2},{4,6,7,5},{0,4,5,1},{2,3,7,6},{0,2,6,4},{1,5,7,3} }; + const float shade[6] = { 0.78f, 1.0f, 0.86f, 0.92f, 0.70f, 0.96f }; + glsafe(::glEnable(GL_DEPTH_TEST)); + glsafe(::glDisable(GL_CULL_FACE)); + for (int f = 0; f < 6; ++f) { + GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; + for (int k = 0; k < 4; ++k) g.add_vertex((Vec3f)c8[faces[f][k]].cast()); + g.add_triangle(0, 1, 2); g.add_triangle(0, 2, 3); + GLModel m; m.init_from(std::move(g)); + m.set_color(ColorRGBA(1.0f * shade[f], 0.62f * shade[f], 0.16f * shade[f], 1.0f)); + m.render(); + } + } +} + +// Best-matching hole handle under the cursor: centre (0) / diameter (1) / depth (2), or -1. +// Tested by ray-to-segment distance in world; ~12 px tolerance. Centre wins at the shared base. +int DesignSketchTool::hit_test_hole_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_hl_active) return -1; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + + const Vec3d centre = m_hl_plane.to_world(Vec2d(m_hl_x, m_hl_y)); + const Vec3d nrm = m_hl_plane.normal.normalized(); + const Vec3d ddir = m_hl_plane.x_axis.normalized(); + const double rad = std::max(m_hl_diameter * 0.5, 40.0 * upp); + const double depL = std::max(m_hl_depth, 40.0 * upp); + const double tol = 12.0 * upp; + + // Centre wins at the shared base: the move cube straddles the centre, so a grab within the cube + // half-size is a reposition. The arrows are only grabbable along the shaft extending outward, + // which also keeps an edge-on arrow (e.g. depth in top view) from stealing the reposition grab. + if (ray_segment_dist3(ro, rd, centre, centre) <= 9.0 * upp) return 0; // cube half-size + + int best = -1; double bestd = tol; + const double dD = ray_segment_dist3(ro, rd, centre, centre + ddir * rad); + if (dD < bestd) { bestd = dD; best = 1; } + if (!m_hl_through) { + const double dZ = ray_segment_dist3(ro, rd, centre, centre + nrm * depL); + if (dZ < bestd) { bestd = dZ; best = 2; } + } + return best; +} + +// Skew-line closest point of the mouse ray to an axis (anchor + t*dir) -> signed distance along +// dir. NaN when the camera is ~parallel to the axis (no meaningful projection). +double DesignSketchTool::hole_axis_proj(GLCanvas3D& canvas, const wxMouseEvent& evt, + const Vec3d& anchor, const Vec3d& dir) const +{ + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Vec3d e = dir; + const Vec3d w0 = anchor - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + // Relative near-parallel guard: when the camera ray is ~along the axis (e.g. the depth axis in + // top view) denom collapses; a tiny absolute floor lets a huge, unstable projection through. + if (std::abs(denom) < 1e-4 * std::max(a * c, 1e-12)) return std::nan(""); + return (b * ee - c * dd) / denom; +} + +void DesignSketchTool::start_hole_drag(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) +{ + m_hl_drag = which; + m_hl_press_x = evt.GetX(); + m_hl_press_y = evt.GetY(); + const Vec3d centre = m_hl_plane.to_world(Vec2d(m_hl_x, m_hl_y)); + if (which == 0) { + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + m_hl_grab_uv = m_hl_plane.project(r.a, r.b - r.a); + m_hl_grab_x = m_hl_x; + m_hl_grab_y = m_hl_y; + } else if (which == 1 || which == 2) { + const Vec3d dir = (which == 1) ? m_hl_plane.x_axis.normalized() : m_hl_plane.normal.normalized(); + const double p = hole_axis_proj(canvas, evt, centre, dir); + m_hl_grab_proj = std::isnan(p) ? 0.0 : p; + m_hl_grab_val = (which == 1) ? m_hl_diameter * 0.5 : m_hl_depth; + } + // which == 3/4 (X/Y dim labels) are edit-only: a stationary click opens the inline editor. +} + +void DesignSketchTool::drag_hole_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + if (m_hl_drag >= 3) return; // X/Y dim labels are click-to-edit, not drag + if (m_hl_drag == 0) { // reposition centre on the plane + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec2d uv = m_hl_plane.project(r.a, r.b - r.a); + m_hl_x = m_hl_grab_x + (uv.x() - m_hl_grab_uv.x()); + m_hl_y = m_hl_grab_y + (uv.y() - m_hl_grab_uv.y()); + } else { // diameter (1) or depth (2): relative axis drag + const Vec3d centre = m_hl_plane.to_world(Vec2d(m_hl_x, m_hl_y)); + const Vec3d dir = (m_hl_drag == 1) ? m_hl_plane.x_axis.normalized() : m_hl_plane.normal.normalized(); + const double proj = hole_axis_proj(canvas, evt, centre, dir); + if (std::isnan(proj)) return; // camera ∥ axis: leave value as-is + const double v = std::max(0.01, m_hl_grab_val + (proj - m_hl_grab_proj)); + if (m_hl_drag == 1) m_hl_diameter = 2.0 * v; + else m_hl_depth = v; + } + if (on_hole_changed) on_hole_changed(m_hl_x, m_hl_y, m_hl_diameter, m_hl_depth); +} + +void DesignSketchTool::open_hole_editor(int which) +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + if (which == 1) { + on_inline_edit(px, m_hl_diameter, "", + [this](double v) { + m_hl_diameter = std::max(0.01, v); + if (on_hole_changed) on_hole_changed(m_hl_x, m_hl_y, m_hl_diameter, m_hl_depth); + }, + []() {}); + } else if (which == 2) { + on_inline_edit(px, m_hl_depth, "", + [this](double v) { + m_hl_depth = std::max(0.01, v); + if (on_hole_changed) on_hole_changed(m_hl_x, m_hl_y, m_hl_diameter, m_hl_depth); + }, + []() {}); + } else if (which == 3) { // #2 Part B: edit the distance from the u-side + const double ru = m_hl_has_bounds ? m_hl_umin : 0.0; + on_inline_edit(px, m_hl_x - ru, "", + [this, ru](double v) { + m_hl_x = ru + v; + if (on_hole_changed) on_hole_changed(m_hl_x, m_hl_y, m_hl_diameter, m_hl_depth); + }, + []() {}); + } else if (which == 4) { // edit the distance from the v-side + const double rv = m_hl_has_bounds ? m_hl_vmin : 0.0; + on_inline_edit(px, m_hl_y - rv, "", + [this, rv](double v) { + m_hl_y = rv + v; + if (on_hole_changed) on_hole_changed(m_hl_x, m_hl_y, m_hl_diameter, m_hl_depth); + }, + []() {}); + } + // which == 0 (centre): no scalar to edit inline — it's a drag-only reposition handle. +} + +// ---- Thread gizmo --------------------------------------------------------------------------- +// Mirrors the hole gizmo: footprint circle on the plane at the nominal radius + a radial radius +// arrow (R label) + a normal-axis length arrow (always shown) + a draggable centre marker. +// Reuses hole_axis_proj() for the relative axis drags. +void DesignSketchTool::set_thread_gizmo(const SketchPlane& plane, double x, double y, + double radius, double height) +{ + m_th_plane = plane; + m_th_x = x; + m_th_y = y; + m_th_radius = std::max(0.01, radius); + m_th_height = std::max(0.01, height); + if (!m_th_active) m_th_drag = -1; // re-pushed every preview: preserve an in-progress drag + m_th_active = true; +} + +void DesignSketchTool::clear_thread_gizmo() +{ + m_th_active = false; + m_th_drag = -1; +} + +void DesignSketchTool::render_thread_gizmo() +{ + if (!m_th_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + + const Vec3d centre = m_th_plane.to_world(Vec2d(m_th_x, m_th_y)); + const Vec3d nrm = m_th_plane.normal.normalized(); + const Vec3d ddir = m_th_plane.x_axis.normalized(); // radius arrow runs along plane u + const double r = std::max(0.01, m_th_radius); + + const SketchPlane saved = m_plane; + + // (1) Footprint circle on the plane at the nominal radius. + { + m_plane = m_th_plane; + std::vector> segs; + const int N = 48; + for (int i = 0; i < N; ++i) { + const double a0 = (2.0 * M_PI * i) / N, a1 = (2.0 * M_PI * (i + 1)) / N; + segs.emplace_back(Vec2d(m_th_x + r * std::cos(a0), m_th_y + r * std::sin(a0)), + Vec2d(m_th_x + r * std::cos(a1), m_th_y + r * std::sin(a1))); + } + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_th_stroke_model, segs, std::max(0.6 * upp, 1e-4), ColorRGBA(0.55f, 0.80f, 0.30f, 1.0f)); + m_plane = saved; + } + + // (2) Billboarded handles. + SketchPlane bb; bb.origin = centre; bb.x_axis = right; bb.y_axis = up; bb.normal = cam.get_dir_forward().normalized(); + m_plane = bb; + const ColorRGBA green(0.55f, 0.80f, 0.30f, 1.0f); + const ColorRGBA blue (0.30f, 0.55f, 1.0f, 1.0f); + + auto arrow_to = [&](const Vec3d& tipw, const ColorRGBA& col, const DimAnnot& da) { + const Vec2d tip2((tipw - centre).dot(right), (tipw - centre).dot(up)); + if (tip2.norm() <= 1e-6) return; + const Vec2d u = tip2.normalized(); + const Vec2d nrm2(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.20, th * 0.9); + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm2 * (as * 0.5)); + segs.emplace_back(tip2, back - nrm2 * (as * 0.5)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_th_stroke_model, segs, std::max(0.7 * upp, 1e-4), col); + draw_text(m_line_model, dim_text(da), tip2 + u * (th * 1.4), th, col); + }; + + { // Radius arrow (R label), floored to a grabbable handle. + const double L = std::max(r, 40.0 * upp); + DimAnnot da; da.kind = DimType::Radius; da.value = m_th_radius; + arrow_to(centre + ddir * L, green, da); + } + { // Length arrow along +normal. + const double L = std::max(m_th_height, 40.0 * upp); + DimAnnot da; da.kind = DimType::Distance; da.value = m_th_height; + arrow_to(centre + nrm * L, blue, da); + } + { // Centre marker. + const double s = 7.0 * upp; + std::vector> segs; + segs.emplace_back(Vec2d(-s, -s), Vec2d(s, -s)); + segs.emplace_back(Vec2d( s, -s), Vec2d(s, s)); + segs.emplace_back(Vec2d( s, s), Vec2d(-s, s)); + segs.emplace_back(Vec2d(-s, s), Vec2d(-s, -s)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_th_stroke_model, segs, std::max(0.7 * upp, 1e-4), green); + } + + m_plane = saved; +} + +int DesignSketchTool::hit_test_thread_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_th_active) return -1; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + + const Vec3d centre = m_th_plane.to_world(Vec2d(m_th_x, m_th_y)); + const Vec3d nrm = m_th_plane.normal.normalized(); + const Vec3d ddir = m_th_plane.x_axis.normalized(); + const double rad = std::max(m_th_radius, 40.0 * upp); + const double hL = std::max(m_th_height, 40.0 * upp); + const double tol = 12.0 * upp; + + if (ray_segment_dist3(ro, rd, centre, centre) <= tol) return 0; // centre wins at the base + int best = -1; double bestd = tol; + const double dR = ray_segment_dist3(ro, rd, centre, centre + ddir * rad); + if (dR < bestd) { bestd = dR; best = 1; } + const double dH = ray_segment_dist3(ro, rd, centre, centre + nrm * hL); + if (dH < bestd) { bestd = dH; best = 2; } + return best; +} + +void DesignSketchTool::start_thread_drag(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) +{ + m_th_drag = which; + m_th_press_x = evt.GetX(); + m_th_press_y = evt.GetY(); + const Vec3d centre = m_th_plane.to_world(Vec2d(m_th_x, m_th_y)); + if (which == 0) { + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + m_th_grab_uv = m_th_plane.project(r.a, r.b - r.a); + m_th_grab_x = m_th_x; + m_th_grab_y = m_th_y; + } else { + const Vec3d dir = (which == 1) ? m_th_plane.x_axis.normalized() : m_th_plane.normal.normalized(); + const double p = hole_axis_proj(canvas, evt, centre, dir); + m_th_grab_proj = std::isnan(p) ? 0.0 : p; + m_th_grab_val = (which == 1) ? m_th_radius : m_th_height; + } +} + +void DesignSketchTool::drag_thread_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + if (m_th_drag == 0) { + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec2d uv = m_th_plane.project(r.a, r.b - r.a); + m_th_x = m_th_grab_x + (uv.x() - m_th_grab_uv.x()); + m_th_y = m_th_grab_y + (uv.y() - m_th_grab_uv.y()); + } else { + const Vec3d centre = m_th_plane.to_world(Vec2d(m_th_x, m_th_y)); + const Vec3d dir = (m_th_drag == 1) ? m_th_plane.x_axis.normalized() : m_th_plane.normal.normalized(); + const double proj = hole_axis_proj(canvas, evt, centre, dir); + if (std::isnan(proj)) return; + const double v = std::max(0.01, m_th_grab_val + (proj - m_th_grab_proj)); + if (m_th_drag == 1) m_th_radius = v; + else m_th_height = v; + } + if (on_thread_changed) on_thread_changed(m_th_x, m_th_y, m_th_radius, m_th_height); +} + +void DesignSketchTool::open_thread_editor(int which) +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + if (which == 1) { + on_inline_edit(px, m_th_radius, "", + [this](double v) { + m_th_radius = std::max(0.01, v); + if (on_thread_changed) on_thread_changed(m_th_x, m_th_y, m_th_radius, m_th_height); + }, + []() {}); + } else if (which == 2) { + on_inline_edit(px, m_th_height, "", + [this](double v) { + m_th_height = std::max(0.01, v); + if (on_thread_changed) on_thread_changed(m_th_x, m_th_y, m_th_radius, m_th_height); + }, + []() {}); + } +} + +// ---- Shell gizmo ---------------------------------------------------------------------------- +// A single inward thickness arrow at the picked open-face centroid (mirrors the fillet radius +// arrow). RELATIVE drag (like fillet), reusing hole_axis_proj for the projection. +void DesignSketchTool::set_shell_gizmo(const Vec3d& face_centroid, const Vec3d& inward_dir, + double thickness) +{ + m_sh_anchor = face_centroid; + if (inward_dir.norm() > 1e-9) m_sh_dir = inward_dir.normalized(); + m_sh_thickness = std::max(0.01, thickness); + if (!m_sh_active) m_sh_drag = false; // re-pushed every preview: preserve an in-progress drag + m_sh_active = true; +} + +void DesignSketchTool::clear_shell_gizmo() +{ + m_sh_active = false; + m_sh_drag = false; +} + +void DesignSketchTool::render_shell_gizmo() +{ + if (!m_sh_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const double L = std::max(m_sh_thickness, 40.0 * upp); // WYSIWYG, floored to a handle + const Vec3d tipw = m_sh_anchor + m_sh_dir * L; + + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = m_sh_anchor; bb.x_axis = right; bb.y_axis = up; bb.normal = cam.get_dir_forward().normalized(); + m_plane = bb; + const ColorRGBA teal(0.20f, 0.80f, 0.75f, 1.0f); + + const Vec2d tip2((tipw - m_sh_anchor).dot(right), (tipw - m_sh_anchor).dot(up)); + if (tip2.norm() > 1e-6) { + const Vec2d u = tip2.normalized(); + const Vec2d nrm(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.20, th * 0.9); + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm * (as * 0.5)); + segs.emplace_back(tip2, back - nrm * (as * 0.5)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_sh_stroke_model, segs, std::max(0.7 * upp, 1e-4), teal); + DimAnnot da; da.kind = DimType::Distance; da.value = m_sh_thickness; + draw_text(m_line_model, dim_text(da), tip2 + u * (th * 1.4), th, teal); + } + m_plane = saved; +} + +bool DesignSketchTool::hit_test_shell_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_sh_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double L = std::max(m_sh_thickness, 40.0 * upp); + return ray_segment_dist3(ro, rd, m_sh_anchor, m_sh_anchor + m_sh_dir * L) <= 12.0 * upp; +} + +void DesignSketchTool::start_shell_drag(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + m_sh_drag = true; + m_sh_press_x = evt.GetX(); + m_sh_press_y = evt.GetY(); + m_sh_grab_val = m_sh_thickness; + const double p = hole_axis_proj(canvas, evt, m_sh_anchor, m_sh_dir); + m_sh_grab_proj = std::isnan(p) ? 0.0 : p; +} + +void DesignSketchTool::drag_shell_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + const double proj = hole_axis_proj(canvas, evt, m_sh_anchor, m_sh_dir); + if (std::isnan(proj)) return; + m_sh_thickness = std::max(0.01, m_sh_grab_val + (proj - m_sh_grab_proj)); + if (on_shell_thickness_changed) on_shell_thickness_changed(m_sh_thickness); +} + +void DesignSketchTool::open_shell_editor() +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, m_sh_thickness, "", + [this](double v) { + m_sh_thickness = std::max(0.01, v); + if (on_shell_thickness_changed) on_shell_thickness_changed(m_sh_thickness); + }, + []() {}); +} + +// ---- Revolve angle-arc gizmo ---------------------------------------------------------------- +// Arc in the revolve plane (perpendicular to the axis) at the profile's radius. The arc center is +// the projection of the profile centroid onto the axis, so the arc rides at the profile's height. +// The yaxis sense follows m_rv_flip, matching the kernel's negative-angle reversed sweep. +static Vec3d rv_yaxis(const Vec3d& axis, const Vec3d& ref, bool flip) +{ + Vec3d y = axis.cross(ref); + if (y.norm() < 1e-9) return ref; // degenerate; never used (ref ⟂ axis by construction) + y.normalize(); + return flip ? -y : y; +} + +// Arc in the draft plane (perpendicular to the world +Z axis through the face centroid). +// The arc sweeps from angle 0 to m_dr_angle, representing the taper amount. +static Vec3d dr_yaxis(const Vec3d& axis, const Vec3d& ref) +{ + Vec3d y = axis.cross(ref); + if (y.norm() < 1e-9) return ref; + y.normalize(); + return y; +} + +void DesignSketchTool::set_draft_gizmo(const Vec3d& face_centroid, const Vec3d& face_normal, double angle) +{ + m_dr_center = face_centroid; + m_dr_angle = std::min(89.0, std::max(-89.0, angle)); + m_dr_axis = Vec3d::UnitZ(); // pull direction is world +Z + Vec3d ref = face_normal - face_normal.dot(m_dr_axis) * m_dr_axis; // horizontal component + if (ref.norm() < 1e-6) ref = Vec3d::UnitX(); // fallback: face normal is parallel to Z + m_dr_ref = ref / ref.norm(); + m_dr_radius = 12.0; // fixed world-size manipulator + if (!m_dr_active) m_dr_drag = false; + m_dr_active = true; +} + +void DesignSketchTool::clear_draft_gizmo() +{ + m_dr_active = false; + m_dr_drag = false; +} + +void DesignSketchTool::render_draft_gizmo() +{ + if (!m_dr_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const Vec3d yax = dr_yaxis(m_dr_axis, m_dr_ref); + + const SketchPlane saved = m_plane; + SketchPlane rp; rp.origin = m_dr_center; rp.x_axis = m_dr_ref; rp.y_axis = yax; rp.normal = m_dr_axis; + m_plane = rp; + const ColorRGBA arcc(0.15f, 0.92f, 1.0f, 1.0f); + const double r = m_dr_radius; + // Draft angle can be negative: sweep counter-clockwise (positive) or clockwise (negative) + const double a = m_dr_angle * M_PI / 180.0; + const int N = std::max(8, int(std::abs(a) / (M_PI / 32.0))); // ~ every 5.6° + + std::vector> segs; + Vec2d prev(r, 0.0); + for (int i = 1; i <= N; ++i) { + const double t = a * double(i) / double(N); + const Vec2d cur(r * std::cos(t), r * std::sin(t)); + segs.emplace_back(prev, cur); + prev = cur; + } + const Vec2d tip(r * std::cos(a), r * std::sin(a)); + segs.emplace_back(Vec2d(0, 0), Vec2d(r, 0)); // spoke at angle 0 + segs.emplace_back(Vec2d(0, 0), tip); // spoke at the swept angle (the handle) + const Vec2d tang(-std::sin(a), std::cos(a)); + const Vec2d radial = tip.normalized(); + const double as = std::max(r * 0.14, th); + segs.emplace_back(tip, tip - tang * as - radial * (as * 0.5)); + segs.emplace_back(tip, tip - tang * as + radial * (as * 0.5)); + const double hs = std::max(th * 0.8, r * 0.05); + const Vec2d du = radial * hs, dv = Vec2d(-radial.y(), radial.x()) * hs; + segs.emplace_back(tip + du, tip + dv); + segs.emplace_back(tip + dv, tip - du); + segs.emplace_back(tip - du, tip - dv); + segs.emplace_back(tip - dv, tip + du); + + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_dr_stroke_model, segs, std::max(0.8 * upp, 1e-4), arcc); + DimAnnot da; da.kind = DimType::Angle; da.value = m_dr_angle; + draw_text(m_line_model, dim_text(da), tip * 1.14, th, arcc); + m_plane = saved; +} + +bool DesignSketchTool::hit_test_draft_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_dr_active) return false; + const Linef3 ray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = ray.a, rd = ray.b - ray.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const Vec3d yax = dr_yaxis(m_dr_axis, m_dr_ref); + const double a = m_dr_angle * M_PI / 180.0; + const double tol = 16.0 * upp; + auto ray_pt = [&](const Vec3d& p) { + const double t = (p - ro).dot(rd) / std::max(rd.dot(rd), 1e-12); + return (p - (ro + t * rd)).norm(); + }; + const Vec3d tip = m_dr_center + m_dr_radius * (std::cos(a) * m_dr_ref + std::sin(a) * yax); + const Vec3d ref = m_dr_center + m_dr_radius * m_dr_ref; // angle-0 spoke end + const int N = 48; + for (int i = 0; i <= N; ++i) { + const double f = double(i) / double(N); + const double th = a * f; + const Vec3d arc = m_dr_center + m_dr_radius * (std::cos(th) * m_dr_ref + std::sin(th) * yax); + if (ray_pt(arc) <= tol) return true; + if (ray_pt(m_dr_center + f * (tip - m_dr_center)) <= tol) return true; + if (ray_pt(m_dr_center + f * (ref - m_dr_center)) <= tol) return true; + } + return false; +} + +void DesignSketchTool::drag_draft_arc(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + const Linef3 ray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = ray.a, rd = ray.b - ray.a; + const double denom = rd.dot(m_dr_axis); + if (std::abs(denom) < 1e-9) return; + const double t = (m_dr_center - ro).dot(m_dr_axis) / denom; + const Vec3d p = ro + t * rd; + const Vec3d yax = dr_yaxis(m_dr_axis, m_dr_ref); + const double u = (p - m_dr_center).dot(m_dr_ref); + const double v = (p - m_dr_center).dot(yax); + double deg = std::atan2(v, u) * 180.0 / M_PI; + deg = std::min(89.0, std::max(-89.0, deg)); + m_dr_angle = deg; + if (m_on_draft_angle_changed) m_on_draft_angle_changed(deg); +} + +// ---- Cut gizmo (plane normal arrow + wire rectangle preview) -------------------------------- +// Arrow: shaft + arrowhead billboarded along the cut-plane normal from the projected body +// centre, with a signed Distance label = offset. Drag is RELATIVE via hole_axis_proj. +// Rectangle: 4 segments in the cut plane at the current offset, sized to the target body. + +void DesignSketchTool::set_cut_gizmo(const SketchPlane& plane, double offset, const Vec3d& body_center, double half_extent) +{ + m_ct_n = plane.normal.normalized(); + m_ct_u = plane.x_axis.normalized(); + m_ct_v = plane.y_axis.normalized(); + Vec3d rel = body_center - plane.origin; + m_ct_base = plane.origin + (rel - rel.dot(m_ct_n) * m_ct_n); // body center projected into the cut plane + m_ct_offset = offset; + m_ct_half = std::max(half_extent, 10.0); + if (!m_ct_active) m_ct_drag = false; + m_ct_active = true; +} + +void DesignSketchTool::clear_cut_gizmo() +{ + m_ct_active = false; + m_ct_drag = false; +} + +void DesignSketchTool::render_cut_gizmo() +{ + if (!m_ct_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const ColorRGBA teal(0.20f, 0.80f, 0.75f, 1.0f); + + // ---- Wire rectangle in the cut plane ---- + { + const Vec3d cutpos = m_ct_base + m_ct_offset * m_ct_n; + const SketchPlane saved = m_plane; + SketchPlane rp; rp.origin = cutpos; rp.x_axis = m_ct_u; rp.y_axis = m_ct_v; rp.normal = m_ct_n; + m_plane = rp; + const double h = m_ct_half; + std::vector> segs; + segs.emplace_back(Vec2d(-h, -h), Vec2d( h, -h)); + segs.emplace_back(Vec2d( h, -h), Vec2d( h, h)); + segs.emplace_back(Vec2d( h, h), Vec2d(-h, h)); + segs.emplace_back(Vec2d(-h, h), Vec2d(-h, -h)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_ct_rect_model, segs, std::max(0.7 * upp, 1e-4), teal); + m_plane = saved; + } + + // ---- Offset arrow (billboarded, clone of render_shell_gizmo) ---- + { + const double L_sign = (std::abs(m_ct_offset) < 40.0 * upp) + ? std::copysign(40.0 * upp, (m_ct_offset == 0.0 ? 1.0 : m_ct_offset)) + : m_ct_offset; + const Vec3d tipw = m_ct_base + m_ct_n * L_sign; + + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = m_ct_base; bb.x_axis = right; bb.y_axis = up; + bb.normal = cam.get_dir_forward().normalized(); + m_plane = bb; + + const Vec2d tip2((tipw - m_ct_base).dot(right), (tipw - m_ct_base).dot(up)); + if (tip2.norm() > 1e-6) { + const Vec2d u = tip2.normalized(); + const Vec2d nrm(-u.y(), u.x()); + std::vector> segs; + segs.emplace_back(Vec2d(0, 0), tip2); + const double as = std::max(tip2.norm() * 0.20, th * 0.9); + const Vec2d back = tip2 - u * as; + segs.emplace_back(tip2, back + nrm * (as * 0.5)); + segs.emplace_back(tip2, back - nrm * (as * 0.5)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_ct_stroke_model, segs, std::max(0.7 * upp, 1e-4), teal); + DimAnnot da; da.kind = DimType::Distance; da.value = m_ct_offset; + draw_text(m_line_model, dim_text(da), tip2 + u * (th * 1.4), th, teal); + } + m_plane = saved; + } +} + +bool DesignSketchTool::hit_test_cut_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_ct_active) return false; + const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = r.a, rd = r.b - r.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double L_sign = (std::abs(m_ct_offset) < 40.0 * upp) + ? std::copysign(40.0 * upp, (m_ct_offset == 0.0 ? 1.0 : m_ct_offset)) + : m_ct_offset; + return ray_segment_dist3(ro, rd, m_ct_base, m_ct_base + m_ct_n * L_sign) <= 12.0 * upp; +} + +void DesignSketchTool::start_cut_drag(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + m_ct_drag = true; + m_ct_grab_val = m_ct_offset; + const double p = hole_axis_proj(canvas, evt, m_ct_base, m_ct_n); + m_ct_grab_proj = std::isnan(p) ? 0.0 : p; +} + +void DesignSketchTool::drag_cut_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + const double proj = hole_axis_proj(canvas, evt, m_ct_base, m_ct_n); + if (std::isnan(proj)) return; + m_ct_offset = m_ct_grab_val + (proj - m_ct_grab_proj); + if (m_on_cut_offset_changed) m_on_cut_offset_changed(m_ct_offset); +} + +void DesignSketchTool::set_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, + int axis_sel, double angle, bool flip) +{ + const Vec3d ax = (axis_sel == 1 ? plane.y_axis : plane.x_axis).normalized(); + const Vec3d cw = plane.to_world(centroid); + const double axial = (cw - plane.origin).dot(ax); + m_rv_center = plane.origin + axial * ax; // foot of the centroid on the axis line + Vec3d ref = cw - m_rv_center; // perpendicular to ax by construction + double r = ref.norm(); + if (r < 1e-6) { ref = plane.normal.normalized(); r = std::max(plane.normal.norm(), 1.0); } + m_rv_axis = ax; + m_rv_ref = ref / ref.norm(); + m_rv_radius = std::max(r, 1.0); + m_rv_angle = std::min(360.0, std::max(1.0, std::abs(angle))); + m_rv_flip = flip; + if (!m_rv_active) m_rv_drag = false; // re-pushed every preview: keep an in-progress drag + m_rv_active = true; +} + +void DesignSketchTool::clear_revolve_gizmo() +{ + m_rv_active = false; + m_rv_drag = false; +} + +void DesignSketchTool::render_revolve_gizmo() +{ + if (!m_rv_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const Vec3d yax = rv_yaxis(m_rv_axis, m_rv_ref, m_rv_flip); + + // The arc lives in the true revolve plane (NOT billboarded) so the sweep reads in 3D. + const SketchPlane saved = m_plane; + SketchPlane rp; rp.origin = m_rv_center; rp.x_axis = m_rv_ref; rp.y_axis = yax; rp.normal = m_rv_axis; + m_plane = rp; + // Vivid cyan: the revolve ghost is amber, so the arc manipulator must contrast with it + // (it overlaps the solid, unlike the extrude depth-arrow which points away into empty space). + const ColorRGBA arcc(0.15f, 0.92f, 1.0f, 1.0f); + const double r = m_rv_radius; + const double a = m_rv_angle * M_PI / 180.0; + const int N = std::max(8, int(a / (M_PI / 32.0))); // ~ every 5.6° + + std::vector> segs; + Vec2d prev(r, 0.0); + for (int i = 1; i <= N; ++i) { + const double t = a * double(i) / double(N); + const Vec2d cur(r * std::cos(t), r * std::sin(t)); + segs.emplace_back(prev, cur); + prev = cur; + } + const Vec2d tip(r * std::cos(a), r * std::sin(a)); + segs.emplace_back(Vec2d(0, 0), Vec2d(r, 0)); // spoke at angle 0 + segs.emplace_back(Vec2d(0, 0), tip); // spoke at the swept angle (the handle) + // Arrowhead at the tip, pointing along the sweep tangent (-sin,cos) rotated by a. + const Vec2d tang(-std::sin(a), std::cos(a)); + const Vec2d radial = tip.normalized(); + const double as = std::max(r * 0.14, th); + segs.emplace_back(tip, tip - tang * as - radial * (as * 0.5)); + segs.emplace_back(tip, tip - tang * as + radial * (as * 0.5)); + // A diamond grab-handle at the tip so the draggable target is unmistakable. + const double hs = std::max(th * 0.8, r * 0.05); + const Vec2d du = radial * hs, dv = Vec2d(-radial.y(), radial.x()) * hs; + segs.emplace_back(tip + du, tip + dv); + segs.emplace_back(tip + dv, tip - du); + segs.emplace_back(tip - du, tip - dv); + segs.emplace_back(tip - dv, tip + du); + + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_rv_stroke_model, segs, std::max(0.8 * upp, 1e-4), arcc); + DimAnnot da; da.kind = DimType::Angle; da.value = m_rv_angle; + draw_text(m_line_model, dim_text(da), tip * 1.14, th, arcc); + m_plane = saved; +} + +bool DesignSketchTool::hit_test_revolve_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_rv_active) return false; + const Linef3 ray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = ray.a, rd = ray.b - ray.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const Vec3d yax = rv_yaxis(m_rv_axis, m_rv_ref, m_rv_flip); + const double a = m_rv_angle * M_PI / 180.0; + const double tol = 16.0 * upp; + // Grab anywhere on the whole gizmo (Onshape-style): the arc curve OR either radial spoke. + // Take the nearest ray-to-point distance over a dense sampling. + auto ray_pt = [&](const Vec3d& p) { + const double t = (p - ro).dot(rd) / std::max(rd.dot(rd), 1e-12); + return (p - (ro + t * rd)).norm(); + }; + const Vec3d tip = m_rv_center + m_rv_radius * (std::cos(a) * m_rv_ref + std::sin(a) * yax); + const Vec3d ref = m_rv_center + m_rv_radius * m_rv_ref; // angle-0 spoke end + const int N = 48; + for (int i = 0; i <= N; ++i) { + const double f = double(i) / double(N); + const double th = a * f; + const Vec3d arc = m_rv_center + m_rv_radius * (std::cos(th) * m_rv_ref + std::sin(th) * yax); + if (ray_pt(arc) <= tol) return true; // on the arc curve + if (ray_pt(m_rv_center + f * (tip - m_rv_center)) <= tol) return true; // on the swept spoke + if (ray_pt(m_rv_center + f * (ref - m_rv_center)) <= tol) return true; // on the ref spoke + } + return false; +} + +// Intersect the mouse ray with the revolve plane, read its angle around the center -> sweep angle. +void DesignSketchTool::drag_revolve_arc(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + const Linef3 ray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = ray.a, rd = ray.b - ray.a; + const double denom = rd.dot(m_rv_axis); + if (std::abs(denom) < 1e-9) return; // ray ∥ revolve plane: leave angle as-is + const double t = (m_rv_center - ro).dot(m_rv_axis) / denom; + const Vec3d p = ro + t * rd; + const Vec3d yax = rv_yaxis(m_rv_axis, m_rv_ref, m_rv_flip); + const double u = (p - m_rv_center).dot(m_rv_ref); + const double v = (p - m_rv_center).dot(yax); + double deg = std::atan2(v, u) * 180.0 / M_PI; + if (deg < 0.0) deg += 360.0; + deg = std::min(360.0, std::max(1.0, deg)); + m_rv_angle = deg; + if (on_revolve_angle_changed) on_revolve_angle_changed(deg); +} + +void DesignSketchTool::open_revolve_editor() +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, m_rv_angle, "", + [this](double v) { + m_rv_angle = std::min(360.0, std::max(1.0, v)); + if (on_revolve_angle_changed) on_revolve_angle_changed(m_rv_angle); + }, + []() {}); +} + +// ---- Pattern gizmo (linear spacing arrow | circular angle-arc) ------------------------------ +// Linear: a 3D arrow along the world march axis with a tick at each copy; the diamond at the end +// drags the spacing. Circular: a Revolve-style arc about the plane normal through the plane origin. +void DesignSketchTool::set_pattern_gizmo(const SketchPlane& plane, const Vec3d& body_centroid, + bool circular, int count, int dir, double spacing, double angle) +{ + m_pt_circular = circular; + m_pt_base = body_centroid; + m_pt_count = std::max(1, count); + m_pt_spacing = std::max(0.01, spacing); + m_pt_angle = std::min(360.0, std::max(1.0, angle)); + m_pt_dirw = (dir == 1 ? plane.y_axis : plane.x_axis).normalized(); + m_pt_origin = plane.origin; + m_pt_normal = plane.normal.normalized(); + // Circular arc center = foot of the body centroid on the rotation axis; ref = perpendicular dir. + const double axial = (body_centroid - plane.origin).dot(m_pt_normal); + m_pt_ccenter = plane.origin + axial * m_pt_normal; + Vec3d ref = body_centroid - m_pt_ccenter; + double r = ref.norm(); + if (r < 1e-6) { ref = m_pt_dirw; r = 1.0; } // body centred on the axis: nominal radius + m_pt_cref = ref / ref.norm(); + m_pt_radius = std::max(r, 1.0); + if (!m_pt_active) m_pt_drag = false; // re-pushed every preview: keep an in-progress drag + m_pt_active = true; +} + +void DesignSketchTool::clear_pattern_gizmo() +{ + m_pt_active = false; + m_pt_drag = false; +} + +// Linear arrow span = spacing*(count-1), at least one step so count=1 still shows a direction. +static double pt_linear_len(double spacing, int count) +{ + return std::max(spacing * double(std::max(1, count) - 1), spacing); +} + +void DesignSketchTool::render_pattern_gizmo() +{ + if (!m_pt_active) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double th = std::max(15.0 * upp, 1e-4); + const ColorRGBA col(0.15f, 0.92f, 1.0f, 1.0f); // vivid cyan, matches the gizmo family + const SketchPlane saved = m_plane; + std::vector> segs; + + if (!m_pt_circular) { + // The arrow lives in the true world plane (NOT billboarded) so the march reads in 3D. + const Vec3d perp = m_pt_normal.cross(m_pt_dirw).normalized(); + SketchPlane fp; fp.origin = m_pt_base; fp.x_axis = m_pt_dirw; fp.y_axis = perp; fp.normal = m_pt_normal; + m_plane = fp; + const int copies = std::max(1, m_pt_count); + const double L = pt_linear_len(m_pt_spacing, copies); + segs.emplace_back(Vec2d(0, 0), Vec2d(L, 0)); // shaft + const double as = std::max(L * 0.12, th); // arrowhead + segs.emplace_back(Vec2d(L, 0), Vec2d(L - as, as * 0.5)); + segs.emplace_back(Vec2d(L, 0), Vec2d(L - as, -as * 0.5)); + const double tk = std::max(th, L * 0.05); // copy tick half-height + for (int i = 0; i < copies; ++i) { + const double x = m_pt_spacing * i; + segs.emplace_back(Vec2d(x, -tk), Vec2d(x, tk)); + } + const Vec2d E(L, 0); // diamond grab-handle + const double hs = std::max(th * 0.8, L * 0.04); + segs.emplace_back(E + Vec2d(hs, 0), E + Vec2d(0, hs)); + segs.emplace_back(E + Vec2d(0, hs), E + Vec2d(-hs, 0)); + segs.emplace_back(E + Vec2d(-hs, 0), E + Vec2d(0, -hs)); + segs.emplace_back(E + Vec2d(0, -hs), E + Vec2d(hs, 0)); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_pt_stroke_model, segs, std::max(0.8 * upp, 1e-4), col); + DimAnnot da; da.kind = DimType::Distance; da.value = m_pt_spacing; + draw_text(m_line_model, dim_text(da), Vec2d(m_pt_spacing * 0.5, tk * 1.7), th, col); + m_plane = saved; + return; + } + + // ---- Circular: clone the Revolve arc about the plane normal through the plane origin ---- + const Vec3d yax = m_pt_normal.cross(m_pt_cref).normalized(); + SketchPlane rp; rp.origin = m_pt_ccenter; rp.x_axis = m_pt_cref; rp.y_axis = yax; rp.normal = m_pt_normal; + m_plane = rp; + const double r = m_pt_radius; + const double a = m_pt_angle * M_PI / 180.0; + const int N = std::max(8, int(a / (M_PI / 32.0))); + Vec2d prev(r, 0.0); + for (int i = 1; i <= N; ++i) { + const double t = a * double(i) / double(N); + const Vec2d cur(r * std::cos(t), r * std::sin(t)); + segs.emplace_back(prev, cur); + prev = cur; + } + const Vec2d tip(r * std::cos(a), r * std::sin(a)); + segs.emplace_back(Vec2d(0, 0), Vec2d(r, 0)); // angle-0 spoke + segs.emplace_back(Vec2d(0, 0), tip); // swept spoke (the handle) + const Vec2d tang(-std::sin(a), std::cos(a)); + const Vec2d radial = tip.normalized(); + const double as = std::max(r * 0.14, th); + segs.emplace_back(tip, tip - tang * as - radial * (as * 0.5)); + segs.emplace_back(tip, tip - tang * as + radial * (as * 0.5)); + const double hs = std::max(th * 0.8, r * 0.05); + const Vec2d du = radial * hs, dv = Vec2d(-radial.y(), radial.x()) * hs; + segs.emplace_back(tip + du, tip + dv); + segs.emplace_back(tip + dv, tip - du); + segs.emplace_back(tip - du, tip - dv); + segs.emplace_back(tip - dv, tip + du); + glsafe(::glDisable(GL_DEPTH_TEST)); + draw_strokes(m_pt_stroke_model, segs, std::max(0.8 * upp, 1e-4), col); + DimAnnot da; da.kind = DimType::Angle; da.value = m_pt_angle; + draw_text(m_line_model, dim_text(da), tip * 1.14, th, col); + m_plane = saved; +} + +bool DesignSketchTool::hit_test_pattern_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const +{ + if (!m_pt_active) return false; + const Linef3 ray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = ray.a, rd = ray.b - ray.a; + const Camera& cam = wxGetApp().plater()->get_camera(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); + auto ray_pt = [&](const Vec3d& p) { + const double t = (p - ro).dot(rd) / std::max(rd.dot(rd), 1e-12); + return (p - (ro + t * rd)).norm(); + }; + if (!m_pt_circular) { + const double tol = 8.0 * upp; + const double L = pt_linear_len(m_pt_spacing, m_pt_count); + return ray_segment_dist3(ro, rd, m_pt_base, m_pt_base + m_pt_dirw * L) <= tol; + } + const double tol = 16.0 * upp; + const Vec3d yax = m_pt_normal.cross(m_pt_cref).normalized(); + const double a = m_pt_angle * M_PI / 180.0; + const Vec3d tip = m_pt_ccenter + m_pt_radius * (std::cos(a) * m_pt_cref + std::sin(a) * yax); + const Vec3d ref = m_pt_ccenter + m_pt_radius * m_pt_cref; + const int N = 48; + for (int i = 0; i <= N; ++i) { + const double f = double(i) / double(N); + const Vec3d arc = m_pt_ccenter + m_pt_radius * (std::cos(a * f) * m_pt_cref + std::sin(a * f) * yax); + if (ray_pt(arc) <= tol) return true; + if (ray_pt(m_pt_ccenter + f * (tip - m_pt_ccenter)) <= tol) return true; + if (ray_pt(m_pt_ccenter + f * (ref - m_pt_ccenter)) <= tol) return true; + } + return false; +} + +void DesignSketchTool::drag_pattern_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) +{ + const Linef3 ray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Vec3d ro = ray.a, rd = ray.b - ray.a; + if (!m_pt_circular) { + // Skew-line closest point of the mouse ray to the march axis -> span -> spacing. + const Vec3d e = m_pt_dirw; + const Vec3d w0 = m_pt_base - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave spacing as-is + const double span = std::max(0.01, (b * ee - c * dd) / denom); + const int div = std::max(1, m_pt_count - 1); + m_pt_spacing = std::max(0.1, span / double(div)); + if (on_pattern_changed) on_pattern_changed(m_pt_spacing); + return; + } + const double denom = rd.dot(m_pt_normal); + if (std::abs(denom) < 1e-9) return; // ray ∥ rotation plane: leave angle as-is + const double t = (m_pt_ccenter - ro).dot(m_pt_normal) / denom; + const Vec3d p = ro + t * rd; + const Vec3d yax = m_pt_normal.cross(m_pt_cref).normalized(); + double deg = std::atan2((p - m_pt_ccenter).dot(yax), (p - m_pt_ccenter).dot(m_pt_cref)) * 180.0 / M_PI; + if (deg < 0.0) deg += 360.0; + m_pt_angle = std::min(360.0, std::max(1.0, deg)); + if (on_pattern_changed) on_pattern_changed(m_pt_angle); +} + +void DesignSketchTool::open_pattern_editor() +{ + if (!on_inline_edit) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + const double cur = m_pt_circular ? m_pt_angle : m_pt_spacing; + on_inline_edit(px, cur, "", + [this](double v) { + if (m_pt_circular) m_pt_angle = std::min(360.0, std::max(1.0, v)); + else m_pt_spacing = std::max(0.1, v); + if (on_pattern_changed) on_pattern_changed(m_pt_circular ? m_pt_angle : m_pt_spacing); + }, + []() {}); +} + +// Closed loops + the entity indices that form each one. A circle/ellipse is its own loop; +// line/arc chains are walked endpoint-to-endpoint. Entity membership lets a single loop be +// highlighted and extruded on its own. +std::vector +DesignSketchTool::region_loops(const std::vector& ents) const +{ + std::vector regions; + const double eps2 = sketch_join_tol() * sketch_join_tol(); + auto is_near = [&](const Vec2d& a, const Vec2d& b) { return (a - b).squaredNorm() < eps2; }; + + // Circles are self-closed regions; lines/arcs are open segments to be chained. Each + // Seg remembers the entity index it came from. + struct Seg { std::vector pts; int ent{-1}; bool used{false}; }; + std::vector segs; + for (int i = 0; i < int(ents.size()); ++i) { + const SketchEntity& e = ents[i]; + if (e.construction) continue; + bool closed = false; + if (e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Ellipse) { + regions.push_back({ entity_polyline(e, closed), { i } }); + } else if (e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc + || e.type == SketchEntity::Type::EllipseArc + || e.type == SketchEntity::Type::BSpline) { + std::vector p = entity_polyline(e, closed); + if (p.size() >= 2) segs.push_back({ std::move(p), i, false }); + } + } + + // Walk each unused segment endpoint-to-endpoint until the chain returns to its + // start (a closed loop) or stalls (an open chain, discarded). + for (size_t s = 0; s < segs.size(); ++s) { + if (segs[s].used) continue; + segs[s].used = true; + std::vector loop = segs[s].pts; + std::vector loop_ents = { segs[s].ent }; + const Vec2d start = loop.front(); + Vec2d cur = loop.back(); + bool extended = true; + while (extended && !is_near(cur, start)) { + extended = false; + for (size_t t = 0; t < segs.size(); ++t) { + if (segs[t].used) continue; + const std::vector& q = segs[t].pts; + if (is_near(q.front(), cur)) { + for (size_t k = 1; k < q.size(); ++k) loop.push_back(q[k]); + cur = q.back(); + } else if (is_near(q.back(), cur)) { + for (int k = int(q.size()) - 2; k >= 0; --k) loop.push_back(q[k]); + cur = q.front(); + } else { + continue; + } + segs[t].used = true; + loop_ents.push_back(segs[t].ent); + extended = true; + break; + } + } + if (is_near(cur, start) && loop.size() >= 4) { + loop.pop_back(); // drop the duplicate closing vertex + regions.push_back({ std::move(loop), std::move(loop_ents), {} }); + } + } + + // NESTING. A loop drawn inside another one is that one's HOLE. Without this a sketch is + // just N disjoint filled polygons, so "the plate with the hole" is not expressible and the + // multi-loop kernel path (88v) is unreachable from the viewport — which is exactly + // what Tommaso hit: a rectangle with a circle inside extruded to a plain box, because only + // the rectangle loop could be picked and only its entities were passed on. + // + // Loops in a well-formed sketch do not cross, so testing ONE point decides containment. + // Each loop is assigned to the SMALLEST loop that contains it, which is what makes a hole + // belong to the region that actually bounds it rather than to every enclosing loop. + // + // The point must be STRICTLY INSIDE the loop, not one of its vertices. A vertex is exactly + // where two loops are most likely to touch in a real drawing — a bore breaking out through + // a boss wall, a slot that ends on an outline — and a ray cast from a point that lies ON the + // polygon being tested answers by rounding, so the same drawing can be read either way. + // Measured on the StudyCadCam corpus: the engine and an independent containment check + // disagreed on 6 of 39 sheets, and every disagreement was a probe point sitting on the other + // loop's boundary. 5hvl. + auto poly_area = [](const std::vector& q) { + double a2 = 0.0; + for (size_t i = 0, j = q.size() - 1; i < q.size(); j = i++) + a2 += (q[j].x() + q[i].x()) * (q[j].y() - q[i].y()); + return std::abs(a2) * 0.5; + }; + auto point_in = [](const Vec2d& pt, const std::vector& q) { + bool in = false; + for (size_t i = 0, j = q.size() - 1; i < q.size(); j = i++) { + const Vec2d& A = q[i]; const Vec2d& B = q[j]; + if (((A.y() > pt.y()) != (B.y() > pt.y())) && + (pt.x() < (B.x() - A.x()) * (pt.y() - A.y()) / (B.y() - A.y()) + A.x())) + in = !in; + } + return in; + }; + // A point strictly inside a simple polygon: the lowest vertex of a simple polygon is always + // CONVEX, so stepping from it along the bisector of its two edges goes into the interior. + // The step is a small fraction of the shorter adjacent edge, so it stays inside however + // sharp the corner is. + auto interior_point = [](const std::vector& q) { + size_t k = 0; + for (size_t i = 1; i < q.size(); ++i) + if (q[i].y() < q[k].y() || (q[i].y() == q[k].y() && q[i].x() < q[k].x())) k = i; + const Vec2d& v = q[k]; + Vec2d a = q[(k + q.size() - 1) % q.size()] - v; + Vec2d b = q[(k + 1) % q.size()] - v; + const double la = a.norm(), lb = b.norm(); + if (la < 1e-12 || lb < 1e-12) return v; // degenerate: nothing better to say + a /= la; b /= lb; + Vec2d bis = a + b; + if (bis.norm() < 1e-12) return v; // 180 deg spike: same + bis.normalize(); + return Vec2d(v + bis * (1e-3 * std::min(la, lb))); + }; + std::vector probe(regions.size()); + for (size_t i = 0; i < regions.size(); ++i) + if (regions[i].poly.size() >= 3) probe[i] = interior_point(regions[i].poly); + else if (!regions[i].poly.empty()) probe[i] = regions[i].poly.front(); + for (size_t i = 0; i < regions.size(); ++i) { + if (regions[i].poly.empty()) continue; + int best = -1; + double best_area = 0.0; + for (size_t j = 0; j < regions.size(); ++j) { + if (i == j || regions[j].poly.size() < 3) continue; + if (!point_in(probe[i], regions[j].poly)) continue; + const double a2 = poly_area(regions[j].poly); + if (best < 0 || a2 < best_area) { best = int(j); best_area = a2; } + } + if (best >= 0) regions[best].holes.push_back(int(i)); + } + return regions; +} + +int DesignSketchTool::region_at(const Vec2d& p) const +{ + // Walk region_loops (which already knows about holes) rather than the raw polygons: the + // returned index must stay meaningful after the sketch is committed, so it has to use the + // SAME numbering selected_loop_entities() and hit_display_sketch consume. + const std::vector regions = region_loops(m_entities); + auto inside = [](const Vec2d& q, const std::vector& poly) { + bool in = false; + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) { + const Vec2d& a = poly[i]; + const Vec2d& b = poly[j]; + if (((a.y() > q.y()) != (b.y() > q.y())) && + (q.x() < (b.x() - a.x()) * (q.y() - a.y()) / (b.y() - a.y()) + a.x())) + in = !in; + } + return in; + }; + // Shoelace area (absolute): the SMALLEST loop containing p is the innermost one, which is + // the region that actually owns the point — a bore inside a plate resolves to the disc, and + // a click on the plate material resolves to the plate even though the disc is also inside it. + auto poly_area = [](const std::vector& q) { + double a2 = 0.0; + for (size_t i = 0, j = q.size() - 1; i < q.size(); j = i++) + a2 += (q[j].x() + q[i].x()) * (q[j].y() - q[i].y()); + return std::abs(a2) * 0.5; + }; + int best = -1; + double best_area = 0.0; + for (size_t i = 0; i < regions.size(); ++i) { + if (regions[i].poly.size() < 3) continue; + if (!inside(p, regions[i].poly)) continue; + bool in_hole = false; // p inside one of this region's holes → that hole owns it, not us + for (int h : regions[i].holes) { + if (h < 0 || h >= int(regions.size()) || regions[h].poly.size() < 3) continue; + if (inside(p, regions[h].poly)) { in_hole = true; break; } + } + if (in_hole) continue; + const double a = poly_area(regions[i].poly); + if (best < 0 || a < best_area) { best = int(i); best_area = a; } + } + return best; +} + +// ---- rendering -------------------------------------------------------------- + +// Chop a polyline into dashes (imlq). Construction geometry is dashed in every CAD; +// this one painted it solid grey, which against the under-constrained orange reads as "another +// line", not as "reference only". The dash and gap arrive in WORLD units — the caller scales them +// by units-per-pixel, so the dash keeps its size on screen at any zoom instead of turning into a +// solid line when you zoom out and into three dashes when you zoom in. +static std::vector> dash_polyline(const std::vector& pts, bool closed, + double dash, double gap) +{ + std::vector> out; + if (pts.size() < 2 || dash <= 0.0 || gap <= 0.0) return out; + const size_t segs = closed ? pts.size() : pts.size() - 1; + bool on = true; // start on a dash, so a short entity is still visible + double left = dash; // distance remaining in the current dash/gap + std::vector cur; + if (on) cur.push_back(pts[0]); + for (size_t i = 0; i < segs; ++i) { + const Vec2d a = pts[i], b = pts[(i + 1) % pts.size()]; + double seg = (b - a).norm(); + if (seg < 1e-12) continue; + const Vec2d dir = (b - a) / seg; + double t = 0.0; + while (seg - t > left) { + t += left; + const Vec2d p = a + dir * t; + if (on) { cur.push_back(p); out.push_back(cur); cur.clear(); } + else { cur.clear(); cur.push_back(p); } + on = !on; + left = on ? dash : gap; + } + left -= (seg - t); + if (on) cur.push_back(b); + } + if (on && cur.size() >= 2) out.push_back(cur); + return out; +} + +void DesignSketchTool::draw_quad_strip(GLModel& model, const std::vector& pts, bool closed, const ColorRGBA& color) +{ + if (pts.size() < 2) + return; + + // Half-width in WORLD units, so a stroke is 2*hw mm wide on the plane. Halved from 0.6 on + // user report 2026-08-23: at 1.2 mm the orange under-constrained line was heavy enough to + // swallow a short segment and to hide which of two near-parallel lines the cursor was on. + // Every one of this function's call sites is a sketch stroke (entities, previews, rubber + // bands), which is why the constant is here and not a parameter at twenty call sites. + const double hw = 0.3; + GLModel::Geometry g; + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned int base = 0; + + const size_t segs = closed ? pts.size() : pts.size() - 1; + for (size_t i = 0; i < segs; ++i) { + const Vec2d a = pts[i]; + const Vec2d b = pts[(i + 1) % pts.size()]; + const Vec2d d = b - a; + const double len = d.norm(); + if (len < 1e-6) + continue; + const Vec2d n(-d.y() / len, d.x() / len); + const Vec2d o = n * hw; + g.add_vertex((Vec3f)m_plane.to_world(a + o).cast()); + g.add_vertex((Vec3f)m_plane.to_world(b + o).cast()); + g.add_vertex((Vec3f)m_plane.to_world(b - o).cast()); + g.add_vertex((Vec3f)m_plane.to_world(a - o).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); + base += 4; + } + + if (base > 0) { + model.reset(); + model.init_from(std::move(g)); + model.set_color(color); + model.render(); + } +} + +namespace { +double poly_signed_area(const std::vector& p) +{ + double a = 0.0; + for (size_t i = 0, n = p.size(); i < n; ++i) { + const Vec2d& u = p[i]; + const Vec2d& v = p[(i + 1) % n]; + a += u.x() * v.y() - v.x() * u.y(); + } + return 0.5 * a; +} +bool pt_in_tri(const Vec2d& p, const Vec2d& a, const Vec2d& b, const Vec2d& c) +{ + auto cross = [](const Vec2d& u, const Vec2d& v, const Vec2d& w) { + return (v.x() - u.x()) * (w.y() - u.y()) - (v.y() - u.y()) * (w.x() - u.x()); + }; + const double d1 = cross(a, b, p), d2 = cross(b, c, p), d3 = cross(c, a, p); + const bool neg = (d1 < 0) || (d2 < 0) || (d3 < 0); + const bool pos = (d1 > 0) || (d2 > 0) || (d3 > 0); + return !(neg && pos); // inside iff all cross products share a sign +} +// Ear-clipping triangulation of a simple polygon; returns index triples into `poly`. +std::vector> ear_clip(const std::vector& poly) +{ + std::vector> tris; + const size_t n = poly.size(); + if (n < 3) return tris; + std::vector idx(n); + for (unsigned i = 0; i < n; ++i) idx[i] = i; + if (poly_signed_area(poly) < 0.0) std::reverse(idx.begin(), idx.end()); // work CCW + int guard = 0; + while (idx.size() > 3 && guard++ < int(10 * n)) { + bool clipped = false; + const int m = int(idx.size()); + for (int i = 0; i < m; ++i) { + const unsigned i0 = idx[(i + m - 1) % m]; + const unsigned i1 = idx[i]; + const unsigned i2 = idx[(i + 1) % m]; + const Vec2d& a = poly[i0]; const Vec2d& b = poly[i1]; const Vec2d& c = poly[i2]; + const double cr = (b.x() - a.x()) * (c.y() - a.y()) - (b.y() - a.y()) * (c.x() - a.x()); + if (cr <= 0.0) continue; // reflex vertex, not an ear tip + bool ear = true; + for (int j = 0; j < m; ++j) { + const unsigned ij = idx[j]; + if (ij == i0 || ij == i1 || ij == i2) continue; + if (pt_in_tri(poly[ij], a, b, c)) { ear = false; break; } + } + if (!ear) continue; + tris.push_back({ i0, i1, i2 }); + idx.erase(idx.begin() + i); + clipped = true; + break; + } + if (!clipped) break; // degenerate input: bail rather than spin + } + if (idx.size() == 3) tris.push_back({ idx[0], idx[1], idx[2] }); + return tris; +} +} // namespace + +// Fill a region that has holes with an EVEN-ODD SCANLINE fill: each horizontal band is scanned +// across every contour, the crossings sorted, and the spans between alternate crossings emitted +// as quads. A hole is just two more crossings, so any number of holes and any concavity fall out +// of the parity rule — no bridge and no triangulation to fail. +void DesignSketchTool::draw_fill_holed(GLModel& model, const std::vector& outer, + const std::vector>& holes, + const ColorRGBA& color) +{ + if (outer.size() < 3) return; + if (holes.empty()) { draw_fill(model, outer, color); return; } + + // EVEN-ODD SCANLINE, not a triangulation. The previous version spliced each hole into the + // outer contour through a keyhole corridor and ear-clipped the result; the corridor did not + // collapse to zero width and was drawn as a visible triangle running from the bore to the + // nearest rectangle corner. Rather than tune a bridge that can always find a new shape to + // fail on, this fills the region the way a rasteriser would: for each horizontal band, cross + // EVERY contour, sort the crossings, and fill between alternate pairs. A hole is simply two + // more crossings, so any number of holes and any concavity fall out of the same rule and no + // corridor exists to leak. + std::vector*> contours; + contours.push_back(&outer); + for (const auto& h : holes) if (h.size() >= 3) contours.push_back(&h); + + double ymin = outer[0].y(), ymax = ymin, xmin = outer[0].x(), xmax = xmin; + for (const auto* c : contours) + for (const Vec2d& v : *c) { + ymin = std::min(ymin, v.y()); ymax = std::max(ymax, v.y()); + xmin = std::min(xmin, v.x()); xmax = std::max(xmax, v.x()); + } + if (ymax - ymin < 1e-9) return; + + // Enough bands that the step is far below a pixel at any sane zoom, cheap enough to rebuild + // every frame: this is a translucent highlight, not geometry. + const int ROWS = 256; + const double dy = (ymax - ymin) / ROWS; + + GLModel::Geometry g; + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned base = 0; + std::vector xs; + for (int row = 0; row < ROWS; ++row) { + const double y0 = ymin + dy * row, y1 = y0 + dy, ym = 0.5 * (y0 + y1); + xs.clear(); + for (const auto* c : contours) { + const std::vector& q = *c; + for (size_t i = 0, j = q.size() - 1; i < q.size(); j = i++) { + const Vec2d& A = q[i]; const Vec2d& B = q[j]; + if ((A.y() > ym) == (B.y() > ym)) continue; // edge does not cross this row + xs.push_back(A.x() + (ym - A.y()) * (B.x() - A.x()) / (B.y() - A.y())); + } + } + if (xs.size() < 2) continue; + std::sort(xs.begin(), xs.end()); + for (size_t k = 0; k + 1 < xs.size(); k += 2) { // even-odd: fill alternate spans + const double xa = xs[k], xb = xs[k + 1]; + if (xb - xa < 1e-9) continue; + g.add_vertex((Vec3f)m_plane.to_world(Vec2d(xa, y0)).cast()); + g.add_vertex((Vec3f)m_plane.to_world(Vec2d(xb, y0)).cast()); + g.add_vertex((Vec3f)m_plane.to_world(Vec2d(xb, y1)).cast()); + g.add_vertex((Vec3f)m_plane.to_world(Vec2d(xa, y1)).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); + base += 4; + } + } + if (base == 0) return; + model.reset(); + model.init_from(std::move(g)); + model.set_color(color); + model.render(); +} + +void DesignSketchTool::draw_fill(GLModel& model, const std::vector& poly, const ColorRGBA& color) +{ + if (poly.size() < 3) return; + const auto tris = ear_clip(poly); + if (tris.empty()) return; + GLModel::Geometry g; + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + for (const Vec2d& p : poly) + g.add_vertex((Vec3f)m_plane.to_world(p).cast()); + for (const auto& t : tris) + g.add_triangle(t[0], t[1], t[2]); + model.reset(); + model.init_from(std::move(g)); + model.set_color(color); + model.render(); +} + +void DesignSketchTool::draw_vertices(GLModel& model, const std::vector& pts, const ColorRGBA& color, + double half_size) +{ + if (pts.empty()) + return; + + const double hs = half_size; + GLModel::Geometry g; + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned int base = 0; + for (const Vec2d& p : pts) { + g.add_vertex((Vec3f)m_plane.to_world(p + Vec2d(-hs, -hs)).cast()); + g.add_vertex((Vec3f)m_plane.to_world(p + Vec2d( hs, -hs)).cast()); + g.add_vertex((Vec3f)m_plane.to_world(p + Vec2d( hs, hs)).cast()); + g.add_vertex((Vec3f)m_plane.to_world(p + Vec2d(-hs, hs)).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); + base += 4; + } + + model.reset(); + model.init_from(std::move(g)); + model.set_color(color); + model.render(); +} + +// Independent thick-line segments batched into one immediate-mode draw (quote lines, +// extension lines, arrowheads, glyph strokes). Mirrors draw_quad_strip's lift-to-world. +void DesignSketchTool::draw_strokes(GLModel& model, const std::vector>& segs, + double hw, const ColorRGBA& color) +{ + GLModel::Geometry g; + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned int base = 0; + for (const auto& s : segs) { + const Vec2d a = s.first, b = s.second; + const Vec2d d = b - a; + const double len = d.norm(); + if (len < 1e-6) continue; + const Vec2d n(-d.y() / len, d.x() / len); + const Vec2d o = n * hw; + g.add_vertex((Vec3f)m_plane.to_world(a + o).cast()); + g.add_vertex((Vec3f)m_plane.to_world(b + o).cast()); + g.add_vertex((Vec3f)m_plane.to_world(b - o).cast()); + g.add_vertex((Vec3f)m_plane.to_world(a - o).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); + base += 4; + } + if (base > 0) { + model.reset(); + model.init_from(std::move(g)); + model.set_color(color); + model.render(); + } +} + +namespace { +// Smooth single-stroke (Hershey-style) vector font for dimension labels. Glyphs +// live in a 0..0.6 (x) by 0..1 (y) cell, baseline at y=0, cap height y=1; curved +// digits are sampled as short segments so they read as rounded shapes, not blocks. +// `advance` is the pen step after the glyph. +constexpr double kPi = 3.14159265358979323846; +inline double rad(double deg) { return deg * kPi / 180.0; } + +// Connect a list of points as a polyline. +void poly(std::vector>& out, std::initializer_list p) +{ + auto it = p.begin(); + if (it == p.end()) return; + Vec2d prev = *it++; + for (; it != p.end(); ++it) { out.emplace_back(prev, *it); prev = *it; } +} +// Sample an elliptical arc (centre cx,cy; radii rx,ry) from angle a0..a1. +void arc(std::vector>& out, double cx, double cy, double rx, double ry, + double a0, double a1, int n = 14) +{ + Vec2d prev(cx + rx * std::cos(a0), cy + ry * std::sin(a0)); + for (int i = 1; i <= n; ++i) { + const double t = a0 + (a1 - a0) * (double)i / n; + const Vec2d cur(cx + rx * std::cos(t), cy + ry * std::sin(t)); + out.emplace_back(prev, cur); + prev = cur; + } +} + +void glyph_strokes(char c, std::vector>& out, double& advance) +{ + advance = 0.72; + switch (c) { + case '0': + arc(out, 0.30, 0.50, 0.25, 0.48, 0.0, 2.0 * kPi); + break; + case '1': + poly(out, {Vec2d(0.13, 0.76), Vec2d(0.33, 1.0), Vec2d(0.33, 0.0)}); + poly(out, {Vec2d(0.13, 0.0), Vec2d(0.53, 0.0)}); + advance = 0.52; + break; + case '2': + arc(out, 0.30, 0.72, 0.25, 0.25, rad(170), rad(-45)); + poly(out, {Vec2d(0.477, 0.543), Vec2d(0.06, 0.0), Vec2d(0.56, 0.0)}); + break; + case '3': + arc(out, 0.30, 0.74, 0.24, 0.24, rad(160), rad(-90)); + arc(out, 0.30, 0.26, 0.26, 0.26, rad(90), rad(-160)); + break; + case '4': + poly(out, {Vec2d(0.42, 1.0), Vec2d(0.04, 0.32), Vec2d(0.58, 0.32)}); + poly(out, {Vec2d(0.42, 1.0), Vec2d(0.42, 0.0)}); + break; + case '5': + poly(out, {Vec2d(0.54, 1.0), Vec2d(0.12, 1.0), Vec2d(0.11, 0.52)}); + arc(out, 0.27, 0.30, 0.27, 0.27, rad(130), rad(-120)); + break; + case '6': + arc(out, 0.30, 0.28, 0.26, 0.26, 0.0, 2.0 * kPi); + arc(out, 0.30, 0.55, 0.30, 0.45, rad(90), rad(190)); + break; + case '7': + poly(out, {Vec2d(0.05, 1.0), Vec2d(0.57, 1.0), Vec2d(0.22, 0.0)}); + break; + case '8': + arc(out, 0.30, 0.73, 0.22, 0.25, 0.0, 2.0 * kPi); + arc(out, 0.30, 0.26, 0.26, 0.26, 0.0, 2.0 * kPi); + break; + case '9': + arc(out, 0.30, 0.70, 0.26, 0.26, 0.0, 2.0 * kPi); + arc(out, 0.28, 0.55, 0.28, 0.55, rad(15), rad(-90)); + break; + case '.': + case ',': // locale (LC_NUMERIC) may format the decimal separator as a comma + // small solid dot: crossed short strokes so the quads fill a visible disk + poly(out, {Vec2d(0.10, 0.08), Vec2d(0.24, 0.08)}); + poly(out, {Vec2d(0.17, 0.02), Vec2d(0.17, 0.15)}); + advance = 0.30; + break; + case '-': + poly(out, {Vec2d(0.10, 0.5), Vec2d(0.50, 0.5)}); + advance = 0.62; + break; + case 'R': + poly(out, {Vec2d(0.08, 0.0), Vec2d(0.08, 1.0), Vec2d(0.38, 1.0)}); + arc(out, 0.38, 0.75, 0.17, 0.25, rad(90), rad(-90)); + poly(out, {Vec2d(0.38, 0.50), Vec2d(0.08, 0.50)}); + poly(out, {Vec2d(0.30, 0.50), Vec2d(0.58, 0.0)}); + advance = 0.80; + break; + case 'X': + poly(out, {Vec2d(0.06, 1.0), Vec2d(0.58, 0.0)}); + poly(out, {Vec2d(0.58, 1.0), Vec2d(0.06, 0.0)}); + advance = 0.72; + break; + case 'Y': + poly(out, {Vec2d(0.06, 1.0), Vec2d(0.32, 0.52)}); + poly(out, {Vec2d(0.58, 1.0), Vec2d(0.32, 0.52)}); + poly(out, {Vec2d(0.32, 0.52), Vec2d(0.32, 0.0)}); + advance = 0.72; + break; + case 'Z': + poly(out, {Vec2d(0.06, 1.0), Vec2d(0.58, 1.0), Vec2d(0.06, 0.0), Vec2d(0.58, 0.0)}); + advance = 0.72; + break; + case ' ': + advance = 0.5; + break; + default: + advance = 0.5; + break; + } +} +} // namespace + +void DesignSketchTool::draw_dim_label(const std::string& txt, const Vec2d& plane_center) +{ + if (txt.empty()) return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const wxPoint sp = world_to_screen_px(cam, m_plane.to_world(plane_center)); + if (sp.x < 0 && sp.y < 0) return; + ImGuiWrapper* imgui = wxGetApp().imgui(); + // Identical to the Prepare/Preview Measure gizmo label (GLGizmoMeasure::render_dimensioning): + // push_common_window_style sets the white text colour + font/scale (without it the text is + // invisible); BringWindowToDisplayFront keeps the per-frame label window on top. + ImGuiWrapper::push_common_window_style(m_render_scale); + imgui->set_next_window_pos((float)sp.x, (float)sp.y, ImGuiCond_Always, 0.5f, 0.5f); + imgui->set_next_window_bg_alpha(0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(1.0f, 1.0f)); + const std::string win = "##sketchdim" + std::to_string(m_dim_label_seq++); + imgui->begin(win, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration + | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoFocusOnAppearing + | ImGuiWindowFlags_NoNav); + ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow()); + ImGui::AlignTextToFramePadding(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImVec2 ts = ImGui::CalcTextSize(txt.c_str()); + const ImGuiStyle& st = ImGui::GetStyle(); + dl->AddRectFilled(ImVec2(pos.x - st.FramePadding.x, pos.y + st.FramePadding.y), + ImVec2(pos.x + ts.x + 2.0f * st.FramePadding.x, + pos.y + ts.y + 2.0f * st.FramePadding.y), + ImGuiWrapper::to_ImU32(ColorRGBA(1.0f, 1.0f, 1.0f, 0.5f))); + ImGui::SetCursorScreenPos(ImVec2(pos.x + st.FramePadding.x, pos.y)); + imgui->text(txt); + imgui->end(); + ImGui::PopStyleVar(3); + ImGuiWrapper::pop_common_window_style(); +} + +void DesignSketchTool::draw_text(GLModel& /*model*/, const std::string& s, const Vec2d& center, + double /*height*/, const ColorRGBA& /*color*/) +{ + // ponytail: all sketch labels now render as Measure-gizmo-style ImGui labels for visual + // parity with the Prepare/Preview tabs; the old vector-font path (glyph_strokes/draw_strokes + // for text) is retired. Leader lines/arrows still draw via draw_strokes at the call sites. + draw_dim_label(s, center); +} + +// Draw every placed dimension: extension lines, the offset dimension line, arrowheads +// and the numeric label. Geometry is recomputed from the (solved) entities each frame +// so the quote tracks the sketch. +// Draw one dimension's quote (extension/dimension lines, arrowheads, numeric label). +// Geometry is recomputed from the (solved) entities so the quote tracks the sketch. +// Returns the label centre in out_label; false if the annot references missing/degenerate +// geometry. Shared by placed (render_dimensions) and live (render_live_quotes) quotes. +bool DesignSketchTool::draw_dim_quote(const DimAnnot& a, double th, const ColorRGBA& dimcol, + Vec2d& out_label) +{ + std::vector> segs; + if (a.kind == DimType::Length || a.kind == DimType::Distance) { + Vec2d pa, pb; + if (a.kind == DimType::Length) { + if (a.ea < 0 || a.ea >= int(m_entities.size())) return false; + pa = m_entities[a.ea].p0; pb = m_entities[a.ea].p1; + } else if (!point_at(a.ea, a.ra, pa) || !point_at(a.eb, a.rb, pb)) { + return false; + } + const Vec2d d = pb - pa; + const double L = d.norm(); + if (L < 1e-6) return false; + const Vec2d u = d / L; + const Vec2d nrm(-u.y(), u.x()); + const double side = (a.side != 0.0) ? a.side : 1.0; + const double off = side * std::max(L * 0.18, 8.0); + const Vec2d A2 = pa + nrm * off, B2 = pb + nrm * off; // offset clear of the sketch line + segs.emplace_back(A2, B2); // dimension line (not on the geometry) + const double as = std::max(L * 0.04, 2.0); + auto arrow = [&](const Vec2d& tip, const Vec2d& dir) { + const Vec2d back = tip + dir * as; + segs.emplace_back(tip, back + nrm * (as * 0.5)); + segs.emplace_back(tip, back - nrm * (as * 0.5)); + }; + arrow(A2, u); arrow(B2, -u); + out_label = (A2 + B2) * 0.5 + nrm * (side * (th * 0.7 + 1.5)); + } else if (a.kind == DimType::Diameter || a.kind == DimType::Radius) { + if (a.ea < 0 || a.ea >= int(m_entities.size())) return false; + const SketchEntity& e = m_entities[a.ea]; + const Vec2d c = e.center; + const double r = e.radius; + if (r < 1e-6) return false; + const Vec2d u(1.0, 0.0); + const double as = std::max(r * 0.12, 2.0); + if (a.kind == DimType::Diameter) { + const Vec2d p1 = c - u * r, p2 = c + u * r; + segs.emplace_back(p1, p2); + segs.emplace_back(p1, p1 + u * as + Vec2d(0, 1) * (as * 0.5)); + segs.emplace_back(p1, p1 + u * as - Vec2d(0, 1) * (as * 0.5)); + segs.emplace_back(p2, p2 - u * as + Vec2d(0, 1) * (as * 0.5)); + segs.emplace_back(p2, p2 - u * as - Vec2d(0, 1) * (as * 0.5)); + out_label = c + Vec2d(0, 1) * (th * 0.8); + } else { + const Vec2d p2 = c + u * r; + segs.emplace_back(c, p2); + segs.emplace_back(p2, p2 - u * as + Vec2d(0, 1) * (as * 0.5)); + segs.emplace_back(p2, p2 - u * as - Vec2d(0, 1) * (as * 0.5)); + out_label = (c + p2) * 0.5 + Vec2d(0, 1) * (th * 0.8); + } + } else if (a.kind == DimType::DistanceToLine) { + Vec2d pa; + if (!point_at(a.ea, a.ra, pa) || a.eb < 0 || a.eb >= int(m_entities.size())) return false; + const SketchEntity& Ln = m_entities[a.eb]; + const Vec2d ld = Ln.p1 - Ln.p0; + const double n = ld.norm(); + if (n < 1e-9) return false; + const Vec2d u = ld / n; + const double t = (pa - Ln.p0).dot(u); + const Vec2d foot = Ln.p0 + u * t; // perpendicular foot on the line + segs.emplace_back(pa, foot); + out_label = (pa + foot) * 0.5 + u * (th * 0.7 + 1.5); + } else if (a.kind == DimType::Angle) { + if (a.ea < 0 || a.ea >= int(m_entities.size())) return false; + const SketchEntity& e = m_entities[a.ea]; + if (e.type != SketchEntity::Type::Line) return false; + const Vec2d d = e.p1 - e.p0; + const double L = d.norm(); + if (L < 1e-6) return false; + double ang = std::atan2(d.y(), d.x()); // signed, matches measure_dim sweep + const double rr = std::max(std::min(L * 0.35, 40.0), th * 1.6); // arc radius + segs.emplace_back(e.p0, e.p0 + Vec2d(rr * 1.15, 0.0)); // horizontal reference leg + const int N = 20; // arc 0 -> ang about p0 + Vec2d prev = e.p0 + Vec2d(rr, 0.0); + for (int i = 1; i <= N; ++i) { + const double t = ang * double(i) / N; + const Vec2d cur = e.p0 + Vec2d(rr * std::cos(t), rr * std::sin(t)); + segs.emplace_back(prev, cur); prev = cur; + } + const double mid = ang * 0.5; + out_label = e.p0 + Vec2d(std::cos(mid), std::sin(mid)) * (rr + th * 1.1); + } else { + return false; + } + draw_strokes(m_highlight_model, segs, 0.2, dimcol); + draw_text(m_line_model, dim_text(a), out_label, th, dimcol); + return true; +} + +// Draw every placed (driving) dimension; cache each label centre for picking. +void DesignSketchTool::render_dimensions(double unit_per_px) +{ + if (m_dimensions.empty()) return; + const ColorRGBA dimcol(0.85f, 0.85f, 0.85f, 1.0f); // neutral leader (Measure parity) + // Label text is a CONSTANT screen size (like real CAD), not scaled to geometry, + // so a long line doesn't get huge text. ~15 px tall in plane units at this zoom. + const double th = std::max(15.0 * unit_per_px, 1e-4); + for (size_t di = 0; di < m_dimensions.size(); ++di) { + Vec2d label; + if (draw_dim_quote(m_dimensions[di], th, dimcol, label)) + m_dimensions[di].label_pos = label; + } +} + +// Per-entity-type characteristic dimensions, drawn as live non-driving quotes for the +// entity being edited (point/handle drag, or a lone selection). This is the Onshape +// pattern that scales to every tool: each kind reports its defining dimension(s); each +// is clickable (m_live_quotes) to promote to a driving dim + open the inline editor. +// A dim already driven on the entity is skipped (render_dimensions draws that one). +void DesignSketchTool::render_live_quotes(double unit_per_px) +{ + m_live_quotes.clear(); + m_live_poly_fi = -1; + m_live_poly_side_label = m_live_poly_angle_label = Vec2d(1e18, 1e18); + m_live_arc_ei = -1; + m_live_arc_angle_label = Vec2d(1e18, 1e18); + m_live_ellipse_ei = -1; + m_live_ellipse_major_label = m_live_ellipse_minor_label = Vec2d(1e18, 1e18); + m_live_ellipsearc_sweep_label = Vec2d(1e18, 1e18); + m_live_obrect_fi = -1; + m_live_obrect_angle_label = Vec2d(1e18, 1e18); + m_live_rrect_fi = -1; + m_live_rrect_w_label = m_live_rrect_h_label = m_live_rrect_r_label = Vec2d(1e18, 1e18); + m_live_aslot_fi = -1; + m_live_aslot_r_label = m_live_aslot_w_label = Vec2d(1e18, 1e18); + m_live_slot_fi = -1; + m_live_slot_len_label = m_live_slot_w_label = m_live_slot_angle_label = Vec2d(1e18, 1e18); + // Edit-op tools (Fillet/Chamfer/Offset/Mirror) put their picks in m_selection for the + // highlight, but their own arrow/label gizmo is the value affordance — don't also draw + // the picked entity's characteristic quotes (Length/Angle/…) or the view gets cluttered. + if (is_edit_op_mode() || is_transform_mode()) return; + int ei = -1; + if (m_dragging_point && m_drag_ei >= 0) ei = m_drag_ei; + else if (m_dragging_handle) ei = m_drag_handle.ei; + else if (m_selection.size() == 1) ei = m_selection[0]; + else if (!m_selection.empty()) { + // A GROUPED FEATURE SELECTS EVERY MEMBER, so "exactly one entity" hid the characteristic + // quotes for precisely the shapes that have nothing else. A rounded rectangle is eight + // entities — four lines and four arcs — so picking it makes m_selection.size() == 8, this + // pass returned here, and its Width / Height / Radius labels were never drawn. With no + // label on screen there is nothing to click, which is the whole of "cannot edit labels in + // rounded rectangles": not a value that refuses to change, a label that does not exist. + // + // A plain rectangle looked fine only by accident: typing into its auto-edit chain creates + // a DRIVEN dimension, and render_dimensions draws that one from the annotation list. The + // rounded rect's W/H/R go through set_rounded_rect, which rebuilds the geometry and leaves + // no annotation behind — so the live label was the only affordance it ever had. + // + // Accept a selection that is entirely ONE feature and let any member speak for it; the + // switch below already keys off feature_of(ei) rather than the entity itself. + const int f0 = feature_of(m_selection.front()); + if (f0 >= 0) { + bool same_feature = true; + for (int s : m_selection) + if (feature_of(s) != f0) { same_feature = false; break; } + if (same_feature) ei = m_selection.front(); + } + } + if (ei < 0 || ei >= int(m_entities.size())) return; + const SketchEntity& e = m_entities[ei]; + + std::vector protos; + auto add_len = [&](int line_ei, double side) { + if (line_ei < 0 || line_ei >= int(m_entities.size())) return; + if (m_entities[line_ei].type != SketchEntity::Type::Line) return; + DimAnnot a; a.kind = DimType::Length; a.ea = line_ei; a.side = side; protos.push_back(a); + }; + + // A grouped gesture (rect/slot/polygon decomposes into raw lines/arcs) exposes its + // DERIVED characteristic dims off the Feature span, regardless of which member edge + // was picked. Rect: Width = first edge length, Height = second edge length (the two + // axes of the 4-line loop pushed by push_closed_lines: edge0 horizontal, edge1 + // vertical). Quotes offset to opposite sides so they don't overlap. + const int fi = feature_of(ei); + if (fi >= 0) { + const Feature& f = m_features[fi]; + switch (f.kind) { + case FeatureKind::CornerRect: + case FeatureKind::CenterRect: { + add_len(f.begin + 0, 1.0); // Width + add_len(f.begin + 1, -1.0); // Height + // OBLIQUE rect (drawn off-axis, also a CornerRect feature): expose its orientation + // too. Gated to genuinely-tilted edges so an axis-aligned Corner/Center rect never + // gets an angle quote (and its verified W/H behaviour is untouched). + if (f.begin >= 0 && f.begin < int(m_entities.size())) { + const SketchEntity& e0 = m_entities[f.begin]; + Vec2d d0 = e0.p1 - e0.p0; + if (d0.squaredNorm() > 1e-12) { + double deg = std::atan2(d0.y(), d0.x()) * 180.0 / M_PI; + const double off = std::fmod(std::fmod(deg, 90.0) + 90.0, 90.0); // dist to axis + if (off > 2.0 && off < 88.0) { + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + double adeg = deg; if (adeg < 0.0) adeg += 360.0; + const Vec2d mid = 0.5 * (e0.p0 + e0.p1); + Vec2d nrm(-d0.y(), d0.x()); if (nrm.squaredNorm() > 1e-12) nrm.normalize(); + m_live_obrect_angle_label = mid + nrm * (th * 1.4); + DimAnnot at; at.kind = DimType::Angle; at.value = adeg; + draw_text(m_line_model, dim_text(at), m_live_obrect_angle_label, th, dc); + m_live_obrect_fi = fi; + } + } + } + break; + } + case FeatureKind::Slot: { + // Straight slot edits GEOMETRICALLY (like the arc-slot / rounded-rect), NOT through + // the constraint-based scalar quotes. Its dims are the centreline LENGTH (distance + // between the two cap centres c0,c1) and the WIDTH (2*param). The old + // Distance-between-arc-centres + Radius quotes never registered as editable, so the + // labels did nothing on click (nde #6). Draw both labels clear of the fillable face + // and remember the feature so open_primary_autoedit / click-to-promote drive set_slot. + // Slot dims: (1) inter-centre distance, (2) radius (= half-width), (3) centreline angle. + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + Vec2d u = f.c1 - f.c0; + const double Lc = u.norm(); + if (Lc > 1e-9) { + u /= Lc; + const Vec2d n(-u.y(), u.x()); + const double w = f.param; + DimAnnot len; len.kind = DimType::Length; len.value = Lc; + m_live_slot_len_label = 0.5 * (f.c0 + f.c1) + n * (w + th * 2.0); + draw_text(m_line_model, dim_text(len), m_live_slot_len_label, th, dc); + DimAnnot rd; rd.kind = DimType::Radius; rd.value = w; + m_live_slot_w_label = f.c1 + u * (w + th * 2.0); + draw_text(m_line_model, dim_text(rd), m_live_slot_w_label, th, dc); + double deg = std::atan2(f.c1.y() - f.c0.y(), f.c1.x() - f.c0.x()) * 180.0 / M_PI; + if (deg < 0.0) deg += 360.0; + DimAnnot an; an.kind = DimType::Angle; an.value = deg; + m_live_slot_angle_label = f.c0 - u * (w + th * 2.0); + draw_text(m_line_model, dim_text(an), m_live_slot_angle_label, th, dc); + m_live_slot_fi = fi; + } + break; + } + case FeatureKind::Polygon: { + // A regular polygon is N raw lines (no centre entity). Its natural editable + // dims are the SIDE length and the ORIENTATION — NOT a circumradius (a polygon + // is not a circle). Both edit the whole loop geometrically: side scales it + // uniformly, angle rotates it. Drawn off edge0 with draw_dim_quote (Length + + // Angle); the side quote is offset OUTWARD so its label clears the face. + if (f.begin < int(m_entities.size()) && + m_entities[f.begin].type == SketchEntity::Type::Line) { + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const SketchEntity& e0 = m_entities[f.begin]; + const Vec2d m0 = 0.5 * (e0.p0 + e0.p1); + Vec2d u0 = e0.p1 - e0.p0; + if (u0.squaredNorm() > 1e-12) u0.normalize(); + const Vec2d n0(-u0.y(), u0.x()); + const double outsign = ((m0 + n0) - f.c0).norm() >= (m0 - f.c0).norm() ? 1.0 : -1.0; + DimAnnot side; side.kind = DimType::Length; side.ea = f.begin; side.side = outsign; + side.value = measure_dim(side); + Vec2d slbl; + if (draw_dim_quote(side, th, dc, slbl)) m_live_poly_side_label = slbl; + + // Orientation = the angle of the centre->vertex0 spoke from +X (the + // intuitive "which way does the polygon point"), NOT the edge direction. + // Drawn as a wedge OUTSIDE the polygon (radius just past the circumradius) + // so its arc/label clear the fillable face. + const Vec2d sp = m_entities[f.begin].p0 - f.c0; // centre -> vertex0 + const double R = sp.norm(); + if (R > 1e-6) { + double av = std::atan2(sp.y(), sp.x()); + double avdeg = av * 180.0 / M_PI; if (avdeg < 0.0) avdeg += 360.0; + const double rr = R + th * 2.5; // wedge just outside the loop + std::vector> asegs; + asegs.emplace_back(f.c0, f.c0 + Vec2d(rr, 0.0)); // +X leg + asegs.emplace_back(f.c0, f.c0 + Vec2d(std::cos(av), std::sin(av)) * rr); // spoke leg + const int N = 20; Vec2d prev = f.c0 + Vec2d(rr, 0.0); + for (int i = 1; i <= N; ++i) { + const double t = av * double(i) / N; + const Vec2d cur = f.c0 + Vec2d(rr * std::cos(t), rr * std::sin(t)); + asegs.emplace_back(prev, cur); prev = cur; + } + const double mid = av * 0.5; + const Vec2d albl = f.c0 + Vec2d(std::cos(mid), std::sin(mid)) * (rr + th * 1.2); + DimAnnot at; at.kind = DimType::Angle; at.value = avdeg; // "NN.N°" + draw_strokes(m_highlight_model, asegs, 0.6, dc); + draw_text(m_line_model, dim_text(at), albl, th, dc); + m_live_poly_angle_label = albl; + } + m_live_poly_fi = fi; + } + break; + } + case FeatureKind::RoundedRect: { + // Width + Height (box bounds) + fillet Radius, drawn as clickable quotes that + // rebuild the box geometrically (set_rounded_rect). c0=min corner, c1=max, param=r. + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const double xmin = std::min(f.c0.x(), f.c1.x()), xmax = std::max(f.c0.x(), f.c1.x()); + const double ymin = std::min(f.c0.y(), f.c1.y()), ymax = std::max(f.c0.y(), f.c1.y()); + const double w = xmax - xmin, h = ymax - ymin, r = f.param; + const double off = th * 2.0; + std::vector> segs; + // Width quote below the box. + const double yb = ymin - off; + segs.emplace_back(Vec2d(xmin, ymin), Vec2d(xmin, yb)); + segs.emplace_back(Vec2d(xmax, ymin), Vec2d(xmax, yb)); + segs.emplace_back(Vec2d(xmin, yb), Vec2d(xmax, yb)); + // Height quote left of the box. + const double xl = xmin - off; + segs.emplace_back(Vec2d(xmin, ymin), Vec2d(xl, ymin)); + segs.emplace_back(Vec2d(xmin, ymax), Vec2d(xl, ymax)); + segs.emplace_back(Vec2d(xl, ymin), Vec2d(xl, ymax)); + // Fillet-radius leader from the TR arc centre out to the corner. + const Vec2d rc(xmax - r, ymax - r); + segs.emplace_back(rc, rc + Vec2d(r, r).normalized() * r); + draw_strokes(m_highlight_model, segs, 0.6, dc); + DimAnnot wa; wa.kind = DimType::Length; wa.value = w; + DimAnnot ha; ha.kind = DimType::Length; ha.value = h; + DimAnnot ra; ra.kind = DimType::Radius; ra.value = r; + m_live_rrect_w_label = Vec2d((xmin + xmax) * 0.5, yb - th * 0.8); + m_live_rrect_h_label = Vec2d(xl - th * 0.8, (ymin + ymax) * 0.5); + m_live_rrect_r_label = rc + Vec2d(r, r).normalized() * (r + th * 1.2); + draw_text(m_line_model, dim_text(wa), m_live_rrect_w_label, th, dc); + draw_text(m_line_model, dim_text(ha), m_live_rrect_h_label, th, dc); + draw_text(m_line_model, dim_text(ra), m_live_rrect_r_label, th, dc); + m_live_rrect_fi = fi; + break; + } + case FeatureKind::ArcSlot: { + // Centreline Radius + slot Width quotes. centre=f.c0, centreline start=f.c1, + // half-width=f.param; end direction from the cap@E arc centre (begin+1). + if (f.begin + 1 < int(m_entities.size())) { + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const Vec2d center = f.c0; + const double Rc = (f.c1 - center).norm(); + const double w = f.param; + Vec2d dirS = (f.c1 - center); + Vec2d dirE = (m_entities[f.begin + 1].center - center); + if (dirS.squaredNorm() > 1e-12 && dirE.squaredNorm() > 1e-12 && Rc > 1e-6) { + dirS.normalize(); dirE.normalize(); + const double aS = std::atan2(dirS.y(), dirS.x()); + double sweep = std::atan2(dirE.y(), dirE.x()) - aS; + while (sweep < 0) sweep += 2.0 * M_PI; + const double aMid = aS + sweep * 0.5; + const Vec2d uMid(std::cos(aMid), std::sin(aMid)); + // Centreline-radius leader: centre -> centreline midpoint. + std::vector> segs; + segs.emplace_back(center, center + uMid * Rc); + // Width tick across the slot at the start cap (outer<->inner). + segs.emplace_back(center + dirS * (Rc + w), center + dirS * (Rc - w)); + draw_strokes(m_highlight_model, segs, 0.6, dc); + DimAnnot ra; ra.kind = DimType::Radius; ra.value = Rc; + DimAnnot wa; wa.kind = DimType::Length; wa.value = 2.0 * w; + m_live_aslot_r_label = center + uMid * (Rc * 0.5) + Vec2d(0, th); + m_live_aslot_w_label = center + dirS * (Rc + w) + dirS * (th * 1.2); + draw_text(m_line_model, dim_text(ra), m_live_aslot_r_label, th, dc); + draw_text(m_line_model, dim_text(wa), m_live_aslot_w_label, th, dc); + m_live_aslot_fi = fi; + } + } + break; + } + default: break; // other features: later chunks + } + } + + if (protos.empty() && m_live_poly_fi < 0 && m_live_rrect_fi < 0 && + m_live_aslot_fi < 0 && m_live_slot_fi < 0) { // ungrouped single entity + switch (e.type) { + case SketchEntity::Type::Line: { + DimAnnot len; len.kind = DimType::Length; len.ea = ei; len.side = 1.0; protos.push_back(len); + DimAnnot ang; ang.kind = DimType::Angle; ang.ea = ei; ang.eb = -1; protos.push_back(ang); + break; // segment length + angle-to-horizontal + } + case SketchEntity::Type::Circle: { + DimAnnot a; a.kind = DimType::Radius; a.ea = ei; protos.push_back(a); break; + } + case SketchEntity::Type::Arc: { + // Arc radius is its single defining dimension (sweep angles edit via the end + // handles). radius lives in the same .radius field measure_dim/constraint_for + // read, so the Radius promotion path is identical to Circle. + DimAnnot a; a.kind = DimType::Radius; a.ea = ei; protos.push_back(a); break; + } + default: break; // ellipse/bspline: later + } + } + + // Arc sweep-angle wedge: drawn inline (like the polygon orientation) because it is a + // GEOMETRIC edit (SLVS angle constraints are line-to-line). A wedge spans the arc's + // start->end angles just OUTSIDE the radius; its label shows the included angle and is + // clickable to type a new sweep. The radius quote is still emitted via `protos`. + if (m_live_poly_fi < 0 && m_live_rrect_fi < 0 && m_live_aslot_fi < 0 && m_live_slot_fi < 0 && + e.type == SketchEntity::Type::Arc && e.radius > 1e-6) { + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const Vec2d c = e.center; + const double a0 = e.start_angle, a1 = e.end_angle; + const double sweep = a1 - a0; // signed (CCW>0); |sweep| shown + double swdeg = std::abs(sweep) * 180.0 / M_PI; + const double rr = e.radius + th * 2.5; // wedge just outside the arc + std::vector> asegs; + asegs.emplace_back(c, c + Vec2d(std::cos(a0), std::sin(a0)) * rr); // start leg + asegs.emplace_back(c, c + Vec2d(std::cos(a1), std::sin(a1)) * rr); // end leg + const int N = 24; Vec2d prev = c + Vec2d(std::cos(a0), std::sin(a0)) * rr; + for (int i = 1; i <= N; ++i) { + const double t = a0 + sweep * double(i) / N; + const Vec2d cur = c + Vec2d(rr * std::cos(t), rr * std::sin(t)); + asegs.emplace_back(prev, cur); prev = cur; + } + const double mid = a0 + sweep * 0.5; + const Vec2d albl = c + Vec2d(std::cos(mid), std::sin(mid)) * (rr + th * 1.2); + DimAnnot at; at.kind = DimType::Angle; at.value = swdeg; // "NN.N°" + draw_strokes(m_highlight_model, asegs, 0.6, dc); + draw_text(m_line_model, dim_text(at), albl, th, dc); + m_live_arc_angle_label = albl; + m_live_arc_ei = ei; + } + + // Ellipse: two clickable axis quotes — semi-major (a) along the major direction and + // semi-minor (b) along the minor. Both edit geometrically (a=e.radius, b=e.rminor); + // phi (orientation) is changed by dragging the major grip, not via a label. + if (m_live_poly_fi < 0 && m_live_rrect_fi < 0 && + (e.type == SketchEntity::Type::Ellipse || e.type == SketchEntity::Type::EllipseArc) && + e.radius > 1e-6 && e.rminor > 1e-6) { + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const Vec2d c = e.center; + const Vec2d um(std::cos(e.rotation), std::sin(e.rotation)); // major dir + const Vec2d un(-um.y(), um.x()); // minor dir + std::vector> segs; + const Vec2d majEnd = c + um * e.radius, minEnd = c + un * e.rminor; + segs.emplace_back(c, majEnd); + segs.emplace_back(c, minEnd); + draw_strokes(m_highlight_model, segs, 0.6, dc); + DimAnnot ma; ma.kind = DimType::Length; ma.value = e.radius; // plain "NN.N" + DimAnnot mi; mi.kind = DimType::Length; mi.value = e.rminor; + const Vec2d majLbl = c + um * (e.radius * 0.5) + un * (th * 1.0); + const Vec2d minLbl = c + un * (e.rminor * 0.5) + um * (th * 1.0); + draw_text(m_line_model, dim_text(ma), majLbl, th, dc); + draw_text(m_line_model, dim_text(mi), minLbl, th, dc); + m_live_ellipse_major_label = majLbl; + m_live_ellipse_minor_label = minLbl; + m_live_ellipse_ei = ei; + // Elliptical arc also has a SWEEP (included parametric angle), drawn outside the arc + // midpoint; the full ellipse skips this (closed). + if (e.type == SketchEntity::Type::EllipseArc) { + const double midp = 0.5 * (e.start_angle + e.end_angle); + const Vec2d mp = ellipse_point(c, e.radius, e.rminor, e.rotation, midp); + Vec2d outw = mp - c; if (outw.squaredNorm() > 1e-12) outw.normalize(); + m_live_ellipsearc_sweep_label = mp + outw * (th * 1.5); + DimAnnot sw; sw.kind = DimType::Angle; + sw.value = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; + draw_text(m_line_model, dim_text(sw), m_live_ellipsearc_sweep_label, th, dc); + } + } + + if (protos.empty()) return; + + const ColorRGBA dimcol(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + for (DimAnnot a : protos) { + bool driven = false; // skip if already a driving dim of this kind + for (const DimAnnot& d : m_dimensions) + if (d.ea == a.ea && d.kind == a.kind) { driven = true; break; } + if (driven) continue; + a.value = measure_dim(a); + Vec2d label; + if (draw_dim_quote(a, th, dimcol, label)) { + a.label_pos = label; + m_live_quotes.push_back(a); // remember for click-to-promote + } + } +} + +// Iconic constraint badges drawn near each constraint's primary entity (C3.4b). +// Each glyph is authored in a unit cell [-0.5,0.5]^2 then scaled to a constant +// on-screen size and translated to the anchor; badges on the same entity stack +// upward so multiple constraints stay legible. +void DesignSketchTool::build_constraint_glyphs(double unit_per_px, + const std::vector& cons, + std::vector>& out) +{ + m_glyph_hits.clear(); + if (cons.empty() || m_entities.empty()) return; + using T = SketchConstraintType; + const double s = std::max(11.0 * unit_per_px, 1e-4); // glyph cell size in plane units + const double step = s * 1.5; // vertical stacking step + + // Representative anchor point on an entity (line midpoint, round-entity centre). + auto anchor_of = [&](int ei) -> Vec2d { + if (ei < 0 || ei >= int(m_entities.size())) return Vec2d(0, 0); + const SketchEntity& e = m_entities[ei]; + switch (e.type) { + case SketchEntity::Type::Line: return 0.5 * (e.p0 + e.p1); + case SketchEntity::Type::Circle: + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: return e.center; + case SketchEntity::Type::BSpline: return 0.5 * (e.p0 + e.p1); + case SketchEntity::Type::Point: return e.p0; + } + return e.p0; + }; + + // Unit-cell stroke authoring helpers (cell centred on origin). + auto seg = [&](std::vector>& v, Vec2d a, Vec2d b) { v.emplace_back(a, b); }; + auto circ = [&](std::vector>& v, Vec2d c, double r) { + const int n = 12; Vec2d prev(c.x() + r, c.y()); + for (int i = 1; i <= n; ++i) { + const double t = 2.0 * 3.14159265358979 * i / n; + Vec2d cur(c.x() + r * std::cos(t), c.y() + r * std::sin(t)); + v.emplace_back(prev, cur); prev = cur; + } + }; + // Author one glyph type into a unit-cell stroke list. + auto unit_glyph = [&](T type, std::vector>& v) { + switch (type) { + case T::Horizontal: seg(v, {-0.5, 0}, {0.5, 0}); break; + case T::Vertical: seg(v, {0, -0.5}, {0, 0.5}); break; + case T::Parallel: seg(v, {-0.35, -0.5}, {0.0, 0.5}); seg(v, {0.05, -0.5}, {0.4, 0.5}); break; + case T::Perpendicular: seg(v, {-0.4, 0.5}, {-0.4, -0.4}); seg(v, {-0.4, -0.4}, {0.5, -0.4}); break; + case T::Coincident: circ(v, {0, 0}, 0.42); break; + case T::Concentric: circ(v, {0, 0}, 0.5); circ(v, {0, 0}, 0.24); break; + case T::EqualLength:seg(v, {-0.4, 0.16}, {0.4, 0.16}); seg(v, {-0.4, -0.16}, {0.4, -0.16}); break; + case T::Tangent: circ(v, {0, -0.1}, 0.35); seg(v, {-0.5, 0.42}, {0.5, 0.42}); break; + case T::Midpoint: seg(v, {-0.4, 0}, {0.4, 0}); seg(v, {0, -0.18}, {0, 0.18}); break; + case T::Symmetric: seg(v, {0, -0.5}, {0, 0.5}); + seg(v, {-0.5, 0.4}, {-0.15, 0}); seg(v, {-0.5, -0.4}, {-0.15, 0}); + seg(v, {0.5, 0.4}, {0.15, 0}); seg(v, {0.5, -0.4}, {0.15, 0}); break; + case T::Fix: seg(v, {-0.4, -0.4}, {0.4, -0.4}); seg(v, {0.4, -0.4}, {0.4, 0.4}); + seg(v, {0.4, 0.4}, {-0.4, 0.4}); seg(v, {-0.4, 0.4}, {-0.4, -0.4}); break; + case T::Angle: seg(v, {-0.4, -0.4}, {0.4, -0.4}); seg(v, {-0.4, -0.4}, {0.3, 0.4}); break; + case T::Radius: circ(v, {0, 0}, 0.45); seg(v, {0, 0}, {0.45, 0}); break; + case T::Diameter: circ(v, {0, 0}, 0.45); seg(v, {-0.45, 0}, {0.45, 0}); break; + case T::PointOnLine: + case T::PointOnObject: seg(v, {-0.5, -0.3}, {0.5, -0.3}); circ(v, {0, 0.05}, 0.16); break; + case T::Distance: + case T::LockX: + case T::LockY: seg(v, {-0.4, 0}, {0.4, 0}); break; // generic tick + } + }; + + // Stack count per entity so successive badges step upward. + std::vector stack(m_entities.size(), 0); + const Vec2d up(0.0, 1.0); // plane-space up; offset so badge sits off the geometry + m_glyph_r = 0.6 * s; + for (size_t ci = 0; ci < cons.size(); ++ci) { + const SketchEntityConstraintDef& d = cons[ci]; + if (d.ea < 0 || d.ea >= int(m_entities.size())) continue; + const int k = stack[d.ea]++; + const Vec2d center = anchor_of(d.ea) + up * (step * (1.0 + k)); + m_glyph_hits.push_back({center, int(ci)}); + std::vector> cell; + unit_glyph(d.type, cell); + for (auto& sgp : cell) + out.emplace_back(center + sgp.first * s, center + sgp.second * s); + } +} + +void DesignSketchTool::draw_entities_preview(const std::vector& ents, const ColorRGBA& color) +{ + std::vector point_markers; + for (const SketchEntity& e : ents) { + // A Point has no polyline (entity_polyline returns nothing), so the strip path below + // cannot draw it; render it as a vertex marker, the same way the live session does. + if (e.type == SketchEntity::Type::Point) { point_markers.push_back(e.p0); continue; } + bool closed = false; + std::vector poly = entity_polyline(e, closed); + draw_quad_strip(m_highlight_model, poly, closed, color); + } + if (!point_markers.empty()) + draw_vertices(m_highlight_model, point_markers, color); +} + +// ---- In-canvas edit-op gizmo (Fillet/Chamfer/Offset/Mirror toolbar tools) ---------- +// These tools replace the docked numeric card. They operate on the LIVE session's +// m_entities/m_constraints, so they work both while drawing and after begin_edit re-opens +// a committed sketch. The SketchEngine op (context-free) is reused verbatim; the +// constraint binding is ported from DesignPanel::apply_entity_constraint into m_constraints +// via try_add_constraints (append→solve→keep / rollback). + +void DesignSketchTool::reset_op() +{ + m_op_a = m_op_b = -1; + m_op_value = 0.0; + m_op_anchor = Vec2d(0, 0); + m_op_dir = Vec2d(0, 0); + m_op_label = Vec2d(1e18, 1e18); + m_op_ghost.clear(); + m_op_dragging_arrow = false; + m_mirror_targets.clear(); +} + +// ---- Imported-art bounding-box transform gizmo (Mode::TransformArt) ---- + +void DesignSketchTool::reset_xform() +{ + m_xform_base.clear(); + m_xform_feat = -1; + m_xform_handle = -1; + m_xform_offset = Vec2d(0, 0); + m_xform_sx = m_xform_sy = 1.0; + m_xform_min = m_xform_max = Vec2d(0, 0); +} + +void DesignSketchTool::begin_imported_transform( + int feat, const std::vector>>& base_regions, + const SketchPlane& plane, const Vec2d& offset, double sx, double sy) +{ + cancel(); // drop any prior session, clears state + m_plane = plane; + m_mode = Mode::TransformArt; + m_xform_base = base_regions; + m_xform_feat = feat; + m_xform_offset = offset; + m_xform_sx = (std::abs(sx) > 1e-6) ? sx : 1.0; + m_xform_sy = (std::abs(sy) > 1e-6) ? sy : 1.0; + m_xform_handle = -1; + Vec2d mn(1e30, 1e30), mx(-1e30, -1e30); + for (const auto& region : m_xform_base) + for (const auto& contour : region) + for (const Vec2d& p : contour) { + mn.x() = std::min(mn.x(), p.x()); mn.y() = std::min(mn.y(), p.y()); + mx.x() = std::max(mx.x(), p.x()); mx.y() = std::max(mx.y(), p.y()); + } + if (mx.x() < mn.x()) { mn = Vec2d(0, 0); mx = Vec2d(0, 0); } + m_xform_min = mn; m_xform_max = mx; + m_active = true; + m_has_cursor = false; +} + +// 4 bbox corners in plane coords: 0=min/min, 1=max/min, 2=max/max, 3=min/max. The art +// transform is world = base*scale + offset (CadFeature import convention). +void DesignSketchTool::xform_world_corners(Vec2d out[4]) const +{ + const double x0 = m_xform_min.x() * m_xform_sx + m_xform_offset.x(); + const double x1 = m_xform_max.x() * m_xform_sx + m_xform_offset.x(); + const double y0 = m_xform_min.y() * m_xform_sy + m_xform_offset.y(); + const double y1 = m_xform_max.y() * m_xform_sy + m_xform_offset.y(); + out[0] = Vec2d(x0, y0); out[1] = Vec2d(x1, y0); + out[2] = Vec2d(x1, y1); out[3] = Vec2d(x0, y1); +} + +int DesignSketchTool::hit_test_xform_handle(const Vec2d& p, double tol) const +{ + Vec2d c[4]; xform_world_corners(c); + int best = -1; double bd = tol; + for (int i = 0; i < 4; ++i) { const double d = (c[i] - p).norm(); if (d < bd) { bd = d; best = i; } } + if (best >= 0) return best; + if ((0.5 * (c[0] + c[2]) - p).norm() <= tol) return 4; // centre move-handle + return -1; +} + +void DesignSketchTool::drag_xform_handle(const Vec2d& target) +{ + if (m_xform_handle < 0) return; + if (m_xform_handle == 4) { // centre move: translate by cursor delta + m_xform_offset += (target - m_xform_anchor); + m_xform_anchor = target; + emit_xform(); + return; + } + // Corner scale: hold the opposite corner (fixed world anchor O), send the grabbed + // corner to the cursor. base coords of grabbed (bg) and opposite (ba) corners. + auto base_corner = [&](int i) { + return Vec2d((i == 1 || i == 2) ? m_xform_max.x() : m_xform_min.x(), + (i == 2 || i == 3) ? m_xform_max.y() : m_xform_min.y()); + }; + const int h = m_xform_handle; + const Vec2d bg = base_corner(h); + const Vec2d ba = base_corner((h + 2) % 4); + const Vec2d O = m_xform_anchor; + const double dbx = bg.x() - ba.x(), dby = bg.y() - ba.y(); + if (std::abs(dbx) > 1e-9) { + double nsx = (target.x() - O.x()) / dbx; + if (std::abs(nsx) < 1e-4) nsx = (nsx < 0 ? -1e-4 : 1e-4); + m_xform_sx = nsx; + m_xform_offset.x() = O.x() - ba.x() * nsx; + } + if (std::abs(dby) > 1e-9) { + double nsy = (target.y() - O.y()) / dby; + if (std::abs(nsy) < 1e-4) nsy = (nsy < 0 ? -1e-4 : 1e-4); + m_xform_sy = nsy; + m_xform_offset.y() = O.y() - ba.y() * nsy; + } + emit_xform(); +} + +void DesignSketchTool::emit_xform() +{ + if (on_imported_transform) + on_imported_transform(m_xform_feat, m_xform_offset, m_xform_sx, m_xform_sy); +} + +void DesignSketchTool::render_xform_gizmo() +{ + if (m_mode != Mode::TransformArt) return; + Vec2d c[4]; xform_world_corners(c); + const ColorRGBA box(0.30f, 0.88f, 0.66f, 1.0f); + std::vector> segs; + for (int i = 0; i < 4; ++i) segs.emplace_back(c[i], c[(i + 1) % 4]); + draw_strokes(m_highlight_model, segs, 0.6, box); + const Camera& cam = wxGetApp().plater()->get_camera(); + const double hs = 7.0 / std::max(cam.get_zoom(), 1e-6); // screen-constant half-size + const ColorRGBA hcol(0.30f, 0.88f, 0.66f, 1.0f); + const ColorRGBA hhot(1.0f, 0.85f, 0.2f, 1.0f); + auto square = [&](const Vec2d& q, const ColorRGBA& col) { + const std::vector sq = { q + Vec2d(-hs, -hs), q + Vec2d(hs, -hs), + q + Vec2d(hs, hs), q + Vec2d(-hs, hs) }; + draw_fill(m_fill_model, sq, col); + }; + for (int i = 0; i < 4; ++i) square(c[i], m_xform_handle == i ? hhot : hcol); + square(0.5 * (c[0] + c[2]), m_xform_handle == 4 ? hhot : hcol); +} + +bool DesignSketchTool::op_ready() const +{ + switch (m_mode) { + case Mode::Fillet: + case Mode::Chamfer: return m_op_a >= 0 && m_op_b >= 0; + case Mode::Offset: return m_op_a >= 0; + case Mode::Mirror: return m_op_a >= 0 && !m_mirror_targets.empty(); + default: return false; + } +} + +// Corner vertex of two lines + the inward angle bisector (unit), pointing from the vertex +// into the fillet/chamfer interior. Mirrors SketchEngine::fillet_lines's geometry so the +// arrow tracks the op exactly. +bool DesignSketchTool::op_corner(int a, int b, Vec2d& C, Vec2d& bis, double& theta) const +{ + if (a < 0 || b < 0 || a >= int(m_entities.size()) || b >= int(m_entities.size())) return false; + const SketchEntity& ea = m_entities[a]; + const SketchEntity& eb = m_entities[b]; + if (ea.type != SketchEntity::Type::Line || eb.type != SketchEntity::Type::Line) return false; + const Vec2d da = ea.p1 - ea.p0, db = eb.p1 - eb.p0; + const double denom = da.x() * db.y() - da.y() * db.x(); + if (std::abs(denom) < 1e-12) return false; // parallel + const Vec2d diff = eb.p0 - ea.p0; + const double s = (diff.x() * db.y() - diff.y() * db.x()) / denom; + C = ea.p0 + s * da; + Vec2d ua = ((ea.p0 - C).norm() <= (ea.p1 - C).norm()) ? (ea.p1 - C) : (ea.p0 - C); + Vec2d ub = ((eb.p0 - C).norm() <= (eb.p1 - C).norm()) ? (eb.p1 - C) : (eb.p0 - C); + if (ua.norm() < 1e-12 || ub.norm() < 1e-12) return false; + ua.normalize(); ub.normalize(); + theta = std::acos(std::max(-1.0, std::min(1.0, ua.dot(ub)))); + bis = ua + ub; + if (bis.norm() < 1e-12) return false; // 180° corner + bis.normalize(); + return true; +} + +void DesignSketchTool::recompute_op_ghost() +{ + m_op_ghost.clear(); + if (m_mode == Mode::Fillet || m_mode == Mode::Chamfer) { + if (m_op_a < 0 || m_op_b < 0) return; + Vec2d C, bis; double theta; + if (op_corner(m_op_a, m_op_b, C, bis, theta)) { m_op_anchor = C; m_op_dir = bis; } + SketchEntity a_out, b_out, extra; + const bool ok = (m_mode == Mode::Fillet) + ? SketchEngine::fillet_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra) + : SketchEngine::chamfer_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra); + if (ok) m_op_ghost = { a_out, b_out, extra }; + } else if (m_mode == Mode::Offset) { + if (m_op_a < 0) return; + const SketchEntity& e = m_entities[m_op_a]; + if (e.type == SketchEntity::Type::Line) { + m_op_anchor = 0.5 * (e.p0 + e.p1); + Vec2d u = e.p1 - e.p0; if (u.norm() > 1e-12) u.normalize(); + m_op_dir = Vec2d(-u.y(), u.x()); // left normal = +distance side + } else if (e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc) { + m_op_anchor = e.center + Vec2d(e.radius, 0.0); + m_op_dir = Vec2d(1, 0); + } + m_op_ghost = SketchEngine::offset_entities({ e }, m_op_value); + } else if (m_mode == Mode::Mirror) { + if (m_op_a < 0 || m_mirror_targets.empty()) return; + const SketchEntity& axis = m_entities[m_op_a]; + std::vector src; + for (int ti : m_mirror_targets) + if (ti >= 0 && ti < int(m_entities.size())) src.push_back(m_entities[ti]); + m_op_ghost = SketchEngine::mirror_entities(src, axis.p0, axis.p1); + } +} + +// Route an entity pick to the active op; sets an initial value + ghost once enough +// entities are picked. Highlights the running picks via m_selection. +void DesignSketchTool::op_pick(int ei) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + const SketchEntity::Type t = m_entities[ei].type; + switch (m_mode) { + case Mode::Fillet: + case Mode::Chamfer: + if (t != SketchEntity::Type::Line) return; // corner ops need two lines + if (m_op_a < 0) m_op_a = ei; + else if (ei != m_op_a) { + m_op_b = ei; + const double la = (m_entities[m_op_a].p1 - m_entities[m_op_a].p0).norm(); + const double lb = (m_entities[m_op_b].p1 - m_entities[m_op_b].p0).norm(); + m_op_value = std::max(0.001, 0.2 * std::min(la, lb)); // a sensible starting size + recompute_op_ghost(); + } + break; + case Mode::Offset: { + m_op_a = ei; + const SketchEntity& e = m_entities[ei]; + const double sz = (e.type == SketchEntity::Type::Line) ? (e.p1 - e.p0).norm() + : std::max(e.radius * 2.0, 1.0); + m_op_value = std::max(0.001, 0.1 * sz); + recompute_op_ghost(); + break; + } + case Mode::Mirror: + if (m_op_a < 0) { + if (t != SketchEntity::Type::Line) return; // axis must be a line + m_op_a = ei; + } else if (ei != m_op_a) { + auto it = std::find(m_mirror_targets.begin(), m_mirror_targets.end(), ei); + if (it == m_mirror_targets.end()) m_mirror_targets.push_back(ei); + else m_mirror_targets.erase(it); + recompute_op_ghost(); + } + break; + default: break; + } + // Mirror the picks into m_selection so the existing highlight shows them. + m_selection.clear(); + if (m_op_a >= 0) m_selection.push_back(m_op_a); + if (m_op_b >= 0) m_selection.push_back(m_op_b); + for (int ti : m_mirror_targets) m_selection.push_back(ti); + if (on_selection_changed) on_selection_changed(int(m_selection.size())); +} + +bool DesignSketchTool::hit_test_op_arrow(const Vec2d& p, double tol) const +{ + if (!op_ready() || m_mode == Mode::Mirror) return false; + const Vec2d tip = m_op_anchor + m_op_dir * m_op_value; + return point_segment_dist(p, m_op_anchor, tip) <= tol * 1.5; +} + +void DesignSketchTool::drag_op_arrow(const Vec2d& target) +{ + const double v = (target - m_op_anchor).dot(m_op_dir); // project onto the arrow axis + if (m_mode == Mode::Offset) m_op_value = v; // signed: chooses the side + else m_op_value = std::max(0.001, v);// fillet/chamfer: positive + recompute_op_ghost(); +} + +void DesignSketchTool::open_op_editor() +{ + if (!on_inline_edit || !op_ready() || m_mode == Mode::Mirror) return; + const double sign = (m_mode == Mode::Offset && m_op_value < 0) ? -1.0 : 1.0; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, std::abs(m_op_value), "", + [this, sign](double v) { + m_op_value = (m_mode == Mode::Offset) ? sign * std::abs(v) : std::max(0.001, v); + // Entering a radius IS the commit. Leaving it as a preview meant the most obvious + // route of all — click the radius, type it, press Return — ended with the value set, + // the ghost drawn, and no geometry written; the only paths that ever applied it were + // finishing the whole sketch or clicking empty space, neither of which is signposted. + if (op_ready()) confirm_op(); + else recompute_op_ghost(); + }, + []() {}); +} + +void DesignSketchTool::render_op_gizmo(double unit_per_px) +{ + m_op_label = Vec2d(1e18, 1e18); + if (!op_ready()) return; + const ColorRGBA ghostc(0.30f, 0.88f, 0.66f, 0.55f); + draw_entities_preview(m_op_ghost, ghostc); + if (m_mode == Mode::Mirror) return; // pick-only, no arrow/label + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const Vec2d dir = (m_op_value >= 0 ? m_op_dir : -m_op_dir); + const Vec2d tip = m_op_anchor + m_op_dir * m_op_value; // signed length picks the side + std::vector> segs; + segs.emplace_back(m_op_anchor, tip); + const double as = std::max(std::abs(m_op_value) * 0.18, th * 0.8); // arrowhead size + const Vec2d nrm(-dir.y(), dir.x()); + const Vec2d back = tip - dir * as; + segs.emplace_back(tip, back + nrm * (as * 0.5)); + segs.emplace_back(tip, back - nrm * (as * 0.5)); + draw_strokes(m_highlight_model, segs, 0.6, dc); + DimAnnot a; + a.kind = (m_mode == Mode::Fillet) ? DimType::Radius : DimType::Distance; + a.value = std::abs(m_op_value); + m_op_label = tip + dir * (th * 1.2); + draw_text(m_line_model, dim_text(a), m_op_label, th, dc); +} + +// Did an entity actually change shape or position? Compares only the fields that define each +// type, so a re-solve that leaves the geometry alone reads as "unchanged" whatever else moved in +// the record. Used by the mirror postcondition below. +static bool entity_moved(const SketchEntity& a, const SketchEntity& b, double tol) +{ + if (a.type != b.type) return true; + auto moved = [tol](const Vec2d& p, const Vec2d& q) { return (p - q).norm() > tol; }; + if (moved(a.p0, b.p0)) return true; + switch (a.type) { + case SketchEntity::Type::Point: + return false; + case SketchEntity::Type::Line: + return moved(a.p1, b.p1); + case SketchEntity::Type::Circle: + return moved(a.center, b.center) || std::abs(a.radius - b.radius) > tol; + case SketchEntity::Type::Arc: + return moved(a.p1, b.p1) || moved(a.center, b.center) + || std::abs(a.radius - b.radius) > tol + || std::abs((a.end_angle - a.start_angle) - (b.end_angle - b.start_angle)) > tol; + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: + return moved(a.center, b.center) || std::abs(a.radius - b.radius) > tol + || std::abs(a.rminor - b.rminor) > tol || std::abs(a.rotation - b.rotation) > tol; + default: + return moved(a.p1, b.p1); + } +} + +void DesignSketchTool::confirm_op() +{ + if (!op_ready()) return; + using R = SketchPointRole; + using CT = SketchConstraintType; + + if (m_mode == Mode::Fillet || m_mode == Mode::Chamfer) { + const bool fillet = (m_mode == Mode::Fillet); + SketchEntity a_out, b_out, extra; + const bool ok = fillet + ? SketchEngine::fillet_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra) + : SketchEngine::chamfer_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra); + if (!ok) { reset_op(); return; } + const int a = m_op_a, b = m_op_b; + m_entities[a] = a_out; m_entities[b] = b_out; + const int xi = int(m_entities.size()); + m_entities.push_back(extra); // fillet arc / chamfer segment + auto role_near = [](const SketchEntity& ln, const Vec2d& q) -> R { + return ((ln.p0 - q).squaredNorm() <= (ln.p1 - q).squaredNorm()) ? R::P0 : R::P1; }; + const R ra = role_near(m_entities[a], extra.p0); + const R rb = role_near(m_entities[b], extra.p1); + // Drop the now-stale corner Coincident + each line's own length Distance (the op + // trimmed both legs back), then bind the new entity onto the trimmed endpoints. + auto refs = [](const SketchEntityConstraintDef& d, int e, R r) { + return (d.ea == e && d.ra == r) || (d.eb == e && d.rb == r); }; + auto self_len = [](const SketchEntityConstraintDef& d, int e) { + return d.type == CT::Distance && d.ea == e && d.eb == e; }; + auto& cs = m_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { + return (d.type == CT::Coincident && refs(d, a, ra) && refs(d, b, rb)) + || self_len(d, a) || self_len(d, b); + }), cs.end()); + auto coin = [&](R xr, int ln, R lr) { + SketchEntityConstraintDef d; d.type = CT::Coincident; d.ea = xi; d.ra = xr; d.eb = ln; d.rb = lr; return d; }; + if (fillet) { + auto tang = [&](int ln) { + SketchEntityConstraintDef d; d.type = CT::Tangent; d.ea = xi; d.eb = ln; return d; }; + const std::vector> ladder = { + { coin(R::P0, a, ra), coin(R::P1, b, rb), tang(a), tang(b) }, + { coin(R::P0, a, ra), coin(R::P1, b, rb), tang(a) }, + { coin(R::P0, a, ra), coin(R::P1, b, rb) }, + }; + for (const auto& set : ladder) if (try_add_constraints(set)) break; + } else { + try_add_constraints({ coin(R::P0, a, ra), coin(R::P1, b, rb) }); + } + } else if (m_mode == Mode::Offset) { + const int a = m_op_a; + auto out = SketchEngine::offset_entities({ m_entities[a] }, m_op_value); + if (out.empty()) { reset_op(); return; } + const int ni = int(m_entities.size()); + for (auto& o : out) m_entities.push_back(o); + const SketchEntity::Type st = m_entities[a].type; + SketchEntityConstraintDef d; d.ea = a; d.eb = ni; + bool emit = true; + if (st == SketchEntity::Type::Line) d.type = CT::Parallel; + else if (st == SketchEntity::Type::Arc || st == SketchEntity::Type::Circle) d.type = CT::Concentric; + else emit = false; + if (emit) try_add_constraints({ d }); + } else if (m_mode == Mode::Mirror) { + const SketchEntity axis = m_entities[m_op_a]; // by value (m_entities grows below) + // The sources as they stand BEFORE any of this op's constraints exist. Two jobs: every + // copy is reflected from the untouched original (so a batch that moves the sketch cannot + // feed a later copy moved geometry), and the invariant at the bottom has something to + // compare against. mirror-slot. + const std::vector before = m_entities; + const size_t cmark = m_constraints.size(); + std::vector> fresh; // copy index -> its pristine reflection + for (int ti : m_mirror_targets) { + if (ti < 0 || ti >= int(before.size())) continue; + auto out = SketchEngine::mirror_entities({ before[ti] }, axis.p0, axis.p1); + if (out.empty()) continue; + const int mi = int(m_entities.size()); + for (auto& m : out) { fresh.emplace_back(int(m_entities.size()), m); m_entities.push_back(m); } + SketchEntityConstraintDef d; d.type = CT::Symmetric; d.ea = ti; d.eb = mi; d.ec = m_op_a; + const SketchEntity::Type st = before[ti].type; + std::vector> ladder; + if (st == SketchEntity::Type::Line) { + d.ra = R::P0; d.rb = R::P0; auto p0 = d; + d.ra = R::P1; d.rb = R::P1; auto p1 = d; + ladder = { { p0, p1 }, { p0 } }; + } else if (st == SketchEntity::Type::Arc) { + // BOTH ENDS AND THE CENTRE. Binding only the centre — which is all this did — + // leaves the copy's endpoints and sweep free while the shape's own coincidences + // still tie them to its neighbours, and the solver then answers with a wildly + // different, internally consistent sketch: a slot's caps came back at r=32.2 and + // a 237 deg sweep, one rail collapsed from 62.9 mm to 2.1 mm, and the ORIGINAL + // moved with them. A circle survived the same code only because a circle has no + // endpoints to leave free, which is why the bug reads as "circles fine, rounded + // rectangles and slots destroyed". + d.ra = R::Center; d.rb = R::Center; auto ct = d; + d.ra = R::P0; d.rb = R::P0; auto p0 = d; + d.ra = R::P1; d.rb = R::P1; auto p1 = d; + // Endpoints BEFORE centre: an arc is five DoF, so {centre, p0, p1} is six + // equations and is refused; {p0, p1} is four and pins the sweep, which is the + // half that was going wild. The postcondition below is what makes the ladder + // safe — any rung that does not reproduce the preview is thrown away whole. + ladder = { { p0, p1 }, { ct, p0 }, { ct } }; + } else if (st == SketchEntity::Type::Circle) { + d.ra = R::Center; d.rb = R::Center; ladder = { { d } }; + } else if (st == SketchEntity::Type::Point) { + d.ra = R::P0; d.rb = R::P0; ladder = { { d } }; + } + for (const auto& set : ladder) if (try_add_constraints(set)) break; + } + // A MIRROR MAY NOT MOVE WHAT IT COPIED. try_add_constraints only rolls back when the + // solve FAILS, and the failure here is a solve that succeeds at something else: the + // numbers above came out of a solver that was perfectly happy. So the op checks its own + // postcondition on the geometry, and if a source moved it keeps the copies — which are + // exactly what the preview showed — and drops the whole constraint web that moved them. + // Restoring the sources needs no re-solve: the pre-batch state was itself solved, and a + // failed solve does not write back (pl5). + // BOTH HALVES. Watching only the sources caught the slot (whose web dragged everything) + // and missed the rounded rectangle, where the solver held the sources still and put the + // COPIES somewhere else: an arc has five degrees of freedom and Symmetric on centre plus + // both endpoints is six equations, so that batch is refused and the ladder degrades to a + // set that leaves the sweep free. The rule that covers both, and that is what the user + // actually asked for, is: THE APPLIED RESULT IS THE PREVIEW. Anything else drops the web. + bool disturbed = false; + for (size_t i = 0; i < before.size() && !disturbed; ++i) + disturbed = entity_moved(before[i], m_entities[i], 1e-6); + for (const auto& f : fresh) + if (!disturbed && f.first < int(m_entities.size())) + disturbed = entity_moved(f.second, m_entities[f.first], 1e-6); + if (disturbed) { + m_constraints.resize(cmark); + for (size_t i = 0; i < before.size(); ++i) m_entities[i] = before[i]; + // The copies too, and for the same reason: a batch that moved the sketch moved them + // as well, so the ones sitting in m_entities are the solver's answer, not the + // reflection. Restoring only the sources left a slot whose copy came back with a + // 13.8 mm rail and a 308 deg cap — the original was safe and the copy was still + // wrong, which is half a fix. `fresh` is what the preview drew. + for (const auto& f : fresh) + if (f.first < int(m_entities.size())) m_entities[f.first] = f.second; + } + } + reset_op(); + m_selection.clear(); + resolve_live(); + if (on_selection_changed) on_selection_changed(0); +} + +// ---- In-canvas transform gizmo (Move/Rotate/Scale/Array/PolarArray) ---- +// These replace the docked numeric cards: pick subject entities in-canvas, then a single +// draggable handle drives the continuous parameter (Move/Array offset, Rotate/Polar angle, +// Scale factor) and an editable value label sets it exactly; Array/PolarArray expose a +// second label for the copy count. A live translucent ghost previews the result. Confirm +// applies the geometry and emits the per-op constraint web (mutating ops drop the classes +// the map invalidates; additive ops bind each copy to its source) into m_constraints. + +void DesignSketchTool::reset_tf() +{ + m_tf_targets.clear(); + m_tf_pivot = Vec2d(0, 0); + m_tf_delta = Vec2d(0, 0); + m_tf_angle = 0.0; + m_tf_scale = 1.0; + m_tf_count = 3; + m_tf_handle_r = 1.0; + m_tf_ghost.clear(); + m_tf_handle = -1; + m_tf_dragging = false; + m_tf_label_a = Vec2d(1e18, 1e18); + m_tf_label_b = Vec2d(1e18, 1e18); +} + +bool DesignSketchTool::tf_ready() const { return !m_tf_targets.empty(); } + +// Centroid of the picked subject set (the rotate/scale/polar pivot, and the array origin), +// plus a reference radius (max distance from the pivot to any subject extremum) used to +// size the rotate/polar handle ring and the scale handle's unit position. +void DesignSketchTool::compute_tf_pivot() +{ + using T = SketchEntity::Type; + auto cen = [](const SketchEntity& e) -> Vec2d { + switch (e.type) { + case T::Line: return 0.5 * (e.p0 + e.p1); + case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: return e.center; + case T::BSpline: + if (!e.ctrl.empty()) { Vec2d s(0, 0); for (const auto& p : e.ctrl) s += p; return s / double(e.ctrl.size()); } + return 0.5 * (e.p0 + e.p1); + default: return e.p0; + } + }; + Vec2d c(0, 0); int n = 0; + for (int ti : m_tf_targets) + if (ti >= 0 && ti < int(m_entities.size())) { c += cen(m_entities[ti]); ++n; } + if (n == 0) { m_tf_pivot = Vec2d(0, 0); m_tf_handle_r = 1.0; return; } + m_tf_pivot = c / double(n); + double r = 0.0; + for (int ti : m_tf_targets) { + if (ti < 0 || ti >= int(m_entities.size())) continue; + const SketchEntity& e = m_entities[ti]; + auto upd = [&](const Vec2d& p) { r = std::max(r, (p - m_tf_pivot).norm()); }; + switch (e.type) { + case T::Line: upd(e.p0); upd(e.p1); break; + case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: + upd(e.center + Vec2d(e.radius, 0)); upd(e.center - Vec2d(e.radius, 0)); break; + case T::BSpline: for (const auto& p : e.ctrl) upd(p); break; + default: upd(e.p0); break; + } + } + m_tf_handle_r = std::max(r, 1.0); +} + +void DesignSketchTool::tf_pick(int ei) +{ + if (ei < 0 || ei >= int(m_entities.size())) return; + auto it = std::find(m_tf_targets.begin(), m_tf_targets.end(), ei); + if (it == m_tf_targets.end()) m_tf_targets.push_back(ei); // toggle-select like Mirror + else m_tf_targets.erase(it); + compute_tf_pivot(); + // Seed sensible starting parameters (mirrors the retired card defaults so the ghost is + // immediately visible). Only seed while still at the neutral value, so re-picking more + // targets keeps a value the user already dialled in. + if (!m_tf_targets.empty()) { + const double step = std::max(m_tf_handle_r * 1.5, 1.0); + switch (m_mode) { + case Mode::Move: + if (m_tf_delta.norm() < 1e-9) m_tf_delta = Vec2d(step, 0.0); + break; + case Mode::Array: + if (m_tf_delta.norm() < 1e-9) { + Vec2d d(step, 0.0); // default: perpendicular to a single line, else +X + if (m_tf_targets.size() == 1) { + const SketchEntity& e = m_entities[m_tf_targets[0]]; + if (e.type == SketchEntity::Type::Line) { + Vec2d t = e.p1 - e.p0; + if (t.norm() > 1e-9) { t.normalize(); d = Vec2d(-t.y(), t.x()) * step; } + } + } + m_tf_delta = d; + } + break; + case Mode::Rotate: if (std::abs(m_tf_angle) < 1e-9) m_tf_angle = M_PI / 4.0; break; // 45° + case Mode::PolarArray: if (std::abs(m_tf_angle) < 1e-9) m_tf_angle = 2.0 * M_PI; break; // 360° + case Mode::Scale: if (std::abs(m_tf_scale - 1.0) < 1e-9) m_tf_scale = 2.0; break; + default: break; + } + } + recompute_tf_ghost(); + m_selection = m_tf_targets; // reuse the existing selection highlight + if (on_selection_changed) on_selection_changed(int(m_selection.size())); +} + +void DesignSketchTool::recompute_tf_ghost() +{ + m_tf_ghost.clear(); + if (m_tf_targets.empty()) return; + std::vector src; + for (int ti : m_tf_targets) + if (ti >= 0 && ti < int(m_entities.size())) src.push_back(m_entities[ti]); + if (src.empty()) return; + const int count = std::max(2, m_tf_count); + switch (m_mode) { + case Mode::Move: + m_tf_ghost = SketchEngine::transform_entities(src, m_tf_delta, 0.0, 1.0, Vec2d(0, 0)); + break; + case Mode::Rotate: + m_tf_ghost = SketchEngine::transform_entities(src, Vec2d(0, 0), m_tf_angle, 1.0, m_tf_pivot); + break; + case Mode::Scale: + m_tf_ghost = SketchEngine::transform_entities(src, Vec2d(0, 0), 0.0, m_tf_scale, m_tf_pivot); + break; + case Mode::Array: + m_tf_ghost = SketchEngine::array_entities(src, count, m_tf_delta, 0.0, m_tf_pivot); + break; + case Mode::PolarArray: + m_tf_ghost = SketchEngine::array_entities(src, count, Vec2d(0, 0), m_tf_angle / double(count), m_tf_pivot); + break; + default: break; + } +} + +// World position of the single drag handle: at the translated/spacing tip for the linear +// ops, on the pivot-centred ring at the current angle for the rotational ops, and at the +// scaled unit position along +X for Scale. +Vec2d DesignSketchTool::tf_handle_pos() const +{ + switch (m_mode) { + case Mode::Move: + case Mode::Array: return m_tf_pivot + m_tf_delta; + case Mode::Rotate: + case Mode::PolarArray: return m_tf_pivot + m_tf_handle_r * Vec2d(std::cos(m_tf_angle), std::sin(m_tf_angle)); + case Mode::Scale: return m_tf_pivot + Vec2d(m_tf_scale * m_tf_handle_r, 0.0); + default: return m_tf_pivot; + } +} + +bool DesignSketchTool::hit_test_tf_handle(const Vec2d& p, double tol) const +{ + if (!tf_ready()) return false; + return (tf_handle_pos() - p).norm() <= tol * 2.5; +} + +void DesignSketchTool::drag_tf_handle(const Vec2d& target) +{ + switch (m_mode) { + case Mode::Move: + case Mode::Array: + m_tf_delta = target - m_tf_pivot; + break; + case Mode::Rotate: + case Mode::PolarArray: { + const Vec2d d = target - m_tf_pivot; + if (d.norm() > 1e-9) m_tf_angle = std::atan2(d.y(), d.x()); + break; + } + case Mode::Scale: { + const double r = (target - m_tf_pivot).norm(); + m_tf_scale = std::max(1e-3, r / std::max(m_tf_handle_r, 1e-9)); + break; + } + default: break; + } + recompute_tf_ghost(); +} + +void DesignSketchTool::open_tf_editor_a() +{ + if (!on_inline_edit || !tf_ready()) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + double cur; + if (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) cur = std::abs(m_tf_angle) * 180.0 / M_PI; + else if (m_mode == Mode::Scale) cur = m_tf_scale; + else cur = m_tf_delta.norm(); + Vec2d dir = m_tf_delta; if (dir.norm() > 1e-9) dir.normalize(); else dir = Vec2d(1, 0); + const double sgn = (m_tf_angle < 0) ? -1.0 : 1.0; + on_inline_edit(px, cur, "", + [this, dir, sgn](double v) { + switch (m_mode) { + case Mode::Move: case Mode::Array: m_tf_delta = dir * v; break; + case Mode::Rotate: case Mode::PolarArray: m_tf_angle = sgn * std::abs(v) * M_PI / 180.0; break; + case Mode::Scale: m_tf_scale = std::max(1e-3, v); break; + default: break; + } + recompute_tf_ghost(); + }, + []() {}); +} + +void DesignSketchTool::open_tf_editor_count() +{ + if (!on_inline_edit || !tf_ready()) return; + if (m_mode != Mode::Array && m_mode != Mode::PolarArray) return; + const wxPoint px(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, double(std::max(2, m_tf_count)), "", + [this](double v) { m_tf_count = std::max(2, int(v + 0.5)); recompute_tf_ghost(); }, + []() {}); +} + +void DesignSketchTool::render_tf_gizmo(double unit_per_px) +{ + m_tf_label_a = Vec2d(1e18, 1e18); + m_tf_label_b = Vec2d(1e18, 1e18); + if (!tf_ready()) return; + const ColorRGBA ghostc(0.30f, 0.88f, 0.66f, 0.55f); + draw_entities_preview(m_tf_ghost, ghostc); + + const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); + const ColorRGBA hot(1.0f, 0.85f, 0.2f, 1.0f); + const double th = std::max(15.0 * unit_per_px, 1e-4); + const Vec2d handle = tf_handle_pos(); + + // spoke from the pivot to the handle (+ arrowhead for the linear ops). + std::vector> segs; + segs.emplace_back(m_tf_pivot, handle); + if (m_mode == Mode::Move || m_mode == Mode::Array || m_mode == Mode::Scale) { + Vec2d dir = handle - m_tf_pivot; const double L = dir.norm(); + if (L > 1e-9) { + dir /= L; + const double as = std::max(L * 0.15, th * 0.8); + const Vec2d nrm(-dir.y(), dir.x()); + const Vec2d back = handle - dir * as; + segs.emplace_back(handle, back + nrm * (as * 0.5)); + segs.emplace_back(handle, back - nrm * (as * 0.5)); + } + } + draw_strokes(m_highlight_model, segs, 0.6, dc); + + const double hs = 6.0 * unit_per_px; // screen-constant handle marker + const std::vector sq = { handle + Vec2d(-hs, -hs), handle + Vec2d(hs, -hs), + handle + Vec2d(hs, hs), handle + Vec2d(-hs, hs) }; + draw_fill(m_fill_model, sq, m_tf_dragging ? hot : dc); + draw_vertices(m_vertex_model, { m_tf_pivot }, dc, std::max(3.0 * unit_per_px, 1e-4)); + + // primary parameter label at the handle. + std::string txt_a; + if (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) { + DimAnnot a; a.kind = DimType::Angle; a.value = std::abs(m_tf_angle) * 180.0 / M_PI; + txt_a = dim_text(a); + } else if (m_mode == Mode::Scale) { + char b[24]; std::snprintf(b, sizeof(b), "x%.2f", m_tf_scale); + for (char& ch : b) if (ch == ',') ch = '.'; + txt_a = b; + } else { + DimAnnot a; a.kind = DimType::Distance; a.value = m_tf_delta.norm(); + txt_a = dim_text(a); + } + Vec2d outw = handle - m_tf_pivot; if (outw.norm() > 1e-9) outw.normalize(); else outw = Vec2d(1, 0); + m_tf_label_a = handle + outw * (th * 1.2); + draw_text(m_line_model, txt_a, m_tf_label_a, th, dc); + + if (m_mode == Mode::Array || m_mode == Mode::PolarArray) { + char cb[16]; std::snprintf(cb, sizeof(cb), "x%d", std::max(2, m_tf_count)); + m_tf_label_b = m_tf_pivot + Vec2d(th * 1.5, th * 1.5); + draw_text(m_line_model, cb, m_tf_label_b, th, dc); + } +} + +void DesignSketchTool::confirm_transform() +{ + if (!tf_ready()) { reset_tf(); return; } + using CT = SketchConstraintType; + const std::vector targets = m_tf_targets; // snapshot by value (m_entities grows) + auto is_target = [&](int e) { + return e >= 0 && std::find(targets.begin(), targets.end(), e) != targets.end(); + }; + + if (m_mode == Mode::Move || m_mode == Mode::Rotate || m_mode == Mode::Scale) { + // MUTATING: map every subject in place, then drop the constraint classes the map + // invalidates for any constraint touching a subject. Surviving classes are + // satisfied by construction; the re-solve folds in the new placement. + const Mode mode = m_mode; + for (int ti : targets) { + if (ti < 0 || ti >= int(m_entities.size())) continue; + std::vector out; + if (mode == Mode::Move) + out = SketchEngine::transform_entities({ m_entities[ti] }, m_tf_delta, 0.0, 1.0, Vec2d(0, 0)); + else if (mode == Mode::Rotate) + out = SketchEngine::transform_entities({ m_entities[ti] }, Vec2d(0, 0), m_tf_angle, 1.0, m_tf_pivot); + else + out = SketchEngine::transform_entities({ m_entities[ti] }, Vec2d(0, 0), 0.0, m_tf_scale, m_tf_pivot); + if (!out.empty()) m_entities[ti] = out[0]; + } + auto& cs = m_constraints; + cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { + if (!(is_target(d.ea) || is_target(d.eb) || is_target(d.ec))) return false; + const bool self = (d.ea == d.eb); // self-length Distance survives translate/rotate + if (mode == Mode::Move) { + switch (d.type) { + case CT::Coincident: case CT::PointOnLine: case CT::PointOnObject: + case CT::Concentric: case CT::Symmetric: case CT::Midpoint: + case CT::Fix: case CT::LockX: case CT::LockY: return true; + case CT::Distance: return !self; + default: return false; // orientation/length preserved by translation + } + } else if (mode == Mode::Rotate) { + switch (d.type) { + case CT::EqualLength: case CT::Radius: case CT::Diameter: return false; + case CT::Distance: return !self; + default: return true; // orientation + position broken by rotation + } + } else { // Scale (uniform / conformal) + switch (d.type) { + case CT::Horizontal: case CT::Vertical: case CT::Parallel: + case CT::Perpendicular: case CT::Angle: return false; + default: return true; // size + position broken + } + } + }), cs.end()); + } else if (m_mode == Mode::Array || m_mode == Mode::PolarArray) { + // ADDITIVE: append copies of each subject, then bind each copy to its source. Lines + // get Parallel+EqualLength (linear) or EqualLength only (polar — rotation breaks + // Parallel); arc/circle copies get a per-copy Radius (equal-radius under any rigid + // map) plus Concentric when the polar pivot is the source's own centre. Emit each + // web as a degrade ladder (try_add_constraints keeps the first set the solver + // accepts, else the geometry stays unconstrained). + const bool polar = (m_mode == Mode::PolarArray); + const int count = std::max(2, m_tf_count); + for (int ti : targets) { + if (ti < 0 || ti >= int(m_entities.size())) continue; + const SketchEntity src = m_entities[ti]; // by value (m_entities grows below) + std::vector copies = polar + ? SketchEngine::array_entities({ src }, count, Vec2d(0, 0), m_tf_angle / double(count), m_tf_pivot) + : SketchEngine::array_entities({ src }, count, m_tf_delta, 0.0, m_tf_pivot); + if (copies.empty()) continue; + const int base = int(m_entities.size()); + for (auto& c : copies) m_entities.push_back(c); + const int nc = int(copies.size()); + const SketchEntity::Type st = src.type; + std::vector> ladder; + if (st == SketchEntity::Type::Line) { + auto mk = [&](bool eq, bool par) { + std::vector w; + for (int k = 0; k < nc; ++k) { + const int ci = base + k; + if (par) { SketchEntityConstraintDef dp; dp.type = CT::Parallel; dp.ea = ti; dp.eb = ci; w.push_back(dp); } + if (eq) { SketchEntityConstraintDef de; de.type = CT::EqualLength; de.ea = ti; de.eb = ci; w.push_back(de); } + } + return w; + }; + if (polar) ladder = { mk(true, false) }; + else ladder = { mk(true, true), mk(false, true) }; + } else if (st == SketchEntity::Type::Arc || st == SketchEntity::Type::Circle) { + const bool can_conc = polar && (m_tf_pivot - src.center).norm() < 1e-6; + auto mk = [&](bool conc) { + std::vector w; + for (int k = 0; k < nc; ++k) { + const int ci = base + k; + SketchEntityConstraintDef dr; dr.type = CT::Radius; dr.ea = ci; dr.value = src.radius; w.push_back(dr); + if (conc) { + SketchEntityConstraintDef dco; dco.type = CT::Concentric; + dco.ea = ti; dco.ra = SketchPointRole::Center; dco.eb = ci; dco.rb = SketchPointRole::Center; + w.push_back(dco); + } + } + return w; + }; + ladder = can_conc ? std::vector>{ mk(true), mk(false) } + : std::vector>{ mk(false) }; + } + for (auto& w : ladder) if (!w.empty() && try_add_constraints(w)) break; + } + } + reset_tf(); + m_selection.clear(); + resolve_live(); + if (on_selection_changed) on_selection_changed(0); +} + +const ColorRGBA* DesignSketchTool::sketch_hl_color(int feature) const +{ + for (const auto& h : m_hl_sketches) if (h.first == feature) return &h.second; + return nullptr; +} + +// Which step of the armed gesture is live, reported only when it moves (1c0c). Called +// from render(), which is the one place EVERY state change passes through — a per-call-site +// notification would have to be added to each of the thirty-odd tool branches and would be +// forgotten by the next one. Cheap: three ints compared per frame. +void DesignSketchTool::emit_step_hint() +{ + if (!on_step_changed) return; + int step = 0, picks = 0; + if (is_edit_op_mode()) { + picks = (m_mode == Mode::Mirror) ? int(m_mirror_targets.size()) + : int(m_op_a >= 0) + int(m_op_b >= 0); + step = (m_op_a < 0) ? 0 : (op_ready() ? 2 : 1); + } else if (is_transform_mode()) { + picks = int(m_tf_targets.size()); + step = m_tf_targets.empty() ? 0 : 1; + } else if (m_mode == Mode::Select || m_mode == Mode::Constrain) { + picks = int(m_selection.size()); + } else { + step = int(m_points.size()); + } + if (int(m_mode) == m_step_mode_last && step == m_step_last && picks == m_step_picks_last) + return; + m_step_mode_last = int(m_mode); m_step_last = step; m_step_picks_last = picks; + on_step_changed(m_mode, step, picks); +} + +void DesignSketchTool::render(GLCanvas3D& canvas) +{ + m_dim_label_seq = 0; + m_render_scale = canvas.get_scale(); + emit_step_hint(); // before the early returns: an armed tool on an empty sketch still guides + // The value field, BEFORE every early return below. It can be up in Constrain mode on a + // committed feature and on an empty sketch, and a field that is not drawn is a field that is + // not there — there is no window to fall back to any more. + if (inline_editor != nullptr) + inline_editor->render(*wxGetApp().imgui(), m_render_scale); + (void)canvas; + if (!has_display()) { + if (on_readout) on_readout(std::string()); // nothing to show -> hide HUD + return; + } + render_view_helpers(); // origin planes / world axes — drawn whenever their toggle is on + m_rubber.render(canvas); // left-drag rubber band (no-op unless one is being swept). Drawn + // here, ahead of every early return below, so a band over an empty + // plate is still visible. + if (m_active && m_mode != Mode::Constrain && m_entities.empty() && m_points.empty() + && m_display_sketches.empty()) { + if (on_readout) on_readout(std::string()); + return; + } + + + // Draw-then-edit: a creation tool that just committed a new entity/feature (gesture now + // idle) gets its result auto-selected — so render_live_quotes below computes its quotes — + // and the primary value editor armed (opened after those quotes exist, see service block). + if (m_active && is_creation_autoedit_mode() && m_points.empty() && + m_open_feature < 0 && !m_awaiting_length) { + const int n = int(m_entities.size()); + if (m_autoedit_seen >= 0 && n > m_autoedit_seen && n > 0) { + m_selection.clear(); + m_selection.push_back(n - 1); // feature_of(last) groups rect/slot/poly/ellipse + m_autoedit_pending = true; + } + m_autoedit_seen = n; + } + + GLShaderProgram* shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + glsafe(::glDisable(GL_DEPTH_TEST)); + glsafe(::glDisable(GL_CULL_FACE)); + shader->start_using(); + const Camera& camera = wxGetApp().plater()->get_camera(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix()); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + + // Persistent committed sketches (e.g. an un-consumed sketch left visible after its + // extrude is removed): faces translucent, outlines orange. Each uses its own plane. + if (!m_display_sketches.empty()) { + const SketchPlane saved_plane = m_plane; + // CYAN/BLUE MEANS SELECTED — nothing else may wear it. The unselected fill used to be + // (0.30,0.60,1.0), one shade off the selected (0.30,0.80,1.0), so an ordinary region + // read as picked and a picked one added nothing. The outlines already got this right: + // orange for a sketch, cyan for the selection. The fill now follows the same logic, so + // an unselected region is faint amber — the sketch's own colour — and every blue thing + // on screen is something you selected. + const ColorRGBA dface = design_idle_face_color(); // unselected: neutral grey, never the selection colour + const ColorRGBA sface = design_selection_color(0.34f); // selected region + const ColorRGBA dwire(1.0f, 0.55f, 0.1f, 1.0f); // normal orange outline + const ColorRGBA swire = design_selection_color(); // selected outline + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + for (const DisplaySketch& ds : m_display_sketches) { + m_plane = ds.plane; + const std::vector loops = region_loops(ds.entities); + for (int r = 0; r < int(loops.size()); ++r) { + const bool sel = (ds.feature == m_display_pick && r == m_display_pick_region); + const ColorRGBA* hlc = sketch_hl_color(ds.feature); + ColorRGBA fc = sel ? sface : dface; + if (hlc && !sel) { fc = *hlc; fc.a(0.20f); } + std::vector> hp; + for (int h : loops[r].holes) + if (h >= 0 && h < int(loops.size())) hp.push_back(loops[h].poly); + draw_fill_holed(m_fill_model, loops[r].poly, hp, fc); + } + } + glsafe(::glDisable(GL_BLEND)); + for (const DisplaySketch& ds : m_display_sketches) { + m_plane = ds.plane; + // Entities forming the selected loop (highlighted cyan); the rest stay orange. + std::vector sel_ent(ds.entities.size(), 0); + if (ds.feature == m_display_pick && m_display_pick_region >= 0) { + const std::vector loops = region_loops(ds.entities); + if (m_display_pick_region < int(loops.size())) { + // The holes light up with their region. The highlight has to show what will + // be EXTRUDED, and a hole is part of that — a plate whose bore stays orange + // while its outline turns cyan would say the circle is not coming along, + // which is precisely the thing that used to be true and is now not. + auto mark = [&](int region) { + if (region < 0 || region >= int(loops.size())) return; + for (int ei : loops[region].ents) + if (ei >= 0 && ei < int(sel_ent.size())) sel_ent[ei] = 1; + }; + mark(m_display_pick_region); + for (int h : loops[m_display_pick_region].holes) mark(h); + } + } + std::vector point_markers, sel_point_markers; + for (int i = 0; i < int(ds.entities.size()); ++i) { + const SketchEntity& e = ds.entities[i]; + // A Point has no polyline to strip; draw it as a vertex marker so a committed + // point stays visible (it vanished on commit). Selection colouring keeps working: + // sel_ent[] stays index-aligned with ds.entities, untouched for the other types. + if (e.type == SketchEntity::Type::Point) { + (sel_ent[i] ? sel_point_markers : point_markers).push_back(e.p0); + continue; + } + bool closed = false; + std::vector poly = entity_polyline(e, closed); + const ColorRGBA* hlc = sketch_hl_color(ds.feature); + ColorRGBA wc = sel_ent[i] ? swire : (hlc ? *hlc : dwire); + draw_quad_strip(m_line_model, poly, closed, wc); + } + if (!point_markers.empty()) { + const ColorRGBA* hlc = sketch_hl_color(ds.feature); + draw_vertices(m_vertex_model, point_markers, hlc ? *hlc : dwire); + } + if (!sel_point_markers.empty()) + draw_vertices(m_highlight_model, sel_point_markers, swire); + } + m_plane = saved_plane; + } + + // Nothing else to draw when no live sketch session is active — except the solid + // face/edge highlight overlay (whole-solid tint is handled by set_body_highlight). + if (!m_active) { + render_datum_planes(); + render_mate_connectors(); + render_solid_highlight(); + if (m_dbp_active) render_base_pick(); + if (m_dz_active) render_datum_gizmo(); + if (m_hx_active) render_helix_gizmo(); + if (m_rb_active) render_rib_gizmo(); + if (m_ex_active) render_extrude_gizmo(); + if (m_mv_active) render_move_gizmo(); + if (m_fl_active) render_fillet_gizmo(); + if (m_hl_active) render_hole_gizmo(); + if (m_th_active) render_thread_gizmo(); + if (m_sh_active) render_shell_gizmo(); + if (m_rv_active) render_revolve_gizmo(); + if (m_dr_active) render_draft_gizmo(); + if (m_ct_active) render_cut_gizmo(); + if (m_pt_active) render_pattern_gizmo(); + shader->stop_using(); + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glEnable(GL_DEPTH_TEST)); + return; + } + + // Imported-art transform: only the bbox + handles over the (display-overlay) art. + if (m_mode == Mode::TransformArt) { + render_xform_gizmo(); + shader->stop_using(); + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glEnable(GL_DEPTH_TEST)); + return; + } + + const ColorRGBA orange(1.0f, 0.55f, 0.1f, 1.0f); + const ColorRGBA yellow(1.0f, 0.85f, 0.2f, 1.0f); + const ColorRGBA grey(0.55f, 0.55f, 0.60f, 1.0f); + + if (m_mode == Mode::Constrain) { + const ColorRGBA cyan(0.30f, 0.80f, 1.0f, 1.0f); + const ColorRGBA red(1.0f, 0.25f, 0.25f, 1.0f); + if (m_constrain_entities) { + // Draw all entities cyan; picked Line entities highlighted red. + std::vector markers; + for (size_t i = 0; i < m_entities.size(); ++i) { + const SketchEntity& e = m_entities[i]; + const bool sel = (int(i) == m_pick0 || int(i) == m_pick1 || int(i) == m_pick2); + // Constraint-manager highlight: the entities a selected constraint + // references glow yellow (picked entities still win as red). + const bool hl = !sel && std::find(m_constraint_hl.begin(), m_constraint_hl.end(), + int(i)) != m_constraint_hl.end(); + const ColorRGBA col = sel ? red : (hl ? yellow : cyan); + if (e.type == SketchEntity::Type::Point) { markers.push_back(e.p0); continue; } + bool closed = false; + std::vector poly = entity_polyline(e, closed); + draw_quad_strip((sel || hl) ? m_highlight_model : m_line_model, poly, closed, col); + } + if (!markers.empty()) + draw_vertices(m_vertex_model, markers, cyan); + // Constraint badges (C3.4b): iconic glyphs near each constraint's entity. + { + const double upp = 1.0 / std::max(camera.get_zoom(), 1e-6); + std::vector> glyphs; + build_constraint_glyphs(upp, m_constrain_cons, glyphs); + if (!glyphs.empty()) { + const ColorRGBA badge(0.45f, 0.95f, 0.70f, 1.0f); // CAD teal-green + draw_strokes(m_fill_model, glyphs, std::max(0.9 * upp, 1e-4), badge); + } + } + shader->stop_using(); + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glEnable(GL_DEPTH_TEST)); + return; + } + draw_quad_strip(m_line_model, m_points, true, cyan); + draw_vertices(m_vertex_model, m_points, cyan); + if (m_sel_a >= 0 && m_sel_b >= 0 && + m_sel_a < int(m_points.size()) && m_sel_b < int(m_points.size())) { + std::vector seg = { m_points[m_sel_a], m_points[m_sel_b] }; + draw_quad_strip(m_highlight_model, seg, false, red); + } + shader->stop_using(); + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glEnable(GL_DEPTH_TEST)); + return; + } + + // Closed loops fill as translucent faces (the "closed loop = selectable face" + // affordance). Drawn first so the entity outlines paint over the fill. + { + // region_loops(), NOT closed_regions(): the latter returns raw polygons with no notion + // of nesting, so a circle drawn inside a rectangle was filled as its own solid disc on + // top of a solid rectangle. That is why a live sketch still showed a filled blue circle + // however often the COMMITTED renderer below was corrected — these are two separate + // renderers and only one of them had been taught about holes. + const std::vector loops = region_loops(m_entities); + if (!loops.empty()) { + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + // A bore is not a face. Skip loops that are somebody's hole, and cut those holes out + // of the region that owns them, so a plate with a hole LOOKS like one while drawing. + std::vector is_hole(loops.size(), 0); + for (const RegionLoop& L : loops) + for (int h : L.holes) + if (h >= 0 && h < int(is_hole.size())) is_hole[h] = 1; + for (size_t r = 0; r < loops.size(); ++r) { + if (is_hole[r]) continue; + std::vector> hp; + for (int h : loops[r].holes) + if (h >= 0 && h < int(loops.size())) hp.push_back(loops[h].poly); + draw_fill_holed(m_fill_model, loops[r].poly, hp, design_idle_face_color()); + } + glsafe(::glDisable(GL_BLEND)); + } + } + + // Committed entities of this session. DoF feedback (P3): a fully-constrained + // sketch (dof==0, consistent) paints green; entities touched by a conflicting + // constraint paint red; otherwise the under-constrained default (orange / grey + // construction). Selected entities always override to the shared selection colour. + // NOT white: the Design tab now paints a bed grid, and white-on-grid made a freshly + // drawn (hence auto-selected) line invisible against it. design_selection_color() is + // the same cyan the solid picks already wear, so "selected" reads the same everywhere. + const ColorRGBA white(1.0f, 1.0f, 1.0f, 1.0f); // hover handle only + const ColorRGBA sel_col = design_selection_color(); + const ColorRGBA green(0.30f, 0.85f, 0.42f, 1.0f); + const ColorRGBA conflict(1.0f, 0.22f, 0.22f, 1.0f); + const ColorRGBA opref(0.80f, 0.45f, 1.0f, 1.0f); // violet: the edit-op's reference pick + const double upp_dash = 1.0 / std::max(camera.get_zoom(), 1e-6); // world units per pixel + const ColorRGBA editing(1.0f, 0.78f, 0.10f, 1.0f); // amber: entity whose dim is being typed + const bool fully = (m_dof == 0 && m_solve_ok); + // While an auto-edit value field is open, the active step names the entities its dimension + // drives — light them up so it's obvious WHICH feature the number (e.g. a circle's radius) + // changes. + const std::vector* edit_hi = + (m_autoedit_dim_idx >= 0 && m_autoedit_dim_idx < int(m_autoedit_dims.size())) + ? &m_autoedit_dims[m_autoedit_dim_idx].hi : nullptr; + std::vector point_markers, sel_point_markers; + for (size_t i = 0; i < m_entities.size(); ++i) { + const SketchEntity& e = m_entities[i]; + const bool selected = + std::find(m_selection.begin(), m_selection.end(), int(i)) != m_selection.end(); + const bool editing_this = + edit_hi && std::find(edit_hi->begin(), edit_hi->end(), int(i)) != edit_hi->end(); + const bool bad = i < m_entity_conflict.size() && m_entity_conflict[i]; + // The first pick of an edit-op has a DIFFERENT ROLE from the rest of the selection — + // Mirror's is the axis, Fillet/Chamfer's is the first of the two lines — and until now + // every pick painted the same white, so the picture could not answer "what did I select + // as what". Violet, not cyan: cyan means SELECTED here and nothing else may wear it. + // vd6v. + const bool op_ref = is_edit_op_mode() && int(i) == m_op_a; + ColorRGBA col; + if (editing_this) col = editing; + else if (op_ref) col = opref; + else if (selected) col = sel_col; + else if (bad) col = conflict; + else if (e.construction) col = grey; + else col = fully ? green : orange; + if (e.type == SketchEntity::Type::Point) { + (selected ? sel_point_markers : point_markers).push_back(e.p0); + continue; + } + bool closed = false; + std::vector poly = entity_polyline(e, closed); + GLModel& target = (selected || editing_this || op_ref) ? m_highlight_model : m_line_model; + if (e.construction) { + for (const std::vector& d : dash_polyline(poly, closed, 9.0 * upp_dash, 6.0 * upp_dash)) + draw_quad_strip(target, d, false, col); + } else { + draw_quad_strip(target, poly, closed, col); + } + } + if (!point_markers.empty()) + draw_vertices(m_vertex_model, point_markers, yellow); + if (!sel_point_markers.empty()) + draw_vertices(m_highlight_model, sel_point_markers, sel_col); + + // Constraint badges during a LIVE sketch. They used to render only inside Mode::Constrain + // on a COMMITTED feature, so every constraint applied while drawing — which is the path the + // Constrain buttons take during a session — was invisible and unremovable: you could not + // tell whether Parallel had been applied, nor take it back. Same glyphs, same teal, sourced + // from this session's m_constraints; click one to delete it (remove_constraint_near). + if (!m_constraints.empty()) { + std::vector> glyphs; + build_constraint_glyphs(upp_dash, m_constraints, glyphs); + if (!glyphs.empty()) + draw_strokes(m_fill_model, glyphs, std::max(0.9 * upp_dash, 1e-4), + ColorRGBA(0.45f, 0.95f, 0.70f, 1.0f)); // CAD teal-green + } + + // Endpoint / centre handles so individual points are visible and pickable in the + // Select and Dimension tools (a line = a segment + 2 points). Selected ones cyan. + m_show_handles = (m_mode == Mode::Select || m_mode == Mode::Dimension); + if (m_show_handles) { + std::vector handles, sel_handles; + auto add_h = [&](int ei, SketchPointRole r, const Vec2d& q) { + const bool s = std::find(m_point_sel.begin(), m_point_sel.end(), + std::make_pair(ei, r)) != m_point_sel.end(); + (s ? sel_handles : handles).push_back(q); + }; + for (size_t i = 0; i < m_entities.size(); ++i) { + const SketchEntity& e = m_entities[i]; + switch (e.type) { + case SketchEntity::Type::Line: + add_h(int(i), SketchPointRole::P0, e.p0); + add_h(int(i), SketchPointRole::P1, e.p1); + break; + case SketchEntity::Type::Arc: + case SketchEntity::Type::EllipseArc: + add_h(int(i), SketchPointRole::P0, e.p0); + add_h(int(i), SketchPointRole::P1, e.p1); + add_h(int(i), SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::Circle: + case SketchEntity::Type::Ellipse: + add_h(int(i), SketchPointRole::Center, e.center); + break; + case SketchEntity::Type::BSpline: + add_h(int(i), SketchPointRole::P0, e.p0); + add_h(int(i), SketchPointRole::P1, e.p1); + break; + case SketchEntity::Type::Point: + break; // its own marker is drawn above + } + } + if (!handles.empty()) draw_vertices(m_vertex_model, handles, ColorRGBA(0.65f, 0.65f, 0.30f, 1.0f)); + if (!sel_handles.empty()) draw_vertices(m_highlight_model, sel_handles, sel_col); + + // Midpoint of every segment, drawn smaller and cooler than the endpoint handles + // (te8v). Without it the Midpoint snap is invisible: it exists in the + // inference engine but the user has nothing to aim at. Construction lines get one + // too — you constrain to them as readily as to real geometry. + std::vector mids; + for (const SketchEntity& e : m_entities) { + if (e.type == SketchEntity::Type::Line) + mids.push_back(0.5 * (e.p0 + e.p1)); + else if (e.type == SketchEntity::Type::Arc) { + const double am = 0.5 * (e.start_angle + e.end_angle); + mids.push_back(Vec2d(e.center.x() + e.radius * std::cos(am), + e.center.y() + e.radius * std::sin(am))); + } + } + if (!mids.empty()) + draw_vertices(m_vertex_model, mids, ColorRGBA(0.35f, 0.75f, 0.85f, 1.0f), 0.9); + + // Derived feature handles (A3): the circle RadiusHandle is not a SketchPointRole, + // so the per-point pass above doesn't draw it. Render it (cyan) + the hovered + // handle (white, larger) at a screen-constant size so they stay grabbable at any + // zoom. A4 makes these draggable; later phases add slot/rect/polygon handles. + const double upp = 1.0 / std::max(camera.get_zoom(), 1e-6); + std::vector radius_h; + for (const Handle& h : build_handles()) + if (h.role == HandleRole::RadiusHandle || h.role == HandleRole::MajorAxis || + h.role == HandleRole::MinorAxis || h.role == HandleRole::BSplineCtrl) + radius_h.push_back(h.pos); + if (!radius_h.empty()) + draw_vertices(m_vertex_model, radius_h, ColorRGBA(0.30f, 0.75f, 0.95f, 1.0f), + std::max(4.0 * upp, 1e-4)); + if (m_has_hover_handle) + draw_vertices(m_highlight_model, { m_hover_handle.pos }, white, + std::max(5.5 * upp, 1e-4)); + } + + // Placed dimension quotes (drawn in every mode so they persist while sketching). + // Pass plane-units-per-pixel so labels keep a constant on-screen size. + render_dimensions(1.0 / std::max(camera.get_zoom(), 1e-6)); + render_live_quotes(1.0 / std::max(camera.get_zoom(), 1e-6)); + // Draw-then-edit: the selection's live quotes now exist. Open the primary value editor on + // the next event-loop tick (NOT here inside the paint) so the floating field grabs focus + // cleanly — the same context the Line path opens from. m_live_quotes persists until the + // next render_live_quotes(), so the deferred open still sees this frame's values. + if (m_autoedit_pending) { + m_autoedit_pending = false; + trace_autoedit("pending -> deferring open", 0); + wxGetApp().CallAfter([this] { open_primary_autoedit(); }); + } + if (is_edit_op_mode()) + render_op_gizmo(1.0 / std::max(camera.get_zoom(), 1e-6)); + if (is_transform_mode()) + render_tf_gizmo(1.0 / std::max(camera.get_zoom(), 1e-6)); + + // In-progress entity preview for the active tool. + const ColorRGBA preview = m_construction ? grey : orange; + switch (m_mode) { + + case Mode::Select: + case Mode::Dimension: + break; // selection highlight / placed quotes are drawn above; no rubber-band + + case Mode::Polyline: { + std::vector pts = m_points; + if (m_has_cursor) + pts.push_back(m_cursor); + draw_quad_strip(m_highlight_model, pts, false, preview); + draw_vertices(m_vertex_model, m_points, yellow); + // Teal rubber-band = the new segment is locked to an inference angle. + if (m_cursor_locked && m_has_cursor && !m_points.empty()) { + const ColorRGBA lock(0.10f, 0.85f, 0.80f, 1.0f); + std::vector seg = { m_points.back(), m_cursor }; + draw_quad_strip(m_line_model, seg, false, lock); + } + break; + } + + case Mode::Line: { + std::vector pts = m_points; + if (m_has_cursor && m_points.size() == 1) + pts.push_back(m_cursor); + if (pts.size() >= 2) { + const ColorRGBA col = (m_cursor_locked && m_points.size() == 1) + ? ColorRGBA(0.10f, 0.85f, 0.80f, 1.0f) : preview; + draw_quad_strip(m_highlight_model, pts, false, col); + } + draw_vertices(m_vertex_model, m_points, yellow); + break; + } + + case Mode::CornerRect: { + if (m_points.size() == 1 && m_has_cursor) { + const Vec2d A = m_points[0]; + const Vec2d B = m_cursor; + std::vector corners = { A, Vec2d(B.x(), A.y()), B, Vec2d(A.x(), B.y()) }; + draw_quad_strip(m_highlight_model, corners, true, preview); + } + break; + } + + case Mode::CenterRect: { + if (m_points.size() == 1 && m_has_cursor) { + const Vec2d C = m_points[0]; + const Vec2d P = m_cursor; + const double hx = std::abs(P.x() - C.x()); + const double hy = std::abs(P.y() - C.y()); + std::vector corners = { + Vec2d(C.x() - hx, C.y() - hy), Vec2d(C.x() + hx, C.y() - hy), + Vec2d(C.x() + hx, C.y() + hy), Vec2d(C.x() - hx, C.y() + hy) }; + draw_quad_strip(m_highlight_model, corners, true, preview); + draw_vertices(m_vertex_model, { C }, yellow); + } + break; + } + + case Mode::ObliqueRect: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 2 && m_has_cursor) { + const Vec2d A = m_points[0], B = m_points[1]; + Vec2d u = B - A; + if (u.squaredNorm() > 1e-12) { + u.normalize(); + const Vec2d n(-u.y(), u.x()); + const double w = n.dot(m_cursor - A); + draw_quad_strip(m_highlight_model, { A, B, B + n * w, A + n * w }, true, preview); + } + } + break; + } + + case Mode::RoundedRect: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + const Vec2d A = m_points[0], B = m_cursor; // box not yet fixed: plain rect + draw_quad_strip(m_highlight_model, { A, Vec2d(B.x(), A.y()), B, Vec2d(A.x(), B.y()) }, true, preview); + } else if (m_points.size() == 2 && m_has_cursor) { + draw_entities_preview(make_rounded_rect(m_points[0], m_points[1], m_cursor), preview); + } + break; + } + + case Mode::CenterCircle: { + if (m_points.size() == 1 && m_has_cursor) { + const Vec2d C = m_points[0]; + const double r = (m_cursor - C).norm(); + draw_quad_strip(m_highlight_model, circle_polygon(C, r), true, preview); + draw_vertices(m_vertex_model, { C }, yellow); + } + break; + } + + case Mode::TwoPointCircle: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + const Vec2d C = (m_points[0] + m_cursor) * 0.5; + const double r = (m_cursor - m_points[0]).norm() * 0.5; + draw_quad_strip(m_highlight_model, circle_polygon(C, r), true, preview); + } + break; + } + + case Mode::ThreePointCircle: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 2 && m_has_cursor) + draw_entities_preview(make_three_point_circle(m_points[0], m_points[1], m_cursor), preview); + break; + } + + case Mode::ThreePointArc: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 2 && m_has_cursor) + draw_entities_preview(make_three_point_arc(m_points[0], m_points[1], m_cursor), preview); + break; + } + + case Mode::TangentArc: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) + draw_entities_preview(make_tangent_arc(m_points[0], m_cursor), preview); + break; + } + + case Mode::CenterArc: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + // center placed: show the radius rubber-band as a faint guide circle + SketchEntity g; g.type = SketchEntity::Type::Circle; g.center = m_points[0]; + g.p0 = m_points[0]; g.radius = (m_cursor - m_points[0]).norm(); g.construction = true; + draw_entities_preview({ g }, preview); + } else if (m_points.size() == 2 && m_has_cursor) { + draw_entities_preview(make_center_arc(m_points[0], m_points[1], m_cursor), preview); + } + break; + } + + case Mode::Slot: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + draw_quad_strip(m_highlight_model, { m_points[0], m_cursor }, false, grey); + } else if (m_points.size() == 2 && m_has_cursor) { + Vec2d u = m_points[1] - m_points[0]; + if (u.squaredNorm() > 1e-12) { + u.normalize(); + const Vec2d n(-u.y(), u.x()); + const double w = std::abs(n.dot(m_cursor - m_points[0])); + draw_entities_preview(make_slot(m_points[0], m_points[1], w), preview); + } + } + break; + } + + case Mode::ArcSlot: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + // center placed: faint guide circle for the centerline radius + SketchEntity g; g.type = SketchEntity::Type::Circle; g.center = m_points[0]; + g.p0 = m_points[0]; g.radius = (m_cursor - m_points[0]).norm(); g.construction = true; + draw_entities_preview({ g }, preview); + } else if (m_points.size() == 2 && m_has_cursor) { + draw_entities_preview(make_center_arc(m_points[0], m_points[1], m_cursor), preview); // centerline arc + } else if (m_points.size() == 3 && m_has_cursor) { + const double Rc = (m_points[1] - m_points[0]).norm(); + const double w = std::abs((m_cursor - m_points[0]).norm() - Rc); + draw_entities_preview(make_arc_slot(m_points[0], m_points[1], m_points[2], w), preview); + } + break; + } + + case Mode::Polygon: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) + draw_entities_preview(make_polygon(m_points[0], m_cursor, m_polygon_sides), preview); + break; + } + + case Mode::Ellipse: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + SketchEntity g; g.type = SketchEntity::Type::Line; + g.p0 = m_points[0]; g.p1 = m_cursor; g.construction = true; // major-axis rubber band + draw_entities_preview({ g }, preview); + } else if (m_points.size() == 2 && m_has_cursor) { + draw_entities_preview(make_ellipse(m_points[0], m_points[1], m_cursor), preview); + } + break; + } + + case Mode::EllipseArc: { + draw_vertices(m_vertex_model, m_points, yellow); + if (m_points.size() == 1 && m_has_cursor) { + SketchEntity g; g.type = SketchEntity::Type::Line; + g.p0 = m_points[0]; g.p1 = m_cursor; g.construction = true; + draw_entities_preview({ g }, preview); + } else if (m_points.size() == 2 && m_has_cursor) { + draw_entities_preview(make_ellipse(m_points[0], m_points[1], m_cursor), preview); + } else if (m_points.size() == 3 && m_has_cursor) { + std::vector full = make_ellipse(m_points[0], m_points[1], m_points[2]); + for (auto& e : full) e.construction = true; // faint full ellipse + draw_entities_preview(full, preview); + } else if (m_points.size() == 4 && m_has_cursor) { + draw_entities_preview(make_ellipse_arc(m_points[0], m_points[1], m_points[2], + m_points[3], m_cursor), preview); + } + break; + } + + case Mode::BSpline: { + std::vector poles = m_points; + if (m_has_cursor) poles.push_back(m_cursor); + // Faint control polygon as a placement guide. + if (poles.size() >= 2) { + std::vector guide; + for (size_t i = 1; i < poles.size(); ++i) { + SketchEntity g; g.type = SketchEntity::Type::Line; + g.p0 = poles[i - 1]; g.p1 = poles[i]; g.construction = true; + guide.push_back(g); + } + draw_entities_preview(guide, grey); + } + // The spline curve itself. + if (poles.size() >= 2) + draw_quad_strip(m_highlight_model, bspline_polyline(poles), false, preview); + draw_vertices(m_vertex_model, m_points, yellow); + break; + } + + case Mode::Trim: + case Mode::Extend: { + // Hover preview: paint the exact sub-portion a click would cut (Trim) / add (Extend) + // in red, recomputed from the cursor every frame (never persisted). The pick tolerance + // matches on_mouse: ~8 px projected to plane units, x3 (here via zoom -> units/px). + if (m_has_cursor) { + const double upp = 1.0 / std::max(camera.get_zoom(), 1e-6); + int subj = -1; + std::vector removed; + const ColorRGBA cut(1.0f, 0.2f, 0.2f, 1.0f); + if (compute_trim_preview(m_cursor, 24.0 * upp, m_mode == Mode::Extend, subj, removed)) + draw_quad_strip(m_highlight_model, removed, false, cut); + } + break; + } + + case Mode::Point: + case Mode::Constrain: + break; + } + + // Inference hint: highlight the snapped target under the cursor (C1.3). Colour + // encodes what the placed point will be Coincident/PointOnObject/Fixed onto. + if (m_has_cursor && m_mode != Mode::Constrain && m_cursor_snap.snapped()) { + ColorRGBA hint(1.0f, 0.55f, 0.1f, 1.0f); // endpoint: orange + switch (m_cursor_snap.kind) { + case InferenceSnap::Kind::Midpoint: hint = ColorRGBA(0.35f, 0.90f, 0.75f, 1.0f); break; // teal + case InferenceSnap::Kind::Center: hint = ColorRGBA(0.30f, 0.80f, 1.0f, 1.0f); break; // cyan + case InferenceSnap::Kind::Origin: hint = ColorRGBA(1.0f, 0.30f, 0.85f, 1.0f); break; // magenta + case InferenceSnap::Kind::OnEdge: hint = ColorRGBA(0.45f, 0.70f, 1.0f, 1.0f); break; // blue + default: break; + } + draw_vertices(m_highlight_model, { m_cursor_snap.point }, hint); + } + + shader->stop_using(); + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glEnable(GL_DEPTH_TEST)); + + if (on_readout) on_readout(build_readout()); // bottom-right viewport HUD +} + +// Compact "current values" for the bottom-right HUD: the live segment being drawn (length +// + bearing) takes priority; otherwise the selected entity's characteristic quotes (the +// same values render_live_quotes just drew on the geometry). +std::string DesignSketchTool::build_readout() const +{ + auto en = [](char* b) { for (char* c = b; *c; ++c) if (*c == ',') *c = '.'; }; + if (m_active && (m_mode == Mode::Line || m_mode == Mode::Polyline) && + !m_points.empty() && m_has_cursor) { + const Vec2d d = m_cursor - m_points.back(); + double ang = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (ang < 0.0) ang += 360.0; + char b[80]; std::snprintf(b, sizeof(b), "L %.2f mm %.1f\xC2\xB0", d.norm(), ang); + en(b); + std::string out = b; + // Tell the user how to end a polyline chain — there's no other affordance for it. + if (m_mode == Mode::Polyline && m_points.size() >= 2) + out += " right-click or double-click to finish, click start to close"; + return out; + } + if (!m_active) return std::string(); + std::string out; + for (const DimAnnot& q : m_live_quotes) { // selection's Length/Radius/Width/… quotes + if (!out.empty()) out += " "; + out += dim_text(q); + } + return out; +} + +// Distance from point p to the segment [a,b] in plane (2D) coordinates. +static double point_segment_dist(const Vec2d& p, const Vec2d& a, const Vec2d& b) +{ + const Vec2d ab = b - a; + const double len2 = ab.squaredNorm(); + if (len2 < 1e-12) + return (p - a).norm(); + double t = (p - a).dot(ab) / len2; + t = std::max(0.0, std::min(1.0, t)); + return (p - (a + t * ab)).norm(); +} + +// Even-odd point-in-polygon test (plane coords), for picking a closed-loop interior. +static bool point_in_poly(const Vec2d& q, const std::vector& poly) +{ + if (poly.size() < 3) return false; + bool in = false; + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) { + const Vec2d& a = poly[i]; + const Vec2d& b = poly[j]; + if (((a.y() > q.y()) != (b.y() > q.y())) && + (q.x() < (b.x() - a.x()) * (q.y() - a.y()) / (b.y() - a.y()) + a.x())) + in = !in; + } + return in; +} + +// Möller–Trumbore ray/triangle intersection in 3D world space. ro=ray origin, rd=ray dir +// (not necessarily unit). Returns true + the ray parameter t (>0) of the hit. +static bool ray_triangle(const Vec3d& ro, const Vec3d& rd, + const Vec3d& v0, const Vec3d& v1, const Vec3d& v2, double& t) +{ + const Vec3d e1 = v1 - v0, e2 = v2 - v0; + const Vec3d p = rd.cross(e2); + const double det = e1.dot(p); + if (std::abs(det) < 1e-12) return false; // parallel + const double inv = 1.0 / det; + const Vec3d s = ro - v0; + const double u = s.dot(p) * inv; + if (u < -1e-9 || u > 1.0 + 1e-9) return false; + const Vec3d q = s.cross(e1); + const double v = rd.dot(q) * inv; + if (v < -1e-9 || u + v > 1.0 + 1e-9) return false; + t = e2.dot(q) * inv; + return t > 1e-9; +} + +// Shortest distance between an infinite ray (ro+rd) and a 3D segment [a,b]. +static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a, const Vec3d& b) +{ + const Vec3d d1 = rd, d2 = b - a, r = ro - a; + const double A = d1.dot(d1), B = d1.dot(d2), C = d2.dot(d2), D = d1.dot(r), E = d2.dot(r); + const double denom = A * C - B * B; + double s = (std::abs(denom) > 1e-12) ? (A * E - B * D) / denom : 0.0; // param on segment + s = std::max(0.0, std::min(1.0, s)); + double tt = (B * s - D) / std::max(A, 1e-12); // param on ray + tt = std::max(0.0, tt); // ray is forward-only + const Vec3d pr = ro + tt * d1, ps = a + s * d2; + return (pr - ps).norm(); +} + +// Screen-plane distance from p to a sketch entity, for click picking in Constrain +// mode. Circles/arcs measure distance to the ring; points to their position. +static double entity_pick_dist(const Vec2d& p, const SketchEntity& e) +{ + switch (e.type) { + case SketchEntity::Type::Line: return point_segment_dist(p, e.p0, e.p1); + case SketchEntity::Type::Point: return (p - e.p0).norm(); + case SketchEntity::Type::Circle: + case SketchEntity::Type::Arc: return std::abs((p - e.center).norm() - e.radius); + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: { + // Accurate edge pick: sample the true ellipse outline as a polyline and take the + // min segment distance. The crude mean-radius circle mis-picks eccentric ellipses + // (the outline at the major/minor extremes is far from that circle), which made the + // face-fill swallow edge clicks. Full ellipse sweeps 0..2pi; an arc its param range. + const double cu = std::cos(e.rotation), su = std::sin(e.rotation); + const bool full = (e.type == SketchEntity::Type::Ellipse); + const double t0 = full ? 0.0 : e.start_angle; + const double t1 = full ? 2.0 * M_PI : e.end_angle; + const int n = 48; + double best = 1e30; Vec2d prev; + for (int i = 0; i <= n; ++i) { + const double t = t0 + (t1 - t0) * double(i) / n; + const double lx = e.radius * std::cos(t), ly = e.rminor * std::sin(t); // local + const Vec2d q(e.center.x() + lx * cu - ly * su, + e.center.y() + lx * su + ly * cu); // world + if (i > 0) best = std::min(best, point_segment_dist(p, prev, q)); + prev = q; + } + return best; + } + case SketchEntity::Type::BSpline: { + const std::vector poly = bspline_polyline(e.ctrl); + double best = 1e30; + for (size_t i = 1; i < poly.size(); ++i) + best = std::min(best, point_segment_dist(p, poly[i - 1], poly[i])); + return best; + } + } + return 1e30; +} + +// Adjacency endpoints for loop walking (Circles/Points have none -> stand-alone). +static void entity_endpoints(const SketchEntity& e, std::vector& out) +{ + out.clear(); + if (e.type == SketchEntity::Type::Line) { + out.push_back(e.p0); + out.push_back(e.p1); + } else if (e.type == SketchEntity::Type::Arc) { + out.push_back(e.center + e.radius * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle))); + out.push_back(e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle))); + } +} + +// Reference point for positioning ops: a Point's position, or a Circle/Arc centre. +// Lines have no single reference point (return false). +static bool entity_ref_point(const SketchEntity& e, Vec2d& out) +{ + switch (e.type) { + case SketchEntity::Type::Point: out = e.p0; return true; + case SketchEntity::Type::Circle: + case SketchEntity::Type::Arc: + case SketchEntity::Type::Ellipse: + case SketchEntity::Type::EllipseArc: out = e.center; return true; + default: return false; // Line + } +} + +// Rigidly translate a whole entity (keeps size/shape). +static void translate_entity(SketchEntity& e, const Vec2d& d) +{ + e.p0 += d; + e.p1 += d; + e.center += d; + for (auto& cp : e.ctrl) cp += d; // BSpline poles +} + +// Select whatever the cursor is over, for a RIGHT-click. In every CAD application the context +// menu belongs to the thing you pointed at; here the menu was built from whatever happened to be +// selected already, so right-clicking a line you had not left-clicked first offered the empty- +// selection vocabulary and its own Delete/Length/Trim rows were nowhere. The offer table already +// described all of those for SkLine/SkArc/SkPoint — the pick was the missing half. +// +// Nothing is stolen from an existing selection: if the entity under the cursor is already part +// of it, the selection is left exactly as it is, so right-clicking one member of a multi-entity +// pick still offers the multi-entity verbs. +// ---- Scripted surface (MCP) ------------------------------------------------------------ + +int DesignSketchTool::add_entities_scripted(const std::vector& ents) +{ + if (ents.empty()) return -1; + const int base = int(m_entities.size()); + for (const SketchEntity& e : ents) m_entities.push_back(e); + // Auto-constrain, but do NOT let the inference move what the caller specified. A gesture + // gets 3 degrees of slack because a hand cannot click an exact horizontal; a scripted add + // has already said exactly what it means, and snapping a segment 2 degrees off to exactly + // horizontal silently rewrites it. Measured on a real drawing: feeding the 64 flattened + // segments of one circle moved vertices by up to 0.058 mm and shrank the enclosed area by + // 0.067%, because several segments of the polygon fell inside that 3 degree window. Exact + // coincidence inference is unaffected — it already tests to 1e-6 — so chains still weld + // and genuinely axis-aligned scripted geometry still gets its Horizontal/Vertical. + // + // ZERO, not 1e-4. Any window at all is a window that moves the caller's points, and 1e-4 rad + // was still wide enough to catch the short chords of a small flattened circle: on four of + // the 39 corpus drawings the loops that came back wrong were all TINY (1.4 to 13 mm^2), out + // by up to 7e-4 relative, because a 0.005 degree tilt on a 0.3 mm chord is inside 1e-4. + // With zero, only a segment that is EXACTLY axis-aligned is constrained, and constraining + // something already true cannot move it. 8xg1. + // The weld window closes too. Two endpoints a micron apart are not the same point when a + // caller typed both of them: on MPD681, 20 of 363 scripted segments were dragged onto a + // common point up to 0.0021 mm away, because welding is TRANSITIVE and three vertices near + // the origin chained into one. Exactly-equal endpoints still weld, which is what keeps a + // scripted profile closed — a ring's last point IS its first point. + infer_auto_constraints(base, 0.0, 0.0); + resolve_live(); + // A scripted add is not a drawn gesture, and draw-then-edit must not fire for it. The render + // pass arms that on a jump in the entity count (see the m_autoedit_seen block in render()), + // so a bulk load made while a creation tool is armed selected the last scripted entity and + // opened that tool's value field — which freezes the canvas (on_mouse_impl returns early + // while m_awaiting_length) and swallows every letter (in_text includes inline_busy()). The + // symptom was that the first key and click after sketch_add did nothing until one Escape had + // dismissed the field. Resyncing the baseline here leaves an ALREADY open field alone; it + // only stops this add from being read as something the user just drew. j7gc. + m_autoedit_seen = int(m_entities.size()); + return base; +} + +bool DesignSketchTool::select_indices(const std::vector& idx) +{ + m_selection.clear(); + m_point_sel.clear(); + const int n = int(m_entities.size()); + for (int i : idx) + if (i >= 0 && i < n && + std::find(m_selection.begin(), m_selection.end(), i) == m_selection.end()) + m_selection.push_back(i); + if (on_selection_changed) on_selection_changed(int(m_selection.size())); + return !m_selection.empty(); +} + +DesignSketchTool::LoopReport DesignSketchTool::loop_report() const +{ + LoopReport out; + + // Closed regions and their voids come straight from the code the viewport already uses to + // decide what can be extruded, so the report cannot drift from what the tool will build. + const auto regs = region_loops(m_entities); + out.loops.reserve(regs.size()); + for (const auto& r : regs) { + LoopInfo li; + li.ents = r.ents; + li.holes = r.holes; + li.closed = true; + // Analytic where the loop IS one closed curve; shoelace only where it is a chain. + // region_loops hands back the render polyline, and a circle's is a 64-gon whose area is + // 0.3% short — a number reported as "area" must not be the faceting error. + if (r.ents.size() == 1 && r.ents[0] >= 0 && r.ents[0] < int(m_entities.size()) && + (m_entities[r.ents[0]].type == SketchEntity::Type::Circle || + m_entities[r.ents[0]].type == SketchEntity::Type::Ellipse)) { + const SketchEntity& c = m_entities[r.ents[0]]; + li.area = (c.type == SketchEntity::Type::Circle) + ? M_PI * c.radius * c.radius + : M_PI * c.radius * c.rminor; + } else { + // EXACT area by Green's theorem over the chain, entity by entity, with no faceting + // anywhere. The obvious alternative — shoelace over the render polyline — is short by + // the slivers between each arc and its chords: 2.02 mm2 on a 3706.86 mm2 stadium, + // 0.054%, invisible on screen and simply wrong in a number reported as "the area". + // Correcting the shoelace afterwards does NOT work: a mirrored arc stores a negated + // sweep, so two corrections that should add cancel instead. Integrating each entity + // in TRAVERSAL order sidesteps the sign question entirely. + // line A->B : x0*y1 - x1*y0 + // arc a0->a1: Cx*r*(sin a1 - sin a0) - Cy*r*(cos a1 - cos a0) + r^2*(a1 - a0) + // Both are the integrand of the contour integral, so area2 accumulates 2*area and is + // halved once at the end. (Doubling the arc term instead reads 5913.72 on the stadium + // — exactly one arc's contribution too much, which is how the slip was caught.) + auto ent_ends = [&](int ei, Vec2d& a, Vec2d& b) { + a = m_entities[ei].p0; b = m_entities[ei].p1; + }; + const double eps = 1e-6; + double area2 = 0.0; + bool exact = true; + Vec2d cur(0, 0); + for (size_t k = 0; k < r.ents.size(); ++k) { + const int ei = r.ents[k]; + if (ei < 0 || ei >= int(m_entities.size())) { exact = false; break; } + const SketchEntity& e = m_entities[ei]; + if (e.type != SketchEntity::Type::Line && e.type != SketchEntity::Type::Arc) { + exact = false; break; // spline / ellipse arc: fall back below + } + Vec2d A, B; ent_ends(ei, A, B); + bool rev = false; + if (k == 0) { + // Orient the first entity by whichever of its ends the SECOND one touches: + // that shared point is where this entity must finish. + if (r.ents.size() > 1) { + Vec2d C, D; ent_ends(r.ents[1], C, D); + if ((A - C).norm() < eps || (A - D).norm() < eps) rev = true; + } + } else { + if ((B - cur).norm() < eps) rev = true; + else if ((A - cur).norm() >= eps) { exact = false; break; } + } + const Vec2d P = rev ? B : A; + const Vec2d Q = rev ? A : B; + if (e.type == SketchEntity::Type::Line) { + area2 += P.x() * Q.y() - Q.x() * P.y(); + } else { + const double a0 = rev ? e.end_angle : e.start_angle; + const double a1 = rev ? e.start_angle : e.end_angle; + area2 += e.center.x() * e.radius * (std::sin(a1) - std::sin(a0)) + - e.center.y() * e.radius * (std::cos(a1) - std::cos(a0)) + + e.radius * e.radius * (a1 - a0); + } + cur = Q; + } + if (exact) { + li.area = 0.5 * area2; + } else { + // Splines and elliptical arcs have no closed form here; the render polyline is + // the honest best estimate, and it is flagged as such by being the fallback. + double a = 0.0; + for (size_t i = 0; i + 1 < r.poly.size(); ++i) + a += r.poly[i].x() * r.poly[i + 1].y() - r.poly[i + 1].x() * r.poly[i].y(); + if (r.poly.size() > 2) + a += r.poly.back().x() * r.poly.front().y() - r.poly.front().x() * r.poly.back().y(); + li.area = 0.5 * a; + } + } + out.loops.push_back(std::move(li)); + } + + // Open ends: an endpoint of an open curve that no other open curve's endpoint meets. This is + // the actionable half of the report — it says WHERE the profile fails to close, in plane + // coordinates, instead of only that it does. + struct End { Vec2d p; }; + std::vector ends; + for (const SketchEntity& e : m_entities) { + if (e.construction) continue; + if (e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc || + e.type == SketchEntity::Type::EllipseArc || e.type == SketchEntity::Type::BSpline) { + ends.push_back({ e.p0 }); + ends.push_back({ e.p1 }); + } + } + const double eps = sketch_join_tol(); + for (size_t i = 0; i < ends.size(); ++i) { + int met = 0; + for (size_t j = 0; j < ends.size(); ++j) { + if (i == j) continue; + if ((ends[i].p - ends[j].p).norm() < eps) ++met; + } + if (met == 0) { + // Report each free end once; two ends of the same gap are two different points. + bool dup = false; + for (const Vec2d& q : out.open_ends) + if ((q - ends[i].p).norm() < eps) { dup = true; break; } + if (!dup) out.open_ends.push_back(ends[i].p); + } + } + return out; +} + +int DesignSketchTool::heal_coincidences(double tol, bool ignore_construction) +{ + if (tol <= 0.0) tol = 1e-3; + // Endpoint roles an entity exposes, same set infer_auto_constraints matches on. + auto roles_of = [](const SketchEntity& e, SketchPointRole out[2]) -> int { + switch (e.type) { + case SketchEntity::Type::Line: + case SketchEntity::Type::Arc: + case SketchEntity::Type::BSpline: + case SketchEntity::Type::EllipseArc: + out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + case SketchEntity::Type::Point: + out[0] = SketchPointRole::P0; return 1; + default: return 0; + } + }; + + const int n = int(m_entities.size()); + int welded = 0; + std::vector cands; + for (int i = 0; i < n; ++i) { + if (ignore_construction && m_entities[i].construction) continue; + SketchPointRole ir[2]; const int ni = roles_of(m_entities[i], ir); + for (int a = 0; a < ni; ++a) { + Vec2d pa; if (!point_at(i, ir[a], pa)) continue; + for (int j = i + 1; j < n; ++j) { + if (ignore_construction && m_entities[j].construction) continue; + SketchPointRole jr[2]; const int nj = roles_of(m_entities[j], jr); + for (int b = 0; b < nj; ++b) { + Vec2d pb; if (!point_at(j, jr[b], pb)) continue; + const double d = (pa - pb).norm(); + if (d > tol) continue; + if (has_coincident(i, ir[a], j, jr[b])) continue; + // WELD FIRST, then constrain. Handing the solver two points a tolerance + // apart and asking it to make them equal lets it move the rest of the sketch + // to get there; snapping them together first means the constraint it is + // asked to satisfy is already true, so nothing else shifts. + if (d > 0.0) { set_point(j, jr[b], pa); pb = pa; } + SketchEntityConstraintDef c; + c.type = SketchConstraintType::Coincident; + c.ea = i; c.ra = ir[a]; c.eb = j; c.rb = jr[b]; + cands.push_back(c); + ++welded; + } + } + } + } + if (!cands.empty()) { + try_add_constraints(cands); + resolve_live(); + } + return welded; +} + +bool DesignSketchTool::select_at_screen(GLCanvas3D& canvas, int sx, int sy) +{ + if (!is_active()) return false; + const Linef3 ray = canvas.mouse_ray(Point(sx, sy)); + const Linef3 ray8 = canvas.mouse_ray(Point(sx + 8, sy)); + const Vec2d p = m_plane.project(ray.a, ray.vector()); + const Vec2d p8 = m_plane.project(ray8.a, ray8.vector()); + const double tol = std::max(1e-3, (p8 - p).norm()); + + // A point handle beats the curve it belongs to, same precedence the left-click pick uses. + int ei = -1; SketchPointRole role = SketchPointRole::P0; + if (hit_test_point(p, tol, ei, role)) { + // A Point ENTITY is its own handle: there is nothing else to select there. Taking the + // handle branch for it filled m_point_sel and left m_selection empty — and the offer + // counts only m_selection, so right-clicking a sketch point produced the EMPTY + // vocabulary and every SkPoint row in the atlas was unreachable from the menu. Other + // entities keep the handle pick: a line's endpoint is a drag target, not a thing with a + // vocabulary of its own. lnri. + if (ei >= 0 && ei < int(m_entities.size()) + && m_entities[ei].type == SketchEntity::Type::Point) { + if (std::find(m_selection.begin(), m_selection.end(), ei) != m_selection.end()) + return false; // already selected: leave it alone + m_selection.assign(1, ei); + m_point_sel.clear(); + if (on_selection_changed) on_selection_changed(1); + return true; + } + const auto pr = std::make_pair(ei, role); + if (std::find(m_point_sel.begin(), m_point_sel.end(), pr) != m_point_sel.end()) + return false; // already selected: leave it alone + m_selection.clear(); + m_point_sel.assign(1, pr); + if (on_selection_changed) on_selection_changed(1); + return true; + } + + const int hit = hit_test(p, tol); + if (hit < 0) return false; + if (std::find(m_selection.begin(), m_selection.end(), hit) != m_selection.end()) + return false; // already selected: leave it alone + m_selection.assign(1, hit); + m_point_sel.clear(); + if (on_selection_changed) on_selection_changed(1); + return true; +} + +int DesignSketchTool::hit_test(const Vec2d& p, double tol) const +{ + double best = tol; + int bi = -1; + for (size_t i = 0; i < m_entities.size(); ++i) { + const double d = entity_pick_dist(p, m_entities[i]); + if (d < best) { best = d; bi = int(i); } + } + return bi; +} + +std::vector DesignSketchTool::connected_loop(int seed) const +{ + std::vector out; + if (seed < 0 || seed >= int(m_entities.size())) return out; + const double eps2 = sketch_join_tol() * sketch_join_tol(); + std::vector vis(m_entities.size(), false); + std::vector stack = { seed }; + vis[seed] = true; + while (!stack.empty()) { + const int cur = stack.back(); + stack.pop_back(); + out.push_back(cur); + std::vector ce; + entity_endpoints(m_entities[cur], ce); + if (ce.empty()) continue; // circle/point: not part of a chain + for (size_t j = 0; j < m_entities.size(); ++j) { + if (vis[j]) continue; + std::vector je; + entity_endpoints(m_entities[j], je); + if (je.empty()) continue; + bool adj = false; + for (const Vec2d& a : ce) + for (const Vec2d& b : je) + if ((a - b).squaredNorm() < eps2) { adj = true; break; } + if (adj) { vis[j] = true; stack.push_back(int(j)); } + } + } + return out; +} + +// Right-click has two jobs in a sketch, and they were resolved by giving one of them everything: +// the offer was excluded in sketch mode wholesale so a right-click could end a polyline chain, +// abandon an anchor or exit a tool. That made every sketch row in the atlas unreachable. +// The honest test is not "which mode are we in" but "did the tool actually USE this right-click", +// and only the tool knows. Wrapping on_mouse records that once, for every terminator, instead of +// threading a flag through the twenty-odd sites that consume a RightDown. +// Right-click abandons the anchor a draw tool has down. With NOTHING down there is nothing to +// abandon — and consuming the click anyway made the offer unreachable from every armed draw tool: +// on_mouse records the consumption in m_right_consumed and DesignCanvas's RIGHT_UP handler +// suppresses the menu whenever it is set, so right-click became a no-op that also hid the one door +// to half the vocabulary (47 of 86 verbs have no shortcut). Measured on the rig: with Line armed, +// two right-clicks in a row produced no menu and no tool change; only Escape freed it. +// Same rule as xmh6, which said it for the selection: clearing nothing is not a gesture +// terminator. ghcz. +bool DesignSketchTool::right_abandon() +{ + if (m_points.empty()) + return false; // hand it back, so the canvas opens the offer + m_points.clear(); + m_has_cursor = false; + return true; +} + +bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas) +{ + const bool consumed = on_mouse_impl(evt, canvas); + if (evt.RightDown()) + m_right_consumed = consumed; + return consumed; +} + +bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) +{ + // Track the cursor in canvas client px so the in-canvas value editor can open right + // where the user clicked (Onshape places the field at the click, not via a camera + // projection — the design canvas's viewport isn't valid outside its own paint). + m_last_mouse_x = evt.GetX(); + m_last_mouse_y = evt.GetY(); + + // Line draw-then-edit: while the length editor is open right after the second click, + // freeze the canvas so a stray move/click can't push a third point or rubber-band a + // segment under the floating field. The editor's Enter/Esc resolves it + // (apply_segment_length / keep_segment_as_drawn, the latter clears this flag). + if (m_awaiting_length) { + // Polyline terminators must work even with a per-segment field open: right-click or + // double-click accepts the current segment as drawn (close the field) and falls through + // so the Polyline handler ends the chain. Without this the freeze ate every terminator. + if (m_mode == Mode::Polyline && (evt.RightDown() || evt.LeftDClick()) && on_inline_dismiss) + on_inline_dismiss(); // -> set_inline_busy(false), m_awaiting_length=false + // The freeze exists so a stray click can't draw under the floating field — it was + // never meant to trap the camera. Let drags and the wheel through, so a field that + // opens somewhere unexpected can't leave the viewport unusable. + else if (evt.Dragging() || evt.GetWheelRotation() != 0) + return false; + else + return true; + } + + // No live session, but committed sketches are shown as overlays on the plate: a left + // click on a loop (its edge OR its closed interior) selects that Sketch feature. This + // is the ONLY interaction in display-only mode; everything else (drag/move/wheel/right) + // falls through (return false) so the camera can still orbit the plate. + if (!m_active) { + // Double-click on a committed sketch stroke OPENS IT FOR EDITING; on empty space it + // still fits the view. A sketch line has to be editable from the line, not from a tree + // row — selecting it already lit the feature, but "now go and press Edit in the panel" + // is the side-panel dependency this tab exists to remove. Fit keeps the rest of the + // plate, so nothing is taken away. + if (evt.LeftDClick()) { + int f = -1, r = -1, e = -1, ff = -1, fr = -1; double dbest = 1e30; + const Linef3 dray = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); + const Linef3 dray8 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + for (const DisplaySketch& d : m_display_sketches) { + const Vec2d dp = d.plane.project(dray.a, dray.vector()); + const Vec2d dp8 = d.plane.project(dray8.a, dray8.vector()); + hit_display_sketch(d, dp, std::max(1e-3, (dp8 - dp).norm()), f, r, e, dbest, ff, fr); + } + const int target = (f >= 0) ? f : ff; + if (target >= 0 && on_display_sketch_activated) { + dp_pick_trace("double-click -> edit sketch feature %d (entity %d)", target, e); + on_display_sketch_activated(target); + return true; + } + canvas.zoom_to_volumes(); + return true; + } + // Visual Extrude gizmo (C5b): while the Extrude card is open the depth arrow is + // grabbable — drag changes the depth live; a click (no drag) on the arrow opens the + // inline depth editor. Intercept before the early no-LeftDown bailout so Dragging/ + // LeftUp reach us; a LeftDown that misses the arrow falls through to solid/loop pick. + // Move-body gizmo (M5): three world-axis arrows on the selected body. Drag an arrow to + // translate live; a stationary click on it opens the inline offset editor; a right click + // exits move mode. A LeftDown that misses the arrows falls through to solid re-pick. + if (m_mv_active) { + if (m_mv_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + if (m_mv_drag < 3) drag_move_arrow(canvas, evt, m_mv_drag); + else drag_move_arc(canvas, evt, m_mv_drag - 3); + return true; + } + if (evt.LeftUp() && m_mv_drag >= 0) { + const int d = m_mv_drag; m_mv_drag = -1; + const bool moved = std::abs(evt.GetX() - m_mv_press_x) + + std::abs(evt.GetY() - m_mv_press_y) > 3; + if (!moved && d < 3) open_move_editor(d); // stationary click on an arrow = edit offset + return true; + } + if (evt.RightDown()) { clear_move_gizmo(); canvas.set_as_dirty(); if (on_move_exit) on_move_exit(); return true; } + if (evt.LeftDown()) { + int axis = -1; + if (hit_test_move_arrow(canvas, evt, axis)) { // translate arrows win over rings + m_mv_drag = axis; m_mv_press_x = evt.GetX(); m_mv_press_y = evt.GetY(); + return true; + } + if (hit_test_move_arc(canvas, evt, axis)) { + m_mv_drag = 3 + axis; m_mv_press_x = evt.GetX(); m_mv_press_y = evt.GetY(); + m_mv_rot_start = m_mv_rot; + double a0; if (arc_mouse_angle(canvas, evt, axis, a0)) m_mv_arc_a0 = a0; + return true; + } + } + } + // Datum-plane resize gizmo (C3): while the Plane card is open the 4 edge handles are + // grabbable — drag changes the u/v extent live. A LeftDown that misses falls through. + if (m_dz_active) { + if (m_dz_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + drag_datum_handle(canvas, evt, m_dz_drag); + return true; + } + if (evt.LeftUp() && m_dz_drag >= 0) { m_dz_drag = -1; return true; } + if (evt.LeftDown()) { + int which = -1; + if (hit_test_datum_handle(canvas, evt, which)) { + m_dz_drag = which; m_dz_press_x = evt.GetX(); m_dz_press_y = evt.GetY(); + return true; + } + } + } + // Helix gizmo: radius/height/pitch handles on the live curve, while the Helix card is open. + if (m_hx_active) { + if (m_hx_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + drag_helix_handle(canvas, evt, m_hx_drag); + return true; + } + if (evt.LeftUp() && m_hx_drag >= 0) { m_hx_drag = -1; return true; } + if (evt.LeftDown()) { + int which = -1; + if (hit_test_helix_handle(canvas, evt, which)) { + m_hx_drag = which; m_hx_press_x = evt.GetX(); m_hx_press_y = evt.GetY(); + return true; + } + } + } + // Rib thickness gizmo: two in-plane handles on the slab footprint, while the Rib card is open. + if (m_rb_active) { + if (m_rb_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + drag_rib_handle(canvas, evt, m_rb_drag); + return true; + } + if (evt.LeftUp() && m_rb_drag >= 0) { m_rb_drag = -1; return true; } + if (evt.LeftDown()) { + int which = -1; + if (hit_test_rib_handle(canvas, evt, which)) { + m_rb_drag = which; + return true; + } + } + } + // Datum base picker: HOVER highlight only here. The CLICK is handled at the very end of the + // selection fall-through (below), so picking existing geometry (committed sketch loops, + // solid faces/edges) always wins over a base-plane click — the planes never block selection. + if (m_dbp_active && evt.Moving() && !evt.LeftIsDown()) { + const int h = hit_test_base_pick(canvas, evt); + if (h != m_dbp_hover) { + m_dbp_hover = h; + canvas.set_as_dirty(); + if (h >= 0) return true; // caller render()s on true -> hover repaints on software GL + } + } + if (m_ex_active) { + if (m_ex_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + drag_extrude_arrow(canvas, evt, m_ex_drag); + return true; + } + if (evt.LeftUp() && m_ex_drag >= 0) { + const int which = m_ex_drag; m_ex_drag = -1; + const bool moved = std::abs(evt.GetX() - m_ex_press_x) + + std::abs(evt.GetY() - m_ex_press_y) > 3; + if (!moved) open_extrude_editor(which); // treat a stationary click as edit + return true; + } + if (evt.LeftDown()) { + int which = -1; + if (hit_test_extrude_arrow(canvas, evt, which)) { + m_ex_drag = which; m_ex_press_x = evt.GetX(); m_ex_press_y = evt.GetY(); + return true; + } + } + } + // Revolve angle-arc gizmo: drag the arc tip to sweep the angle; a stationary click on the + // handle opens the inline editor. A LeftDown that misses falls through to normal picking. + if (m_rv_active) { + if (m_rv_drag && evt.Dragging() && evt.LeftIsDown()) { + drag_revolve_arc(canvas, evt); + return true; + } + if (evt.LeftUp() && m_rv_drag) { + m_rv_drag = false; + const bool moved = std::abs(evt.GetX() - m_rv_press_x) + + std::abs(evt.GetY() - m_rv_press_y) > 3; + if (!moved) open_revolve_editor(); + return true; + } + if (evt.LeftDown() && hit_test_revolve_handle(canvas, evt)) { + m_rv_drag = true; m_rv_press_x = evt.GetX(); m_rv_press_y = evt.GetY(); + return true; + } + } + // Draft angle-arc gizmo: drag the arc tip to sweep the draft angle; a stationary click + // edits it. A LeftDown that misses falls through to normal picking. + if (m_dr_active) { + if (m_dr_drag && evt.Dragging() && evt.LeftIsDown()) { + drag_draft_arc(canvas, evt); + return true; + } + if (evt.LeftUp() && m_dr_drag) { + m_dr_drag = false; + return true; + } + if (evt.LeftDown() && hit_test_draft_handle(canvas, evt)) { + m_dr_drag = true; m_dr_press_x = evt.GetX(); m_dr_press_y = evt.GetY(); + return true; + } + } + // Cut gizmo: drag the offset arrow along the cut-plane normal; no positive clamp (offset is signed). + if (m_ct_active) { + if (m_ct_drag && evt.Dragging() && evt.LeftIsDown()) { + drag_cut_arrow(canvas, evt); + return true; + } + if (evt.LeftUp() && m_ct_drag) { + m_ct_drag = false; + return true; + } + if (evt.LeftDown() && hit_test_cut_arrow(canvas, evt)) { + start_cut_drag(canvas, evt); + return true; + } + } + // Pattern gizmo: drag the diamond/arc handle to set the spacing (linear) or angle (circular); + // a stationary click opens the inline editor. A LeftDown that misses falls through to picking. + if (m_pt_active) { + if (m_pt_drag && evt.Dragging() && evt.LeftIsDown()) { + drag_pattern_handle(canvas, evt); + return true; + } + if (evt.LeftUp() && m_pt_drag) { + m_pt_drag = false; + const bool moved = std::abs(evt.GetX() - m_pt_press_x) + + std::abs(evt.GetY() - m_pt_press_y) > 3; + if (!moved) open_pattern_editor(); + return true; + } + if (evt.LeftDown() && hit_test_pattern_handle(canvas, evt)) { + m_pt_drag = true; m_pt_press_x = evt.GetX(); m_pt_press_y = evt.GetY(); + return true; + } + } + // Fillet/Chamfer radius gizmo: drag the edge-anchored arrow to set the radius live; a + // stationary click opens the inline editor. A LeftDown that misses falls through to pick. + if (m_fl_active) { + if (m_fl_drag && evt.Dragging() && evt.LeftIsDown()) { + drag_fillet_arrow(canvas, evt); + return true; + } + if (evt.LeftUp() && m_fl_drag) { + m_fl_drag = false; + const bool moved = std::abs(evt.GetX() - m_fl_press_x) + + std::abs(evt.GetY() - m_fl_press_y) > 3; + if (!moved) open_fillet_editor(); // stationary click = edit + return true; + } + if (evt.LeftDown() && hit_test_fillet_arrow(canvas, evt)) { + start_fillet_drag(canvas, evt); + return true; + } + } + // Hole gizmo: drag the centre to reposition / the diameter or depth arrow to resize; a + // stationary click on an arrow opens its inline editor. A LeftDown that misses any handle + // falls through to solid/loop picking. + if (m_hl_active) { + if (m_hl_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + drag_hole_handle(canvas, evt); + return true; + } + if (evt.LeftUp() && m_hl_drag >= 0) { + const int which = m_hl_drag; m_hl_drag = -1; + const bool moved = std::abs(evt.GetX() - m_hl_press_x) + + std::abs(evt.GetY() - m_hl_press_y) > 3; + if (!moved) open_hole_editor(which); // stationary click = edit + return true; + } + if (evt.LeftDown()) { + const int which = hit_test_hole_handle(canvas, evt); + if (which >= 0) { start_hole_drag(canvas, evt, which); return true; } + } + } + // Thread gizmo: same interaction as the hole gizmo (centre / radius / length handles). + if (m_th_active) { + if (m_th_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { + drag_thread_handle(canvas, evt); + return true; + } + if (evt.LeftUp() && m_th_drag >= 0) { + const int which = m_th_drag; m_th_drag = -1; + const bool moved = std::abs(evt.GetX() - m_th_press_x) + + std::abs(evt.GetY() - m_th_press_y) > 3; + if (!moved) open_thread_editor(which); + return true; + } + if (evt.LeftDown()) { + const int which = hit_test_thread_handle(canvas, evt); + if (which >= 0) { start_thread_drag(canvas, evt, which); return true; } + } + } + // Shell gizmo: drag the inward thickness arrow; stationary click opens the inline editor. + if (m_sh_active) { + if (m_sh_drag && evt.Dragging() && evt.LeftIsDown()) { + drag_shell_arrow(canvas, evt); + return true; + } + if (evt.LeftUp() && m_sh_drag) { + m_sh_drag = false; + const bool moved = std::abs(evt.GetX() - m_sh_press_x) + + std::abs(evt.GetY() - m_sh_press_y) > 3; + if (!moved) open_shell_editor(); + return true; + } + if (evt.LeftDown() && hit_test_shell_arrow(canvas, evt)) { + start_shell_drag(canvas, evt); + return true; + } + } + // Left-drag rubber band -> whole body. Past the click budget the press becomes a sweep: + // the rectangle is anchored at the ORIGINAL press point (not at the frame where the + // threshold was crossed, which would lose the first few pixels) and the events are + // consumed from here on. Left-drag no longer orbits in this canvas — DesignCanvas puts + // orbit on middle-drag and pan on right-drag, the CAD convention — so nothing downstream + // is being starved of a gesture it used to own. + // HOVER PRE-HIGHLIGHT (9xw part 3): say what a click would take, before it is + // taken. Plain motion only — no button down, no band running — because during a drag the + // pointer is doing something else and a promise about clicking would be a lie. Returns + // false so the event still reaches the camera; this only asks for a repaint, it does not + // consume the gesture, which is the difference between a hint and a handler. + if (evt.Moving() && !evt.LeftIsDown() && !m_rubber.is_dragging()) { + if (update_solid_hover(canvas, evt)) canvas.set_as_dirty(); + return false; + } + if (evt.Dragging() && evt.LeftIsDown() && m_pick_pending) { + if (!m_rubber.is_dragging()) { + if (std::max(std::abs(evt.GetX() - m_pick_press_x), + std::abs(evt.GetY() - m_pick_press_y)) <= 8) + return false; // still inside the click budget + m_rubber.start_dragging(Vec2d(m_pick_press_x, m_pick_press_y), + GLSelectionRectangle::Select); + } + m_rubber.dragging(Vec2d(evt.GetX(), evt.GetY())); + return true; + } + // Any event with the left button up ends a band — not just LeftUp. A release that lands + // outside the canvas never sends us one, and a band left running would then paint a + // rectangle that follows the cursor with no button held. + if (m_rubber.is_dragging() && !evt.LeftIsDown()) { + m_pick_pending = false; + if (evt.LeftUp()) pick_bodies_in_rectangle(); // a release elsewhere selects nothing + m_rubber.stop_dragging(); + return evt.LeftUp(); + } + // Click vs drag. Consuming the LeftDown here killed camera orbit/pan the moment a + // solid was on screen: Orca starts a rotate drag on the press, so swallowing it meant + // the canvas never began one. (A SpaceMouse kept working — it never goes through + // wxMouseEvent.) Remember the press, let it fall through so the canvas can orbit, and + // resolve the pick on release only if the pointer stayed put. + if (evt.LeftDown()) { + m_pick_press_x = evt.GetX(); + m_pick_press_y = evt.GetY(); + m_pick_pending = true; + dp_pick_trace("down x=%d y=%d", evt.GetX(), evt.GetY()); + return false; + } + if (!(evt.LeftUp() && m_pick_pending)) { + if (evt.LeftUp()) dp_pick_trace("up with no pending press (press was eaten upstream)"); + return false; + } + m_pick_pending = false; + dp_pick_trace("up x=%d y=%d drift=%d", evt.GetX(), evt.GetY(), + std::max(std::abs(evt.GetX() - m_pick_press_x), + std::abs(evt.GetY() - m_pick_press_y))); + // Threshold per axis, at GTK's own drag threshold. A hand-held mouse drifts several + // pixels during an ordinary click — a tight budget silently swallowed real clicks and + // looked exactly like "selection does not work". Synthetic clicks never drift, which + // is why the headless rig could not show this. + if (std::max(std::abs(evt.GetX() - m_pick_press_x), + std::abs(evt.GetY() - m_pick_press_y)) > 8) { + dp_pick_trace("rejected as drag"); + return false; // it was a drag: the canvas already orbited, don't also select + } + // Committed-sketch loop pick is computed FIRST. A click that lands on a loop's + // STROKE (edge) selects that loop even when it lies on a solid face — so a sketch + // drawn ON a face can be selected and extruded/cut (Onshape engraving workflow). + // Open-face area (no loop stroke under the cursor) falls through to the solid + // whole/face/edge cycle; an interior hit with no solid behind it is the last resort. + Point pos(evt.GetX(), evt.GetY()); + const Linef3 ray = canvas.mouse_ray(pos); + const Linef3 ray8 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + int edge_feat = -1, edge_reg = -1, edge_ent = -1; double edge_d = 1e30; // nearest stroke + int face_feat = -1, face_reg = -1; // interior (fallback) + dp_pick_trace("display sketches available: %zu", m_display_sketches.size()); + for (const DisplaySketch& d : m_display_sketches) { + const Vec2d p = d.plane.project(ray.a, ray.vector()); + const Vec2d p8 = d.plane.project(ray8.a, ray8.vector()); + const double tol = std::max(1e-3, (p8 - p).norm()); + hit_display_sketch(d, p, tol, edge_feat, edge_reg, edge_ent, edge_d, face_feat, face_reg); + } + // A precise hit on a loop outline wins over the solid face beneath it. + if (edge_feat >= 0) { + m_display_pick = edge_feat; m_display_pick_region = edge_reg; + if (on_display_sketch_selected) on_display_sketch_selected(edge_feat, edge_reg, edge_ent); + return true; + } + // No loop stroke under the cursor: the solid is the foreground (whole/face/edge cycle). + if (handle_solid_click(canvas, evt)) return true; + // Interior of a committed loop with no solid behind it. + if (face_feat >= 0) { + m_display_pick = face_feat; m_display_pick_region = face_reg; + if (on_display_sketch_selected) on_display_sketch_selected(face_feat, face_reg, -1); + return true; + } + m_display_pick = -1; m_display_pick_region = -1; // clicked bare plate -> drop highlight + // ...and the SOLID selection goes with it (od0). A click that hits nothing has to + // mean what a rubber band that sweeps nothing already means — pick_bodies_in_rectangle + // clears on an empty sweep, and the two gestures cannot disagree about the same outcome. + // Until now the face survived a click on bare plate, so "click away, then click the face + // again" arrived here as the SECOND click on the same face and escalated to the whole + // body, when the click away was the user letting go of it. + // + // The cost is real and is the intended trade: Thicken / Shell / Draft hold their input + // face in the panel's m_sel_solid_face, so a stray click on empty canvas with one of + // those cards open gives that face back. Their handlers already write the "(pick a solid + // face)" placeholder and rebuild the ghost at level 0, so the card SAYS it lost the pick + // rather than confirming against a face the viewport has stopped highlighting. + // + // Guarded on there being something to clear: this runs on every click that misses, and + // the callback re-renders the open card's preview. + if (m_solid_sel != SolidSel::None) { + clear_solid_selection(); + dp_pick_trace("clicked empty space -> selection cleared"); + if (on_solid_selection_changed) + on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); + } + // Last resort: a click that hit no geometry but landed on a reference/base plane picks it. + if (m_dbp_active) { + const int h = hit_test_base_pick(canvas, evt); + if (h >= 0 && h < int(m_dbp_base.size())) { + if (on_datum_base_picked) on_datum_base_picked(m_dbp_base[h]); + return true; + } + } + return false; // let the stock canvas orbit / deselect + } + + // In-canvas edit-op tools (Fillet/Chamfer/Offset/Mirror): pick entities, then a + // draggable arrow + editable value label (Mirror: a two-phase pick) drives a live + // ghost. A click on empty space confirms; right-click/Esc cancels the gesture. + // Standalone Trim / Extend scissors: click a segment to cut it back to (Trim) or out to + // (Extend) its nearest intersection with the other live entities. One cut per click; the + // tool stays active for more cuts; right-click exits. Drag falls through so the camera can + // still orbit. Operates directly on the live sketch — no Constrain mode. + if (m_mode == Mode::Trim || m_mode == Mode::Extend) { + if (evt.Moving()) { screen_to_plane(canvas, evt, m_cursor); m_has_cursor = true; return true; } + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + if (apply_live_trim(p, tol * 3.0, m_mode == Mode::Extend)) { + resolve_live(); + } else if (on_readout) { + // nde #15: don't fail silently. The pick found nothing to cut/extend — either + // the click missed every live segment, or the picked segment has no crossing / + // target among the OTHER live entities (committed sketches aren't trimmed). + on_readout(m_mode == Mode::Extend + ? std::string("Extend: click a line/arc that can reach another live entity") + : std::string("Trim: click a segment where it crosses another live entity")); + } + return true; + } + if (evt.RightDown()) { request_exit(); return true; } + return false; // let move/drag orbit the camera + } + + if (is_edit_op_mode()) { + if (evt.Moving()) { + screen_to_plane(canvas, evt, m_cursor); + m_has_cursor = true; + return true; + } + if (m_op_dragging_arrow && evt.Dragging() && evt.LeftIsDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + drag_op_arrow(p); + return true; + } + if (evt.LeftUp()) { + if (m_op_dragging_arrow) { m_op_dragging_arrow = false; return true; } + return false; + } + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + // 1) live gizmo: click the value label to type, or grab the arrow to drag. + if (op_ready() && m_mode != Mode::Mirror) { + const Linef3 rl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); + const double ltol = std::max(tol, (m_plane.project(rl.a, rl.vector()) - p).norm()); + if ((m_op_label - p).norm() <= ltol) { open_op_editor(); return true; } + if (hit_test_op_arrow(p, tol)) { m_op_dragging_arrow = true; return true; } + } + // 2) entity pick (close enough to an entity edge). + double best = 1e30; int bi = -1; + for (size_t i = 0; i < m_entities.size(); ++i) { + const double d = entity_pick_dist(p, m_entities[i]); + if (d < best) { best = d; bi = int(i); } + } + if (bi >= 0 && best <= tol * 3.0) { op_pick(bi); return true; } + // 3) empty click confirms a ready gesture. + if (op_ready()) confirm_op(); + return true; + } + if (evt.RightDown()) { + if (m_op_a >= 0 || !m_mirror_targets.empty()) { + reset_op(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); + } else { + request_exit(); + } + return true; + } + return false; + } + + // In-canvas transform tools (Move/Rotate/Scale/Array/PolarArray): pick subject + // entities, then a single draggable handle + editable value label(s) drive a live + // ghost. A click on empty space confirms; right-click drops the gesture / exits. + if (is_transform_mode()) { + if (evt.Moving()) { + screen_to_plane(canvas, evt, m_cursor); + m_has_cursor = true; + return true; + } + if (m_tf_dragging && evt.Dragging() && evt.LeftIsDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + drag_tf_handle(p); + return true; + } + if (evt.LeftUp()) { + if (m_tf_dragging) { m_tf_dragging = false; return true; } + return false; + } + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + // 1) live gizmo: click a value label to type, or grab the handle to drag. + if (tf_ready()) { + const Linef3 rl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); + const double ltol = std::max(tol, (m_plane.project(rl.a, rl.vector()) - p).norm()); + if ((m_tf_label_a - p).norm() <= ltol) { open_tf_editor_a(); return true; } + if ((m_tf_label_b - p).norm() <= ltol) { open_tf_editor_count(); return true; } + if (hit_test_tf_handle(p, tol)) { m_tf_dragging = true; return true; } + } + // 2) entity pick (close enough to an entity edge). + double best = 1e30; int bi = -1; + for (size_t i = 0; i < m_entities.size(); ++i) { + const double d = entity_pick_dist(p, m_entities[i]); + if (d < best) { best = d; bi = int(i); } + } + if (bi >= 0 && best <= tol * 3.0) { tf_pick(bi); return true; } + // 3) empty click confirms a ready gesture. + if (tf_ready()) confirm_transform(); + return true; + } + if (evt.RightDown()) { + if (!m_tf_targets.empty()) { + reset_tf(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); + } else { + request_exit(); + } + return true; + } + return false; + } + + // Imported-art bbox transform: drag a corner to scale, the centre to move. Right-click + // ends the session. Values stream live to the host via on_imported_transform. + if (m_mode == Mode::TransformArt) { + if (evt.Moving()) { + screen_to_plane(canvas, evt, m_cursor); + m_has_cursor = true; + return true; + } + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 10, evt.GetY())); + const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + const int h = hit_test_xform_handle(p, tol); + if (h >= 0) { + m_xform_handle = h; + if (h == 4) m_xform_anchor = p; // centre move: track the delta + else { Vec2d c[4]; xform_world_corners(c); m_xform_anchor = c[(h + 2) % 4]; } + } + return true; // swallow (no camera orbit while placing) + } + if (m_xform_handle >= 0 && evt.Dragging() && evt.LeftIsDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + drag_xform_handle(p); + return true; + } + if (evt.LeftUp()) { m_xform_handle = -1; return true; } + if (evt.RightDown()) { if (on_exit) on_exit(); else cancel(); return true; } + return false; + } + + // Constrain mode: pick a segment on click; let move/drag fall through so the + // camera can still orbit while inspecting the sketch. + if (m_mode == Mode::Constrain) { + if (m_constrain_entities) { + // Pick any entity (line/circle/arc/point): rolling two-slot selection. + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + double best = 1e30; + int bi = -1; + for (size_t i = 0; i < m_entities.size(); ++i) { + const double d = entity_pick_dist(p, m_entities[i]); + if (d < best) { best = d; bi = int(i); } + } + if (bi >= 0) { + // Rolling three-slot selection: slots 0/1 feed all 2-entity + // constraints; slot 2 is the Symmetric axis (only filled once + // 0 and 1 are set). A click past slot 2 restarts the cycle. + if (m_pick0 < 0) { m_pick0 = bi; m_pick0_pt = p; } + else if (m_pick1 < 0 && bi != m_pick0) m_pick1 = bi; + else if (m_pick1 >= 0 && m_pick2 < 0 && bi != m_pick0 && bi != m_pick1) m_pick2 = bi; + else { m_pick0 = bi; m_pick1 = m_pick2 = -1; m_pick0_pt = p; } + } + return true; + } + if (evt.RightDown()) { cancel(); return true; } + return false; + } + if (evt.LeftDown()) { + if (m_points.size() < 2) + return false; + Vec2d p; + screen_to_plane(canvas, evt, p); + const size_t n = m_points.size(); + double best = 1e30; + int bi = -1; + for (size_t i = 0; i < n; ++i) { + const double d = point_segment_dist(p, m_points[i], m_points[(i + 1) % n]); + if (d < best) { best = d; bi = int(i); } + } + if (bi >= 0) { + m_sel_a = bi; + m_sel_b = int((bi + 1) % n); + } + return true; + } + if (evt.RightDown()) { + cancel(); + return true; + } + return false; + } + + // Selection mode: click to pick an entity, Shift/Ctrl to extend, double-click + // to grab the whole connected loop. Drag falls through so the camera can orbit. + if (m_mode == Mode::Select) { + if (update_hover(canvas, evt)) return true; // repaint when the hovered handle changes + const bool extend = evt.ShiftDown() || evt.ControlDown(); + + // A constraint badge is a click target: clicking it DELETES that constraint. Existing or + // not is the whole of a constraint's state, so this click is the toggle. Checked before + // entity picking because badges sit clear of the geometry (offset along +Y), so a hit + // here is unambiguous; plain click only, so Shift/Ctrl multi-select is never eaten. + if (evt.LeftDown() && !extend) { + Vec2d gp; + if (screen_to_plane(canvas, evt, gp) && remove_constraint_near(gp)) { + if (on_selection_changed) + on_selection_changed(int(m_selection.size() + m_point_sel.size())); + return true; + } + } + + // Live point drag: once an endpoint/centre was grabbed on LeftDown, dragging + // moves it (re-solving constraints live) until the button is released. When + // nothing is grabbed, drag falls through so the camera can still orbit. + if (m_dragging_point && evt.Dragging() && evt.LeftIsDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + if (m_drag_poly_fi >= 0) + drag_polygon_vertex(m_drag_poly_fi, m_drag_ei, m_drag_role, p); // keep regular + else if (m_drag_rect_fi >= 0) + drag_rect_corner(m_drag_rect_fi, p); // resize axis-aligned box + else if (m_drag_slot_fi >= 0) + drag_slot_handle(m_drag_slot_fi, p); // move a slot end + else if (m_drag_ei >= 0 && m_drag_ei < int(m_entities.size()) && + m_entities[m_drag_ei].type == SketchEntity::Type::Arc) + drag_arc_handle(m_drag_ei, m_drag_role, p); // center/radius/angle grips + else if (m_drag_ei >= 0 && m_drag_ei < int(m_entities.size()) && + m_entities[m_drag_ei].type == SketchEntity::Type::EllipseArc) + drag_ellipsearc_handle(m_drag_ei, m_drag_role, p); // center/sweep endpoints + else { + set_point(m_drag_ei, m_drag_role, p); + resolve_live_drag(m_drag_ei, m_drag_role); + } + return true; + } + // Derived-handle drag (A4): the circle RadiusHandle (and later slot/rect/etc.) + // isn't an entity point, so it rides set_handle, which applies the role-specific + // geometry edit + re-solve. + if (m_dragging_handle && evt.Dragging() && evt.LeftIsDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + set_handle(m_drag_handle, p); + return true; + } + if (evt.LeftUp()) { + if (m_dragging_point) { + Vec2d p; + screen_to_plane(canvas, evt, p); + if (m_drag_poly_fi >= 0) + drag_polygon_vertex(m_drag_poly_fi, m_drag_ei, m_drag_role, p); + else if (m_drag_rect_fi >= 0) + drag_rect_corner(m_drag_rect_fi, p); + else if (m_drag_slot_fi >= 0) + drag_slot_handle(m_drag_slot_fi, p); + else if (m_drag_ei >= 0 && m_drag_ei < int(m_entities.size()) && + m_entities[m_drag_ei].type == SketchEntity::Type::Arc) + drag_arc_handle(m_drag_ei, m_drag_role, p); + else if (m_drag_ei >= 0 && m_drag_ei < int(m_entities.size()) && + m_entities[m_drag_ei].type == SketchEntity::Type::EllipseArc) + drag_ellipsearc_handle(m_drag_ei, m_drag_role, p); + else { + set_point(m_drag_ei, m_drag_role, p); + resolve_live_drag(m_drag_ei, m_drag_role); + } + m_dragging_point = false; + m_drag_ei = -1; + m_drag_poly_fi = -1; + m_drag_rect_fi = -1; + m_drag_slot_fi = -1; + return true; + } + if (m_dragging_handle) { + Vec2d p; + screen_to_plane(canvas, evt, p); + set_handle(m_drag_handle, p); + m_dragging_handle = false; + return true; + } + return false; + } + + if (evt.LeftDown() || evt.LeftDClick()) { + m_dragging_point = false; // a fresh press disarms any stale grab + m_dragging_handle = false; + m_drag_poly_fi = -1; + m_drag_rect_fi = -1; + m_drag_slot_fi = -1; + Vec2d p; + screen_to_plane(canvas, evt, p); + if (evt.LeftDClick()) { // double-click a quote label -> edit it + const Linef3 rd = canvas.mouse_ray(Point(evt.GetX() + 28, evt.GetY())); + const double dtol = std::max(2.0, (m_plane.project(rd.a, rd.vector()) - p).norm()); + const int di = hit_test_dimension(p, dtol); + if (di >= 0) { edit_dimension(di); return true; } + } + // Zoom-aware tolerance: project a point 8 px away and measure in plane units. + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + const Vec2d p2 = m_plane.project(r2.a, r2.vector()); + const double tol = std::max(1e-3, (p2 - p).norm()); + + // Single-click a dimension quote label -> edit its value in place. A placed + // driving quote reopens its editor; a live (non-driving) characteristic quote + // is first promoted to a driving dimension (place_dimension), then its editor + // opens — so typing a value sets the precise dimension. Generous label + // tolerance (~24 px) since text labels are wider than a point grip. + if (evt.LeftDown()) { + const Linef3 rdl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); + const double ltol = std::max(tol, (m_plane.project(rdl.a, rdl.vector()) - p).norm()); + const int di = hit_test_dimension(p, ltol); + if (di >= 0) { open_value_editor(di); return true; } + for (const DimAnnot& q : m_live_quotes) { + if ((q.label_pos - p).norm() <= ltol) { + if (q.kind == DimType::Angle) + open_angle_editor(q.ea); // geometric rotate to a typed angle + else + place_dimension(q); // promote -> driving dim + open editor + return true; + } + } + if (m_live_poly_fi >= 0) { + if ((m_live_poly_side_label - p).norm() <= ltol) { + open_polygon_side_editor(m_live_poly_fi); // geometric uniform scale + return true; + } + if ((m_live_poly_angle_label - p).norm() <= ltol) { + open_polygon_angle_editor(m_live_poly_fi); // geometric rotate + return true; + } + } + if (m_live_arc_ei >= 0 && (m_live_arc_angle_label - p).norm() <= ltol) { + open_arc_angle_editor(m_live_arc_ei); // geometric sweep change + return true; + } + if (m_live_ellipse_ei >= 0) { + if ((m_live_ellipse_major_label - p).norm() <= ltol) { + open_ellipse_axis_editor(m_live_ellipse_ei, true); // semi-major + return true; + } + if ((m_live_ellipse_minor_label - p).norm() <= ltol) { + open_ellipse_axis_editor(m_live_ellipse_ei, false); // semi-minor + return true; + } + } + if (m_live_rrect_fi >= 0) { + if ((m_live_rrect_w_label - p).norm() <= ltol) { open_rounded_rect_editor(m_live_rrect_fi, 0); return true; } + if ((m_live_rrect_h_label - p).norm() <= ltol) { open_rounded_rect_editor(m_live_rrect_fi, 1); return true; } + if ((m_live_rrect_r_label - p).norm() <= ltol) { open_rounded_rect_editor(m_live_rrect_fi, 2); return true; } + } + if (m_live_aslot_fi >= 0) { + if ((m_live_aslot_r_label - p).norm() <= ltol) { open_arc_slot_editor(m_live_aslot_fi, true); return true; } + if ((m_live_aslot_w_label - p).norm() <= ltol) { open_arc_slot_editor(m_live_aslot_fi, false); return true; } + } + if (m_live_slot_fi >= 0) { + if ((m_live_slot_len_label - p).norm() <= ltol) { open_slot_editor(m_live_slot_fi, 0); return true; } + if ((m_live_slot_w_label - p).norm() <= ltol) { open_slot_editor(m_live_slot_fi, 1); return true; } + if ((m_live_slot_angle_label - p).norm() <= ltol) { open_slot_editor(m_live_slot_fi, 2); return true; } + } + } + + // A derived handle (the circle RadiusHandle — not an entity point, so + // hit_test_point can't grab it) arms a handle drag that resizes on motion. + // Checked before the point/entity hit-tests so the radius grip wins near + // the circle edge. + if (evt.LeftDown()) { + Handle hh; + if (hit_test_handle(p, tol, hh) && + (hh.role == HandleRole::RadiusHandle || hh.role == HandleRole::MajorAxis || + hh.role == HandleRole::MinorAxis || hh.role == HandleRole::BSplineCtrl)) { + m_dragging_handle = true; + m_drag_handle = hh; + m_selection.clear(); + m_point_sel.clear(); + m_selection.push_back(hh.ei); // highlight the circle being resized + if (on_selection_changed) + on_selection_changed(int(m_selection.size())); + return true; + } + } + + // A nearby endpoint/centre selects that POINT (a line = a segment + 2 + // points); a click on the bare segment selects the whole entity. + if (evt.LeftDown()) { + int pe; SketchPointRole pr; + if (hit_test_point(p, tol, pe, pr)) { + const auto key = std::make_pair(pe, pr); + auto it = std::find(m_point_sel.begin(), m_point_sel.end(), key); + if (extend) { + if (it == m_point_sel.end()) m_point_sel.push_back(key); + else m_point_sel.erase(it); + } else { + m_selection.clear(); + m_point_sel.clear(); + m_point_sel.push_back(key); + } + // Arm the drag so the grabbed point follows the cursor. + m_dragging_point = true; + m_drag_ei = pe; + m_drag_role = pr; + // If the grabbed point is a polygon vertex, the drag must keep the + // polygon REGULAR — it adjusts the circumradius + orientation (the + // vertex follows the cursor) instead of moving one point freely. + const int pf = feature_of(pe); + m_drag_poly_fi = (pf >= 0 && m_features[pf].kind == FeatureKind::Polygon) ? pf : -1; + m_drag_rect_fi = -1; m_drag_slot_fi = -1; + if (pf >= 0 && m_drag_poly_fi < 0) { + const Feature& ft = m_features[pf]; + if (ft.kind == FeatureKind::CornerRect || ft.kind == FeatureKind::CenterRect) { + // Only axis-aligned boxes resize by corner (oblique rects fall + // back to free point move). Capture the fixed opposite corner. + const SketchEntity& e0 = m_entities[ft.begin]; + const Vec2d d0 = e0.p1 - e0.p0; + const bool aa = std::abs(d0.x()) < 1e-6 || std::abs(d0.y()) < 1e-6; + Vec2d gp; + if (aa && point_at(pe, pr, gp)) { + Vec2d opp; + opp.x() = (std::abs(gp.x() - ft.c0.x()) < std::abs(gp.x() - ft.c1.x())) ? ft.c1.x() : ft.c0.x(); + opp.y() = (std::abs(gp.y() - ft.c0.y()) < std::abs(gp.y() - ft.c1.y())) ? ft.c1.y() : ft.c0.y(); + m_drag_rect_fi = pf; m_drag_rect_anchor = opp; + } + } else if (ft.kind == FeatureKind::Slot && + pr == SketchPointRole::Center && + (pe == ft.begin + 1 || pe == ft.begin + 3)) { + m_drag_slot_fi = pf; // cap@c1 = begin+1, cap@c0 = begin+3 + m_drag_slot_c1 = (pe == ft.begin + 1); + } + } + if (on_selection_changed) + on_selection_changed(int(m_selection.size() + m_point_sel.size())); + return true; + } + } + + const int hit = hit_test(p, tol); + + if (evt.LeftDClick() && hit >= 0) { + if (!extend) m_selection.clear(); + for (int idx : connected_loop(hit)) + if (std::find(m_selection.begin(), m_selection.end(), idx) == m_selection.end()) + m_selection.push_back(idx); + } else if (hit >= 0) { + auto it = std::find(m_selection.begin(), m_selection.end(), hit); + if (extend) { + if (it == m_selection.end()) m_selection.push_back(hit); + else m_selection.erase(it); // toggle off + } else { + m_selection.clear(); + m_point_sel.clear(); + m_selection.push_back(hit); + } + } else if (!extend) { + // Inside a closed loop (not on an edge/point) → select it as a face + // and hand off to the panel, which commits the sketch and extrudes. + if (evt.LeftDown() && on_face_selected) { + const int reg = region_at(p); + if (reg >= 0) { + m_selection.clear(); + m_point_sel.clear(); + on_face_selected(reg); + return true; + } + } + m_selection.clear(); // clicked empty space + m_point_sel.clear(); + } + if (on_selection_changed) + on_selection_changed(int(m_selection.size() + m_point_sel.size())); + return true; + } + if (evt.RightDown()) { + // Hand the click back (return false) so the offer opens: the m_right_consumed flag + // this return value feeds means "the tool USED this right-click", and a plain + // right-click in Select mode is not a gesture terminator. + // + // But do NOT drop the selection on the way out. The offer menu describes WHAT IS + // SELECTED, so clearing first guaranteed it could only ever describe nothing: select + // a line, right-click, and the sketch verbs — Trim, Extend, Fillet, Chamfer, Offset, + // Mirror, the arrays, Constrain — were all greyed, because by the time the menu was + // built the line was no longer selected. Reported from the machine as "selected a + // line, right-click exit from selection: only create and reference are usable". + // Deselecting still has a gesture: left-click on empty space, a few lines above. + return false; + } + return false; // let drag orbit the camera + } + + // Dimension mode: click entities directly. 2 points -> Distance, a line -> Length, + // a circle -> Diameter, an arc -> Radius, a point then a line -> DistanceToLine. + // Each resolved pick places a driving quote and pops the value card. + if (m_mode == Mode::Dimension) { + if (update_hover(canvas, evt)) return true; // repaint when the hovered handle changes + if (evt.LeftDClick()) { // double-click a quote label -> edit it + Vec2d p; + screen_to_plane(canvas, evt, p); + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 28, evt.GetY())); + const Vec2d p2 = m_plane.project(r2.a, r2.vector()); + const double di_tol = std::max(2.0, (p2 - p).norm()); + const int di = hit_test_dimension(p, di_tol); + if (di >= 0) edit_dimension(di); + m_dim_has0 = false; + return true; + } + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); + const Vec2d p2 = m_plane.project(r2.a, r2.vector()); + const double tol = std::max(1e-3, (p2 - p).norm()); + int pe; SketchPointRole pr; + const bool got_pt = hit_test_point(p, tol, pe, pr); + const int he = hit_test(p, tol); + if (!m_dim_has0) { + if (got_pt) { // first point picked: await a second + m_dim_e0 = pe; m_dim_r0 = pr; m_dim_has0 = true; + } else if (he >= 0) { // whole-entity dimension + DimAnnot a; a.ea = he; + const SketchEntity::Type t = m_entities[he].type; + if (t == SketchEntity::Type::Line) { a.kind = DimType::Length; place_dimension(a); } + else if (t == SketchEntity::Type::Circle) { a.kind = DimType::Diameter; place_dimension(a); } + else if (t == SketchEntity::Type::Arc) { a.kind = DimType::Radius; place_dimension(a); } + } + } else { + if (got_pt && !(pe == m_dim_e0 && pr == m_dim_r0)) { + DimAnnot a; a.kind = DimType::Distance; + a.ea = m_dim_e0; a.ra = m_dim_r0; a.eb = pe; a.rb = pr; + place_dimension(a); + } else if (he >= 0 && m_entities[he].type == SketchEntity::Type::Line) { + DimAnnot a; a.kind = DimType::DistanceToLine; + a.ea = m_dim_e0; a.ra = m_dim_r0; a.eb = he; + place_dimension(a); + } + m_dim_has0 = false; // reset after the second pick + } + return true; + } + if (evt.RightDown()) { m_dim_has0 = false; return true; } + return false; // let drag orbit the camera + } + + if (evt.Moving()) { + m_snap_off = evt.ShiftDown(); + screen_to_plane(canvas, evt, m_cursor); + m_has_cursor = true; + m_cursor_locked = false; + bool vsnap = false; + m_cursor = snap_vertex(canvas, evt, m_cursor, vsnap); // preview-snap to endpoints + // The chain's own start is not an entity yet, so snap_vertex cannot see it. Offer it + // here: the cursor lands exactly on it, so the rubber band below IS the closing segment. + if (snap_chain_start(canvas, evt, m_cursor)) vsnap = true; + const bool line_like = (m_mode == Mode::Polyline || m_mode == Mode::Line); + if (line_like && !m_points.empty() && !vsnap) + m_cursor = snap_dir(m_points.back(), m_cursor, m_cursor_locked); + if (on_cursor_metrics && line_like && !m_points.empty() && !m_awaiting_length) { + const Vec2d d = m_cursor - m_points.back(); + on_cursor_metrics(d.norm(), std::atan2(d.y(), d.x()) * 180.0 / M_PI, m_cursor_locked); + } + return true; + } + + switch (m_mode) { + + case Mode::Polyline: { + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + m_snap_off = evt.ShiftDown(); + bool vsnap = false; + p = snap_vertex(canvas, evt, p, vsnap); // snap onto an existing endpoint + if (snap_chain_start(canvas, evt, p)) { // clicked the previewed close target + const int base = int(m_entities.size()); + push_closed_lines(m_points); // close the loop + infer_auto_constraints(base); // loop self-closes via auto Coincident + H/V + m_points.clear(); + return true; + } + if (!m_points.empty() && !vsnap) { + bool lk = false; + p = snap_dir(m_points.back(), p, lk); // lock new segment to inference angle + } + m_points.push_back(p); + arm_polyline_segment_edit(); // refine this segment's Length+Angle, then continue + return true; + } + if (evt.LeftDClick()) { + if (m_points.size() >= 2) { + const int base = int(m_entities.size()); + push_open_chain(m_points); // end as an open chain + infer_auto_constraints(base); + m_points.clear(); + } + return true; + } + if (evt.RightDown() && m_points.empty()) + return false; // no chain to end — ghcz, let the offer open + if (evt.RightDown()) { + // END the chain — do NOT close it. This used to call push_closed_lines() for three + // or more points, i.e. it drew a final segment from the last point back to the + // first that the user never asked for, and did it silently. No mainstream sketcher + // does that: FreeCAD, Fusion and Onshape all end an open chain on right-click and + // require the close to be EXPLICIT. Ours has that gesture — click the chain's first + // point, which snap_chain_start() previews and snaps onto in the LeftDown branch + // above, so the closing segment is on screen BEFORE it is committed. A closed loop + // is this tab's goal and it stays a four-action triangle either way; what changes + // is that the closing edge is now something the user saw and chose, not one the + // app appended on their behalf. + const int base = int(m_entities.size()); + if (m_points.size() >= 2) + push_open_chain(m_points); + infer_auto_constraints(base); + m_points.clear(); + return true; + } + break; + } + + case Mode::Line: { + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + m_snap_off = evt.ShiftDown(); + bool vsnap = false; + p = snap_vertex(canvas, evt, p, vsnap); // snap onto an existing endpoint + if (m_points.empty()) { // first click = anchor + m_points.push_back(p); + return true; + } + bool lk = false; + if (!vsnap) p = snap_dir(m_points.back(), p, lk); // vertex snap wins over angle + m_points.push_back(p); // second click completes the segment + // Draw-then-edit: just commit the segment. The generic detect/service path + // (is_creation_autoedit_mode now includes Line) auto-selects it and opens its + // Length THEN Angle fields in sequence, each over its label — same UX as every + // other 2D tool. Enter advances (Length drives a Distance constraint, Angle rotates + // about P0); Esc keeps it as drawn. + keep_segment_as_drawn(); + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::CornerRect: { + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + if (m_points.empty()) { + m_points.push_back(p); + } else { + const Vec2d A = m_points[0]; + const Vec2d B = p; + const int base = int(m_entities.size()); + begin_feature(FeatureKind::CornerRect); + push_closed_lines({ A, Vec2d(B.x(), A.y()), B, Vec2d(A.x(), B.y()) }); + infer_auto_constraints(base); // corners Coincident + sides H/V + end_feature(A, B); // group: Width/Height live quotes + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::CenterRect: { + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + if (m_points.empty()) { + m_points.push_back(p); + } else { + const Vec2d C = m_points[0]; + const double hx = std::abs(p.x() - C.x()); + const double hy = std::abs(p.y() - C.y()); + const int base = int(m_entities.size()); + begin_feature(FeatureKind::CenterRect); + push_closed_lines({ + Vec2d(C.x() - hx, C.y() - hy), Vec2d(C.x() + hx, C.y() - hy), + Vec2d(C.x() + hx, C.y() + hy), Vec2d(C.x() - hx, C.y() + hy) }); + infer_auto_constraints(base); + end_feature(Vec2d(C.x() - hx, C.y() - hy), Vec2d(C.x() + hx, C.y() + hy)); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::ObliqueRect: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + if (m_points.size() < 2) // corners snap; 3rd click is the width + p = snap_vertex(canvas, evt, p, vsnap); + m_points.push_back(p); + if (m_points.size() == 3) { + const Vec2d A = m_points[0], B = m_points[1]; + Vec2d u = B - A; + if (u.squaredNorm() > 1e-12) { + u.normalize(); + const Vec2d n(-u.y(), u.x()); + const double w = n.dot(m_points[2] - A); // signed perpendicular width + const int base = int(m_entities.size()); + begin_feature(FeatureKind::CornerRect); + push_closed_lines({ A, B, B + n * w, A + n * w }); + infer_auto_constraints(base); // corners Coincident + the AB pair parallel + end_feature(A, B + n * w); // group: Width/Height live quotes + } + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::RoundedRect: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + if (m_points.size() < 2) // the two corners snap; 3rd sets radius + p = snap_vertex(canvas, evt, p, vsnap); + m_points.push_back(p); + if (m_points.size() == 3) { + const int base = int(m_entities.size()); + begin_feature(FeatureKind::RoundedRect); + append_entities(make_rounded_rect(m_points[0], m_points[1], m_points[2])); + infer_auto_constraints(base); + // Group with the actual clamped fillet radius so the W/H/R live quotes + // and rebuild edits can recover the box. (Skip grouping if degenerate.) + const Vec2d a = m_points[0], b = m_points[1]; + const double xmin=std::min(a.x(),b.x()), xmax=std::max(a.x(),b.x()); + const double ymin=std::min(a.y(),b.y()), ymax=std::max(a.y(),b.y()); + const double bw=xmax-xmin, bh=ymax-ymin; + const Vec2d cs[4]={{xmin,ymin},{xmax,ymin},{xmax,ymax},{xmin,ymax}}; + double r=1e18; for(const Vec2d&c:cs) r=std::min(r,(m_points[2]-c).norm()); + r=std::min(r, std::min(bw,bh)*0.5); + end_feature(Vec2d(xmin,ymin), Vec2d(xmax,ymax), r); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::CenterCircle: { + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + if (m_points.empty()) { + m_points.push_back(p); + } else { + const Vec2d C = m_points[0]; + push_circle(C, (p - C).norm()); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::TwoPointCircle: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + p = snap_vertex(canvas, evt, p, vsnap); // diameter ends snap onto geometry + m_points.push_back(p); + if (m_points.size() == 2) { + const Vec2d C = (m_points[0] + m_points[1]) * 0.5; + push_circle(C, (m_points[1] - m_points[0]).norm() * 0.5); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::ThreePointCircle: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + m_points.push_back(p); + if (m_points.size() == 3) { + append_entities(make_three_point_circle(m_points[0], m_points[1], m_points[2])); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::ThreePointArc: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + if (m_points.size() < 2) // snap the start/end onto endpoints + p = snap_vertex(canvas, evt, p, vsnap); // (the 3rd click is the through-point) + m_points.push_back(p); + if (m_points.size() == 3) { + // clicks: start, end, point-on-arc + const int base = int(m_entities.size()); + append_entities(make_three_point_arc(m_points[0], m_points[1], m_points[2])); + infer_auto_constraints(base); // arc ends Coincident onto snapped vertices + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::TangentArc: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + p = snap_vertex(canvas, evt, p, vsnap); // snap both ends onto endpoints + m_points.push_back(p); + if (m_points.size() == 2) { + const int base = int(m_entities.size()); + append_entities(make_tangent_arc(m_points[0], m_points[1])); + infer_auto_constraints(base); // tangent-arc ends Coincident onto vertices + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::CenterArc: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + // The start (2nd click) snaps onto endpoints; center & end-dir are free. + if (m_points.size() == 1) + p = snap_vertex(canvas, evt, p, vsnap); + m_points.push_back(p); + if (m_points.size() == 3) { + const int base = int(m_entities.size()); + append_entities(make_center_arc(m_points[0], m_points[1], m_points[2])); + infer_auto_constraints(base); // arc start Coincident onto a snapped vertex + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::Slot: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + if (m_points.size() < 2) { + m_points.push_back(p); + } else { + // third click sets the half-width (distance to the centerline) + Vec2d u = m_points[1] - m_points[0]; + if (u.squaredNorm() > 1e-12) { + u.normalize(); + const Vec2d n(-u.y(), u.x()); + const double w = std::abs(n.dot(p - m_points[0])); + const int base = int(m_entities.size()); + begin_feature(FeatureKind::Slot); + append_entities(make_slot(m_points[0], m_points[1], w)); + infer_auto_constraints(base); + end_feature(m_points[0], m_points[1], w); // centres + half-width + } + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::ArcSlot: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + if (m_points.size() == 1) // start snaps; center & end-dir are free + p = snap_vertex(canvas, evt, p, vsnap); + m_points.push_back(p); + if (m_points.size() == 4) { + // clicks: center, start, end-dir, width + const double Rc = (m_points[1] - m_points[0]).norm(); + const double w = std::abs((m_points[3] - m_points[0]).norm() - Rc); + const int base = int(m_entities.size()); + begin_feature(FeatureKind::ArcSlot); + append_entities(make_arc_slot(m_points[0], m_points[1], m_points[2], w)); + infer_auto_constraints(base); + // c0 = centre, c1 = centreline start point; param = half-width. The end + // direction is recovered from the cap@E arc centre when rebuilding. + end_feature(m_points[0], m_points[1], w); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::Polygon: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + if (m_points.empty()) { + m_points.push_back(p); + } else { + const int base = int(m_entities.size()); + begin_feature(FeatureKind::Polygon); + append_entities(make_polygon(m_points[0], p, m_polygon_sides)); + infer_auto_constraints(base); + end_feature(m_points[0], p, (p - m_points[0]).norm(), m_polygon_sides); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::Ellipse: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + if (m_points.empty()) p = snap_vertex(canvas, evt, p, vsnap); // center can snap + m_points.push_back(p); + if (m_points.size() == 3) { // center, major-end, minor + const int base = int(m_entities.size()); + append_entities(make_ellipse(m_points[0], m_points[1], m_points[2])); + infer_auto_constraints(base); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::EllipseArc: { + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + bool vsnap = false; + if (m_points.empty()) p = snap_vertex(canvas, evt, p, vsnap); // center can snap + m_points.push_back(p); + if (m_points.size() == 5) { // center, major, minor, start, end + const int base = int(m_entities.size()); + append_entities(make_ellipse_arc(m_points[0], m_points[1], m_points[2], + m_points[3], m_points[4])); + infer_auto_constraints(base); + m_points.clear(); + } + return true; + } + if (evt.RightDown()) return right_abandon(); + break; + } + + case Mode::BSpline: { + // Variable-length: left-click adds a control pole (each can snap onto existing + // geometry); double-click or right-click finishes as an open spline. + if (evt.LeftDown()) { + Vec2d p; screen_to_plane(canvas, evt, p); + m_snap_off = evt.ShiftDown(); + bool vsnap = false; + p = snap_vertex(canvas, evt, p, vsnap); // poles can land on endpoints + m_points.push_back(p); + return true; + } + if (evt.RightDown() && m_points.empty()) + return false; // no poles down — ghcz, let the offer open + if (evt.LeftDClick() || evt.RightDown()) { + if (m_points.size() >= 2) { + const int base = int(m_entities.size()); + append_entities(make_bspline(m_points)); + infer_auto_constraints(base); // end poles auto-Coincident -> loops close + } + m_points.clear(); + return true; + } + break; + } + + case Mode::Point: { + if (evt.LeftDown()) { + Vec2d p; + screen_to_plane(canvas, evt, p); + push_point(p); + return true; + } + if (evt.RightDown()) return false; // no anchor to abandon: the offer belongs here + break; + } + + case Mode::Constrain: + break; + } + + if (evt.Dragging()) + return false; + + return false; +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp new file mode 100644 index 0000000000..c88b149c59 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -0,0 +1,1466 @@ +#ifndef slic3r_DesignSketchTool_hpp_ +#define slic3r_DesignSketchTool_hpp_ + +#include "libslic3r/Point.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/CAD/CadDocument.hpp" // CadBody for per-body solid picking +#include "libslic3r/CAD/SketchInference.hpp" +#include "libslic3r/CAD/SketchSolver.hpp" +#include "slic3r/GUI/GLModel.hpp" +#include "slic3r/GUI/GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies +#include +#include +#include +#include + +class wxMouseEvent; +class wxPoint; + +namespace Slic3r { + +class TriangleMesh; // fwd (libslic3r) — solid-pick mesh, non-owning pointer + +namespace GUI { + +class GLCanvas3D; +class Camera; // fwd — move_gizmo_arm() sizes the gizmo from the current zoom + +// Onshape-style sketch session. `begin` enters a session on a plane; the active +// drawing tool (Mode) can be switched mid-session via `set_tool` while entities +// accumulate. `finish` commits the whole entity list as one sketch feature; +// `cancel` aborts. Constrain is a separate legacy mode that operates on a +// committed profile's points (entity constraints land in a later chunk). + + +// ONE colour means SELECTED — a face, an edge, a vertex, a whole body, a 2D sketch region. +// Nothing else on screen may wear it. Before this there were four near-identical cyans plus a +// constant still named sel_gold that had long since become cyan, and the UNSELECTED region fill +// was blue (0.30,0.60,1.0) — one shade from the selected one — so an ordinary region read as +// picked. Selection is a state, not a decoration: it gets its own colour and keeps it. +inline ColorRGBA design_selection_color(float alpha = 1.0f) +{ + return ColorRGBA(0.20f, 0.85f, 1.00f, alpha); +} +// Unselected geometry — 2D regions and faces — is neutral translucent grey, so the only +// coloured thing in the viewport is the thing you picked. +inline ColorRGBA design_idle_face_color() +{ + return ColorRGBA(0.72f, 0.76f, 0.80f, 0.14f); +} +class DesignSketchTool { +public: + enum class Mode { Select, Dimension, Polyline, Line, CornerRect, CenterRect, ObliqueRect, + RoundedRect, CenterCircle, TwoPointCircle, Point, + ThreePointCircle, ThreePointArc, TangentArc, CenterArc, Slot, ArcSlot, Polygon, + Ellipse, EllipseArc, BSpline, + // In-canvas edit-op TOOLBAR tools (drag-arrow + label, no numeric card): + Fillet, Chamfer, Offset, Mirror, + // Standalone scissors: click a segment to trim/extend it (immediate, no card): + Trim, Extend, + // In-canvas transform TOOLBAR tools (pick targets + drag handle/label, no card): + Move, Rotate, Scale, Array, PolarArray, + // In-canvas bounding-box transform for imported Text/SVG art: + TransformArt, + Constrain }; + // Which tool is armed, and how many anchors it has down. Read-only, for the offer ladder: + // "the menu armed the verb I chose" is otherwise unassertable, and a menu walk that lands one + // row off arms a NEIGHBOURING tool and then grades whatever that drew. ekt9. + Mode mode() const { return m_mode; } + int pending_points() const { return int(m_points.size()); } + void emit_step_hint(); // fires on_step_changed when the step actually moved + // Is an in-canvas value field open? While one is, the canvas is frozen and every letter is + // swallowed — the single most common reason a driven gesture "does nothing". + bool value_field_open() const { return m_awaiting_length; } + bool is_edit_op_mode() const { return m_mode == Mode::Fillet || m_mode == Mode::Chamfer || + m_mode == Mode::Offset || m_mode == Mode::Mirror; } + bool is_transform_mode() const { return m_mode == Mode::Move || m_mode == Mode::Rotate || + m_mode == Mode::Scale || m_mode == Mode::Array || + m_mode == Mode::PolarArray; } + // Creation tools that get draw-then-edit: on commit the new entity/feature is selected + // and its primary value editor opens. Line is handled inline (its own length field); + // Polyline/BSpline/Point have no single primary value, so they opt out. + bool is_creation_autoedit_mode() const { + switch (m_mode) { + case Mode::Line: + case Mode::CornerRect: case Mode::CenterRect: case Mode::ObliqueRect: + case Mode::RoundedRect: case Mode::CenterCircle: case Mode::TwoPointCircle: + case Mode::ThreePointCircle: case Mode::ThreePointArc: case Mode::TangentArc: + case Mode::CenterArc: case Mode::Slot: case Mode::ArcSlot: case Mode::Polygon: + case Mode::Ellipse: case Mode::EllipseArc: + return true; + default: return false; + } + } + // The host (DesignCanvas) flags the canvas frozen while an inline value editor is open, + // so a stray click/move can't draw under the floating field. Reuses m_awaiting_length + // (Line's existing freeze flag) as the single "inline editor open" gate. + void set_inline_busy(bool b) { m_awaiting_length = b; } + bool inline_busy() const { return m_awaiting_length; } // true while a value field is open + // Does the live session hold anything a cancel would throw away? Escape must not silently + // destroy drawn geometry; the panel asks this before treating Escape as "discard sketch". + bool live_sketch_has_work() const { return !m_entities.empty(); } + + // Clicking the same sub-element again escalates to the whole body. That is right for free + // picking and WRONG while a card has armed a face/edge pick: the card says "click a FACE", + // the user clicks the face it is already showing, and the escalation turns it into a + // whole-body pick that the armed capture then rejects. The host turns this off for as long + // as a pick is armed. + void set_escalate_on_repick(bool on) { m_escalate_repick = on; } + bool constrain_value_anchor(wxPoint& out) const; // screen anchor over the picked constrain geometry + + void begin(const SketchPlane& plane, Mode mode = Mode::Polyline); + // Re-open a committed entity sketch for full in-canvas editing: load its entities + + // driving constraints, re-detect the polygon/rect/slot grouping, and live-solve. The + // caller re-commits via finish() (the panel replaces the feature, see m_edit_index). + void begin_edit(const std::vector& entities, + const std::vector& constraints, + const SketchPlane& plane); + // Drop rigid 2D art (Text / SVG outlines) INTO the live sketch as ordinary line entities, + // so it joins the sketch being drawn instead of committing a separate Sketch feature. + // `regions` are loops in PLANE coordinates; every closed loop becomes a closed polyline, so + // the result is editable, constrainable and extrudable like anything else drawn by hand — + // unlike imported_regions, which are rigid and carry no solver entities. + // Returns false when no session is live, so the caller can fall back to a new feature. + bool add_imported_regions(const std::vector>>& regions); + void set_tool(Mode mode); // switch tool, keep accumulated entities + void set_plane(const SketchPlane& plane) { m_plane = plane; } // re-plane a live sketch (a reference plane was clicked mid-session); entities are 2D, re-lifted through the new plane + void set_construction(bool c) { m_construction = c; } + void set_polygon_sides(int n) { m_polygon_sides = (n < 3 ? 3 : n); } + void set_polygon_circumscribed(bool c) { m_polygon_circumscribed = c; } + void finish(); // emit accumulated entities, end session + void cancel(); + bool is_active() const { return m_active; } + bool has_entities() const { return !m_entities.empty(); } + bool on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas); + // Right-click on a draw tool: true when an in-progress anchor was abandoned, false when + // there was nothing to abandon — and false is what lets the offer menu open. ghcz. + bool right_abandon(); + // True if the LAST right-press was consumed as a gesture terminator (end a polyline chain, + // abandon an anchor, exit a tool). Read-and-clear: the canvas asks on the matching release to + // decide whether that right-click was the user's, in which case it opens the offer. + bool take_right_consumed() { const bool b = m_right_consumed; m_right_consumed = false; return b; } + void render(GLCanvas3D& canvas); + // The in-canvas value field, drawn by render() before any early return. Owned by + // DesignCanvas; null until it sets it. Not a window — see SketchInlineEditor.hpp. + class SketchInlineEditor* inline_editor{nullptr}; + + // Persistent committed sketches to draw even when no session is active (e.g. an + // un-consumed sketch left visible after its extrude is removed). Each carries its + // own plane. render() draws these as translucent faces + outlines. + struct DisplaySketch { std::vector entities; SketchPlane plane; int feature{-1}; }; + void set_display_sketches(std::vector ds) { m_display_sketches = std::move(ds); } + void set_highlight_sketches(std::vector> hl) { m_hl_sketches = std::move(hl); } + // Solid pick is resolved on LeftUp (see on_mouse): consuming the press broke orbit/pan. + int m_pick_press_x = 0; + int m_pick_press_y = 0; + bool m_pick_pending = false; + + bool has_display() const { return m_active || !m_display_sketches.empty() + || (m_solid_bodies != nullptr && !m_solid_bodies->empty()) + || !m_datum_planes.empty() + || m_show_planes || m_show_axes + || m_ex_active || m_mv_active || m_fl_active + || m_hl_active || m_th_active || m_sh_active + || m_dr_active || m_ct_active || m_dz_active || m_dbp_active || m_hx_active || m_rb_active; } + + // View helpers: the 3 world origin planes (XY/XZ/YZ) and the world axis triad, each + // shown/hidden by a toggle (keys P / A). Off by default so the idle scene stays clean. + void set_show_planes(bool s) { m_show_planes = s; } + void set_show_axes(bool s) { m_show_axes = s; } + bool toggle_show_planes() { m_show_planes = !m_show_planes; return m_show_planes; } + bool toggle_show_axes() { m_show_axes = !m_show_axes; return m_show_axes; } + + // Solid topology selection on the committed bodies: clicking a solid cycles + // whole-solid -> face -> edge (Onshape-style) to target fillet/chamfer/extrude. With + // multiple bodies the pick resolves WHICH body was hit (per-triangle body id). + // Appended, never reordered: DesignPanel maps this to a level int (Whole=1, Face=2, + // Edge=3, Vertex=4) and the offer table keys off it. + enum class SolidSel { None, Whole, Face, Edge, Vertex }; + // Point the tool at the current bodies + their concatenated tessellation (non-owning; + // pass nullptr to clear). Call after each recompute — selection resets (ids invalidate). + // tri_face = per-triangle face id within its body; tri_body = per-triangle body index. + void set_solid_pick(const std::vector* bodies, const TriangleMesh* mesh, + const std::vector* tri_face, const std::vector* tri_body, + const std::vector* visible = nullptr, + const std::vector* xform = nullptr); + // Body-focus picking: >=0 restricts every pick to that one body, so a face behind + // another solid is reachable without hiding anything. -1 = no restriction. + // Survives set_solid_pick() — it is owned by the panel, not by the mesh feed. + void set_pick_only_body(int b) { m_pick_only_body = b; } + void clear_solid_selection(); + bool has_solid_selection() const { return m_solid_sel != SolidSel::None; } + // Select a whole body by index (from the Parts list) — Whole-level highlight, no face/edge. + // body < 0 or out of range clears the selection. + void select_body(int body); + + // Move-body gizmo (M5): translate a whole body with three world-axis drag arrows + // (X red / Y green / Z blue) anchored at the body centroid. Display-only — the host + // keeps a per-body Transform3d and re-feeds the moved display/pick meshes; the OCCT + // shape (and thus face/edge global ids) is never touched. Drag fires on_body_move_changed + // live; a stationary click on an arrow opens the inline offset editor for that axis. + void set_move_gizmo(int body, const Vec3d& pivot, const Transform3d& base_xform, + double body_radius = 0.0); + void clear_move_gizmo(); + bool moving_body() const { return m_mv_active; } + int move_body_index() const { return m_mv_body; } + // F key forwarded from the canvas (Prepare's Place on Face): returns true if it acted. + bool request_place_on_face() { return on_place_on_face ? on_place_on_face() : false; } + std::function on_place_on_face; + std::function on_body_move_changed; + // Fired on each cycle change: (level 0=None/1=Whole/2=Face/3=Edge, body index, face id, edge id). + std::function on_solid_selection_changed; + // Click a committed sketch overlay (no live session) -> select that loop: the Sketch + // feature index + the clicked closed-region index within it (-1 = no specific loop). + // entity = the sketch entity index under the cursor when the click landed on a loop + // STROKE, else -1 for an interior/region hit. Carried because a tool can legitimately + // want the LINE you pointed at, not just the loop it belongs to (Rib, 3648). + std::function on_display_sketch_selected; + // Double-click on a committed sketch stroke: open THAT feature for editing. Selecting a line + // and then hunting for an Edit button in a panel is the dependency this tab exists to remove. + std::function on_display_sketch_activated; + // Entities forming the currently click-selected loop (for a per-loop extrude); empty + // if no loop is selected. + std::vector selected_loop_entities() const; + // Per closed loop, the indices into `ents` that form it (for hiding already-extruded + // loops from the committed-sketch overlay). + std::vector> region_entity_indices(const std::vector& ents) const; + // Same, but each region's entry is its OWN entities followed by the entities of each of its + // holes, in that order — the exact list a per-loop extrude of a region WITH holes stores + // (see selected_loop_entities()). Needed to match a consumed loop against its source sketch. + std::vector> region_entity_indices_with_holes(const std::vector& ents) const; + void clear_display_pick() { m_display_pick = -1; m_display_pick_region = -1; } + // Adopt a loop pick the tool did not make itself. The live-sketch path resolves the region + // BEFORE the sketch is committed, so once finish_sketch() has turned it into a display + // sketch there is nothing left that would set this — and selected_loop_entities(), which is + // what Extrude consumes, reads exactly these two fields. + void set_display_pick(int feature, int region) { m_display_pick = feature; m_display_pick_region = region; } + + // Visual Extrude gizmo (C5b). The Extrude tool is a DesignPanel docked card, so the + // sketch tool is NOT active during it; the panel feeds the profile plane + a 2D centroid + // (arrow anchor) + the live depths/flags, and the tool renders an in-canvas world-space + // depth arrow along plane.normal with a draggable handle + editable label. TwoSided draws + // a second arrow along -normal driven by depth2. Drag/edit fire on_extrude_depth_changed + // back to the panel, which writes the spin value + refreshes the ghost preview. + void set_extrude_gizmo(const SketchPlane& plane, const Vec2d& centroid, + double depth, double depth2, bool two_sided, bool flip); + void clear_extrude_gizmo(); + // (new_depth, second_side): second_side=false drives the primary depth, true the 2nd side. + std::function on_extrude_depth_changed; + + // Datum-plane resize gizmo (C3). The Plane tool is a DesignPanel docked card (sketch tool + // NOT active), so the panel resolves the candidate plane's frame + current u/v extent and + // feeds them here; the tool draws the rectangle outline + 4 edge-midpoint handles. Dragging a + // handle changes the u (left/right) or v (top/bottom) extent live and fires on_datum_size_changed + // back to the panel, which writes the Size spins + re-pushes the rendered datum. + void set_datum_gizmo(const SketchPlane& plane, double usize, double vsize, + const Vec3d& base_origin, const Vec3d& base_normal, + double offset, bool offset_on); + void clear_datum_gizmo(); + std::function on_datum_size_changed; + std::function on_datum_offset_changed; + + // Visual Helix gizmo. The Helix tool is a DesignPanel docked card (sketch tool NOT active), + // so the panel resolves the axis plane and feeds the live parameters here; the tool draws + // the helix curve itself plus three handles — radius on the base circle, height at the top + // of the axis, pitch at the end of the first turn. Taper and handedness stay on the card: + // one is a shape modifier and the other is a flag, and L2 governs numbers you can point at. + void set_helix_gizmo(const SketchPlane& plane, double radius, double pitch, double height, + double taper, bool left_handed); + void clear_helix_gizmo(); + std::function on_helix_changed; + + // Visual Rib thickness gizmo. The rib is a thin slab grown either side of an open sketch + // line, so its thickness is an IN-PLANE offset perpendicular to that line — the depth arrow + // (which points along the plane normal) cannot express it. Two handles, one per side, + // dragged symmetrically: the slab is centred on the line, so a drag on either side sets the + // full thickness rather than one half. + void set_rib_gizmo(const SketchPlane& plane, const Vec2d& p0, const Vec2d& p1, double thickness); + void clear_rib_gizmo(); + std::function on_rib_thickness_changed; + + // Graphical base/origin pick: while the Plane card is open, the candidate base planes + // (XY/XZ/YZ origin planes + existing datums) draw as translucent clickable ghosts. A click + // on one fires on_datum_base_picked(base) with that plane's base index (0/1/2 or 3+N). + void set_base_pick(std::vector planes, std::vector bases, + std::vector labels = {}); + void clear_base_pick(); + std::function on_datum_base_picked; + + // Visual Fillet/Chamfer gizmo. The Dressup tool is a DesignPanel docked card, so the sketch + // tool is NOT active during it; when a solid EDGE is picked the panel passes the body centroid + // + current radius and the tool anchors a world-space radius arrow at the picked edge midpoint + // (from m_sel_edge_pts), perpendicular to the edge, pointing outward (away from the centroid). + // Dragging the arrow changes the radius live; a stationary click opens the inline editor; both + // fire on_fillet_radius_changed back to the panel, which writes the spin + refreshes the ghost. + // Returns true if it could anchor (needs a picked edge with >=2 sample points). + bool set_fillet_gizmo(const Vec3d& body_centroid, double radius); + void clear_fillet_gizmo(); + bool filleting() const { return m_fl_active; } + std::function on_fillet_radius_changed; + + // Visual Hole gizmo. Like Dressup, the Hole tool is a DesignPanel docked card, so the sketch + // tool is NOT active during it; the panel passes the hole plane + position + diameter + depth + + // through flag, and the tool draws an on-plane footprint circle plus a radial diameter arrow, + // a normal-axis depth arrow (only when !through), and a draggable centre marker. Dragging the + // centre repositions (plane u/v), the diameter arrow resizes, the depth arrow deepens — all + // live; a stationary click on an arrow opens its inline editor. Every change fires + // on_hole_changed back to the panel, which writes the spins + refreshes the ghost. + void set_hole_gizmo(const SketchPlane& plane, double x, double y, + double diameter, double depth, bool through); + // Provide the face (u,v) bounds so the hole's construction dims read from the face sides. + void set_hole_face_bounds(bool has, double umin, double umax, double vmin, double vmax); + void clear_hole_gizmo(); + bool holing() const { return m_hl_active; } + std::function on_hole_changed; + + // Visual Thread gizmo. Same docked-card story as Hole: the panel feeds the thread plane + + // axis position + nominal radius + length; the tool draws an on-plane footprint circle plus a + // radial radius arrow and a normal-axis length arrow (always shown — a thread has no "through") + // and a draggable centre. Pitch/depth/internal stay in the card. Drag is live; a stationary + // click on an arrow opens its inline editor; every change fires on_thread_changed. + void set_thread_gizmo(const SketchPlane& plane, double x, double y, + double radius, double height); + void clear_thread_gizmo(); + bool threading() const { return m_th_active; } + std::function on_thread_changed; + + // Visual Shell gizmo. The panel passes the picked open-face centroid + an inward direction + // (-outward normal) + the current wall thickness; the tool anchors a single thickness arrow + // there (mirrors the fillet radius arrow). Dragging sets the thickness live; a stationary + // click opens the inline editor; both fire on_shell_thickness_changed. + void set_shell_gizmo(const Vec3d& face_centroid, const Vec3d& inward_dir, double thickness); + void clear_shell_gizmo(); + bool shelling() const { return m_sh_active; } + std::function on_shell_thickness_changed; + + // Datum/reference planes (Plane feature) carry no solid; the panel feeds their resolved + // SketchPlanes so they render as translucent rectangles in feature mode (otherwise a + // Plane feature is invisible in the canvas). + void set_datum_planes(std::vector planes, std::vector sizes = {}) { + m_datum_planes = std::move(planes); m_datum_sizes = std::move(sizes); + } + + // Mate connectors. Until now a connector was visible only to a program — resolve_datum_coordsys + // had exactly one consumer, the MCP socket — so the frame a mate is built on could not be seen + // at all. The glyph has to answer two questions on sight (wgsc): which way does Z point + // (the VERSE), and which of the pair is anchored versus driven (the POLARITY). Nothing in any + // surveyed CAD system encodes the second one. + struct MateConnectorGlyph { + Vec3d origin{0, 0, 0}; + Vec3d x{1, 0, 0}; // roll reference; the filled quadrant spans x -> y + Vec3d y{0, 1, 0}; + int role{0}; // 0 = neutral, 1 = fixed (receives), 2 = driven (moves) + bool roll_undefined{false}; + }; + void set_mate_connectors(std::vector g) { m_mate_connectors = std::move(g); } + // The pair line: the two origins of every mate — committed ones, plus the live pick of an open + // Mate card. Two connectors a mate binds are ONE object with a gap still in it; drawn as two + // separate frames they read as unrelated. + void set_mate_links(std::vector> l) { m_mate_links = std::move(l); } + void clear_mate_connectors() { m_mate_connectors.clear(); m_mate_links.clear(); } + + // Visual Revolve gizmo. The panel feeds the sketch plane + profile centroid + axis (0=plane X, + // 1=plane Y) + angle + flip while its Revolve card is open; an angle-arc is drawn in the + // revolve plane at the profile radius. Dragging the tip sweeps the angle, a stationary click + // edits it; both fire on_revolve_angle_changed. + void set_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, + int axis_sel, double angle, bool flip); + void clear_revolve_gizmo(); + bool revolving() const { return m_rv_active; } + std::function on_revolve_angle_changed; + + // Visual Draft angle-arc gizmo (taper a picked face; axis = world +Z, arc in XY). + void set_draft_gizmo(const Vec3d& face_centroid, const Vec3d& face_normal, double angle); + void clear_draft_gizmo(); + bool drafting() const { return m_dr_active; } + void set_on_draft_angle_changed(std::function cb) { m_on_draft_angle_changed = std::move(cb); } + + // Visual Cut gizmo (plane normal arrow + plane rectangle preview). + void set_cut_gizmo(const SketchPlane& plane, double offset, const Vec3d& body_center, double half_extent); + void clear_cut_gizmo(); + bool cutting() const { return m_ct_active; } + void set_on_cut_offset_changed(std::function cb) { m_on_cut_offset_changed = std::move(cb); } + + // Visual Pattern gizmo. Linear: a 3D arrow along the world axis (plane X/Y per `dir`) of length + // spacing*(count-1) with a tick at each copy; dragging the end sets the spacing. Circular: a + // revolve-style angle-arc about the plane normal through the plane origin sweeping `angle`. + // Both fire on_pattern_changed (spacing for linear, angle for circular). + void set_pattern_gizmo(const SketchPlane& plane, const Vec3d& body_centroid, bool circular, + int count, int dir, double spacing, double angle); + void clear_pattern_gizmo(); + bool patterning() const { return m_pt_active; } + std::function on_pattern_changed; + + // Constrain mode: load an already-committed profile for entity picking + + // constraint application (the geometry is solved in the kernel, not here). + void begin_constrain(const SketchProfile& prof, const SketchPlane& plane); + bool is_constraining() const { return m_active && m_mode == Mode::Constrain; } + // Replace the displayed profile (e.g. after the kernel re-solved it). + void set_profile_points(const std::vector& pts) { m_points = pts; } + // The currently picked segment's endpoint indices into the profile. + bool selected_segment(int& a, int& b) const; + + // Entity-aware Constrain (Fase 4.2): load a committed entity sketch and pick + // Line entities (constraints are solved against entity endpoints in the kernel). + void begin_constrain_entities(const std::vector& ents, const SketchPlane& plane); + bool is_constraining_entities() const { return m_active && m_mode == Mode::Constrain && m_constrain_entities; } + + // In-canvas bounding-box transform of imported Text/SVG art (replaces the Move/Scale + // dialog). `base_regions` are the untransformed region contours; the gizmo shows the + // current bbox with 4 corner scale-handles + a centre move-handle. Dragging fires + // on_imported_transform live with the new offset/scale, which the host writes back to + // the feature. Exiting (Esc/right-click) ends the session. + void begin_imported_transform(int feat, + const std::vector>>& base_regions, + const SketchPlane& plane, const Vec2d& offset, + double scale_x, double scale_y); + std::function on_imported_transform; + // Up to two picked line-entity indices; returns true if at least one is picked. + bool selected_constrain_entities(int& e0, int& e1) const { e0 = m_pick0; e1 = m_pick1; return m_pick0 >= 0; } + // Third pick slot (Symmetric axis): only filled after slots 0 and 1 are set. + int pick2() const { return m_pick2; } + // Plane-coords of the click that filled slot 0 (for pick-point edit ops: trim/extend). + bool pick0_point(Vec2d& out) const { out = m_pick0_pt; return m_pick0 >= 0; } + // Refresh the displayed entities after the kernel re-solved them. + void set_constrain_entities(const std::vector& ents) { m_entities = ents; } + // Constraint manager (C3.4): entity indices the panel asks to highlight (the + // entities a selected constraint references); rendered yellow in Constrain mode. + void set_constraint_highlight(std::vector v) { m_constraint_hl = std::move(v); } + // The committed feature's constraints, supplied so Constrain-mode render can draw + // an iconic glyph badge per constraint near its primary entity (C3.4b). + void set_constraint_glyphs(std::vector v) { m_constrain_cons = std::move(v); } + + // Line tool: after a single segment is placed, the panel pops a length dialog + // (length, angle_deg are the as-drawn values); it then resolves via + // apply_segment_length() (exact length) or keep_segment_as_drawn() (cancel). + std::function on_segment_drawn; + void apply_segment_length(double len); // rescale the pending segment, then commit it + void keep_segment_as_drawn(); // commit the pending segment unchanged + + // Live readout while drawing a Line/Polyline segment (anchor->cursor metrics). + std::function on_cursor_metrics; + + // Live step guidance (1c0c). The armed tool reports WHICH STEP of its gesture the + // user is on, every time that changes, so the status line can name the next click instead of + // repeating the one-shot sentence written when the tool was armed. step = anchors/picks + // already down (Mirror: 0 = no axis, 1 = axis down, 2 = ready to apply); picks = size of the + // set the gesture accumulates (mirror targets, transform targets, Select's selection). + std::function on_step_changed; + // Fired when the live constraint SET changes (one added or removed), never on a re-solve. + // The panel rebuilds its constraint rows from this; binding it to on_solve_state instead + // would rebuild the whole list on every frame of a drag. + std::function on_constraints_changed; + + // DoF feedback (P3): solver state after each live solve. dof>0 = under-constrained, + // dof==0 = fully constrained, ok==false = conflicting/inconsistent constraints. + // has_constraints is false while the sketch carries no driving constraints yet. + std::function on_solve_state; + + // Selection (Mode::Select): pick points/lines/arcs/circles of the in-session + // sketch; Shift/Ctrl extends, double-click grabs the whole connected loop. + const std::vector& selection() const { return m_selection; } + // Entities OR bare points: clear_selection() drops both, so "is anything picked" must ask + // about both, or Esc at idle would report nothing to do while a point sat highlighted. + bool sketch_has_selection() const { return !m_selection.empty() || !m_point_sel.empty(); } + // Type of the first selected entity. False when nothing is selected, so the offer menu can + // tell a line from an arc from a point and stop collapsing every sketch selection to "none". + bool first_selected_type(SketchEntity::Type& out) const { + if (m_selection.empty()) return false; + const int i = m_selection.front(); + if (i < 0 || i >= int(m_entities.size())) return false; + out = m_entities[i].type; + return true; + } + // Right-click pick: select the entity (or point handle) under the given canvas pixel, so + // the context menu describes what was pointed at. No-op when it is already selected, or + // when nothing is there. Returns true if the selection changed. + bool select_at_screen(GLCanvas3D& canvas, int sx, int sy); + // Open the in-canvas value field on the SELECTION's defining number (a line's length, an + // arc's radius, a circle's diameter, the angle between two lines, a point-to-point or + // point-to-line distance). False when the selection has no such number. + bool open_selection_dimension_editor(); + // ---- Scripted surface (MCP) ------------------------------------------------------- + // The same operations the right-click offers, reachable without a mouse gesture, so the 2D + // layer can be driven and asserted headlessly. Everything here goes through the SAME code a + // gesture goes through — append + infer_auto_constraints + live solve — because a test that + // exercises a private shortcut proves nothing about the tool the user drives. + const std::vector& entities() const { return m_entities; } + const SketchPlane& plane() const { return m_plane; } + int dof() const { return m_dof; } + bool solve_ok() const { return m_solve_ok; } + // Append entities exactly as a finished gesture does. Returns the index of the first one. + int add_entities_scripted(const std::vector& ents); + // Replace the selection with these entity indices (out-of-range ones are ignored). + bool select_indices(const std::vector& idx); + // Append candidates, live-solve, and roll back the batch if it turns the system + // inconsistent. Returns true when the batch was kept. Public so the panel's live-constraint + // path can commit a plan through the SAME append→solve→keep-or-rollback the gestures use. + bool try_add_constraints(const std::vector& cands); + // Click-to-delete on a constraint badge: drop the constraint whose glyph sits under `p` + // and re-solve. Returns true if one was removed (the caller then repaints). + bool remove_constraint_near(const Vec2d& p); + // Drop constraint `idx` from the live session and re-solve. Same operation the badge click + // performs, addressed by index instead of by position — the panel list needs the index form. + bool remove_constraint(int idx); + + // The loop report: what is CLOSED, what its internal voids are, and where a chain is still + // open. This is the answer to "is my profile buildable", and it is the one question the + // sketch layer could never be asked from outside. + struct LoopInfo { + std::vector ents; // entities of this loop, in chain order + std::vector holes; // indices into LoopReport::loops that this loop encloses + bool closed{false}; + double area{0.0}; // signed shoelace area of the loop polyline + }; + struct LoopReport { + std::vector loops; + std::vector open_ends; // free endpoints: where a chain fails to close + }; + LoopReport loop_report() const; + + // FreeCAD's ValidateSketch, as one call: weld endpoints that are within `tol` of each other + // and RECORD the Coincident constraints, so a loop that was closed by floating-point luck + // becomes closed by construction and survives every later solve. Returns how many pairs were + // welded. Construction geometry is skipped when `ignore_construction`. + int heal_coincidences(double tol, bool ignore_construction); + + void clear_selection(); + // Flip the selection between construction and real geometry (whole Feature groups). + // Returns how many entities changed; 0 when nothing is selected. + int toggle_selection_construction(); + void delete_selected(); // erase selected entities + // Abort any pending/queued draw-then-edit value-field sequence. Removing an entity that + // still has a deferred auto-edit would otherwise open a field on a now-deleted entity and + // freeze the flow (its live quote label also lingers). Mirrors set_tool's resync. + void reset_autoedit() { + m_awaiting_length = false; + m_autoedit_pending = false; + m_autoedit_dims.clear(); + m_autoedit_dim_idx = -1; + m_autoedit_seen = int(m_entities.size()); + m_live_quotes.clear(); // rebuilt from current geometry on the next render + } + // Take down the session's floating chrome: the open value field (dismiss = keep-as-drawn), + // the queue of fields behind it, and the corner readout. All three are top-level windows fed + // only while the tool is live, so nothing else would ever clear them — reset_autoedit() alone + // clears the flag and leaves the frame on screen. Called by finish()/cancel(); safe when + // nothing is open. + void close_session_chrome() { + if (on_inline_dismiss) on_inline_dismiss(); // no-op when no field is open + reset_autoedit(); + if (on_readout) on_readout(std::string()); // the HUD is not redrawn once the tool stops + } + // Ctrl+Z while sketching: drop the last drawn entity (reuses delete_selected's remap). + bool undo_last_entity() { + if (!m_active || m_entities.empty()) return false; + m_selection.assign(1, int(m_entities.size()) - 1); + delete_selected(); + reset_autoedit(); + return true; + } + // Delete while sketching: the selected entities, or the last drawn one if none is selected. + bool delete_selected_or_last() { + if (!m_active) return false; + if (m_selection.empty()) { + if (m_entities.empty()) return false; + m_selection.assign(1, int(m_entities.size()) - 1); + } + delete_selected(); + reset_autoedit(); + return true; + } + std::function on_selection_changed; + + // Dimension tool: infer a driving dimension from the current selection and set + // it exactly. Sizing: 1 line=Length, 1 circle=Diameter, 1 arc=Radius, + // 2 lines=Angle. Positioning (a value of 0 makes them coincident): + // 2 point-likes (point/circle-centre/arc-centre)=Distance, moving the 2nd onto + // the 1st; a point-like + a line=DistanceToLine, moving the point-like's + // reference point onto/away-from the line (e.g. a circle centre onto an axis). + enum class DimType { None, Length, Diameter, Radius, Angle, Distance, DistanceToLine }; + DimType dimension_kind() const; // what the selection supports (None if invalid) + double dimension_current() const; // current value, to pre-fill the dialog + void apply_dimension(double v); // set it exactly, then clear the selection + + // Onshape-style Dimension tool (Mode::Dimension): with the tool active you click + // directly in the viewport — 2 points -> Distance, a line -> Length, a circle -> + // Diameter, an arc -> Radius, a point then a line -> DistanceToLine. A quote line + // with extension lines, arrowheads and a numeric label is PLACED in the sketch and + // drives the geometry (auto-offset; label editable). on_dimension_pick_complete + // fires when a pick resolves so the panel can pop the value card pre-filled. + std::function on_dimension_pick_complete; + DimType pending_dimension_type() const; // type of the dim awaiting a value, or None + void set_dimension_value(double v); // apply the typed value to the placed dim + void cancel_dimension_value(); // keep the placed dim at its measured value + + // Onshape-style in-canvas value editing: open a floating text editor at the given + // screen pixel, pre-filled with `current`; commit applies the value, cancel keeps + // it. The owner (DesignCanvas) hosts the wxTextCtrl over the GL canvas. This is the + // single numeric-entry path for all sketch dimensions (replaces the modal cards). + std::function commit, + std::function cancel)> on_inline_edit; + // Force-close any open inline field (runs its cancel = keep-as-drawn). Used by the polyline + // terminators (right-click / double-click) to end the chain even mid per-segment edit. + std::function on_inline_dismiss; + // Accept and close an open inline value field. dismiss() CANCELS; this one keeps the value, + // which is what leaving a tool should do — see set_tool(). + std::function on_inline_commit; + + // Bottom-right viewport readout: emitted each frame with the active tool's current + // values (live segment length/angle while drawing a line, or the selected entity's + // characteristic dimensions). Empty string -> hide the HUD. The owner (DesignCanvas) + // shows it as a floating corner label over the GL canvas. + std::function on_readout; + + // Driving dimension constraints accumulated during the session (the Dimension + // tool records a SketchEntityConstraintDef per applied dimension); committed + // alongside the entities on finish() so the kernel keeps enforcing them. + const std::vector& constraints() const { return m_constraints; } + + // Emitted by finish() with the accumulated entities + driving constraints. + std::function&, + const std::vector&, + const SketchPlane&)> on_commit_entities; + // Legacy single-profile commit (kept for compatibility; unused by entity tools). + std::function on_commit; + // Emitted when a closed-loop face is clicked in Select mode (Onshape: a region + // becomes a selectable face → extrude). The panel commits the sketch + extrudes. + std::function on_face_selected; // region index into region_loops(m_entities) + // Esc pressed while the tool is active with nothing left to unwind and no geometry to + // lose: leave the session (the panel restores Feature mode). + std::function on_exit; + // request_exit() declined to leave because the session holds geometry. The panel owns the + // status line, so the tool reports through this instead of writing text itself. + std::function on_exit_refused; + std::function on_move_exit; // right-click finished the move-body gizmo + // The two inner Esc levels, callable on their own so the panel can route one press to one + // level (see DesignInteraction.hpp). Each returns whether it had anything to unwind. + bool abort_gesture(); // CadLevel::Gesture — drop the entity being drawn + bool disarm_tool(); // CadLevel::Tool — armed draw/edit tool falls back to Select + void request_exit(); + // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) while the Design canvas is focused: undo/redo the + // committed feature history. The tool just forwards to the host, which owns the + // CadDocument (the tool has no document of its own). redo == true requests redo. + std::function on_undo_redo; + void request_undo_redo(bool redo); + +private: + bool screen_to_plane(GLCanvas3D& canvas, const wxMouseEvent& evt, Vec2d& out) const; + // True when the cursor is close enough to the open chain's FIRST point to close the loop, + // on the same screen tolerance as every other snap; snaps `p` exactly onto that point so + // the rubber band previews the closing segment and the snap marker lights. + bool snap_chain_start(GLCanvas3D& canvas, const wxMouseEvent& evt, Vec2d& p) const; + + // Onshape-style angle inference: snap the direction anchor->raw to the nearest + // of {0,30,45,60,90} deg (replicated every 90 deg) when within tolerance, keeping + // the same length. Sets `locked` when a snap was applied. Suppressed by m_snap_off. + Vec2d snap_dir(const Vec2d& anchor, const Vec2d& raw, bool& locked) const; + // Snap a placed point onto the nearest existing entity endpoint within ~8 px so + // chains join across entities (a line + an arc can close into one loop). Shift + // disables it. `snapped` reports whether a vertex was hit. + Vec2d snap_vertex(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw, bool& snapped) const; + + // --- P1 inference / auto-constraint engine --------------------------------- + // Plane-units tolerance equivalent to ~`px` screen pixels at the cursor. + double screen_tol(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& at, double px = 8.0) const; + // Run kernel inference at the cursor, cache the target for the hint renderer. + InferenceSnap infer_at(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw) const; + // True if m_constraints already holds an equivalent Coincident between the two refs. + bool has_coincident(int ea, SketchPointRole ra, int eb, SketchPointRole rb) const; + // After entities [base, end) were committed, auto-emit the constraints that make + // the new geometry stick: Coincident between co-located endpoints (so loops close + // on their own) and Horizontal/Vertical on axis-aligned new segments. + // ang_tol_rad is how far from an axis a segment may be and still be CALLED axis-aligned. + // A gesture needs the default 3 degrees — nobody clicks a horizontal line exactly — but + // that same slack MOVES geometry that was given exactly, so the scripted path passes a + // tolerance tight enough to recognise only what is already true. See add_entities_scripted. + // ang_tol_rad: how far off axis a segment may be and still be called Horizontal/Vertical. + // weld_tol: how far apart two endpoints may be and still be called Coincident. + // Both default to GESTURE slack. A scripted add passes zero for both: the caller has + // already said exactly what it means, and every non-zero window is a window in which the + // inference rewrites it. 8xg1. + void infer_auto_constraints(int base, double ang_tol_rad = 3.0 * M_PI / 180.0, + double weld_tol = 1e-3); + + // Selection helpers (Mode::Select). + int hit_test(const Vec2d& p, double tol) const; // nearest entity within tol, or -1 + std::vector connected_loop(int seed) const; // entities joined by shared endpoints + void apply_angle_between(int ia, int ib, double deg); // rotate line B to set the A^B angle + bool selection_valid() const; // all selection indices in range + void record_dimension_constraint(double v); // append the driving def for the selection + void resolve_live(); // solve accumulated constraints on m_entities now + // Drag-aware re-solve: pins the dragged point at its current coord and lets the + // solver move the rest (Slvs dragged[]). Used live while a point grab is active. + void resolve_live_drag(int dragged_ei, SketchPointRole dragged_role); + + // Placed dimension annotation. References entity points/entities (not cached + // coords) so the quote follows the geometry as the kernel solves it. `value` + // drives the constraint stored at index `con` in m_constraints. + struct DimAnnot { + DimType kind{DimType::None}; + int ea{-1}; SketchPointRole ra{SketchPointRole::P0}; + int eb{-1}; SketchPointRole rb{SketchPointRole::P0}; + double value{0.0}; + double side{1.0}; // perpendicular offset sign of the quote line + int con{-1}; // slot in m_constraints driving this dimension + Vec2d label_pos{0, 0}; // cached label centre (plane coords), for picking + }; + + // --- Onshape-style visual editing: handles + parametric feature grouping ----- + // A draggable handle on a defining point of an entity (or a derived point of a + // feature group). GUI-only; recomputed from solved geometry every frame (never + // persisted), so handles always track the current solve. Derived roles (radius, + // slot width/centres, rect corners, polygon vertex, ellipse axes) let tools that + // decompose into raw Line/Arc entities still expose their parametric controls. + enum class HandleRole { P0, P1, Center, RadiusHandle, + SlotCenter0, SlotCenter1, SlotWidth, + RectCorner, PolygonVertex, MajorAxis, MinorAxis, BSplineCtrl }; + struct Handle { + HandleRole role{HandleRole::P0}; + int ei{-1}; // primary entity index + int group{-1}; // index into m_features, or -1 for a raw-entity handle + int ctrl_index{-1}; // BSplineCtrl pole index + Vec2d pos{0, 0}; // current plane coords (recomputed each frame) + bool hovered{false}; + }; + // A parametric grouping over a contiguous run of entities produced by one gesture. + // Slot/Rect/Polygon/etc. have no SketchEntity type of their own — they decompose + // into raw Line/Arc entities — so the Feature carries the gesture's anchors so + // derived handles + characteristic dimensions can be reconstructed. + enum class FeatureKind { Free, Line, Circle, Arc, CornerRect, CenterRect, + Slot, ArcSlot, Polygon, Ellipse, RoundedRect, BSpline }; + struct Feature { + FeatureKind kind{FeatureKind::Free}; + int begin{0}, end{0}; // [begin,end) into m_entities + Vec2d c0{0, 0}, c1{0, 0}; // slot centres / rect corners / ellipse centre+major + double param{0.0}; // slot half-width / polygon circumradius / fillet radius + int sides{0}; // polygon side count + }; + // Build the live handle set for the current selection / just-drawn feature. + std::vector build_handles() const; + // Nearest handle to plane-point p within tol; fills `out`. (Phase A: stub.) + bool hit_test_handle(const Vec2d& p, double tol, Handle& out) const; + // Move a handle to `target`, applying the role-specific geometry edit + re-solve. + void set_handle(const Handle& h, const Vec2d& target); + // On a no-button move, recompute the hovered handle; returns true iff it changed + // (so the caller forces exactly one repaint). No-op for non-Moving events. + bool update_hover(GLCanvas3D& canvas, wxMouseEvent& evt); + // Index of the Feature whose [begin,end) entity span contains ei, or -1. + int feature_of(int ei) const; + // Re-detect parametric Feature groups (polygon / rect / slot) from the raw entity + // list — used when a committed sketch is re-opened, where m_features is empty. + void rebuild_features_from_entities(); + // Open/close a Feature record around the entities a single gesture appends. + void begin_feature(FeatureKind kind); + void end_feature(const Vec2d& c0 = Vec2d(0, 0), const Vec2d& c1 = Vec2d(0, 0), + double param = 0.0, int sides = 0); + + bool point_at(int ei, SketchPointRole role, Vec2d& out) const; // current coords + void set_point(int ei, SketchPointRole role, const Vec2d& v); // move an entity point + bool hit_test_point(const Vec2d& p, double tol, int& ei, SketchPointRole& role) const; + int hit_test_dimension(const Vec2d& p, double tol) const; // nearest dim label + void edit_dimension(int di); // reopen value card for di + // Representative plane-coords anchor of a dimension (label centre if known, else a + // geometric midpoint/centre) — where the in-canvas value editor is positioned. + Vec2d dim_anchor(const DimAnnot& a) const; + // Open the in-canvas value editor on dimension `di` (falls back to the modal + // pick-complete callback when no inline-edit host is wired). + void open_value_editor(int di); + // In-canvas editor for a line's angle-to-horizontal; commit rotates the segment + // geometrically about P0 (no single-line angle constraint in libslvs). + void open_angle_editor(int ei); + void set_line_angle(int ei, double deg); + // Draw-then-edit (all creation tools): open the inline editor on the freshly-drawn + // selection's PRIMARY characteristic value. Called after render_live_quotes has computed + // the selection's quotes, so it dispatches on the same live-quote state a Select-mode + // click would use. + void open_primary_autoedit(); + // Compact "current values" string for the bottom-right HUD (see on_readout). + std::string build_readout() const; + // Open a characteristic live quote as a TENTATIVE driving dimension: the constraint is + // appended only if the user commits a value (Enter); cancel (Esc) adds nothing — so + // drawing never silently over-constrains. (place_dimension is the eager Select-mode twin.) + void open_next_autoedit_dim(); // opens m_autoedit_dims[idx]; commit -> next, Esc -> stop + void arm_polyline_segment_edit();// per-segment Length+Angle edit of the pending chain vertex + // In-canvas editors for a regular polygon's side length and orientation. Both edit + // the whole loop GEOMETRICALLY (polygon has no centre entity): side scales it + // uniformly about its centre, angle rotates it. set_polygon_radius is the shared + // uniform-scale primitive (circumradius). + void open_polygon_side_editor(int fi); + void open_polygon_angle_editor(int fi); + void set_polygon_side(int fi, double side); + void set_polygon_angle(int fi, double deg); + void set_polygon_radius(int fi, double R); + // Arc sweep-angle quote: geometric edit (SLVS angle is line-to-line only). Keeps the + // arc start point + radius fixed and moves the end point to span `deg` degrees. + void open_arc_angle_editor(int ei); + void set_arc_sweep(int ei, double deg); + // Arc handle drag (3 grips): Center rigidly translates; the START point changes the + // radius (keeps both sweep angles); the END point changes the sweep angle (keeps the + // radius). Geometric — no solver (SLVS has no arc radius/angle handle concept here). + void drag_arc_handle(int ei, SketchPointRole role, const Vec2d& target); + // Ellipse axis labels (geometric edit of the semi-axes a/b; phi via the major grip). + void open_ellipse_axis_editor(int ei, bool major); + void set_ellipse_axis(int ei, bool major, double v); + void set_ellipsearc_sweep(int ei, double deg); // draw-then-edit: included sweep of an elliptical arc + void set_rect_angle(int fi, double deg); // draw-then-edit: orientation of an oblique rect + // EllipseArc endpoint drag: Center translates; P0/P1 move the sweep start/end to the + // parametric angle of the cursor on the ellipse frame (radius/shape preserved). + void drag_ellipsearc_handle(int ei, SketchPointRole role, const Vec2d& target); + // Drop orientation constraints (H/V/Parallel/Perp/Angle/LockX/LockY) on entities in + // [begin,end). A ROTATION makes inferred per-edge H/V inconsistent, so re-solving + // against them collapses the shape — drop them first (fixes up DimAnnot.con indices). + void drop_orientation_constraints(int begin, int end); + // Drop every live constraint that references entity `ei` (Trim/Extend slide an endpoint, + // invalidating its constraints) and fix the dimensions' cached constraint indices. + void drop_constraints_referencing(int ei); + // Standalone Trim/Extend scissors on the LIVE sketch: pick the entity nearest `p` (within + // `tol` plane units) and cut it back to / out to its nearest intersection with the others. + // Returns true if an entity was modified. + bool apply_live_trim(const Vec2d& p, double tol, bool extend); + // Pure-computation hover preview for Trim/Extend: mirror apply_live_trim's pick + the + // engine's cut on a COPY (mutating nothing) and return, via `removed_poly`, the polyline + // of the sub-portion a click would REMOVE (Trim) or ADD (Extend). `subject_ei` is the + // picked entity. Returns false if nothing is in range or nothing would change. + bool compute_trim_preview(const Vec2d& p, double tol, bool extend, + int& subject_ei, std::vector& removed_poly) const; + // Drag a polygon vertex while keeping the loop REGULAR: scale + rotate the whole + // polygon about its centroid so the grabbed vertex follows `target` (adjusts + // circumradius + orientation together). + void drag_polygon_vertex(int fi, int ei, SketchPointRole role, const Vec2d& target); + double measure_dim(const DimAnnot& a) const; // value from geometry + std::string dimtype_title(DimType k) const; + SketchEntityConstraintDef constraint_for(const DimAnnot& a) const; // driving def + // One driving constraint (and one visible quote) per kind+operands: re-typing a value must + // UPDATE it, not append a rival asking for something else. Both return the index. + int upsert_constraint(const SketchEntityConstraintDef& c); + int upsert_dimension(const DimAnnot& a); + int place_dimension(DimAnnot a); // create+drive+notify + std::string dim_text(const DimAnnot& a) const; // rendered label string + void render_dimensions(double unit_per_px); // quote lines + labels + // Draw ONE dimension's quote (extension/dimension lines, arrowheads, label) and + // return its label centre in out_label; false if the annot can't be drawn. Shared + // by render_dimensions (placed driving quotes) and render_live_quotes (live ones). + bool draw_dim_quote(const DimAnnot& a, double th, const ColorRGBA& col, Vec2d& out_label); + // Live, non-driving characteristic quotes for the entity being edited (point/handle + // drag, or a lone selection): the tool's defining dimensions shown Onshape-style so + // editing shows live values; click one (m_live_quotes) to promote it to a driving + // dim. Self-gates; skips a dim already driven on that entity. + void render_live_quotes(double unit_per_px); + // Iconic constraint badges (C3.4b): for each m_constrain_cons entry, append a + // small screen-constant glyph (H, V, ∥, ⊥, =, ○, …) near its primary entity into + // `out`; glyphs touching the same entity stack so they don't overlap. + void build_constraint_glyphs(double unit_per_px, + const std::vector& cons, + std::vector>& out); + void draw_strokes(GLModel& model, const std::vector>& segs, + double hw, const ColorRGBA& color); + void draw_text(GLModel& model, const std::string& s, const Vec2d& center, + double height, const ColorRGBA& color); // GL stroke font + void draw_dim_label(const std::string& txt, const Vec2d& plane_center); + + // Entity builders: append to m_entities (honoring the construction flag). + void push_line(const Vec2d& a, const Vec2d& b); + void push_closed_lines(const std::vector& corners); + void push_open_chain(const std::vector& pts); + void push_circle(const Vec2d& center, double radius); + void push_point(const Vec2d& p); + + // Multi-click tool builders: return the entities for a finished gesture so + // both on_mouse (append) and render (preview) share one geometry path. + std::vector make_three_point_circle(const Vec2d& a, const Vec2d& b, const Vec2d& c) const; + std::vector make_three_point_arc(const Vec2d& start, const Vec2d& end, const Vec2d& on_arc) const; + std::vector make_tangent_arc(const Vec2d& start, const Vec2d& end) const; + // Center-start-end arc: click center, then start (sets radius), then a third + // point whose direction from the center sets the CCW end angle. + std::vector make_center_arc(const Vec2d& center, const Vec2d& start, const Vec2d& end_dir) const; + std::vector make_slot(const Vec2d& c0, const Vec2d& c1, double half_width) const; + std::vector make_arc_slot(const Vec2d& center, const Vec2d& start, + const Vec2d& end_dir, double half_width) const; + std::vector make_rounded_rect(const Vec2d& a, const Vec2d& b, const Vec2d& radius_pt) const; + std::vector rounded_rect_entities(double xmin, double ymin, + double xmax, double ymax, double r) const; + // Rounded-rect grouped edit: W/H/fillet-R labels rebuild the 8-entity span in place. + void open_rounded_rect_editor(int fi, int which); // 0=Width 1=Height 2=fillet R + void set_rounded_rect(int fi, double w, double h, double r); + // Arc-slot grouped edit: centreline-radius + width labels rebuild the 4-arc span. + void open_arc_slot_editor(int fi, bool radius); // true=centreline R, false=width + void set_arc_slot(int fi, double Rc, double w); + // Straight-slot grouped edit: centreline-length + width labels rebuild the 4-entity span. + void open_slot_editor(int fi, int which); // 0=inter-centre distance, 1=radius, 2=angle + void set_slot(int fi, double length, double w); + void set_slot_angle(int fi, double deg); // rotate the centreline about c0, keep len+radius + // Grouped derived-handle drag: resize an axis-aligned rect by a corner (opposite corner + // fixed); move a slot end by its cap centre. Both rebuild the feature span geometrically. + void drag_rect_corner(int fi, const Vec2d& cursor); + void drag_slot_handle(int fi, const Vec2d& cursor); + std::vector make_polygon(const Vec2d& center, const Vec2d& vertex, int sides) const; + // Ellipse: click center, then major-axis endpoint (sets a + rotation phi), + // then a point whose perpendicular distance to the major axis sets b. + std::vector make_ellipse(const Vec2d& center, const Vec2d& major_end, + const Vec2d& minor_pt) const; + // Elliptical arc: same 3 axis clicks, then start and end points whose parametric + // angles on the ellipse bound the CCW sweep. + std::vector make_bspline(const std::vector& ctrl) const; + std::vector make_ellipse_arc(const Vec2d& center, const Vec2d& major_end, + const Vec2d& minor_pt, const Vec2d& start_pt, + const Vec2d& end_pt) const; + void append_entities(const std::vector& ents); + void draw_entities_preview(const std::vector& ents, const ColorRGBA& color); + + // --- In-canvas edit-op gizmo (Fillet/Chamfer/Offset/Mirror toolbar tools) -------- + // These replace the docked numeric card: pick the entities in-canvas, then a draggable + // arrow with a value label is projected toward the corner/centre (Fillet/Chamfer/Offset), + // or a two-phase pick (axis line, then targets) drives a live mirrored ghost. The + // SketchEngine op is recomputed live so a translucent ghost previews the result; confirm + // applies the geometry and binds constraints into m_constraints (try_add_constraints). + bool op_corner(int a, int b, Vec2d& C, Vec2d& bis, double& theta) const; // line-line vertex + inward bisector + void op_pick(int ei); // route an entity pick to the active op + void recompute_op_ghost(); // rebuild m_op_ghost from m_op_value + void render_op_gizmo(double unit_per_px); // ghost + arrow + value label (caches m_op_label) + bool hit_test_op_arrow(const Vec2d& p, double tol) const; + void drag_op_arrow(const Vec2d& target); // project cursor onto m_op_dir -> value + void open_op_editor(); // inline-edit the value label + void confirm_op(); // apply + bind, then reset for the next gesture + void reset_op(); // clear gizmo state (keeps the tool active) + bool op_ready() const; // required entities picked -> arrow/ghost live + + // Sample an entity into a 2D polyline for the overlay renderer. + std::vector entity_polyline(const SketchEntity& e, bool& closed) const; + + // Closed regions formed by the current (non-construction) entities: each a CCW- + // ordered boundary polygon on the plane. A circle is its own region; line/arc + // chains are walked endpoint-to-endpoint into loops. Used to fill faces. + std::vector> closed_regions() const; + std::vector> closed_regions(const std::vector& ents) const; + // Same loops, but each carries the indices of the entities that form it — so a single + // loop can be highlighted / extruded on its own (per-region selection on the plate). + // A selectable sketch region: its own boundary, plus the loops nested INSIDE it, which + // are its holes. Modelling holes is what makes "the plate with the hole in it" a thing the + // user can point at — without it a sketch is N disjoint filled polygons and the only + // selectable things are the rectangle alone or the circle alone (txp8). + struct RegionLoop { + std::vector poly; + std::vector ents; + std::vector holes; // indices into the same vector; one nesting level + }; + std::vector region_loops(const std::vector& ents) const; + // Index of the closed region containing plane-point p (point-in-polygon), or -1. + int region_at(const Vec2d& p) const; + + void draw_quad_strip(GLModel& model, const std::vector& pts, bool closed, const ColorRGBA& color); + // half_size is the square marker half-extent in PLANE units. Callers pass a + // zoom-scaled value (k / zoom) for screen-constant handles; the default keeps + // legacy point markers exactly as before. + void draw_vertices(GLModel& model, const std::vector& pts, const ColorRGBA& color, + double half_size = 1.3); + void draw_fill(GLModel& model, const std::vector& poly, const ColorRGBA& color); + // Same, with the region's holes cut out, so a selected plate-with-a-hole is drawn as an + // ANNULUS instead of a filled rectangle painted straight across its own bore. + void draw_fill_holed(GLModel& model, const std::vector& outer, + const std::vector>& holes, const ColorRGBA& color); + const ColorRGBA* sketch_hl_color(int feature) const; + + bool m_active{false}; + SketchPlane m_plane; + std::vector m_points; // clicks of the in-progress entity / chain + std::vector m_entities; // committed entities of this session + bool m_construction{false}; + int m_polygon_sides{6}; + bool m_polygon_circumscribed{false}; + Vec2d m_cursor{0,0}; + bool m_has_cursor{false}; + bool m_snap_off{false}; // Shift held -> suppress angle snapping + InferenceSnap m_cursor_snap; // last cursor inference target (for hint render) + bool m_cursor_locked{false}; // rubber-band segment is angle-locked + bool m_awaiting_length{false}; // inline value editor open -> freeze canvas + int m_autoedit_seen{-1}; // entity count baseline for draw-then-edit + bool m_autoedit_pending{false};// a new entity just committed -> open editor + // Draw-then-edit step queue: every characteristic dimension of the freshly-drawn shape + // (scalar quote OR geometric editor) becomes one step, opened in sequence over its label. + struct AutoEditStep { + Vec2d label; // anchor (plane coords) — field opens over this + double value; // initial value shown + std::function apply; // commit: set the dimension + std::vector hi; // entities to highlight while THIS field is open + std::string title; // label shown above the value field + }; + std::vector m_autoedit_dims; // queued steps to edit in sequence + int m_autoedit_dim_idx{-1}; // index into m_autoedit_dims (-1 = idle) + std::vector m_selection; // selected entity indices (Mode::Select) + std::vector> m_point_sel; // selected individual points + int m_last_mouse_x{0}; // last cursor pos (canvas client px), for + int m_last_mouse_y{0}; // anchoring the in-canvas value editor + bool m_dragging_point{false}; // a point grab is in progress (Mode::Select) + int m_drag_ei{-1}; // entity whose point is being dragged + int m_drag_poly_fi{-1}; // >=0 if the grabbed point is a polygon + // vertex: drag scales+rotates the loop + int m_drag_rect_fi{-1}; // >=0 if dragging an axis-aligned rect corner + Vec2d m_drag_rect_anchor{0,0}; // the fixed (opposite) corner + int m_drag_slot_fi{-1}; // >=0 if dragging a slot cap centre + bool m_drag_slot_c1{false}; // true=cap@c1, false=cap@c0 + SketchPointRole m_drag_role{SketchPointRole::P0}; + std::vector m_constraints; // driving dims, committed on finish + + // Onshape-style visual editing state. + bool m_show_handles{false}; // draw + interact with handles + bool m_dragging_handle{false};// a handle grab is in progress + Handle m_drag_handle; // the handle being dragged + bool m_has_hover_handle{false};// cursor is near a handle (highlight it) + Handle m_hover_handle; // the hovered handle (recomputed on move) + std::vector m_live_quotes; // live non-driving characteristic quotes, + // clickable to promote to driving dims + Vec2d m_live_poly_side_label{0,0}; // polygon side-length quote label + Vec2d m_live_poly_angle_label{0,0}; // polygon orientation quote label + int m_live_poly_fi{-1}; // their Feature (geometric edits) + Vec2d m_live_arc_angle_label{0,0}; // arc sweep-angle quote label + int m_live_arc_ei{-1}; // the arc it belongs to (geometric edit) + Vec2d m_live_ellipse_major_label{0,0}; // ellipse semi-major quote label + Vec2d m_live_ellipse_minor_label{0,0}; // ellipse semi-minor quote label + Vec2d m_live_ellipsearc_sweep_label{0,0}; // elliptical-arc sweep quote label + int m_live_ellipse_ei{-1}; // the ellipse the labels belong to + Vec2d m_live_obrect_angle_label{0,0}; // oblique-rect orientation quote label + int m_live_obrect_fi{-1}; // an OBLIQUE rect Feature (angle editable) + Vec2d m_live_rrect_w_label{0,0}; // rounded-rect width quote label + Vec2d m_live_rrect_h_label{0,0}; // rounded-rect height quote label + Vec2d m_live_rrect_r_label{0,0}; // rounded-rect fillet-radius label + int m_live_rrect_fi{-1}; // the rounded-rect Feature (rebuild edits) + Vec2d m_live_aslot_r_label{0,0}; // arc-slot centreline-radius label + Vec2d m_live_aslot_w_label{0,0}; // arc-slot width label + int m_live_aslot_fi{-1}; // the arc-slot Feature (rebuild edits) + Vec2d m_live_slot_len_label{0,0}; // straight-slot inter-centre distance label + Vec2d m_live_slot_w_label{0,0}; // straight-slot radius (half-width) label + Vec2d m_live_slot_angle_label{0,0}; // straight-slot centreline angle label + int m_live_slot_fi{-1}; // the straight-slot Feature (rebuild edits) + std::vector m_features; // parametric groups over m_entities + int m_open_feature{-1}; // index of the Feature being built, or -1 + + // In-canvas edit-op gizmo state (Fillet/Chamfer/Offset/Mirror). GUI-only, reset by + // set_tool/cancel. Fillet/Chamfer: m_op_a,m_op_b = the two lines; Offset: m_op_a = src; + // Mirror: m_op_a = axis line, m_mirror_targets = entities to mirror. + // Last (mode, step, picks) reported through on_step_changed; -1 mode = nothing reported yet. + int m_step_mode_last{-1}; + int m_step_last{-1}; + int m_step_picks_last{-1}; + int m_op_a{-1}; + int m_op_b{-1}; + double m_op_value{0.0}; // radius / setback / signed offset distance + Vec2d m_op_anchor{0,0}; // arrow base (corner vertex / entity midpoint) + Vec2d m_op_dir{0,0}; // unit arrow direction (inward bisector / outward normal) + Vec2d m_op_label{1e18,1e18}; // cached arrow-label centre, for picking + std::vector m_op_ghost; // live result preview (recomputed on value change) + bool m_op_dragging_arrow{false}; // arrowhead drag in progress + std::vector m_mirror_targets; // Mirror: entities to be mirrored (axis = m_op_a) + + // In-canvas imported-art transform gizmo (Mode::TransformArt). GUI-only. The art's + // untransformed contours + its bbox in base coords; the live offset/scale; the grabbed + // handle (0..3 = corners, 4 = centre move, -1 = none) and the fixed world anchor (the + // opposite corner during a corner-scale drag). + std::vector>> m_xform_base; + int m_xform_feat{-1}; + Vec2d m_xform_min{0,0}, m_xform_max{0,0}; // bbox of m_xform_base (untransformed) + Vec2d m_xform_offset{0,0}; + double m_xform_sx{1.0}, m_xform_sy{1.0}; + int m_xform_handle{-1}; + Vec2d m_xform_anchor{0,0}; + void xform_world_corners(Vec2d out[4]) const; // 4 bbox corners in plane coords + int hit_test_xform_handle(const Vec2d& p, double tol) const; + void drag_xform_handle(const Vec2d& target); + void render_xform_gizmo(); + void emit_xform(); + void reset_xform(); + + // In-canvas transform gizmo state (Mode::Move/Rotate/Scale/Array/PolarArray). GUI-only, + // reset by set_tool/cancel. Pick one or more subject entities (m_tf_targets), then a + // single draggable handle drives the continuous parameter and a live translucent ghost + // previews the result; Array/PolarArray add a second editable label for the copy count. + // Mutating ops (Move/Rotate/Scale) drop the constraint classes the map invalidates; + // additive ops (Array/PolarArray) bind each copy to its source. See confirm_transform(). + std::vector m_tf_targets; // picked subject entity indices + Vec2d m_tf_pivot{0,0}; // rotate/scale/polar pivot = set centroid + Vec2d m_tf_delta{0,0}; // Move translation / Array per-step vector + double m_tf_angle{0.0}; // Rotate angle / PolarArray total sweep (rad) + double m_tf_scale{1.0}; // Scale factor + int m_tf_count{3}; // Array/PolarArray copy count (incl. original) + double m_tf_handle_r{1.0}; // ring/handle reference radius (set on pick) + std::vector m_tf_ghost; // live result preview + int m_tf_handle{-1}; // 0 = primary drag handle grabbed, -1 = none + bool m_tf_dragging{false}; + Vec2d m_tf_label_a{1e18,1e18}; // primary-param label centre (picking) + Vec2d m_tf_label_b{1e18,1e18}; // count label centre (Array/PolarArray) + bool tf_ready() const; // >=1 target picked -> gizmo + ghost live + void tf_pick(int ei); // accumulate a subject, seed defaults once + void compute_tf_pivot(); // centroid + extent of the target set + void recompute_tf_ghost(); + Vec2d tf_handle_pos() const; // world position of the drag handle + bool hit_test_tf_handle(const Vec2d& p, double tol) const; + void drag_tf_handle(const Vec2d& target); + void render_tf_gizmo(double unit_per_px); + void open_tf_editor_a(); // inline-edit the continuous parameter + void open_tf_editor_count(); // inline-edit the copy count + void confirm_transform(); // apply geometry + constraint web + void reset_tf(); + + // DoF feedback state, refreshed by resolve_live() from the libslvs solve result. + int m_dof{-1}; // remaining DoF; 0 = fully constrained, <0 = unknown + bool m_solve_ok{true}; // solver consistent (no conflicting constraints) + std::vector m_entity_conflict; // per-entity flag: touched by a conflicting constraint + std::vector m_dimensions; // placed dimension quotes (Mode::Dimension) + int m_dim_e0{-1}; // first picked point's entity (Dimension) + SketchPointRole m_dim_r0{SketchPointRole::P0}; + bool m_dim_has0{false}; // a first point is pending + int m_pending_dim{-1}; // dim awaiting a value-card entry + Mode m_mode{Mode::Polyline}; + int m_sel_a{-1}; // picked segment endpoints (legacy Constrain mode) + int m_sel_b{-1}; + bool m_constrain_entities{false}; // Constrain mode acts on entities + int m_pick0{-1}; // picked line-entity indices (entity Constrain) + int m_pick1{-1}; + int m_pick2{-1}; // third slot (Symmetric axis) + Vec2d m_pick0_pt{0,0}; // plane-coords of the slot-0 pick (trim/extend) + std::vector m_constraint_hl; // entities highlighted by the constraint manager + std::vector m_constrain_cons; // for glyph badges (C3.4b) + // Where each badge landed, so a click can find the constraint it stands for. Rebuilt by + // build_constraint_glyphs on every render, which is always the frame the user clicked on. + struct GlyphHit { Vec2d c{0,0}; int con{-1}; }; + std::vector m_glyph_hits; + double m_glyph_r{0.0}; // badge half-size in plane units (hit radius) + GLModel m_line_model; + GLModel m_vertex_model; + GLModel m_highlight_model; + int m_dim_label_seq{0}; + float m_render_scale{1.0f}; // canvas scale for Measure-style dim labels + GLModel m_fill_model; // translucent face fill for closed regions + std::vector m_display_sketches; // committed sketches drawn persistently + std::vector> m_hl_sketches; // feature index -> outline colour (Sweep/Loft operands) + int m_display_pick{-1}; // FEATURE index of the click-selected display sketch (-1 none) + + // Solid (whole/face/edge) selection on the committed bodies. Pointers are non-owning, + // into CadDocument (bodies + display_mesh + per-triangle face/body ids), refreshed each + // recompute via set_solid_pick. m_sel_edge_pts caches the picked edge's world polyline. + const std::vector* m_solid_bodies{nullptr}; + const TriangleMesh* m_solid_mesh{nullptr}; + const std::vector* m_solid_tri_face{nullptr}; + const std::vector* m_solid_tri_body{nullptr}; + const std::vector* m_solid_visible{nullptr}; // per-body visibility; hidden bodies aren't pickable + int m_pick_only_body{-1}; // >=0: only this body catches clicks (body-focus x-ray for CoordSys picking) + const std::vector* m_solid_xform{nullptr}; // per-body display transform (for edge sampling) + Vec3d body_xform_pt(int body, const Vec3d& p) const; // map an OCCT-shape point through the body xform + bool body_pickable(int b) const; // false when the body is explicitly hidden + SolidSel m_solid_sel{SolidSel::None}; + int m_sel_body{-1}; // which body the face/edge selection is on + int m_sel_face{-1}; + int m_sel_edge{-1}; + std::vector m_sel_edge_pts; + Vec3d m_sel_vertex_pt{Vec3d::Zero()}; // world point of a picked vertex + bool handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt); // pick + notify + // What a click at (mx,my) WOULD take, resolved without touching the selection. One + // implementation, two callers: the click, and the hover pre-highlight that promises what the + // click is about to do. Split so the promise cannot drift from the act. + struct SolidPick { + SolidSel kind{SolidSel::None}; + int body{-1}, face{-1}, edge{-1}; + std::vector edge_pts; + Vec3d vertex_pt{Vec3d::Zero()}; + }; + bool resolve_solid_pick(GLCanvas3D& canvas, int mx, int my, SolidPick& out) const; + // HOVER PRE-HIGHLIGHT (9xw part 3). Vertex-beats-edge-beats-face is a rule the user + // cannot see until after they commit to a click; showing the outcome under the pointer is + // what makes the precedence learnable at all, and is the charter's L5 (one click, one visible + // change) read honestly — the change has to be predictable before the click, not only after. + SolidPick m_pre; // what the pointer is currently over (kind None = nothing) + bool update_solid_hover(GLCanvas3D& canvas, const wxMouseEvent& evt); // true when it changed + // Left-drag rubber band: sweep a rectangle over the plate to take a whole body. Orbit + // moves to middle-drag in this canvas (DesignCanvas::set_cad_navigation) so the left + // button is free for it, which is the CAD convention (Onshape/SolidWorks). + GLSelectionRectangle m_rubber; + void pick_bodies_in_rectangle(); // resolve the swept rectangle -> whole-body selection + bool on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas); // the body; on_mouse wraps it + // Nearest stroke + enclosing region of ONE committed sketch. Shared by the click and + // double-click paths so they cannot disagree about what is under the pointer. + void hit_display_sketch(const DisplaySketch& d, const Vec2d& p, double tol, + int& edge_feat, int& edge_reg, int& edge_ent, + double& edge_d, int& face_feat, int& face_reg) const; + bool m_right_consumed{false}; // last RightDown was a gesture terminator, not a menu + bool m_escalate_repick{true}; // re-picking the same sub-element takes the whole body + void render_solid_highlight(); + // The shared body of the above: one highlight from explicit arguments, so the committed + // selection and the hover pre-highlight cannot drift apart in how they look. + void render_solid_sel(SolidSel kind, int body, int face, const std::vector& edge_pts, + const Vec3d& vertex_pt, const ColorRGBA& rgb, float alpha_mul); + void render_datum_planes(); // translucent rectangles for datum/reference planes + void render_view_helpers(); // world origin planes + axis triad (P / A toggles) + bool m_show_planes{false}; + bool m_show_axes{false}; + std::vector m_datum_planes; + std::vector m_datum_sizes; // per-plane (u,v) full extent; empty -> default + void render_mate_connectors(); // disc + roll quadrant + one-sided Z arrow + // The face treatment of the same connector: a shaded low-poly relief of a bear's head in the + // connector's own frame. Draws the plate, the snout tent and the marks; the caller still draws + // the Z arrow, which is shared with the disc treatment. + void render_mate_face(const Vec3d& origin, const Vec3d& X, const Vec3d& Y, const Vec3d& Z, + double R, const ColorRGBA& body); + std::vector m_mate_connectors; + std::vector> m_mate_links; + GLModel m_mc_stroke_model; + GLModel m_mc_fill_model; // the face treatment's shaded facets + GLModel m_solid_face_model; + GLModel m_solid_edge_model; + GLModel m_solid_vertex_model; + int m_display_pick_region{-1}; // selected closed-region index within that feature (-1 none) + + // Visual Extrude gizmo state (C5b). GUI-only; fed by the panel each refresh_preview. + bool m_ex_active{false}; + SketchPlane m_ex_plane; // profile plane (gives normal + to_world anchor) + Vec2d m_ex_centroid{0,0}; // arrow base in plane coords (profile centroid) + double m_ex_depth{0.0}; // primary depth (= m_distance) + double m_ex_depth2{0.0}; // second-side depth (TwoSided, = m_distance2) + bool m_ex_two_sided{false}; + bool m_ex_flip{false}; + int m_ex_drag{-1}; // 0 = primary arrow, 1 = second arrow, -1 = none + int m_ex_press_x{0}, m_ex_press_y{0}; // press px to tell click-to-edit from drag + void render_extrude_gizmo(); + bool hit_test_extrude_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const; + void drag_extrude_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + void open_extrude_editor(int which); + GLModel m_ex_arrow_model; + + // Datum-plane resize gizmo state (C3). GUI-only; fed by the panel while the Plane card is open. + bool m_dz_active{false}; + SketchPlane m_dz_plane; // resolved datum frame (origin + axes) + double m_dz_usize{60.0}; // current u extent (full width) + double m_dz_vsize{60.0}; // current v extent (full height) + int m_dz_drag{-1}; // 0=+u,1=-u,2=+v,3=-v handle, 4=offset tip, -1 none + int m_dz_press_x{0}, m_dz_press_y{0}; + Vec3d m_dz_anchor{Vec3d::Zero()}; // base-plane origin (offset arrow tail) + Vec3d m_dz_normal{0.0, 0.0, 1.0}; // base normal (offset arrow direction) + double m_dz_offset{0.0}; // current signed offset along the base normal + bool m_dz_offset_on{false}; // draw/allow the offset arrow (Offset-from-base only) + void render_datum_gizmo(); + bool hit_test_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const; + void drag_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + // Helix gizmo state (plane-anchored curve + 3 drag handles). Fed by the panel while the + // Helix card is open (sketch tool NOT active); the tool draws the live helix plus a handle + // on each length parameter (radius/height/pitch). Taper and handedness stay on the card. + bool m_hx_active{false}; + SketchPlane m_hx_plane; // axis = plane normal, base circle in the plane + double m_hx_radius{10.0}; + double m_hx_pitch{2.0}; + double m_hx_height{20.0}; + double m_hx_taper{0.0}; // DEGREES (cone half-angle), as the kernel reads it + bool m_hx_left{false}; + int m_hx_drag{-1}; // 0=radius, 1=height, 2=pitch, -1 none + int m_hx_press_x{0}, m_hx_press_y{0}; + Vec3d helix_point(double t) const; // curve point at parameter t (shared render/hit/drag) + void render_helix_gizmo(); + bool hit_test_helix_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const; + void drag_helix_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + // Rib thickness gizmo state (plane-anchored slab footprint + 2 drag handles). Fed by the + // panel while the Rib card is open (sketch tool NOT active); the tool draws the rib's + // footprint outline and a handle on each side of the line at half-thickness. Dragging either + // handle sets the full thickness (the slab is centred on the line). + bool m_rb_active{false}; + SketchPlane m_rb_plane; + Vec2d m_rb_p0{Vec2d::Zero()}; // rib line endpoints, in plane coords + Vec2d m_rb_p1{Vec2d::Zero()}; + double m_rb_thickness{2.0}; + int m_rb_drag{-1}; // 0 = +perp handle, 1 = -perp handle, -1 none + void render_rib_gizmo(); + bool hit_test_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const; + void drag_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + // Datum base picker (translucent clickable origin/datum planes) + bool m_dbp_active{false}; + std::vector m_dbp_planes; + std::vector m_dbp_base; + std::vector m_dbp_labels; + int m_dbp_hover{-1}; + double dbp_half_extent() const; // bed-derived: reference planes are larger than the bed + void render_base_pick(); + int hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + + // Move-body gizmo state: 3 world-axis translate arrows + 3 world-axis rotate rings. + // Delta model: offset/rot are deltas about a fixed pivot, composed onto m_mv_base_xform + // (the body's pose when Move opened) so rotation works even on an already-placed body. + bool m_mv_active{false}; + int m_mv_body{-1}; + Vec3d m_mv_base{Vec3d::Zero()}; // pivot = body's world centroid at Move-open + Vec3d m_mv_offset{Vec3d::Zero()}; // delta translation along world X/Y/Z + Transform3d m_mv_base_xform{Transform3d::Identity()}; // pose when Move opened + Eigen::Matrix3d m_mv_rot{Eigen::Matrix3d::Identity()}; // accumulated delta rotation (world, about pivot) + Eigen::Matrix3d m_mv_rot_start{Eigen::Matrix3d::Identity()}; // rot snapshot at arc-drag start + double m_mv_arc_a0{0.0}; // mouse angle on the ring at drag start + int m_mv_drag{-1}; // 0..2 = X/Y/Z arrow, 3..5 = X/Y/Z ring, -1 none + double m_mv_radius{0.0}; // body bounding-sphere radius (mm); 0 = unknown + int m_mv_press_x{0}, m_mv_press_y{0}; + Transform3d compose_move_xform() const; // T(offset)*T(pivot)*rot*T(-pivot)*base_xform + void ring_basis(int axis, Vec3d& e, Vec3d& u, Vec3d& v) const; // world axis + in-plane basis + void render_move_gizmo(); + // Gizmo arm length (world mm): scales with the body so the rings clear its surface. + double move_gizmo_arm(const Camera& cam) const; + bool hit_test_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const; + bool hit_test_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const; + void drag_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis); + void drag_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis); + bool arc_mouse_angle(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis, double& ang) const; + void open_move_editor(int axis); + GLModel m_mv_arrow_model; + + // Fillet/Chamfer radius gizmo state (single world-space arrow at the picked edge midpoint). + bool m_fl_active{false}; + Vec3d m_fl_anchor{Vec3d::Zero()}; // edge midpoint (world, already body-transformed) + Vec3d m_fl_dir{Vec3d::UnitZ()}; // unit radius direction (perp to edge, outward) + double m_fl_radius{1.0}; // current radius (= dressup size) + bool m_fl_drag{false}; + int m_fl_press_x{0}, m_fl_press_y{0}; + double m_fl_grab_proj{0.0}; // axis projection at grab (relative drag reference) + double m_fl_grab_radius{1.0}; // radius at grab (relative drag reference) + void render_fillet_gizmo(); + bool hit_test_fillet_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + double fillet_axis_proj(GLCanvas3D& canvas, const wxMouseEvent& evt) const; // NaN if camera∥axis + void start_fillet_drag(GLCanvas3D& canvas, const wxMouseEvent& evt); + void drag_fillet_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt); + void open_fillet_editor(); + GLModel m_fl_arrow_model; + + // Hole gizmo state. The hole is a positioned circular cut on m_hl_plane at (m_hl_x, m_hl_y); + // the footprint circle is drawn on the plane, the diameter arrow runs along the plane u-axis, + // the depth arrow along +normal (matching the kernel's make_extrude). Three draggable handles: + // 0 = centre (reposition in plane u/v), 1 = diameter, 2 = depth (only shown when !through). + bool m_hl_active{false}; + SketchPlane m_hl_plane; + double m_hl_x{0.0}, m_hl_y{0.0}; // centre on the plane (u/v mm) + double m_hl_diameter{6.0}; + double m_hl_depth{10.0}; + bool m_hl_through{true}; + // #2 Part B: face (u,v) bounds, so the construction dims read as distance from the face SIDES + // (umin/vmin = two adjacent edges) rather than from the centre. Off for a dropdown-plane hole. + bool m_hl_has_bounds{false}; + double m_hl_umin{0}, m_hl_umax{0}, m_hl_vmin{0}, m_hl_vmax{0}; + int m_hl_drag{-1}; // 0=centre, 1=diameter, 2=depth, 3=X-dim, 4=Y-dim, -1=none + int m_hl_press_x{0}, m_hl_press_y{0}; + double m_hl_grab_proj{0.0}; // diameter/depth axis projection at grab (relative) + double m_hl_grab_val{0.0}; // radius (diameter drag) or depth at grab + Vec2d m_hl_grab_uv{0.0, 0.0}; // centre drag: plane-projected grab point + double m_hl_grab_x{0.0}, m_hl_grab_y{0.0}; // centre drag: x/y at grab + void render_hole_gizmo(); + int hit_test_hole_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const; // 0/1/2/-1 + double hole_axis_proj(GLCanvas3D& canvas, const wxMouseEvent& evt, + const Vec3d& anchor, const Vec3d& dir) const; // NaN if camera∥axis + void start_hole_drag(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + void drag_hole_handle(GLCanvas3D& canvas, const wxMouseEvent& evt); + void open_hole_editor(int which); + GLModel m_hl_stroke_model; + + // Thread gizmo state (mirrors the hole gizmo; radius arrow uses an R label, length arrow is + // always shown). Handles: 0 = centre (thread_x/y), 1 = radius, 2 = length. + bool m_th_active{false}; + SketchPlane m_th_plane; + double m_th_x{0.0}, m_th_y{0.0}; + double m_th_radius{5.0}; + double m_th_height{10.0}; + int m_th_drag{-1}; // 0=centre, 1=radius, 2=length, -1=none + int m_th_press_x{0}, m_th_press_y{0}; + double m_th_grab_proj{0.0}; + double m_th_grab_val{0.0}; + Vec2d m_th_grab_uv{0.0, 0.0}; + double m_th_grab_x{0.0}, m_th_grab_y{0.0}; + void render_thread_gizmo(); + int hit_test_thread_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const; // 0/1/2/-1 + void start_thread_drag(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + void drag_thread_handle(GLCanvas3D& canvas, const wxMouseEvent& evt); + void open_thread_editor(int which); + GLModel m_th_stroke_model; + + // Shell gizmo state (single inward thickness arrow at the picked face centroid). + bool m_sh_active{false}; + Vec3d m_sh_anchor{Vec3d::Zero()}; // picked face centroid (world) + Vec3d m_sh_dir{Vec3d::UnitZ()}; // inward unit direction (-outward normal) + double m_sh_thickness{2.0}; + bool m_sh_drag{false}; + int m_sh_press_x{0}, m_sh_press_y{0}; + double m_sh_grab_proj{0.0}; + double m_sh_grab_val{2.0}; + void render_shell_gizmo(); + bool hit_test_shell_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + void start_shell_drag(GLCanvas3D& canvas, const wxMouseEvent& evt); + void drag_shell_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt); + void open_shell_editor(); + GLModel m_sh_stroke_model; + + // Revolve gizmo state (arc center = projection of the profile centroid onto the axis). + bool m_rv_active{false}; + Vec3d m_rv_center{Vec3d::Zero()}; // arc center on the axis (world) + Vec3d m_rv_axis{Vec3d::UnitX()}; // revolve axis unit dir (world) + Vec3d m_rv_ref{Vec3d::UnitY()}; // angle-0 reference dir (perp to axis, toward profile) + double m_rv_radius{10.0}; // arc radius = profile perpendicular distance (world) + double m_rv_angle{360.0}; // current sweep magnitude (deg, 1..360) + bool m_rv_flip{false}; // sweep sense (matches the kernel's negative-angle flip) + bool m_rv_drag{false}; + int m_rv_press_x{0}, m_rv_press_y{0}; + void render_revolve_gizmo(); + bool hit_test_revolve_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + void drag_revolve_arc(GLCanvas3D& canvas, const wxMouseEvent& evt); + void open_revolve_editor(); + GLModel m_rv_stroke_model; + + // Draft gizmo state (arc center = picked face centroid; axis = world +Z, taper pull direction). + bool m_dr_active{false}; + Vec3d m_dr_center{Vec3d::Zero()}; // arc center = face centroid (world) + Vec3d m_dr_axis{Vec3d::UnitZ()}; // draft axis = world +Z (pull direction) + Vec3d m_dr_ref{Vec3d::UnitX()}; // angle-0 reference dir (perp to axis) + double m_dr_radius{10.0}; // arc radius (world) + double m_dr_angle{5.0}; // current sweep magnitude (deg, [-89, 89]) + bool m_dr_drag{false}; + int m_dr_press_x{0}, m_dr_press_y{0}; + void render_draft_gizmo(); + bool hit_test_draft_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + void drag_draft_arc(GLCanvas3D& canvas, const wxMouseEvent& evt); + GLModel m_dr_stroke_model; + std::function m_on_draft_angle_changed; + + // Cut gizmo state (plane normal arrow + wire rectangle at the current offset). + bool m_ct_active{false}; + Vec3d m_ct_base{Vec3d::Zero()}; // body centre projected into the cut plane + Vec3d m_ct_n{Vec3d::UnitZ()}; // cut plane normal (unit) + Vec3d m_ct_u{Vec3d::UnitX()}; // cut plane U axis (unit) + Vec3d m_ct_v{Vec3d::UnitY()}; // cut plane V axis (unit) + double m_ct_offset{0.0}; + double m_ct_half{10.0}; + bool m_ct_drag{false}; + double m_ct_grab_val{0.0}; + double m_ct_grab_proj{0.0}; + void render_cut_gizmo(); + bool hit_test_cut_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + void start_cut_drag(GLCanvas3D& canvas, const wxMouseEvent& evt); + void drag_cut_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt); + GLModel m_ct_stroke_model; + GLModel m_ct_rect_model; + std::function m_on_cut_offset_changed; + + // Pattern gizmo state. Linear arrow along m_pt_dirw from m_pt_base; circular arc like Revolve + // but axis = m_pt_normal through m_pt_origin (the world XY plane by default). + bool m_pt_active{false}; + bool m_pt_circular{false}; + Vec3d m_pt_base{Vec3d::Zero()}; // target body centroid (world): linear anchor / radius ref + Vec3d m_pt_dirw{Vec3d::UnitX()}; // linear march direction (world) + Vec3d m_pt_origin{Vec3d::Zero()}; // circular rotation axis origin (world) + Vec3d m_pt_normal{Vec3d::UnitZ()}; // circular rotation axis (world) + Vec3d m_pt_cref{Vec3d::UnitX()}; // circular angle-0 reference dir (perp to normal, toward body) + Vec3d m_pt_ccenter{Vec3d::Zero()}; // circular arc center (foot of body centroid on the axis) + double m_pt_radius{10.0}; // circular arc radius (world) + int m_pt_count{3}; + double m_pt_spacing{20.0}; + double m_pt_angle{360.0}; + bool m_pt_drag{false}; + int m_pt_press_x{0}, m_pt_press_y{0}; + void render_pattern_gizmo(); + bool hit_test_pattern_handle(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + void drag_pattern_handle(GLCanvas3D& canvas, const wxMouseEvent& evt); + void open_pattern_editor(); + GLModel m_pt_stroke_model; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_DesignSketchTool_hpp_ diff --git a/src/slic3r/GUI/CAD/McpControl.cpp b/src/slic3r/GUI/CAD/McpControl.cpp new file mode 100644 index 0000000000..8e34e2aac8 --- /dev/null +++ b/src/slic3r/GUI/CAD/McpControl.cpp @@ -0,0 +1,2221 @@ +#include "slic3r/GUI/CAD/McpControl.hpp" + +#ifndef _WIN32 // POSIX Unix-domain-socket transport only (slice 1) + +#include +#include +#include // umask/chmod: the socket's file mode IS its access control +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include // OCCT base error (not a std::exception) + +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/MainFrame.hpp" +#include "slic3r/GUI/CAD/DesignPanel.hpp" +#include "slic3r/GUI/CAD/DesignCanvas.hpp" +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" +#include "slic3r/GUI/CAD/DesignOffer.hpp" // offer table — served whole by list_verbs, fired by run_verb + +#include "libslic3r/CAD/CadDocument.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/CAD/GeometryEngine.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Format/OBJ.hpp" +#include +#include +#include "libslic3r/BoundingBox.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace Slic3r { namespace GUI { + +namespace { + +const char* feature_type_name(CadFeatureType t) +{ + switch (t) { + case CadFeatureType::Sketch: return "Sketch"; + case CadFeatureType::Extrude: return "Extrude"; + case CadFeatureType::Fillet: return "Fillet"; + case CadFeatureType::Chamfer: return "Chamfer"; + case CadFeatureType::Hole: return "Hole"; + case CadFeatureType::Thread: return "Thread"; + case CadFeatureType::Shell: return "Shell"; + case CadFeatureType::Revolve: return "Revolve"; + case CadFeatureType::Sweep: return "Sweep"; + case CadFeatureType::Pattern: return "Pattern"; + case CadFeatureType::Plane: return "Plane"; + case CadFeatureType::Loft: return "Loft"; + case CadFeatureType::Draft: return "Draft"; + case CadFeatureType::Import: return "Import"; + case CadFeatureType::Boolean: return "Boolean"; + case CadFeatureType::Cut: return "Cut"; + case CadFeatureType::Mirror: return "Mirror"; + case CadFeatureType::Axis: return "Axis"; + case CadFeatureType::CoordSys: return "CoordSys"; + case CadFeatureType::Helix: return "Helix"; + case CadFeatureType::Transform: return "Transform"; + case CadFeatureType::Thicken: return "Thicken"; + case CadFeatureType::Project: return "Project"; + case CadFeatureType::DeleteFace: return "DeleteFace"; + case CadFeatureType::Rib: return "Rib"; + case CadFeatureType::SurfaceExtrude: return "SurfaceExtrude"; + case CadFeatureType::SurfaceRevolve: return "SurfaceRevolve"; + case CadFeatureType::ThickenSurface: return "ThickenSurface"; + case CadFeatureType::SurfaceOffset: return "SurfaceOffset"; + case CadFeatureType::SurfaceLoft: return "SurfaceLoft"; + case CadFeatureType::SurfaceFill: return "SurfaceFill"; + case CadFeatureType::Mate: return "Mate"; + } + return "Unknown"; +} + +// --- JSON-RPC envelope helpers ------------------------------------------------- +std::string rpc_result(const json& id, const json& result) +{ + return json{{"jsonrpc", "2.0"}, {"id", id}, {"result", result}}.dump(); +} +std::string rpc_error(const json& id, int code, const std::string& msg) +{ + return json{{"jsonrpc", "2.0"}, {"id", id}, + {"error", {{"code", code}, {"message", msg}}}}.dump(); +} + +// --- the three slice-1 methods (run on the wx MAIN thread) --------------------- + +json describe_tools() +{ + // Hand-written descriptor. The bridge turns this into MCP tool schemas; later + // slices grow this list (ideally from the kernel directly). + return json{ + {"app", "Orca CAD"}, + {"protocol", "jsonrpc-2.0"}, + {"slice", 5}, + // Read this before using any face or edge id. + {"id_lifetime", + "Global face and edge ids are indices into the CURRENT topology and expire the moment " + "a feature rebuilds the model. Reading the scene once and then issuing several " + "operations addresses the wrong edge on every call after the first, and does NOT " + "error, because a stale id still names a real edge. Either re-read query_topology " + "before each id-taking call, or pass the 'generation' you were given back with the " + "call and have it refused if the model has moved on."}, + {"tools", json::array({ + json{{"name", "describe_tools"}, {"summary", "List callable tools and their parameters."}, + {"params", json::array()}}, + json{{"name", "describe_scene"}, {"summary", "Feature tree + per-body bounding boxes of the Design document."}, + {"params", json::array()}}, + json{{"name", "extrude"}, {"summary", "Extrude a profile to a depth. Give an explicit closed `profile` (list of [x,y]) or default to a centred width x height rectangle. End conditions match Onshape."}, + {"params", json::array({ + json{{"name", "width"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "distance"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + json{{"name", "profile"}, {"type", "array"}, {"default", json::array()}, {"description", "optional closed contour [[x,y],...] in plane mm; overrides width/height"}}, + json{{"name", "boolean"}, {"type", "string"}, {"enum", json::array({"new", "union", "subtract", "intersect"})}, {"default", "new"}}, + json{{"name", "end"}, {"type", "string"}, {"enum", json::array({"blind", "symmetric", "two_sided", "through_all", "up_to_face"})}, {"default", "blind"}}, + json{{"name", "distance2"},{"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "second-side depth when end=two_sided (else falls back to distance)"}}, + json{{"name", "up_to_face"},{"type", "integer"}, {"default", -1}, {"description", "target face id (query_topology on the last body) when end=up_to_face"}}, + json{{"name", "taper"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}, {"description", "draft/taper of the side wall"}}, + json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, + })}}, + json{{"name", "revolve"}, {"summary", "Revolve a profile about a plane axis. Give an explicit `profile` offset from the axis (or a rectangle) — angle degrees about axis 0=plane X / 1=plane Y."}, + {"params", json::array({ + json{{"name", "width"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}}, + json{{"name", "axis"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}}, + json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + json{{"name", "profile"}, {"type", "array"}, {"default", json::array()}, {"description", "optional closed contour [[x,y],...] in plane mm; overrides width/height"}}, + json{{"name", "boolean"}, {"type", "string"}, {"enum", json::array({"new", "union", "subtract", "intersect"})}, {"default", "new"}}, + })}}, + json{{"name", "fillet"}, {"summary", "Round a measured edge of a body (edge id from query_topology on that body)."}, + {"params", json::array({ + json{{"name", "edge"}, {"type", "integer"}}, + json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}}, + })}}, + json{{"name", "chamfer"}, {"summary", "Chamfer a measured edge of a body (edge id from query_topology on that body)."}, + {"params", json::array({ + json{{"name", "edge"}, {"type", "integer"}}, + json{{"name", "distance"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}}, + })}}, + json{{"name", "hole"}, {"summary", "Drill a circular hole into the current body at (x,y) on a plane."}, + {"params", json::array({ + json{{"name", "diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 5}, {"min", 0.01}}, + json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "through"}, {"type", "boolean"}, {"default", false}}, + json{{"name", "x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}}, + json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + })}}, + json{{"name", "hole_styled"}, {"summary", "Drill a hole with optional counterbore (style=1) or countersink (style=2) at (x,y) on a plane."}, + {"params", json::array({ + json{{"name", "diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 5}, {"min", 0.01}}, + json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "through"}, {"type", "boolean"}, {"default", true}}, + json{{"name", "x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}}, + json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + json{{"name", "style"}, {"type", "integer"}, {"default", 0}, {"description", "0=simple, 1=counterbore, 2=countersink"}}, + json{{"name", "cbore_diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "cbore_depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "csink_diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "csink_angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 90}}, + json{{"name", "standard"}, {"type", "string"}, {"default", ""}, {"description", "provenance designation, e.g. M6"}}, + })}}, + json{{"name", "hole_standard"}, {"summary", "Drill a standard clearance hole (ISO 273 / ANSI) at (x,y) on a plane. style: 0=simple, 1=counterbore, 2=countersink."}, + {"params", json::array({ + json{{"name", "designation"}, {"type", "string"}, {"description", "e.g. M6, 1/4-20"}}, + json{{"name", "style"}, {"type", "integer"}, {"default", 0}}, + json{{"name", "through"}, {"type", "boolean"}, {"default", true}}, + json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}}, + json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + })}}, + json{{"name", "boolean"}, {"summary", "Combine two bodies: union | subtract (tool from target) | intersect."}, + {"params", json::array({ + json{{"name", "op"}, {"type", "string"}, {"enum", json::array({"union", "subtract", "intersect"})}, {"default", "subtract"}}, + json{{"name", "target"}, {"type", "integer"}, {"default", 0}}, + json{{"name", "tool"}, {"type", "integer"}, {"default", 1}}, + json{{"name", "keep_tool"}, {"type", "boolean"}, {"default", false}}, + json{{"name", "tolerance"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + })}}, + json{{"name", "pattern"}, {"summary", "Replicate a body: linear (count along a plane axis at spacing) or circular (count over an angle about the plane normal)."}, + {"params", json::array({ + json{{"name", "circular"}, {"type", "boolean"}, {"default", false}}, + json{{"name", "count"}, {"type", "integer"}, {"default", 3}, {"min", 1}}, + json{{"name", "spacing"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"description", "linear step"}}, + json{{"name", "dir"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}, {"description", "linear axis: 0=plane X, 1=plane Y"}}, + json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}, {"description", "circular total sweep"}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, + json{{"name", "pattern_on_curve"}, {"summary", "Replicate a body along a sketch curve: `count` copies placed at equal-parameter points on the entity, each translated by (P_i - P_0)."}, + {"params", json::array({ + json{{"name", "count"}, {"type", "integer"}, {"default", 3}, {"min", 1}}, + json{{"name", "sketch"}, {"type", "integer"}, {"description", "feature index of the sketch holding the guide curve"}}, + json{{"name", "entity"}, {"type", "integer"}, {"description", "entity index of the guide curve within that sketch"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, + json{{"name", "shell"}, {"summary", "Hollow a body to a wall thickness (inward); optionally leave one face open."}, + {"params", json::array({ + json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id to leave open (query_topology); omit for a closed hollow"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, + json{{"name", "draft"}, {"summary", "Taper a body face by an angle about its base (pull direction +Z)."}, + {"params", json::array({ + json{{"name", "face"}, {"type", "integer"}, {"description", "face id to draft (query_topology)"}}, + json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 5}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, + json{{"name", "mirror"}, {"summary", "Mirror a body about a base plane. mode=new creates a mirrored copy; mode=add fuses the mirror back into the source."}, + {"params", json::array({ + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XZ"}}, + json{{"name", "mode"}, {"type", "string"}, {"enum", json::array({"new", "add"})}, {"default", "new"}}, + json{{"name", "keep_original"},{"type", "boolean"}, {"default", true}, {"description", "when mode=new, keep the source body"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, + json{{"name", "transform"}, {"summary", "Move and/or rotate a body (B-rep transform). copy=true keeps the source and appends the transformed copy as a new body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + json{{"name", "dx"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "dy"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "dz"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "axis_x"}, {"type", "number"}, {"default", 0}}, + json{{"name", "axis_y"}, {"type", "number"}, {"default", 0}}, + json{{"name", "axis_z"}, {"type", "number"}, {"default", 1}}, + json{{"name", "pivot_x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "pivot_y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "pivot_z"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}}, + json{{"name", "copy"}, {"type", "boolean"}, {"default", false}}, + })}}, + json{{"name", "axis"}, {"summary", "Create a datum axis (reference line): two points, face normal, cylinder centreline, plane intersection, or along edge."}, + {"params", json::array({ + json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"two_points", "face_normal", "cylinder", "plane_intersection", "along_edge"})}, {"default", "two_points"}}, + json{{"name", "p1"}, {"type", "array"}, {"default", json::array({0,0,0})}, {"description", "first point [x,y,z] for two_points"}}, + json{{"name", "p2"}, {"type", "array"}, {"default", json::array({0,0,10})}, {"description", "second point [x,y,z] for two_points"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "body for face/edge refs"}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id (query_topology) for face_normal/cylinder"}}, + json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for along_edge"}}, + json{{"name", "plane_a"}, {"type", "integer"}, {"default", -1}, {"description", "first datum plane feature index for plane_intersection"}}, + json{{"name", "plane_b"}, {"type", "integer"}, {"default", -1}, {"description", "second datum plane feature index for plane_intersection"}}, + })}}, + json{{"name", "coordsys"}, {"summary", "Create a datum coordinate system (origin + orthonormal axes). PointWorld aligns to world; FaceAndDirection uses a face for Z and an edge/hint for X."}, + {"params", json::array({ + json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"point_world", "face_and_direction"})}, {"default", "point_world"}}, + json{{"name", "point"}, {"type", "array"}, {"default", json::array({0,0,0})}, {"description", "origin [x,y,z] for point_world"}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "body for face/edge refs"}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id (query_topology) for Z axis"}}, + json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for X axis hint"}}, + json{{"name", "x_hint"}, {"type", "array"}, {"default", json::array({1,0,0})}, {"description", "fallback X direction hint if no edge given"}}, + })}}, + json{{"name", "helix"}, {"summary", "Create a helical curve (consumed by sweep as a path to build springs/coils/augers). pitch = axial rise per turn. left_handed flips the winding. taper_deg != 0 gives a conical helix."}, + {"params", json::array({ + json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "pitch"}, {"type", "number"}, {"unit", "mm"}, {"default", 5}, {"min", 0.01}}, + json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "left_handed"}, {"type", "boolean"}, {"default", false}}, + json{{"name", "taper_deg"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + })}}, + json{{"name", "thicken"}, {"summary", "Offset a face of a body by a wall thickness, producing a new thin solid body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + json{{"name", "face"}, {"type", "integer"}, {"description", "face id to thicken (query_topology)"}}, + json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}}, + json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, + })}}, + json{{"name", "split"}, {"summary", "Split a body along the plane of a picked face."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + json{{"name", "face_body"}, {"type", "integer"}, {"default", -1}, {"description", "body that owns the face; -1 = target"}}, + json{{"name", "face"}, {"type", "integer"}, {"description", "face id to split along (query_topology)"}}, + json{{"name", "keep_upper"},{"type", "boolean"}, {"default", true}}, + json{{"name", "keep_lower"},{"type", "boolean"}, {"default", true}}, + })}}, + json{{"name", "project"}, {"summary", "Project edges of a solid onto a sketch plane, producing a new sketch feature."}, + {"params", json::array({ + json{{"name", "source_body"}, {"type", "integer"}, {"default", -1}, {"description", "body owning the edges; -1 = last body"}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "global face id on the source body; when set, all its edges are projected"}}, + json{{"name", "edges"}, {"type", "array"}, {"default", json::array()}, {"description", "global edge ids to project; empty => project the face"}}, + json{{"name", "plane"}, {"type", "string"}, {"default", "XY"}, {"description", "target sketch plane (XY/XZ/YZ)"}}, + })}}, + json{{"name", "delete_face"}, {"summary", "Remove faces from a solid, healing the gap via OCCT defeaturing."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + json{{"name", "faces"}, {"type", "array"}, {"description", "global face ids to delete"}}, + })}}, + json{{"name", "bridge"}, {"summary", "Build a cubic-Bezier G1 bridge (BSpline) between two sketch-entity endpoints within a sketch feature."}, + {"params", json::array({ + json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index"}}, + json{{"name", "ent_a"}, {"type", "integer"}, {"description", "first entity index within the sketch"}}, + json{{"name", "end_a"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 1}, {"description", "0 = start/p0 side, 1 = end/p1 side"}}, + json{{"name", "ent_b"}, {"type", "integer"}, {"description", "second entity index within the sketch"}}, + json{{"name", "end_b"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}, {"description", "0 = start/p0 side, 1 = end/p1 side"}}, + })}}, + json{{"name", "rib"}, {"summary", "Grow a thin rib wall (stiffener) from an open Line sketch entity, fused to a body."}, + {"params", json::array({ + json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index holding the open line"}}, + json{{"name", "entity"}, {"type", "integer"}, {"description", "entity index of the open Line within the sketch"}}, + json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}}, + json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + })}}, + json{{"name", "surface_extrude"}, {"summary", "Extrude a sketch wire with no end caps -> an open sheet body."}, + {"params", json::array({ + json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index"}}, + json{{"name", "distance"},{"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + })}}, + json{{"name", "surface_revolve"}, {"summary", "Revolve a sketch wire with no caps -> an open sheet body."}, + {"params", json::array({ + json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index"}}, + json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}}, + json{{"name", "axis"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}}, + })}}, + json{{"name", "mate"}, {"summary", "Mate two bodies: transform the moving body (cs_b) so its connector lands on the fixed one (cs_a). kind: 0=Fastened, 1=Planar, 2=Revolute, 3=Slider, 4=Cylindrical."}, + {"params", json::array({ + json{{"name", "kind"}, {"type", "integer"}, {"default", 0}, {"description", "0=Fastened (rigid), 1=Planar (normal only), 2=Revolute (free rotation about axis), 3=Slider (free translation along axis), 4=Cylindrical (free rotation+translation)"}}, + json{{"name", "cs_a"}, {"type", "integer"}, {"description", "feature index of the fixed CoordSys (mate connector A)"}}, + json{{"name", "cs_b"}, {"type", "integer"}, {"description", "feature index of the CoordSys on the body that moves"}}, + json{{"name", "offset"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}}, + json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, + })}}, + json{{"name", "check_interference"}, {"summary", "Solid bodies that overlap, as {body_a, body_b, volume}. Read-only; bodies that merely touch enclose no volume and are not reported."}, + {"params", json::array({ + json{{"name", "min_volume"}, {"type", "number"}, {"unit", "mm^3"}, {"default", 1e-6}, {"description", "overlap volume above which a pair counts as interfering"}}, + })}}, + json{{"name", "query_topology"}, {"summary", "Measured faces (centroid/normal/cylinder) and edges (length/circle) of a body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, + })}}, + json{{"name", "measure"}, {"summary", "Distance (and angle, when both have direction) between two refs {face|edge|point} on a body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, + json{{"name", "a"}, {"type", "object"}}, + json{{"name", "b"}, {"type", "object"}}, + })}}, + json{{"name", "mass_properties"}, {"summary", "Volume / surface area / centre of mass / inertia tensor of a body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, + })}}, + json{{"name", "slice_body"}, {"summary", "Cross-section of a body by a base plane at an offset (sections-as-evidence); returns ordered world contours, each flagged closed/open."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + json{{"name", "offset"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, + })}}, + json{{"name", "import_step"}, {"summary", "Import a STEP file as native B-rep bodies (the reference part to measure)."}, + {"params", json::array({ + json{{"name", "path"}, {"type", "string"}}, + })}}, + json{{"name", "import_mesh"}, {"summary", "Convert a triangle mesh (STL/OBJ) into an editable B-rep body. Reports whether the result is a real solid or an open shell, and why."}, + {"params", json::array({ + json{{"name", "path"}, {"type", "string"}}, + json{{"name", "tolerance"}, {"type", "number"}, {"default", 0.01}}, + json{{"name", "merge_angle_deg"}, {"type", "number"}, {"default", 5.0}}, + })}}, + json{{"name", "validate_against"}, {"summary", "Volume + bbox/centroid + surface deviation (max/mean/rms mm) of a body vs a reference {step:path|body:id} (the RE acceptance metric)."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, + json{{"name", "reference"}, {"type", "object"}}, + })}}, + })}, + }; +} + +json describe_scene(DesignPanel* panel) +{ + CadDocument& doc = panel->mcp_doc(); + + json features = json::array(); + for (size_t i = 0; i < doc.features.size(); ++i) { + const CadFeature& f = doc.features[i]; + features.push_back(json{ + {"index", int(i)}, {"type", feature_type_name(f.type)}, + {"name", f.name}, {"enabled", f.enabled}}); + } + + json bodies = json::array(); + for (size_t i = 0; i < doc.bodies.size(); ++i) { + // The user's name when the body has one, the derived maker name otherwise — the same + // rule the Bodies row shows, so a driver and a person read the same thing. + json b{{"index", int(i)}, + {"name", doc.bodies[i].has_user_name ? doc.bodies[i].user_name : doc.bodies[i].name}, + {"user_name", doc.bodies[i].has_user_name}, + {"has_color", doc.bodies[i].has_color}}; + // Per-body bbox/centre from the already-tessellated display meshes. + if (i < doc.display_body_meshes.size() && !doc.display_body_meshes[i].empty()) { + BoundingBoxf3 bb = doc.display_body_meshes[i].bounding_box(); + b["bbox"] = json{{"min", {bb.min.x(), bb.min.y(), bb.min.z()}}, + {"max", {bb.max.x(), bb.max.y(), bb.max.z()}}}; + Vec3d c = bb.center(); + b["center"] = {c.x(), c.y(), c.z()}; + } + bodies.push_back(std::move(b)); + } + + return json{ + {"modeling_origin", {doc.modeling_origin.x(), doc.modeling_origin.y(), doc.modeling_origin.z()}}, + {"features", std::move(features)}, + {"bodies", std::move(bodies)}, + {"error", doc.error}, + // Pass this back as "generation" on any call that takes a face or edge id and the call + // is refused if the topology has moved on. See the note on the dispatcher. + {"generation", doc.topo_generation}, + }; +} + +// --- Measure layer (read-only "evidence" half of the RE loop) ------------------ +inline json vec3(const Vec3d& v) { return json::array({v.x(), v.y(), v.z()}); } + +// Shared Build helpers. +SketchPlane plane_from(const json& params, const CadDocument& doc) +{ + std::string n = params.value("plane", std::string("XY")); + SketchPlane pl = n == "XZ" ? SketchPlane::XZ() : n == "YZ" ? SketchPlane::YZ() : SketchPlane::XY(); + pl.origin = doc.modeling_origin; // land on the bed centre, like the GUI + return pl; +} +BooleanMode bool_from(const std::string& s) +{ + if (s == "union" || s == "add") return BooleanMode::Add; + if (s == "subtract" || s == "cut") return BooleanMode::Cut; + if (s == "intersect" || s == "common")return BooleanMode::Intersect; + return BooleanMode::New; +} +// Optional explicit closed profile: params["profile"] = [[x,y],...] in plane mm. +// This is the Measure->Build bridge — feed a measured contour straight back. +bool profile_from(const json& params, SketchProfile& out) +{ + if (!params.contains("profile")) return false; + out.points.clear(); + for (const auto& p : params["profile"]) out.points.emplace_back(p[0].get(), p[1].get()); + out.closed = true; + return out.points.size() >= 3; +} + +// Resolve body index -> shape, throwing a clear error if out of range / null. +const TopoDS_Shape& body_shape(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + int idx = params.value("body", 0); + if (idx < 0 || idx >= int(doc.bodies.size())) + throw std::runtime_error("body index out of range (have " + std::to_string(doc.bodies.size()) + ")"); + if (doc.bodies[idx].shape.IsNull()) + throw std::runtime_error("body has no shape"); + return doc.bodies[idx].shape; +} + +json query_topology(DesignPanel* panel, const json& params) +{ + const TopoDS_Shape& shape = body_shape(panel, params); + // Enumerate once. The _by_index accessors rescan the shape on every call (edge_by_index + // rebuilds the whole indexed map), so indexing a body face-by-face is quadratic: ~15 s on a + // 4.7k-face imported solid, on the UI thread. faces_of/edges_of keep the very same ids. + const std::vector all_faces = GeometryEngine::faces_of(shape); + const std::vector all_edges = GeometryEngine::edges_of(shape); + + json faces = json::array(); + const int nf = int(all_faces.size()); + for (int i = 0; i < nf; ++i) { + const TopoDS_Face& f = all_faces[i]; + if (f.IsNull()) continue; + json jf{{"id", i}, {"centroid", vec3(GeometryEngine::face_centroid_world(f))}, + {"normal", vec3(GeometryEngine::face_normal_world(f))}, {"kind", "planar"}}; + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(f); + if (cyl.ok) { jf["kind"] = "cylindrical"; jf["radius"] = cyl.radius; + jf["axis"] = vec3(cyl.axis); jf["internal"] = cyl.internal; } + faces.push_back(std::move(jf)); + } + json edges = json::array(); + const int ne = int(all_edges.size()); + for (int i = 0; i < ne; ++i) { + const TopoDS_Edge& e = all_edges[i]; + if (e.IsNull()) continue; + std::vector pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) continue; + double len = 0; for (size_t k = 1; k < pts.size(); ++k) len += (pts[k] - pts[k-1]).norm(); + json je{{"id", i}, {"length", len}, {"p0", vec3(pts.front())}, {"p1", vec3(pts.back())}, + {"kind", "line"}}; + GeometryEngine::CylinderFace circ = GeometryEngine::circle_of_edge(e); + if (circ.ok) { je["kind"] = "circle"; je["radius"] = circ.radius; je["center"] = vec3(circ.base); } + edges.push_back(std::move(je)); + } + return json{{"body", params.value("body", 0)}, {"face_count", nf}, {"edge_count", ne}, + {"faces", std::move(faces)}, {"edges", std::move(edges)}, + // The ids above are indices into this exact topology and expire with it. Echo + // this back as "generation" on the calls that consume them. + {"generation", panel->mcp_doc().topo_generation}}; +} + +// One measurement reference -> a representative point and (optionally) a direction. +// ref = {"face": id} | {"edge": id} | {"point": [x,y,z]} on the given body. +bool resolve_ref(const TopoDS_Shape& shape, const json& ref, Vec3d& point, Vec3d& dir, bool& has_dir) +{ + has_dir = false; + if (ref.contains("point")) { auto p = ref["point"]; point = Vec3d(p[0], p[1], p[2]); return true; } + if (ref.contains("face")) { + TopoDS_Face f = GeometryEngine::face_by_index(shape, ref["face"].get()); + if (f.IsNull()) return false; + point = GeometryEngine::face_centroid_world(f); + dir = GeometryEngine::face_normal_world(f); has_dir = true; return true; + } + if (ref.contains("edge")) { + TopoDS_Edge e = GeometryEngine::edge_by_index(shape, ref["edge"].get()); + if (e.IsNull()) return false; + std::vector pts = GeometryEngine::sample_edge_world(e); + if (pts.empty()) return false; + point = pts[pts.size() / 2]; // midpoint sample + if (pts.size() >= 2) { dir = (pts.back() - pts.front()).normalized(); has_dir = true; } + return true; + } + return false; +} + +json measure(DesignPanel* panel, const json& params) +{ + const TopoDS_Shape& shape = body_shape(panel, params); + Vec3d pa, pb, da, db; bool hda = false, hdb = false; + if (!params.contains("a") || !params.contains("b")) + throw std::runtime_error("measure needs refs 'a' and 'b' ({face|edge|point})"); + if (!resolve_ref(shape, params["a"], pa, da, hda) || !resolve_ref(shape, params["b"], pb, db, hdb)) + throw std::runtime_error("could not resolve a measurement reference"); + json r{{"distance", (pa - pb).norm()}, {"point_a", vec3(pa)}, {"point_b", vec3(pb)}}; + if (hda && hdb) { + double c = std::max(-1.0, std::min(1.0, da.normalized().dot(db.normalized()))); + r["angle_deg"] = std::acos(c) * 180.0 / M_PI; + } + return r; +} + +json mass_properties(DesignPanel* panel, const json& params) +{ + const TopoDS_Shape& shape = body_shape(panel, params); + auto mp = GeometryEngine::mass_properties(shape); + if (!mp.valid) throw std::runtime_error("mass properties could not be computed (null/empty shape)"); + // A sheet body has no volume and no inertia. Report the area and say so, rather than + // returning numbers a caller would reasonably treat as a material check. + if (!mp.is_solid) + return json{ + {"surface_area", mp.surface_area}, + {"volume", 0.0}, + {"is_solid", false}, + {"valid", mp.valid}, + {"note", "sheet body (open shell): it encloses no material, so volume and inertia " + "are not defined; surface_area is exact"}, + }; + return json{ + {"volume", mp.volume}, + {"surface_area", mp.surface_area}, + {"center_of_mass", json::array({mp.center_of_mass.x(), mp.center_of_mass.y(), mp.center_of_mass.z()})}, + {"inertia", mp.inertia}, + {"is_solid", true}, + {"valid", mp.valid}, + }; +} + +// Chain raw section segments (each a sampled-edge polyline) into ordered contours by joining +// endpoints within tol. Grows the tail; when the tail is stuck, reverses the contour and grows +// the other end. A contour is closed when its two ends meet. OCCT section vertices are exact, +// so a small absolute tol suffices. +std::vector, bool>> +chain_segments(std::vector> segs, double tol) +{ + std::vector, bool>> contours; + std::vector used(segs.size(), 0); + auto meets = [&](const Vec3d& a, const Vec3d& b) { return (a - b).norm() <= tol; }; + for (size_t i = 0; i < segs.size(); ++i) { + if (used[i] || segs[i].size() < 2) continue; + used[i] = 1; + std::vector c = segs[i]; + for (int side = 0; side < 2; ) { // grow tail; reverse once when stuck + bool grew = false; + for (size_t j = 0; j < segs.size(); ++j) { + if (used[j] || segs[j].size() < 2) continue; + if (meets(c.back(), segs[j].front())) { + c.insert(c.end(), segs[j].begin() + 1, segs[j].end()); used[j] = 1; grew = true; + } else if (meets(c.back(), segs[j].back())) { + for (auto it = segs[j].rbegin() + 1; it != segs[j].rend(); ++it) c.push_back(*it); + used[j] = 1; grew = true; + } + } + if (grew) { side = 0; continue; } + std::reverse(c.begin(), c.end()); ++side; // try the other end + } + bool closed = c.size() > 2 && meets(c.front(), c.back()); + contours.emplace_back(std::move(c), closed); + } + return contours; +} + +// sections-as-evidence: cross-section of a body by a named base plane at an offset. +json slice_body(DesignPanel* panel, const json& params) +{ + const TopoDS_Shape& shape = body_shape(panel, params); + const std::string plane_name = params.value("plane", std::string("XY")); + const double offset = params.value("offset", 0.0); + // Base plane normal; offset shifts the plane along it. + gp_Dir n = plane_name == "XZ" ? gp_Dir(0, 1, 0) + : plane_name == "YZ" ? gp_Dir(1, 0, 0) + : gp_Dir(0, 0, 1); + gp_Pnt o(n.X() * offset, n.Y() * offset, n.Z() * offset); + BRepAlgoAPI_Section sect(shape, gp_Pln(o, n), Standard_False); + sect.ComputePCurveOn1(Standard_False); + sect.Approximation(Standard_True); + sect.Build(); + if (!sect.IsDone()) throw std::runtime_error("section failed"); + std::vector> segs; + for (TopExp_Explorer ex(sect.Shape(), TopAbs_EDGE); ex.More(); ex.Next()) { + std::vector pts = GeometryEngine::sample_edge_world(TopoDS::Edge(ex.Current())); + if (pts.size() >= 2) segs.push_back(std::move(pts)); + } + const int raw = int(segs.size()); + auto contours = chain_segments(std::move(segs), 1e-3); + json jcont = json::array(); + int closed_n = 0; + for (auto& pc : contours) { + if (pc.second) ++closed_n; + json pts = json::array(); + for (const Vec3d& p : pc.first) pts.push_back(vec3(p)); + jcont.push_back(json{{"closed", pc.second}, {"points", std::move(pts)}}); + } + return json{{"body", params.value("body", 0)}, {"plane", plane_name}, {"offset", offset}, + {"segment_count", raw}, {"contour_count", int(contours.size())}, + {"closed_count", closed_n}, {"contours", std::move(jcont)}}; +} + +// --- Build: bring a reference part in (Import STEP as native B-rep bodies) ------ +json import_step(DesignPanel* panel, const json& params) +{ + if (!params.contains("path")) throw std::runtime_error("import_step needs 'path'"); + const std::string path = params["path"].get(); + std::string err; + std::vector solids = GeometryEngine::read_step_solids(path, err); + if (solids.empty()) throw std::runtime_error(err.empty() ? "no solids in STEP" : err); + + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + int first = int(doc.features.size()); + for (const TopoDS_Shape& s : solids) { + CadFeature f; + f.type = CadFeatureType::Import; + f.name = "STEP" + std::to_string(int(doc.features.size()) + 1); + f.imported_solid = s; + f.mode = BooleanMode::New; // each solid = its own coexisting body + doc.features.push_back(f); + } + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"imported", int(solids.size())}, {"first_feature", first}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +// --- Import a triangle mesh as a B-rep body (GeometryEngine::mesh_to_brep) --- +// Same destination as import_step: a CadFeatureType::Import body every feature tool can edit. +// The full conversion stats come back so a caller can tell an honest solid from an open shell +// instead of discovering it later when a boolean silently fails. +json import_mesh(DesignPanel* panel, const json& params) +{ + if (!params.contains("path")) throw std::runtime_error("import_mesh needs 'path'"); + const std::string path = params["path"].get(); + const double tolerance = params.value("tolerance", 0.01); + const double merge_angle_deg = params.value("merge_angle_deg", 5.0); + + TriangleMesh mesh; + const std::string ext = boost::algorithm::to_lower_copy( + boost::filesystem::path(path).extension().string()); + if (ext == ".stl") { + if (!mesh.ReadSTLFile(path.c_str())) throw std::runtime_error("could not read STL: " + path); + } else if (ext == ".obj") { + ObjInfo obj_info; std::string obj_err; + if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) + throw std::runtime_error("could not read OBJ: " + obj_err); + } else { + throw std::runtime_error("unsupported mesh format (want .stl or .obj): " + ext); + } + + GeometryEngine::MeshBrepStats st; + const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(mesh.its, tolerance, merge_angle_deg, st); + if (shape.IsNull()) throw std::runtime_error("mesh conversion produced no geometry"); + + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + const int first = int(doc.features.size()); + CadFeature f; + f.type = CadFeatureType::Import; + f.name = "Mesh" + std::to_string(first + 1); + f.imported_solid = shape; + f.mode = BooleanMode::New; + doc.features.push_back(f); + + const bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"first_feature", first}, {"bodies", int(doc.bodies.size())}, + {"input_triangles", st.input_tris}, {"kept_triangles", st.kept_tris}, + {"degenerate_collapsed", st.degenerate_collapsed}, + {"degenerate_sliver", st.degenerate_sliver}, + {"faces_built", st.faces_built}, {"faces_failed", st.faces_failed}, + {"faces_final", st.faces_final}, {"unique_edges", st.unique_edges}, + {"boundary_edges", st.boundary_edges}, + {"nonmanifold_edges", st.nonmanifold_edges}, + {"watertight", st.watertight}, {"is_solid", st.is_solid}, + {"volume", st.volume}, {"error", doc.error}}; +} + +// --- Validate: volume + bbox deviation of a body vs a reference (the "scarto %") -- +// ponytail: volume delta + bbox/centroid offset (the RE skill's actual acceptance metric). +// Surface-deviation heat-map is the upgrade path (per-vertex BRepExtrema), add when needed. +struct ShapeMetrics { double volume; Vec3d centroid, bmin, bmax; }; +ShapeMetrics shape_metrics(const TopoDS_Shape& s) +{ + GProp_GProps vp; BRepGProp::VolumeProperties(s, vp); + gp_Pnt c = vp.CentreOfMass(); + Bnd_Box bb; BRepBndLib::Add(s, bb); + Standard_Real x0, y0, z0, x1, y1, z1; bb.Get(x0, y0, z0, x1, y1, z1); + return {vp.Mass(), Vec3d(c.X(), c.Y(), c.Z()), Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}; +} + +json validate_against(DesignPanel* panel, const json& params) +{ + const TopoDS_Shape& cand = body_shape(panel, params); // candidate = the reconstruction + if (!params.contains("reference")) throw std::runtime_error("validate_against needs 'reference' {step|body}"); + const json& r = params["reference"]; + + TopoDS_Shape ref; + if (r.contains("step")) { + std::string err; + std::vector solids = GeometryEngine::read_step_solids(r["step"].get(), err); + if (solids.empty()) throw std::runtime_error(err.empty() ? "reference STEP has no solids" : err); + BRep_Builder b; TopoDS_Compound comp; b.MakeCompound(comp); + for (const TopoDS_Shape& s : solids) if (!s.IsNull()) b.Add(comp, s); + ref = comp; + } else if (r.contains("body")) { + CadDocument& doc = panel->mcp_doc(); + int idx = r["body"].get(); + if (idx < 0 || idx >= int(doc.bodies.size()) || doc.bodies[idx].shape.IsNull()) + throw std::runtime_error("reference body index out of range / null"); + ref = doc.bodies[idx].shape; + } else { + throw std::runtime_error("reference must be {\"step\": path} or {\"body\": id}"); + } + + ShapeMetrics a = shape_metrics(cand), b = shape_metrics(ref); + double dv = b.volume > 0 ? (a.volume - b.volume) / b.volume * 100.0 : 0.0; + Vec3d coff = a.centroid - b.centroid; + Vec3d dmin = a.bmin - b.bmin, dmax = a.bmax - b.bmax; + // Surface-level deviation (one-sided Hausdorff, candidate vertices -> reference solid): + // catches local shape error that matching volume + bbox can hide. + GeometryEngine::Deviation dev = GeometryEngine::surface_deviation(cand, ref); + return json{ + {"volume", a.volume}, {"volume_reference", b.volume}, {"volume_delta_pct", dv}, + {"centroid_offset", vec3(coff)}, {"centroid_offset_mm", coff.norm()}, + {"bbox", json{{"min", vec3(a.bmin)}, {"max", vec3(a.bmax)}}}, + {"bbox_reference", json{{"min", vec3(b.bmin)}, {"max", vec3(b.bmax)}}}, + {"bbox_delta", json{{"min", vec3(dmin)}, {"max", vec3(dmax)}}}, + {"surface_deviation", json{{"max_mm", dev.max_mm}, {"mean_mm", dev.mean_mm}, + {"rms_mm", dev.rms_mm}, {"samples", dev.sample_count}}}, + }; +} + +json action_extrude(DesignPanel* panel, const json& params) +{ + const double d = params.value("distance", 10.0); + if (d <= 0) throw std::runtime_error("distance must be > 0"); + CadDocument& doc = panel->mcp_doc(); + SketchPlane pl = plane_from(params, doc); + BooleanMode mode = bool_from(params.value("boolean", std::string("new"))); + + SketchProfile prof; + bool has_prof = profile_from(params, prof); + double w = 0, h = 0; + if (!has_prof) { + w = params.value("width", 20.0); h = params.value("height", 20.0); + if (w <= 0 || h <= 0) throw std::runtime_error("width and height must be > 0"); + } + doc.checkpoint(); + int s = has_prof ? doc.add_sketch_profile(prof, pl, "Sketch") + : doc.add_sketch(SketchShape::Rectangle, pl, w, h, 0.0, "Sketch"); + int e = doc.add_extrude(s, d, /*symmetric*/false, mode, "Extrude"); + // End condition (Onshape parity). apply reads extrude_end directly; the kernel `symmetric` + // bool is unused, so set the field here. up_to_face id comes from query_topology on the + // target (last) body. taper_deg lofts the side wall; flip negates the direction. + const std::string end = params.value("end", std::string("blind")); + CadFeature& fe = doc.features[e]; + fe.flip = params.value("flip", false); + fe.taper_deg = params.value("taper", 0.0); + if (end == "symmetric") fe.extrude_end = ExtrudeEnd::Symmetric; + else if (end == "two_sided") { fe.extrude_end = ExtrudeEnd::TwoSided; fe.distance2 = params.value("distance2", d); } + else if (end == "through_all") fe.extrude_end = ExtrudeEnd::ThroughAll; + else if (end == "up_to_face") { fe.extrude_end = ExtrudeEnd::UpToFace; fe.up_to_face = params.value("up_to_face", -1); } + else fe.extrude_end = ExtrudeEnd::Blind; + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"sketch_index", s}, {"extrude_index", e}, {"end", end}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_revolve(DesignPanel* panel, const json& params) +{ + const double angle = params.value("angle", 360.0); + const int axis = params.value("axis", 0); // 0 = plane X, 1 = plane Y + const bool flip = params.value("flip", false); + CadDocument& doc = panel->mcp_doc(); + SketchPlane pl = plane_from(params, doc); + BooleanMode mode = bool_from(params.value("boolean", std::string("new"))); + + SketchProfile prof; + bool has_prof = profile_from(params, prof); + double w = 0, h = 0; + if (!has_prof) { // ponytail: rectangle centred on the axis may self-overlap; offset via `profile` + w = params.value("width", 20.0); h = params.value("height", 10.0); + if (w <= 0 || h <= 0) throw std::runtime_error("width and height must be > 0"); + } + doc.checkpoint(); + int s = has_prof ? doc.add_sketch_profile(prof, pl, "Sketch") + : doc.add_sketch(SketchShape::Rectangle, pl, w, h, 0.0, "Sketch"); + int r = doc.add_revolve(s, angle, axis, flip, mode, "Revolve"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"sketch_index", s}, {"revolve_index", r}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +// Resolve an optional explicit body target. Default (no `body`, or <0) = last body, which is +// what the kernel picks anyway. Validated BEFORE any checkpoint so a bad index throws clean. +int target_body_arg(const json& params, const CadDocument& doc) +{ + int bi = params.value("body", -1); + if (bi >= int(doc.bodies.size())) + throw std::runtime_error("body index out of range (have " + std::to_string(doc.bodies.size()) + ")"); + return bi; // <0 -> kernel uses the last body +} + +json action_fillet(DesignPanel* panel, const json& params) +{ + if (!params.contains("edge")) throw std::runtime_error("fillet needs 'edge' (id from query_topology)"); + const double radius = params.value("radius", 1.0); + if (radius <= 0) throw std::runtime_error("radius must be > 0"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to fillet"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int f = doc.add_fillet(radius, params["edge"].get(), "Fillet"); + if (bi >= 0) doc.features[f].target_body = bi; // edge id resolved against THIS body's shape + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"fillet_index", f}, {"body", bi < 0 ? int(doc.bodies.size()) - 1 : bi}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_chamfer(DesignPanel* panel, const json& params) +{ + if (!params.contains("edge")) throw std::runtime_error("chamfer needs 'edge' (id from query_topology)"); + const double dist = params.value("distance", 1.0); + if (dist <= 0) throw std::runtime_error("distance must be > 0"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to chamfer"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int c = doc.add_chamfer(dist, params["edge"].get(), "Chamfer"); + if (bi >= 0) doc.features[c].target_body = bi; // edge id resolved against THIS body's shape + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"chamfer_index", c}, {"body", bi < 0 ? int(doc.bodies.size()) - 1 : bi}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_hole(DesignPanel* panel, const json& params) +{ + const double dia = params.value("diameter", 5.0); + const double depth = params.value("depth", 10.0); + const bool thru = params.value("through", false); + const double x = params.value("x", 0.0), y = params.value("y", 0.0); + if (dia <= 0) throw std::runtime_error("diameter must be > 0"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to drill"); + SketchPlane pl = plane_from(params, doc); + doc.checkpoint(); + int h = doc.add_hole(dia, depth, thru, x, y, pl, "Hole"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_hole_styled(DesignPanel* panel, const json& params) +{ + const double dia = params.value("diameter", 5.0); + const double depth = params.value("depth", 10.0); + const bool thru = params.value("through", true); + const double x = params.value("x", 0.0), y = params.value("y", 0.0); + const int style = params.value("style", 0); + const double cbore_diameter = params.value("cbore_diameter", 0.0); + const double cbore_depth = params.value("cbore_depth", 0.0); + const double csink_diameter = params.value("csink_diameter", 0.0); + const double csink_angle = params.value("csink_angle", 90.0); + const std::string standard = params.value("standard", std::string("")); + if (dia <= 0) throw std::runtime_error("diameter must be > 0"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to drill"); + SketchPlane pl = plane_from(params, doc); + doc.checkpoint(); + int h = doc.add_hole_styled(dia, depth, thru, x, y, pl, style, + cbore_diameter, cbore_depth, + csink_diameter, csink_angle, standard, "Hole"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_hole_standard(DesignPanel* panel, const json& params) +{ + const std::string desig = params.value("designation", std::string("")); + if (desig.empty()) throw std::runtime_error("designation is required"); + const int style = params.value("style", 0); + const bool thru = params.value("through", true); + const double depth = params.value("depth", 10.0); + const double x = params.value("x", 0.0), y = params.value("y", 0.0); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to drill"); + SketchPlane pl = plane_from(params, doc); + doc.checkpoint(); + try { + int h = doc.add_hole_standard(desig, style, thru, depth, x, y, pl, "Hole"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; + } catch (const std::exception& ex) { + doc.undo(); + panel->mcp_after_change(); + return json{{"ok", false}, {"error", ex.what()}}; + } +} + +json action_boolean(DesignPanel* panel, const json& params) +{ + BooleanMode m = bool_from(params.value("op", std::string("subtract"))); + if (m == BooleanMode::New) throw std::runtime_error("op must be union | subtract | intersect"); + const int target = params.value("target", 0); + const int tool = params.value("tool", 1); + const bool keep = params.value("keep_tool", false); + const double tol = params.value("tolerance", 0.0); + CadDocument& doc = panel->mcp_doc(); + int n = int(doc.bodies.size()); + if (target < 0 || target >= n || tool < 0 || tool >= n) + throw std::runtime_error("target/tool body index out of range (have " + std::to_string(n) + ")"); + if (target == tool) throw std::runtime_error("target and tool must differ"); + doc.checkpoint(); + int b = doc.add_boolean(m, target, tool, keep, tol, -1, -1, "Boolean"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"boolean_index", b}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_pattern(DesignPanel* panel, const json& params) +{ + const bool circular = params.value("circular", false); + const int count = params.value("count", 3); + const double spacing = params.value("spacing", 10.0); // linear step (mm) + const int dir = params.value("dir", 0); // 0 = plane X, 1 = plane Y + const double angle = params.value("angle", 360.0); // circular total sweep (deg) + if (count < 1) throw std::runtime_error("count must be >= 1"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to pattern"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int p = doc.add_pattern(circular, count, spacing, dir, angle, bi, "Pattern"); + doc.features[p].plane = plane_from(params, doc); // axis (circular) / step dirs (linear) + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"pattern_index", p}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_pattern_on_curve(DesignPanel* panel, const json& params) +{ + const int count = params.value("count", 3); + if (count < 1) throw std::runtime_error("count must be >= 1"); + if (!params.contains("sketch")) throw std::runtime_error("pattern_on_curve needs 'sketch' (feature index)"); + if (!params.contains("entity")) throw std::runtime_error("pattern_on_curve needs 'entity' (entity index)"); + const int sketch = params["sketch"].get(); + const int entity = params["entity"].get(); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to pattern"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int p = doc.add_pattern_on_curve(count, sketch, entity, bi, "PatternOnCurve"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"pattern_index", p}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_shell(DesignPanel* panel, const json& params) +{ + const double thickness = params.value("thickness", 1.0); + if (thickness <= 0) throw std::runtime_error("thickness must be > 0"); + const int face = params.value("face", -1); // face id to leave open (-1 = closed hollow) + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to shell"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int s = doc.add_shell(thickness, face, bi, "Shell"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"shell_index", s}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_rib(DesignPanel* panel, const json& params) +{ + if (!params.contains("sketch")) throw std::runtime_error("rib needs 'sketch' (feature index)"); + if (!params.contains("entity")) throw std::runtime_error("rib needs 'entity' (entity index)"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to rib"); + int sketch = params["sketch"].get(); + int entity = params["entity"].get(); + double thickness = params.value("thickness", 2.0); + double depth = params.value("depth", 10.0); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int idx = doc.add_rib(sketch, entity, thickness, depth, bi, "Rib"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"rib_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_surface_extrude(DesignPanel* panel, const json& params) +{ + if (!params.contains("sketch")) throw std::runtime_error("surface_extrude needs 'sketch' (feature index)"); + CadDocument& doc = panel->mcp_doc(); + int sketch = params["sketch"].get(); + double distance = params.value("distance", 10.0); + doc.checkpoint(); + int idx = doc.add_surface_extrude(sketch, distance, "SurfaceExtrude"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_surface_revolve(DesignPanel* panel, const json& params) +{ + if (!params.contains("sketch")) throw std::runtime_error("surface_revolve needs 'sketch' (feature index)"); + CadDocument& doc = panel->mcp_doc(); + int sketch = params["sketch"].get(); + double angle = params.value("angle", 360.0); + int axis = params.value("axis", 0); + doc.checkpoint(); + int idx = doc.add_surface_revolve(sketch, angle, axis, "SurfaceRevolve"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_thicken_surface(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no sheet body to thicken"); + int bi = target_body_arg(params, doc); + double thickness = params.value("thickness", 2.0); + bool flip = params.value("flip", false); + doc.checkpoint(); + int idx = doc.add_thicken_surface(bi, thickness, flip, "ThickenSurface"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_surface_offset(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no sheet body to offset"); + int bi = target_body_arg(params, doc); + double offset = params.value("offset", 1.0); + doc.checkpoint(); + int idx = doc.add_surface_offset(bi, offset, "SurfaceOffset"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_surface_loft(DesignPanel* panel, const json& params) +{ + if (!params.contains("profiles") || !params["profiles"].is_array()) + throw std::runtime_error("surface_loft needs 'profiles' (array of int feature indices)"); + CadDocument& doc = panel->mcp_doc(); + std::vector profiles; + for (const json& j : params["profiles"]) profiles.push_back(j.get()); + bool ruled = params.value("ruled", false); + doc.checkpoint(); + int idx = doc.add_surface_loft(profiles, ruled, "SurfaceLoft"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_surface_fill(DesignPanel* panel, const json& params) +{ + if (!params.contains("sketch")) throw std::runtime_error("surface_fill needs 'sketch' (feature index)"); + CadDocument& doc = panel->mcp_doc(); + int sketch = params["sketch"].get(); + doc.checkpoint(); + int idx = doc.add_surface_fill(sketch, "SurfaceFill"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_draft(DesignPanel* panel, const json& params) +{ + if (!params.contains("face")) throw std::runtime_error("draft needs 'face' (id from query_topology)"); + const double angle = params.value("angle", 5.0); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to draft"); + int bi = target_body_arg(params, doc); + doc.checkpoint(); + int d = doc.add_draft(angle, params["face"].get(), bi, "Draft"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"draft_index", d}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +// ---- 2D sketch verbs -------------------------------------------------------------------- +// +// The model is FreeCAD's Sketcher, adapted to this tab's right-click world. Three ideas are +// taken over deliberately: +// +// * GEOMETRY IS SEPARATE FROM CONSTRAINTS. You add curves, then you constrain them; the +// solver reports degrees of freedom and whether it is consistent. `sketch_describe` returns +// both halves plus the DoF, which is the whole state a caller needs to reason about. +// * A SKETCH IS JUDGED BY ITS LOOPS, not by its coordinates. FreeCAD asks whether the profile +// is closed before it will build from it; `sketch_describe` answers that directly, listing +// each closed loop, the loops it encloses as VOIDS, and — the actionable part — the exact +// plane coordinates where a chain is still open. +// * VALIDATE, THEN FIX. FreeCAD's ValidateSketch finds vertices that overlap within a +// tolerance but carry no coincidence, and adds the missing ones. `sketch_validate` reports +// them, `sketch_heal` welds and constrains them. That is what turns a loop that is closed by +// floating-point luck into one that is closed by construction and stays closed through +// every later solve. +// +// What is NOT taken over: FreeCAD's Sketcher is a modal dialog with its own toolbars. Here the +// vocabulary is the right-click offer, so these verbs are named after what the menu offers on a +// selection, and every one of them drives the SAME DesignSketchTool the mouse drives. + +DesignSketchTool& mcp_sketch(DesignPanel* panel) +{ + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr) throw std::runtime_error("no viewport"); + if (!vp->is_sketching()) + throw std::runtime_error("no sketch is open — call sketch_begin first"); + return vp->mcp_sketch_tool(); +} + +SketchEntity sketch_entity_from(const json& j) +{ + const std::string t = j.value("type", std::string("")); + SketchEntity e; + auto p = [&](const char* k, double dx, double dy) { + if (!j.contains(k)) return Vec2d(dx, dy); + const json& a = j.at(k); + if (!a.is_array() || a.size() < 2) throw std::runtime_error(std::string(k) + " must be [x, y]"); + return Vec2d(a[0].get(), a[1].get()); + }; + e.construction = j.value("construction", false); + // A circle and an arc are defined BY their centre, so a request that does not carry one is + // incomplete, not a request for a circle at the origin. Defaulting it silently put geometry + // somewhere the caller never asked for and then reported perfectly consistent loops, areas + // and hole attribution ABOUT THAT WRONG GEOMETRY — which is far more expensive to disbelieve + // than an error would have been. `p0` is accepted as an alias because that is exactly what a + // circle stores internally (e.p0 = e.center below), so a caller who writes p0 means centre. + auto centre_of = [&](const char* what) { + if (j.contains("center")) return p("center", 0, 0); + if (j.contains("centre")) return p("centre", 0, 0); + if (j.contains("p0")) return p("p0", 0, 0); + throw std::runtime_error(std::string(what) + " needs a 'center' (or 'p0')"); + }; + if (t == "line") { + e.type = SketchEntity::Type::Line; + e.p0 = p("p0", 0, 0); e.p1 = p("p1", 0, 0); + } else if (t == "circle") { + e.type = SketchEntity::Type::Circle; + e.center = centre_of("circle"); + e.radius = j.value("radius", 0.0); + e.p0 = e.center; + if (e.radius <= 0.0) throw std::runtime_error("circle needs a positive 'radius'"); + } else if (t == "arc") { + e.type = SketchEntity::Type::Arc; + e.center = centre_of("arc"); + e.radius = j.value("radius", 0.0); + e.start_angle = j.value("start_angle", 0.0); + e.end_angle = j.value("end_angle", 0.0); + if (e.radius <= 0.0) throw std::runtime_error("arc needs a positive 'radius'"); + e.p0 = e.center + e.radius * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); + e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + } else if (t == "point") { + e.type = SketchEntity::Type::Point; + e.p0 = p("p", 0, 0); + } else { + throw std::runtime_error("unknown entity type '" + t + "' (line, arc, circle, point)"); + } + return e; +} + +json sketch_entity_to(const SketchEntity& e, int index) +{ + json j{{"index", index}, {"construction", e.construction}}; + switch (e.type) { + case SketchEntity::Type::Line: + j["type"] = "line"; + j["p0"] = json::array({e.p0.x(), e.p0.y()}); + j["p1"] = json::array({e.p1.x(), e.p1.y()}); + j["length"] = (e.p1 - e.p0).norm(); + break; + case SketchEntity::Type::Circle: + j["type"] = "circle"; + j["center"] = json::array({e.center.x(), e.center.y()}); + j["radius"] = e.radius; + break; + case SketchEntity::Type::Arc: + j["type"] = "arc"; + j["center"] = json::array({e.center.x(), e.center.y()}); + j["radius"] = e.radius; + j["start_angle"] = e.start_angle; + j["end_angle"] = e.end_angle; + j["p0"] = json::array({e.p0.x(), e.p0.y()}); + j["p1"] = json::array({e.p1.x(), e.p1.y()}); + break; + case SketchEntity::Type::Point: + j["type"] = "point"; + j["p"] = json::array({e.p0.x(), e.p0.y()}); + break; + // Ellipses and splines used to serialise as a TYPE NAME and nothing else, so every + // parameter they have was invisible to the only read-back this project has. A ladder could + // count them and grade the faceted area of the loop they close (2e-2, the faceting error) — + // it could not check a single axis, angle or pole. "Precise definition of every aspect" + // cannot be asserted about an entity whose aspects the instrument cannot see. + case SketchEntity::Type::Ellipse: + j["type"] = "ellipse"; + j["center"] = json::array({e.center.x(), e.center.y()}); + j["radius"] = e.radius; // semi-major (a) + j["rminor"] = e.rminor; // semi-minor (b) + j["rotation"] = e.rotation; // major-axis angle, radians + break; + case SketchEntity::Type::EllipseArc: + j["type"] = "ellipse_arc"; + j["center"] = json::array({e.center.x(), e.center.y()}); + j["radius"] = e.radius; + j["rminor"] = e.rminor; + j["rotation"] = e.rotation; + j["start_angle"] = e.start_angle; + j["end_angle"] = e.end_angle; + j["p0"] = json::array({e.p0.x(), e.p0.y()}); + j["p1"] = json::array({e.p1.x(), e.p1.y()}); + break; + case SketchEntity::Type::BSpline: { + j["type"] = "spline"; + json poles = json::array(); + for (const Vec2d& c : e.ctrl) poles.push_back(json::array({c.x(), c.y()})); + j["ctrl"] = poles; + j["p0"] = json::array({e.p0.x(), e.p0.y()}); + j["p1"] = json::array({e.p1.x(), e.p1.y()}); + break; + } + } + return j; +} + +// Which entities a verb acts on: an explicit "entities" array, else the current selection, +// else — only where the verb says so — everything. Same precedence the menu uses: what you +// pointed at wins, and the menu never silently acts on the whole sketch. +std::vector sketch_targets(const json& params, DesignSketchTool& t, bool all_if_empty) +{ + std::vector out; + if (params.contains("entities")) { + for (const auto& v : params.at("entities")) out.push_back(v.get()); + return out; + } + out = t.selection(); + if (out.empty() && all_if_empty) + for (int i = 0; i < int(t.entities().size()); ++i) out.push_back(i); + return out; +} + +json action_sketch_begin(DesignPanel* panel, const json& params) +{ + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr) throw std::runtime_error("no viewport"); + if (vp->is_sketching()) throw std::runtime_error("a sketch is already open"); + const std::string pl = params.value("plane", std::string("XY")); + SketchPlane plane = SketchPlane::XY(); + if (pl == "XZ") plane = SketchPlane::XZ(); + else if (pl == "YZ") plane = SketchPlane::YZ(); + else if (pl != "XY") throw std::runtime_error("plane must be XY, XZ or YZ"); + vp->begin_sketch(plane, DesignSketchTool::Mode::Select); + // The panel has to enter sketch mode too, or the app is half in it: the tool sketches, the + // offer menu offers sketch verbs, and every sketch KEY is dead because key dispatch tests + // m_ui_mode while the menu tests the viewport. Driving the socket must leave the GUI in the + // state a user would be in, not a state only the socket can produce. + panel->mcp_set_sketch_mode(true); + return json{{"ok", true}, {"plane", pl}}; +} + +json action_sketch_commit(DesignPanel* panel, const json& params) +{ + (void)params; + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr || !vp->is_sketching()) throw std::runtime_error("no sketch is open"); + vp->finish_sketch(); + panel->mcp_set_sketch_mode(false); + panel->mcp_after_change(); + return json{{"ok", true}, {"features", int(panel->mcp_doc().features.size())}}; +} + +json action_sketch_cancel(DesignPanel* panel, const json& params) +{ + (void)params; + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr || !vp->is_sketching()) throw std::runtime_error("no sketch is open"); + vp->cancel_sketch(); + panel->mcp_set_sketch_mode(false); + return json{{"ok", true}}; +} + +json action_sketch_add(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + std::vector ents; + if (params.contains("entities")) { + for (const auto& j : params.at("entities")) ents.push_back(sketch_entity_from(j)); + } else if (params.contains("type")) { + ents.push_back(sketch_entity_from(params)); // single-entity shorthand + } else if (params.contains("rect")) { + // Corner rectangle as four shared-endpoint lines, so it arrives as ONE closed loop + // rather than four segments that happen to touch. + const json& r = params.at("rect"); + if (!r.is_array() || r.size() < 4) throw std::runtime_error("rect must be [x0, y0, x1, y1]"); + const double x0 = r[0].get(), y0 = r[1].get(); + const double x1 = r[2].get(), y1 = r[3].get(); + const bool c = params.value("construction", false); + auto seg = [&](Vec2d a, Vec2d b) { SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = a; e.p1 = b; e.construction = c; return e; }; + ents.push_back(seg({x0, y0}, {x1, y0})); + ents.push_back(seg({x1, y0}, {x1, y1})); + ents.push_back(seg({x1, y1}, {x0, y1})); + ents.push_back(seg({x0, y1}, {x0, y0})); + } else { + throw std::runtime_error("sketch_add needs 'entities', a single 'type', or 'rect'"); + } + const int base = t.add_entities_scripted(ents); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", base >= 0}, {"first_index", base}, {"added", int(ents.size())}, + {"entities", int(t.entities().size())}, {"dof", t.dof()}}; +} + +json action_sketch_select(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + std::vector idx; + if (params.contains("entities")) + for (const auto& v : params.at("entities")) idx.push_back(v.get()); + const bool any = t.select_indices(idx); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"selected", int(t.selection().size())}, {"any", any}}; +} + +json action_sketch_delete(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const std::vector tgt = sketch_targets(params, t, false); + if (tgt.empty()) throw std::runtime_error("nothing selected and no 'entities' given"); + const int before = int(t.entities().size()); + t.select_indices(tgt); + t.delete_selected(); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"removed", before - int(t.entities().size())}, + {"entities", int(t.entities().size())}}; +} + +json action_sketch_construction(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const std::vector tgt = sketch_targets(params, t, false); + if (tgt.empty()) throw std::runtime_error("nothing selected and no 'entities' given"); + t.select_indices(tgt); + const int n = t.toggle_selection_construction(); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", n > 0}, {"changed", n}}; +} + +json action_sketch_offset(DesignPanel* panel, const json& params) +{ + if (!params.contains("distance")) throw std::runtime_error("sketch_offset needs 'distance'"); + DesignSketchTool& t = mcp_sketch(panel); + const double d = params.at("distance").get(); + const std::vector tgt = sketch_targets(params, t, true); + std::vector src; + for (int i : tgt) + if (i >= 0 && i < int(t.entities().size())) src.push_back(t.entities()[i]); + if (src.empty()) throw std::runtime_error("nothing to offset"); + const auto out = SketchEngine::offset_entities(src, d); + if (out.empty()) throw std::runtime_error("offset produced nothing (ellipses and splines are not offset)"); + const int base = t.add_entities_scripted(out); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"first_index", base}, {"added", int(out.size())}, {"dof", t.dof()}}; +} + +json action_sketch_mirror(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + auto pt = [&](const char* k, double dx, double dy) { + if (!params.contains(k)) return Vec2d(dx, dy); + const json& a = params.at(k); + if (!a.is_array() || a.size() < 2) throw std::runtime_error(std::string(k) + " must be [x, y]"); + return Vec2d(a[0].get(), a[1].get()); + }; + const Vec2d a = pt("axis_a", 0, 0), b = pt("axis_b", 0, 1); + const std::vector tgt = sketch_targets(params, t, true); + std::vector src; + for (int i : tgt) + if (i >= 0 && i < int(t.entities().size())) src.push_back(t.entities()[i]); + if (src.empty()) throw std::runtime_error("nothing to mirror"); + const auto out = SketchEngine::mirror_entities(src, a, b); + const int base = t.add_entities_scripted(out); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"first_index", base}, {"added", int(out.size())}, {"dof", t.dof()}}; +} + +json sketch_report(DesignSketchTool& t) +{ + const auto rep = t.loop_report(); + json loops = json::array(); + for (const auto& l : rep.loops) { + json holes = json::array(); + for (int h : l.holes) holes.push_back(h); + loops.push_back(json{{"entities", l.ents}, {"holes", holes}, + {"closed", l.closed}, {"area", l.area}}); + } + json open_ends = json::array(); + for (const Vec2d& p : rep.open_ends) open_ends.push_back(json::array({p.x(), p.y()})); + // A profile is buildable when at least one loop closed and nothing is left dangling. + const bool buildable = !rep.loops.empty() && rep.open_ends.empty(); + return json{{"closed_loops", loops}, {"open_ends", open_ends}, {"buildable", buildable}}; +} + +json action_sketch_describe(DesignPanel* panel, const json& params) +{ + (void)params; + DesignSketchTool& t = mcp_sketch(panel); + json ents = json::array(); + for (int i = 0; i < int(t.entities().size()); ++i) + ents.push_back(sketch_entity_to(t.entities()[i], i)); + // The armed TOOL and its pending anchors. Without these the only way to tell which tool a + // menu row actually armed is to draw with it and infer from what came out — which is how a + // menu walk that lands one row off gets diagnosed as "the tool is broken". + static const char* const kModeNames[] = { + "select", "dimension", "polyline", "line", "rect_corner", "rect_center", "rect_oblique", + "rect_rounded", "circle_center", "circle_2pt", "point", + "circle_3pt", "arc_3pt", "arc_tangent", "arc_center", "slot", "slot_arc", "polygon", + "ellipse", "ellipse_arc", "spline", + "fillet", "chamfer", "offset", "mirror", + "trim", "extend", + "move", "rotate", "scale", "array", "array_polar", + "transform_art", + "constrain" }; + const int mi = int(t.mode()); + json out{{"ok", true}, + {"entities", ents}, + {"constraints", int(t.constraints().size())}, + {"dof", t.dof()}, + {"solve_ok", t.solve_ok()}, + {"tool", (mi >= 0 && mi < int(sizeof(kModeNames) / sizeof(kModeNames[0]))) + ? kModeNames[mi] : "unknown"}, + {"pending", t.pending_points()}, + {"editing", t.value_field_open()}, + {"selection", t.selection()}}; + out.update(sketch_report(t)); + return out; +} + +json action_sketch_validate(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const double tol = params.value("tolerance", 1e-3); + json out{{"ok", true}, {"tolerance", tol}, {"dof", t.dof()}, {"solve_ok", t.solve_ok()}}; + out.update(sketch_report(t)); + return out; +} + +json action_sketch_heal(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const double tol = params.value("tolerance", 1e-3); + const bool ic = params.value("ignore_construction", true); + const int welded = t.heal_coincidences(tol, ic); + panel->mcp_viewport()->request_repaint(); + json out{{"ok", true}, {"welded", welded}, {"tolerance", tol}, + {"dof", t.dof()}, {"solve_ok", t.solve_ok()}}; + out.update(sketch_report(t)); + return out; +} + +// Why sketch_set_value exists: committing a typed dimension could previously only be exercised +// by driving the in-canvas value field, and the rig's window manager never gives that frame +// keyboard focus, so the behaviour of apply_dimension on CONSTRAINED geometry was untestable. +// This calls the same function the widget calls, so the geometry can be asserted with no window +// manager involved. Note apply_dimension clears the selection. +json action_sketch_set_value(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + if (!params.contains("value")) throw std::runtime_error("sketch_set_value needs 'value'"); + const double value = params["value"].get(); + const DesignSketchTool::DimType kind = t.dimension_kind(); + if (kind == DesignSketchTool::DimType::None) + throw std::runtime_error("the selection has no value to set — pick a line, an arc, a circle, or two entities"); + + const char* kind_name = "none"; + switch (kind) { + case DesignSketchTool::DimType::Length: kind_name = "length"; break; + case DesignSketchTool::DimType::Radius: kind_name = "radius"; break; + case DesignSketchTool::DimType::Diameter: kind_name = "diameter"; break; + case DesignSketchTool::DimType::Angle: kind_name = "angle"; break; + case DesignSketchTool::DimType::Distance: kind_name = "distance"; break; + case DesignSketchTool::DimType::DistanceToLine: kind_name = "distance_to_line"; break; + default: break; + } + + // Validate BEFORE apply_dimension, at the socket boundary. The tool only MOVES geometry when + // the value passes its own per-case thresholds, but it records the driving constraint + // UNCONDITIONALLY afterwards — a negative/zero/NaN value that moved nothing would still be + // pushed to the solver as a constraint it must satisfy and cannot, silently corrupting the + // sketch rather than failing. (apply_dimension has the same flaw for any other caller; this + // guard protects the socket, not the tool.) Fail loudly instead of recording a poison value. + if (!std::isfinite(value)) + throw std::runtime_error("dimension must be finite — NaN or infinity is not a dimension"); + switch (kind) { + case DesignSketchTool::DimType::Length: + case DesignSketchTool::DimType::Radius: + case DesignSketchTool::DimType::Diameter: + if (value <= 0.0) + throw std::runtime_error(std::string(kind_name) + " must be positive (got " + std::to_string(value) + ")"); + break; + case DesignSketchTool::DimType::Distance: + case DesignSketchTool::DimType::DistanceToLine: + if (value < 0.0) + throw std::runtime_error(std::string(kind_name) + " must be >= 0 (zero means coincident / on the line)"); + break; + case DesignSketchTool::DimType::Angle: + default: + break; // any finite angle is valid + } + + const double before = t.dimension_current(); + t.apply_dimension(value); + panel->mcp_viewport()->request_repaint(); + + json out{{"ok", true}, {"kind", kind_name}, {"before", before}, {"value", value}, + {"dof", t.dof()}, {"solve_ok", t.solve_ok()}}; + out.update(sketch_report(t)); + return out; +} + +// Verbs whose handler opens a MODAL dialog. This matters more than it looks: the socket thread +// posts the call to the wx main thread and waits 15 s on a future, so a verb that blocks that +// thread inside ShowModal() times the RPC out AND leaves the main thread blocked until a human +// dismisses the dialog — every later call then times out too, 15 s at a time. Which is exactly +// the trap for the deck user this surface exists for: one button press and the app is modal, +// waiting for a mouse they may not be reaching for. +// +// So run_verb NEVER dispatches inline. The list here is only so list_verbs can label the keys +// that will want a mouse; re-derive it with +// grep -n ShowModal src/slic3r/GUI/CAD/DesignPanel.cpp +// and map each handler back to its action string in DesignOffer.hpp. +bool verb_is_modal(const char* id) +{ + static const char* kModal[] = { "sk_text", "sk_svg", "colour" }; + for (const char* m : kModal) + if (std::strcmp(m, id) == 0) return true; + return false; +} + +// Why list_verbs exists: the deck profile in VSD_n1_streamcontroller is built by parsing +// DesignPanel's key tables out of the SOURCE, so a verb without a keyboard shortcut is invisible +// to it. Serving the whole offer table over the socket lets the profile be generated from the +// running app instead, and lets a deck key name a verb rather than spend a letter. +json action_list_verbs(DesignPanel* panel, const json& params) +{ + const int kind = panel->mcp_offer_selection_kind(); + const uint32_t bit = offer_bit(OfferSel(kind)); + const bool applicable_only = params.value("applicable_only", false); + const bool has_sketch_mode = params.contains("sketch_mode"); + const bool want_sketch_mode = has_sketch_mode && params["sketch_mode"].get(); + + json verbs = json::array(); + for (int i = 0; i < kOfferVerbCount; ++i) { + const OfferVerb& v = kOfferVerbs[i]; + const bool applies = (v.accepts & bit) != 0; + if (applicable_only && !applies) continue; + if (has_sketch_mode && v.sketch_mode != want_sketch_mode) continue; + // Verbs whose action is nullptr exist in the vocabulary but have no GUI path yet — report + // them with a null action rather than dropping them. + verbs.push_back(json{ + {"id", v.id}, + {"name", v.name}, + {"row", v.row}, + {"row_name", kOfferRowNames[v.row]}, + {"key", v.key ? json(v.key) : json(nullptr)}, + {"action", v.action ? json(v.action) : json(nullptr)}, + {"sketch_mode", v.sketch_mode}, + {"applies", applies}, + {"modal", verb_is_modal(v.id)}, // opening a dialog: this key will want a mouse + {"hint", v.hint ? json(v.hint) : json(nullptr)}, + }); + } + return json{{"ok", true}, {"selection_kind", kind}, {"count", verbs.size()}, + {"verbs", std::move(verbs)}}; +} + +json action_run_verb(DesignPanel* panel, const json& params) +{ + if (!params.contains("verb")) throw std::runtime_error("run_verb needs 'verb' (an offer verb id)"); + const std::string verb = params["verb"].get(); + + // Scan the offer table BEFORE dispatching so the failure message can tell an unknown id from + // one that exists in the vocabulary but has no GUI path (action == nullptr). + bool known = false; + bool has_action = false; + const char* action = nullptr; + for (int i = 0; i < kOfferVerbCount; ++i) { + if (std::strcmp(kOfferVerbs[i].id, verb.c_str()) == 0) { + known = true; + has_action = (kOfferVerbs[i].action != nullptr); + action = kOfferVerbs[i].action; + break; + } + } + + if (!known) throw std::runtime_error("run_verb: unknown verb '" + verb + "'"); + if (!has_action) throw std::runtime_error("run_verb: '" + verb + "' has no GUI path yet"); + + // Whether the verb would be OFFERED for the current selection. Not a refusal: the GUI lets + // you press a tool's shortcut whatever is selected, and refusing here would make the socket + // stricter than the keyboard for no reason. Reported so a caller can tell "did nothing + // because it did not apply" from "did nothing because it is broken". + const int kind = panel->mcp_offer_selection_kind(); + const uint32_t bit = offer_bit(OfferSel(kind)); + bool applies = false; + for (int i = 0; i < kOfferVerbCount; ++i) + if (std::strcmp(kOfferVerbs[i].id, verb.c_str()) == 0) { applies = (kOfferVerbs[i].accepts & bit) != 0; break; } + + // The rule is not "validate more", it is "the socket should offer exactly what the GUI + // offers, no more and no less". A "btn:"/"fly:" verb is reachable in the GUI ONLY through + // the offer menu, which GREYS its row when it does not apply to the current selection — so + // a socket caller must not be able to fire it either. But a "key:" verb is reachable from + // the KEYBOARD whatever is selected, and the app permits that, so refusing it would make + // the socket stricter than the keyboard for no reason. Refuse only the menu-only verbs. + if (!applies && action && + (std::strncmp(action, "btn:", 4) == 0 || std::strncmp(action, "fly:", 4) == 0)) { + throw std::runtime_error("run_verb: '" + verb + + "' does not apply to the current selection (selection_kind " + + std::to_string(kind) + ")"); + } + + // Dispatch on the NEXT turn of the event loop, never inline. See verb_is_modal above: a verb + // that opens a dialog would otherwise block the thread this call is running on. Deferring + // costs the ability to report the verb's outcome — which run_offer_action never returned + // anyway — and buys a socket that cannot be wedged by any verb in the table. + wxGetApp().CallAfter([panel, verb]() { panel->mcp_run_verb(verb.c_str()); }); + + return json{{"ok", true}, {"verb", verb}, {"dispatched", true}, + {"applies", applies}, {"modal", verb_is_modal(verb.c_str())}, + {"selection_kind", kind}}; +} + +json action_mirror(DesignPanel* panel, const json& params) +{ + std::string m_str = params.value("mode", std::string("new")); + BooleanMode m = (m_str == "add") ? BooleanMode::Add : BooleanMode::New; + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to mirror"); + int bi = target_body_arg(params, doc); + bool keep = params.value("keep_original", true); + doc.checkpoint(); + int idx = doc.add_mirror(plane_from(params, doc), bi, m, "Mirror"); + doc.features[idx].mirror_keep_original = keep; + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"mirror_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_transform(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to transform"); + int bi = target_body_arg(params, doc); + Vec3d translate(params.value("dx", 0.0), params.value("dy", 0.0), params.value("dz", 0.0)); + Vec3d axis(params.value("axis_x", 0.0), params.value("axis_y", 0.0), params.value("axis_z", 1.0)); + Vec3d pivot(params.value("pivot_x", 0.0), params.value("pivot_y", 0.0), params.value("pivot_z", 0.0)); + double angle = params.value("angle", 0.0); + bool copy = params.value("copy", false); + doc.checkpoint(); + int idx = doc.add_transform(bi, translate, axis, pivot, angle, copy, "Transform"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"transform_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_thicken(DesignPanel* panel, const json& params) +{ + if (!params.contains("face")) throw std::runtime_error("thicken needs 'face' (id from query_topology)"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to thicken"); + int bi = target_body_arg(params, doc); + int face = params["face"].get(); + double thickness = params.value("thickness", 2.0); + bool flip = params.value("flip", false); + doc.checkpoint(); + int idx = doc.add_thicken(bi, face, thickness, flip, "Thicken"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"thicken_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_split(DesignPanel* panel, const json& params) +{ + if (!params.contains("face")) throw std::runtime_error("split needs 'face' (id from query_topology)"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to split"); + int bi = target_body_arg(params, doc); + int face_body = params.value("face_body", -1); + int face = params["face"].get(); + bool keep_upper = params.value("keep_upper", true); + bool keep_lower = params.value("keep_lower", true); + doc.checkpoint(); + int idx = doc.add_split_by_face(bi, face_body, face, keep_upper, keep_lower, "Split"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"split_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_project(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no source body to project from"); + int source_body = params.value("source_body", -1); + int face = params.value("face", -1); + std::vector edges; + if (params.contains("edges") && params["edges"].is_array()) + for (const auto& v : params["edges"]) edges.push_back(v.get()); + SketchPlane pl = plane_from(params, doc); + doc.checkpoint(); + int idx = doc.add_project_edges(source_body, edges, face, pl, "Project"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"project_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_delete_face(DesignPanel* panel, const json& params) +{ + if (!params.contains("faces")) throw std::runtime_error("delete_face needs 'faces' (array of face ids)"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to delete faces from"); + int bi = target_body_arg(params, doc); + std::vector faces; + if (params["faces"].is_array()) + for (const auto& v : params["faces"]) faces.push_back(v.get()); + doc.checkpoint(); + int idx = doc.add_delete_face(bi, faces, "DeleteFace"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_bridge(DesignPanel* panel, const json& params) +{ + if (!params.contains("sketch")) throw std::runtime_error("bridge needs 'sketch' (feature index)"); + if (!params.contains("ent_a")) throw std::runtime_error("bridge needs 'ent_a' (entity index)"); + if (!params.contains("ent_b")) throw std::runtime_error("bridge needs 'ent_b' (entity index)"); + int sketch = params["sketch"].get(); + int ent_a = params["ent_a"].get(); + int ent_b = params["ent_b"].get(); + int end_a = params.value("end_a", 1); + int end_b = params.value("end_b", 0); + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + int ei = doc.add_bridge(sketch, ent_a, end_a, ent_b, end_b, "Bridge"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"sketch_index", sketch}, {"entity_index", ei}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_axis(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + std::string t = params.value("type", std::string("two_points")); + AxisType at = AxisType::TwoPoints; + if (t == "face_normal") at = AxisType::FaceNormal; + else if (t == "cylinder") at = AxisType::CylinderCenterline; + else if (t == "plane_intersection") at = AxisType::PlaneIntersection; + else if (t == "along_edge") at = AxisType::AlongEdge; + doc.checkpoint(); + int idx = doc.add_axis(at, "Axis"); + CadFeature& f = doc.features[idx]; + if (params.contains("p1") && params["p1"].is_array() && params["p1"].size() >= 3) + f.axis_p1 = Vec3d(params["p1"][0].get(), params["p1"][1].get(), params["p1"][2].get()); + if (params.contains("p2") && params["p2"].is_array() && params["p2"].size() >= 3) + f.axis_p2 = Vec3d(params["p2"][0].get(), params["p2"][1].get(), params["p2"][2].get()); + f.axis_body = params.value("body", -1); + f.axis_face = params.value("face", -1); + f.axis_edge = params.value("edge", -1); + f.axis_plane_a = params.value("plane_a", -1); + f.axis_plane_b = params.value("plane_b", -1); + // Datum features don't produce a body; check for an error returned by resolve. + bool recompute_ok = doc.recompute(); + auto axes = doc.resolve_datum_axes(); + std::string err = doc.error; + if (recompute_ok && err.empty() && !axes.empty() && !axes.back().error.empty()) + err = axes.back().error; + panel->mcp_after_change(); + return json{{"ok", true}, {"axis_index", idx}, {"error", err}}; +} + +json action_coordsys(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + std::string t = params.value("type", std::string("point_world")); + CoordSysType ct = CoordSysType::PointWorld; + if (t == "face_and_direction") ct = CoordSysType::FaceAndDirection; + Vec3d pt(0, 0, 0); + if (params.contains("point") && params["point"].is_array() && params["point"].size() >= 3) + pt = Vec3d(params["point"][0].get(), params["point"][1].get(), params["point"][2].get()); + doc.checkpoint(); + int idx = doc.add_coordsys(ct, pt, "CoordSys"); + CadFeature& f = doc.features[idx]; + f.coordsys_body = params.value("body", -1); + f.coordsys_face = params.value("face", -1); + f.coordsys_edge = params.value("edge", -1); + if (params.contains("x_hint") && params["x_hint"].is_array() && params["x_hint"].size() >= 3) + f.coordsys_x_hint = Vec3d(params["x_hint"][0].get(), params["x_hint"][1].get(), params["x_hint"][2].get()); + bool recompute_ok = doc.recompute(); + auto css = doc.resolve_datum_coordsys(); + std::string err = doc.error; + if (recompute_ok && err.empty() && !css.empty() && !css.back().error.empty()) + err = css.back().error; + panel->mcp_after_change(); + return json{{"ok", true}, {"coordsys_index", idx}, {"error", err}}; +} + +json action_helix(DesignPanel* panel, const json& params) +{ + const double radius = params.value("radius", 10.0); + const double pitch = params.value("pitch", 5.0); + const double height = params.value("height", 20.0); + const bool left_handed = params.value("left_handed", false); + const double taper = params.value("taper_deg", 0.0); + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + int idx = doc.add_helix(plane_from(params, doc), radius, pitch, height, left_handed, taper, "Helix"); + panel->mcp_after_change(); + return json{{"ok", true}, {"helix_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_mate(DesignPanel* panel, const json& params) +{ + if (!params.contains("cs_a")) throw std::runtime_error("mate needs 'cs_a' (CoordSys feature index)"); + if (!params.contains("cs_b")) throw std::runtime_error("mate needs 'cs_b' (CoordSys feature index)"); + const int kind = params.value("kind", 0); + const int cs_a = params["cs_a"].get(); + const int cs_b = params["cs_b"].get(); + const double offset = params.value("offset", 0.0); + const double angle = params.value("angle", 0.0); + const bool flip = params.value("flip", false); + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + int idx = doc.add_mate(kind, cs_a, cs_b, offset, angle, flip, "Mate"); + bool ok = doc.recompute(); + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"mate_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +json action_check_interference(DesignPanel* panel, const json& params) +{ + const double min_volume = params.value("min_volume", 1e-6); + CadDocument& doc = panel->mcp_doc(); + // Read-only: no checkpoint(), no recompute(), no mcp_after_change(). + const auto hits = doc.check_interference(min_volume); + json arr = json::array(); + for (const auto& h : hits) { + arr.push_back(json{{"body_a", h.body_a}, {"body_b", h.body_b}, {"volume", h.volume}}); + } + return json{{"ok", true}, {"count", int(hits.size())}, {"interferences", arr}}; +} + +json action_set_variable(DesignPanel* panel, const json& params) +{ + if (!params.contains("name")) throw std::runtime_error("set_variable needs 'name'"); + if (!params.contains("expr")) throw std::runtime_error("set_variable needs 'expr'"); + CadDocument& doc = panel->mcp_doc(); + std::string name = params["name"].get(); + std::string expr = params["expr"].get(); + std::string old = doc.variables.count(name) ? doc.variables[name] : ""; + doc.checkpoint(); + doc.variables[name] = expr; + bool ok = doc.recompute(); + // undo() recomputes, which succeeds and clears doc.error; carry the reason across so + // the JSON reply below reports why the edit was rejected instead of an empty string. + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"name", name}, {"error", doc.error}}; +} + +json action_set_feature_expr(DesignPanel* panel, const json& params) +{ + if (!params.contains("feature")) throw std::runtime_error("set_feature_expr needs 'feature' index"); + if (!params.contains("field")) throw std::runtime_error("set_feature_expr needs 'field' name"); + if (!params.contains("expr")) throw std::runtime_error("set_feature_expr needs 'expr' string"); + CadDocument& doc = panel->mcp_doc(); + int fi = params["feature"].get(); + if (fi < 0 || fi >= (int)doc.features.size()) + return json{{"ok", false}, {"error", "feature index out of range"}}; + std::string field = params["field"].get(); + std::string expr = params["expr"].get(); + std::string old = doc.features[fi].expr.count(field) ? doc.features[fi].expr[field] : ""; + doc.checkpoint(); + doc.features[fi].expr[field] = expr; + bool ok = doc.recompute(); + // undo() recomputes, which succeeds and clears doc.error; carry the reason across so + // the JSON reply below reports why the edit was rejected instead of an empty string. + if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } + panel->mcp_after_change(); + return json{{"ok", ok}, {"feature", fi}, {"field", field}, {"error", doc.error}}; +} + +// Dispatch one parsed request ON THE MAIN THREAD. Returns a JSON-RPC reply string. +std::string handle_on_main(const std::string& method, const json& params, const json& id) +{ + MainFrame* mf = wxGetApp().mainframe; + // The panel is built on first use, and in a headless session nobody clicks the tab that + // would build it -- so build it here rather than refusing. Safe: this runs on the main + // thread (see the CallAfter that dispatches us). + DesignPanel* panel = mf ? mf->ensure_design_panel() : nullptr; + if (!panel) + return rpc_error(id, -32001, "Design panel not ready"); + + // Stale-id guard, checked here rather than in each handler. + // + // Global face and edge ids are indices into TopExp::MapShapes, valid only against the + // topology that produced them. Every dress-up rewrites those maps, so the natural way to + // drive this socket — read the scene once, then issue several operations — silently + // addresses the WRONG edge on every call after the first. Measured on a box: four chamfers + // with ids re-read each time remove 0.400/0.397/0.397/0.395 mm3; the same four with ids + // captured up front remove 0.400/0.008/0.397/0.280. Neither run errors, because a stale id + // still resolves to a real edge — just not the one that was asked for. + // + // So a caller may pass back the "generation" it got from describe_scene or query_topology, + // and a mismatch is refused instead of silently obeyed. Optional by design: omitting it + // keeps every existing script working exactly as before, and supplying it is what buys the + // guarantee. One check at the dispatcher rather than one per handler, so a method added + // later cannot forget it. + // + // The type check is not decoration: this runs OUTSIDE the try below, and a bare + // get() on `"generation": "x"` throws nlohmann::type_error straight out of + // the CallAfter lambda that invoked us — through a wx event loop, which does not catch, + // so the whole GUI went down on one malformed line. Refuse it as a parameter error. + if (params.is_object() && params.contains("generation")) { + if (!params["generation"].is_number_unsigned()) + return rpc_error(id, -32602, "generation must be an unsigned integer"); + const uint64_t want = params["generation"].get(); + const uint64_t have = panel->mcp_doc().topo_generation; + if (want != have) + return rpc_error(id, -32010, + "stale face/edge ids: they were read at generation " + std::to_string(want) + + " but the model is now at " + std::to_string(have) + + ". Re-read query_topology and use the new ids — the old ones still name real " + "edges, just not the ones you measured."); + } + + try { + if (method == "describe_tools") return rpc_result(id, describe_tools()); + if (method == "describe_scene") return rpc_result(id, describe_scene(panel)); + if (method == "query_topology") return rpc_result(id, query_topology(panel, params)); + if (method == "measure") return rpc_result(id, measure(panel, params)); + if (method == "mass_properties") return rpc_result(id, mass_properties(panel, params)); + if (method == "slice_body") return rpc_result(id, slice_body(panel, params)); + if (method == "import_step") return rpc_result(id, import_step(panel, params)); + if (method == "import_mesh") return rpc_result(id, import_mesh(panel, params)); + if (method == "validate_against") return rpc_result(id, validate_against(panel, params)); + if (method == "extrude") return rpc_result(id, action_extrude(panel, params)); + if (method == "revolve") return rpc_result(id, action_revolve(panel, params)); + if (method == "fillet") return rpc_result(id, action_fillet(panel, params)); + if (method == "chamfer") return rpc_result(id, action_chamfer(panel, params)); + if (method == "hole") return rpc_result(id, action_hole(panel, params)); + if (method == "hole_styled") return rpc_result(id, action_hole_styled(panel, params)); + if (method == "hole_standard") return rpc_result(id, action_hole_standard(panel, params)); + if (method == "boolean") return rpc_result(id, action_boolean(panel, params)); + if (method == "pattern") return rpc_result(id, action_pattern(panel, params)); + if (method == "pattern_on_curve") return rpc_result(id, action_pattern_on_curve(panel, params)); + if (method == "shell") return rpc_result(id, action_shell(panel, params)); + if (method == "rib") return rpc_result(id, action_rib(panel, params)); + if (method == "draft") return rpc_result(id, action_draft(panel, params)); + if (method == "mirror") return rpc_result(id, action_mirror(panel, params)); + if (method == "sketch_begin") return rpc_result(id, action_sketch_begin(panel, params)); + if (method == "sketch_commit") return rpc_result(id, action_sketch_commit(panel, params)); + if (method == "sketch_cancel") return rpc_result(id, action_sketch_cancel(panel, params)); + if (method == "sketch_add") return rpc_result(id, action_sketch_add(panel, params)); + if (method == "sketch_select") return rpc_result(id, action_sketch_select(panel, params)); + if (method == "sketch_delete") return rpc_result(id, action_sketch_delete(panel, params)); + if (method == "sketch_construction") return rpc_result(id, action_sketch_construction(panel, params)); + if (method == "sketch_offset") return rpc_result(id, action_sketch_offset(panel, params)); + if (method == "sketch_mirror") return rpc_result(id, action_sketch_mirror(panel, params)); + if (method == "sketch_describe") return rpc_result(id, action_sketch_describe(panel, params)); + if (method == "sketch_validate") return rpc_result(id, action_sketch_validate(panel, params)); + if (method == "sketch_heal") return rpc_result(id, action_sketch_heal(panel, params)); + if (method == "sketch_set_value") return rpc_result(id, action_sketch_set_value(panel, params)); + if (method == "list_verbs") return rpc_result(id, action_list_verbs(panel, params)); + if (method == "run_verb") return rpc_result(id, action_run_verb(panel, params)); + if (method == "transform") return rpc_result(id, action_transform(panel, params)); + if (method == "thicken") return rpc_result(id, action_thicken(panel, params)); + if (method == "split") return rpc_result(id, action_split(panel, params)); + if (method == "project") return rpc_result(id, action_project(panel, params)); + if (method == "delete_face") return rpc_result(id, action_delete_face(panel, params)); + if (method == "bridge") return rpc_result(id, action_bridge(panel, params)); + if (method == "axis") return rpc_result(id, action_axis(panel, params)); + if (method == "coordsys") return rpc_result(id, action_coordsys(panel, params)); + if (method == "helix") return rpc_result(id, action_helix(panel, params)); + if (method == "set_variable") return rpc_result(id, action_set_variable(panel, params)); + if (method == "set_feature_expr") return rpc_result(id, action_set_feature_expr(panel, params)); + if (method == "surface_extrude") return rpc_result(id, action_surface_extrude(panel, params)); + if (method == "surface_revolve") return rpc_result(id, action_surface_revolve(panel, params)); + if (method == "thicken_surface") return rpc_result(id, action_thicken_surface(panel, params)); + if (method == "surface_offset") return rpc_result(id, action_surface_offset(panel, params)); + if (method == "surface_loft") return rpc_result(id, action_surface_loft(panel, params)); + if (method == "surface_fill") return rpc_result(id, action_surface_fill(panel, params)); + if (method == "mate") return rpc_result(id, action_mate(panel, params)); + if (method == "check_interference") return rpc_result(id, action_check_interference(panel, params)); + return rpc_error(id, -32601, "Unknown method: " + method); + } catch (const Standard_Failure& ex) { // OCCT errors are NOT std::exception + return rpc_error(id, -32000, std::string("OCCT: ") + (ex.GetMessageString() ? ex.GetMessageString() : "failure")); + } catch (const std::exception& ex) { + return rpc_error(id, -32000, ex.what()); + } +} + +// Marshal a request to the main thread and block (with a timeout) for the reply. +std::string dispatch_request(const std::string& line) +{ + json req; + try { req = json::parse(line); } + catch (const std::exception& ex) { return rpc_error(nullptr, -32700, std::string("parse error: ") + ex.what()); } + + json id = req.contains("id") ? req["id"] : json(nullptr); + std::string method = req.value("method", std::string()); + json params = req.contains("params") ? req["params"] : json::object(); + if (method.empty()) return rpc_error(id, -32600, "missing method"); + + auto prom = std::make_shared>(); + auto fut = prom->get_future(); + // Nothing may escape this lambda. It is invoked by the wx event loop, which has no + // handler of its own, so an escaping exception is std::terminate — the socket would + // become a way for any client to kill the application. handle_on_main() catches what + // it knows about; this catches what it does not, and still answers the caller. + wxGetApp().CallAfter([prom, method, params, id]() { + try { + prom->set_value(handle_on_main(method, params, id)); + } catch (const std::exception& ex) { + prom->set_value(rpc_error(id, -32000, std::string("internal error: ") + ex.what())); + } catch (...) { + prom->set_value(rpc_error(id, -32000, "internal error: unknown exception")); + } + }); + if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) + return rpc_error(id, -32000, "main-thread timeout"); + return fut.get(); +} + +// Read newline-delimited requests off one client connection until EOF. +void serve_client(int cfd) +{ + std::string buf; + char chunk[4096]; + for (;;) { + ssize_t n = ::read(cfd, chunk, sizeof(chunk)); + if (n <= 0) break; + buf.append(chunk, size_t(n)); + size_t nl; + while ((nl = buf.find('\n')) != std::string::npos) { + std::string line = buf.substr(0, nl); + buf.erase(0, nl + 1); + if (line.empty()) continue; + std::string reply = dispatch_request(line); + reply.push_back('\n'); + // Never a bare write(): a client that hangs up between its request and our + // reply raises SIGPIPE, whose default action kills the process — so closing + // a socket mid-call would take the GUI with it. +#ifdef MSG_NOSIGNAL + if (::send(cfd, reply.data(), reply.size(), MSG_NOSIGNAL) < 0) return; +#else + if (::write(cfd, reply.data(), reply.size()) < 0) return; // SO_NOSIGPIPE set at accept +#endif + } + } +} + +void server_thread(std::string sock_path) +{ + ::unlink(sock_path.c_str()); + int sfd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (sfd < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: socket() failed"; return; } + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, sock_path.c_str(), sizeof(addr.sun_path) - 1); + // The socket is the full CAD command surface, including import_step on absolute paths. + // It lands in a world-writable directory by default (/tmp), so its access control is + // its file mode and nothing else — leaving that to the ambient umask means any local + // process may drive the modeller. umask around bind() makes it 0600 with no window in + // which a wider mode exists; the chmod afterwards covers platforms that do not apply + // umask to sockets. + const mode_t old_umask = ::umask(0177); + const int bind_rc = ::bind(sfd, reinterpret_cast(&addr), sizeof(addr)); + ::umask(old_umask); + if (bind_rc < 0) { + BOOST_LOG_TRIVIAL(error) << "MCP: bind() failed on " << sock_path; + ::close(sfd); return; + } + if (::chmod(sock_path.c_str(), S_IRUSR | S_IWUSR) < 0) { + BOOST_LOG_TRIVIAL(error) << "MCP: cannot restrict " << sock_path << " to the owner; refusing to listen"; + ::close(sfd); ::unlink(sock_path.c_str()); return; + } + if (::listen(sfd, 1) < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: listen() failed"; ::close(sfd); return; } + BOOST_LOG_TRIVIAL(info) << "MCP control listening on " << sock_path; + + for (;;) { + int cfd = ::accept(sfd, nullptr, nullptr); + if (cfd < 0) continue; +#if !defined(MSG_NOSIGNAL) && defined(SO_NOSIGPIPE) + const int on = 1; // macOS/BSD equivalent of MSG_NOSIGNAL + ::setsockopt(cfd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#endif + serve_client(cfd); + ::close(cfd); + } +} + +} // namespace + +void start_mcp_control_if_enabled() +{ + const char* env = std::getenv("ORCA_CAD_MCP"); + if (!env || !*env) return; + std::string path = (std::strcmp(env, "1") == 0) ? "/tmp/orca-cad-mcp.sock" : env; + static bool started = false; + if (started) return; + started = true; + std::thread(server_thread, path).detach(); +} + +}} // namespace Slic3r::GUI + +#else // _WIN32 + +namespace Slic3r { namespace GUI { +void start_mcp_control_if_enabled() {} // ponytail: no Windows transport yet +}} + +#endif diff --git a/src/slic3r/GUI/CAD/McpControl.hpp b/src/slic3r/GUI/CAD/McpControl.hpp new file mode 100644 index 0000000000..12e3289eb5 --- /dev/null +++ b/src/slic3r/GUI/CAD/McpControl.hpp @@ -0,0 +1,24 @@ +#ifndef slic3r_GUI_McpControl_hpp_ +#define slic3r_GUI_McpControl_hpp_ + +// MCP control surface (slice 1): a local JSON-RPC 2.0 server, line-delimited over a +// Unix domain socket, that lets an external MCP bridge drive and perceive the Design +// tab. Off unless the env var ORCA_CAD_MCP is set: +// ORCA_CAD_MCP=1 -> socket at /tmp/orca-cad-mcp.sock +// ORCA_CAD_MCP=/path/to.sock -> socket at that path +// All CAD work is marshalled onto the wx main thread and runs through the SAME +// CadDocument kernel the GUI uses (no parallel engine). Slice-1 methods: +// describe_tools, describe_scene, extrude. +// +// ponytail: Unix-socket only (POSIX). Windows compiles this to a no-op; add a named +// pipe transport when a Windows agent actually needs it. + +namespace Slic3r { namespace GUI { + +// Start the server thread iff ORCA_CAD_MCP is set. Safe to call once after the +// MainFrame + DesignPanel exist. No-op when the env var is unset or on Windows. +void start_mcp_control_if_enabled(); + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_McpControl_hpp_ diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.cpp b/src/slic3r/GUI/CAD/SketchInlineEditor.cpp new file mode 100644 index 0000000000..9c438c7d06 --- /dev/null +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.cpp @@ -0,0 +1,203 @@ +#include "slic3r/GUI/CAD/SketchInlineEditor.hpp" + +#include "slic3r/GUI/ImGuiWrapper.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "libslic3r/Color.hpp" + +#include +#include // BringWindowToDisplayFront / GetCurrentWindow + +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +namespace { + +// Numbers are typed and shown with a POINT, whatever the locale: this field feeds a CAD kernel, +// and a decimal comma reaching it as a thousands separator is a silent order-of-magnitude error. +// Parsing accepts either separator because a keyboard's numeric pad may only offer one. +std::string fmt_value(double v, int digits = 2) +{ + char fmt[16]; + std::snprintf(fmt, sizeof(fmt), "%%.%df", digits); + char buf[64]; + std::snprintf(buf, sizeof(buf), fmt, v); + for (char* c = buf; *c; ++c) + if (*c == ',') *c = '.'; + return std::string(buf); +} + +bool parse_value(const char* text, double& out) +{ + if (text == nullptr) return false; + std::string t(text); + for (char& c : t) + if (c == ',') c = '.'; + // strtod, not std::stod: no exceptions, and `end` tells us whether the WHOLE field was a + // number. "12mm" must be refused, not silently read as 12. + const char* b = t.c_str(); + char* end = nullptr; + const double v = std::strtod(b, &end); + if (end == b) return false; + while (*end == ' ' || *end == '\t') ++end; + if (*end != '\0') return false; + out = v; + return true; +} + +// One machine-readable line per event of the click-edit contract, for the UX check that runs +// after every build (scripts/CAD/check-gui-click-edit.py). Deliberately NOT the same switch as +// ORCA_CAD_KEYTRACE: that one is a debugging firehose, this one is an assertion surface and its +// format is a contract the script parses. +// +// The pair that matters is `open` vs `commit`: the check always types a value DIFFERENT from the +// prefill, so a field that is on screen but not editable commits its prefill and the two lines +// disagree. A focus flag cannot show that — it read 0 even when typing worked — but the number +// the user actually gets can. +void ux_trace(const char* event, const std::string& title, const std::string& detail) +{ + if (!std::getenv("ORCA_CAD_UXTRACE")) return; + std::fprintf(stderr, "[UX] %s title=%s %s\n", event, title.c_str(), detail.c_str()); + std::fflush(stderr); +} + +} // namespace + +void SketchInlineEditor::open(const wxPoint& canvas_px, double value, const std::string& title, + std::function on_commit, + std::function on_cancel) +{ + m_anchor = canvas_px; + m_title = title; + m_err.clear(); + m_commit = std::move(on_commit); + m_cancel = std::move(on_cancel); + const std::string v = fmt_value(value); + std::snprintf(m_buf, sizeof(m_buf), "%s", v.c_str()); + m_open = true; + // ImGui takes keyboard focus for one frame on request; asking on the frame the field first + // appears is what makes typing land without a click. There is no window manager to consult. + m_focus_pending = true; + ux_trace("open", m_title, "prefill=" + v); +} + +void SketchInlineEditor::close() +{ + m_open = false; + m_focus_pending = false; + m_commit = nullptr; + m_cancel = nullptr; + m_err.clear(); +} + +void SketchInlineEditor::cancel() +{ + if (m_open) do_cancel(); +} + +void SketchInlineEditor::commit() +{ + if (m_open) do_commit(); +} + +void SketchInlineEditor::do_cancel() +{ + ux_trace("cancel", m_title, ""); + auto cb = m_cancel; + close(); + if (cb) cb(); +} + +void SketchInlineEditor::do_commit() +{ + double v = 0.0; + if (!parse_value(m_buf, v)) { + // Refusing input in silence is indistinguishable from the app having frozen: the field + // just sits there and the user has no idea what it wants. Say so in the title line and + // keep editing. + ux_trace("refused", m_title, std::string("typed=") + m_buf); + m_err = (m_buf[0] == '\0') ? into_u8(_L("Enter a number")) : into_u8(_L("Not a number")); + m_focus_pending = true; + return; + } + ux_trace("commit", m_title, std::string("typed=") + m_buf + " value=" + fmt_value(v, 4)); + auto cb = m_commit; + close(); + // AFTER close(): the callback may open the next queued dimension (a rectangle queues Width + // then Height), and doing that into a field that still believes it is open would drop the + // second one's prefill on the floor. + if (cb) cb(v); +} + +bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) +{ + if (!m_open) return false; + + ImGuiWrapper::push_common_window_style(scale); + imgui.set_next_window_pos((float) m_anchor.x, (float) m_anchor.y, ImGuiCond_Always, 0.5f, 0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f); + // NoInputs is what every other sketch overlay sets and is exactly what this one must not: + // it is the only overlay in the tab that the user types into. + imgui.begin(std::string("##sketchvalue"), + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration + | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); + ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow()); + + if (!m_title.empty() || !m_err.empty()) { + if (m_err.empty()) { + imgui.text(m_title); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, ImGuiWrapper::to_ImVec4(ColorRGBA(0.91f, 0.42f, 0.42f, 1.0f))); + imgui.text(m_err); + ImGui::PopStyleColor(); + } + } + + if (m_focus_pending) { + ImGui::SetKeyboardFocusHere(); + m_focus_pending = false; + } + ImGui::PushItemWidth(90.0f * scale); + // EnterReturnsTrue so Enter commits from inside the widget; AutoSelectAll so the prefill is + // replaced by the first digit typed, which is what "pre-selected" meant when this was a + // wxTextCtrl and is what makes typing a value a single gesture. + const bool entered = ImGui::InputText("##sketchvalue_in", m_buf, sizeof(m_buf), + ImGuiInputTextFlags_EnterReturnsTrue + | ImGuiInputTextFlags_AutoSelectAll + | ImGuiInputTextFlags_CharsDecimal); + // MEASUREMENT, not a fix: one line per frame saying whether ImGui believes it owns the + // keyboard and whether our widget is the active one. "Typing does not arrive" has two very + // different causes — no FRAMES (this canvas repaints on demand only, so an idle canvas never + // processes ImGui's queued characters) versus frames that run while the input is not active — + // and they are indistinguishable from outside. + if (std::getenv("ORCA_CAD_UXTRACE")) { + const ImGuiIO& io = ImGui::GetIO(); + std::fprintf(stderr, "[UX] frame title=%s want_text=%d want_kb=%d active=%d buf=%s\n", + m_title.c_str(), (int) io.WantTextInput, (int) io.WantCaptureKeyboard, + (int) ImGui::IsItemActive(), m_buf); + std::fflush(stderr); + } + ImGui::PopItemWidth(); + imgui.end(); + ImGui::PopStyleVar(); + ImGuiWrapper::pop_common_window_style(); + + // Keep the frames coming while the field is up — see request_frame's note in the header. + if (m_open && request_frame) + request_frame(); + + // Act AFTER end(): do_commit can reopen the field for the next queued dimension, and that + // must not happen inside this frame's window. + if (entered) + do_commit(); + else if (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_Escape))) + do_cancel(); + return true; +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp new file mode 100644 index 0000000000..162d157864 --- /dev/null +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp @@ -0,0 +1,95 @@ +#ifndef slic3r_SketchInlineEditor_hpp_ +#define slic3r_SketchInlineEditor_hpp_ + +#include +#include + +#include + +namespace Slic3r { +namespace GUI { + +class ImGuiWrapper; + +// Onshape-style in-canvas value editor. +// +// IT IS NOT A WINDOW. It used to be a borderless top-level wxFrame holding a wxTextCtrl, and +// that is the whole history of this file: a separate top-level window can only receive typing +// if the window manager grants it focus, and whether it does is not ours to decide. openbox +// grants it; mutter's focus-stealing prevention refuses it, so on a GNOME desktop the field +// appeared, showed its value selected, and silently ignored every keystroke — Enter then +// committed the number it opened with. Seven workarounds were tried against that (a real X11 +// server timestamp for gtk_window_present, re-asserted SetFocus, dropping the _UTILITY hint, +// keeping the frame mapped between two queued fields, forwarding keys from the panel's +// CHAR_HOOK), one of them caused a macOS regression, and the test harness ended up clicking the +// field before typing — which is the workaround a user cannot be asked to perform, and is +// exactly the "label value not editable" report. +// +// So the field stops asking. It is now drawn INSIDE the GL canvas as an ImGui overlay, at the +// same screen point as before, and its keys arrive through the canvas's own key events, which +// GLCanvas3D already feeds to ImGui (see GLCanvas3D::on_key / on_char -> update_key_data). The +// canvas is part of the main window and already has focus, so there is no second window, no +// second focus, and no window manager in the path. The dimension labels next to it are already +// ImGui overlays (DesignSketchTool::draw_dim_label), so this is the same vocabulary, not a new +// one. +// +// Ownership: DesignCanvas owns it; DesignSketchTool::render() calls render() once per frame. +class SketchInlineEditor +{ +public: + SketchInlineEditor() = default; + + // Open the field anchored at `canvas_px` (canvas DEVICE pixels, the coordinate space the + // sketch tool works in), pre-filled with `value` and pre-selected. on_commit(parsed) fires + // on Enter with a valid number; on_cancel() on Esc. + void open(const wxPoint& canvas_px, double value, const std::string& title, + std::function on_commit, + std::function on_cancel); + void close(); // drop it with neither callback + void cancel(); // if open, run the registered cancel (keep-as-drawn) + void commit(); // if open, run the registered commit (accept the typed value) + bool is_open() const { return m_open; } + + // Draw it, and let ImGui do the editing. Called from DesignSketchTool::render() inside the + // frame's ImGui pass; `scale` is the tool's m_render_scale. Returns true if it drew. + bool render(ImGuiWrapper& imgui, float scale); + + // Ask for another frame. THE FIELD DOES NOT WORK WITHOUT THIS, and the reason is a deadlock + // that only a per-frame trace shows: + // + // [UX] frame want_text=0 want_kb=0 active=0 <- frame 1: the widget is not active yet + // [UX] frame want_text=0 want_kb=0 active=1 <- frame 2: it is now + // (nothing further) <- the canvas has nothing to redraw, so it stops + // + // This canvas repaints ON DEMAND. ImGui decides whether it wants the keyboard at the END of a + // frame, from the active item, and GLCanvas3D::on_char only calls render() when + // update_key_data() says ImGui wants it. No frames -> WantTextInput never turns on -> no + // render on a keystroke -> still no frames. The characters sit in ImGui's queue and the field + // looks exactly as deaf as the window it replaced. One repaint per frame while it is open + // breaks the circle. + std::function request_frame; + + // Kept because callers ask them, but there is no longer any difference to report: with no + // window there is no state where the field is on screen but logically closed, and no state + // where it is open but somebody else holds the keyboard. + bool is_mapped() const { return m_open; } + bool has_focus() const { return m_open; } + void dismiss() { close(); } + +private: + void do_commit(); + void do_cancel(); + + std::function m_commit; + std::function m_cancel; + bool m_open{false}; + bool m_focus_pending{false}; // one frame of SetKeyboardFocusHere after opening + wxPoint m_anchor{0, 0}; // canvas device px + std::string m_title; + std::string m_err; // why the last value was refused, shown in the title line + char m_buf[64]{}; // the edited text; ImGui::InputText writes into it +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_SketchInlineEditor_hpp_ diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 83c8cba0ee..ca22df3237 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1,5 +1,8 @@ #include "libslic3r/libslic3r.h" #include "GLCanvas3D.hpp" +#ifdef SLIC3R_CAD +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" // Design tab: interactive 2D sketch tool +#endif #include @@ -1827,6 +1830,16 @@ void GLCanvas3D::enable_separator_toolbar(bool enable) m_separator_toolbar.set_enabled(enable); } +void GLCanvas3D::enable_collapse_toolbar(bool enable) +{ + m_collapse_toolbar_enabled = enable; +} + +void GLCanvas3D::enable_plate_chrome(bool enable) +{ + m_plate_chrome_enabled = enable; +} + bool GLCanvas3D::has_mouse_capture() const { return m_canvas != nullptr && m_canvas->HasCapture(); } @@ -2048,14 +2061,24 @@ void GLCanvas3D::render(bool only_init) no_partplate = true; else if (gizmo_type == GLGizmosManager::BrimEars && !camera.is_looking_downward()) show_grid = false; + if (m_axes_at_bed_center) + // Design tab: the plate grid is generated from the plate's front-left corner, so it + // floats mid-cell under the modeling-origin triad. Suppress it here; a CAD grid centred + // on the origin is rendered in its place (see _render_cad_grid). + show_grid = false; /* view3D render*/ int hover_id = (m_hover_plate_idxs.size() > 0)?m_hover_plate_idxs.front():-1; if (m_canvas_type == ECanvasType::CanvasView3D) { - if (!no_partplate) + // m_show_bed gates the plate list too: hiding the bed but leaving its grid and outline + // floating would read as a rendering fault rather than a deliberate view option. + if (!no_partplate && m_show_bed) _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), m_show_world_axes); - if (!no_partplate) //BBS: add outline logic + if (!no_partplate && m_show_bed) //BBS: add outline logic _render_platelist(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), only_current, only_body, hover_id, true, show_grid); + if (m_axes_at_bed_center && m_show_bed && !no_partplate) + // Design tab: replace the plate's corner-origin grid with the origin-centred CAD grid. + _render_cad_grid(camera.get_view_matrix(), camera.get_projection_matrix()); //BBS: add outline logic // Depth pass for object-on-object and self shadows; consumed by the gouraud shader below. @@ -2121,6 +2144,13 @@ void GLCanvas3D::render(bool only_init) if (_is_fxaa_enabled()) _render_fxaa_pass(static_cast(cnv_size.get_width()), static_cast(cnv_size.get_height())); + // Design tab: interactive 2D sketch overlay, drawn over the scene but + // beneath the UI overlays (toolbars, labels). +#ifdef SLIC3R_CAD + if (m_design_sketch_tool != nullptr && m_design_sketch_tool->has_display()) + m_design_sketch_tool->render(*this); +#endif + // draw overlays _render_overlays(); @@ -3203,7 +3233,11 @@ void GLCanvas3D::on_idle(wxIdleEvent& evt) // BBS //m_dirty |= wxGetApp().plater()->get_view_toolbar().update_items_state(); m_dirty |= wxGetApp().plater()->get_collapse_toolbar().update_items_state(); - bool mouse3d_controller_applied = wxGetApp().plater()->get_mouse3d_controller().apply(wxGetApp().plater()->get_camera()); + // apply() DRAINS the 3D-mouse queue, so only the canvas actually on screen may call it: a + // hidden canvas renders nothing, so the motion it swallowed moves the shared camera without + // ever being drawn and the next visible frame jumps several states at once. + bool mouse3d_controller_applied = _is_shown_on_screen() + && wxGetApp().plater()->get_mouse3d_controller().apply(wxGetApp().plater()->get_camera()); m_dirty |= mouse3d_controller_applied; m_dirty |= wxGetApp().plater()->get_notification_manager()->update_notifications(*this); auto gizmo = wxGetApp().plater()->get_view3D_canvas3D()->get_gizmos_manager().get_current(); @@ -3271,6 +3305,64 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) return; } + // Design tab: Delete/Backspace removes the selected sketch entities while a + // sketch tool is active and the canvas has focus (dialog text fields are separate + // wx controls, so this never eats their editing keys). +#ifdef SLIC3R_CAD + if (m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() + && (keyCode == WXK_DELETE || keyCode == WXK_BACK) + && !m_design_sketch_tool->selection().empty()) { + m_design_sketch_tool->delete_selected(); + m_dirty = true; + render(); + return; + } +#endif + + // Esc exits the active sketch tool (Onshape-like, layered: abort in-progress entity -> + // drop to Select -> exit the session back to Feature mode). +#ifdef SLIC3R_CAD + if (m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() + && keyCode == WXK_ESCAPE) { + m_design_sketch_tool->request_exit(); + m_dirty = true; + render(); + return; + } +#endif + + // Design tab: Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y) undo/redo the Design feature + // history. Scoped by m_design_sketch_tool — only the Design canvas owns one — so the + // main 3D editor's undo/redo (the CanvasView3D-gated cases further below) is untouched. + // Handled here, before the generic Ctrl block, so it takes precedence and early-returns. +#ifdef SLIC3R_CAD + if (m_design_sketch_tool != nullptr && (evt.GetModifiers() & ctrlMask) != 0) { + const bool is_z = (keyCode == 'z' || keyCode == 'Z' || keyCode == WXK_CONTROL_Z); + const bool is_y = (keyCode == 'y' || keyCode == 'Y' || keyCode == WXK_CONTROL_Y); + if (is_z || is_y) { + const bool redo = is_y || ((evt.GetModifiers() & shiftMask) != 0); + m_design_sketch_tool->request_undo_redo(redo); + m_dirty = true; + render(); + return; + } + } +#endif + + // Design tab: F = Place on Face (Prepare's lay-flat), when the Design viewport is up + // and a body face is selected. The tool forwards to DesignPanel::place_on_face; it returns + // false (no face picked) so F falls through to the default handler below. +#ifdef SLIC3R_CAD + if (m_design_sketch_tool != nullptr && m_design_sketch_tool->has_display() + && (keyCode == 'f' || keyCode == 'F') && (evt.GetModifiers() & ctrlMask) == 0) { + if (m_design_sketch_tool->request_place_on_face()) { + m_dirty = true; + render(); + return; + } + } +#endif + bool is_in_painting_mode = false; GLGizmoPainterBase *current_gizmo_painter = dynamic_cast(get_gizmos_manager().get_current()); if (current_gizmo_painter != nullptr) { @@ -3648,6 +3740,20 @@ public: void GLCanvas3D::on_key(wxKeyEvent& evt) { + // Design tab: Delete/Backspace removes selected sketch entities. GTK delivers + // these as KEY_DOWN rather than CHAR, so handle it here too. +#ifdef SLIC3R_CAD + if (evt.GetEventType() == wxEVT_KEY_DOWN + && m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() + && (evt.GetKeyCode() == WXK_DELETE || evt.GetKeyCode() == WXK_BACK) + && !m_design_sketch_tool->selection().empty()) { + m_design_sketch_tool->delete_selected(); + m_dirty = true; + render(); + return; + } +#endif + static GLCanvas3D const * thiz = nullptr; static TranslationProcessor translationProcessor(nullptr, nullptr); if (thiz != this) { @@ -4212,6 +4318,23 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) return; } + // Design tab: the interactive sketch tool owns the mouse whenever it has + // something on screen — an active session OR committed sketch overlays that the user + // can click to select. It runs after ImGui (so dialogs still work) but before + // camera/toolbar/gizmo handling; on_mouse returns false for events it doesn't consume + // (drag/orbit/wheel) so the camera keeps working over the display-only plate. +#ifdef SLIC3R_CAD + if (m_design_sketch_tool != nullptr && m_design_sketch_tool->has_display()) { + if (evt.LeftDown() && m_canvas != nullptr) + m_canvas->SetFocus(); // grab keyboard focus so Delete/keys reach this canvas + if (m_design_sketch_tool->on_mouse(evt, *this)) { + m_dirty = true; + render(); // force an immediate redraw so the sketch overlay updates live + return; + } + } +#endif + #ifdef __WXMSW__ bool on_enter_workaround = false; if (! evt.Entering() && ! evt.Leaving() && m_mouse.position.x() == -1.0) { @@ -4923,6 +5046,8 @@ bool GLCanvas3D::is_camera_rotate(const wxMouseEvent& evt, const std::mapget_partplate_list().render(view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid); + wxGetApp().plater()->get_partplate_list().render(view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled); +} + +// Design tab: CAD grid on the bed plane, drawn in place of the plate's corner-origin grid. +// Generated from the bed centre (= modeling origin) so a grid line passes exactly through the +// triad in both axes. Minor lines every 10 mm, major every 50 mm; the two GLModels are built +// once and rebuilt only when the bed shape changes, not per frame. +void GLCanvas3D::_render_cad_grid(const Transform3d& view_matrix, const Transform3d& projection_matrix) +{ + const BuildVolume& build_volume = m_bed.build_volume(); + if (!build_volume.valid()) + return; + + const Vec2d center = build_volume.bed_center(); + const BoundingBoxf bb = build_volume.bounding_volume2d(); + if (!m_cad_grid_valid || m_cad_grid_center != center || m_cad_grid_bb != bb) { + m_cad_grid_center = center; + m_cad_grid_bb = bb; + m_cad_grid_valid = true; + + // Same z as PartPlate::GROUND_Z_GRIDLINE (-0.26f): just below the bed fill (GROUND_Z = + // -0.03f, which is drawn with the depth mask disabled) and above the physical bed model + // (offset z = -0.41), so the grid never z-fights the bed quad. Chosen by construction, + // not by magic number: it is the exact z the plate grid already uses on the shared bed. + const float z = -0.26f; + + auto build_grid = [&z, ¢er, &bb](double step, GLModel& model) { + std::vector> segs; + // Constant-x (vertical on screen) lines, both directions from the centre so the + // centre column itself is always present. Clipped to the bed bounding box so nothing + // spills past the bed quad. + for (double x = center.x(); x >= bb.min.x(); x -= step) + segs.emplace_back(Vec2d(x, bb.min.y()), Vec2d(x, bb.max.y())); + for (double x = center.x() + step; x <= bb.max.x(); x += step) + segs.emplace_back(Vec2d(x, bb.min.y()), Vec2d(x, bb.max.y())); + // Constant-y (horizontal on screen) lines, same centre-first convention. + for (double y = center.y(); y >= bb.min.y(); y -= step) + segs.emplace_back(Vec2d(bb.min.x(), y), Vec2d(bb.max.x(), y)); + for (double y = center.y() + step; y <= bb.max.y(); y += step) + segs.emplace_back(Vec2d(bb.min.x(), y), Vec2d(bb.max.x(), y)); + + GLModel::Geometry data; + data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + data.reserve_vertices(2 * segs.size()); + data.reserve_indices(2 * segs.size()); + for (const auto& s : segs) { + data.add_vertex(Vec3f(float(s.first.x()), float(s.first.y()), z)); + data.add_vertex(Vec3f(float(s.second.x()), float(s.second.y()), z)); + const unsigned int vc = static_cast(data.vertices_count()); + data.add_line(vc - 2, vc - 1); + } + model.init_from(std::move(data)); + }; + + m_cad_grid_minor.reset(); + m_cad_grid_major.reset(); + build_grid(10.0, m_cad_grid_minor); + build_grid(50.0, m_cad_grid_major); + } + + if (!m_cad_grid_minor.is_initialized() || !m_cad_grid_major.is_initialized()) + return; + + GLShaderProgram* shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + shader->start_using(); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + shader->set_uniform("view_model_matrix", view_matrix); + shader->set_uniform("projection_matrix", projection_matrix); + + // White every 5 cm, grey every 1 cm — the SAME in both themes, deliberately. There is no + // "white bed" to vanish against: the plate is dark grey either way, DEFAULT_MODEL_COLOR + // {0.326,0.337,0.337} on light and DEFAULT_MODEL_COLOR_DARK {0.255,0.255,0.283} on dark + // (3DBed.cpp:185-186), a difference of 0.07. A per-theme palette here would be a branch + // that buys nothing and one more thing to keep in step. + // + // For contrast with what this replaces: the plate's own grid uses LINE_TOP_DARK_COLOR, a + // 0.43 grey, for BOTH its thin and its bold family — which is most of why the stock grid + // reads as a flat mesh with no scale to it. + const ColorRGBA minor_color(0.40f, 0.40f, 0.42f, 1.0f); + const ColorRGBA major_color(0.90f, 0.90f, 0.90f, 1.0f); + + glsafe(::glLineWidth(1.0f)); + m_cad_grid_minor.set_color(minor_color); + m_cad_grid_minor.render(); + + glsafe(::glLineWidth(2.0f)); + m_cad_grid_major.set_color(major_color); + m_cad_grid_major.render(); + + glsafe(::glDisable(GL_BLEND)); } void GLCanvas3D::_render_shadows(const Transform3d& view_matrix, const Transform3d& projection_matrix) @@ -9704,6 +9931,9 @@ void GLCanvas3D::_render_separator_toolbar_left() const void GLCanvas3D::_render_collapse_toolbar() const { + if (!m_collapse_toolbar_enabled) + return; + auto& plater = *wxGetApp().plater(); const auto sidebar_docking_dir = plater.get_sidebar_docking_state(); if (sidebar_docking_dir == Sidebar::None) { diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 1ea352ac96..7c18fa3f64 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -57,6 +57,9 @@ namespace GUI { class Bed3D; class PartPlateList; +#ifdef SLIC3R_CAD +class DesignSketchTool; // Design tab: interactive 2D sketch tool +#endif #if ENABLE_RETINA_GL class RetinaHelper; @@ -543,6 +546,27 @@ private: mutable Vec2i32 m_canvas_toolbar_pos = {140, 5}; mutable float m_sc{1}; mutable float m_paint_toolbar_width; + bool m_collapse_toolbar_enabled{true}; + bool m_plate_chrome_enabled{true}; + // Design tab: render the world-axis triad at the bed centre (= modeling origin) instead of + // the bed corner. Default false preserves the main editor's corner triad. + bool m_axes_at_bed_center{false}; + // Design tab: draw the printer bed and its plate grid at all. Default true, so the + // main editor is untouched; the Design tab lets the user hide it to model without a bed. + bool m_show_bed{true}; + // Design tab: CAD grid drawn on the bed plane in place of the plate's corner-origin grid. + // Two GLModels (10 mm minor / 50 mm major) generated from the bed centre so a line passes + // exactly through the modeling origin; built once and rebuilt only when the bed shape changes. + GLModel m_cad_grid_minor; + GLModel m_cad_grid_major; + // Geometry the CAD grid models were last built from, so they are rebuilt on bed-shape change + // rather than every frame. + BoundingBoxf m_cad_grid_bb; + Vec2d m_cad_grid_center; + bool m_cad_grid_valid{false}; +#ifdef SLIC3R_CAD + DesignSketchTool* m_design_sketch_tool{nullptr}; +#endif //BBS: add canvas type for assemble view usage ECanvasType m_canvas_type; @@ -570,6 +594,10 @@ private: std::array m_old_size{ 0, 0 }; bool m_is_touchpad_navigation{ false }; + // CAD navigation (Design tab only): left-drag is a selection rubber band, so orbit moves + // to middle-drag and pan to right-drag — the Onshape/SolidWorks mapping. Off everywhere + // else, so Prepare/Preview keep the mouse the user already learned. + bool m_cad_navigation{ false }; // Screen is only refreshed from the OnIdle handler if it is dirty. bool m_dirty; @@ -883,6 +911,15 @@ public: void enable_assemble_view_toolbar(bool enable); void enable_return_toolbar(bool enable); void enable_separator_toolbar(bool enable); + void enable_collapse_toolbar(bool enable); + void enable_plate_chrome(bool enable); + void set_axes_at_bed_center(bool b) { m_axes_at_bed_center = b; } + void set_show_bed(bool b) { m_show_bed = b; } + bool get_show_bed() const { return m_show_bed; } +#ifdef SLIC3R_CAD + void set_design_sketch_tool(DesignSketchTool* tool) { m_design_sketch_tool = tool; } + DesignSketchTool* get_design_sketch_tool() const { return m_design_sketch_tool; } +#endif void enable_dynamic_background(bool enable) { m_dynamic_background_enabled = enable; } void enable_labels(bool enable) { m_labels.enable(enable); } void enable_slope(bool enable) { m_slope.enable(enable); } @@ -1052,6 +1089,7 @@ public: bool clicked_button_matches_action(const wxMouseEvent& evt, MouseAction action, const std::map& mappings) const; bool is_camera_rotate(const wxMouseEvent& evt, const std::map& mappings) const; bool is_camera_pan(const wxMouseEvent& evt, const std::map& mappings) const; + void set_cad_navigation(bool b) { m_cad_navigation = b; } Size get_canvas_size() const; Vec2d get_local_mouse_position() const; @@ -1255,6 +1293,10 @@ private: void _render_shadows(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add part plate related logic void _render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true); + // Design tab: draw the CAD grid (minor 10 mm + major 50 mm) in place of the plate's + // corner-origin grid when the axes sit at the bed centre (modeling origin). Rebuilds its + // GLModels lazily, only when the bed shape changed. + void _render_cad_grid(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add outline drawing logic void _render_objects(GLVolumeCollection::ERenderType type, bool with_outline = true); void _render_wireframe_overlay(); diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index f6f0b81c92..1b9b209ff8 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -350,6 +350,11 @@ public: int OnExit() override; bool initialized() const { return m_initialized; } inline bool is_enable_multi_machine() { return this->app_config&& this->app_config->get("enable_multi_machine") == "true"; } +#ifdef SLIC3R_CAD + inline bool is_enable_cad_feature() { return this->app_config && this->app_config->get_bool("enable_cad_feature"); } + inline bool is_auto_close_sketch_loops() { return !this->app_config + || this->app_config->get_bool("auto_close_sketch_loops"); } +#endif std::map test_url_state; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp new file mode 100644 index 0000000000..4e1a197a39 --- /dev/null +++ b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp @@ -0,0 +1,186 @@ +#include "GLGizmoPrimitive.hpp" +#include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/ImGuiWrapper.hpp" +#include "slic3r/GUI/GUI.hpp" +#include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/GUI/NotificationManager.hpp" +#include "libslic3r/Model.hpp" + +#ifndef IMGUI_DEFINE_MATH_OPERATORS +#define IMGUI_DEFINE_MATH_OPERATORS +#endif +#include + +namespace Slic3r { +namespace GUI { + +GLGizmoPrimitive::GLGizmoPrimitive(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id) + : GLGizmoBase(parent, icon_filename, sprite_id) {} + +bool GLGizmoPrimitive::on_init() { return true; } +std::string GLGizmoPrimitive::on_get_name() const { return _u8L("Primitive"); } +bool GLGizmoPrimitive::on_is_activable() const { return true; } +void GLGizmoPrimitive::on_render() {} +void GLGizmoPrimitive::on_set_state() +{ if (m_state == EState::On) { m_params = PrimitiveParams{}; m_preview_dirty = true; } } + +bool GLGizmoPrimitive::on_mouse(const wxMouseEvent&) { return false; } + +CommonGizmosDataID GLGizmoPrimitive::on_get_requirements() const +{ return CommonGizmosDataID(int(CommonGizmosDataID::SelectionInfo) | int(CommonGizmosDataID::InstancesHider)); } + +void GLGizmoPrimitive::on_load(cereal::BinaryInputArchive& ar) +{ ar(m_params); m_preview_dirty = true; } +void GLGizmoPrimitive::on_save(cereal::BinaryOutputArchive& ar) const +{ ar(m_params); } + +void GLGizmoPrimitive::apply_preset(const char*, double w, double h, double d) +{ + m_params.type = PrimitiveType::Box; + m_params.box_w = w; m_params.box_h = h; m_params.box_d = d; + m_preview_dirty = true; +} + +static void gen_mesh_and_add(PrimitiveParams& p, const char* snap_name) +{ + TopoDS_Solid solid = GeometryEngine::make_primitive(p); + TopoDS_Shape shape = solid; + if (p.dressup_enabled) { + if (p.dressup_type == DressUpType::Fillet) + shape = GeometryEngine::apply_fillet(shape, p.dressup_radius, p.dressup_faces); + else + shape = GeometryEngine::apply_chamfer(shape, p.dressup_chamfer_dist, p.dressup_faces); + } + TriangleMesh mesh = GeometryEngine::tessellate(shape, p.linear_deflection, p.angular_deflection); + if (mesh.its.indices.empty()) { + wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::WarningNotificationLevel, _u8L("Empty mesh generated")); + return; + } + wxGetApp().plater()->take_snapshot(snap_name); + ModelObject* mo = wxGetApp().model().add_object(); + std::string name = GeometryEngine::primitive_name(p.type); + if (p.dressup_enabled && p.dressup_type == DressUpType::Fillet) name += " (Fillet)"; + else if (p.dressup_enabled) name += " (Chamfer)"; + mo->name = name; + mo->add_volume(std::move(mesh))->set_new_unique_id(); + mo->ensure_on_bed(); + wxGetApp().plater()->update(); +} + +void GLGizmoPrimitive::apply_primitive() { gen_mesh_and_add(m_params, "Add Primitive"); } + +void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_limit) +{ + y = std::min(y, bottom_limit - ImGui::GetWindowHeight()); + const float scale = m_parent.get_scale(); + ImGuiWrapper::push_toolbar_style(scale); + GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 0.0f, 0.0f); + GizmoImguiBegin("Primitive", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove + | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse + | ImGuiWindowFlags_NoTitleBar); + + if (ImGui::CollapsingHeader("Shape", ImGuiTreeNodeFlags_DefaultOpen)) { + static const char* names[] = {"Box", "Cylinder", "Sphere", "Cone", "Torus"}; + int cur = (int)m_params.type; + if (ImGui::Combo("##type", &cur, names, (int)PrimitiveType::COUNT)) { + m_params.type = (PrimitiveType)cur; + m_preview_dirty = true; + } + ImGui::Text("Quick:"); + ImGui::SameLine(); + if (ImGui::SmallButton("10mm")) apply_preset("10mm cube", 10, 10, 10); + ImGui::SameLine(); + if (ImGui::SmallButton("20mm")) apply_preset("20mm cube", 20, 20, 20); + ImGui::SameLine(); + if (ImGui::SmallButton("50mm")) apply_preset("50mm cube", 50, 50, 50); + } + + ImGui::Separator(); + + if (ImGui::CollapsingHeader("Dimensions", ImGuiTreeNodeFlags_DefaultOpen)) { + auto dim = [&](const char* label, double& val, double step=0.5, double fast=5.0) { + ImGui::SetNextItemWidth(130); + if (ImGui::InputDouble(label, &val, step, fast, "%.1f mm")) m_preview_dirty = true; + if (val < 0.5) val = 0.5; + }; + switch (m_params.type) { + case PrimitiveType::Box: + dim("Width (X)", m_params.box_w); + dim("Depth (Y)", m_params.box_d); + dim("Height (Z)", m_params.box_h); + break; + case PrimitiveType::Cylinder: + dim("Radius", m_params.cyl_radius); + dim("Height", m_params.cyl_height); + break; + case PrimitiveType::Sphere: + dim("Radius", m_params.sph_radius); + break; + case PrimitiveType::Cone: + dim("Bottom R", m_params.cone_r1); + dim("Top R", m_params.cone_r2); + dim("Height", m_params.cone_height); + break; + case PrimitiveType::Torus: + dim("Major R", m_params.torus_r1); + dim("Minor R", m_params.torus_r2, 0.1, 1.0); + break; + default: break; + } + } + + ImGui::Separator(); + + if (ImGui::CollapsingHeader("Fillet / Chamfer")) { + ImGui::Checkbox("Enable", &m_params.dressup_enabled); + if (m_params.dressup_enabled) { + static const char* dn[] = {"Fillet", "Chamfer"}; + int du = (int)m_params.dressup_type; + ImGui::SetNextItemWidth(100); + if (ImGui::Combo("##dtype", &du, dn, 2)) { m_params.dressup_type = (DressUpType)du; m_preview_dirty = true; } + static const char* fn[] = {"All edges", "Top edges", "Bottom edges", "Lateral edges"}; + int fg = (int)m_params.dressup_faces; + ImGui::SetNextItemWidth(140); + if (ImGui::Combo("Edges", &fg, fn, 4)) { m_params.dressup_faces = (FaceGroup)fg; m_preview_dirty = true; } + if (m_params.dressup_type == DressUpType::Fillet) { + ImGui::SetNextItemWidth(100); + if (ImGui::InputDouble("Radius", &m_params.dressup_radius, 0.1, 1.0, "%.1f mm")) { + if (m_params.dressup_radius < 0.1) m_params.dressup_radius = 0.1; + m_preview_dirty = true; + } + } else { + ImGui::SetNextItemWidth(100); + if (ImGui::InputDouble("Distance", &m_params.dressup_chamfer_dist, 0.1, 1.0, "%.1f mm")) { + if (m_params.dressup_chamfer_dist < 0.1) m_params.dressup_chamfer_dist = 0.1; + m_preview_dirty = true; + } + } + } + } + + ImGui::Separator(); + + if (ImGui::CollapsingHeader("Quality")) { + ImGui::SetNextItemWidth(130); + if (ImGui::InputDouble("Mesh resolution", &m_params.linear_deflection, 0.001, 0.1, "%.3f mm")) { + if (m_params.linear_deflection < 0.001) m_params.linear_deflection = 0.001; + if (m_params.linear_deflection > 1.0) m_params.linear_deflection = 1.0; + m_preview_dirty = true; + } + } + + ImGui::Separator(); + + if (ImGui::Button("Add Shape", {-1, 28})) + apply_primitive(); + + if (ImGui::Button("Close", {-1, 0})) + m_parent.reset_all_gizmos(); + + GizmoImguiEnd(); + ImGuiWrapper::pop_toolbar_style(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp new file mode 100644 index 0000000000..f78289a58d --- /dev/null +++ b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp @@ -0,0 +1,43 @@ +#ifndef slic3r_GLGizmoPrimitive_hpp_ +#define slic3r_GLGizmoPrimitive_hpp_ + +#include "GLGizmoBase.hpp" +#include "GLGizmosCommon.hpp" +#include "libslic3r/CAD/GeometryEngine.hpp" + +namespace Slic3r { +namespace GUI { + +class GLGizmoPrimitive : public GLGizmoBase +{ +public: + GLGizmoPrimitive(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id); + ~GLGizmoPrimitive() = default; + + bool on_mouse(const wxMouseEvent& mouse_event) override; + +protected: + bool on_init() override; + std::string on_get_name() const override; + bool on_is_activable() const override; + void on_render() override; + void on_set_state() override; + CommonGizmosDataID on_get_requirements() const override; + void on_render_input_window(float x, float y, float bottom_limit) override; + + void on_load(cereal::BinaryInputArchive& ar) override; + void on_save(cereal::BinaryOutputArchive& ar) const override; + +private: + void apply_primitive(); + void apply_preset(const char* name, double w, double h, double d); + + PrimitiveParams m_params; + TriangleMesh m_preview_mesh; + bool m_preview_dirty{true}; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GLGizmoPrimitive_hpp_ diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp new file mode 100644 index 0000000000..88bd636563 --- /dev/null +++ b/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp @@ -0,0 +1,459 @@ +#include "GLGizmoSketch.hpp" +#include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/ImGuiWrapper.hpp" +#include "slic3r/GUI/GUI.hpp" +#include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/GUI/NotificationManager.hpp" +#include "libslic3r/Model.hpp" +#include +#include +#include + +#ifndef IMGUI_DEFINE_MATH_OPERATORS +#define IMGUI_DEFINE_MATH_OPERATORS +#endif +#include + +#define L(s) Slic3r::GUI::I18N::translate((s)).c_str() +#define UL(s) Slic3r::GUI::I18N::translate_utf8((s)).c_str() + +namespace Slic3r { +namespace GUI { + +GLGizmoSketch::GLGizmoSketch(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id) + : GLGizmoBase(parent, icon_filename, sprite_id) {} + +bool GLGizmoSketch::on_init() { return true; } +std::string GLGizmoSketch::on_get_name() const { return _u8L("Sketch"); } +bool GLGizmoSketch::on_is_activable() const { return true; } +void GLGizmoSketch::on_render() {} +void GLGizmoSketch::on_set_state() { if (m_state == EState::On) clear_all(); } +bool GLGizmoSketch::on_mouse(const wxMouseEvent&) { return false; } + +CommonGizmosDataID GLGizmoSketch::on_get_requirements() const +{ return CommonGizmosDataID(int(CommonGizmosDataID::SelectionInfo)); } + +void GLGizmoSketch::on_load(cereal::BinaryInputArchive& ar) +{ + ar(m_tool, m_profiles, m_plane, m_sp, m_rect_w, m_rect_h, m_circle_r, m_poly_sides, m_poly_r, m_snap_grid, m_grid_step); + m_active_profile = -1; +} + +void GLGizmoSketch::on_save(cereal::BinaryOutputArchive& ar) const +{ + ar(m_tool, m_profiles, m_plane, m_sp, m_rect_w, m_rect_h, m_circle_r, m_poly_sides, m_poly_r, m_snap_grid, m_grid_step); +} + +SketchProfile& GLGizmoSketch::active_profile() +{ + if (m_active_profile < 0 || m_active_profile >= (int)m_profiles.size()) { + m_profiles.emplace_back(); + m_active_profile = (int)m_profiles.size() - 1; + } + return m_profiles[m_active_profile]; +} + +bool GLGizmoSketch::has_closed_profile() const +{ + for (auto& p : m_profiles) if (p.closed && p.points.size() >= 3) return true; + return false; +} + +void GLGizmoSketch::clear_all() +{ + m_profiles.clear(); + m_canvas_points.clear(); + m_active_profile = -1; +} + +void GLGizmoSketch::add_closed_profile() +{ + auto& ap = active_profile(); + if (ap.points.size() >= 3) { + ap.closed = true; + m_active_profile = -1; + } +} + +void GLGizmoSketch::delete_profile(int idx) +{ + if (idx >= 0 && idx < (int)m_profiles.size()) { + m_profiles.erase(m_profiles.begin() + idx); + if (m_active_profile >= (int)m_profiles.size()) m_active_profile = -1; + } +} + +Vec2d GLGizmoSketch::snap(Vec2d pt) const +{ + if (!m_snap_grid) return pt; + double gs = m_grid_step; + return {round(pt.x() / gs) * gs, round(pt.y() / gs) * gs}; +} + +void GLGizmoSketch::build_preset_profile() +{ + auto& ap = active_profile(); + ap.clear(); + auto add = [&](double x, double y) { ap.points.emplace_back(x, y); }; + switch (m_tool) { + case SketchTool::Rectangle: + add(-m_rect_w/2, -m_rect_h/2); add( m_rect_w/2, -m_rect_h/2); + add( m_rect_w/2, m_rect_h/2); add(-m_rect_w/2, m_rect_h/2); + ap.closed = true; m_active_profile = -1; break; + case SketchTool::Circle: + for (int i = 0; i <= m_circle_seg; ++i) { + double a = 2.0*M_PI*i/m_circle_seg; + add(cos(a)*m_circle_r, sin(a)*m_circle_r); + } + ap.closed = true; m_active_profile = -1; break; + case SketchTool::Polygon: + for (int i = 0; i < m_poly_sides; ++i) { + double a = 2.0*M_PI*i/m_poly_sides - M_PI/2; + add(cos(a)*m_poly_r, sin(a)*m_poly_r); + } + ap.closed = true; m_active_profile = -1; break; + default: break; + } +} + +void GLGizmoSketch::handle_canvas_click(ImVec2 pos) +{ + Vec2d pt = snap({pos.x / m_canvas_scale, -pos.y / m_canvas_scale}); + if (m_tool == SketchTool::Line) { + auto& ap = active_profile(); + if (ap.points.size() >= 3 && (pt - ap.points.front()).norm() < m_grid_step) { + ap.points.push_back(ap.points.front()); + ap.closed = true; + m_active_profile = -1; + return; + } + ap.points.push_back(pt); + } +} + +void GLGizmoSketch::draw_canvas() +{ + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 pos = ImGui::GetCursorScreenPos(); + float w = 280, h = 200; + ImVec2 end(pos.x+w, pos.y+h); + float cx = pos.x+w/2, cy = pos.y+h/2; + auto tc = [&](const ImVec2& p) { return ImVec2(cx+p.x*m_canvas_scale, cy-p.y*m_canvas_scale); }; + + dl->AddRectFilled(pos, end, IM_COL32(28,28,36,255)); + dl->AddRect(pos, end, IM_COL32(55,55,68,255)); + + float gs = m_grid_step; + for (float g = 0; g < w; g += gs * m_canvas_scale) { + ImU32 gc = (int(g/(gs*m_canvas_scale)) % 5 == 0) ? IM_COL32(60,60,75,100) : IM_COL32(45,45,55,60); + dl->AddLine({pos.x+g,pos.y}, {pos.x+g,end.y}, gc); + } + for (float g = 0; g < h; g += gs * m_canvas_scale) { + ImU32 gc = (int(g/(gs*m_canvas_scale)) % 5 == 0) ? IM_COL32(60,60,75,100) : IM_COL32(45,45,55,60); + dl->AddLine({pos.x,pos.y+g}, {end.x,pos.y+g}, gc); + } + + dl->AddLine({cx,pos.y},{cx,end.y}, IM_COL32(70,70,85,180), 1.5f); + dl->AddLine({pos.x,cy},{end.x,cy}, IM_COL32(70,70,85,180), 1.5f); + dl->AddText({end.x-12, cy+2}, IM_COL32(120,120,140,200), "X"); + dl->AddText({cx+4, pos.y+2}, IM_COL32(120,120,140,200), "Y"); + + for (size_t pi = 0; pi < m_profiles.size(); ++pi) { + auto& prof = m_profiles[pi]; + if (prof.points.size() < 2) continue; + std::vector sp; + for (auto& p : prof.points) sp.push_back(tc({(float)p.x(), (float)p.y()})); + if (prof.closed && sp.size() >= 3) { + bool is_outer = (pi == 0); + ImU32 fill = is_outer ? IM_COL32(0,180,90,35) : IM_COL32(180,60,60,35); + ImU32 line = is_outer ? IM_COL32(0,220,100,255) : IM_COL32(220,80,80,255); + dl->AddConvexPolyFilled(sp.data(), (int)sp.size(), fill); + for (size_t i=0; iAddLine(sp[i], sp[(i+1)%sp.size()], line, (pi==0)?2.5f:2.0f); + for (size_t i=0; iAddCircleFilled(sp[i], 3.0f, IM_COL32(255,255,255,255)); + } + } + + auto& ap = active_profile(); + if (!ap.closed && ap.points.size() >= 1) { + std::vector sp; + for (auto& p : ap.points) sp.push_back(tc({(float)p.x(), (float)p.y()})); + for (size_t i=1; iAddLine(sp[i-1], sp[i], IM_COL32(0,200,255,200), 2.0f); + for (auto& s : sp) dl->AddCircleFilled(s, 3.5f, IM_COL32(100,200,255,255)); + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x > pos.x && mouse.x < end.x && mouse.y > pos.y && mouse.y < end.y) + dl->AddLine(sp.back(), mouse, IM_COL32(100,160,220,120), 1.5f); + } + + ImGui::InvisibleButton("canvas", ImVec2(w,h)); + if (ImGui::IsItemHovered()) { + ImVec2 m = ImGui::GetMousePos(); + Vec2d sk({(m.x-cx)/m_canvas_scale, -(m.y-cy)/m_canvas_scale}); + if (m_snap_grid) sk = snap(sk); + auto txt = wxString::Format("X:%.1f Y:%.1f", sk.x(), sk.y()).ToStdString(); + dl->AddText({pos.x+4, end.y-16}, IM_COL32(160,160,180,200), txt.c_str()); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) + handle_canvas_click({(m.x-cx)/m_canvas_scale, -(m.y-cy)/m_canvas_scale}); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + auto& ap2 = active_profile(); + if (ap2.points.size() >= 3) { + ap2.points.push_back(ap2.points.front()); + ap2.closed = true; + m_active_profile = -1; + } + } + } +} + +TopoDS_Shape GLGizmoSketch::build_combined_shape() +{ + if (m_profiles.empty() || !m_profiles[0].closed) + throw std::runtime_error("No outer profile"); + + TopoDS_Wire outer_wire = m_profiles[0].to_occt_wire(m_plane); + BRepBuilderAPI_MakeFace face_maker(outer_wire); + if (!face_maker.IsDone()) throw std::runtime_error("Failed to make outer face"); + + for (size_t i = 1; i < m_profiles.size(); ++i) { + if (!m_profiles[i].closed) continue; + TopoDS_Wire inner = m_profiles[i].to_occt_wire(m_plane); + face_maker.Add(inner); + } + face_maker.Build(); + if (!face_maker.IsDone()) throw std::runtime_error("Failed to build face with holes"); + + TopoDS_Face face = face_maker.Face(); + + TopoDS_Shape shape; + if (m_sp.revolve_deg < 360.0 && m_sp.revolve_deg > 0.0) { + gp_Pnt o(m_plane.origin.x(), m_plane.origin.y(), m_plane.origin.z()); + gp_Dir xd(m_plane.x_axis.x(), m_plane.x_axis.y(), m_plane.x_axis.z()); + gp_Ax1 axis(o, xd); + BRepPrimAPI_MakeRevol rev(face, axis, m_sp.revolve_deg * M_PI / 180.0); + if (!rev.IsDone()) throw std::runtime_error("Revolve failed"); + shape = rev.Shape(); + } else { + shape = SketchEngine::make_extrude_face(face, m_plane, m_sp.extrude_len, m_sp.extrude_sym); + } + + if (m_sp.dressup_enabled) { + if (m_sp.dressup_type == DressUpType::Fillet) + shape = GeometryEngine::apply_fillet(shape, m_sp.dressup_radius, m_sp.dressup_faces); + else + shape = GeometryEngine::apply_chamfer(shape, m_sp.dressup_chamfer_dist, m_sp.dressup_faces); + } + return shape; +} + +void GLGizmoSketch::on_render_input_window(float x, float y, float bottom_limit) +{ + y = std::min(y, bottom_limit - ImGui::GetWindowHeight()); + const float scale = m_parent.get_scale(); + ImGuiWrapper::push_toolbar_style(scale); + GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 0.0f, 0.0f); + GizmoImguiBegin("Sketch", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove + | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse + | ImGuiWindowFlags_NoTitleBar); + + if (ImGui::CollapsingHeader(UL("Profile"), ImGuiTreeNodeFlags_DefaultOpen)) { + static const char* names[] = {"Line", "Rectangle", "Circle", "Polygon"}; + int cur = (int)m_tool; + if (ImGui::Combo("##shape", &cur, names, (int)SketchTool::COUNT)) { + m_tool = (SketchTool)cur; + if (m_tool != SketchTool::Line) build_preset_profile(); + } + ImGui::SameLine(); + if (m_imgui->button("+##newprofile")) m_active_profile = -1; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", UL("Start new profile (for holes)")); + + if (m_tool == SketchTool::Rectangle) { + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("W", &m_rect_w,1,10,"%.0f")) build_preset_profile(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("H", &m_rect_h,1,10,"%.0f")) build_preset_profile(); + } else if (m_tool == SketchTool::Circle) { + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("R", &m_circle_r,1,5,"%.0f")) build_preset_profile(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); if (ImGui::SliderInt("Seg", &m_circle_seg,8,64)) build_preset_profile(); + } else if (m_tool == SketchTool::Polygon) { + ImGui::SetNextItemWidth(80); if (ImGui::SliderInt("Sides", &m_poly_sides,3,12)) build_preset_profile(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("R", &m_poly_r,1,5,"%.0f")) build_preset_profile(); + } else { + ImGui::Text("%s", UL("Click on canvas to draw")); + } + + ImGui::Checkbox(UL("Snap to grid"), &m_snap_grid); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); ImGui::InputFloat("Step", &m_grid_step, 1, 5, "%.0f mm"); + + draw_canvas(); + + if (!m_profiles.empty()) { + ImGui::Text("%s: %zu", UL("Profiles"), m_profiles.size()); + for (int i = 0; i < (int)m_profiles.size(); ++i) { + auto& p = m_profiles[i]; + ImGui::PushID(i); + bool outer = (i == 0); + ImVec4 col = outer ? ImVec4(0,1,0,1) : ImVec4(1,0.3f,0.3f,1); + const char* label = outer ? "Outer" : "Hole"; + ImGui::TextColored(col, "%s %d: %zu pts %s", label, i+1, p.points.size(), p.closed ? "CLOSED" : ""); + ImGui::SameLine(); + if (ImGui::SmallButton("X")) delete_profile(i); + ImGui::PopID(); + } + } + } + + ImGui::Separator(); + + bool is_revolve = false; + bool has_sel = false; + + if (ImGui::CollapsingHeader(UL("Operation"), ImGuiTreeNodeFlags_DefaultOpen)) { + static int pi = 0; + if (ImGui::Combo(UL("Plane"), &pi, "XY (Top)\0XZ (Front)\0YZ (Side)\0")) + m_plane = (pi==0) ? SketchPlane::XY() : (pi==1) ? SketchPlane::XZ() : SketchPlane::YZ(); + + is_revolve = (m_sp.revolve_deg > 0 && m_sp.revolve_deg < 360); + ImGui::SetNextItemWidth(100); + if (ImGui::InputDouble(UL("Revolve deg"), &m_sp.revolve_deg, 15, 90, "%.0f")) { + if (m_sp.revolve_deg > 360) m_sp.revolve_deg = 360; + if (m_sp.revolve_deg < 0) m_sp.revolve_deg = 0; + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", UL("Set to 0 for extrude, >0 for revolve")); + + if (!is_revolve) { + ImGui::SetNextItemWidth(100); + ImGui::InputDouble(UL("Length"), &m_sp.extrude_len, 0.5, 5, "%.1f mm"); + ImGui::SameLine(); + ImGui::Checkbox(UL("Symmetric"), &m_sp.extrude_sym); + } + + has_sel = !m_parent.get_selection().is_empty(); + if (has_sel) { + if (ImGui::Checkbox(UL("Pocket (cut)"), &m_sp.is_pocket)) + if (m_sp.is_pocket) m_sp.dressup_enabled = false; + } else m_sp.is_pocket = false; + } + + ImGui::Separator(); + + if (!m_sp.is_pocket && ImGui::CollapsingHeader(UL("Fillet / Chamfer"))) { + ImGui::Checkbox(UL("Enable"), &m_sp.dressup_enabled); + if (m_sp.dressup_enabled) { + static const char* dn[] = {"Fillet", "Chamfer"}; + int du = (int)m_sp.dressup_type; + ImGui::SetNextItemWidth(100); + if (ImGui::Combo("##dtype", &du, dn, 2)) m_sp.dressup_type = (DressUpType)du; + static const char* fn[] = {"All edges", "Top edges", "Bottom edges", "Lateral edges"}; + int fg = (int)m_sp.dressup_faces; + ImGui::SetNextItemWidth(140); + ImGui::Combo(UL("Edges"), &fg, fn, 4); m_sp.dressup_faces = (FaceGroup)fg; + ImGui::SetNextItemWidth(100); + if (m_sp.dressup_type == DressUpType::Fillet) + ImGui::InputDouble(UL("Radius"), &m_sp.dressup_radius, 0.1, 1, "%.1f mm"); + else + ImGui::InputDouble(UL("Distance"), &m_sp.dressup_chamfer_dist, 0.1, 1, "%.1f mm"); + } + } + + ImGui::Separator(); + + bool ok = has_closed_profile(); + if (ok) ImGui::TextColored({0,1,0,1}, "%zu %s", m_profiles.size(), UL("closed profile(s)")); + else ImGui::TextColored({0.6f,0.6f,0.6f,1}, "%s", UL("Draw a closed profile to enable")); + + auto btn = [&](const char* label, bool enabled) { + if (!enabled) { ImGui::PushItemFlag(ImGuiItemFlags_Disabled,true); ImGui::PushStyleColor(ImGuiCol_Button,{0.25f,0.25f,0.25f,1}); } + bool clicked = ImGui::Button(label, {-1,0}); + if (!enabled) { ImGui::PopStyleColor(); ImGui::PopItemFlag(); } + return clicked && enabled; + }; + + if (m_sp.is_pocket && has_sel) { + if (btn(L("Pocket (Cut)"), ok)) apply_pocket(); + } else if (is_revolve) { + if (btn(L("Revolve"), ok)) apply_revolve(); + } else { + if (btn(L("Extrude"), ok)) apply_extrude(); + } + + if (ImGui::Button(L("Clear All"), {-1,0})) clear_all(); + if (ImGui::Button(L("Close"), {-1,0})) m_parent.reset_all_gizmos(); + + GizmoImguiEnd(); + ImGuiWrapper::pop_toolbar_style(); +} + +void GLGizmoSketch::apply_extrude() +{ + try { + TopoDS_Shape shape = build_combined_shape(); + TriangleMesh mesh = SketchEngine::tessellate(shape, m_sp.linear_deflection); + if (mesh.its.indices.empty()) throw std::runtime_error("Empty result"); + wxGetApp().plater()->take_snapshot("Sketch Extrude"); + ModelObject* mo = wxGetApp().model().add_object(); + mo->name = "Extrusion"; + mo->add_volume(std::move(mesh))->set_new_unique_id(); + mo->ensure_on_bed(); + wxGetApp().plater()->update(); + clear_all(); + } catch (const std::exception& e) { + wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::ErrorNotificationLevel, std::string("Extrude: ")+e.what()); + } +} + +void GLGizmoSketch::apply_revolve() +{ + try { + TopoDS_Shape shape = build_combined_shape(); + TriangleMesh mesh = SketchEngine::tessellate(shape, m_sp.linear_deflection); + if (mesh.its.indices.empty()) throw std::runtime_error("Empty result"); + wxGetApp().plater()->take_snapshot("Sketch Revolve"); + ModelObject* mo = wxGetApp().model().add_object(); + mo->name = "Revolve"; + mo->add_volume(std::move(mesh))->set_new_unique_id(); + mo->ensure_on_bed(); + wxGetApp().plater()->update(); + clear_all(); + } catch (const std::exception& e) { + wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::ErrorNotificationLevel, std::string("Revolve: ")+e.what()); + } +} + +void GLGizmoSketch::apply_pocket() +{ + try { + Selection& sel = m_parent.get_selection(); + int obj_idx = sel.get_object_idx(); + if (obj_idx < 0) throw std::runtime_error("No object selected"); + ModelObject* mo = wxGetApp().model().objects[obj_idx]; + + TopoDS_Wire outer = m_profiles[0].to_occt_wire(m_plane); + BRepBuilderAPI_MakeFace fm(outer); + if (!fm.IsDone()) throw std::runtime_error("Face failed"); + for (size_t i = 1; i < m_profiles.size(); ++i) + if (m_profiles[i].closed) fm.Add(m_profiles[i].to_occt_wire(m_plane)); + fm.Build(); + if (!fm.IsDone()) throw std::runtime_error("Face with holes failed"); + + TopoDS_Shape tool = SketchEngine::make_extrude_face(fm.Face(), m_plane, m_sp.extrude_len + 5.0, false); + TriangleMesh tool_mesh = SketchEngine::tessellate(tool, m_sp.linear_deflection); + if (tool_mesh.its.indices.empty()) throw std::runtime_error("Tool mesh empty"); + + wxGetApp().plater()->take_snapshot("Sketch Pocket"); + mo->add_volume(std::move(tool_mesh), ModelVolumeType::NEGATIVE_VOLUME)->set_new_unique_id(); + mo->ensure_on_bed(); + wxGetApp().plater()->update(); + clear_all(); + wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel, UL("Pocket added (negative volume)")); + } catch (const std::exception& e) { + wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::ErrorNotificationLevel, std::string("Pocket: ")+e.what()); + } +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp b/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp new file mode 100644 index 0000000000..5a4b59edfd --- /dev/null +++ b/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp @@ -0,0 +1,74 @@ +#ifndef slic3r_GLGizmoSketch_hpp_ +#define slic3r_GLGizmoSketch_hpp_ + +#include "GLGizmoBase.hpp" +#include "GLGizmosCommon.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include + +namespace Slic3r { +namespace GUI { + +enum class SketchTool { Line, Rectangle, Circle, Polygon, COUNT }; + +class GLGizmoSketch : public GLGizmoBase +{ +public: + GLGizmoSketch(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id); + + bool on_mouse(const wxMouseEvent& mouse_event) override; + +protected: + bool on_init() override; + std::string on_get_name() const override; + bool on_is_activable() const override; + void on_render() override; + void on_set_state() override; + CommonGizmosDataID on_get_requirements() const override; + void on_render_input_window(float x, float y, float bottom_limit) override; + + void on_load(cereal::BinaryInputArchive& ar) override; + void on_save(cereal::BinaryOutputArchive& ar) const override; + +private: + SketchTool m_tool{SketchTool::Line}; + std::vector m_profiles; // multiple profiles (outer + holes) + SketchPlane m_plane{SketchPlane::XY()}; + SketchParams m_sp; + + // Shape presets + double m_rect_w{20}, m_rect_h{15}; + double m_circle_r{10}; int m_circle_seg{32}; + int m_poly_sides{6}; double m_poly_r{10}; + + // Canvas + std::vector m_canvas_points; + Vec2d m_canvas_center{0,0}; + float m_canvas_scale{5.0f}; + bool m_snap_grid{true}; + float m_grid_step{5.0f}; + + // Current profile being drawn + int m_active_profile{-1}; + + SketchProfile& active_profile(); + bool has_closed_profile() const; + + void build_preset_profile(); + void add_closed_profile(); + void delete_profile(int idx); + void clear_all(); + + TopoDS_Shape build_combined_shape(); // all profiles as face with holes + void apply_extrude(); + void apply_revolve(); + void apply_pocket(); + void draw_canvas(); + void handle_canvas_click(ImVec2 pos); + Vec2d snap(Vec2d pt) const; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GLGizmoSketch_hpp_ diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 9cff834346..be586ec55a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -27,6 +27,10 @@ #include "slic3r/GUI/Gizmos/GLGizmoSVG.hpp" #include "slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp" #include "slic3r/GUI/Gizmos/GLGizmoAssembly.hpp" +#ifdef SLIC3R_CAD +#include "slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp" +#include "slic3r/GUI/Gizmos/GLGizmoSketch.hpp" +#endif #include "libslic3r/format.hpp" #include "libslic3r/Model.hpp" @@ -176,6 +180,14 @@ void GLGizmosManager::switch_gizmos_icon_filename() case (EType::BrimEars): gizmo->set_icon_filename(m_is_dark ? "toolbar_brimears_dark.svg" : "toolbar_brimears.svg"); break; +#ifdef SLIC3R_CAD + case (EType::Primitive): + gizmo->set_icon_filename(m_is_dark ? "toolbar_modifier_cube_dark.svg" : "toolbar_modifier_cube.svg"); + break; + case (EType::Sketch): + gizmo->set_icon_filename(m_is_dark ? "toolbar_sketch_dark.svg" : "toolbar_sketch.svg"); + break; +#endif } } @@ -219,6 +231,12 @@ bool GLGizmosManager::init() m_gizmos.emplace_back(new GLGizmoAssembly(m_parent, m_is_dark ? "toolbar_assembly_dark.svg" : "toolbar_assembly.svg", EType::Assembly)); m_gizmos.emplace_back(new GLGizmoSimplify(m_parent, "reduce_triangles.svg", EType::Simplify)); m_gizmos.emplace_back(new GLGizmoBrimEars(m_parent, m_is_dark ? "toolbar_brimears_dark.svg" : "toolbar_brimears.svg", EType::BrimEars)); +#ifdef SLIC3R_CAD + // Registered last: Primitive and Sketch are the final entries before Undefined, so + // omitting them leaves every preceding m_gizmos index (indexed by EType) untouched. + m_gizmos.emplace_back(new GLGizmoPrimitive(m_parent, m_is_dark ? "toolbar_modifier_cube_dark.svg" : "toolbar_modifier_cube.svg", static_cast(Primitive))); + m_gizmos.emplace_back(new GLGizmoSketch(m_parent, m_is_dark ? "toolbar_sketch_dark.svg" : "toolbar_sketch.svg", static_cast(Sketch))); +#endif //m_gizmos.emplace_back(new GLGizmoSlaSupports(m_parent, "sla_supports.svg", sprite_id++)); //m_gizmos.emplace_back(new GLGizmoFaceDetector(m_parent, "face recognition.svg", sprite_id++)); //m_gizmos.emplace_back(new GLGizmoHollow(m_parent, "hollow.svg", sprite_id++)); diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp index 157eb43dc7..d490a6c05f 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp @@ -90,6 +90,12 @@ public: Assembly, Simplify, BrimEars, +#ifdef SLIC3R_CAD + // Both need the CAD kernel (GeometryEngine); keep them last so that with + // SLIC3R_CAD off the enum matches upstream's numbering exactly. + Primitive, + Sketch, +#endif //SlaSupports, // BBS //FaceRecognition, diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index eeaadd0cbd..cf107565e3 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -505,6 +505,23 @@ bool ImGuiWrapper::update_key_data(wxKeyEvent &evt) if (evt.GetEventType() == wxEVT_CHAR) { // Char event const auto key = evt.GetUnicodeKey(); + // THE MEASUREMENT THAT CANNOT LIE. This is the ONLY place in the application where ImGui + // is ever handed a character, so an ImGui text field that stays empty while reporting + // itself active has exactly two possible causes, and this line separates them: no output + // at all means the wxEVT_CHAR never reached the GL canvas (a focus problem, upstream of + // ImGui entirely), while output with unicode=0 means the character arrived empty and is + // being dropped right here. + // + // It lives here rather than on the canvas because a probe bound on the canvas CANNOT + // answer this: GLCanvas3D::on_char is bound later than any constructor-time probe, wx + // runs handlers in reverse bind order, and on_char returns without Skip() whenever this + // function returns true — so such a probe stays silent whether or not the key arrived. + // A day was lost to reading that silence as evidence. + if (std::getenv("ORCA_CAD_UXTRACE")) { + fprintf(stderr, "[UX] imgui_char unicode=%d keycode=%d want_text=%d\n", + (int) key, evt.GetKeyCode(), (int) io.WantTextInput); + fflush(stderr); + } if (key != 0) { io.AddInputCharacter(key); } diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 5f36323d6e..cc994b2370 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -37,6 +37,10 @@ #include "I18N.hpp" #include "GLCanvas3D.hpp" #include "Plater.hpp" +#ifdef SLIC3R_CAD +#include "slic3r/GUI/CAD/DesignPanel.hpp" +#include "slic3r/GUI/CAD/McpControl.hpp" +#endif #include "WebViewDialog.hpp" #include "../Utils/Process.hpp" // BBS @@ -1019,7 +1023,15 @@ void MainFrame::update_layout() // Right after Home — or first, when there is no Home tab (PositionAfter() would // append instead, and by now the other built-in tabs are already in place). const int home_idx = m_tabpanel->FindPageByName(TAB_ID_HOME); - const size_t prepare_pos = (home_idx == wxNOT_FOUND) ? 0 : static_cast(home_idx) + 1; + size_t prepare_pos = (home_idx == wxNOT_FOUND) ? 0 : static_cast(home_idx) + 1; +#ifdef SLIC3R_CAD + // Design sits between Home and Prepare, so it goes in first and pushes Prepare along. + // The page only exists when the experimental CAD feature is enabled. + if (m_design_page != nullptr) { + m_design_page->Reparent(m_tabpanel); + m_tabpanel->InsertPage(prepare_pos++, TAB_ID_DESIGN, m_design_page, _L("Design"), "tab_design_active"); + } +#endif m_tabpanel->InsertPage(prepare_pos, TAB_ID_PREPARE, m_plater, _L("Prepare"), "tab_3d_active"); m_tabpanel->InsertPage(prepare_pos + 1, TAB_ID_PREVIEW, m_plater, _L("Preview"), "tab_preview_active"); m_main_sizer->Add(m_tabpanel, 1, wxEXPAND | wxTOP, 0); @@ -1237,6 +1249,19 @@ void MainFrame::show_option(bool show) } } +#ifdef SLIC3R_CAD +DesignPanel* MainFrame::ensure_design_panel() +{ + if (m_design_panel == nullptr && m_design_page != nullptr) { + wxBusyCursor busy; + m_design_panel = new DesignPanel(m_design_page); + m_design_page->GetSizer()->Add(m_design_panel, 1, wxEXPAND); + m_design_page->Layout(); + } + return m_design_panel; +} +#endif + void MainFrame::init_tabpanel() { // wxNB_NOPAGETHEME: Disable Windows Vista theme for the Notebook background. The theme performance is terrible on // Windows 10 with multiple high resolution displays connected. @@ -1277,9 +1302,26 @@ void MainFrame::init_tabpanel() { } //else if (panel == m_param_panel) // m_param_panel->OnActivate(); +#ifdef SLIC3R_CAD + else if (m_design_page != nullptr && panel == m_design_page) { + // Built on first activation, never at startup: the panel creates several hundred + // controls and its own GL canvas, which a user who does not open the tab should + // not pay for. + ensure_design_panel(); + // Re-sync the Design bed to the active printer: the panel is built before the + // printer profile is fully applied, so its bed must refresh on activation or the + // grid (true bed) spills past the stale default bed quad. + m_design_panel->on_tab_shown(); + } +#endif else if (panel == m_monitor) { //monitor } +#ifdef SLIC3R_CAD + // Any page that is not Design takes the Design status line down with it — see + // DesignPanel::on_tab_hidden for why the popup does not follow the page on its own. + if (m_design_panel != nullptr && panel != m_design_page) m_design_panel->on_tab_hidden(); +#endif #ifndef __APPLE__ if (m_last_selected_tab == TAB_ID_PREPARE) { m_topbar->EnableUndoRedoItems(); @@ -1310,6 +1352,20 @@ void MainFrame::init_tabpanel() { wxGetApp().plater_ = m_plater; +#ifdef SLIC3R_CAD + // Stand-in page for the Design tab. The real DesignPanel is built into it the first time + // the tab is selected (see the page-changed handler above), so nothing it constructs sits + // on the startup path. The experimental feature is off by default, and when it is off the + // page is never created, so the tab does not appear at all (the preference takes effect on + // the next start, like the other feature toggles). + if (wxGetApp().is_enable_cad_feature()) { + m_design_page = new wxPanel(this); + m_design_page->SetSizer(new wxBoxSizer(wxVERTICAL)); + m_design_page->Hide(); + start_mcp_control_if_enabled(); // opens the MCP socket iff ORCA_CAD_MCP is set + } +#endif + create_preset_tabs(); //BBS add pages diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index 5340115609..bbdd8a4694 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -40,6 +40,9 @@ // Stable identifiers for MainFrame::m_tabpanel's built-in pages. These are // names rather than positional indices so optional pages cannot shift them. #define TAB_ID_HOME "home" +#ifdef SLIC3R_CAD +#define TAB_ID_DESIGN "design" +#endif #define TAB_ID_PREPARE "prepare" #define TAB_ID_PREVIEW "preview" #define TAB_ID_MONITOR "monitor" @@ -65,6 +68,9 @@ namespace GUI class Tab; class PrintHostQueueDialog; class Plater; +#ifdef SLIC3R_CAD +class DesignPanel; +#endif class MainFrame; class WebViewPanel; class ParamsDialog; @@ -384,6 +390,17 @@ public: BBLTopbar* m_topbar{ nullptr }; PrintHostQueueDialog* printhost_queue_dlg() { return m_printhost_queue_dlg; } Plater* m_plater { nullptr }; +#ifdef SLIC3R_CAD + // The tab page is the placeholder; m_design_panel stays null until the tab is first + // selected, so everything the Design panel builds stays off the startup path. + wxPanel* m_design_page { nullptr }; + DesignPanel* m_design_panel { nullptr }; + // Builds the Design panel if it does not exist yet and returns it (null only before the + // placeholder page itself exists). Main thread only -- it creates wx controls. Both the + // tab activation and the MCP socket go through this: the socket is driven headlessly, + // with nobody to click the tab, and without this every verb would answer "not ready". + DesignPanel* ensure_design_panel(); +#endif //BBS: GUI refactor MonitorPanel* m_monitor{ nullptr }; diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 260c857f22..ba0d9580dc 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -3507,7 +3507,7 @@ bool PartPlate::intersects(const BoundingBoxf3& bb) const return print_volume.intersects(bb); } -void PartPlate::render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_body, bool force_background_color, HeightLimitMode mode, int hover_id, bool render_cali, bool show_grid) +void PartPlate::render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_body, bool force_background_color, HeightLimitMode mode, int hover_id, bool render_cali, bool show_grid, bool hide_chrome) { glsafe(::glEnable(GL_DEPTH_TEST)); @@ -3558,16 +3558,18 @@ void PartPlate::render(const Transform3d& view_matrix, const Transform3d& projec if (wxGetApp().show_plate_gridlines() && show_grid) render_grid(bottom); - if (!bottom && m_selected && !force_background_color) { + if (!hide_chrome && !bottom && m_selected && !force_background_color) { if (m_partplate_list) render_logo(bottom, m_partplate_list->render_cali_logo && render_cali); else render_logo(bottom); } - render_icons(bottom, only_body, hover_id); - if (!force_background_color) { - render_only_numbers(bottom); + if (!hide_chrome) { + render_icons(bottom, only_body, hover_id); + if (!force_background_color) { + render_only_numbers(bottom); + } } glsafe(::glDisable(GL_DEPTH_TEST)); @@ -5962,7 +5964,7 @@ void PartPlateList::postprocess_arrange_polygon(arrangement::ArrangePolygon& arr /*rendering related functions*/ //render -void PartPlateList::render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid) +void PartPlateList::render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid, bool hide_chrome) { const std::lock_guard local_lock(m_plates_mutex); std::vector::iterator it = m_plate_list.begin(); @@ -5987,15 +5989,15 @@ void PartPlateList::render(const Transform3d& view_matrix, const Transform3d& pr if (current_index == m_current_plate) { PartPlate::HeightLimitMode height_mode = (only_current)?PartPlate::HEIGHT_LIMIT_NONE:m_height_limit_mode; if (plate_hover_index == current_index) - (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, height_mode, plate_hover_action, render_cali, show_grid); + (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, height_mode, plate_hover_action, render_cali, show_grid, hide_chrome); else - (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, height_mode, -1, render_cali, show_grid); + (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, height_mode, -1, render_cali, show_grid, hide_chrome); } else { if (plate_hover_index == current_index) - (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, PartPlate::HEIGHT_LIMIT_NONE, plate_hover_action, render_cali, show_grid); + (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, PartPlate::HEIGHT_LIMIT_NONE, plate_hover_action, render_cali, show_grid, hide_chrome); else - (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, PartPlate::HEIGHT_LIMIT_NONE, -1, render_cali, show_grid); + (*it)->render(view_matrix, projection_matrix, bottom, only_body, false, PartPlate::HEIGHT_LIMIT_NONE, -1, render_cali, show_grid, hide_chrome); } } } diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index e913ebaabf..c05775a963 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -428,7 +428,7 @@ public: bool contains(const BoundingBoxf3& bb) const; bool intersects(const BoundingBoxf3& bb) const; - void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_body = false, bool force_background_color = false, HeightLimitMode mode = HEIGHT_LIMIT_NONE, int hover_id = -1, bool render_cali = false, bool show_grid = true); + void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_body = false, bool force_background_color = false, HeightLimitMode mode = HEIGHT_LIMIT_NONE, int hover_id = -1, bool render_cali = false, bool show_grid = true, bool hide_chrome = false); void set_selected(); void set_unselected(); @@ -857,7 +857,7 @@ public: /*rendering related functions*/ void on_change_color_mode(bool is_dark) { m_is_dark = is_dark; } - void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current = false, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true); + void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current = false, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true, bool hide_chrome = false); void set_render_option(bool bedtype_texture, bool plate_settings); void set_render_cali(bool value = true) { render_cali_logo = value; } void register_raycasters_for_picking(GLCanvas3D& canvas) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 06d953aa25..30e1ae1313 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -91,6 +91,9 @@ #include "wxExtensions.hpp" #include "../Utils/PrintHost.hpp" #include "MainFrame.hpp" +#ifdef SLIC3R_CAD +#include "slic3r/GUI/CAD/DesignPanel.hpp" +#endif #include "format.hpp" #include "3DScene.hpp" #include "GLCanvas3D.hpp" @@ -8327,6 +8330,9 @@ std::vector Plater::priv::load_files(const std::vector& input_ int answer_convert_from_meters = wxOK_DEFAULT; int answer_convert_from_imperial_units = wxOK_DEFAULT; int tolal_model_count = 0; + // Whether one of the files being loaded here carried a CAD recipe. A statement about these + // files, not about the plater — q->model() may still hold the previous project's recipe. + bool loaded_cad_recipe = false; int progress_percent = 0; int total_files = input_files.size(); @@ -9452,6 +9458,16 @@ std::vector Plater::priv::load_files(const std::vector& input_ auto loaded_idxs = load_model_objects(model.objects, is_project_file); obj_idxs.insert(obj_idxs.end(), loaded_idxs.begin(), loaded_idxs.end()); + // load_model_objects only transfers ModelObjects; carry the Model-level CAD recipe + // onto the plater model so the Design tab can rehydrate the editable feature tree on + // reopen. Assigned unconditionally on the project-replacing path so that opening a + // project without a recipe clears whatever the previous one left behind; importing a + // plain model into the open project leaves the current recipe alone. + if (is_project_file) { + q->model().cad_recipe = model.cad_recipe; + loaded_cad_recipe = !model.cad_recipe.empty(); + } + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", finished load_model_objects"); wxString msg = wxString::Format(_L("Loading file: %s"), from_path(real_filename)); dlg_cont = dlg.Update(progress_percent, msg); @@ -9668,7 +9684,12 @@ std::vector Plater::priv::load_files(const std::vector& input_ // q->model().stl_design_country = ""; //} - if (tolal_model_count <= 0 && !q->m_exported_file) { + // A CAD project legitimately carries no mesh: the model lives in the feature tree until it is + // committed to the plate. Warning "no geometry data" for one is false, and it is the LAST thing + // a user sees after opening a design they spent an hour on — it reads as "your work is gone" + // when the recipe has in fact just been loaded and the Design tab will rehydrate it. Count a + // recipe that came from THESE files as geometry. + if (tolal_model_count <= 0 && !loaded_cad_recipe && !q->m_exported_file) { dlg.Hide(); if (!is_user_cancel) { MessageDialog msg(wxGetApp().mainframe, _L("The file does not contain any geometry data."), _L("Warning"), wxYES | wxICON_WARNING); @@ -10215,6 +10236,16 @@ void Plater::priv::reset(bool apply_presets_change) // Stop and reset the Print content. this->background_process.reset(); model.clear_objects(); + // clear_objects() only drops the ModelObjects; the CAD recipe is Model-level state and would + // otherwise be written into every project saved for the rest of the session. + model.cad_recipe.clear(); +#ifdef SLIC3R_CAD + // Same reason, one level up: the Design tab keeps the editable document, not the Model, so + // clearing the recipe alone leaves the tab showing the previous project's feature tree — + // and its next edit syncs that tree straight back into the new project. + if (wxGetApp().mainframe != nullptr && wxGetApp().mainframe->m_design_panel != nullptr) + wxGetApp().mainframe->m_design_panel->clear_document(); +#endif assemble_view->get_canvas3d()->reset_explosion_ratio(); update(); @@ -13717,6 +13748,14 @@ void Plater::priv::unbind_canvas_event_handlers() if (assemble_view != nullptr) assemble_view->get_canvas3d()->unbind_event_handlers(); + +#ifdef SLIC3R_CAD + // The Design tab's viewport is a fourth GLCanvas3D on the same shared GL context, owned by + // MainFrame rather than by us — same reach as reset() uses for clear_document(). Null until + // the tab has been opened once, so most sessions skip it. + if (wxGetApp().mainframe != nullptr && wxGetApp().mainframe->m_design_panel != nullptr) + wxGetApp().mainframe->m_design_panel->unbind_canvas_event_handlers(); +#endif } void Plater::priv::reset_canvas_volumes() @@ -13726,6 +13765,11 @@ void Plater::priv::reset_canvas_volumes() if (preview != nullptr) preview->get_canvas3d()->reset_volumes(); + +#ifdef SLIC3R_CAD + if (wxGetApp().mainframe != nullptr && wxGetApp().mainframe->m_design_panel != nullptr) + wxGetApp().mainframe->m_design_panel->reset_canvas_volumes(); +#endif } bool Plater::priv::check_ams_status_impl(bool is_slice_all) @@ -15677,8 +15721,12 @@ bool Plater::up_to_date(bool saved, bool backup) Slic3r::clear_other_changes(backup); return p->up_to_date(saved, backup); } - return p->model.objects.empty() || (p->up_to_date(saved, backup) && - !Slic3r::has_other_changes(backup)); + // A Design-tab project is object-less until it is committed to the plate, but its feature + // tree is real work: treating it as an empty project skipped both the autosave and the + // "unsaved changes" prompt, so quitting threw it away without asking. Non-CAD projects + // never carry a recipe, so the empty-project shortcut is unchanged for them. + return (p->model.objects.empty() && p->model.cad_recipe.empty()) || + (p->up_to_date(saved, backup) && !Slic3r::has_other_changes(backup)); } bool Plater::add_model(bool imperial_units, std::string fname) diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 73f3a2c90a..aa831ad489 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -9,6 +9,7 @@ #include "I18N.hpp" #include "libslic3r/AppConfig.hpp" #include "libslic3r/Format/DRC.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" #include #include "OG_CustomCtrl.hpp" #include "wx/graphics.h" @@ -367,7 +368,8 @@ wxBoxSizer *PreferencesDialog::create_item_language_combobox(wxString title, wxS wxLANGUAGE_PORTUGUESE_BRAZILIAN, wxLANGUAGE_LITHUANIAN, wxLANGUAGE_VIETNAMESE, - wxLANGUAGE_THAI + wxLANGUAGE_THAI, + wxLANGUAGE_ROMANIAN }; auto translations = wxTranslations::Get()->GetAvailableTranslations(SLIC3R_APP_KEY); @@ -1724,6 +1726,21 @@ void PreferencesDialog::create_items() auto item_multi_machine = create_item_checkbox(_L("Multi device management"), _L("With this option enabled, you can send a task to multiple devices at the same time and manage multiple devices."), "enable_multi_machine", _L("(Requires restart)")); g_sizer->Add(item_multi_machine); +#ifdef SLIC3R_CAD + auto item_cad_feature = create_item_checkbox(_L("CAD feature (experimental)"), + _L("With this option enabled, the Design tab is shown, where models can be built and edited " + "parametrically. This feature is experimental and still under development."), + "enable_cad_feature", _L("(Requires restart)")); + g_sizer->Add(item_cad_feature); + + auto item_auto_close_sketch_loops = create_item_checkbox(_L("Auto-close sketch loops"), + _L("Treat sketch endpoints within 0.001 mm as one joint and weld the loop shut. " + "Off: only exactly coincident endpoints join, so a loop with a tiny gap is " + "shown as open instead of being closed for you."), + "auto_close_sketch_loops"); + g_sizer->Add(item_auto_close_sketch_loops); +#endif + #if 0 g_sizer->Add(create_item_title(_L("Filament Grouping")), 1, wxEXPAND); //temporarily disable it @@ -1800,6 +1817,21 @@ void PreferencesDialog::create_items() auto reverse_mouse_zoom = create_item_checkbox(_L("Reverse mouse zoom"), _L("If enabled, reverses the direction of zoom with mouse wheel."), "reverse_mouse_wheel_zoom"); g_sizer->Add(reverse_mouse_zoom); +#ifdef SLIC3R_CAD + // Design-tab only, so it stays out of the way while the CAD feature is switched off. + if (wxGetApp().is_enable_cad_feature()) { + auto item_connector_face_glyph = create_item_checkbox(_L("Draw mate connectors as a face"), + _L("In the Design tab, draw a mate connector as a small face instead of the conventional " + "disc with a roll quadrant. A face's orientation is read without being learned. " + "Turn this off for the conventional CAD representation."), "design_connector_face_glyph"); + g_sizer->Add(item_connector_face_glyph); + } + + // Push the weld preference into the kernel now so toggling it takes effect without + // a restart (the sketch tool also re-pushes on activation, see DesignSketchTool::begin). + Slic3r::set_sketch_auto_close(wxGetApp().is_auto_close_sketch_loops()); +#endif + std::vector ButtonDragActions = {_L("None"), _L("Pan"), _L("Rotate")}; auto item_left_mouse_drag = create_item_combobox(_L("Left Mouse Drag"), _L("Set the action that dragging the left mouse button should perform."), "left_mouse_drag_action", ButtonDragActions); g_sizer->Add(item_left_mouse_drag); diff --git a/tests/data/cad_recipe_v3.bin b/tests/data/cad_recipe_v3.bin new file mode 100644 index 0000000000..c3bc6e95f7 Binary files /dev/null and b/tests/data/cad_recipe_v3.bin differ diff --git a/tests/data/cad_recipe_v4.bin b/tests/data/cad_recipe_v4.bin new file mode 100644 index 0000000000..eb8f8ec730 Binary files /dev/null and b/tests/data/cad_recipe_v4.bin differ diff --git a/tests/data/cad_recipe_v5.bin b/tests/data/cad_recipe_v5.bin new file mode 100644 index 0000000000..718f2d2d84 Binary files /dev/null and b/tests/data/cad_recipe_v5.bin differ diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index b3c74335cc..185dce37da 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -52,6 +52,17 @@ add_executable(${_TEST_NAME}_tests ../libnest2d/printer_parts.cpp ) +if (SLIC3R_CAD) + target_sources(${_TEST_NAME}_tests PRIVATE + test_caddocument.cpp + test_sketchconstraints.cpp + test_sketchedit.cpp + test_sketchprofile.cpp + test_sketchimport.cpp + test_sketchinference.cpp + test_slvs_constraints.cpp) +endif () + if (TARGET OpenVDB::openvdb) target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp) endif() diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 5c38859a12..02d392140b 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -3,6 +3,8 @@ #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" +#include "libslic3r/miniz_extension.hpp" +#include "libslic3r/Zipper.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Semver.hpp" #include "libslic3r/Preset.hpp" @@ -15,6 +17,8 @@ #include #include +#include +#include #include #include @@ -143,6 +147,236 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") { } } +// The recipe is an opaque binary blob (CadDocument::serialize_recipe()), so the 3mf backend has +// to carry it byte-for-byte — no XML/text mangling, embedded NULs intact. +static std::string make_cad_recipe() +{ + // Built from an explicit length, not append(const char*), which would stop at the first + // embedded NUL — the one thing this blob exists to prove survives the archive. + static const char blob[] = "\x01" "RECIPE" "\0" "\xff\xfe\x00\x10" "cad-features-blob"; + return std::string(blob, sizeof(blob) - 1); +} + +static const std::string CAD_RECIPE_ENTRY = "Metadata/orca_cad.bin"; +static const std::string LEGACY_CAD_RECIPE_ENTRY = "Metadata/SnapOrca_cad.bin"; + +// Pulls one named entry out of a 3mf archive; false when it is absent. +static bool read_cad_recipe_entry(const std::string& path, std::string& out, + const std::string& entry = CAD_RECIPE_ENTRY) +{ + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_reader(&zip, path)); + bool found = false; + mz_uint n = mz_zip_reader_get_num_files(&zip); + for (mz_uint i = 0; i < n; ++i) { + mz_zip_archive_file_stat st; + if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; + std::string name(st.m_filename); + std::replace(name.begin(), name.end(), '\\', '/'); + if (boost::algorithm::iequals(name, entry)) { + out.resize(st.m_uncomp_size); + found = mz_zip_reader_extract_to_mem(&zip, i, out.data(), out.size(), 0) != 0; + break; + } + } + close_zip_reader(&zip); + return found; +} + +// Rewrites the archive at `path` with the recipe entry back under the name it had before the +// rename, which is what every project saved by an earlier build looks like on disk. Generated +// rather than checked in because a whole project archive is not frozen evidence the way a bare +// recipe blob is -- it has to be whatever today's exporter writes, with only the name aged. +// miniz cannot rename in place and open_zip_writer truncates, so the entries are held across +// the switch. +static void rename_cad_recipe_entry_to_legacy(const std::string& path) +{ + std::vector> entries; + bool renamed = false; + { + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_reader(&zip, path)); + mz_uint n = mz_zip_reader_get_num_files(&zip); + for (mz_uint i = 0; i < n; ++i) { + mz_zip_archive_file_stat st; + REQUIRE(mz_zip_reader_file_stat(&zip, i, &st)); + if (st.m_is_directory) continue; + std::string name(st.m_filename); + std::replace(name.begin(), name.end(), '\\', '/'); + std::string data((size_t) st.m_uncomp_size, '\0'); + if (st.m_uncomp_size > 0) + REQUIRE(mz_zip_reader_extract_to_mem(&zip, i, data.data(), data.size(), 0)); + if (boost::algorithm::iequals(name, CAD_RECIPE_ENTRY)) { + name = LEGACY_CAD_RECIPE_ENTRY; + renamed = true; + } + entries.emplace_back(std::move(name), std::move(data)); + } + close_zip_reader(&zip); + } + // Without this the scenario would degrade silently into re-testing the new name if the + // exporter's constant ever moved again: every load below would still pass. + REQUIRE(renamed); + + Zipper out(path); + for (const auto& e : entries) + out.add_entry(e.first, e.second.data(), e.second.size()); + out.finalize(); +} + +// The recipe lives only in the BBS-native backend, because that is the only one that runs: +// store_bbs_3mf is the sole exporter the app calls, and 3mf.cpp's load_3mf is reached only for +// files fingerprinted as PrusaSlicer's, which never carry a recipe. This locks in both halves: +// the archive entry is at the exact path the importer looks for, and the recipe comes back +// through the real importer. +SCENARIO("CAD recipe is embedded in the BBS 3mf archive", "[3mf][CAD]") { + GIVEN("a model carrying a binary cad_recipe") { + Model model; + std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src.c_str(), &model)); + model.add_default_instances(); + + // store_bbs_3mf stages its metadata through the model's backup path; point it at a + // writable temp dir, as the sibling BBS scenarios do. The process-global + // set_temporary_dir() would leak into every test that ran afterwards. + ScopedTemporaryDir backup_dir("orca_cad"); + model.set_backup_path(backup_dir.string()); + + const std::string recipe = make_cad_recipe(); + model.cad_recipe = recipe; + + WHEN("saved through the BBS backend (the format the GUI uses)") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig cfg; + StoreParams sp; + sp.path = test_file.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(sp)); + + THEN("the archive entry is present byte-for-byte") { + std::string got; + REQUIRE(read_cad_recipe_entry(test_file, got)); + REQUIRE(got.size() == recipe.size()); + REQUIRE(got == recipe); + } + + THEN("the importer restores it onto the loaded model") { + Model dst_model; + ScopedTemporaryDir dst_backup_dir("orca_cad_dst"); + dst_model.set_backup_path(dst_backup_dir.string()); + + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + REQUIRE(dst_model.cad_recipe.size() == recipe.size()); + REQUIRE(dst_model.cad_recipe == recipe); + + release_PlateData_list(dst_plates); + } + } + + WHEN("the same model is saved with no recipe") { + model.cad_recipe.clear(); + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig cfg; + StoreParams sp; + sp.path = test_file.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(sp)); + + THEN("no entry is written at all") { + std::string got; + REQUIRE_FALSE(read_cad_recipe_entry(test_file, got)); + } + } + } +} + +// The recipe entry was renamed from Metadata/SnapOrca_cad.bin to Metadata/orca_cad.bin. Nothing +// in the blob marks that move, so a reader that knows only the new name loads a project written +// before it with an empty cad_recipe and no error at all — a feature tree gone with no symptom +// but an empty Design tab. The importer must still accept the old name; the exporter may never +// write it. +SCENARIO("a project saved under the pre-rename recipe name still loads", "[3mf][CAD]") { + GIVEN("a project whose recipe entry carries the old name") { + Model model; + std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src.c_str(), &model)); + model.add_default_instances(); + const std::string recipe = make_cad_recipe(); + model.cad_recipe = recipe; + + WHEN("it was written by the BBS backend") { + ScopedTemporaryDir backup_dir("orca_cad_legacy"); + model.set_backup_path(backup_dir.string()); + + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + DynamicPrintConfig cfg; + StoreParams sp; + sp.path = test_file.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(sp)); + rename_cad_recipe_entry_to_legacy(test_file); + + // Catch2 replays the enclosing sections per THEN, so one load here serves both. + Model dst_model; + ScopedTemporaryDir dst_backup_dir("orca_cad_legacy_dst"); + dst_model.set_backup_path(dst_backup_dir.string()); + + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + release_PlateData_list(dst_plates); + + THEN("the recipe still comes back byte-for-byte") { + REQUIRE(dst_model.cad_recipe == recipe); + } + + THEN("re-saving migrates it to the new name and leaves the old one behind") { + ScopedTemporaryFile again(".3mf"); + const std::string resaved = again.string(); + DynamicPrintConfig cfg2; + StoreParams sp2; + sp2.path = resaved.c_str(); + sp2.model = &dst_model; + sp2.config = &cfg2; + sp2.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(sp2)); + + std::string got; + REQUIRE(read_cad_recipe_entry(resaved, got)); + REQUIRE(got == recipe); + REQUIRE_FALSE(read_cad_recipe_entry(resaved, got, LEGACY_CAD_RECIPE_ENTRY)); + } + } + } +} + // .3mf multi-nozzle round-trip. // Locks the load/save handling for the H2C multi-nozzle plate metadata: // * filament_volume_maps -> plate config "filament_volume_map" (with the >1 -> 0 clamp) diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp new file mode 100644 index 0000000000..d69ada09b6 --- /dev/null +++ b/tests/libslic3r/test_caddocument.cpp @@ -0,0 +1,8414 @@ +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) + +// Substring assertions, spelled so this file compiles UNCHANGED on both forks. +// Catch2 v2 (Snapmaker) spells it Matchers::Contains; v3 (orca_cad / mainline) spells it +// Matchers::ContainsSubstring and gives Contains an incompatible meaning — range-contains- +// ELEMENT — which fails to compile against a std::string rather than failing a test. +// Using find() sidesteps the rename entirely; INFO keeps the actual string in the report. +#define REQUIRE_CONTAINS(str, sub) \ + do { const std::string _actual = (str); INFO("actual: " << _actual); \ + REQUIRE(_actual.find(sub) != std::string::npos); } while (0) +#define CHECK_CONTAINS(str, sub) \ + do { const std::string _actual = (str); INFO("actual: " << _actual); \ + CHECK(_actual.find(sub) != std::string::npos); } while (0) + +#include "libslic3r/CAD/CadDocument.hpp" +#include "libslic3r/CAD/GeometryEngine.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/CAD/SketchImport.hpp" +#include "libslic3r/CAD/ThreadStandards.hpp" +#include "libslic3r/Utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; +using Catch::Approx; // Catch2 v3 scopes Approx under Catch:: (v2 had it unqualified) + +TEST_CASE("CadDocument profile sketch -> extrude -> solid", "[CadDocument]") +{ + CadDocument doc; + + SketchProfile sp; + sp.points.push_back(Vec2d(-10, -10)); + sp.points.push_back(Vec2d( 10, -10)); + sp.points.push_back(Vec2d( 10, 10)); + sp.points.push_back(Vec2d(-10, 10)); + sp.closed = true; + + int sk_idx = doc.add_sketch_profile(sp, SketchPlane::XY(), "SquareProfile"); + REQUIRE(sk_idx >= 0); + doc.add_extrude(sk_idx, 5.0, false, BooleanMode::New, "Extrude1"); + + bool ok = doc.recompute(); + REQUIRE(ok); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); +} + +TEST_CASE("CadDocument legacy Rectangle sketch still works", "[CadDocument]") +{ + CadDocument doc; + + int sk_idx = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "RectSketch"); + REQUIRE(sk_idx >= 0); + doc.add_extrude(sk_idx, 5.0, false, BooleanMode::New, "Extrude1"); + + bool ok = doc.recompute(); + REQUIRE(ok); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); +} + +TEST_CASE("CadDocument preview with profile on self-contained candidate", "[CadDocument]") +{ + CadDocument doc; + + CadFeature candidate; + candidate.type = CadFeatureType::Extrude; + candidate.plane = SketchPlane::XY(); + candidate.distance = 4; + candidate.mode = BooleanMode::New; + // sketch_ref is -1 by default -> apply_feature uses candidate's own params + + SketchProfile tri; + tri.points.push_back(Vec2d(0, 0)); + tri.points.push_back(Vec2d(10, 0)); + tri.points.push_back(Vec2d(5, 8.66)); + tri.closed = true; + candidate.profile = tri; + + TriangleMesh mesh; + std::string err; + bool ok = doc.preview(candidate, mesh, err); + REQUIRE(ok); + REQUIRE(mesh.facets_count() > 0); +} + +TEST_CASE("CadDocument solve_sketch_feature snaps a rough quad to a rectangle", "[CadDocument]") +{ + CadDocument doc; + SketchProfile sp; + sp.points = { Vec2d(0,0), Vec2d(8,1), Vec2d(9,5), Vec2d(-1,4) }; + sp.closed = true; + int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "S"); + + auto& cons = doc.features[sk].constraints; + cons.push_back({SketchConstraintType::Fix, 0,-1,-1,-1, 0}); + cons.push_back({SketchConstraintType::LockX, 0,-1,-1,-1, 0}); + cons.push_back({SketchConstraintType::LockY, 0,-1,-1,-1, 0}); + cons.push_back({SketchConstraintType::Horizontal, 0, 1,-1,-1, 0}); + cons.push_back({SketchConstraintType::Vertical, 1, 2,-1,-1, 0}); + cons.push_back({SketchConstraintType::Horizontal, 2, 3,-1,-1, 0}); + cons.push_back({SketchConstraintType::Vertical, 3, 0,-1,-1, 0}); + cons.push_back({SketchConstraintType::Distance, 0, 1,-1,-1, 10}); + cons.push_back({SketchConstraintType::Distance, 1, 2,-1,-1, 6}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& pts = doc.features[sk].profile.points; + REQUIRE_THAT(pts[1].x(), Catch::Matchers::WithinAbs(10.0, 1e-3)); + REQUIRE_THAT(pts[1].y(), Catch::Matchers::WithinAbs(0.0, 1e-3)); + REQUIRE_THAT(pts[2].x(), Catch::Matchers::WithinAbs(10.0, 1e-3)); + REQUIRE_THAT(pts[2].y(), Catch::Matchers::WithinAbs(6.0, 1e-3)); + REQUIRE_THAT(pts[3].x(), Catch::Matchers::WithinAbs(0.0, 1e-3)); + REQUIRE_THAT(pts[3].y(), Catch::Matchers::WithinAbs(6.0, 1e-3)); + + // the solved profile still extrudes into a solid + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.display_mesh.facets_count() > 0); +} + +TEST_CASE("sketch entities -> wire -> extrude", "[CadDocument]") +{ + SECTION("square from 4 lines") { + CadDocument doc; + + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "square"; + sk.plane = SketchPlane::XY(); + sk.entities = { + {SketchEntity::Type::Line, Vec2d(-10,-10), Vec2d(10,-10)}, + {SketchEntity::Type::Line, Vec2d(10,-10), Vec2d(10,10)}, + {SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)}, + {SketchEntity::Type::Line, Vec2d(-10,10), Vec2d(-10,-10)}, + }; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "extrude"; + ex.sketch_ref = 0; + ex.distance = 5; + ex.mode = BooleanMode::New; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto bb = doc.display_mesh.bounding_box(); + auto sz = bb.max - bb.min; + REQUIRE(std::abs(sz.x() - 20.0) < 0.5); + REQUIRE(std::abs(sz.y() - 20.0) < 0.5); + REQUIRE(std::abs(sz.z() - 5.0) < 0.5); + } + + SECTION("single circle") { + CadDocument doc; + + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "circle"; + sk.plane = SketchPlane::XY(); + sk.entities = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 10.0}, + }; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "extrude"; + ex.sketch_ref = 0; + ex.distance = 8; + ex.mode = BooleanMode::New; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto bb = doc.display_mesh.bounding_box(); + auto sz = bb.max - bb.min; + REQUIRE(std::abs(sz.x() - 20.0) < 0.5); + REQUIRE(std::abs(sz.y() - 20.0) < 0.5); + REQUIRE(std::abs(sz.z() - 8.0) < 0.5); + } + + SECTION("construction line excluded") { + CadDocument doc; + + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "square_with_construction"; + sk.plane = SketchPlane::XY(); + + SketchEntity cline; + cline.type = SketchEntity::Type::Line; + cline.p0 = Vec2d(-30, 0); + cline.p1 = Vec2d(30, 0); + cline.construction = true; + + sk.entities = { + {SketchEntity::Type::Line, Vec2d(-10,-10), Vec2d(10,-10)}, + {SketchEntity::Type::Line, Vec2d(10,-10), Vec2d(10,10)}, + {SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)}, + {SketchEntity::Type::Line, Vec2d(-10,10), Vec2d(-10,-10)}, + cline, + }; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "extrude"; + ex.sketch_ref = 0; + ex.distance = 5; + ex.mode = BooleanMode::New; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto bb = doc.display_mesh.bounding_box(); + auto sz = bb.max - bb.min; + REQUIRE(std::abs(sz.x() - 20.0) < 0.5); + REQUIRE(std::abs(sz.y() - 20.0) < 0.5); + REQUIRE(std::abs(sz.z() - 5.0) < 0.5); + } +} + +TEST_CASE("construction flag survives recipe round-trip", "[CadDocument]") +{ + CadDocument doc; + + // Square edge + 1 construction line + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(-10, -10), Vec2d(10, -10)}, + {SketchEntity::Type::Line, Vec2d(10, -10), Vec2d(10, 10)}, + {SketchEntity::Type::Line, Vec2d(10, 10), Vec2d(-10, 10)}, + {SketchEntity::Type::Line, Vec2d(-10, 10), Vec2d(-10, -10)}, + }; + SketchEntity cx; + cx.type = SketchEntity::Type::Line; + cx.p0 = Vec2d(-33.0, 7.0); + cx.p1 = Vec2d(33.0, 7.0); + cx.construction = true; + ents.push_back(cx); // ents[4] + + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "SkCtor"); + REQUIRE(sk == 0); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Ex"); + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + + // Locate SkCtor in fresh.features + const CadFeature* sf = nullptr; + for (const auto& f : fresh.features) { + if (f.name == "SkCtor") { sf = &f; break; } + } + REQUIRE(sf != nullptr); + REQUIRE(sf->entities.size() == 5); + REQUIRE(sf->entities[0].construction == false); + REQUIRE(sf->entities[4].construction == true); + REQUIRE_THAT(sf->entities[4].p0.x(), Catch::Matchers::WithinAbs(-33.0, 1e-9)); + REQUIRE_THAT(sf->entities[4].p1.x(), Catch::Matchers::WithinAbs( 33.0, 1e-9)); +} + +// Mirrors the GUI interactive-sketch commit path (DesignPanel -> +// add_sketch_entities) for the Fase 4.1 entity drawing tools: a corner-rect and +// a center-rect produce 4 closed Line entities; a center-circle produces 1 +// Circle entity. add_sketch_entities must store them and extrude into a solid. +TEST_CASE("add_sketch_entities commit path -> extrude", "[CadDocument]") +{ + SECTION("corner-rect 4 lines -> 30x16x5") { + CadDocument doc; + // Corner A=(-15,-8), B=(15,8): the tool's push_closed_lines order. + const Vec2d A(-15, -8), B(15, 8); + std::vector ents = { + {SketchEntity::Type::Line, A, Vec2d(B.x(), A.y())}, + {SketchEntity::Type::Line, Vec2d(B.x(),A.y()), B}, + {SketchEntity::Type::Line, B, Vec2d(A.x(), B.y())}, + {SketchEntity::Type::Line, Vec2d(A.x(),B.y()), A}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "Sketch1"); + REQUIRE(sk == 0); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude1"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + auto sz = doc.display_mesh.bounding_box().size(); + REQUIRE(std::abs(sz.x() - 30.0) < 0.5); + REQUIRE(std::abs(sz.y() - 16.0) < 0.5); + REQUIRE(std::abs(sz.z() - 5.0) < 0.5); + } + + SECTION("center-circle 1 entity -> r=7 cylinder") { + CadDocument doc; + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(0, 0); + c.p0 = Vec2d(0, 0); + c.radius = 7.0; + int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Sketch1"); + doc.add_extrude(sk, 4.0, false, BooleanMode::New, "Extrude1"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + auto sz = doc.display_mesh.bounding_box().size(); + REQUIRE(std::abs(sz.x() - 14.0) < 0.5); + REQUIRE(std::abs(sz.y() - 14.0) < 0.5); + REQUIRE(std::abs(sz.z() - 4.0) < 0.5); + } +} + +// A slot (stadium): 2 lines + 2 semicircular Arc entities forming one closed +// loop — the shape the Fase 4.1b Slot tool emits. Validates the kernel's Arc +// edge path (GC_MakeArcOfCircle via center/radius/start_angle/end_angle, with +// the mid reconstructed at (start+end)/2) inside a mixed Line/Arc wire. +TEST_CASE("slot (line+arc closed wire) -> extrude", "[CadDocument]") +{ + const double PI = 3.14159265358979323846; + CadDocument doc; + + // Centerline ends c0=(-10,0), c1=(10,0); half-width w=5 → stadium 30 x 10. + SketchEntity top; // top line A0(-10,5) -> A1(10,5) + top.type = SketchEntity::Type::Line; top.p0 = Vec2d(-10, 5); top.p1 = Vec2d(10, 5); + + SketchEntity cap1; // right cap @c1=(10,0): A1(10,5) -> B1(10,-5) through (15,0) + cap1.type = SketchEntity::Type::Arc; cap1.center = Vec2d(10, 0); cap1.radius = 5; + cap1.p0 = Vec2d(10, 5); cap1.p1 = Vec2d(10, -5); + cap1.start_angle = PI / 2; cap1.end_angle = -PI / 2; // mid angle 0 -> (15,0) + + SketchEntity bot; // bottom line B1(10,-5) -> B0(-10,-5) + bot.type = SketchEntity::Type::Line; bot.p0 = Vec2d(10, -5); bot.p1 = Vec2d(-10, -5); + + SketchEntity cap0; // left cap @c0=(-10,0): B0(-10,-5) -> A0(-10,5) through (-15,0) + cap0.type = SketchEntity::Type::Arc; cap0.center = Vec2d(-10, 0); cap0.radius = 5; + cap0.p0 = Vec2d(-10, -5); cap0.p1 = Vec2d(-10, 5); + cap0.start_angle = -PI / 2; cap0.end_angle = -3 * PI / 2; // mid angle -PI -> (-15,0) + + int sk = doc.add_sketch_entities({top, cap1, bot, cap0}, SketchPlane::XY(), "Slot"); + doc.add_extrude(sk, 4.0, false, BooleanMode::New, "Extrude1"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + auto sz = doc.display_mesh.bounding_box().size(); + REQUIRE(std::abs(sz.x() - 30.0) < 0.5); + REQUIRE(std::abs(sz.y() - 10.0) < 0.5); + REQUIRE(std::abs(sz.z() - 4.0) < 0.5); +} + +// Onshape-style constraints on coexisting entities (Fase 4.2). The solver maps +// each Line/Point entity endpoint to a solver variable, applies the entity +// constraints, and writes the solved coordinates back into the entities. +TEST_CASE("entity constraints: solve on SketchEntity endpoints", "[CadDocument]") +{ + using R = SketchPointRole; + using T = SketchConstraintType; + + auto dir = [](const SketchEntity& e) { return Vec2d(e.p1 - e.p0); }; + + SECTION("perpendicular rotates line1 normal to a pinned line0") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 (pinned) + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(7,7)}, // line1 @45 deg + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); + ec.push_back({T::Fix, 0, -1, R::P1, R::P0, 0.0}); + ec.push_back({T::Perpendicular, 0, 1, R::P0, R::P0, 0.0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + // line0 stayed put. + REQUIRE(std::abs(e[0].p0.x() - 0.0) < 1e-6); + REQUIRE(std::abs(e[0].p1.x() - 10.0) < 1e-6); + // line1 is now perpendicular to line0: directions dot to ~0. + const double d = dir(e[0]).dot(dir(e[1])); + REQUIRE(std::abs(d) < 1e-6); + } + + // Driving length: the Dimension tool records a Distance between a line's own + // P0/P1 (committed via add_sketch_entities' constraints arg). Solving drives + // the line to that exact length. + SECTION("driving length: Distance(P0,P1) sets a line's length") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // length 10 + }; + std::vector cons; + cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); // pin the start + cons.push_back({T::Distance, 0, 0, R::P0, R::P1, 25.0}); // length -> 25 + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S", cons); + REQUIRE(doc.features[sk].entity_constraints.size() == 2); // constraints stored + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs((e[0].p1 - e[0].p0).norm() - 25.0) < 1e-6); + } + + SECTION("parallel flattens line1 onto a pinned horizontal line0") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 (pinned) + {SketchEntity::Type::Line, Vec2d(0,5), Vec2d(7,9)}, // line1 tilted + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); + ec.push_back({T::Fix, 0, -1, R::P1, R::P0, 0.0}); + ec.push_back({T::Parallel, 0, 1, R::P0, R::P0, 0.0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + const Vec2d d0 = dir(e[0]), d1 = dir(e[1]); + const double cross = d0.x() * d1.y() - d0.y() * d1.x(); + REQUIRE(std::abs(cross) < 1e-6); + } + + SECTION("coincident merges a line endpoint onto another") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 + {SketchEntity::Type::Line, Vec2d(12,1), Vec2d(20,1)}, // line1 + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Coincident, 0, 1, R::P1, R::P0, 0.0}); // line0.end == line1.start + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + const Vec2d gap = Vec2d(e[0].p1 - e[1].p0); + REQUIRE(gap.norm() < 1e-6); + } + + SECTION("horizontal levels a tilted line's endpoints") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,2)}, // tilted line + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Horizontal, 0, 0, R::P0, R::P1, 0.0}); // p0.y == p1.y + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs(e[0].p0.y() - e[0].p1.y()) < 1e-6); + } +} + +TEST_CASE("entity constraints: arc/circle registration + concentric", "[CadDocument]") +{ + using R = SketchPointRole; + using T = SketchConstraintType; + + SECTION("concentric centers coincide") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, // circle0 + {SketchEntity::Type::Circle, Vec2d(10,2), Vec2d(10,2), Vec2d(10,2), 3.0}, // circle1 + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0}); + ec.push_back({T::Concentric, 0, 1, R::Center, R::Center, 0.0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE((e[1].center - e[0].center).norm() < 1e-6); + REQUIRE(e[0].center.x() < 1e-6); + REQUIRE(e[0].center.y() < 1e-6); + } + + SECTION("arc reflow keeps radius and angle consistent") { + CadDocument doc; + const double PI2 = M_PI / 2; + std::vector ents = { + {SketchEntity::Type::Arc, Vec2d(5,0), Vec2d(0,5), Vec2d(0,0), 5.0, 0.0, PI2}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0}); + ec.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs((e[0].p0 - e[0].center).norm() - 5.0) < 1e-6); + REQUIRE(std::abs(e[0].start_angle - 0.0) < 1e-6); + REQUIRE(std::abs(e[0].end_angle - PI2) < 1e-3); + REQUIRE(e[0].end_angle > e[0].start_angle); + } +} + +TEST_CASE("entity constraints: radius/diameter dimensions", "[CadDocument]") +{ + using R = SketchPointRole; + using T = SketchConstraintType; + + SECTION("circle radius dimension") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0, -1, R::P0}); + ec.push_back({T::Radius, 0, -1, R::Center, R::P0, 8.0, -1, R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs(e[0].radius - 8.0) < 1e-9); + } + + SECTION("circle diameter dimension") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0, -1, R::P0}); + ec.push_back({T::Diameter, 0, -1, R::Center, R::P0, 20.0, -1, R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs(e[0].radius - 10.0) < 1e-9); + } + + SECTION("arc radius rescales endpoints") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Arc, Vec2d(5,0), Vec2d(0,5), Vec2d(0,0), 5.0, 0.0, M_PI/2}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0, -1, R::P0}); + ec.push_back({T::Radius, 0, -1, R::Center, R::P0, 10.0, -1, R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs(e[0].radius - 10.0) < 1e-9); + REQUIRE((e[0].p0 - Vec2d(10,0)).norm() < 1e-6); + REQUIRE((e[0].p1 - Vec2d(0,10)).norm() < 1e-6); + } +} + +// PointOnLine (Fase: persistent positioning). A point-like entity is held on a +// line (value 0) or at perpendicular distance `value`. Drives e.g. a circle centre +// onto a construction axis and, being a real constraint, keeps it there on re-solve. +TEST_CASE("entity constraints: point-on-line positions a centre onto an axis", "[CadDocument]") +{ + using R = SketchPointRole; + using T = SketchConstraintType; + + SECTION("circle centre snaps onto a pinned axis (value 0) and persists") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // axis (X) + {SketchEntity::Type::Circle, Vec2d(5,7), Vec2d(5,7), Vec2d(5,7), 3.0}, // off-axis centre + }; + std::vector cons; + cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); // pin axis endpoints + cons.push_back({T::Fix, 0, -1, R::P1, R::P1, 0.0}); + cons.push_back({T::PointOnLine, 1, 0, R::Center, R::P0, 0.0}); // centre onto axis + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S", cons); + REQUIRE(doc.solve_sketch_feature(sk)); + REQUIRE(std::abs(doc.features[sk].entities[1].center.y()) < 1e-6); // on the axis + // Re-solving keeps it on the axis (a driving constraint, not a one-shot move). + REQUIRE(doc.solve_sketch_feature(sk)); + REQUIRE(std::abs(doc.features[sk].entities[1].center.y()) < 1e-6); + } + + SECTION("non-zero perpendicular distance via the free solve_sketch_entities") { + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, + {SketchEntity::Type::Point, Vec2d(4,9)}, // point above the axis + }; + std::vector cons; + cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); + cons.push_back({T::Fix, 0, -1, R::P1, R::P1, 0.0}); + cons.push_back({T::PointOnLine, 1, 0, R::P0, R::P0, 2.0}); // hold at distance 2 + REQUIRE(solve_sketch_entities(ents, cons)); + REQUIRE(std::abs(std::abs(ents[1].p0.y()) - 2.0) < 1e-6); // 2 mm off the axis + } +} + +// The "tangent line to circle" SECTION used to abort the whole Catch2 process inside the +// vendored solver (slvs/dsc.h FindById, "Cannot find handle"), taking every later test with +// it, and was quarantined for it. Fixed in SketchSolver: a full circle can no longer be handed +// to SLVS_C_ARC_LINE_TANGENT, which dereferences arc endpoints a circle does not have. See +// tkz. +TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument][sketch]") +{ + using R = SketchPointRole; + using T = SketchConstraintType; + + auto dir = [](const SketchEntity& e) { return Vec2d(e.p1 - e.p0); }; + + SECTION("angle 90 between two lines") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(5,5)}, // line1 + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0,-1, R::P0,R::P0, 0.0, -1,R::P0}); + ec.push_back({T::Fix, 0,-1, R::P1,R::P0, 0.0, -1,R::P0}); + ec.push_back({T::Fix, 1,-1, R::P0,R::P0, 0.0, -1,R::P0}); + ec.push_back({T::Angle,0, 1, R::P0,R::P0, M_PI/2,-1,R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + const Vec2d d0 = dir(e[0]).normalized(); + const Vec2d d1 = dir(e[1]).normalized(); + REQUIRE(std::abs(d0.dot(d1)) < 1e-3); + } + + SECTION("midpoint of a line") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 + {SketchEntity::Type::Point, Vec2d(3,9)}, // point p + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0,-1, R::P0,R::P0, 0.0,-1,R::P0}); + ec.push_back({T::Fix, 0,-1, R::P1,R::P0, 0.0,-1,R::P0}); + ec.push_back({T::Midpoint,1, 0, R::P0,R::P0, 0.0,-1,R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE((e[1].p0 - Vec2d(5,0)).norm() < 1e-3); + } + + SECTION("tangent line to circle") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, // circle0 r=5 + {SketchEntity::Type::Line, Vec2d(-10,8), Vec2d(10,8)}, // line1 y=8 + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0,-1, R::Center,R::Center, 0.0,-1,R::P0}); + ec.push_back({T::LockX, 1,-1, R::P0, R::P0, -10.0,-1,R::P0}); + ec.push_back({T::LockX, 1,-1, R::P1, R::P0, 10.0,-1,R::P0}); + ec.push_back({T::Horizontal, 1, 1, R::P0, R::P1, 0.0,-1,R::P0}); + ec.push_back({T::Tangent, 0, 1, R::Center,R::P0, 0.0,-1,R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE(std::abs(std::abs(e[1].p0.y()) - 5.0) < 1e-3); + } + + SECTION("symmetric across a line") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Point, Vec2d(2,3)}, // pointA + {SketchEntity::Type::Point, Vec2d(-1,1)}, // pointB + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(0,10)}// axis (Y axis) + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0,-1, R::P0,R::P0, 0.0,-1,R::P0}); + ec.push_back({T::Fix, 2,-1, R::P0,R::P0, 0.0,-1,R::P0}); + ec.push_back({T::Fix, 2,-1, R::P1,R::P0, 0.0,-1,R::P0}); + ec.push_back({T::Symmetric, 0, 1, R::P0,R::P0, 0.0, 2,R::P0}); + + REQUIRE(doc.solve_sketch_feature(sk)); + const auto& e = doc.features[sk].entities; + REQUIRE((e[1].p0 - Vec2d(-2,3)).norm() < 1e-3); + } +} + +TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]") +{ + std::string font = resources_dir().empty() + ? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") + : resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; + { + std::ifstream probe(font); + if (!probe.good()) { + SUCCEED("bundled font not reachable in this environment; covered live on :10"); + return; + } + } + + const std::string charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789" + "@#$%&*()[]{}<>?/+-=.,;:!" + "\xC3\xA0\xC3\xA8\xC3\xA9\xC3\xAC\xC3\xB2\xC3\xB9"; // à è é ì ò ù (UTF-8) + + std::string failures; + for (char c : charset) { + ImportRegions regs = text_to_regions(std::string(1, c), 12.0, font); + if (regs.empty()) continue; + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = regs; + doc.features.push_back(sk); + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 3; + doc.features.push_back(ex); + if (!doc.recompute() || !doc.error.empty()) + failures += c; + } + INFO("glyphs that failed to extrude: [" << failures << "]"); + CHECK(failures.empty()); + + // A realistic multi-glyph word must extrude too. + { + ImportRegions regs = text_to_regions("Snapmaker", 12.0, font); + REQUIRE_FALSE(regs.empty()); + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = regs; + doc.features.push_back(sk); + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 3; + doc.features.push_back(ex); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + } +} + +TEST_CASE("imported regions: faces-with-holes extrude (Text/SVG carrier)", "[CadDocument]") +{ + SECTION("square with a square hole -> tube volume") { + CadDocument doc; + + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "art"; + sk.plane = SketchPlane::XY(); + // One region: outer 20x20 (CCW) + inner 8x8 hole (CW). + sk.imported_regions = {{ + {Vec2d(-10,-10), Vec2d(10,-10), Vec2d(10,10), Vec2d(-10,10)}, // outer + {Vec2d(-4,-4), Vec2d(-4,4), Vec2d(4,4), Vec2d(4,-4)}, // hole (reversed winding) + }}; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "extrude"; + ex.sketch_ref = 0; + ex.distance = 5; + ex.mode = BooleanMode::New; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + // (20*20 - 8*8) * 5 = 1680 mm^3 + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(1680.0, 0.02)); + + auto sz = doc.display_mesh.bounding_box().size(); + REQUIRE(std::abs(sz.x() - 20.0) < 0.5); + REQUIRE(std::abs(sz.y() - 20.0) < 0.5); + REQUIRE(std::abs(sz.z() - 5.0) < 0.5); + } + + SECTION("inverted winding (CW outer, CCW hole) keeps body solid, counter empty") { + // Real glyph contours (P, e, o, 8) arrive with outer CW + hole CCW. The + // extrude must NORMALISE winding so the letter BODY is solid and the + // counter is the hole — not the inverse (the reported bug). 20x20 outer + // CW with an 8x8 hole CCW -> volume (400-64)*5 = 1680, NOT the inverse. + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = {{ + {Vec2d(-10,-10), Vec2d(-10,10), Vec2d(10,10), Vec2d(10,-10)}, // outer CW + {Vec2d(-4,-4), Vec2d(4,-4), Vec2d(4,4), Vec2d(-4,4)}, // hole CCW + }}; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 5; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(1680.0, 0.02)); + } + + SECTION("degenerate / duplicate points are sanitised, not fatal") { + // FreeType/SVG flattening can emit repeated points; the extrude must + // survive them (previously to_occt_wire threw and failed the whole op). + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = {{ + // 20x20 outer square with consecutive dups + an explicit closing dup + {Vec2d(-10,-10), Vec2d(-10,-10), Vec2d(10,-10), Vec2d(10,-10), + Vec2d(10,10), Vec2d(-10,10), Vec2d(-10,-10)}, + }}; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 5; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(2000.0, 0.02)); + } + + SECTION("a degenerate region is skipped, valid ones still extrude") { + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = { + { {Vec2d(0,0), Vec2d(0,0), Vec2d(0,0)} }, // collapses to nothing + { {Vec2d(0,0), Vec2d(5,0), Vec2d(5,5), Vec2d(0,5)} }, // valid 5x5 + }; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 4; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(100.0, 0.02)); + } + + SECTION("two disjoint regions form one shape") { + CadDocument doc; + + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = { + {{Vec2d(0,0), Vec2d(5,0), Vec2d(5,5), Vec2d(0,5)}}, + {{Vec2d(10,0), Vec2d(15,0), Vec2d(15,5), Vec2d(10,5)}}, + }; + doc.features.push_back(sk); + + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 3; + doc.features.push_back(ex); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + // 2 * (5*5*3) = 150 mm^3 + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(150.0, 0.02)); + } +} + +TEST_CASE("tessellate tracks per-triangle face id", "[CadDocument]") +{ + TopoDS_Shape box = BRepPrimAPI_MakeBox(10., 10., 10.).Shape(); + std::vector tf; + TriangleMesh m = SketchEngine::tessellate(box, tf); + + REQUIRE(tf.size() == m.its.indices.size()); + REQUIRE(!tf.empty()); + + std::set distinct(tf.begin(), tf.end()); + REQUIRE(distinct.size() == 6); + REQUIRE(*distinct.begin() == 0); + REQUIRE(*distinct.rbegin() == 5); + + for (int fid : distinct) { + int count = 0; + for (int x : tf) if (x == fid) ++count; + REQUIRE(count >= 2); + } + + REQUIRE(m.its.indices.size() >= 12); +} + +TEST_CASE("extrude two-sided + through-all + intersect", "[CadDocument]") +{ + using namespace Slic3r; + SketchPlane xy = SketchPlane::XY(); + // a 10x10 square wire centred on origin + SketchProfile sp; + sp.points = { Vec2d(-5,-5), Vec2d(5,-5), Vec2d(5,5), Vec2d(-5,5) }; + sp.closed = true; + TopoDS_Wire w = sp.to_occt_wire(xy); + + SECTION("two-sided height = up+down") { + TopoDS_Shape s = SketchEngine::make_extrude_two_sided(w, xy, 10.0, 4.0); + REQUIRE_FALSE(s.IsNull()); + Bnd_Box bb; BRepBndLib::Add(s, bb); + double xmin,ymin,zmin,xmax,ymax,zmax; bb.Get(xmin,ymin,zmin,xmax,ymax,zmax); + REQUIRE_THAT(zmax - zmin, Catch::Matchers::WithinAbs(14.0, 0.05)); // 10 up + 4 down + REQUIRE_THAT(zmax, Catch::Matchers::WithinAbs(10.0, 0.05)); + REQUIRE_THAT(zmin, Catch::Matchers::WithinAbs(-4.0, 0.05)); + } +} + +TEST_CASE("extrude taper + up-to-face distance", "[CadDocument]") +{ + using namespace Slic3r; + SketchPlane xy = SketchPlane::XY(); + SketchProfile sp; sp.points = { Vec2d(-5,-5),Vec2d(5,-5),Vec2d(5,5),Vec2d(-5,5) }; sp.closed = true; + TopoDS_Wire w = sp.to_occt_wire(xy); + + SECTION("taper widens the top") { + TopoDS_Shape s = SketchEngine::make_extrude_taper(w, xy, 10.0, 15.0); + REQUIRE_FALSE(s.IsNull()); + Bnd_Box bb; BRepBndLib::Add(s, bb); + double x0,y0,z0,x1,y1,z1; bb.Get(x0,y0,z0,x1,y1,z1); + REQUIRE_THAT(z1 - z0, Catch::Matchers::WithinAbs(10.0, 0.1)); + REQUIRE((x1 - x0) > 12.0); + } + SECTION("extreme taper falls back to a straight prism") { + TopoDS_Shape s = SketchEngine::make_extrude_taper(w, xy, 10.0, 89.0); + REQUIRE_FALSE(s.IsNull()); + Bnd_Box bb; BRepBndLib::Add(s, bb); + double x0,y0,z0,x1,y1,z1; bb.Get(x0,y0,z0,x1,y1,z1); + REQUIRE_THAT(x1 - x0, Catch::Matchers::WithinAbs(10.0, 0.1)); + } +} + +// Was [known-broken] until the numbers were actually measured (kzy). The geometry +// was right all along; the TEST compared against the wrong reference. An internal thread bores +// at the MINOR radius (radius - depth) and then carves the groove out to radius + depth, so a +// tapped hole keeps the crests between turns and therefore holds MORE material than a plain +// clearance hole at the nominal radius. Asserting the thread removes more than a plain Ø12 bore +// asked for something no real tapped hole does. Measured on this fixture: plain Ø12 bore removes +// 2261 mm3, the thread removes 2157 = 1571 (minor bore) + 586 (groove). Exact BRepGProp volume +// agrees with the tessellated volume to 2.5 mm3, so this was never a meshing artefact either. +// The tap-drill bore is the honest reference: against it, the groove's 586 mm3 is the thing worth +// asserting, because that is what "the thread actually cuts" means. +TEST_CASE("internal thread cuts a visible groove into the bore wall", "[CadDocument][thread]") +{ + using namespace Slic3r; + SketchPlane xy = SketchPlane::XY(); + + // 40x40x20 box centred on the origin, extruded +Z. + auto make_box = [&](CadDocument& doc) { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = xy; + sk.imported_regions = {{ + {Vec2d(-20,-20), Vec2d(20,-20), Vec2d(20,20), Vec2d(-20,20)}, + }}; + doc.features.push_back(sk); + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = 20; + doc.features.push_back(ex); + }; + + // Reference: box bored at the MINOR diameter — the tap drill the thread starts from. + // Ø10 = 2 * (radius 6 - depth 1), matching what apply_thread cuts before the groove. + CadDocument hole_doc; + make_box(hole_doc); + hole_doc.add_hole(10.0, 20.0, true, 0.0, 0.0, xy, "Hole"); + REQUIRE(hole_doc.recompute()); + REQUIRE(hole_doc.error.empty()); + const double v_hole = double(hole_doc.display_mesh.volume()); + + // Threaded: same box, internal thread of radius 6 (the bore radius). + CadDocument thr_doc; + make_box(thr_doc); + thr_doc.add_thread(6.0, 3.0, 20.0, 1.0, /*internal=*/true, 0.0, 0.0, xy, "Thread"); + REQUIRE(thr_doc.recompute()); + REQUIRE(thr_doc.error.empty()); + const double v_thread = double(thr_doc.display_mesh.volume()); + + // The groove must carve material out of the wall BEYOND the tap-drill bore, which is what + // makes the thread visible. A profile that only swept already-empty bore space would give + // v_thread ~= v_hole; the real one removes several hundred mm3 more. + REQUIRE(v_thread > 0.0); + REQUIRE(v_thread < v_hole); + REQUIRE((v_hole - v_thread) > 20.0); +} + +TEST_CASE("revolve builds a solid of revolution about an in-plane axis", "[CadDocument]") +{ + using namespace Slic3r; + SketchPlane xy = SketchPlane::XY(); + + // Rectangle profile (10 wide x 10 tall, area 100) offset to +v so it lies entirely + // on one side of the X axis; revolved 360deg about X -> a rectangular-section ring. + // Pappus: V = 2*pi*R*A = 2*pi*15*100 = ~9424.78 mm^3 (tessellation slightly under). + auto make_rev_doc = [&](double angle, int axis, double u0, double v0) { + auto doc = std::make_unique(); + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = xy; + sk.profile.points = { Vec2d(u0 - 5, v0 - 5), Vec2d(u0 + 5, v0 - 5), + Vec2d(u0 + 5, v0 + 5), Vec2d(u0 - 5, v0 + 5) }; + sk.profile.closed = true; + doc->features.push_back(sk); + doc->add_revolve(0, angle, axis, false, BooleanMode::New, "Rev"); + return doc; + }; + + auto full = make_rev_doc(360.0, /*axis=X*/0, 0.0, 15.0); + REQUIRE(full->recompute()); + REQUIRE(full->error.empty()); + const double v_full = double(full->display_mesh.volume()); + REQUIRE(v_full > 0.0); + REQUIRE(v_full == Approx(9424.78).epsilon(0.05)); + + // A 180deg sweep removes exactly half the material. + auto half = make_rev_doc(180.0, 0, 0.0, 15.0); + REQUIRE(half->recompute()); + REQUIRE(half->error.empty()); + const double v_half = double(half->display_mesh.volume()); + REQUIRE(v_half > 0.0); + REQUIRE(v_full == Approx(2.0 * v_half).epsilon(0.05)); + + // Axis = plane Y: profile offset to +u (one side of the Y axis) gives the same ring. + auto ydoc = make_rev_doc(360.0, /*axis=Y*/1, 15.0, 0.0); + REQUIRE(ydoc->recompute()); + REQUIRE(ydoc->error.empty()); + REQUIRE(double(ydoc->display_mesh.volume()) == Approx(9424.78).epsilon(0.05)); +} + +TEST_CASE("sweep builds a solid by sweeping a profile along a path", "[CadDocument]") +{ + using namespace Slic3r; + CadDocument doc; + + // Profile: circle r=5 on the XY plane at the origin (area = 25*pi). + SketchEntity circ; + circ.type = SketchEntity::Type::Circle; + circ.center = Vec2d(0, 0); + circ.radius = 5.0; + const int prof = doc.add_sketch_entities({circ}, SketchPlane::XY(), "Profile"); + + // Path: a straight line on the XZ plane from 2D (0,0)->(0,100), i.e. world + // (0,0,0)->(0,0,100): the spine starts on the profile plane and runs +Z by 100. + SketchEntity line; + line.type = SketchEntity::Type::Line; + line.p0 = Vec2d(0, 0); + line.p1 = Vec2d(0, 100); + const int path = doc.add_sketch_entities({line}, SketchPlane::XZ(), "Path"); + + doc.add_sweep(prof, path, BooleanMode::New, "Sweep1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Straight sweep of a circle == a cylinder: V = pi*r^2*h = pi*25*100 = ~7853.98. + const double v = double(doc.display_mesh.volume()); + REQUIRE(v > 0.0); + REQUIRE_THAT(v, Catch::Matchers::WithinRel(M_PI * 25.0 * 100.0, 0.03)); + + // A valid path sketch is mandatory: a -1 path ref must error cleanly, not crash. + CadDocument bad; + const int p2 = bad.add_sketch_entities({circ}, SketchPlane::XY(), "Profile"); + bad.add_sweep(p2, -1, BooleanMode::New, "BadSweep"); + REQUIRE_FALSE(bad.recompute()); +} + +TEST_CASE("pattern replicates a body linearly and circularly", "[CadDocument]") +{ + using namespace Slic3r; + + // Linear: a 10x10x10 box (V=1000) repeated 3x at 20mm spacing along plane X. + // 20 > 10 so the copies are disjoint -> total V = 3*1000 = 3000. + { + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 10, 10, 5, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + doc.add_pattern(/*circular=*/false, /*count=*/3, /*spacing=*/20, + /*dir=*/0, /*angle=*/0, /*target=*/-1, "LinearPattern"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double v = double(doc.display_mesh.volume()); + REQUIRE_THAT(v, Catch::Matchers::WithinRel(3000.0, 0.02)); + } + + // Circular: a 10x10x10 box centred at x=50 (radius 50 from the Z axis), 4 copies + // over 360deg about the plane normal through the origin -> a ring of 4 disjoint + // boxes -> V = 4*1000 = 4000. + { + CadDocument doc; + SketchProfile sp; + sp.points.push_back(Vec2d(45, -5)); + sp.points.push_back(Vec2d(55, -5)); + sp.points.push_back(Vec2d(55, 5)); + sp.points.push_back(Vec2d(45, 5)); + sp.closed = true; + int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "OffsetBox"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + doc.add_pattern(/*circular=*/true, /*count=*/4, /*spacing=*/0, + /*dir=*/0, /*angle=*/360, /*target=*/-1, "CircularPattern"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double v = double(doc.display_mesh.volume()); + REQUIRE_THAT(v, Catch::Matchers::WithinRel(4000.0, 0.02)); + } + + // A pattern with no body must error cleanly, not crash. + { + CadDocument bad; + bad.add_pattern(false, 3, 20, 0, 0, -1, "NoBody"); + REQUIRE_FALSE(bad.recompute()); + } +} + +TEST_CASE("pattern-on-curve: copies land on a line and bbox spans the curve length", "[CadDocument][pattern]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + using namespace Slic3r; + + CadDocument doc; + + // Seed body: 4x4x4 box at the origin via sketch+extrude. + int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed"); + doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E"); + + // Guide sketch: one Line entity from (0,0) to (30,0) on XY. + std::vector guide = { + {SketchEntity::Type::Line, Vec2d(0, 0), Vec2d(30, 0)}, + }; + int gs = doc.add_sketch_entities(guide, SketchPlane::XY(), "Guide"); + + doc.add_pattern_on_curve(4, gs, 0, 0, "OnCurve"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto bb = doc.display_mesh.bounding_box(); + double x_extent = bb.max.x() - bb.min.x(); + // 4 copies: at x=0, x=10, x=20, x=30. The seed is a 4x4 box centred at origin, + // so the overall X span is from -2 to 32 = 34 mm. + REQUIRE_THAT(x_extent, WithinAbs(34.0, 2.0)); +} + +TEST_CASE("pattern-on-curve: bad refs are safe", "[CadDocument][pattern]") +{ + using namespace Slic3r; + + CadDocument doc; + int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed"); + doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E"); + + // Bad sketch ref -> error. + doc.add_pattern_on_curve(3, 999, 0, 0, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); + REQUIRE(doc.error.find("pattern") != std::string::npos); +} + +TEST_CASE("pattern-on-curve: round-trip through serialize/deserialize", "[CadDocument][pattern]") +{ + using Catch::Matchers::WithinRel; + using namespace Slic3r; + + CadDocument doc; + int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed"); + doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E"); + + std::vector guide = { + {SketchEntity::Type::Line, Vec2d(0, 0), Vec2d(30, 0)}, + }; + int gs = doc.add_sketch_entities(guide, SketchPlane::XY(), "Guide"); + + doc.add_pattern_on_curve(4, gs, 0, 0, "OnCurve"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto orig_bb = doc.display_mesh.bounding_box(); + size_t orig_n = doc.bodies.size(); + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.recompute()); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.bodies.size() == orig_n); + + auto fresh_bb = fresh.display_mesh.bounding_box(); + REQUIRE_THAT(orig_bb.min.x(), WithinRel(fresh_bb.min.x(), 1e-6)); + REQUIRE_THAT(orig_bb.max.x(), WithinRel(fresh_bb.max.x(), 1e-6)); + + // Verify the deserialized field values. + bool found = false; + for (const auto& f : fresh.features) { + if (f.name == "OnCurve") { + REQUIRE(f.pattern_curve_sketch == gs); + REQUIRE(f.pattern_curve_entity == 0); + found = true; + break; + } + } + REQUIRE(found); +} + +TEST_CASE("thread standards table carries correct ISO/UTS measures", "[CadDocument]") +{ + using namespace Slic3r; + + // Table is non-empty and every entry is self-consistent. + const auto& table = thread_standards(); + REQUIRE(table.size() > 40); + for (const ThreadSpec& s : table) { + REQUIRE(s.major_diameter_mm > 0.0); + REQUIRE(s.pitch_mm > 0.0); + REQUIRE(s.minor_diameter_mm() < s.major_diameter_mm); + REQUIRE(s.minor_diameter_mm() > 0.0); + // 60deg V cut depth = 0.6134 * pitch. + REQUIRE(s.thread_depth_mm() == Approx(0.6134 * s.pitch_mm)); + } + + // ISO metric coarse: known nominal/pitch pairs. + const ThreadSpec* m6 = find_thread_standard("M6"); + REQUIRE(m6 != nullptr); + REQUIRE(m6->major_diameter_mm == Approx(6.0)); + REQUIRE(m6->pitch_mm == Approx(1.0)); + REQUIRE(m6->series == ThreadSpec::Series::MetricCoarse); + REQUIRE_FALSE(m6->imperial()); + REQUIRE(m6->thread_depth_mm() == Approx(0.6134)); + // Tapped minor (tap-drill) diameter D - 1.0825*P = 6 - 1.0825 = 4.9175. + REQUIRE(m6->minor_diameter_mm() == Approx(4.9175)); + + const ThreadSpec* m3 = find_thread_standard("M3"); + REQUIRE(m3 != nullptr); + REQUIRE(m3->pitch_mm == Approx(0.5)); + + // Imperial UNC: 1/4-20 -> 0.25in major, pitch = 25.4/20 = 1.27 mm. + const ThreadSpec* q = find_thread_standard("1/4-20 UNC"); + REQUIRE(q != nullptr); + REQUIRE(q->major_diameter_mm == Approx(6.35)); + REQUIRE(q->pitch_mm == Approx(1.27)); + REQUIRE(q->series == ThreadSpec::Series::UNC); + REQUIRE(q->imperial()); + + // Imperial UNF fine variant has a finer pitch than its UNC sibling. + const ThreadSpec* qf = find_thread_standard("1/4-28 UNF"); + REQUIRE(qf != nullptr); + REQUIRE(qf->major_diameter_mm == Approx(6.35)); + REQUIRE(qf->pitch_mm == Approx(25.4 / 28.0)); + REQUIRE(qf->pitch_mm < q->pitch_mm); + + // Unknown designation -> nullptr. + REQUIRE(find_thread_standard("M7.3 bogus") == nullptr); +} + +TEST_CASE("datum plane: offset + tilt resolution and sketching on it", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + // Parallel offset plane 30 mm above XY (normal +Z, origin at z=30). + CadDocument doc; + int p0 = doc.add_plane(0 /*XY*/, 30.0, 0.0, 0, "Plane1"); + REQUIRE(p0 == 0); + + auto planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == 1); + REQUIRE(planes[0].first == "Plane1"); + CHECK_THAT(planes[0].second.origin.z(), WithinAbs(30.0, 1e-9)); + CHECK_THAT(planes[0].second.normal.z(), WithinAbs(1.0, 1e-9)); + + // A second datum plane tilted 90 deg about the base (XY) X axis: its normal + // rotates from +Z toward -Y (Rodrigues about +X: +Z -> -Y). + doc.add_plane(0 /*XY*/, 0.0, 90.0, 0 /*about X*/, "Plane2"); + planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == 2); + CHECK_THAT(planes[1].second.normal.y(), WithinAbs(-1.0, 1e-9)); + CHECK_THAT(planes[1].second.normal.z(), WithinAbs(0.0, 1e-9)); + + // Sketch a 10x10 square ON Plane1 and extrude 4 mm: the solid must sit in z=[30,34]. + SketchPlane sp = planes[0].second; + SketchProfile prof; + prof.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; + prof.closed = true; + int sk = doc.add_sketch_profile(prof, sp, "S"); + doc.add_extrude(sk, 4.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + + Bnd_Box bb; + BRepBndLib::Add(doc.body, bb); + double xmin,ymin,zmin,xmax,ymax,zmax; + bb.Get(xmin,ymin,zmin,xmax,ymax,zmax); + CHECK_THAT(zmin, WithinAbs(30.0, 1e-6)); + CHECK_THAT(zmax, WithinAbs(34.0, 1e-6)); + CHECK_THAT(double(doc.display_mesh.volume()), WithinRel(400.0, 0.02)); + + // A datum-plane-only document has no solid, and that is a benign SUCCESS, not a benign + // failure. It used to return false, and "benign failure" is exactly the phrasing that hid + // mtav: two callers read the false as "unusable document" and threw the design + // away — the 3MF recipe was never written, and a project that had one was refused on load. + CadDocument only_plane; + only_plane.add_plane(0, 10.0, 0.0, 0, "P"); + REQUIRE(only_plane.recompute()); + REQUIRE(only_plane.error.empty()); + REQUIRE(only_plane.bodies.empty()); +} + +TEST_CASE("loft builds a solid skinning two profiles on parallel planes", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Bottom 20x20 square on XY. + SketchProfile bot; + bot.points = {{-10,-10},{10,-10},{10,10},{-10,10}}; + bot.closed = true; + int s0 = doc.add_sketch_profile(bot, SketchPlane::XY(), "Bottom"); + + // Top 10x10 square on a datum plane 20 mm above XY (exercises datum -> loft). + doc.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1"); + SketchPlane top = doc.resolve_datum_planes()[0].second; + SketchProfile tp; + tp.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; + tp.closed = true; + int s1 = doc.add_sketch_profile(tp, top, "Top"); + + // Ruled (straight) sections -> exact planar end caps at z=0 and z=20. + doc.add_loft({s0, s1}, true /*ruled*/, BooleanMode::New, "Loft1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // The loft spans z=[0,20]. ThruSections approximates each section as a BSpline + // curve, so the lateral surface bulges ~0.05 mm past the end planes (sub-visual, + // ~0.25%) — assert the span loosely; the volume below is the real correctness gate. + Bnd_Box bb; + BRepBndLib::Add(doc.body, bb); + double xmin,ymin,zmin,xmax,ymax,zmax; + bb.Get(xmin,ymin,zmin,xmax,ymax,zmax); + CHECK_THAT(zmin, WithinAbs(0.0, 0.1)); + CHECK_THAT(zmax, WithinAbs(20.0, 0.1)); + // Square frustum volume = h/3*(A1+A2+sqrt(A1*A2)) = 20/3*(400+100+200) = 4666.67. + CHECK_THAT(double(doc.display_mesh.volume()), WithinRel(4666.67, 0.03)); + + // A single profile is not enough -> benign recompute failure. + CadDocument one; + SketchProfile sp; sp.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; sp.closed = true; + int only = one.add_sketch_profile(sp, SketchPlane::XY(), "Only"); + one.add_loft({only}, false, BooleanMode::New, "L"); + REQUIRE_FALSE(one.recompute()); +} + +TEST_CASE("draft tapers a solid face about the body base", "[CadDocument]") +{ + using namespace Slic3r; + + // 10x10x10 box from z=0..10 (V=1000). Drafting a vertical side face by +10deg about + // the bottom (neutral) plane tilts its top edge inward, removing material so V<1000. + // A box's 2 horizontal faces are parallel to the neutral plane and cannot be drafted + // (Add fails -> recompute returns false), so exactly the 4 vertical sides succeed. + int ok_faces = 0; + bool saw_taper = false; + for (int fid = 0; fid < 6; ++fid) { + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + doc.add_draft(10.0, fid, -1, "Draft1"); + if (!doc.recompute()) continue; // top/bottom faces: parallel to base -> benign skip + ++ok_faces; + const double v = double(doc.display_mesh.volume()); + REQUIRE(v > 0.0); + if (v < 999.0) saw_taper = true; + } + REQUIRE(ok_faces == 4); + REQUIRE(saw_taper); + + // Draft with no body must fail cleanly, not crash. + CadDocument bad; + bad.add_draft(5.0, 0, -1, "NoBody"); + REQUIRE_FALSE(bad.recompute()); +} + +TEST_CASE("a split renumbers the bodies a later feature indexes", "[CadDocument][cut]") +{ + // Pins the invariant the Design tab's re-edit path depends on (oz7): a stored + // target_body indexes the body list AS IT WAS when that feature ran, and a Cut placed + // later in the tree changes that list. If this test ever fails, the GUI's + // fill_body_choice() replay-to-timeline-slot assumption needs revisiting with it. + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + // Cut the single body in half, keeping BOTH pieces — what the Cut card always does. + doc.add_cut(SketchPlane::XY(), 10.0, false, true, true, 0, "Split"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); // one body became two + + // Body index 1 did not exist before the Cut. A feature recorded BEFORE the Cut could + // never have referred to it, which is exactly why re-edit must list the earlier set. + const double half = 20.0 * 20.0 * 10.0; + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + CHECK_THAT(v0 + v1, WithinRel(2.0 * half, 1e-3)); + + // Replaying to just before the Cut yields the pre-split list — the one a feature sitting + // there indexes into. This is the operation fill_body_choice() performs. + CadDocument as_of = doc; + as_of.features.resize(as_of.features.size() - 1); // drop the Cut + REQUIRE(as_of.recompute()); + REQUIRE(as_of.bodies.size() == 1); + CHECK(as_of.bodies.size() < doc.bodies.size()); +} + +TEST_CASE("cut splits a body with a plane", "[cut]") +{ + using Catch::Matchers::WithinRel; + + auto make_box = [](CadDocument& doc, double w, double h, double d) { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + sk.imported_regions = {{ + {Vec2d(-w / 2, -h / 2), Vec2d(w / 2, -h / 2), + Vec2d(w / 2, h / 2), Vec2d(-w / 2, h / 2)}, + }}; + doc.features.push_back(sk); + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.sketch_ref = 0; + ex.distance = d; + doc.features.push_back(ex); + }; + + SECTION("keep upper half only") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double v_orig = double(doc.display_mesh.volume()); + const int n_before = int(doc.bodies.size()); + REQUIRE(v_orig > 0.0); + + CadFeature cut; + cut.type = CadFeatureType::Cut; + cut.plane = SketchPlane::XY(); + cut.cut_offset = 10.0; // mid-height of the 0..20 box + cut.cut_keep_upper = true; + cut.cut_keep_lower = false; + doc.features.push_back(cut); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(int(doc.bodies.size()) == n_before); + REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig * 0.5, 0.01)); + } + + SECTION("keep both halves splits into two bodies") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + const double v_orig = double(doc.display_mesh.volume()); + const int n_before = int(doc.bodies.size()); + REQUIRE(v_orig > 0.0); + + CadFeature cut; + cut.type = CadFeatureType::Cut; + cut.plane = SketchPlane::XY(); + cut.cut_offset = 10.0; + cut.cut_keep_upper = true; + cut.cut_keep_lower = true; + doc.features.push_back(cut); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(int(doc.bodies.size()) == n_before + 1); + REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig, 0.01)); + + for (const auto& b : doc.bodies) { + double v = double(SketchEngine::tessellate(b.shape).volume()); + REQUIRE_THAT(v, WithinRel(v_orig * 0.5, 0.01)); + } + } + + SECTION("keep lower half only") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + const double v_orig = double(doc.display_mesh.volume()); + const int n_before = int(doc.bodies.size()); + + CadFeature cut; + cut.type = CadFeatureType::Cut; + cut.plane = SketchPlane::XY(); + cut.cut_offset = 10.0; + cut.cut_keep_upper = false; + cut.cut_keep_lower = true; + doc.features.push_back(cut); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(int(doc.bodies.size()) == n_before); + REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig * 0.5, 0.01)); + } + + SECTION("flip swaps which side is kept") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + const double v_orig = double(doc.display_mesh.volume()); + + // cut with flip=true, keep_upper=true → the -normal side (original bottom half) + CadFeature cut; + cut.type = CadFeatureType::Cut; + cut.plane = SketchPlane::XY(); + cut.cut_offset = 10.0; + cut.cut_flip = true; + cut.cut_keep_upper = true; + cut.cut_keep_lower = false; + doc.features.push_back(cut); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig * 0.5, 0.01)); + } + + SECTION("both keep flags false throws") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + + CadFeature cut; + cut.type = CadFeatureType::Cut; + cut.plane = SketchPlane::XY(); + cut.cut_keep_upper = false; + cut.cut_keep_lower = false; + doc.features.push_back(cut); + + REQUIRE_FALSE(doc.recompute()); + } +} + +TEST_CASE("both-halves plane cut splits a body into two equal halves", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + double v_orig = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE(v_orig > 0.0); + + doc.add_cut(SketchPlane::XY(), 5.0, false, true, true, 0, "SplitBoth"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v0 + v1, WithinRel(v_orig, 1e-4)); + REQUIRE_THAT(v0, WithinRel(20.0 * 20.0 * 5.0, 0.01)); + REQUIRE_THAT(v1, WithinRel(20.0 * 20.0 * 5.0, 0.01)); +} + +TEST_CASE("split by a body face divides a box into two halves via top-face offset", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + double v_orig = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_split_by_face(0, -1, top_face, true, true, "SplitByFace"); + doc.features.back().cut_offset = -5.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v0 + v1, WithinRel(v_orig, 1e-4)); + REQUIRE_THAT(v0, WithinRel(20.0 * 20.0 * 5.0, 0.02)); + REQUIRE_THAT(v1, WithinRel(20.0 * 20.0 * 5.0, 0.02)); +} + +TEST_CASE("split by face keep upper only", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_split_by_face(0, -1, top_face, true, false, "SplitUpper"); + doc.features.back().cut_offset = -5.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + + double v = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 0.02)); +} + +TEST_CASE("split by face round-trip serialization", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_split_by_face(0, 0, top_face, true, true, "SplitRT"); + doc.features.back().cut_offset = -5.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + int saved_face_body = doc.features.back().cut_face_body; + int saved_face = doc.features.back().cut_face; + bool saved_upper = doc.features.back().cut_keep_upper; + bool saved_lower = doc.features.back().cut_keep_lower; + size_t saved_nb = doc.bodies.size(); + + std::vector> bboxes; + for (const auto& b : doc.bodies) { + Bnd_Box bb; BRepBndLib::Add(b.shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}); + } + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.bodies.size() == saved_nb); + REQUIRE(doc2.features.size() == doc.features.size()); + + const auto& f2 = doc2.features.back(); + REQUIRE(f2.cut_face_body == saved_face_body); + REQUIRE(f2.cut_face == saved_face); + REQUIRE(f2.cut_keep_upper == saved_upper); + REQUIRE(f2.cut_keep_lower == saved_lower); + + for (size_t i = 0; i < saved_nb; ++i) { + Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6)); + REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6)); + REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6)); + REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6)); + REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6)); + REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6)); + } +} + +TEST_CASE("mirror reflects a body about a plane", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + auto make_box = [](CadDocument& doc, double w, double h, double d) { + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), w, h, 0, "Box"); + doc.add_extrude(sk, d, false, BooleanMode::New, "E"); + }; + + // --- New mode: 20x20x20 cube, mirror about XZ plane offset to x=30 --- + // The cube is in x=[-10,10]; the mirror is at x=30, so the mirrored cube + // is at x=[50,70]. Disjoint -> two bodies, equal volumes (8000 each). + SECTION("New mode: two disjoint bodies, equal volumes") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + double v_orig = double(doc.display_mesh.volume()); + REQUIRE_THAT(v_orig, WithinRel(8000.0, 0.01)); + + doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v0, WithinRel(8000.0, 0.01)); + REQUIRE_THAT(v1, WithinRel(8000.0, 0.01)); + } + + // --- New mode with keep_original=false: the source body is replaced --- + SECTION("New mode, keep_original=false: source body removed") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror1"); + doc.features.back().mirror_keep_original = false; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + double v = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE_THAT(v, WithinRel(8000.0, 0.01)); + } + + // --- Add mode: L-shape, entirely on one side of the mirror plane -> 2x volume --- + // Build an L-shape completely in x>0: base 20x20x5 at x=[0,20] + wall 10x20x10 + // at x=[0,10] on top. Mirror about XZ at x=30 -> mirror at x=[40,60], disjoint. + SECTION("Add mode: asymmetric L-shape, disjoint halves -> 2x volume") { + CadDocument doc; + // Base: 20x20x5, shifted to x=10 so it's in x=[0,20] + CadFeature skb; + skb.type = CadFeatureType::Sketch; + skb.name = "Base"; + skb.plane = SketchPlane::XY(); + skb.imported_regions = {{ {Vec2d(0,-10), Vec2d(20,-10), Vec2d(20,10), Vec2d(0,10)} }}; + doc.features.push_back(skb); + int skb_idx = int(doc.features.size()) - 1; + CadFeature exb; + exb.type = CadFeatureType::Extrude; + exb.name = "EBase"; + exb.sketch_ref = skb_idx; + exb.distance = 5.0; + exb.mode = BooleanMode::New; + doc.features.push_back(exb); + + // Wall: 10x20x10 on top of the base, x=[0,10] + CadFeature skw; + skw.type = CadFeatureType::Sketch; + skw.name = "Wall"; + skw.plane = SketchPlane::XY(); + skw.plane.origin = Vec3d(0, 0, 5); + skw.imported_regions = {{ {Vec2d(0,-10), Vec2d(10,-10), Vec2d(10,10), Vec2d(0,10)} }}; + doc.features.push_back(skw); + int skw_idx = int(doc.features.size()) - 1; + CadFeature exw; + exw.type = CadFeatureType::Extrude; + exw.name = "EWall"; + exw.sketch_ref = skw_idx; + exw.distance = 10.0; + exw.mode = BooleanMode::Add; + exw.target_body = 0; + doc.features.push_back(exw); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double v_l = double(doc.display_mesh.volume()); + + // Mirror about YZ at x=30 -> the mirror is entirely in x=[40,60], + // disjoint from the original in x=[0,20]. + SketchPlane mp = SketchPlane::YZ(); + mp.origin = Vec3d(30, 0, 0); + doc.add_mirror(mp, 0, BooleanMode::Add, "Mirror1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + const double v_m = double(doc.display_mesh.volume()); + REQUIRE_THAT(v_m, WithinRel(2.0 * v_l, 0.02)); + } + + // --- Add mode with intersecting plane -> fused volume < 2x --- + // A 20x20x20 cube centred on the origin (so x=[-10,10]), mirrored about + // XZ plane at x=0. The mirror maps the cube onto itself exactly (symmetry). + // Fusing a cube with itself at the plane of symmetry produces the same cube + // -> volume == original, strictly less than 2x. + SECTION("Add mode: intersecting plane -> volume < 2x original") { + CadDocument doc; + make_box(doc, 20.0, 20.0, 20.0); + REQUIRE(doc.recompute()); + const double v_orig = double(doc.display_mesh.volume()); + REQUIRE_THAT(v_orig, WithinRel(8000.0, 0.01)); + + doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::Add, "Mirror1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double v_mir = double(doc.display_mesh.volume()); + REQUIRE(v_mir < v_orig * 1.9); + } + + // --- Invalid target body index --- + SECTION("invalid target body index -> error, no crash") { + CadDocument doc; + // No bodies in the document -> mirror must fail, not crash. + doc.features.push_back({CadFeatureType::Mirror, "BadMirror", true, + SketchShape::Rectangle, SketchPlane::XZ()}); + doc.features.back().mode = BooleanMode::New; + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); + } +} + +TEST_CASE("mirror serialization round-trip", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + + doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror1"); + doc.features.back().mirror_keep_original = false; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.recompute()); + REQUIRE(doc2.error.empty()); + + REQUIRE(doc2.features.size() == doc.features.size()); + const auto& f1 = doc.features.back(); + const auto& f2 = doc2.features.back(); + REQUIRE(f2.type == CadFeatureType::Mirror); + REQUIRE(f2.mode == BooleanMode::New); + REQUIRE(f2.mirror_keep_original == false); + REQUIRE(f2.name == "Mirror1"); + + REQUIRE(doc2.bodies.size() == doc.bodies.size()); +} + +TEST_CASE("mass properties: analytic cube", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto mp = doc.body_mass_properties(0); + REQUIRE(mp.valid); + // Cube 20x20x20 -> V = 8000 mm^3 + REQUIRE_THAT(mp.volume, WithinRel(8000.0, 1e-6)); + // Surface area = 6 * 20^2 = 2400 mm^2 + REQUIRE_THAT(mp.surface_area, WithinRel(2400.0, 1e-6)); + // COM at geometric centre: the box centred on origin is from z=0 to z=20, + // so centre at (0, 0, 10) + REQUIRE_THAT(mp.center_of_mass.x(), WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.center_of_mass.y(), WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.center_of_mass.z(), WithinAbs(10.0, 1e-6)); + // Inertia sanity: symmetric cube -> diagonal terms equal, off-diagonal ~0 + double Ixx = mp.inertia[0], Iyy = mp.inertia[4], Izz = mp.inertia[8]; + REQUIRE_THAT(Ixx, WithinRel(8000.0 * (20*20 + 20*20) / 12.0, 1e-4)); // I = m/12*(b^2+h^2) about COM + REQUIRE_THAT(Iyy, WithinRel(Ixx, 1e-4)); + REQUIRE_THAT(Izz, WithinRel(Ixx, 1e-4)); + REQUIRE_THAT(mp.inertia[1], WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.inertia[2], WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(mp.inertia[5], WithinAbs(0.0, 1e-6)); +} + +TEST_CASE("mass properties: analytic cylinder", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + // Circle r=10 on XY, extrude height=5 -> cylinder + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(0, 0); + c.radius = 10.0; + int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circle"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto mp = doc.body_mass_properties(0); + REQUIRE(mp.valid); + double expected_vol = M_PI * 100.0 * 5.0; // pi * r^2 * h + REQUIRE_THAT(mp.volume, WithinRel(expected_vol, 1e-4)); + // Surface: 2*pi*r^2 + 2*pi*r*h = 2*pi*100 + 2*pi*10*5 = 200*pi + 100*pi = 300*pi + double expected_area = 2 * M_PI * 100.0 + 2 * M_PI * 10.0 * 5.0; + REQUIRE_THAT(mp.surface_area, WithinRel(expected_area, 1e-4)); +} + +TEST_CASE("mass properties: hollow body", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + // Solid cube + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + auto mp_solid = doc.body_mass_properties(0); + REQUIRE(mp_solid.valid); + double solid_vol = mp_solid.volume; + + // Hollow cube with a through hole + CadDocument doc2; + int sk2 = doc2.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc2.add_extrude(sk2, 20.0, false, BooleanMode::New, "Extrude"); + doc2.add_hole(10.0, 20.0, true, 0.0, 0.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc2.recompute()); + auto mp_hollow = doc2.body_mass_properties(0); + REQUIRE(mp_hollow.valid); + + // Hollow volume < solid volume + REQUIRE(mp_hollow.volume < solid_vol); + // Hollow = solid - cylinder: V_cyl = pi*5^2*20 = 500*pi + double expected_hollow = solid_vol - M_PI * 25.0 * 20.0; + REQUIRE_THAT(mp_hollow.volume, WithinRel(expected_hollow, 0.01)); +} + +TEST_CASE("mass properties: invalid body index", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + // Out of range -> valid=false + auto mp = doc.body_mass_properties(99); + REQUIRE_FALSE(mp.valid); + REQUIRE(mp.volume == 0.0); + // Negative index + auto mp2 = doc.body_mass_properties(-1); + REQUIRE_FALSE(mp2.valid); + + // Empty document (no recompute) -> all bodies empty, index 0 out of range + CadDocument empty; + auto mp3 = empty.body_mass_properties(0); + REQUIRE_FALSE(mp3.valid); +} + +TEST_CASE("serialize_recipe roundtrip with two bodies", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + CadDocument doc; + + // Body 1: rectangle sketch + extrude + fillet + int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Rect1"); + REQUIRE(sk1 >= 0); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Extrude1"); + doc.add_fillet(2.0, FaceGroup::All, "Fillet1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body 2: circle sketch + extrude (separate New body) + SketchEntity circ; + circ.type = SketchEntity::Type::Circle; + circ.center = Vec2d(0, 0); + circ.radius = 8.0; + int sk2 = doc.add_sketch_entities({circ}, SketchPlane::XZ(), "Circle2"); + doc.add_extrude(sk2, 6.0, false, BooleanMode::New, "Extrude2"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + REQUIRE(doc.bodies.size() >= 2); + std::vector orig_vols; + for (const auto& b : doc.bodies) + orig_vols.push_back(double(SketchEngine::tessellate(b.shape).volume())); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + + for (size_t i = 0; i < doc.bodies.size(); ++i) { + double v2 = double(SketchEngine::tessellate(doc2.bodies[i].shape).volume()); + REQUIRE_THAT(v2, WithinRel(orig_vols[i], 1e-6)); + } +} + +TEST_CASE("deserialize_recipe rejects future version with error", "[CadDocument]") +{ + CadDocument doc; + std::ostringstream oss; + { + cereal::BinaryOutputArchive ar(oss); + uint32_t v = 999; + ar(v); + } + REQUIRE_FALSE(doc.deserialize_recipe(oss.str())); + REQUIRE_FALSE(doc.error.empty()); + CHECK_CONTAINS(doc.error, "newer version"); +} + +TEST_CASE("deserialize_recipe rejects older version with error", "[CadDocument]") +{ + CadDocument doc; + std::ostringstream oss; + { + cereal::BinaryOutputArchive ar(oss); + uint32_t v = 1; + ar(v); + } + REQUIRE_FALSE(doc.deserialize_recipe(oss.str())); + REQUIRE_FALSE(doc.error.empty()); + CHECK_CONTAINS(doc.error, "older version"); +} + +TEST_CASE("deserialize_recipe handles truncated blob without throwing", "[CadDocument]") +{ + CadDocument doc; + std::string garbage = "this is not a valid cereal blob"; + REQUIRE_FALSE(doc.deserialize_recipe(garbage)); + REQUIRE_FALSE(doc.error.empty()); +} + +TEST_CASE("deserialize_recipe handles empty blob without throwing", "[CadDocument]") +{ + CadDocument doc; + REQUIRE_FALSE(doc.deserialize_recipe("")); + REQUIRE_FALSE(doc.error.empty()); +} + +TEST_CASE("deserialize_recipe error is non-empty on every failure path", "[CadDocument]") +{ + CadDocument doc; + auto reset = [&]() { doc = CadDocument{}; }; + + // too new + { + std::ostringstream oss; + { cereal::BinaryOutputArchive ar(oss); uint32_t v = 999; ar(v); } + reset(); + REQUIRE_FALSE(doc.deserialize_recipe(oss.str())); + REQUIRE_FALSE(doc.error.empty()); + } + // too old + { + std::ostringstream oss; + { cereal::BinaryOutputArchive ar(oss); uint32_t v = 1; ar(v); } + reset(); + REQUIRE_FALSE(doc.deserialize_recipe(oss.str())); + REQUIRE_FALSE(doc.error.empty()); + } + // truncated / garbage + { + reset(); + REQUIRE_FALSE(doc.deserialize_recipe("not a valid blob \x00\x01\x02")); + REQUIRE_FALSE(doc.error.empty()); + } + // empty + { + reset(); + REQUIRE_FALSE(doc.deserialize_recipe("")); + REQUIRE_FALSE(doc.error.empty()); + } +} + +TEST_CASE("a v4 project still opens", "[CadDocument][recipe]") +{ + // The regression guard for everyone who already has projects: a pre-framing v4 + // recipe must keep opening on this build, through the unchanged flat path — not be + // refused at the version gate after the bump to v5. + std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v4.bin"; + std::ifstream ifs(path, std::ios::binary); + REQUIRE(ifs.is_open()); + std::string blob((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); + ifs.close(); + REQUIRE_FALSE(blob.empty()); + + // Trusted reference read of the flat v4 layout (what the golden test's Layer 1 does). + std::vector flat; + { + std::istringstream iss(blob); + cereal::BinaryInputArchive ar(iss); + uint32_t v; + ar(v); + REQUIRE(v == 4); + ar(flat); + } + REQUIRE_FALSE(flat.empty()); + + CadDocument doc; + doc.deserialize_recipe(blob); + // Not refused at the gate: the only failure allowed is the fixture's own geometry + // (the golden doc is a serialization-coverage tree whose fillet radius is too large), + // which is orthogonal to the v5 framing change and unchanged by it. + REQUIRE(doc.error.find("older version") == std::string::npos); + REQUIRE(doc.error.find("newer version") == std::string::npos); + // The v4 flat path read the same feature tree as the trusted reference. + REQUIRE(doc.features.size() == flat.size()); +} + +// Do NOT regenerate cad_recipe_v5.bin either. It was written by the build that predates the +// bump to v6, which is the whole reason it can prove the gate lets a v5 project through. +TEST_CASE("a v5 project still opens", "[CadDocument][recipe]") +{ + // The bump to v6 renamed nothing inside the blob — it records the recipe's move to + // Metadata/orca_cad.bin — so a v5 project must still come through the framed path rather + // than be refused at the version gate for being old. + std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v5.bin"; + std::ifstream ifs(path, std::ios::binary); + REQUIRE(ifs.is_open()); + std::string blob((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); + ifs.close(); + REQUIRE(blob.size() >= sizeof(uint32_t)); + + uint32_t stamped = 0; + std::memcpy(&stamped, blob.data(), sizeof(stamped)); + REQUIRE(stamped == 5); // the fixture really is pre-bump, not a regenerated one + + CadDocument doc; + doc.deserialize_recipe(blob); + // As with the v4 fixture: the only failure allowed is the golden tree's own geometry, which + // is orthogonal to the version gate. Being turned away at the gate is not. + REQUIRE(doc.error.find("older version") == std::string::npos); + REQUIRE(doc.error.find("newer version") == std::string::npos); + REQUIRE_FALSE(doc.features.empty()); +} + +TEST_CASE("a v5 round trip is exact", "[CadDocument][recipe]") +{ + using Catch::Matchers::WithinRel; + CadDocument doc; + + // Body 1: rectangle sketch + extrude + fillet + int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Rect1"); + REQUIRE(sk1 >= 0); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Extrude1"); + doc.add_fillet(2.0, FaceGroup::All, "Fillet1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body 2: circle sketch + extrude (separate New body) + SketchEntity circ; + circ.type = SketchEntity::Type::Circle; + circ.center = Vec2d(0, 0); + circ.radius = 8.0; + int sk2 = doc.add_sketch_entities({circ}, SketchPlane::XZ(), "Circle2"); + doc.add_extrude(sk2, 6.0, false, BooleanMode::New, "Extrude2"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + REQUIRE(doc.bodies.size() >= 2); + std::vector orig_vols; + for (const auto& b : doc.bodies) + orig_vols.push_back(double(SketchEngine::tessellate(b.shape).volume())); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.features.size() == doc.features.size()); + for (size_t i = 0; i < doc.features.size(); ++i) + REQUIRE(doc2.features[i].type == doc.features[i].type); + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + for (size_t i = 0; i < doc.bodies.size(); ++i) { + double v2 = double(SketchEngine::tessellate(doc2.bodies[i].shape).volume()); + REQUIRE_THAT(v2, WithinRel(orig_vols[i], 1e-6)); + } +} + +TEST_CASE("a truncated feature keeps what it could read", "[CadDocument][recipe]") +{ + // The forward-compat proof: a framed reader that meets a feature blob shorter than its own + // field list must keep what it read and default the rest, not error. Simulate an older + // file by hand-shortening ONE feature's frame: rewrite its length prefix and drop the + // tail bytes, then confirm the load still succeeds and the fields before the cut survive. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Sk1"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Ex1"); + int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 12, 12, 0, "Sk2"); + doc.add_extrude(sk2, 3.0, false, BooleanMode::New, "Ex2"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Two trailing connector-fingerprint fields, made distinctive so the cut is observable: + // coordsys_face_kind sits right before the LAST serialized field (coordsys_face_edges), + // which is the one we drop. They are ignored by an Extrude's recompute, so the truncated + // document still replays cleanly. + doc.features[1].coordsys_face_kind = 777; + doc.features[1].coordsys_face_edges = 888; + + auto blob = doc.serialize_recipe(); + REQUIRE(blob.size() > 8); + + auto rd32 = [](const std::string& s, size_t off) -> uint32_t { + uint32_t u; + std::memcpy(&u, s.data() + off, sizeof(u)); + return u; + }; + auto wr32 = [](std::string& s, size_t off, uint32_t u) { + std::memcpy(&s[off], &u, sizeof(u)); + }; + + // Against the constant, not a literal: what this asserts is that the outer frame opens with + // the version stamp, which stays true across every bump. + REQUIRE(rd32(blob, 0) == CadDocument::ORCA_CAD_RECIPE_VERSION); + uint32_t count = rd32(blob, 4); + REQUIRE(count == doc.features.size()); + + // Walk the outer framing: version + count, then [len][bytes] per feature. + std::vector f_off, f_len; + size_t off = 8; + for (uint32_t i = 0; i < count; ++i) { + f_off.push_back(off); + f_len.push_back(rd32(blob, off)); + off += 4 + f_len.back(); + } + + // Shorten feature 1 by dropping its final field (4 bytes): rewrite its length prefix + // and erase the tail bytes. The reader then runs out inside fa(f), throws, and keeps + // everything it had already assigned — that is the whole point of the try/catch. + const size_t drop = sizeof(uint32_t); + REQUIRE(f_len[1] > drop); + std::string shortened = blob; + shortened.erase(f_off[1] + 4 + f_len[1] - drop, drop); + wr32(shortened, f_off[1], static_cast(f_len[1] - drop)); + + CadDocument loaded; + REQUIRE(loaded.deserialize_recipe(shortened)); + REQUIRE(loaded.error.empty()); + REQUIRE(loaded.features.size() == count); + + // Fields before the cut survived; the cut field defaulted; the neighbours on both sides + // are intact, proving the outer framing held. + REQUIRE(loaded.features[1].name == doc.features[1].name); + REQUIRE(loaded.features[1].coordsys_face_kind == 777); // right before the cut + REQUIRE(loaded.features[1].coordsys_face_edges == -1); // defaulted by the cut + REQUIRE(loaded.features[0].name == doc.features[0].name); + REQUIRE(loaded.features[2].name == doc.features[2].name); +} + +TEST_CASE("a v3 recipe is refused with a message naming the version", "[CadDocument][recipe]") +{ + // Honesty guard: the framing fixes the future, not the past. A v3 field list no longer + // exists in this code, so a v3 blob must be refused cleanly and must say which version. + std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v3.bin"; + std::ifstream ifs(path, std::ios::binary); + REQUIRE(ifs.is_open()); + std::string blob((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); + ifs.close(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc; + REQUIRE_FALSE(doc.deserialize_recipe(blob)); + REQUIRE_FALSE(doc.error.empty()); + REQUIRE(doc.error.find("v3") != std::string::npos); +} + +TEST_CASE("re-edit: editing a mid-timeline feature rebuilds downstream", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + CadDocument doc; + + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 10, 0, "Sketch1"); + REQUIRE(sk >= 0); + int ex = doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(ex >= 0); + doc.add_fillet(1.0, FaceGroup::All, "Fillet1"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + Vec3d sz0 = doc.display_mesh.bounding_box().size(); + REQUIRE(sz0.z() > 0.0); + + // Edit the MID feature (extrude — NOT the last; Fillet is downstream). + doc.features[ex].distance = 12.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + Vec3d sz1 = doc.display_mesh.bounding_box().size(); + REQUIRE(sz1.z() > sz0.z() + 1.0); + REQUIRE(doc.bodies.size() == 1); + + // Edit the FIRST feature (sketch width) — must propagate the whole chain. + doc.features[sk].width = 30; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + Vec3d sz2 = doc.display_mesh.bounding_box().size(); + REQUIRE(sz2.x() > sz1.x() + 1.0); +} + +TEST_CASE("re-edit survives serialize -> deserialize", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + CadDocument doc; + + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 10, 0, "Sketch1"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude1"); + doc.add_fillet(1.0, FaceGroup::All, "Fillet1"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.recompute()); + REQUIRE(doc2.error.empty()); + Vec3d sz_pre = doc2.display_mesh.bounding_box().size(); + REQUIRE(sz_pre.z() > 0.0); + + // Mid-edit the deserialized document — the persisted recipe stays re-editable. + doc2.features[1].distance = 12.0; + REQUIRE(doc2.recompute()); + REQUIRE(doc2.error.empty()); + Vec3d sz_post = doc2.display_mesh.bounding_box().size(); + REQUIRE(sz_post.z() > sz_pre.z() + 1.0); +} + +TEST_CASE("re-edit: multi-type timeline replays all downstream features", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + CadDocument doc; + + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 30, 30, 0, "Sketch1"); + REQUIRE(sk >= 0); + int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(ex >= 0); + doc.add_hole(6.0, 4.0, false, 0.0, 0.0, SketchPlane::XY(), "Hole1"); + doc.add_chamfer(1.0, FaceGroup::All, "Chamfer1"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + size_t n_bodies = doc.bodies.size(); + REQUIRE(n_bodies >= 1); + Vec3d sz0 = doc.display_mesh.bounding_box().size(); + REQUIRE(sz0.z() > 0.0); + + // Edit the MID extrude — downstream Hole and Chamfer must rebuild. + doc.features[ex].distance = 16.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == n_bodies); + Vec3d sz1 = doc.display_mesh.bounding_box().size(); + REQUIRE(sz1.z() > sz0.z() + 1.0); +} + +TEST_CASE("datum plane construction methods", "[CadDocument][plane]") +{ + using Catch::Matchers::WithinAbs; + + // Build a doc with a box (sketch rect 40x30 + extrude 20) so bodies[0] has faces/edges. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 30, 0, "BoxSketch"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + const int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + REQUIRE(n_faces == 6); // a box + const int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape); + REQUIRE(n_edges == 12); + + // Find top face (normal ~ +Z) and bottom face (normal ~ -Z) by scanning. + int top_idx = -1, bot_idx = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) top_idx = i; + if (n.z() < -0.9) bot_idx = i; + } + REQUIRE(top_idx >= 0); + REQUIRE(bot_idx >= 0); + + // --- Offset from base XY by 10 --- + auto planes0 = doc.resolve_datum_planes(); + size_t initial = planes0.size(); + + CadFeature f0; + f0.type = CadFeatureType::Plane; + f0.name = "Offset10"; + f0.plane_base = 0; // XY + f0.plane_type = PlaneType::Offset; + f0.plane_offset = 10; + doc.features.push_back(f0); + + auto planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == initial + 1); + CHECK_THAT(planes.back().second.origin.z(), WithinAbs(10.0, 1e-6)); + CHECK_THAT(planes.back().second.normal.z(), WithinAbs(1.0, 1e-6)); + + // --- Coincident to top face --- + CadFeature f1; + f1.type = CadFeatureType::Plane; + f1.name = "CoincidentTop"; + f1.plane_type = PlaneType::Coincident; + f1.plane_face_body = 0; + f1.plane_face = top_idx; + doc.features.push_back(f1); + + planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == initial + 2); + CHECK_THAT(planes.back().second.origin.z(), WithinAbs(20.0, 1e-6)); // box height is 20 + CHECK_THAT(planes.back().second.normal.z(), WithinAbs(1.0, 1e-6)); + + // --- Midplane between top and bottom faces --- + CadFeature f2; + f2.type = CadFeatureType::Plane; + f2.name = "Midplane"; + f2.plane_type = PlaneType::Midplane; + f2.plane_face_body = 0; + f2.plane_face = top_idx; + f2.plane_face2_body = 0; + f2.plane_face2 = bot_idx; + doc.features.push_back(f2); + + planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == initial + 3); + CHECK_THAT(planes.back().second.origin.z(), WithinAbs(10.0, 1e-6)); // midway + CHECK_THAT(std::abs(planes.back().second.normal.z()), WithinAbs(1.0, 1e-6)); + + // --- Orthonormality check on all resolved planes --- + for (const auto& [name, sp] : planes) { + INFO("Plane: " << name); + CHECK_THAT(sp.normal.norm(), WithinAbs(1.0, 1e-6)); + CHECK_THAT(sp.x_axis.norm(), WithinAbs(1.0, 1e-6)); + CHECK_THAT(sp.y_axis.norm(), WithinAbs(1.0, 1e-6)); + CHECK_THAT(std::abs(sp.x_axis.dot(sp.y_axis)), WithinAbs(0.0, 1e-6)); + CHECK_THAT(std::abs(sp.x_axis.dot(sp.normal)), WithinAbs(0.0, 1e-6)); + CHECK_THAT(std::abs(sp.y_axis.dot(sp.normal)), WithinAbs(0.0, 1e-6)); + } +} + +// Mirrors the Snapmaker [Deviation] case (Snapmaker carries it in test_geometry.cpp; here it lives +// alongside the CAD suite). GeometryEngine::surface_deviation = one-sided Hausdorff used by the +// MCP validate_against acceptance metric. +TEST_CASE("surface_deviation: identical solids ~0, shifted solid ~shift", "[Deviation]") +{ + TopoDS_Shape ref = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape(); + auto d0 = GeometryEngine::surface_deviation(ref, ref, 0.5); + REQUIRE(d0.sample_count > 0); + REQUIRE_THAT(d0.max_mm, Catch::Matchers::WithinAbs(0.0, 1e-6)); + + // candidate shifted +2mm in X: far corner vertices sit 2mm outside the reference + gp_Trsf t; t.SetTranslation(gp_Vec(2.0, 0.0, 0.0)); + TopoDS_Shape shifted = BRepBuilderAPI_Transform(ref, t, true).Shape(); + auto d1 = GeometryEngine::surface_deviation(shifted, ref, 0.5); + REQUIRE_THAT(d1.max_mm, Catch::Matchers::WithinAbs(2.0, 0.05)); + REQUIRE(d1.mean_mm > 0.0); +} + +TEST_CASE("mesh_to_brep: watertight cube -> solid, coplanar merge gives 6 faces", "[design][mesh2brep]") +{ + const indexed_triangle_set cube = its_make_cube(10.0, 20.0, 30.0); + REQUIRE(cube.indices.size() == 12); + + SECTION("faceted: one B-rep face per triangle, exact volume") { + GeometryEngine::MeshBrepStats st; + const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(cube, 0.01, /*no merge*/0.0, st); + REQUIRE_FALSE(shape.IsNull()); + CHECK(st.kept_tris == 12); + CHECK(st.faces_built == 12); + CHECK(st.faces_final == 12); + // Shared topology by construction: a cube has 8 vertices and 18 edges once the two + // triangles of each face share their diagonal. Every edge used exactly twice. + CHECK(st.unique_edges == 18); + CHECK(st.boundary_edges == 0); + CHECK(st.nonmanifold_edges == 0); + CHECK(st.watertight); + REQUIRE(st.is_solid); + REQUIRE_THAT(st.volume, Catch::Matchers::WithinRel(10.0 * 20.0 * 30.0, 1e-9)); + } + + SECTION("merge coplanar: 12 triangles collapse to the cube's 6 real faces") { + GeometryEngine::MeshBrepStats st; + const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(cube, 0.01, 5.0, st); + REQUIRE_FALSE(shape.IsNull()); + REQUIRE(st.is_solid); + // This is the whole point of merging: the imported body must expose pickable CAD faces, + // not one face per triangle, or the fillet/extrude tools have nothing meaningful to grab. + REQUIRE(st.faces_final == 6); + REQUIRE_THAT(st.volume, Catch::Matchers::WithinRel(10.0 * 20.0 * 30.0, 1e-9)); + } +} + +TEST_CASE("mesh_to_brep: an open mesh is reported as a shell, never a fake solid", "[design][mesh2brep]") +{ + indexed_triangle_set open_cube = its_make_cube(10.0, 10.0, 10.0); + open_cube.indices.pop_back(); // punch a hole: drop one triangle + open_cube.indices.pop_back(); // (and its coplanar partner -> a whole face missing) + + GeometryEngine::MeshBrepStats st; + const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(open_cube, 0.01, 5.0, st); + REQUIRE_FALSE(shape.IsNull()); + CHECK(st.kept_tris == 10); + CHECK(st.boundary_edges > 0); // the hole's rim + CHECK_FALSE(st.watertight); + REQUIRE_FALSE(st.is_solid); // must NOT be dressed up as a solid + CHECK(st.volume == 0.0); +} + +TEST_CASE("mesh_to_brep: degenerate triangles are rejected on a scale-independent test", "[design][mesh2brep]") +{ + // A thin but perfectly legitimate CAD sliver. Every edge (1.0, ~0.5, ~0.5) is far above the + // 0.01 dedup tolerance, so no vertex collapses — but its area (5e-5) is BELOW tolerance^2 + // (1e-4). A rule of "reject when area < tolerance^2" would therefore throw it away, which is + // precisely the bug that turned a watertight 62k-triangle input into a falsely-open shell. + // The scale-independent test (area < 1e-9 * longest_edge^2 = 1e-9) keeps it, as it must. + indexed_triangle_set sliver; + sliver.vertices = { {0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, {0.5f, 0.0001f, 0.f} }; + sliver.indices = { {0, 1, 2} }; + GeometryEngine::MeshBrepStats st; + GeometryEngine::mesh_to_brep(sliver, 0.01, 0.0, st); + CHECK(st.degenerate_sliver == 0); + CHECK(st.degenerate_collapsed == 0); + CHECK(st.kept_tris == 1); + + // A truly collinear triangle has no area at any scale -> rejected as a sliver. + indexed_triangle_set collinear; + collinear.vertices = { {0.f, 0.f, 0.f}, {10.f, 0.f, 0.f}, {20.f, 0.f, 0.f} }; + collinear.indices = { {0, 1, 2} }; + GeometryEngine::MeshBrepStats st2; + CHECK_THROWS(GeometryEngine::mesh_to_brep(collinear, 0.01, 0.0, st2)); // nothing left to build + CHECK(st2.degenerate_sliver == 1); + + // A triangle entirely inside one tolerance cell is sub-resolution noise -> collapsed. + indexed_triangle_set tiny; + tiny.vertices = { {0.f, 0.f, 0.f}, {0.001f, 0.f, 0.f}, {0.f, 0.001f, 0.f} }; + tiny.indices = { {0, 1, 2} }; + GeometryEngine::MeshBrepStats st3; + CHECK_THROWS(GeometryEngine::mesh_to_brep(tiny, 0.1, 0.0, st3)); + CHECK(st3.degenerate_collapsed == 1); +} + +TEST_CASE("datum axis: two points direction is unit and analytic", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int ax = doc.add_axis(AxisType::TwoPoints, "AxisThroughZ"); + REQUIRE(ax == 0); + doc.features[ax].axis_p1 = Vec3d(0, 0, 0); + doc.features[ax].axis_p2 = Vec3d(0, 0, 10); + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE(axes[0].name == "AxisThroughZ"); + REQUIRE(axes[0].error.empty()); + CHECK_THAT(axes[0].direction.x(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.z(), WithinAbs(1.0, 1e-12)); + CHECK_THAT(axes[0].direction.norm(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(axes[0].origin.x(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(axes[0].origin.y(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(axes[0].origin.z(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("datum axis: degenerate two identical points fails cleanly", "[CadDocument]") +{ + CadDocument doc; + int ax = doc.add_axis(AxisType::TwoPoints, "Degenerate"); + doc.features[ax].axis_p1 = Vec3d(5, 5, 5); + doc.features[ax].axis_p2 = Vec3d(5, 5, 5); + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE_FALSE(axes[0].error.empty()); +} + +TEST_CASE("datum axis: two parallel planes fail with error", "[CadDocument]") +{ + CadDocument doc; + // Two offset XY planes are parallel -> no intersection. + // 3 and 4, not 0 and 1: axis_plane_a/b use the same encoding as plane_base — 0/1/2 are the + // XY/XZ/YZ base planes and datums start at 3 — because the GUI fills these from + // populate_plane_choices() and stores the row verbatim. + doc.add_plane(0 /*XY*/, 10.0, 0.0, 0, "PlaneA"); + doc.add_plane(0 /*XY*/, 30.0, 0.0, 0, "PlaneB"); + + int ax = doc.add_axis(AxisType::TwoPoints, "Parallel"); + doc.features[ax].axis_type = AxisType::PlaneIntersection; + doc.features[ax].axis_plane_a = 3; + doc.features[ax].axis_plane_b = 4; + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE_FALSE(axes[0].error.empty()); +} + +// The case the GUI actually produces most often, and which could not work before: the two refs +// are rows of the plane picker, whose first three entries are the base planes. XY x XZ is the +// X axis. Previously ref 0 was read as "datum plane 0", so this silently resolved to the wrong +// plane or failed with "plane ref not found". +TEST_CASE("datum axis: intersection of two BASE planes gives the expected axis", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int ax = doc.add_axis(AxisType::PlaneIntersection, "XAxis"); + doc.features[ax].axis_plane_a = 0; // XY + doc.features[ax].axis_plane_b = 1; // XZ + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE(axes[0].error.empty()); + // XY normal is Z, XZ normal is Y; Z x Y is +/-X. + CHECK_THAT(std::abs(axes[0].direction.x()), WithinAbs(1.0, 1e-12)); + CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.z(), WithinAbs(0.0, 1e-12)); +} + +// A datum plane crossed with a base plane — the mixed case, which pins the +3 offset. +TEST_CASE("datum axis: base plane crossed with a datum plane", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + doc.add_plane(1 /*XZ*/, 5.0, 0.0, 0, "OffsetXZ"); // datum 0 -> row 3 + + int ax = doc.add_axis(AxisType::PlaneIntersection, "MixedAxis"); + doc.features[ax].axis_plane_a = 0; // XY base + doc.features[ax].axis_plane_b = 3; // the datum above + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE(axes[0].error.empty()); + CHECK_THAT(std::abs(axes[0].direction.x()), WithinAbs(1.0, 1e-12)); +} + +TEST_CASE("datum axis: cylinder centreline from extruded circle", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Build a cylinder: circle r=5 at origin, extrude 20 mm along +Z -> cylinder z=[0,20] + int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Cyl"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + // Find the lateral cylindrical face + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int lateral_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(fc); + if (cyl.ok) { lateral_face = i; break; } + } + REQUIRE(lateral_face >= 0); + + int ax = doc.add_axis(AxisType::TwoPoints, "CylAx"); + doc.features[ax].axis_type = AxisType::CylinderCenterline; + doc.features[ax].axis_body = 0; + doc.features[ax].axis_face = lateral_face; + + auto axes = doc.resolve_datum_axes(); + REQUIRE(axes.size() == 1); + REQUIRE(axes[0].error.empty()); + // OCCT may return the axis direction as +Z or -Z depending on face orientation; + // the centreline is always collinear with Z and passes through (x=0,y=0). + CHECK_THAT(std::abs(axes[0].direction.z()), WithinAbs(1.0, 1e-12)); + CHECK_THAT(axes[0].direction.x(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12)); + CHECK_THAT(axes[0].origin.x(), WithinAbs(0.0, 1e-6)); + CHECK_THAT(axes[0].origin.y(), WithinAbs(0.0, 1e-6)); +} + +TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal axes", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Build a body so we have a face to reference for FaceAndDirection. + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + Vec3d fn = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, i)); + if (fn.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1"); + REQUIRE(cs >= 0); + doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection; + doc.features[cs].coordsys_body = 0; + doc.features[cs].coordsys_face = top_face; + // Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z). + doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5); + + auto css = doc.resolve_datum_coordsys(); + REQUIRE(css.size() == 1); + REQUIRE(css[0].error.empty()); + + Vec3d X = css[0].x, Y = css[0].y; + // Orthonormality: each axis has unit length + CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9)); + // Pairwise dot products are ~0 + CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9)); + // Z = X x Y (derived), also unit and perpendicular + Vec3d Z = X.cross(Y); + CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9)); + CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9)); + // Right-handedness: X x Y == Z + CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9)); + CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9)); + CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9)); +} + +TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + // The bug this pins down: Z came from the face normal (body-following) but X came from + // coordsys_x_hint, a WORLD constant. Spinning the body about its own face normal left the + // frame identical, so a face-only connector could not encode that rotation at all — and a + // Fastened or Slider mate built on it claimed to fix an orientation it could not see. + // A rectangular top face is used deliberately: its edges give an unambiguous in-plane + // direction, so "did the frame follow the body" is answerable to the degree. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + Vec3d fn = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, i)); + if (fn.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS"); + doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection; + doc.features[cs].coordsys_body = 0; + doc.features[cs].coordsys_face = top_face; + doc.features[cs].coordsys_edge = -1; // face only: no explicit direction edge + REQUIRE(doc.recompute()); + + auto before = doc.resolve_datum_coordsys(); + REQUIRE(before.size() == 1); + REQUIRE(before[0].error.empty()); + const Vec3d X0 = before[0].x; + const Vec3d Z0 = before[0].x.cross(before[0].y); + + // Spin the body 90 degrees about its own face normal (world Z here). + doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 90.0, false, "Spin"); + REQUIRE(doc.recompute()); + + auto after = doc.resolve_datum_coordsys(); + REQUIRE(after.size() == 1); + REQUIRE(after[0].error.empty()); + const Vec3d X1 = after[0].x; + + // The normal is unchanged by a spin about itself — that is exactly why the old code could + // not detect the rotation. + const Vec3d Z1 = after[0].x.cross(after[0].y); + CHECK_THAT(std::abs(Z0.dot(Z1)), WithinAbs(1.0, 1e-6)); + + // X must have turned with the body. Before the fix X0 == X1 and this failed. + const double cos_turn = std::clamp(X0.dot(X1), -1.0, 1.0); + CHECK_THAT(std::abs(cos_turn), WithinAbs(0.0, 1e-6)); // 90 degrees apart +} + +TEST_CASE("datum coordinate system: point_world gives world axes", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 20, 30), "CS_World"); + REQUIRE(cs == 0); + + auto css = doc.resolve_datum_coordsys(); + REQUIRE(css.size() == 1); + REQUIRE(css[0].error.empty()); + CHECK_THAT(css[0].origin.x(), WithinAbs(10.0, 1e-9)); + CHECK_THAT(css[0].origin.y(), WithinAbs(20.0, 1e-9)); + CHECK_THAT(css[0].origin.z(), WithinAbs(30.0, 1e-9)); + CHECK_THAT(css[0].x.x(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(css[0].x.y(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(css[0].x.z(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(css[0].y.x(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(css[0].y.y(), WithinAbs(1.0, 1e-9)); + CHECK_THAT(css[0].y.z(), WithinAbs(0.0, 1e-9)); +} + + +TEST_CASE("helix curve: arc length, bounding box, left-handed, conical", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + // --- Cylindrical helix r=5, pitch=2, height=10 (5 turns) --- + // One turn arc length = sqrt((2*pi*r)^2 + pitch^2) = sqrt((10*pi)^2 + 4). + // Total = 5 * sqrt(986.96...) ≈ 5 * 31.4159 ≈ 157.08 mm. + SECTION("cylindrical helix arc length matches analytic") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "H1"); + REQUIRE(doc.features.size() == 1); + std::string err; + TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err); + REQUIRE_FALSE(w.IsNull()); + REQUIRE(err.empty()); + + GProp_GProps props; + BRepGProp::LinearProperties(w, props); + const double len = props.Mass(); + const double one_turn = std::sqrt(std::pow(2.0 * M_PI * 5.0, 2) + std::pow(2.0, 2)); + const double expected = 5.0 * one_turn; + REQUIRE_THAT(len, WithinRel(expected, 1e-3)); + } + + // --- Bounding box: X/Y extent = 2*radius, Z extent = height --- + SECTION("cylindrical helix bounding box") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "H1"); + std::string err; + TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err); + REQUIRE_FALSE(w.IsNull()); + + Bnd_Box bb; + BRepBndLib::Add(w, bb); + double xmin, ymin, zmin, xmax, ymax, zmax; + bb.Get(xmin, ymin, zmin, xmax, ymax, zmax); + REQUIRE_THAT(xmax - xmin, WithinRel(10.0, 0.1)); + REQUIRE_THAT(ymax - ymin, WithinRel(10.0, 0.1)); + REQUIRE_THAT(zmax - zmin, WithinRel(10.0, 0.1)); + } + + // --- Left-handed helix: sample a point at parameter ~0.25 and compare --- + SECTION("left_handed flips the winding direction") { + CadDocument doc_rh, doc_lh; + doc_rh.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "RH"); + doc_lh.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, true, 0.0, "LH"); + + std::string err; + TopoDS_Wire w_rh = doc_rh.build_helix_wire(doc_rh.features[0], err); + TopoDS_Wire w_lh = doc_lh.build_helix_wire(doc_lh.features[0], err); + REQUIRE_FALSE(w_rh.IsNull()); + REQUIRE_FALSE(w_lh.IsNull()); + + // Sample at height = height/4 along the helix: + // RH: angle = 2*pi*turns*0.25 = pi/2, direction is +2*pi*turns + // so at z = 2.5: u = pi/2 => x = r*cos(pi/2) = 0, y = r*sin(pi/2) = +5 + // LH: angle goes negative, at z = 2.5: u = -pi/2 => x = 0, y = -5 + double z_sample = 2.5; // height/4 + // Approximate by scanning edges and picking the vertex nearest to target z + auto point_at_z = [&](const TopoDS_Wire& w, double target_z) -> gp_Pnt { + double best_dz = 1e9; + gp_Pnt best(0,0,0); + for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) { + TopoDS_Edge e = TopoDS::Edge(ex.Current()); + BRepAdaptor_Curve curve(e); + double u0 = curve.FirstParameter(); + double u1 = curve.LastParameter(); + for (int s = 0; s <= 100; ++s) { + double u = u0 + (u1 - u0) * s / 100.0; + gp_Pnt p = curve.Value(u); + if (std::abs(p.Z() - target_z) < best_dz) { + best_dz = std::abs(p.Z() - target_z); + best = p; + } + } + } + return best; + }; + + gp_Pnt prh = point_at_z(w_rh, z_sample); + gp_Pnt plh = point_at_z(w_lh, z_sample); + + // At z=2.5 for RH: angle ~ pi/2 -> y > 0 + REQUIRE(prh.Y() > 0.0); + // At z=2.5 for LH: angle ~ -pi/2 -> y < 0 + REQUIRE(plh.Y() < 0.0); + // They must differ in sign of y (mirror winding), not just "differ" + REQUIRE(prh.Y() * plh.Y() < 0.0); + } + + // --- Conical helix: top radius matches r + height*tan(taper) --- + SECTION("conical helix top radius") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 10.0, "Cone"); + std::string err; + TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err); + REQUIRE_FALSE(w.IsNull()); + REQUIRE(err.empty()); + + // Top radius = 5 + 10*tan(10) ≈ 5 + 1.7633 = 6.7633 + const double expected_top = 5.0 + 10.0 * std::tan(10.0 * M_PI / 180.0); + + // Sample at z = height: use same sampling approach + double best_dz = 1e9; + gp_Pnt best(0,0,0); + for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) { + TopoDS_Edge e = TopoDS::Edge(ex.Current()); + BRepAdaptor_Curve curve(e); + double u0 = curve.FirstParameter(); + double u1 = curve.LastParameter(); + for (int s = 0; s <= 200; ++s) { + double u = u0 + (u1 - u0) * s / 200.0; + gp_Pnt p = curve.Value(u); + if (std::abs(p.Z() - 10.0) < best_dz) { + best_dz = std::abs(p.Z() - 10.0); + best = p; + } + } + } + double top_r = std::sqrt(best.X() * best.X() + best.Y() * best.Y()); + REQUIRE_THAT(top_r, WithinRel(expected_top, 1e-2)); + } +} + +TEST_CASE("helix: invalid inputs fail cleanly", "[CadDocument]") +{ + SECTION("radius <= 0") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 0.0, 2.0, 10.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("pitch <= 0") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 0.0, 10.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("height < 0") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, -1.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("height == 0 (flat spiral) rejected") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 0.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + CHECK_CONTAINS(err, "flat spiral"); + } + SECTION("absurd turn count") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 1e-4, 2.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("taper drives radius negative") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 1.0, 2.0, 10.0, false, -10.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + CHECK_CONTAINS(err, "negative"); + } +} + +TEST_CASE("helix as sweep path: spring integration test", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + + // Build a plane at the helix start (5,0,0) whose normal IS the start tangent direction. + // The helix tangent at u=0 is (0, R, P/(2*pi)) = (0, 5, 3/(2*pi)). + const double R = 5.0, P = 3.0; + Vec3d tan_dir(0, R, P / (2.0 * M_PI)); + tan_dir.normalize(); + Vec3d ref = (std::abs(tan_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + Vec3d x_axis = ref.cross(tan_dir); + if (x_axis.squaredNorm() < 1e-12) x_axis = Vec3d(1, 0, 0); + x_axis.normalize(); + Vec3d y_axis = tan_dir.cross(x_axis).normalized(); + SketchPlane profile_plane; + profile_plane.origin = Vec3d(R, 0, 0); + profile_plane.normal = tan_dir; + profile_plane.x_axis = x_axis; + profile_plane.y_axis = y_axis; + + // Profile: small circle r=1.5 centered at 2D (0,0) = world (5,0,0) = helix start + SketchEntity prof; + prof.type = SketchEntity::Type::Circle; + prof.center = Vec2d(0, 0); + prof.radius = 1.5; + int prof_idx = doc.add_sketch_entities({prof}, profile_plane, "CircleProfile"); + + // Helix path: r=5, pitch=3, height=15 (5 turns) about Z axis from origin + int helix_idx = doc.add_helix(SketchPlane::XY(), R, P, 15.0, false, 0.0, "HelixPath"); + + int sweep_idx = doc.add_sweep(prof_idx, helix_idx, BooleanMode::New, "Spring"); + REQUIRE(sweep_idx >= 0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + const double v = double(doc.display_mesh.volume()); + REQUIRE(v > 0.0); + + const double one_turn = std::sqrt(std::pow(2.0 * M_PI * R, 2) + std::pow(P, 2)); + const double total_len = 5.0 * one_turn; + const double prof_area = M_PI * 1.5 * 1.5; + const double expected_v = prof_area * total_len; + REQUIRE_THAT(v, WithinRel(expected_v, 0.1)); +} + +TEST_CASE("helix serialization round-trip with distinctive values", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + doc.add_helix(SketchPlane::XZ(), 7.5, 3.25, 22.0, true, 5.0, "Helix_RT"); + doc.features.back().helix_radius = 7.5; + doc.features.back().helix_pitch = 3.25; + doc.features.back().helix_height = 22.0; + doc.features.back().helix_left_handed = true; + doc.features.back().helix_taper_deg = 5.0; + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + // Deserialize through the real entry point — recompute fails because a lone + // helix doesn't produce a solid, but the serialized field values must survive. + CadDocument doc2; + doc2.deserialize_recipe(blob); + REQUIRE(doc2.features.size() == 1); + + const auto& f = doc2.features[0]; + REQUIRE(f.type == CadFeatureType::Helix); + REQUIRE(f.name == "Helix_RT"); + REQUIRE_THAT(f.helix_radius, WithinAbs(7.5, 1e-9)); + REQUIRE_THAT(f.helix_pitch, WithinAbs(3.25, 1e-9)); + REQUIRE_THAT(f.helix_height, WithinAbs(22.0, 1e-9)); + REQUIRE(f.helix_left_handed == true); + REQUIRE_THAT(f.helix_taper_deg, WithinAbs(5.0, 1e-9)); +} + +TEST_CASE("helix with sweep path from a non-sketch/non-helix feature errors", "[CadDocument]") +{ + // An Extrude feature used as sweep path must fail cleanly. + CadDocument doc; + + // Profile: circle + SketchEntity prof; + prof.type = SketchEntity::Type::Circle; + prof.center = Vec2d(0, 0); + prof.radius = 2.0; + int prof_idx = doc.add_sketch_entities({prof}, SketchPlane::XY(), "Profile"); + + // Path: an Extrude feature (not a Sketch or Helix) + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "NotAValidPath"; + ex.sketch_ref = -1; + doc.features.push_back(ex); + int path_idx = int(doc.features.size()) - 1; + + int sw = doc.add_sweep(prof_idx, path_idx, BooleanMode::New, "BadSweep"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); +} + +TEST_CASE("transform translate shifts body bbox by the given vector", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box"); + REQUIRE(doc.recompute()); + + Bnd_Box bb0; + BRepBndLib::Add(doc.bodies[0].shape, bb0); + double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0; + bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0); + double vol0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + + doc.checkpoint(); + doc.add_transform(0, Vec3d(5, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Move"); + REQUIRE(doc.recompute()); + + REQUIRE(doc.bodies.size() == 1); + double vol1 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE_THAT(vol1, WithinRel(vol0, 1e-6)); + + Bnd_Box bb1; + BRepBndLib::Add(doc.bodies[0].shape, bb1); + double xmin1, ymin1, zmin1, xmax1, ymax1, zmax1; + bb1.Get(xmin1, ymin1, zmin1, xmax1, ymax1, zmax1); + REQUIRE_THAT(xmin1, WithinAbs(xmin0 + 5, 1e-6)); + REQUIRE_THAT(xmax1, WithinAbs(xmax0 + 5, 1e-6)); +} + +TEST_CASE("transform rotate 90 about Z swaps XY bbox extents", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box"); + REQUIRE(doc.recompute()); + + Bnd_Box bb0; + BRepBndLib::Add(doc.bodies[0].shape, bb0); + double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0; + bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0); + double vol0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double dx0 = xmax0 - xmin0; + double dy0 = ymax0 - ymin0; + + doc.checkpoint(); + doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 90, false, "Rot90"); + REQUIRE(doc.recompute()); + + double vol1 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE_THAT(vol1, WithinRel(vol0, 1e-6)); + + Bnd_Box bb1; + BRepBndLib::Add(doc.bodies[0].shape, bb1); + double xmin1, ymin1, zmin1, xmax1, ymax1, zmax1; + bb1.Get(xmin1, ymin1, zmin1, xmax1, ymax1, zmax1); + double dx1 = xmax1 - xmin1; + double dy1 = ymax1 - ymin1; + REQUIRE_THAT(dx1, WithinAbs(dy0, 1e-6)); + REQUIRE_THAT(dy1, WithinAbs(dx0, 1e-6)); +} + +TEST_CASE("transform copy keeps original and appends transformed body", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box"); + REQUIRE(doc.recompute()); + + Bnd_Box bb0; + BRepBndLib::Add(doc.bodies[0].shape, bb0); + double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0; + bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0); + + doc.checkpoint(); + doc.add_transform(0, Vec3d(5, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, true, "Copy"); + REQUIRE(doc.recompute()); + + REQUIRE(doc.bodies.size() == 2); + + Bnd_Box bb_orig; + BRepBndLib::Add(doc.bodies[0].shape, bb_orig); + double xo_min, yo_min, zo_min, xo_max, yo_max, zo_max; + bb_orig.Get(xo_min, yo_min, zo_min, xo_max, yo_max, zo_max); + REQUIRE_THAT(xo_min, WithinAbs(xmin0, 1e-6)); + REQUIRE_THAT(xo_max, WithinAbs(xmax0, 1e-6)); + + Bnd_Box bb_copy; + BRepBndLib::Add(doc.bodies[1].shape, bb_copy); + double xc_min, yc_min, zc_min, xc_max, yc_max, zc_max; + bb_copy.Get(xc_min, yc_min, zc_min, xc_max, yc_max, zc_max); + REQUIRE_THAT(xc_min, WithinAbs(xmin0 + 5, 1e-6)); + REQUIRE_THAT(xc_max, WithinAbs(xmax0 + 5, 1e-6)); +} + +TEST_CASE("transform degenerate axis errors", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box"); + REQUIRE(doc.recompute()); + + doc.checkpoint(); + doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 0), Vec3d(0, 0, 0), 45, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE(doc.error.find("axis") != std::string::npos); +} + +TEST_CASE("transform moved body participates in later boolean at its new position", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Two boxes that do NOT overlap + int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "S0"); + doc.add_extrude(sk0, 5.0, false, BooleanMode::New, "Box0"); + + int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "S1"); + doc.add_extrude(sk1, 5.0, false, BooleanMode::New, "Box1"); + doc.features.back().target_body = 0; + REQUIRE(doc.recompute()); + + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + double sum = v0 + v1; + + // Move body 1 so it overlaps body 0 + doc.checkpoint(); + doc.add_transform(1, Vec3d(5, 5, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Move1"); + REQUIRE(doc.recompute()); + + doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse"); + REQUIRE(doc.recompute()); + + REQUIRE(doc.bodies.size() == 1); + double vf = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + // Partial overlap: strictly more than one box (the move DID take effect) and strictly + // less than both (they still intersect). Without a real B-rep transform the two boxes + // stay coincident and vf would equal v0 -- that is what `vf > v0` catches. + REQUIRE(vf > v0 * 1.05); + REQUIRE(vf < sum); +} + +TEST_CASE("transform round-trip preserves all xf_* fields", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box"); + doc.add_transform(0, Vec3d(3, 4, 5), Vec3d(0, 1, 0), Vec3d(1, 2, 3), 37, true, "RoundTrip"); + REQUIRE(doc.recompute()); + int nb = int(doc.bodies.size()); + REQUIRE(nb >= 1); + + // Capture bboxes before serialization + std::vector> bboxes_before; + for (int i = 0; i < nb; ++i) { + Bnd_Box bb; + BRepBndLib::Add(doc.bodies[i].shape, bb); + double x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes_before.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}); + } + + auto blob = doc.serialize_recipe(); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.bodies.size() == size_t(nb)); + + // Check the Transform feature fields + bool found = false; + for (const auto& f : doc2.features) { + if (f.type != CadFeatureType::Transform) continue; + REQUIRE_THAT(f.xf_translate.x(), WithinAbs(3, 1e-9)); + REQUIRE_THAT(f.xf_translate.y(), WithinAbs(4, 1e-9)); + REQUIRE_THAT(f.xf_translate.z(), WithinAbs(5, 1e-9)); + REQUIRE_THAT(f.xf_axis.x(), WithinAbs(0, 1e-9)); + REQUIRE_THAT(f.xf_axis.y(), WithinAbs(1, 1e-9)); + REQUIRE_THAT(f.xf_axis.z(), WithinAbs(0, 1e-9)); + REQUIRE_THAT(f.xf_pivot.x(), WithinAbs(1, 1e-9)); + REQUIRE_THAT(f.xf_pivot.y(), WithinAbs(2, 1e-9)); + REQUIRE_THAT(f.xf_pivot.z(), WithinAbs(3, 1e-9)); + REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37, 1e-9)); + REQUIRE(f.xf_copy == true); + found = true; + break; + } + REQUIRE(found); + + // Verify bboxes equal + for (int i = 0; i < nb; ++i) { + Bnd_Box bb; + BRepBndLib::Add(doc2.bodies[i].shape, bb); + double x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(x0, WithinAbs(bboxes_before[i].first.x(), 1e-6)); + REQUIRE_THAT(y0, WithinAbs(bboxes_before[i].first.y(), 1e-6)); + REQUIRE_THAT(z0, WithinAbs(bboxes_before[i].first.z(), 1e-6)); + REQUIRE_THAT(x1, WithinAbs(bboxes_before[i].second.x(), 1e-6)); + REQUIRE_THAT(y1, WithinAbs(bboxes_before[i].second.y(), 1e-6)); + REQUIRE_THAT(z1, WithinAbs(bboxes_before[i].second.z(), 1e-6)); + } +} + +// --- Thicken tests --- + +TEST_CASE("thicken a planar face to a plate", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_thicken(0, top_face, 3.0, false, "Plate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 3.0, 0.01)); +} + +TEST_CASE("thicken flip offsets against face normal", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + // non-flipped: plate grows above the box (z > 10) + CadDocument doc2; + int sk2 = doc2.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch2"); + doc2.add_extrude(sk2, 10.0, false, BooleanMode::New, "Extrude2"); + REQUIRE(doc2.recompute()); + int nf2 = GeometryEngine::face_count(doc2.bodies[0].shape); + int tf2 = -1; + for (int i = 0; i < nf2; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc2.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { tf2 = i; break; } + } + REQUIRE(tf2 >= 0); + doc2.add_thicken(0, tf2, 3.0, false, "PlateFwd"); + REQUIRE(doc2.recompute()); + Bnd_Box bb_fwd; BRepBndLib::Add(doc2.bodies[1].shape, bb_fwd); + Standard_Real x0, y0, z0, x1, y1, z1; + bb_fwd.Get(x0, y0, z0, x1, y1, z1); + + // flipped: plate grows below the face plane (z < 10) + CadDocument doc3; + int sk3 = doc3.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch3"); + doc3.add_extrude(sk3, 10.0, false, BooleanMode::New, "Extrude3"); + REQUIRE(doc3.recompute()); + int nf3 = GeometryEngine::face_count(doc3.bodies[0].shape); + int tf3 = -1; + for (int i = 0; i < nf3; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc3.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { tf3 = i; break; } + } + REQUIRE(tf3 >= 0); + doc3.add_thicken(0, tf3, 3.0, true, "PlateRev"); + REQUIRE(doc3.recompute()); + Bnd_Box bb_rev; BRepBndLib::Add(doc3.bodies[1].shape, bb_rev); + Standard_Real rx0, ry0, rz0, rx1, ry1, rz1; + bb_rev.Get(rx0, ry0, rz0, rx1, ry1, rz1); + + // forward plate bbox z > 10 (source face at z=10, +3 offset = z in (10,13)) + REQUIRE(z0 >= 9.9); + // reverse plate bbox z < 10 (source face at z=10, -3 offset = z in (7,10)) + REQUIRE(rz1 <= 10.1); +} + +TEST_CASE("thicken bad face index returns error", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + + doc.add_thicken(0, 9999, 3.0, false, "Bad"); + bool ok = doc.recompute(); + REQUIRE_FALSE(ok); + REQUIRE(doc.error.find("face") != std::string::npos); +} + +TEST_CASE("thicken zero thickness returns error", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_thicken(0, top_face, 0.0, false, "Zero"); + bool ok = doc.recompute(); + REQUIRE_FALSE(ok); + REQUIRE(doc.error.find("thickness") != std::string::npos); +} + +TEST_CASE("thickened plate fuses with source", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + double v_box = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_thicken(0, top_face, 3.0, false, "Plate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + double v_fused = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE(v_fused > v_box); +} + +TEST_CASE("thicken round-trip serialization", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + doc.add_thicken(0, top_face, 3.0, false, "Plate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + // Remember field values + int tf = doc.features.back().thicken_face; + double tt = doc.features.back().thicken_thickness; + bool tb = doc.features.back().thicken_flip; + size_t nb = doc.bodies.size(); + + std::vector> bboxes; + for (const auto& b : doc.bodies) { + Bnd_Box bb; BRepBndLib::Add(b.shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}); + } + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.features.size() == doc.features.size()); + REQUIRE(doc2.bodies.size() == nb); + + const CadFeature& f2 = doc2.features.back(); + REQUIRE(f2.thicken_face == tf); + REQUIRE_THAT(f2.thicken_thickness, WithinAbs(tt, 1e-9)); + REQUIRE(f2.thicken_flip == tb); + + for (size_t i = 0; i < nb; ++i) { + Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6)); + REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6)); + REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6)); + REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6)); + REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6)); + REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6)); + } +} + +// --- Project feature tests --- + +TEST_CASE("project a box top face to 4 lines, extrudable", "[CadDocument][project]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int proj = doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop"); + REQUIRE(proj >= 0); + doc.add_extrude(proj, 5.0, false, BooleanMode::New, "FromProj"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const auto& pf = doc.features[proj]; + REQUIRE(pf.entities.size() == 4); + for (const auto& e : pf.entities) + REQUIRE(e.type == SketchEntity::Type::Line); + + REQUIRE(doc.bodies.size() >= 2); + double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume()); + REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 1e-3)); +} + +TEST_CASE("project a cylinder top edge to 1 circle, extrudable", "[CadDocument][project]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(0, 0); + c.radius = 6.0; + int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circ"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape); + int top_edge = -1; + for (int i = 0; i < n_edges; ++i) { + TopoDS_Edge e = GeometryEngine::edge_by_index(doc.bodies[0].shape, i); + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.empty()) continue; + Vec3d mid = Vec3d::Zero(); + for (const auto& p : pts) mid += p; + mid /= double(pts.size()); + if (mid.z() > 9.0) { + BRepAdaptor_Curve ac(e); + if (ac.GetType() == GeomAbs_Circle) { top_edge = i; break; } + } + } + REQUIRE(top_edge >= 0); + + int proj = doc.add_project_edges(0, {top_edge}, -1, SketchPlane::XY(), "ProjCirc"); + REQUIRE(proj >= 0); + doc.add_extrude(proj, 4.0, false, BooleanMode::New, "FromCirc"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const auto& pf = doc.features[proj]; + REQUIRE(pf.entities.size() == 1); + REQUIRE(pf.entities[0].type == SketchEntity::Type::Circle); + REQUIRE_THAT(pf.entities[0].radius, WithinRel(6.0, 1e-3)); + + REQUIRE(doc.bodies.size() >= 2); + double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume()); + REQUIRE_THAT(v, WithinRel(M_PI * 36.0 * 4.0, 1e-2)); +} + +TEST_CASE("project with no face and no edge selection projects every edge", "[CadDocument][project]") +{ + // This is the state the Project card opens in — its label reads "(all edges)". Before the + // all-edges branch existed it threw "no edges or face selected", so the card's default + // could never be confirmed. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + + int proj = doc.add_project_edges(0, {}, -1, SketchPlane::XY(), "ProjAll"); + REQUIRE(proj >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // A box has 12 edges; the 4 running along Z collapse to points on XY and are dropped, + // leaving the 4 bottom and 4 top edges. + const auto& pf = doc.features[proj]; + REQUIRE(pf.entities.size() == 8); + for (const auto& e : pf.entities) { + REQUIRE(e.type == SketchEntity::Type::Line); + REQUIRE((e.p1 - e.p0).norm() > 1e-6); + } +} + +TEST_CASE("project bad face id returns error", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + + doc.add_project_edges(0, {}, 9999, SketchPlane::XY(), "Bad"); + bool ok = doc.recompute(); + REQUIRE_FALSE(ok); + bool has_project = doc.error.find("project") != std::string::npos + || doc.error.find("face") != std::string::npos; + REQUIRE(has_project); +} + +TEST_CASE("project round-trip serialization", "[CadDocument][project]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int saved_src = doc.features.back().project_source_body; + int saved_face = doc.features.back().project_face; + auto saved_edges = doc.features.back().project_edges; + size_t saved_nb = doc.bodies.size(); + + std::vector> bboxes; + for (const auto& b : doc.bodies) { + Bnd_Box bb; BRepBndLib::Add(b.shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}); + } + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.bodies.size() == saved_nb); + REQUIRE(doc2.features.size() == doc.features.size()); + + const auto& f2 = doc2.features.back(); + REQUIRE(f2.project_source_body == saved_src); + REQUIRE(f2.project_face == saved_face); + REQUIRE(f2.project_edges == saved_edges); + + for (size_t i = 0; i < saved_nb; ++i) { + Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6)); + REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6)); + REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6)); + REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6)); + REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6)); + REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6)); + } +} + +TEST_CASE("Use/Convert Entities: box top-face edges land as construction geometry", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + if (GeometryEngine::face_normal_world(fc).z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + REQUIRE(target >= 0); + + int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); + REQUIRE(appended == 4); + + const auto& ents = doc.features[target].entities; + REQUIRE(ents.size() == 4); + for (const auto& e : ents) { + REQUIRE(e.type == SketchEntity::Type::Line); + REQUIRE(e.construction == true); + } +} + +TEST_CASE("Use/Convert Entities: construction references never become solid", "[CadDocument][project]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + if (GeometryEngine::face_normal_world(fc).z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + const size_t body_count_before = doc.bodies.size(); + const double volume_before = doc.body_mass_properties(0).volume; + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); + REQUIRE(appended == 4); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == body_count_before); + REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9)); +} + +TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + REQUIRE(target >= 0); + + REQUIRE(doc.project_edges_into_sketch(-1, 0, {}, -1) == -1); + REQUIRE(doc.project_edges_into_sketch(999, 0, {}, -1) == -1); + REQUIRE(doc.project_edges_into_sketch(target, -1, {}, -1) == -1); + REQUIRE(doc.project_edges_into_sketch(target, 999, {}, -1) == -1); + REQUIRE(doc.features[target].entities.empty()); +} + +TEST_CASE("Use/Convert Entities: cylinder edge arrives as a Circle, not a polyline", "[CadDocument][project]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(0, 0); + c.radius = 6.0; + int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circ"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape); + int top_edge = -1; + for (int i = 0; i < n_edges; ++i) { + TopoDS_Edge e = GeometryEngine::edge_by_index(doc.bodies[0].shape, i); + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.empty()) continue; + Vec3d mid = Vec3d::Zero(); + for (const auto& p : pts) mid += p; + mid /= double(pts.size()); + if (mid.z() > 9.0) { + BRepAdaptor_Curve ac(e); + if (ac.GetType() == GeomAbs_Circle) { top_edge = i; break; } + } + } + REQUIRE(top_edge >= 0); + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + int appended = doc.project_edges_into_sketch(target, 0, {top_edge}, -1); + REQUIRE(appended == 1); + + const auto& ents = doc.features[target].entities; + REQUIRE(ents.size() == 1); + REQUIRE(ents[0].type == SketchEntity::Type::Circle); + REQUIRE(ents[0].construction == true); + REQUIRE_THAT(ents[0].radius, WithinRel(6.0, 1e-6)); +} + +TEST_CASE("Project feature still emits non-construction entities (regression guard)", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + if (GeometryEngine::face_normal_world(fc).z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int proj = doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop"); + REQUIRE(proj >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const auto& pf = doc.features[proj]; + REQUIRE(pf.entities.size() == 4); + for (const auto& e : pf.entities) + REQUIRE(e.construction == false); +} + +// --- Golden recipe fixture (v1 format tripwire) --- + +static CadDocument make_golden_doc_v1() +{ + CadDocument doc; + + // ---- Body 0: base box with distinctive taper ---- + int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 30, 20, 15, "Sketch_Base"); + doc.add_extrude(sk0, 15.0, false, BooleanMode::New, "Extrude_Base"); + int ex0 = int(doc.features.size()) - 1; + doc.features[ex0].taper_deg = 8.5; + doc.features[ex0].extrude_end = ExtrudeEnd::Blind; + + // Dress-up: fillet lateral faces, chamfer top face — distinct non-default sizes + doc.add_fillet(3.5, FaceGroup::Lateral, "Fillet_Lat35"); + doc.add_chamfer(2.0, FaceGroup::Top, "Chamfer_Top2"); + + // Hole: offset position, non-through + doc.add_hole(7.5, 11.0, false, 4.0, 3.0, SketchPlane::XY(), "Hole_Off75"); + + // Draft: angle 7.25 deg on face 3 + doc.add_draft(7.25, 3, 0, "Draft_F3"); + + // Shell: thickness 1.375 mm, open face 1 + doc.add_shell(1.375, 1, 0, "Shell_T1375"); + + // Thread: internal, radius 4, pitch 2.5, height 15, depth 1.25 + doc.add_thread(4.0, 2.5, 15.0, 1.25, true, 0.0, 0.0, SketchPlane::XY(), "Thread_Int"); + + // Cut: plane XY, offset 10, flip, keep upper only + doc.add_cut(SketchPlane::XY(), 10.0, true, true, false, 0, "Cut_Flip"); + + // Pattern: linear 5 copies, spacing 13.5 mm along plane X + doc.add_pattern(false, 5, 13.5, 0, 360.0, 0, "Pattern_Lin5"); + + // ---- Datum plane ---- + doc.add_plane(0, 25.0, 0.0, 0, "Plane_Datum25"); + + // ---- Revolve: self-contained body (sketch + revolve) ---- + { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "Sketch_Rev"; + sk.plane = SketchPlane::XY(); + sk.entities = {{SketchEntity::Type::Circle, Vec2d(12,0), Vec2d(12,0), Vec2d(12,0), 4.0}}; + doc.features.push_back(sk); + } + int rev_sk = int(doc.features.size()) - 1; + doc.add_revolve(rev_sk, 217.0, 1, false, BooleanMode::New, "Revolve_Y217"); + + // ---- Sweep: self-contained body (profile + path sketches + sweep) ---- + { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "Sketch_SwProf"; + sk.plane = SketchPlane::XY(); + sk.entities = {{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 3.0}}; + doc.features.push_back(sk); + } + int sw_prof = int(doc.features.size()) - 1; + { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "Sketch_SwPath"; + sk.plane = SketchPlane::XZ(); + sk.entities = {{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(0,35)}}; + doc.features.push_back(sk); + } + int sw_path = int(doc.features.size()) - 1; + doc.add_sweep(sw_prof, sw_path, BooleanMode::New, "Sweep_Z35"); + + // ---- Loft: self-contained body (two profiles + loft) ---- + { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "Sketch_LoftBot"; + sk.plane = SketchPlane::XY(); + sk.profile.points = {{-7,-7},{7,-7},{7,7},{-7,7}}; + sk.profile.closed = true; + doc.features.push_back(sk); + } + int loft_bot = int(doc.features.size()) - 1; + { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "Sketch_LoftTop"; + sk.plane.origin = Vec3d(0, 0, 25); + sk.plane.normal = Vec3d(0, 0, 1); + sk.plane.x_axis = Vec3d(1, 0, 0); + sk.plane.y_axis = Vec3d(0, 1, 0); + sk.profile.points = {{-9,-9},{9,-9},{9,9},{-9,9}}; + sk.profile.closed = true; + doc.features.push_back(sk); + } + int loft_top = int(doc.features.size()) - 1; + doc.add_loft({loft_bot, loft_top}, true, BooleanMode::New, "Loft_Ruled"); + + // ---- Boolean: distinctive tolerance and face-mate params ---- + doc.add_boolean(BooleanMode::Cut, 0, 1, false, 0.01, 2, 3, "Boolean_Cut"); + + // ---- Extrude variant: symmetric + two-sided end ---- + { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "Sketch_Ex2"; + sk.plane = SketchPlane::XZ(); + sk.entities = {{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 6.0}}; + doc.features.push_back(sk); + } + int sk_ex2 = int(doc.features.size()) - 1; + { + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "Extrude_Sym"; + ex.sketch_ref = sk_ex2; + ex.distance = 25.0; + ex.symmetric = true; + ex.mode = BooleanMode::New; + ex.extrude_end = ExtrudeEnd::Symmetric; + ex.distance2 = 12.5; + doc.features.push_back(ex); + } + + // ---- Mirror: XZ plane, New mode, keep_original=false (distinctive non-defaults) ---- + doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror_XZ"); + doc.features.back().mirror_keep_original = false; + + // ---- Datum Axis: two-points with distinctive non-default coordinates ---- + { + int ax = doc.add_axis(AxisType::TwoPoints, "Axis_TP"); + doc.features[ax].axis_p1 = Vec3d(10, 20, 30); + doc.features[ax].axis_p2 = Vec3d(13, 24, 34); + doc.features[ax].axis_body = 1; + doc.features[ax].axis_face = 3; + doc.features[ax].axis_edge = 2; + doc.features[ax].axis_plane_a = 4; + doc.features[ax].axis_plane_b = 5; + } + + // ---- Datum CoordSys: PointWorld with distinctive origin ---- + { + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(7, 8, 9), "CS_PtWorld"); + doc.features[cs].coordsys_body = 2; + doc.features[cs].coordsys_face = 1; + doc.features[cs].coordsys_edge = 0; + doc.features[cs].coordsys_x_hint = Vec3d(0.5, 0.8, 0.3); + } + + // ---- Helix: conical left-handed with distinctive non-default values ---- + { + int hx = doc.add_helix(SketchPlane::XZ(), 11.5, 4.25, 18.0, true, 3.0, "Helix_CLH"); + (void)hx; + } + + // ---- Transform: rigid move/rotate with distinctive non-default values ---- + doc.add_transform(0, Vec3d(3.5, 4.5, 5.5), Vec3d(0.0, 1.0, 0.0), Vec3d(1.5, 2.5, 3.5), + 37.0, true, "GoldenTransform"); + + doc.add_thicken(0, 0, 1.75, true, "GoldenThicken"); + + doc.add_split_by_face(0, 0, 2, true, false, "GoldenSplit"); + + doc.add_project_edges(0, {}, 0, SketchPlane::XY(), "GoldenProject"); + + // Construction-flag on-disk lock: a real edge + a construction edge with distinctive + // literals. Regen-golden does not recompute, so this only exercises serialization. + { + std::vector ge; + SketchEntity real0; real0.type = SketchEntity::Type::Line; + real0.p0 = Vec2d(-12.0, -12.0); real0.p1 = Vec2d(12.0, -12.0); + SketchEntity ctor; ctor.type = SketchEntity::Type::Line; + ctor.p0 = Vec2d(-40.0, 9.0); ctor.p1 = Vec2d(40.0, 9.0); + ctor.construction = true; + ge.push_back(real0); + ge.push_back(ctor); + doc.add_sketch_entities(ge, SketchPlane::XY(), "Sketch_Ctor"); + } + + // ---- Mate connectors: two CoordSys features with distinctive non-default values ---- + int cs_idx_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(11, 12, 13), "CS_MateA"); + doc.features[cs_idx_a].coordsys_body = 0; + doc.features[cs_idx_a].coordsys_x_hint = Vec3d(0.1, 0.2, 0.9); + int cs_idx_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(14, 15, 16), "CS_MateB"); + doc.features[cs_idx_b].coordsys_body = 1; + doc.features[cs_idx_b].coordsys_x_hint = Vec3d(0.6, 0.7, 0.3); + + // ---- Mate: Fastened with all six fields carrying distinctive non-defaults ---- + doc.add_mate(1, cs_idx_a, cs_idx_b, 7.25, 33.0, true, "GoldenMate"); + + return doc; +} + +TEST_CASE("regenerate golden recipe fixture", "[.regen]") +{ + CadDocument doc = make_golden_doc_v1(); + // ponytail: serialize_recipe() only needs features, recompute is unnecessary + // for a fixture that exercises the serialization format. + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + // Version-free on purpose: this writes whatever today's format is, so a version bump does + // not have to remember to edit it. The version-stamped fixtures beside it are frozen + // evidence from older builds and are never regenerated. + std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_current.bin"; + std::ofstream ofs(path, std::ios::binary); + REQUIRE(ofs.is_open()); + ofs.write(blob.data(), static_cast(blob.size())); + ofs.close(); + SUCCEED("Fixture written to " << path); +} + +// The previous format's real blob, kept on disk deliberately. It is the only thing that can +// prove the version gate does its job: a v3 recipe carries FEWER fields per feature than this +// build reads, so without the bump to v4 it would have passed the gate and had two ints read +// past the end of every connector — into the next feature's bytes. Silent corruption of a saved +// project, which is far worse than a refusal. This asserts the refusal is clean and says why. +// +// Do NOT regenerate cad_recipe_v3.bin. Its value is entirely that it was written by an older +// build; rewriting it with today's code destroys the only evidence this test rests on. +TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocument]") +{ + std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v3.bin"; + std::ifstream ifs(path, std::ios::binary); + REQUIRE(ifs.is_open()); + std::string blob((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); + ifs.close(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc; + REQUIRE_FALSE(doc.deserialize_recipe(blob)); + REQUIRE_FALSE(doc.error.empty()); + REQUIRE(doc.error.find("older version") != std::string::npos); + REQUIRE(doc.features.empty()); // nothing half-read was left behind +} + +TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + // Read the golden blob from disk + std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v4.bin"; + std::ifstream ifs(path, std::ios::binary); + REQUIRE(ifs.is_open()); + std::string blob((std::istreambuf_iterator(ifs)), + std::istreambuf_iterator()); + ifs.close(); + REQUIRE_FALSE(blob.empty()); + + // --- Layer 1: deserialize features WITHOUT recomputing, assert field values --- + std::vector features; + { + std::istringstream iss(blob); + cereal::BinaryInputArchive ar(iss); + uint32_t v; + ar(v); + REQUIRE(v <= CadDocument::ORCA_CAD_RECIPE_VERSION); + ar(features); + } + + CadDocument expected = make_golden_doc_v1(); + // Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in + // scope for the field-value assertions below, where "regenerate the fixture" is the + // one thing you must never do -- regenerating after a reorder bakes the corrupted + // layout in as the new golden and permanently disarms this test. + { + INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?"); + INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test."); + REQUIRE(features.size() == expected.features.size()); + } + + // A failure BELOW this point means the on-disk serialization format changed: some field + // in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be + // APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass -- + // fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case. + + // Field-by-field assertions against expected values. + // Every field set to a distinctive non-default literal must be checked here. + // A field reorder in save()/load() that swaps fields of differing types + // will produce a wrong value at this position and FAIL the test. + + for (size_t i = 0; i < features.size(); ++i) { + const auto& f = features[i]; + const auto& e = expected.features[i]; + + INFO("Feature index " << i << " type " << int(f.type)); + + REQUIRE(f.type == e.type); + REQUIRE(f.name == e.name); + REQUIRE(f.enabled == e.enabled); + + // Sketch params (all Sketch types) + if (f.type == CadFeatureType::Sketch) { + REQUIRE(f.shape == e.shape); + if (e.name == "Sketch_Base") { + REQUIRE(f.width == 30); + REQUIRE(f.height == 20); + REQUIRE(f.radius == 15); + } + if (e.name == "Sketch_Rev" || e.name == "Sketch_SwProf" || e.name == "Sketch_Ex2" || e.name == "Sketch_SwPath") { + REQUIRE(f.entities.size() == e.entities.size()); + if (!f.entities.empty()) { + REQUIRE(f.entities[0].type == e.entities[0].type); + REQUIRE_THAT(f.entities[0].p0.x(), WithinAbs(e.entities[0].p0.x(), 1e-9)); + REQUIRE_THAT(f.entities[0].p0.y(), WithinAbs(e.entities[0].p0.y(), 1e-9)); + } + } + if (e.name == "Sketch_LoftBot" || e.name == "Sketch_LoftTop") { + REQUIRE(f.profile.points.size() == e.profile.points.size()); + REQUIRE(f.profile.closed == e.profile.closed); + } + if (e.name == "Sketch_LoftTop") { + REQUIRE_THAT(f.plane.origin.z(), WithinAbs(25.0, 1e-9)); + } + if (e.name == "Sketch_Ctor") { + REQUIRE(f.entities.size() == 2); + REQUIRE(f.entities[0].construction == false); + REQUIRE(f.entities[1].construction == true); + REQUIRE_THAT(f.entities[1].p0.x(), WithinAbs(-40.0, 1e-9)); + } + } + + // Extrude params + if (f.type == CadFeatureType::Extrude) { + REQUIRE(f.mode == e.mode); + if (e.name == "Extrude_Base") { + REQUIRE(f.distance == 15.0); + REQUIRE(f.symmetric == false); + REQUIRE(f.extrude_end == ExtrudeEnd::Blind); + REQUIRE_THAT(f.taper_deg, WithinAbs(8.5, 1e-9)); + } + if (e.name == "Extrude_Sym") { + REQUIRE(f.distance == 25.0); + REQUIRE(f.symmetric == true); + REQUIRE(f.extrude_end == ExtrudeEnd::Symmetric); + REQUIRE_THAT(f.distance2, WithinAbs(12.5, 1e-9)); + } + } + + // Fillet + if (f.type == CadFeatureType::Fillet && e.name == "Fillet_Lat35") { + REQUIRE_THAT(f.dressup_size, WithinAbs(3.5, 1e-9)); + REQUIRE(f.face_group == FaceGroup::Lateral); + } + + // Chamfer + if (f.type == CadFeatureType::Chamfer && e.name == "Chamfer_Top2") { + REQUIRE_THAT(f.dressup_size, WithinAbs(2.0, 1e-9)); + REQUIRE(f.face_group == FaceGroup::Top); + } + + // Hole + if (f.type == CadFeatureType::Hole && e.name == "Hole_Off75") { + REQUIRE_THAT(f.hole_diameter, WithinAbs(7.5, 1e-9)); + REQUIRE_THAT(f.hole_depth, WithinAbs(11.0, 1e-9)); + REQUIRE(f.hole_through == false); + REQUIRE_THAT(f.hole_x, WithinAbs(4.0, 1e-9)); + REQUIRE_THAT(f.hole_y, WithinAbs(3.0, 1e-9)); + } + + // Draft + if (f.type == CadFeatureType::Draft && e.name == "Draft_F3") { + REQUIRE(f.draft_face == 3); + REQUIRE_THAT(f.draft_angle, WithinAbs(7.25, 1e-9)); + } + + // Shell + if (f.type == CadFeatureType::Shell && e.name == "Shell_T1375") { + REQUIRE_THAT(f.shell_thickness, WithinAbs(1.375, 1e-9)); + REQUIRE(f.shell_face == 1); + } + + // Thread + if (f.type == CadFeatureType::Thread && e.name == "Thread_Int") { + REQUIRE_THAT(f.thread_radius, WithinAbs(4.0, 1e-9)); + REQUIRE_THAT(f.thread_pitch, WithinAbs(2.5, 1e-9)); + REQUIRE_THAT(f.thread_height, WithinAbs(15.0, 1e-9)); + REQUIRE_THAT(f.thread_depth, WithinAbs(1.25, 1e-9)); + REQUIRE(f.thread_internal == true); + REQUIRE_THAT(f.thread_x, WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(f.thread_y, WithinAbs(0.0, 1e-9)); + } + + // Cut + if (f.type == CadFeatureType::Cut && e.name == "Cut_Flip") { + REQUIRE_THAT(f.cut_offset, WithinAbs(10.0, 1e-9)); + REQUIRE(f.cut_flip == true); + REQUIRE(f.cut_keep_upper == true); + REQUIRE(f.cut_keep_lower == false); + } + + // Pattern + if (f.type == CadFeatureType::Pattern && e.name == "Pattern_Lin5") { + REQUIRE(f.pattern_circular == false); + REQUIRE(f.pattern_count == 5); + REQUIRE_THAT(f.pattern_spacing, WithinAbs(13.5, 1e-9)); + REQUIRE(f.pattern_dir == 0); + } + + // Datum Plane + if (f.type == CadFeatureType::Plane && e.name == "Plane_Datum25") { + REQUIRE(f.plane_base == 0); + REQUIRE_THAT(f.plane_offset, WithinAbs(25.0, 1e-9)); + REQUIRE_THAT(f.plane_angle_tilt, WithinAbs(0.0, 1e-9)); + REQUIRE(f.plane_axis == 0); + } + + // Revolve + if (f.type == CadFeatureType::Revolve && e.name == "Revolve_Y217") { + REQUIRE_THAT(f.revolve_angle, WithinAbs(217.0, 1e-9)); + REQUIRE(f.revolve_axis == 1); + } + + // Sweep + if (f.type == CadFeatureType::Sweep && e.name == "Sweep_Z35") { + REQUIRE(f.sweep_path_ref == e.sweep_path_ref); + REQUIRE(f.sweep_path_ref >= 0); + } + + // Loft + if (f.type == CadFeatureType::Loft && e.name == "Loft_Ruled") { + REQUIRE(f.loft_ruled == true); + REQUIRE(f.loft_profile_refs.size() == 2); + } + + // Boolean + if (f.type == CadFeatureType::Boolean && e.name == "Boolean_Cut") { + REQUIRE(f.mode == BooleanMode::Cut); + REQUIRE(f.bool_tool_body == 1); + REQUIRE(f.bool_keep_tool == false); + REQUIRE_THAT(f.bool_tolerance, WithinAbs(0.01, 1e-9)); + REQUIRE(f.bool_target_face == 2); + REQUIRE(f.bool_tool_face == 3); + } + + // Mirror + if (f.type == CadFeatureType::Mirror && e.name == "Mirror_XZ") { + REQUIRE(f.mode == BooleanMode::New); + REQUIRE(f.mirror_keep_original == false); + REQUIRE(f.target_body == 0); + } + + // Datum Axis + if (f.type == CadFeatureType::Axis && e.name == "Axis_TP") { + REQUIRE(f.axis_type == AxisType::TwoPoints); + REQUIRE_THAT(f.axis_p1.x(), WithinAbs(10.0, 1e-9)); + REQUIRE_THAT(f.axis_p1.y(), WithinAbs(20.0, 1e-9)); + REQUIRE_THAT(f.axis_p1.z(), WithinAbs(30.0, 1e-9)); + REQUIRE_THAT(f.axis_p2.x(), WithinAbs(13.0, 1e-9)); + REQUIRE_THAT(f.axis_p2.y(), WithinAbs(24.0, 1e-9)); + REQUIRE_THAT(f.axis_p2.z(), WithinAbs(34.0, 1e-9)); + REQUIRE(f.axis_body == 1); + REQUIRE(f.axis_face == 3); + REQUIRE(f.axis_edge == 2); + REQUIRE(f.axis_plane_a == 4); + REQUIRE(f.axis_plane_b == 5); + } + + // Datum CoordSys + if (f.type == CadFeatureType::CoordSys && e.name == "CS_PtWorld") { + REQUIRE(f.coordsys_type == CoordSysType::PointWorld); + REQUIRE_THAT(f.coordsys_point.x(), WithinAbs(7.0, 1e-9)); + REQUIRE_THAT(f.coordsys_point.y(), WithinAbs(8.0, 1e-9)); + REQUIRE_THAT(f.coordsys_point.z(), WithinAbs(9.0, 1e-9)); + REQUIRE(f.coordsys_body == 2); + REQUIRE(f.coordsys_face == 1); + REQUIRE(f.coordsys_edge == 0); + REQUIRE_THAT(f.coordsys_x_hint.x(), WithinAbs(0.5, 1e-9)); + REQUIRE_THAT(f.coordsys_x_hint.y(), WithinAbs(0.8, 1e-9)); + REQUIRE_THAT(f.coordsys_x_hint.z(), WithinAbs(0.3, 1e-9)); + } + + // Helix + if (f.type == CadFeatureType::Helix && e.name == "Helix_CLH") { + REQUIRE_THAT(f.helix_radius, WithinAbs(11.5, 1e-9)); + REQUIRE_THAT(f.helix_pitch, WithinAbs(4.25, 1e-9)); + REQUIRE_THAT(f.helix_height, WithinAbs(18.0, 1e-9)); + REQUIRE(f.helix_left_handed == true); + REQUIRE_THAT(f.helix_taper_deg, WithinAbs(3.0, 1e-9)); + } + + // Transform + if (f.type == CadFeatureType::Transform && e.name == "GoldenTransform") { + REQUIRE_THAT(f.xf_translate.x(), WithinAbs(3.5, 1e-9)); + REQUIRE_THAT(f.xf_translate.y(), WithinAbs(4.5, 1e-9)); + REQUIRE_THAT(f.xf_translate.z(), WithinAbs(5.5, 1e-9)); + REQUIRE_THAT(f.xf_axis.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(f.xf_axis.y(), WithinAbs(1.0, 1e-9)); + REQUIRE_THAT(f.xf_axis.z(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(f.xf_pivot.x(), WithinAbs(1.5, 1e-9)); + REQUIRE_THAT(f.xf_pivot.y(), WithinAbs(2.5, 1e-9)); + REQUIRE_THAT(f.xf_pivot.z(), WithinAbs(3.5, 1e-9)); + REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37.0, 1e-9)); + REQUIRE(f.xf_copy == true); + } + + // Thicken + if (f.type == CadFeatureType::Thicken && e.name == "GoldenThicken") { + REQUIRE(f.thicken_face == 0); + REQUIRE_THAT(f.thicken_thickness, WithinAbs(1.75, 1e-9)); + REQUIRE(f.thicken_flip == true); + } + + // Cut-by-face: GoldenSplit uses cut_face_body/cut_face instead of plane + if (f.type == CadFeatureType::Cut && e.name == "GoldenSplit") { + REQUIRE(f.cut_face_body == 0); + REQUIRE(f.cut_face == 2); + REQUIRE(f.cut_keep_upper == true); + REQUIRE(f.cut_keep_lower == false); + } + + // Project + if (f.type == CadFeatureType::Project && e.name == "GoldenProject") { + REQUIRE(f.project_source_body == 0); + REQUIRE(f.project_face == 0); + REQUIRE(f.project_edges == e.project_edges); + } + + // Mate + if (f.type == CadFeatureType::Mate && e.name == "GoldenMate") { + REQUIRE(f.mate_kind == 1); + REQUIRE(f.mate_cs_a == e.mate_cs_a); + REQUIRE(f.mate_cs_b == e.mate_cs_b); + REQUIRE_THAT(f.mate_offset, WithinAbs(7.25, 1e-9)); + REQUIRE_THAT(f.mate_angle, WithinAbs(33.0, 1e-9)); + REQUIRE(f.mate_flip == true); + } + } + + // --- Layer 2: geometry check (optional — only if the document recomputes) --- + // Build expected document and try to recompute it. + // ponytail: the field-value layer above is the real tripwire; + // this layer is a bonus sanity check on the full recompute path. + CadDocument exp_doc = make_golden_doc_v1(); + bool exp_ok = exp_doc.recompute(); + if (!exp_ok) { + INFO("Expected document from make_golden_doc_v1() could not recompute " + "(complex feature tree). Field-value checks above are sufficient."); + } + + CadDocument doc; + bool ser_ok = doc.deserialize_recipe(blob); + + if (exp_ok && ser_ok) { + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == exp_doc.bodies.size()); + for (size_t i = 0; i < doc.bodies.size(); ++i) { + double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume()); + double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume()); + REQUIRE_THAT(v, WithinRel(ev, 1e-6)); + } + } else { + INFO("The golden recipe fixture was loaded and field-value checks passed."); + INFO("Recompute on the deserialized or expected document failed — this is"); + INFO("expected for the extended golden fixture (many feature types coexist"); + INFO("purely for serialization coverage). The field-value tripwire above is"); + INFO("the primary format check."); + } +} + +// --- Rib tests (M5b) --- + +TEST_CASE("rib adds material to a box", "[CadDocument][rib]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + + // Build a box: 40x40x10 extruded on XY -> z=[0,10] + int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 40, 40, 10, "Box"); + doc.add_extrude(sk_box, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + double Vbox = double(doc.display_mesh.volume()); + REQUIRE(Vbox > 0.0); + + // Sketch a single open Line across the box footprint, on the same XY plane. + // Line from (5,20) to (35,20), centred in Y but off-centre in X. + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(5, 20), Vec2d(35, 20)}, + }; + int sk_rib = doc.add_sketch_entities(ents, SketchPlane::XY(), "RibLine"); + REQUIRE(sk_rib >= 0); + + int fi = doc.add_rib(sk_rib, 0, 3.0, 12.0, 0, "Rib"); + REQUIRE(fi >= 0); + REQUIRE(doc.features[fi].type == CadFeatureType::Rib); + + bool ok = doc.recompute(); + REQUIRE(ok); + REQUIRE(doc.error.empty()); + // The rib fused extra material -> volume must be strictly larger. + double Vrib = double(doc.display_mesh.volume()); + REQUIRE(Vrib > Vbox); + + // A rib with a bad sketch ref must fail cleanly, not crash. + CadDocument bad; + bad.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + bad.add_extrude(0, 10.0, false, BooleanMode::New, "E"); + REQUIRE(bad.recompute()); + bad.add_rib(999, 0, 3.0, 10.0, 0, "BadRib"); + REQUIRE_FALSE(bad.recompute()); +} + +TEST_CASE("rib accepts a Project feature as its sketch ref", "[CadDocument][rib]") +{ + // Project carries plane + Line entities, which is all a rib reads. Every other consumer + // (Extrude, SurfaceExtrude, SurfaceRevolve) already accepts it; Rib used to reject it, + // which made "project a body edge, then rib along it" unreachable. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + double Vbox = double(doc.display_mesh.volume()); + + // Project the whole body onto XY: the 4 top and 4 bottom edges survive as Lines. + int proj = doc.add_project_edges(0, {}, -1, SketchPlane::XY(), "Proj"); + REQUIRE(proj >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.features[proj].entities.size() == 8); + + int fi = doc.add_rib(proj, 0, 3.0, 12.0, 0, "RibFromProjection"); + REQUIRE(fi >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(double(doc.display_mesh.volume()) > Vbox); +} + +TEST_CASE("rib non-line entity rejected safely", "[CadDocument][rib]") +{ + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + + // Sketch with a Circle entity (not a Line) + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0, 0), Vec2d(0, 0), Vec2d(0, 0), 5.0}, + }; + int sk2 = doc.add_sketch_entities(ents, SketchPlane::XY(), "CircleSketch"); + doc.add_rib(sk2, 0, 2.0, 10.0, 0, "BadRib"); + + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); + REQUIRE_CONTAINS(doc.error, "rib"); +} + +TEST_CASE("rib round-trip serialization", "[CadDocument][rib]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(5, 20), Vec2d(35, 20)}, + }; + int sk2 = doc.add_sketch_entities(ents, SketchPlane::XY(), "RibLine"); + doc.add_rib(sk2, 0, 3.0, 12.0, 0, "Rib"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + size_t nb = doc.bodies.size(); + double Vdoc = double(doc.display_mesh.volume()); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.bodies.size() == nb); + double Vfresh = double(fresh.display_mesh.volume()); + REQUIRE_THAT(Vfresh, WithinAbs(Vdoc, 1e-6)); + + // Find the rib feature and check its fields survived. + const CadFeature* rf = nullptr; + for (const auto& f : fresh.features) { + if (f.type == CadFeatureType::Rib) { rf = &f; break; } + } + REQUIRE(rf != nullptr); + REQUIRE_THAT(rf->rib_thickness, WithinAbs(3.0, 1e-9)); + REQUIRE_THAT(rf->rib_depth, WithinAbs(12.0, 1e-9)); +} + +// --- Bridge tests (M3c) --- + +TEST_CASE("bridge two collinear lines", "[CadDocument][bridge]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, + {SketchEntity::Type::Line, Vec2d(20,0), Vec2d(30,0)}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + REQUIRE(sk == 0); + + int bi = doc.add_bridge(0, 0, 1, 1, 0, "Bridge"); + REQUIRE(bi == 2); + + const auto& se = doc.features[0].entities; + REQUIRE(se.size() == 3); + REQUIRE(se[bi].type == SketchEntity::Type::BSpline); + REQUIRE(se[bi].ctrl.size() == 4); + REQUIRE_THAT(se[bi].ctrl.front().x(), WithinAbs(10.0, 1e-6)); + REQUIRE_THAT(se[bi].ctrl.front().y(), WithinAbs(0.0, 1e-6)); + REQUIRE_THAT(se[bi].ctrl.back().x(), WithinAbs(20.0, 1e-6)); + REQUIRE_THAT(se[bi].ctrl.back().y(), WithinAbs(0.0, 1e-6)); +} + +TEST_CASE("bridge closes a C profile and extrudes", "[CadDocument][bridge]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + // Right-side of a closed square: P0(10,-10), up to P1(10,10) + std::vector ents = { + // bottom edge: (-10,-10) to (10,-10) + {SketchEntity::Type::Line, Vec2d(-10,-10), Vec2d(10,-10)}, + // left edge: (10,-10) to (10,10) + {SketchEntity::Type::Line, Vec2d(10,-10), Vec2d(10,10)}, + // top edge: (10,10) to (-10,10) + {SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)}, + }; + // Missing: left edge from (-10,10) to (-10,-10). Build it as a separate line + // entity so the bridge connects two existing lines. + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "C"); + REQUIRE(sk == 0); + + // Add the closing line as entity 3: (-10,10) to (-10,-10) + CadFeature& f = doc.features[sk]; + SketchEntity closing; + closing.type = SketchEntity::Type::Line; + closing.p0 = Vec2d(-10, 10); + closing.p1 = Vec2d(-10, -10); + // The C is entities 0,1,2 (bottom cap, right side, top cap). + // Entity 0 end=1 is (10,-10); entity 2 start=0 is (10,10). That's a U. + // But we need a closed square from C shape. + // Re-think: a C shape open on the left side. + // Entities: 0 = bottom edge (-10,-10)->(10,-10) [end=1 at (10,-10)] + // 1 = right edge (10,-10)->(10,10) [start=0 at (10,-10), end=1 at (10,10)] + // 2 = top edge (10,10)->(-10,10) [start=0 at (10,10), end=1 at (-10,10)] + // The C is open: entity 2's end is at (-10,10) and entity 0's start is at (-10,-10). + // Bridge: entity 2 end=1 (-10,10) -> entity 0 start=0 (-10,-10). + f.entities.push_back(closing); + REQUIRE(f.entities.size() == 4); + + // Now bridge from top end (entity 2 end=1 = (-10,10)) to bottom start (entity 0 end=0 = (-10,-10)) + int bi = doc.add_bridge(sk, 2/*top edge*/, 1/*end*/, 0/*bottom edge*/, 0/*start*/, "Bridge"); + REQUIRE(bi == 4); + REQUIRE(f.entities.size() == 5); + + // Now the entities should form a closed loop -> extrude + int ex = doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(ex >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(20.0 * 20.0 * 5.0, 1e-2)); +} + +TEST_CASE("bridge bad indices throw", "[CadDocument][bridge]") +{ + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, + {SketchEntity::Type::Line, Vec2d(20,0), Vec2d(30,0)}, + }; + doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + + // out-of-range entity a + REQUIRE_THROWS(doc.add_bridge(0, 99, 1, 1, 0, "Bad")); + // out-of-range entity b + REQUIRE_THROWS(doc.add_bridge(0, 0, 1, 99, 0, "Bad")); + // out-of-range sketch_ref + REQUIRE_THROWS(doc.add_bridge(99, 0, 1, 1, 0, "Bad")); + // non-sketch feature as sketch_ref + doc.add_extrude(0, 5.0, false, BooleanMode::New, "Ex"); + REQUIRE_THROWS(doc.add_bridge(1, 0, 1, 1, 0, "Bad")); +} + +TEST_CASE("bridge round-trip serialization", "[CadDocument][bridge]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // The two collinear stubs plus the bridge span (0,0)->(30,0); the remaining three lines + // return to the origin so the profile is genuinely CLOSED. It used to be just the two + // stubs and the bridge, an entirely open chain that "extruded" only because OCCT will + // make a face out of an open wire — the silently-wrong geometry this area exists to stop. + // This test is about the bridge's serialization round-trip, and deserialize_recipe + // recomputes, so the document has to be one that legitimately builds. + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, + {SketchEntity::Type::Line, Vec2d(20,0), Vec2d(30,0)}, + {SketchEntity::Type::Line, Vec2d(30,0), Vec2d(30,10)}, + {SketchEntity::Type::Line, Vec2d(30,10), Vec2d(0,10)}, + {SketchEntity::Type::Line, Vec2d(0,10), Vec2d(0,0)}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + REQUIRE(sk == 0); + int bi = doc.add_bridge(sk, 0, 1, 1, 0, "Bridge"); + REQUIRE(bi == 5); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.features.size() == 2); + + const auto& br = doc2.features[0].entities[5]; + REQUIRE(br.type == SketchEntity::Type::BSpline); + REQUIRE(br.ctrl.size() == 4); + REQUIRE_THAT(br.ctrl.front().x(), WithinAbs(10.0, 1e-9)); + REQUIRE_THAT(br.ctrl.front().y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(br.ctrl.back().x(), WithinAbs(20.0, 1e-9)); + REQUIRE_THAT(br.ctrl.back().y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("delete_face removes a fillet face and restores volume", "[CadDocument][deleteface]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + double Vbox = double(doc.display_mesh.volume()); + REQUIRE(Vbox > 0.0); + + doc.add_fillet(2.0, FaceGroup::All, "Fillet"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + double Vf = double(doc.display_mesh.volume()); + REQUIRE(Vf < Vbox); + + int nfaces = GeometryEngine::face_count(doc.bodies[0].shape); + doc.checkpoint(); + bool found = false; + for (int fi = 0; fi < nfaces && !found; ++fi) { + int idx = doc.add_delete_face(0, {fi}, "Unfillet"); + (void)idx; + if (doc.recompute() && doc.error.empty()) { + double Vr = double(doc.display_mesh.volume()); + if (Vr > Vf) { + found = true; + } + } + if (!found) doc.undo(); + } + REQUIRE(found); +} + +TEST_CASE("delete_face with bad face index fails safely", "[CadDocument][deleteface]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_delete_face(0, {9999}, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "face"); +} + +TEST_CASE("delete_face round-trip serialization", "[CadDocument][deleteface]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.add_fillet(2.0, FaceGroup::All, "Fillet"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int nfaces = GeometryEngine::face_count(doc.bodies[0].shape); + int fillet_face = -1; + for (int fi = 0; fi < nfaces; ++fi) { + doc.checkpoint(); + doc.add_delete_face(0, {fi}, "Unfillet"); + bool ok = doc.recompute(); + if (ok && doc.error.empty()) { + fillet_face = fi; + break; + } + doc.undo(); + } + REQUIRE(fillet_face >= 0); + + std::vector> bboxes; + for (const auto& b : doc.bodies) { + Bnd_Box bb; BRepBndLib::Add(b.shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}); + } + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.recompute()); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + + for (size_t i = 0; i < bboxes.size(); ++i) { + Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6)); + REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6)); + REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6)); + REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6)); + REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6)); + REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6)); + } +} + +TEST_CASE("hole: counterbore removes more material than a simple bore", "[CadDocument][hole]") +{ + using Catch::Matchers::WithinAbs; + auto make_box_hole = [](int style, double cbore_d, double cbore_depth, + double csink_d, double csink_angle, const std::string& desig) { + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box"); + doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + double v_box = doc.body_mass_properties(0).volume; + if (style == 0) + doc.add_hole(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), "Hole"); + else + doc.add_hole_styled(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), style, + cbore_d, cbore_depth, csink_d, csink_angle, desig, "Hole"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + double v = doc.body_mass_properties(0).volume; + REQUIRE(v < v_box); + return v; + }; + + double v_simple = make_box_hole(0, 0, 0, 0, 0, ""); + double v_cbore = make_box_hole(1, 11.0, 6.0, 0, 0, "M6"); + REQUIRE(v_cbore < v_simple); +} + +TEST_CASE("hole: countersink removes more material than a simple bore", "[CadDocument][hole]") +{ + using Catch::Matchers::WithinAbs; + auto make_box = []() { + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box"); + doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + return doc; + }; + + CadDocument doc_simple = make_box(); + doc_simple.add_hole(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc_simple.recompute()); + double v_simple = doc_simple.body_mass_properties(0).volume; + double v_box = 30.0 * 30.0 * 15.0; + + CadDocument doc_csink = make_box(); + doc_csink.add_hole_styled(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), 2, + 0.0, 0.0, 12.0, 90.0, "M6", "Hole"); + REQUIRE(doc_csink.recompute()); + double v_csink = doc_csink.body_mass_properties(0).volume; + + REQUIRE(v_simple < v_box); + REQUIRE(v_csink < v_simple); +} + +TEST_CASE("hole: standards table lookup", "[CadDocument][hole]") +{ + using Catch::Matchers::WithinAbs; + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box"); + doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + int fi = doc.add_hole_standard("M6", 0, true, 10, 0, 0, SketchPlane::XY(), "H"); + REQUIRE(fi >= 0); + REQUIRE_THAT(doc.features[fi].hole_diameter, WithinAbs(6.6, 1e-6)); + REQUIRE(doc.features[fi].hole_standard == "M6"); + + REQUIRE_THROWS(doc.add_hole_standard("M999", 0, true, 10, 0, 0, SketchPlane::XY(), "H")); + try { + doc.add_hole_standard("M999", 0, true, 10, 0, 0, SketchPlane::XY(), "H"); + } catch (const std::exception& ex) { + CHECK_CONTAINS(std::string(ex.what()), "standard"); + } +} + +TEST_CASE("hole: round-trip preserves styled counterbore hole", "[CadDocument][hole]") +{ + using Catch::Matchers::WithinAbs; + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box"); + doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_hole_styled(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), 1, + 11.0, 6.0, 0.0, 90.0, "M6", "Cbore"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + std::vector> bboxes; + for (const auto& b : doc.bodies) { + Bnd_Box bb; BRepBndLib::Add(b.shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes.emplace_back(Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)); + } + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + + for (size_t i = 0; i < bboxes.size(); ++i) { + Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6)); + REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6)); + REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6)); + REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6)); + REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6)); + REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6)); + } + + bool found = false; + for (const auto& f : doc2.features) { + if (f.type == CadFeatureType::Hole && f.name == "Cbore") { + REQUIRE(f.hole_style == 1); + REQUIRE_THAT(f.hole_cbore_diameter, WithinAbs(11.0, 1e-9)); + found = true; + } + } + REQUIRE(found); +} + +TEST_CASE("variables drive a box (parametric sketch + extrude)", "[CadDocument][variables]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.features.size() >= 2); + int ex = sk + 1; + + doc.variables = {{"w", "10"}, {"h", "w*2"}}; + doc.features[sk].expr = {{"width", "w"}, {"height", "w"}}; + doc.features[ex].expr = {{"distance", "h"}}; + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_FALSE(doc.body.IsNull()); + + Bnd_Box bb; BRepBndLib::Add(doc.body, bb); + double xlo, ylo, zlo, xhi, yhi, zhi; + bb.Get(xlo, ylo, zlo, xhi, yhi, zhi); + REQUIRE_THAT(xhi - xlo, WithinAbs(10.0, 1.0)); + REQUIRE_THAT(yhi - ylo, WithinAbs(10.0, 1.0)); + REQUIRE_THAT(zhi - zlo, WithinAbs(20.0, 2.0)); +} + +TEST_CASE("function + pi in expression binding", "[CadDocument][variables]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.features.size() >= 2); + + doc.variables = {{"r", "sqrt(16)+abs(-2)"}}; + doc.features[sk].expr = {{"radius", "r"}}; + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(doc.features[sk].radius, WithinAbs(6.0, 1e-6)); + + doc.variables = {{"r", "max(3, pi)"}}; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(doc.features[sk].radius, WithinAbs(M_PI, 1e-6)); + + doc.variables = {{"r", "5"}, {"d", "r*2"}}; + doc.features[sk].expr = {{"radius", "d"}}; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_THAT(doc.features[sk].radius, WithinAbs(10.0, 1e-6)); +} + +TEST_CASE("cycle detected fails recompute with error", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"width", "a"}}; + + doc.variables = {{"a", "b"}, {"b", "a"}}; + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "cycle"); +} + +TEST_CASE("unknown parameter fails recompute", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"nope", "1"}}; + + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "unknown parameter"); +} + +TEST_CASE("unknown identifier fails recompute", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"width", "x"}}; + + doc.variables = {{"x", "y+1"}}; + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "unknown identifier"); +} + +// The checkpoint -> mutate -> recompute -> undo-on-failure pattern is what both the GUI and +// McpControl use to keep a bad edit out of the document. It only works if the snapshot covers +// `variables` as well as `features`: undo() used to restore features alone, so a bad variable +// survived the rollback and every later recompute failed — the document was left unusable. +TEST_CASE("undo rolls back a bad variable, not just features", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.features[sk].expr = {{"width", "w"}}; + doc.variables = {{"w", "20"}}; + REQUIRE(doc.recompute()); + + // Caller sets a variable to something unevaluable, exactly as action_set_variable does. + doc.checkpoint(); + doc.variables["w"] = "nosuchvar + 1"; + REQUIRE_FALSE(doc.recompute()); + REQUIRE(doc.undo()); + + // The good value must be back... + REQUIRE(doc.variables.at("w") == "20"); + // ...and, the part that actually bit, the document must still be usable. + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); +} + +// A variable ADDED under a checkpoint must disappear entirely on undo, not linger with a +// stale value: the pre-mutation snapshot simply did not contain the key. +TEST_CASE("undo removes a variable that did not exist before the checkpoint", "[CadDocument][variables]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.variables.empty()); + + doc.checkpoint(); + doc.variables["bogus"] = "1/0 +"; // syntactically broken + REQUIRE_FALSE(doc.recompute()); + REQUIRE(doc.undo()); + + REQUIRE(doc.variables.count("bogus") == 0); + REQUIRE(doc.recompute()); +} + +TEST_CASE("parametric recipe round-trips through serialize/deserialize", "[CadDocument][variables]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.features.size() >= 2); + int ex = sk + 1; + + doc.variables = {{"w", "10"}, {"h", "w*2"}}; + doc.features[sk].expr = {{"width", "w"}, {"height", "w"}}; + doc.features[ex].expr = {{"distance", "h"}}; + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + Bnd_Box bb_orig; BRepBndLib::Add(doc.body, bb_orig); + double ox0, oy0, oz0, ox1, oy1, oz1; + bb_orig.Get(ox0, oy0, oz0, ox1, oy1, oz1); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + + REQUIRE(doc2.variables.size() == 2); + REQUIRE(doc2.variables["w"] == "10"); + REQUIRE(doc2.variables["h"] == "w*2"); + + bool found_sk = false, found_ex = false; + for (const auto& f : doc2.features) { + if (f.name == "Sketch") { + REQUIRE(f.expr.size() == 2); + REQUIRE(f.expr.at("width") == "w"); + REQUIRE(f.expr.at("height") == "w"); + found_sk = true; + } + if (f.name == "Extrude") { + REQUIRE(f.expr.size() == 1); + REQUIRE(f.expr.at("distance") == "h"); + found_ex = true; + } + } + REQUIRE(found_sk); + REQUIRE(found_ex); + + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + + Bnd_Box bb2; BRepBndLib::Add(doc2.body, bb2); + double nx0, ny0, nz0, nx1, ny1, nz1; + bb2.Get(nx0, ny0, nz0, nx1, ny1, nz1); + REQUIRE_THAT(nx0, WithinAbs(ox0, 1e-6)); + REQUIRE_THAT(ny0, WithinAbs(oy0, 1e-6)); + REQUIRE_THAT(nz0, WithinAbs(oz0, 1e-6)); + REQUIRE_THAT(nx1, WithinAbs(ox1, 1e-6)); + REQUIRE_THAT(ny1, WithinAbs(oy1, 1e-6)); + REQUIRE_THAT(nz1, WithinAbs(oz1, 1e-6)); +} + +TEST_CASE("surface-extrude makes an open shell", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + REQUIRE(sk >= 0); + int fi = doc.add_surface_extrude(sk, 12.0, "Skin"); + REQUIRE(fi == 1); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + // A rectangle skin has 4 side faces (no end caps). + int face_count = 0, solid_count = 0; + for (TopExp_Explorer fe(doc.bodies.back().shape, TopAbs_FACE); fe.More(); fe.Next()) ++face_count; + for (TopExp_Explorer se(doc.bodies.back().shape, TopAbs_SOLID); se.More(); se.Next()) ++solid_count; + REQUIRE(face_count >= 1); + REQUIRE(solid_count == 0); + REQUIRE(doc.display_mesh.facets_count() > 0); +} + +TEST_CASE("surface-revolve makes an open shell", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + // A small rectangle offset from the axis: u=10..15, v=0..5. + SketchProfile sp; + sp.points = {{10,0},{15,0},{15,5},{10,5}}; + sp.closed = true; + const int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "Profile"); + REQUIRE(sk >= 0); + int fi = doc.add_surface_revolve(sk, 360, 0, "Rev"); + REQUIRE(fi == 1); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + int solid_count = 0; + for (TopExp_Explorer se(doc.bodies.back().shape, TopAbs_SOLID); se.More(); se.Next()) ++solid_count; + REQUIRE(solid_count == 0); + REQUIRE(doc.display_mesh.facets_count() > 0); +} + +TEST_CASE("surface-extrude bad ref safe", "[CadDocument][surface]") +{ + CadDocument doc; + int fi = doc.add_surface_extrude(999, 10, "Bad"); + REQUIRE(fi == 0); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "surface-extrude"); +} + +TEST_CASE("surface round-trip serialize/deserialize", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + doc.add_surface_extrude(sk, 12.0, "Skin"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + size_t orig_nb = doc.bodies.size(); + Bnd_Box orig_bb; + BRepBndLib::Add(doc.bodies.back().shape, orig_bb); + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.bodies.size() == orig_nb); + REQUIRE(CadDocument::is_sheet_shape(fresh.bodies.back().shape)); + + Bnd_Box fresh_bb; + BRepBndLib::Add(fresh.bodies.back().shape, fresh_bb); + Standard_Real ox0, oy0, oz0, ox1, oy1, oz1; + orig_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1); + Standard_Real fx0, fy0, fz0, fx1, fy1, fz1; + fresh_bb.Get(fx0, fy0, fz0, fx1, fy1, fz1); + REQUIRE_THAT(double(fx0), WithinAbs(double(ox0), 1e-6)); + REQUIRE_THAT(double(fy0), WithinAbs(double(oy0), 1e-6)); + REQUIRE_THAT(double(fz0), WithinAbs(double(oz0), 1e-6)); + REQUIRE_THAT(double(fx1), WithinAbs(double(ox1), 1e-6)); + REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6)); + REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6)); +} + +TEST_CASE("thicken-surface makes a solid from a sheet", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + REQUIRE(sk >= 0); + int si = doc.add_surface_extrude(sk, 12.0, "Skin"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + Bnd_Box sheet_bb; + BRepBndLib::Add(doc.bodies.back().shape, sheet_bb); + + int ti = doc.add_thicken_surface(0, 2.0, false, "Wall"); + REQUIRE(ti == 2); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + REQUIRE(!CadDocument::is_sheet_shape(doc.bodies.back().shape)); + double vol = doc.body_mass_properties(1).volume; + REQUIRE(vol > 0); + + Bnd_Box thick_bb; + BRepBndLib::Add(doc.bodies.back().shape, thick_bb); + Standard_Real sx0, sy0, sz0, sx1, sy1, sz1; + Standard_Real tx0, ty0, tz0, tx1, ty1, tz1; + sheet_bb.Get(sx0, sy0, sz0, sx1, sy1, sz1); + thick_bb.Get(tx0, ty0, tz0, tx1, ty1, tz1); + REQUIRE_THAT(double(tx0), WithinAbs(double(sx0), 2.1)); + REQUIRE_THAT(double(tx1), WithinAbs(double(sx1), 2.1)); +} + +// Regression for lu27, with the rig's own numbers. A 60x60x40 four-walled open box +// used to report volume 96000 and an inertia diagonal of [-4.2e7, -4.2e7, -6.9e7] — negative +// principal moments, which no real body can have. VolumeProperties was being integrated over +// an open shell as though it were closed, and std::abs() on the mass hid the only obvious tell. +// The area was always right, so that stays reported and valid; the volume and inertia must not. +TEST_CASE("mass properties of a sheet body report area only, never a volume", + "[CadDocument][surface]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 60, 60, 0, "Rect"); + REQUIRE(sk >= 0); + doc.add_surface_extrude(sk, 40.0, "Skin"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + + auto sheet = doc.body_mass_properties(0); + REQUIRE_FALSE(sheet.is_solid); + REQUIRE(sheet.valid); // the area IS trustworthy + REQUIRE_THAT(sheet.surface_area, WithinRel(9600.0, 1e-6)); // 4 walls x 60 x 40 + REQUIRE(sheet.volume == 0.0); + for (double v : sheet.inertia) + REQUIRE(v == 0.0); + + // The solid thickened from that same sheet is the contrast case: it does have a volume, + // and its principal moments are positive, as any real body's must be. + REQUIRE(doc.add_thicken_surface(0, 2.0, false, "Wall") >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + auto solid = doc.body_mass_properties(1); + REQUIRE(solid.is_solid); + REQUIRE(solid.valid); + REQUIRE(solid.volume > 0.0); + REQUIRE(solid.inertia[0] > 0.0); + REQUIRE(solid.inertia[4] > 0.0); + REQUIRE(solid.inertia[8] > 0.0); +} + +// wm4s. The wall of a thickened open box must contain the corner material. Thickening +// each face along its own normal and sewing (MakeThickSolidBySimple) leaves the four vertical +// corners empty and measured 29648.15 where the geometry requires 44000; the two controls below +// were exact before and must stay exact, since they are what a corner-only fix must not disturb. +TEST_CASE("thicken surface fills the corners of a closed-loop wall", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinRel; + + SECTION("open box: (60^2 - 50^2) * 40") { + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 60, 60, 0, "Rect"); + REQUIRE(sk >= 0); + doc.add_surface_extrude(sk, 40.0, "Skin"); // 4 walls, no caps + REQUIRE(doc.recompute()); + REQUIRE(doc.body_mass_properties(0).surface_area == Approx(9600.0)); // the sheet is what we think + + REQUIRE(doc.add_thicken_surface(0, 5.0, false, "Wall") >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto w = doc.body_mass_properties(1); + REQUIRE(w.is_solid); // NOT a shell: the old ByJoin attempts + REQUIRE_THAT(w.volume, WithinRel(44000.0, 1e-6)); // returned volume 0.0 / is_solid false + // The wall must sit ON the sheet, not around it: an inverted capped solid offsets the + // wrong way and lands at 70x70 with a volume larger than its own bounding box. + const auto bb = doc.display_body_meshes[1].bounding_box(); + REQUIRE_THAT(bb.max.x() - bb.min.x(), WithinRel(60.0, 1e-3)); + REQUIRE_THAT(bb.max.z() - bb.min.z(), WithinRel(40.0, 1e-3)); + } + + SECTION("control, flat sheet stays exact: 3600 * 5") { + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 60, 60, 0, "Rect"); + REQUIRE(doc.add_surface_fill(sk, "Face") >= 0); // one flat face, no rim to mitre + REQUIRE(doc.recompute()); + REQUIRE(doc.add_thicken_surface(0, 5.0, false, "Plate") >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto p = doc.body_mass_properties(1); + REQUIRE(p.is_solid); + REQUIRE_THAT(p.volume, WithinRel(18000.0, 1e-6)); + } +} + +TEST_CASE("surface-offset creates another sheet shifted outward", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + REQUIRE(sk >= 0); + int si = doc.add_surface_extrude(sk, 12.0, "Skin"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + Bnd_Box src_bb; + BRepBndLib::Add(doc.bodies.back().shape, src_bb); + + int oi = doc.add_surface_offset(0, 1.0, "Off"); + REQUIRE(oi == 2); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + Bnd_Box off_bb; + BRepBndLib::Add(doc.bodies.back().shape, off_bb); + Standard_Real sx0, sy0, sz0, sx1, sy1, sz1; + Standard_Real ox0, oy0, oz0, ox1, oy1, oz1; + src_bb.Get(sx0, sy0, sz0, sx1, sy1, sz1); + off_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1); + REQUIRE(std::abs(double(ox0) - double(sx0)) > 1e-3); + REQUIRE(std::abs(double(ox1) - double(sx1)) > 1e-3); + REQUIRE(std::abs(double(oy0) - double(sy0)) > 1e-3); + REQUIRE(std::abs(double(oy1) - double(sy1)) > 1e-3); +} + +TEST_CASE("thicken-surface on non-sheet fails", "[CadDocument][surface]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Solid"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_FALSE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + doc.add_thicken_surface(0, 2.0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "sheet"); +} + +TEST_CASE("thicken-surface round-trip serialize/deserialize", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + doc.add_surface_extrude(sk, 12.0, "Skin"); + REQUIRE(doc.recompute()); + doc.add_thicken_surface(0, 2.0, false, "Wall"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_FALSE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + size_t orig_nb = doc.bodies.size(); + Bnd_Box orig_bb; + BRepBndLib::Add(doc.bodies.back().shape, orig_bb); + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.bodies.size() == orig_nb); + REQUIRE_FALSE(CadDocument::is_sheet_shape(fresh.bodies.back().shape)); + + Bnd_Box fresh_bb; + BRepBndLib::Add(fresh.bodies.back().shape, fresh_bb); + Standard_Real ox0, oy0, oz0, ox1, oy1, oz1; + Standard_Real fx0, fy0, fz0, fx1, fy1, fz1; + orig_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1); + fresh_bb.Get(fx0, fy0, fz0, fx1, fy1, fz1); + REQUIRE_THAT(double(fx0), WithinAbs(double(ox0), 1e-6)); + REQUIRE_THAT(double(fy0), WithinAbs(double(oy0), 1e-6)); + REQUIRE_THAT(double(fz0), WithinAbs(double(oz0), 1e-6)); + REQUIRE_THAT(double(fx1), WithinAbs(double(ox1), 1e-6)); + REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6)); + REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6)); +} + +TEST_CASE("surface-loft makes an open shell", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + SketchProfile bot; + bot.points = {{-10,-10},{10,-10},{10,10},{-10,10}}; + bot.closed = true; + int s0 = doc.add_sketch_profile(bot, SketchPlane::XY(), "Bottom"); + + doc.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1"); + SketchPlane top = doc.resolve_datum_planes()[0].second; + SketchProfile tp; + tp.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; + tp.closed = true; + int s1 = doc.add_sketch_profile(tp, top, "Top"); + + int fi = doc.add_surface_loft({s0, s1}, false, "Skin"); + REQUIRE(fi >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + // Contrast with a solid loft on the same profiles. + CadDocument doc2; + SketchProfile bot2; + bot2.points = {{-10,-10},{10,-10},{10,10},{-10,10}}; + bot2.closed = true; + int a0 = doc2.add_sketch_profile(bot2, SketchPlane::XY(), "Bottom"); + doc2.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1"); + SketchPlane top2 = doc2.resolve_datum_planes()[0].second; + SketchProfile tp2; + tp2.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; + tp2.closed = true; + int a1 = doc2.add_sketch_profile(tp2, top2, "Top"); + doc2.add_loft({a0, a1}, false, BooleanMode::New, "SolidLoft"); + REQUIRE(doc2.recompute()); + REQUIRE(doc2.error.empty()); + REQUIRE_FALSE(CadDocument::is_sheet_shape(doc2.bodies.back().shape)); +} + +TEST_CASE("surface-fill makes a one-face sheet", "[CadDocument][surface]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect"); + REQUIRE(sk >= 0); + int fi = doc.add_surface_fill(sk, "Patch"); + REQUIRE(fi == 1); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + int face_count = 0; + for (TopExp_Explorer fe(doc.bodies.back().shape, TopAbs_FACE); fe.More(); fe.Next()) ++face_count; + REQUIRE(face_count >= 1); +} + +TEST_CASE("surface-loft / surface-fill bad refs safe", "[CadDocument][surface]") +{ + { + CadDocument doc; + doc.add_surface_loft({999}, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "surface-loft"); + } + { + CadDocument doc; + doc.add_surface_fill(999, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "surface-fill"); + } +} + +TEST_CASE("surface-loft round-trip serialize/deserialize", "[CadDocument][surface]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + SketchProfile bot; + bot.points = {{-10,-10},{10,-10},{10,10},{-10,10}}; + bot.closed = true; + int s0 = doc.add_sketch_profile(bot, SketchPlane::XY(), "Bottom"); + doc.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1"); + SketchPlane top = doc.resolve_datum_planes()[0].second; + SketchProfile tp; + tp.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; + tp.closed = true; + int s1 = doc.add_sketch_profile(tp, top, "Top"); + doc.add_surface_loft({s0, s1}, false, "Skin"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape)); + + size_t orig_nb = doc.bodies.size(); + Bnd_Box orig_bb; + BRepBndLib::Add(doc.bodies.back().shape, orig_bb); + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.bodies.size() == orig_nb); + REQUIRE(CadDocument::is_sheet_shape(fresh.bodies.back().shape)); + + Bnd_Box fresh_bb; + BRepBndLib::Add(fresh.bodies.back().shape, fresh_bb); + Standard_Real ox0, oy0, oz0, ox1, oy1, oz1; + Standard_Real fx0, fy0, fz0, fx1, fy1, fz1; + orig_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1); + fresh_bb.Get(fx0, fy0, fz0, fx1, fy1, fz1); + REQUIRE_THAT(double(fx0), WithinAbs(double(ox0), 1e-6)); + REQUIRE_THAT(double(fy0), WithinAbs(double(oy0), 1e-6)); + REQUIRE_THAT(double(fz0), WithinAbs(double(oz0), 1e-6)); + REQUIRE_THAT(double(fx1), WithinAbs(double(ox1), 1e-6)); + REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6)); + REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6)); +} + +// --- Mate tests (M8a) --- + +TEST_CASE("fastened mate with zero offset/angle", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int mi = doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate"); + REQUIRE(mi >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Z()), WithinAbs(5.0, 1e-4)); +} + +TEST_CASE("fastened mate with offset", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 7.0, 0.0, false, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.Z()), WithinAbs(12.0, 1e-4)); +} + +TEST_CASE("fastened mate with angle", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_x_hint = Vec3d(1, 0, 0); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 90.0, false, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); +} + +TEST_CASE("fastened mate with flip", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + // Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid, + // but the mate executed cleanly and the body moved to the target connector. +} + +TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(20, 0, 0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(1, cs_fixed, cs_moving, 3.0, 0.0, false, "MatePlanar"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + // Connector B was at z=0 (bottom of 10mm cylinder). After planar mate with + // offset=3 and A at z=5, the z-distance from A to B becomes 3 => B.z = 8. + // The cylinder centroid (was at z=5) moves to z=13. + REQUIRE_THAT(double(com.Z()), WithinAbs(13.0, 1e-4)); +} + +TEST_CASE("mate round-trip serialization", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_A"); + doc.features[cs_a].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(20, 0, 5), "CS_B"); + doc.features[cs_b].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(1, cs_a, cs_b, 7.25, 33.0, true, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + size_t nb = doc.bodies.size(); + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.bodies.size() == nb); + + const CadFeature* mf = nullptr; + for (const auto& f : fresh.features) { + if (f.type == CadFeatureType::Mate) { mf = &f; break; } + } + REQUIRE(mf != nullptr); + REQUIRE(mf->mate_kind == 1); + REQUIRE_THAT(mf->mate_offset, WithinAbs(7.25, 1e-9)); + REQUIRE_THAT(mf->mate_angle, WithinAbs(33.0, 1e-9)); + REQUIRE(mf->mate_flip == true); +} + +TEST_CASE("version 2 blob is rejected", "[CadDocument][mate]") +{ + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + std::ostringstream oss; + { + cereal::BinaryOutputArchive ar(oss); + uint32_t fake_v = 2; + ar(fake_v); + ar(doc.features); + ar(doc.variables); + } + std::string blob = oss.str(); + + CadDocument fresh; + REQUIRE_FALSE(fresh.deserialize_recipe(blob)); + REQUIRE_CONTAINS(fresh.error, "older version"); +} + +TEST_CASE("mate error: out of range connectors", "[CadDocument][mate]") +{ + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS"); + doc.features[cs].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, 999, cs, 0, 0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "mate_cs_a out of range"); + doc.features.pop_back(); doc.error.clear(); + + doc.add_mate(0, cs, 999, 0, 0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "mate_cs_b out of range"); + doc.features.pop_back(); doc.error.clear(); + + doc.add_mate(0, sk, cs, 0, 0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "not a valid CoordSys"); + doc.features.pop_back(); doc.error.clear(); +} + +// A mate stores its two connectors as FEATURE INDICES. remove_feature() erases a slot and remaps +// every surviving sketch_ref through the deletion, but it did not remap mate_cs_a / mate_cs_b — +// so deleting anything ahead of a connector slid both references down onto whatever features +// happened to occupy those indices. The validation in recompute() only catches out-of-range and +// non-CoordSys targets; when the landing slots are themselves CoordSys features — the normal +// case, since an assembly carries at least two — nothing reports anything. The mate silently +// resolves against the wrong frames and moves the wrong body. +// +// The third connector below (CS_Spare) is what makes the corruption silent rather than loud: +// without it the one-slot shift would push mate_cs_b onto the Mate feature itself and trip the +// "not a valid CoordSys" guard, hiding the real defect behind an error that looks handled. +TEST_CASE("mate connectors survive deletion of an earlier feature", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // The victim: an unrelated connector sitting AHEAD of the pair the mate will use. + int cs_decoy = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(1, 1, 1), "CS_Decoy"); + doc.features[cs_decoy].coordsys_body = 0; + REQUIRE(doc.recompute()); + + int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + int cs_spare = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(9, 9, 9), "CS_Spare"); + doc.features[cs_spare].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + REQUIRE_THAT(double(props.CentreOfMass().X()), WithinAbs(5.0, 1e-4)); + + REQUIRE(doc.remove_feature(cs_decoy)); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // The mate must still name the same two connectors it was built with. + const CadFeature& mate = doc.features.back(); + REQUIRE(mate.type == CadFeatureType::Mate); + REQUIRE(mate.mate_cs_a >= 0); + REQUIRE(mate.mate_cs_b >= 0); + REQUIRE(doc.features[mate.mate_cs_a].name == "CS_Fixed"); + REQUIRE(doc.features[mate.mate_cs_b].name == "CS_Moving"); + + // ...and therefore still assemble the same way: the cylinder on the fixed connector. + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + REQUIRE_THAT(double(props.CentreOfMass().X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(props.CentreOfMass().Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(props.CentreOfMass().Z()), WithinAbs(5.0, 1e-4)); +} + +// move_feature() has the same blind spot from the other direction: it swaps two slots and +// rewrites sketch_ref for both, but leaves mate_cs_a / mate_cs_b pointing at the old positions. +TEST_CASE("mate connectors survive reordering of an earlier feature", "[CadDocument][mate]") +{ + CadDocument doc; + int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt"); + REQUIRE(doc.recompute()); + + int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Swap the two connectors' slots. The mate's references must follow the features, + // not stay behind on the indices. + REQUIRE(doc.move_feature(cs_fixed, 1)); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const CadFeature& mate = doc.features.back(); + REQUIRE(mate.type == CadFeatureType::Mate); + REQUIRE(mate.mate_cs_a >= 0); + REQUIRE(mate.mate_cs_b >= 0); + REQUIRE(doc.features[mate.mate_cs_a].name == "CS_Fixed"); + REQUIRE(doc.features[mate.mate_cs_b].name == "CS_Moving"); +} + +TEST_CASE("mate error: no associated body", "[CadDocument][mate]") +{ + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 0, 0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "no associated body"); +} + +TEST_CASE("mate error: disabled connector", "[CadDocument][mate]") +{ + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.features[cs_moving].enabled = false; + + doc.add_mate(0, cs_fixed, cs_moving, 0, 0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "not a valid CoordSys"); +} + +TEST_CASE("mate conflicts: a clean assembly reports none", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + + int mi = doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate1"); + REQUIRE(mi >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.mate_conflicts.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Z()), WithinAbs(5.0, 1e-4)); +} + +TEST_CASE("mate conflicts: two mates driving the same body", "[CadDocument][mate]") +{ + CadDocument doc; + int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); + doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + int cs_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_A"); + doc.features[cs_a].coordsys_body = 0; + int cs_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_B"); + doc.features[cs_b].coordsys_body = 1; + int cs_c = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 10), "CS_C"); + doc.features[cs_c].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int mate1 = doc.add_mate(0, cs_c, cs_b, 0, 0, false, "Mate1"); + REQUIRE(mate1 >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int mate2 = doc.add_mate(0, cs_a, cs_b, 0, 0, false, "Mate2"); + REQUIRE(mate2 >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + REQUIRE(doc.mate_conflicts.size() == 1); + REQUIRE(doc.mate_conflicts[0].first == mate2); + REQUIRE(doc.mate_conflicts[0].second.find("Mate1") != std::string::npos); + REQUIRE(doc.mate_conflicts[0].second.find("Mate2") != std::string::npos); +} + +TEST_CASE("mate conflicts: a circular mate chain", "[CadDocument][mate]") +{ + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "ExtA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "ExtB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + int cs_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_A"); + doc.features[cs_a].coordsys_body = 0; + int cs_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_B"); + doc.features[cs_b].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_c = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 10), "CS_C"); + doc.features[cs_c].coordsys_body = 0; + int cs_d = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 10), "CS_D"); + doc.features[cs_d].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int mate1 = doc.add_mate(0, cs_a, cs_b, 0, 0, false, "MateAB"); + REQUIRE(mate1 >= 0); + REQUIRE(doc.recompute()); + + int mate2 = doc.add_mate(0, cs_d, cs_c, 0, 0, false, "MateBA"); + REQUIRE(mate2 >= 0); + REQUIRE(doc.recompute()); + + REQUIRE(doc.error.empty()); + REQUIRE(doc.mate_conflicts.size() >= 1); + bool found_cycle = false; + for (const auto& c : doc.mate_conflicts) { + if (c.second.find("circular") != std::string::npos) { + found_cycle = true; + break; + } + } + REQUIRE(found_cycle); +} + +TEST_CASE("mate conflicts: a broken mate is left to apply_mate", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS"); + doc.features[cs].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, 999, cs, 0, 0, false, "Bad"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "mate_cs_a out of range"); + REQUIRE(doc.mate_conflicts.empty()); +} + +TEST_CASE("ordering: fillet after mate resolves face ids", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_fillet(1.0, FaceGroup::All, "FilletAfterMate"); + doc.features.back().target_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); +} + +// --- M8a fix round: planar antiparallel + blind tests --- + +static int find_face_by_normal(const CadDocument& doc, int body_idx, const Vec3d& dir, double tol = 0.99) +{ + auto faces = GeometryEngine::faces_of(doc.bodies[body_idx].shape); + for (int fi = 0; fi < int(faces.size()); ++fi) { + if (GeometryEngine::face_normal_world(faces[fi]).dot(dir) > tol) return fi; + } + return -1; +} + +// Global edge index of the first edge belonging to `face_idx`. +// +// A CoordSys built from a face alone takes its z from the face normal (which follows the +// body) but its x from coordsys_x_hint, a WORLD constant. Such a frame is only half +// body-following: the body's rotation about the face normal is invisible to it, so no mate +// can correct or preserve a spin the connector cannot see. Pinning coordsys_edge to an edge +// of the body makes the in-plane direction follow the body too, which is what any test +// distinguishing Slider (corrects spin) from Cylindrical (preserves it) requires. +static int find_edge_on_face(const CadDocument& doc, int body_idx, int face_idx) +{ + TopoDS_Face f = GeometryEngine::face_by_index(doc.bodies[body_idx].shape, face_idx); + if (f.IsNull()) return -1; + auto face_edges = GeometryEngine::edges_of_face(f); + auto all_edges = GeometryEngine::edges_of(doc.bodies[body_idx].shape); + for (int ei = 0; ei < int(all_edges.size()); ++ei) { + for (const auto& fe : face_edges) { + if (all_edges[ei].IsSame(fe)) return ei; + } + } + return -1; +} + +TEST_CASE("planar mate: antiparallel normals with asymmetric body", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: box 20x20x5, centered at origin in XY + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body B: box 20x10x5, also centered at origin (asymmetric in Y) + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + // Face on A with normal +X, face on B with normal -X + int faceA = find_face_by_normal(doc, 0, Vec3d(1, 0, 0)); + REQUIRE(faceA >= 0); + int faceB = find_face_by_normal(doc, 1, Vec3d(-1, 0, 0)); + REQUIRE(faceB >= 0); + + // Verify z_A != z_B (they point opposite) + Vec3d nA_pre = GeometryEngine::face_normal_world( + GeometryEngine::face_by_index(doc.bodies[0].shape, faceA)); + Vec3d nB_pre = GeometryEngine::face_normal_world( + GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)); + REQUIRE(nA_pre.dot(nB_pre) < -0.9); // antiparallel + + int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + doc.features[cs_fixed].coordsys_face = faceA; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + doc.features[cs_moving].coordsys_face = faceB; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Planar mate: z_A=+X, z_B=-X antiparallel. offset=0. + // With fix: 180° flip, then translated so z-distance=0. + // Without fix: no rotation, only translation — body X-centroid moves differently. + doc.add_mate(1, cs_fixed, cs_moving, 0.0, 0.0, false, "PlanarAnti"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // With the fix: 180° rotation about faceB centroid, then translation. + // Body B's X-extents are mirrored. After rotation, centroid X = 2*o_B.x - pre_cx = -20, + // then translated by offset ≈ 20mm → centroid returns near 0. + // Without the fix: no rotation, body just translates ~+20mm → centroid X ≈ 20. + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + double post_cx = post_props.CentreOfMass().X(); + REQUIRE_THAT(std::abs(post_cx), WithinAbs(0.0, 1e-4)); +} + +TEST_CASE("planar mate: in-plane pose preserved", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + GProp_GProps pre_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props); + gp_Pnt pre_com = pre_props.CentreOfMass(); + + // Both connectors use PointWorld (z=(0,0,1)). B's connector offset in X/Y from A's. + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(7, 4, 10), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(1, cs_fixed, cs_moving, 0.0, 0.0, false, "PlanarSameZ"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + gp_Pnt post_com = post_props.CentreOfMass(); + + // In-plane (X,Y) components unchanged, only Z moves. + REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4)); + REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4)); + REQUIRE_THAT(double(post_com.Z()), !WithinAbs(double(pre_com.Z()), 1e-4)); +} + +TEST_CASE("mate with FaceAndDirection connectors on non-Z faces", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: box 20x20x10 + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Face on A with normal +Y + int faceA = find_face_by_normal(doc, 0, Vec3d(0, 1, 0)); + REQUIRE(faceA >= 0); + + int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + doc.features[cs_fixed].coordsys_face = faceA; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body B: box placed at a different location + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Face on B with normal +Y + int faceB = find_face_by_normal(doc, 1, Vec3d(0, 1, 0)); + REQUIRE(faceB >= 0); + + int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + doc.features[cs_moving].coordsys_face = faceB; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Fastened mate: B's +Y face lands on A's +Y face, offset=0. + // Both normals are +Y so z_A = z_B = (0,1,0) — genuine rotation from frame composition. + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateY"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // After fastened mate, the two mated faces should be coincident: + // same centroid position along the normal (Y), and same centroid in X and Z + // (within face dimensions since they're different sizes). + Vec3d ca = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[0].shape, faceA)); + Vec3d cb = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)); + REQUIRE_THAT(cb.x(), WithinAbs(ca.x(), 1e-4)); + REQUIRE_THAT(cb.y(), WithinAbs(ca.y(), 1e-4)); + REQUIRE_THAT(cb.z(), WithinAbs(ca.z(), 1e-4)); +} + +TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: box + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body B: tapered extrude (asymmetric, centroid not at geometric centre) + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + int ex_b = doc.add_extrude(sk_b, 8.0, false, BooleanMode::New, "EB"); + doc.features[ex_b].taper_deg = 8.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // PointWorld connectors at distinct positions. + // A's connector on the top face centre, B's connector at a corner. + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 10), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(2, 3, 0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Fastened, NO flip + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + GProp_GProps nf_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props); + double nf_z = nf_props.CentreOfMass().Z(); + + doc.undo(); + + // Fastened, WITH flip + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + GProp_GProps f_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props); + double f_z = f_props.CentreOfMass().Z(); + + // Flip changes the centroid Z for an asymmetric body + REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4)); +} + +// --- M8b: Revolute / Slider / Cylindrical mates --- + +TEST_CASE("revolute mate: position corrected, rotation about axis preserved", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: reference box + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body B: asymmetric box 20x10x5 + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + // Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 7) + doc.add_transform(1, Vec3d(15, 2, 7), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Record rotation: body B has a face that was originally +X, now at 30deg + auto faces_pre = GeometryEngine::faces_of(doc.bodies[1].shape); + REQUIRE(faces_pre.size() == 6); + Vec3d pre_x_face_normal; + bool found = false; + for (const auto& f : faces_pre) { + Vec3d n = GeometryEngine::face_normal_world(f); + // The face that was originally +X now points ~(cos30, sin30, 0) + if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) { + pre_x_face_normal = n; found = true; break; + } + } + REQUIRE(found); + + // Fixed connector on A at (5,5,5), world axes + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + // Moving connector on B: PointWorld at its current (transformed) centroid + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 7), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "Revolute"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Position corrected: body centroid moves to the axis line. + // Connector B (15,2,7) → (5,5,5); body centroid (15,2,9.5) → (5,5,7.5). + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Z()), WithinAbs(7.5, 1e-4)); + + // Rotation about Z preserved: the ~(0.866, 0.5, 0) face normal still exists + auto faces_post = GeometryEngine::faces_of(doc.bodies[1].shape); + REQUIRE(faces_post.size() == 6); + bool rot_preserved = false; + for (const auto& f : faces_post) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) { + rot_preserved = true; break; + } + } + REQUIRE(rot_preserved); + // Fastened would have aligned face normals to world axes: no face with Y≈0.5 would exist. +} + +TEST_CASE("revolute mate: no-op when already correct", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Move body B to exactly the correct pose: on axis at (5,5,5) with no rotation + doc.add_transform(1, Vec3d(5, 5, 5), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps pre_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props); + gp_Pnt pre_com = pre_props.CentreOfMass(); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "RevoluteNoOp"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + gp_Pnt post_com = post_props.CentreOfMass(); + REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4)); + REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4)); + REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4)); +} + +TEST_CASE("revolute mate: mate_angle applies additional rotation", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body B: asymmetric box, pre-rotated 30deg about Z, off-axis + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_transform(1, Vec3d(15, 2, 7), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 7), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // mate_angle=45deg on top of preserved 30deg → face originally +X now at 75deg + doc.add_mate(2, cs_fixed, cs_moving, 0.0, 45.0, false, "RevoluteAngle"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Position on axis line (same as revolute preservation test) + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Z()), WithinAbs(7.5, 1e-4)); + + // Rotation = 30deg (preserved) + 45deg (mate_angle) = 75deg → normal ≈ (cos75, sin75, 0) + auto faces = GeometryEngine::faces_of(doc.bodies[1].shape); + bool found_75 = false; + for (const auto& f : faces) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.x() - 0.2588) < 0.02 && std::abs(n.y() - 0.9659) < 0.02) { + found_75 = true; break; + } + } + REQUIRE(found_75); +} + +TEST_CASE("slider mate: rotation corrected, axial position preserved", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 13) + doc.add_transform(1, Vec3d(15, 2, 13), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps pre_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props); + double pre_z = pre_props.CentreOfMass().Z(); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 13), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "Slider"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Perpendicular position corrected to the axis line (X=5, Y=5) + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + gp_Pnt post_com = post_props.CentreOfMass(); + REQUIRE_THAT(double(post_com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(post_com.Y()), WithinAbs(5.0, 1e-4)); + + // Axial (Z) position preserved from pre-mate pose + REQUIRE_THAT(double(post_com.Z()), WithinAbs(pre_z, 1e-4)); + // Fastened would have placed the body at Z=5. pre_z=15.5 (centroid), clearly different. +} + +TEST_CASE("slider mate: mate_offset shifts axial position", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_transform(1, Vec3d(12, 3, 9), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(12, 3, 9), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Slider with mate_offset=4: body centroid at (12,3,11.5), connector at (12,3,9). + // Perpendicular corrected: X=5,Y=5. Axial: preserved Z 11.5 + offset 4 = 15.5. + doc.add_mate(3, cs_fixed, cs_moving, 4.0, 0.0, false, "SliderOffset"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Z()), WithinAbs(15.5, 1e-3)); +} + +TEST_CASE("slider mate: no-op when already correct", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body already on axis at (5,5,20), no rotation + doc.add_transform(1, Vec3d(5, 5, 20), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps pre_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props); + gp_Pnt pre_com = pre_props.CentreOfMass(); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 20), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "SliderNoOp"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + gp_Pnt post_com = post_props.CentreOfMass(); + REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4)); + REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4)); + REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4)); +} + +TEST_CASE("cylindrical mate: perpendicular corrected, rotation and axial preserved", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 13) + doc.add_transform(1, Vec3d(15, 2, 13), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps pre_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props); + double pre_z = pre_props.CentreOfMass().Z(); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 13), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "Cylindrical"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Perpendicular position corrected to axis line + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + gp_Pnt com = post_props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + + // Axial Z position preserved from pre-mate + REQUIRE_THAT(double(com.Z()), WithinAbs(pre_z, 1e-4)); + + // Rotation about Z preserved: face originally +X is still at 30deg + auto faces = GeometryEngine::faces_of(doc.bodies[1].shape); + REQUIRE(faces.size() == 6); + bool rot_preserved = false; + for (const auto& f : faces) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) { + rot_preserved = true; break; + } + } + REQUIRE(rot_preserved); + // Fastened would have aligned face normals to world axes and fixed Z. +} + +TEST_CASE("cylindrical mate: mate_offset and mate_angle applied on top", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_transform(1, Vec3d(15, 2, 9), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 9), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(4, cs_fixed, cs_moving, 3.0, 45.0, false, "CylOffsetAngle"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Position: perpendicular corrected, axial = 11.5 (centroid preserved) + 3 (offset) = 14.5 + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + REQUIRE_THAT(double(com.Z()), WithinAbs(14.5, 1e-3)); + + // Rotation = 30deg (preserved) + 45deg (angle) = 75deg + auto faces = GeometryEngine::faces_of(doc.bodies[1].shape); + bool found_75 = false; + for (const auto& f : faces) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.x() - 0.2588) < 0.02 && std::abs(n.y() - 0.9659) < 0.02) { + found_75 = true; break; + } + } + REQUIRE(found_75); +} + +TEST_CASE("cylindrical mate: no-op when already correct", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body already on axis at (5,5,20), no rotation + doc.add_transform(1, Vec3d(5, 5, 20), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps pre_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props); + gp_Pnt pre_com = pre_props.CentreOfMass(); + + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 20), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "CylNoOp"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + GProp_GProps post_props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props); + gp_Pnt post_com = post_props.CentreOfMass(); + REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4)); + REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4)); + REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4)); +} + +TEST_CASE("revolute mate with FaceAndDirection on non-Z faces", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: box 20x20x10 + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Face on A with normal +Y + int faceA = find_face_by_normal(doc, 0, Vec3d(0, 1, 0)); + REQUIRE(faceA >= 0); + Vec3d nA = GeometryEngine::face_normal_world( + GeometryEngine::face_by_index(doc.bodies[0].shape, faceA)); + + // Body B: asymmetric box 20x10x5, also with +Y face + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int faceB = find_face_by_normal(doc, 1, Vec3d(0, 1, 0)); + REQUIRE(faceB >= 0); + Vec3d nB_pre = GeometryEngine::face_normal_world( + GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)); + REQUIRE_THAT(nB_pre.dot(nA), WithinAbs(1.0, 1e-4)); + + // Pre-rotate B about Y (the face normal) by 30deg to give it a non-trivial rotation about its connector z + doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 1, 0), Vec3d(0, 0, 0), 30.0, false, "PrePose"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // FaceAndDirection on A's +Y face + int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + doc.features[cs_fixed].coordsys_face = faceA; + // FaceAndDirection on B's +Y face + int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + doc.features[cs_moving].coordsys_face = faceB; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Record face normals on B before mate (to verify rotation preservation) + // +30deg rotation about Y maps +Z=(0,0,1) → (0.5, 0, 0.866), -Z → (-0.5, 0, -0.866) + auto faces_pre = GeometryEngine::faces_of(doc.bodies[1].shape); + Vec3d pre_rotated_normal; + { + bool fnd = false; + for (const auto& f : faces_pre) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.z()) > 0.85 && std::abs(n.x()) > 0.45 && std::abs(n.y()) < 0.02) { + pre_rotated_normal = n; fnd = true; break; + } + } + REQUIRE(fnd); + } + + doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "RevoluteFaceDir"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Position: faces are coincident (same as fastened position since no offset and axes aligned) + Vec3d ca = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[0].shape, faceA)); + Vec3d cb = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)); + REQUIRE_THAT(cb.x(), WithinAbs(ca.x(), 1e-4)); + REQUIRE_THAT(cb.y(), WithinAbs(ca.y(), 1e-4)); + REQUIRE_THAT(cb.z(), WithinAbs(ca.z(), 1e-4)); + + // Rotation about the connector z (which is +Y) is preserved: + // the face with |z| ≈ 0.866, |x| ≈ 0.5, y ≈ 0 must still exist. + auto faces_post = GeometryEngine::faces_of(doc.bodies[1].shape); + bool rot_preserved = false; + for (const auto& f : faces_post) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.z()) > 0.85 && std::abs(n.x()) > 0.45 && std::abs(n.y()) < 0.02) { + rot_preserved = true; break; + } + } + REQUIRE(rot_preserved); + // Fastened would force all axes to align: no face with Z≈0.87 would exist. +} + +// --- M8b: rotation-coverage hole — FaceAndDirection captures body orientation --- + +TEST_CASE("slider mate with FaceAndDirection corrects rotation", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: box 20x20x10 + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int faceA = find_face_by_normal(doc, 0, Vec3d(0, 0, 1)); + REQUIRE(faceA >= 0); + + // Body B: asymmetric box 20x10x5 + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int faceB = find_face_by_normal(doc, 1, Vec3d(0, 0, 1)); + REQUIRE(faceB >= 0); + + // Pre-rotate B: first 20deg about X (tilts +Z face normal off-axis, so the + // connector z genuinely differs from A's), then 30deg about Z (adds rotation + // about the mate axis that Slider must correct and Cylindrical must preserve). + doc.add_transform(1, Vec3d(14, 3, 7), Vec3d(1, 0, 0), Vec3d(0, 0, 0), 20.0, false, "RotX"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "RotZ"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Both connectors pin their in-plane direction to an edge of their own body, so each + // frame follows its body's spin. Without this the frames' x comes from the world hint + // and the 30deg spin is invisible to the mate — see find_edge_on_face. + int edgeA = find_edge_on_face(doc, 0, faceA); + int edgeB = find_edge_on_face(doc, 1, faceB); + REQUIRE(edgeA >= 0); + REQUIRE(edgeB >= 0); + + int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + doc.features[cs_fixed].coordsys_face = faceA; + doc.features[cs_fixed].coordsys_edge = edgeA; + + int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + doc.features[cs_moving].coordsys_face = faceB; + doc.features[cs_moving].coordsys_edge = edgeB; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Record the pre-mate axial position of the CONNECTOR, not of the centroid. Slider + // rotates the body about the connector origin to correct the 20deg tilt, and that + // rotation legitimately moves the centroid in Z (by -d*(1-cos20) for a centroid d + // below the mated face) while leaving the connector itself where it is. The DOF + // Slider preserves is the connector's position along the axis. + const double pre_conn_z = GeometryEngine::face_centroid_world( + GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z(); + + doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "SliderFaceDir"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Position: perpendicular corrected to axis line through A's +Z face centroid. + // The +Z face of a 20x20x10 box centred at origin has centroid at (0, 0, 10). + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(0.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(0.0, 1e-4)); + // Axial position of the connector preserved — Slider does not own that DOF. + const double post_conn_z = GeometryEngine::face_centroid_world( + GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z(); + REQUIRE_THAT(post_conn_z, WithinAbs(pre_conn_z, 1e-4)); + + // Rotation: Slider owns this DOF — all face normals must be axis-aligned. + auto faces = GeometryEngine::faces_of(doc.bodies[1].shape); + REQUIRE(faces.size() == 6); + int axis_aligned = 0; + for (const auto& f : faces) { + Vec3d n = GeometryEngine::face_normal_world(f); + double d = std::max({std::abs(n.x()), std::abs(n.y()), std::abs(n.z())}); + if (d > 0.999) ++axis_aligned; + } + REQUIRE(axis_aligned == 6); + // Dropping rotation correction would leave 30deg Z rotation — at least 2 + // faces would have normals not aligned to any world axis. +} + +TEST_CASE("cylindrical mate with FaceAndDirection preserves rotation", "[CadDocument][mate]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // Body A: box 20x20x10 + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int faceA = find_face_by_normal(doc, 0, Vec3d(0, 0, 1)); + REQUIRE(faceA >= 0); + + // Body B: asymmetric box 20x10x5 + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB"); + doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int faceB = find_face_by_normal(doc, 1, Vec3d(0, 0, 1)); + REQUIRE(faceB >= 0); + + // Same two-step pre-rotation as the slider test. + doc.add_transform(1, Vec3d(14, 3, 7), Vec3d(1, 0, 0), Vec3d(0, 0, 0), 20.0, false, "RotX"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "RotZ"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Edge-pinned frames, as in the Slider case. These matter here for the opposite + // reason: with a world-derived in-plane direction, a Cylindrical that WRONGLY aligned + // the spin would still leave the body's 30deg face at 30deg, and the assertion below + // would pass for the wrong implementation. + int edgeA = find_edge_on_face(doc, 0, faceA); + int edgeB = find_edge_on_face(doc, 1, faceB); + REQUIRE(edgeA >= 0); + REQUIRE(edgeB >= 0); + + int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + doc.features[cs_fixed].coordsys_face = faceA; + doc.features[cs_fixed].coordsys_edge = edgeA; + + int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + doc.features[cs_moving].coordsys_face = faceB; + doc.features[cs_moving].coordsys_edge = edgeB; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // As in the Slider case, the preserved DOF is the CONNECTOR's position along the + // axis, not the centroid's: aligning the 20deg tilt rotates the body about the + // connector origin, which moves the centroid in Z by design. + const double pre_conn_z_c = GeometryEngine::face_centroid_world( + GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z(); + + doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "CylFaceDir"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Position: perpendicular corrected to axis line (X=0, Y=0 at +Z face centroid) + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[1].shape, props); + gp_Pnt com = props.CentreOfMass(); + REQUIRE_THAT(double(com.X()), WithinAbs(0.0, 1e-4)); + REQUIRE_THAT(double(com.Y()), WithinAbs(0.0, 1e-4)); + // Axial position of the connector preserved — Cylindrical leaves that DOF free. + const double post_conn_z_c = GeometryEngine::face_centroid_world( + GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z(); + REQUIRE_THAT(post_conn_z_c, WithinAbs(pre_conn_z_c, 1e-4)); + + // Rotation about Z (30deg) is preserved: the 20deg X-rotation was undone + // by z-alignment, leaving the 30deg Z-rotation intact. + // The +X face normal should be at ~(cos30, sin30, 0). + auto faces = GeometryEngine::faces_of(doc.bodies[1].shape); + REQUIRE(faces.size() == 6); + bool rot_preserved = false; + for (const auto& f : faces) { + Vec3d n = GeometryEngine::face_normal_world(f); + if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) { + rot_preserved = true; break; + } + } + REQUIRE(rot_preserved); + // If cylindrical behaved like slider, the face would be at (1,0,0) instead. +} + +// --- Interference detection (M8c) --- + +TEST_CASE("interference: overlapping bodies reported with overlap volume", "[CadDocument][interference]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + // A: 20x20x10 box centred on the origin in XY, z in [0,10] + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + // B: 20x20x10 box, same footprint + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxB"); + doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + // Lift B by 6mm: the two overlap over 4mm of height => 20*20*4 = 1600 mm^3. + doc.add_transform(1, Vec3d(0, 0, 6), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "LiftB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto hits = doc.check_interference(); + REQUIRE(hits.size() == 1); + REQUIRE(hits[0].body_a == 0); + REQUIRE(hits[0].body_b == 1); + REQUIRE_THAT(hits[0].volume, WithinAbs(1600.0, 1e-3)); +} + +TEST_CASE("interference: disjoint and merely touching bodies are not reported", "[CadDocument][interference]") +{ + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxB"); + doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + + SECTION("clearly apart") { + doc.add_transform(1, Vec3d(0, 0, 50), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "MoveB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.check_interference().empty()); + } + + SECTION("face-to-face contact encloses no volume") { + // B sits exactly on top of A: they share a face but nothing overlaps. + doc.add_transform(1, Vec3d(0, 0, 10), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "StackB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.check_interference().empty()); + } +} + +TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]") +{ + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + // A sheet passing straight through the solid: it has no volume, so no interference. + int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet"); + doc.add_surface_extrude(sk_s, 5.0, "SE"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape)); + + REQUIRE(doc.check_interference().empty()); +} + +TEST_CASE("interference: reports every overlapping pair", "[CadDocument][interference]") +{ + CadDocument doc; + for (int i = 0; i < 3; ++i) { + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + } + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 3); + + // 0 and 1 stay coincident (overlapping); 2 is moved clear of both. + doc.add_transform(2, Vec3d(0, 0, 100), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "MoveC"); + REQUIRE(doc.recompute()); + + auto hits = doc.check_interference(); + REQUIRE(hits.size() == 1); + REQUIRE(hits[0].body_a == 0); + REQUIRE(hits[0].body_b == 1); + + // min_volume gates the report: a threshold above the overlap silences it. + REQUIRE(doc.check_interference(1e9).empty()); +} + +TEST_CASE("interference: detects a clash created by a mate", "[CadDocument][interference]") +{ + CadDocument doc; + int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); + doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); + int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxB"); + doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB"); + REQUIRE(doc.recompute()); + + // Park B well clear, so the clash below is created by the mate and nothing else. + doc.add_transform(1, Vec3d(0, 0, 80), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "ParkB"); + REQUIRE(doc.recompute()); + REQUIRE(doc.check_interference().empty()); + + // Fastened mate onto a connector inside A's volume drives B into A. + int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Fixed"); + doc.features[cs_fixed].coordsys_body = 0; + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 85), "CS_Moving"); + doc.features[cs_moving].coordsys_body = 1; + REQUIRE(doc.recompute()); + + doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Clash"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + auto hits = doc.check_interference(); + REQUIRE(hits.size() == 1); + REQUIRE(hits[0].volume > 1.0); +} + +// A filleted solid must reach the plate as a watertight mesh. OCCT emits one degenerate +// triangle at the pole of every corner sphere patch; welded, its v->v edge counts as an open +// edge and the slicer tells the user to go repair the model in another CAD application -- +// the exact round trip this feature exists to remove. agw. +TEST_CASE("CadDocument filleted solid tessellates watertight", "[CadDocument]") +{ + CadDocument doc; + SketchProfile sp; + sp.points.push_back(Vec2d( 0, 0)); + sp.points.push_back(Vec2d( 80, 0)); + sp.points.push_back(Vec2d( 80, 50)); + sp.points.push_back(Vec2d( 0, 50)); + sp.closed = true; + const int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "P"); + doc.add_extrude(sk, 12.0, false, BooleanMode::New, "E"); + doc.add_fillet(3.0, FaceGroup::All, "F"); + REQUIRE(doc.recompute()); + + CHECK(doc.display_mesh.stats().open_edges == 0); + CHECK(its_num_open_edges(doc.display_mesh.its) == 0); + // No degenerate triangles survive the weld, and the per-triangle face map stays aligned. + size_t degenerate = 0; + for (const auto& t : doc.display_mesh.its.indices) + if (t[0] == t[1] || t[1] == t[2] || t[0] == t[2]) ++degenerate; + REQUIRE(degenerate == 0); + REQUIRE(doc.display_tri_face.size() == doc.display_mesh.its.indices.size()); + REQUIRE(doc.display_tri_body.size() == doc.display_mesh.its.indices.size()); +} + +// A rejected solve must leave the sketch untouched. libslvs writes its last Newton iterate +// into the params whether or not it converged, so reading geometry back unconditionally made +// every failed attempt destructive -- and the fillet degrade ladder tries a deliberately +// over-constrained rung FIRST, so a filleted corner was wrecked before the rung that works +// ever got a chance. pl5. +TEST_CASE("Failed sketch solve leaves geometry untouched", "[CadDocument]") +{ + using R = SketchPointRole; + using CT = SketchConstraintType; + auto line = [](Vec2d p0, Vec2d p1) { + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = p0; e.p1 = p1; return e; }; + auto coinc = [](int ea, R ra, int eb, R rb) { + SketchEntityConstraintDef d; d.type = CT::Coincident; d.ea = ea; d.ra = ra; d.eb = eb; d.rb = rb; return d; }; + auto axis = [](CT t, int e) { + SketchEntityConstraintDef d; d.type = t; d.ea = e; d.ra = R::P0; d.eb = e; d.rb = R::P1; return d; }; + + // Axis-aligned rectangle, drawn as four lines with the constraints the sketch tool + // infers: a Coincident at each corner and H/V per leg. + std::vector ents = { + line(Vec2d(-89.32, 72.05), Vec2d( 52.00, 72.05)), // 0 top (H) + line(Vec2d( 52.00, 72.05), Vec2d( 52.00, -68.97)), // 1 right (V) + line(Vec2d( 52.00, -68.97), Vec2d(-89.32, -68.97)), // 2 bottom (H) + line(Vec2d(-89.32, -68.97), Vec2d(-89.32, 72.05)), // 3 left (V) + }; + std::vector cs = { + coinc(0, R::P1, 1, R::P0), coinc(0, R::P0, 3, R::P1), + coinc(1, R::P1, 2, R::P0), coinc(2, R::P1, 3, R::P0), + axis(CT::Horizontal, 0), axis(CT::Vertical, 1), + axis(CT::Horizontal, 2), axis(CT::Vertical, 3), + }; + REQUIRE(solve_sketch_entities(ents, cs)); + + // Fillet the top-left corner: trim both legs, drop the stale corner Coincident. + const int a = 0, b = 3; + SketchEntity a_out, b_out, arc; + REQUIRE(SketchEngine::fillet_lines(ents[a], ents[b], 28.205, a_out, b_out, arc)); + ents[a] = a_out; ents[b] = b_out; + const int xi = int(ents.size()); + ents.push_back(arc); + cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { + return d.type == CT::Coincident && d.ea == a && d.ra == R::P0 && d.eb == b && d.rb == R::P1; + }), cs.end()); + const std::vector trimmed = ents; + + auto coin = [&](R xr, int ln, R lr) { return coinc(xi, xr, ln, lr); }; + auto tang = [&](int ln) { + SketchEntityConstraintDef d; d.type = CT::Tangent; d.ea = xi; d.eb = ln; return d; }; + + // Rung 1 of the ladder: a tangent on each leg. Over-constrained against the legs' + // own H/V, so it must be rejected -- and must not move a single point. + { + std::vector pc = cs; + for (const auto& c : { coin(R::P0, a, R::P0), coin(R::P1, b, R::P1), tang(a), tang(b) }) + pc.push_back(c); + std::vector e = ents; + REQUIRE_FALSE(solve_sketch_entities(e, pc)); + for (size_t i = 0; i < e.size(); ++i) { + CHECK((e[i].p0 - trimmed[i].p0).norm() == Approx(0.0).margin(1e-9)); + CHECK((e[i].p1 - trimmed[i].p1).norm() == Approx(0.0).margin(1e-9)); + CHECK((e[i].center - trimmed[i].center).norm() == Approx(0.0).margin(1e-9)); + } + } + + // Rung 2 (one tangent) solves, and the arc keeps the radius the fillet gave it. + { + std::vector pc = cs; + for (const auto& c : { coin(R::P0, a, R::P0), coin(R::P1, b, R::P1), tang(a) }) + pc.push_back(c); + REQUIRE(solve_sketch_entities(ents, pc)); + CHECK(ents[xi].radius == Approx(28.205).margin(1e-6)); + // Corner stays open: the legs end on the arc, they do not meet each other. + CHECK((ents[a].p0 - ents[b].p1).norm() > 1.0); + CHECK((ents[a].p0 - ents[xi].p0).norm() == Approx(0.0).margin(1e-6)); + CHECK((ents[b].p1 - ents[xi].p1).norm() == Approx(0.0).margin(1e-6)); + } +} + +// A subtraction whose tool misses the target is a perfectly legal boolean that removes nothing, +// so OCCT reports success and the feature lands in the recipe with ok:true and an unchanged body. +// That is how a hole placed with world coordinates instead of plane-frame ones read as "drilled" +// three times in a row while the volume never moved. daf. +TEST_CASE("A cut that removes no material is an error, not a silent success", "[CadDocument]") +{ + // 20 x 20 box, 20 tall, centred on the origin of the XY plane. + auto box = [] { + CadDocument d; + int sk = d.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + d.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + return d; + }; + + SECTION("hole placed clear of the body is rejected") { + CadDocument doc = box(); + REQUIRE(doc.recompute()); + const double solid = doc.body_mass_properties(0).volume; + + // 135 mm away — exactly the failure the issue reported (world centre passed for a + // plane-frame coordinate). The old behaviour: recompute() true, volume unchanged. + doc.add_hole(8.0, 20.0, true, 135.0, 0.0, SketchPlane::XY(), "Hole"); + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("removed no material") != std::string::npos); + + // And the body is left as it was, not half-applied. + CadDocument again = box(); + REQUIRE(again.recompute()); + CHECK_THAT(again.body_mass_properties(0).volume, + Catch::Matchers::WithinAbs(solid, 1e-6)); + } + + SECTION("the same hole on the body still works") { + CadDocument doc = box(); + REQUIRE(doc.recompute()); + const double solid = doc.body_mass_properties(0).volume; + doc.add_hole(8.0, 20.0, true, 0.0, 0.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc.recompute()); + CHECK_THAT(doc.body_mass_properties(0).volume, + Catch::Matchers::WithinRel(solid - M_PI * 16.0 * 20.0, 0.01)); + } + + SECTION("a cut-mode extrude that misses is rejected too") { + CadDocument doc = box(); + REQUIRE(doc.recompute()); + // Same trick, on the sketch plane's origin: the profile sits far outside the box, + // so the subtraction is a legal no-op. + SketchPlane far_plane = SketchPlane::XY(); + far_plane.origin = Vec3d(200.0, 0.0, 0.0); + int sk = doc.add_sketch(SketchShape::Rectangle, far_plane, 5, 5, 10, "Tool"); + doc.add_extrude(sk, 30.0, false, BooleanMode::Cut, "Cut"); + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("removed no material") != std::string::npos); + } +} + +// entities_to_wire handles exactly two shapes: one lone closed entity, or a chain of open ones. +// Anything else returns a null wire, and build_sketch_wire used to answer that by falling through +// to its legacy tail — which ends in a rectangle built from width/height. For an entity sketch +// those are whatever they were initialised to, so the extrude produced a box nobody drew. +// 88v. +TEST_CASE("An entity sketch that forms no wire fails instead of extruding a default box", "[CadDocument]") +{ + auto circle = [](Vec2d c, double r) { + SketchEntity e; e.type = SketchEntity::Type::Circle; e.center = c; e.p0 = c; e.radius = r; + return e; + }; + auto line = [](Vec2d a, Vec2d b) { + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = a; e.p1 = b; + return e; + }; + + SECTION("a lone circle still works — the supported case is untouched") { + CadDocument doc; + int sk = doc.add_sketch_entities({ circle({0, 0}, 10.0) }, SketchPlane::XY(), "Sketch"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + CHECK_THAT(doc.body_mass_properties(0).volume, + Catch::Matchers::WithinRel(M_PI * 100.0 * 5.0, 0.01)); + } + + // Behaviour CHANGED 2026-09-02 by Tommaso's decision. This used to require a refusal, + // because back then ignoring the stray meant falling through to a default rectangle — a + // box the user never drew. That fallback is gone: the closed profile is now built and the + // stray open chain is discarded, exactly as the viewport's region_loops already discards + // open chains when it decides what is extrudable. A stray click must not break a model + // that looks perfect on screen. + SECTION("circle + stray line builds the circle and ignores the stray") { + CadDocument doc; + int sk = doc.add_sketch_entities({ circle({0, 0}, 10.0), line({40, 40}, {60, 40}) }, + SketchPlane::XY(), "Sketch"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + CHECK(doc.error.empty()); + CHECK_THAT(doc.body_mass_properties(0).volume, + Catch::Matchers::WithinRel(M_PI * 100.0 * 5.0, 0.01)); + } + + SECTION("two disjoint circles are rejected too") { + CadDocument doc; + int sk = doc.add_sketch_entities({ circle({-20, 0}, 8.0), circle({20, 0}, 8.0) }, + SketchPlane::XY(), "Sketch"); + doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("disjoint") != std::string::npos); + } +} + +namespace { + +std::vector rect_entities(double w, double h) +{ + const double hw = w * 0.5, hh = h * 0.5; + return { + {SketchEntity::Type::Line, Vec2d(-hw, -hh), Vec2d( hw, -hh)}, + {SketchEntity::Type::Line, Vec2d( hw, -hh), Vec2d( hw, hh)}, + {SketchEntity::Type::Line, Vec2d( hw, hh), Vec2d(-hw, hh)}, + {SketchEntity::Type::Line, Vec2d(-hw, hh), Vec2d(-hw, -hh)}, + }; +} + +SketchEntity circle_entity(const Vec2d& c, double r) +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = c; e.p0 = c; e.radius = r; + return e; +} + +CadDocument plate_doc(const std::vector& entities, double distance) +{ + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "sketch"; + sk.plane = SketchPlane::XY(); + sk.entities = entities; + doc.features.push_back(sk); + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "extrude"; + ex.sketch_ref = 0; + ex.distance = distance; + ex.mode = BooleanMode::New; + doc.features.push_back(ex); + return doc; +} + +} // namespace + +// 88v: a sketch may hold more than one closed loop. The Extrude path builds the +// sketch's planar region via SketchEngine::entities_to_wires + wires_to_face: the largest loop +// is the outer boundary, every other loop a hole. Volumes are the proof — a plate with a hole +// must subtract the hole, not merely "not throw". +TEST_CASE("a circle inside a rectangle extrudes to a plate with a hole", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(40, 30); + ents.push_back(circle_entity({0, 0}, 5.0)); + CadDocument doc = plate_doc(ents, 10.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = 40.0 * 30.0 * 10.0 - M_PI * 25.0 * 10.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("two holes are both subtracted", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(40, 30); + ents.push_back(circle_entity({ 5, 0}, 3.0)); + ents.push_back(circle_entity({-5, 0}, 3.0)); + CadDocument doc = plate_doc(ents, 10.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = 40.0 * 30.0 * 10.0 - 2.0 * M_PI * 9.0 * 10.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("a lone circle still extrudes exactly as before", "[CadDocument][sketchwire]") +{ + std::vector ents = { circle_entity({0, 0}, 8.0) }; + CadDocument doc = plate_doc(ents, 5.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = M_PI * 64.0 * 5.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("a closed polygon still extrudes exactly as before", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(20, 20); + CadDocument doc = plate_doc(ents, 10.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = 20.0 * 20.0 * 10.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("two disjoint regions are refused, not guessed", "[CadDocument][sketchwire]") +{ + std::vector ents = { circle_entity({-10, 0}, 5.0), circle_entity({10, 0}, 5.0) }; + CadDocument doc = plate_doc(ents, 10.0); + + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("disjoint") != std::string::npos); + CHECK(doc.bodies.empty()); +} + +TEST_CASE("entities_to_wires returns one wire per loop", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(40, 30); + ents.push_back(circle_entity({0, 0}, 5.0)); + + const std::vector wires = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + REQUIRE(wires.size() == 2); + REQUIRE_FALSE(wires[0].IsNull()); + REQUIRE_FALSE(wires[1].IsNull()); +} + +// Sketching on a picked face is the most common gesture in solid modelling, and it was impossible: +// the plane came from a combo of base + datum planes only, so the sole route onto a face was to +// build a Coincident datum plane first. plane_of_face is the shared derivation that makes the +// viewport selection usable directly. 3a2. +TEST_CASE("plane_of_face gives a sketchable plane for a planar face only", "[CadDocument]") +{ + // 20 x 20 x 20 box on XY, so its top face sits at z = 20 with +Z normal. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + SECTION("every planar face of the box resolves, and its normal is a unit axis") { + int resolved = 0, top = -1; + for (int fi = 0; fi < 64; ++fi) { + SketchPlane p; + if (!doc.plane_of_face(0, fi, p)) continue; + ++resolved; + CHECK_THAT(p.normal.norm(), Catch::Matchers::WithinAbs(1.0, 1e-9)); + // The frame must be orthonormal or sketch coordinates on it would be skewed. + CHECK_THAT(p.x_axis.dot(p.y_axis), Catch::Matchers::WithinAbs(0.0, 1e-9)); + CHECK_THAT(p.x_axis.dot(p.normal), Catch::Matchers::WithinAbs(0.0, 1e-9)); + if (p.normal.z() > 0.99) top = fi; + } + CHECK(resolved == 6); // a box has exactly six planar faces + REQUIRE(top >= 0); // and one of them faces +Z + SketchPlane p; + REQUIRE(doc.plane_of_face(0, top, p)); + CHECK_THAT(p.origin.z(), Catch::Matchers::WithinAbs(20.0, 1e-6)); // the top, not the base + } + + SECTION("bad indices are refused rather than guessed") { + SketchPlane p; + CHECK_FALSE(doc.plane_of_face(0, -1, p)); + CHECK_FALSE(doc.plane_of_face(-1, 0, p)); + CHECK_FALSE(doc.plane_of_face(9, 0, p)); + CHECK_FALSE(doc.plane_of_face(0, 999, p)); + } + + SECTION("a cylindrical face is refused — it has no single sketch plane") { + CadDocument cyl; + int c = cyl.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 10.0, "Sketch"); + cyl.add_extrude(c, 20.0, false, BooleanMode::New, "Extrude"); + REQUIRE(cyl.recompute()); + int planar = 0, refused = 0; + for (int fi = 0; fi < 16; ++fi) { + SketchPlane p; + if (cyl.plane_of_face(0, fi, p)) ++planar; else ++refused; + } + CHECK(planar == 2); // the two flat caps, and NOT the barrel + CHECK(refused > 0); + } +} + +// 5425 — POSITIVE-CONTRACT variant. A feature that left a body with a null +// TopoDS_Shape used to be tolerated: recompute() returned true and the document kept +// advertising the body. The new guard makes that a hard failure. This test asserts the +// contract the guard preserves on the healthy side: a normal box + fillet document +// recomputes true, reports an empty error, and NO resulting body has a null shape. +// +// Why not assert the negative branch (a dress-up chain nulling a body -> recompute false)? +// Reproducing the original silent-null degeneracy (prism -> 6 vertical fillets -> chamfer on +// the already-filleted rim -> hole) is a separate open question. Driving that order headlessly +// makes the dress-up step THROW (OCCT "fillet radius too large" — an already-loud, already- +// caught path) rather than silently null a body, so no public-API sequence reliably reaches +// the guard's null branch in a headless test. Faking one (writing a null into `bodies` after +// recompute) would not exercise the guard — it runs on the freshly-built vector — and a test +// that asserts a state the code cannot reach is exactly what the contract forbids. +TEST_CASE("recompute on a healthy box + fillet leaves no body null and no error (positive contract)", "[CadDocument]") +{ + using namespace Slic3r; + CadDocument doc; + SketchProfile sp; + sp.points.push_back(Vec2d(0, 0)); + sp.points.push_back(Vec2d(20, 0)); + sp.points.push_back(Vec2d(20, 20)); + sp.points.push_back(Vec2d(0, 20)); + sp.closed = true; + const int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + doc.add_fillet(2.0, FaceGroup::All, "Fillet"); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE_FALSE(doc.bodies.empty()); + for (size_t i = 0; i < doc.bodies.size(); ++i) { + INFO("body " << i << " has a null shape"); + REQUIRE_FALSE(doc.bodies[i].shape.IsNull()); + } +} + +// ============================================================================ +// rgbj — does a chamfer chain degenerate from a KERNEL defect, or from +// how the DRIVER captured its edge ids? Experiment, not a fix. +// +// CadFeature::dressup_edge is a GLOBAL edge id: an ordinal into +// TopExp::MapShapes(body, TopAbs_EDGE) (GeometryEngine::edge_by_index), resolved +// at recompute time against the body AS IT STANDS at that feature's position in +// the recipe. Every dress-up rewrites the edge map, so an id captured against one +// version of the shape names a DIFFERENT edge once an earlier dress-up has run. +// +// Test 1 = correct usage (re-read the map after each chamfer, pick by geometry). +// Test 2 = suspected-wrong usage (capture all four ids up-front, then run four). +// ============================================================================ + +namespace { + +// A square box centred on the origin, extruded +Z by `height`. Its four top-rim +// edges have midpoints at Z=height: (+X)(half,0,h), (+Y)(0,half,h), (-X)(-half,0,h), +// (-Y)(0,-half,h). All four are geometrically identical (equal length), so a +// near-uniform chamfer removes the same volume from each. +CadDocument make_centred_box(double half, double height) +{ + CadDocument doc; + SketchProfile sp; + sp.points = { Vec2d(-half, -half), Vec2d(half, -half), Vec2d(half, half), Vec2d(-half, half) }; + sp.closed = true; + const int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "Box"); + doc.add_extrude(sk, height, false, BooleanMode::New, "Extrude"); + return doc; +} + +// Exact analytic volume (BRepGProp, not tessellation) of a solid. +double solid_volume(const TopoDS_Shape& s) +{ + GeometryEngine::MassProps p = GeometryEngine::mass_properties(s); + return p.valid ? p.volume : 0.0; +} + +// World-space midpoint of an edge, from its sampled polyline. +Vec3d edge_mid(const TopoDS_Edge& e) +{ + const std::vector pts = GeometryEngine::sample_edge_world(e); + Vec3d m = Vec3d::Zero(); + for (const Vec3d& p : pts) m += p; + return m / double(pts.size()); +} + +// Global edge id whose midpoint is within `tol` mm of `target`. -1 if none. +// Picks by GEOMETRY (position), never by a hardcoded index. +int edge_near(const TopoDS_Shape& shape, const Vec3d& target, double tol) +{ + const std::vector edges = GeometryEngine::edges_of(shape); + for (int i = 0; i < int(edges.size()); ++i) { + if (edges[i].IsNull()) continue; + const std::vector pts = GeometryEngine::sample_edge_world(edges[i]); + if (pts.size() < 2) continue; + if ((edge_mid(edges[i]) - target).norm() < tol) return i; + } + return -1; +} + +} // namespace + +TEST_CASE("dressup: sequential chamfers, ids re-read after each, stay uniform", "[CadDocument][dressup]") +{ + const double half = 10.0, h = 10.0, d = 0.2; // tiny d: the corner-shortening of a + // neighbour's rim edge stays negligible + CadDocument doc = make_centred_box(half, h); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // The four top-rim edges, addressed by world midpoint — never by index. + const std::vector rim = { + Vec3d( half, 0.0, h), // +X + Vec3d(0.0, half, h), // +Y + Vec3d(-half, 0.0, h), // -X + Vec3d(0.0, -half, h), // -Y + }; + + std::vector removals; + double prev = solid_volume(doc.bodies[0].shape); + for (size_t k = 0; k < rim.size(); ++k) { + // Correct usage: re-read the CURRENT body's edge map, pick by geometry. + const int id = edge_near(doc.bodies[0].shape, rim[k], 1.5); + INFO("chamfer " << k << " -> edge id " << id); + REQUIRE(id >= 0); + doc.add_chamfer(d, id, "Chamfer" + std::to_string(k)); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double now = solid_volume(doc.bodies[0].shape); + removals.push_back(prev - now); + prev = now; + } + + std::ostringstream os; + for (size_t i = 0; i < removals.size(); ++i) os << (i ? ", " : "") << removals[i]; + INFO("removals (mm^3): [" << os.str() << "]"); + + REQUIRE(removals.size() == 4); + double mn = removals[0], mx = removals[0]; + for (double r : removals) { mn = std::min(mn, r); mx = std::max(mx, r); } + INFO("min=" << mn << " max=" << mx); + REQUIRE(mn > 0.0); + // All four removals equal within a few percent. Measured on this fixture: + // [0.4, 0.397333, 0.397333, 0.394667] mm^3. The residual spread is real geometry (each + // chamfer shortens the two rim edges it shares a corner with), not id drift; drift would + // push the later removals toward zero and blow the spread wide open. + REQUIRE((mx - mn) <= 0.10 * mn); +} + +TEST_CASE("dressup: four chamfer ids captured up-front drift as earlier chamfers run", "[CadDocument][dressup]") +{ + const double half = 10.0, h = 10.0, d = 0.2; + CadDocument doc = make_centred_box(half, h); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const std::vector rim = { + Vec3d( half, 0.0, h), Vec3d(0.0, half, h), Vec3d(-half, 0.0, h), Vec3d(0.0, -half, h), + }; + + // Capture all four ids ONCE, from the shape as it stands before any chamfer. + std::vector ids; + for (const Vec3d& t : rim) { + const int id = edge_near(doc.bodies[0].shape, t, 1.5); + REQUIRE(id >= 0); + ids.push_back(id); + } + REQUIRE(ids.size() == 4); + INFO("captured ids: [" << ids[0] << "," << ids[1] << "," << ids[2] << "," << ids[3] << "]"); + + // Recompute resolves each dressup_edge against the body as it stands at that feature's + // position — exactly what chaining apply_chamfer reproduces here. Measure each step, and + // record whether a stale id throws (invalid/out-of-range edge) or silently lands elsewhere. + TopoDS_Shape s = doc.bodies[0].shape; + std::vector vols{ solid_volume(s) }; + bool threw = false; + std::string why; + for (int id : ids) { + try { + s = GeometryEngine::apply_chamfer(s, d, id); + vols.push_back(solid_volume(s)); + } catch (const std::exception& e) { threw = true; why = e.what(); break; } + } + + std::vector rmv; + for (size_t i = 1; i < vols.size(); ++i) rmv.push_back(vols[i - 1] - vols[i]); + + std::ostringstream os; + for (size_t i = 0; i < rmv.size(); ++i) os << (i ? ", " : "") << rmv[i]; + INFO("up-front-id removals (mm^3): [" << os.str() << "] threw=" << threw << " (" << why << ")"); + + // Documents a defect, not a desired result. Measured on this fixture: [0.4, 0.008324, + // 0.397333, 0.280405] mm^3 — the second chamfer's stale id landed on a nearly-consumed + // edge and cut ~2% of the intended volume. Ids captured once and replayed against an + // evolving edge map DRIFT; that is the degeneration the socket chamfers showed, and it is + // a driver artefact (wrong usage), not a kernel defect — Test 1 proves the kernel stays + // uniform when ids are re-read. + REQUIRE_FALSE(threw); // currently it does NOT throw: it silently drifts + REQUIRE(rmv.size() == 4); + double mn = rmv[0], mx = rmv[0]; + for (double r : rmv) { mn = std::min(mn, r); mx = std::max(mx, r); } + INFO("up-front-id min=" << mn << " max=" << mx); + REQUIRE(mx > 1.10 * mn); // non-uniform: the id-drift signature + + // The first captured id is still a valid top-rim edge, so the first chamfer must cut. + REQUIRE(vols.size() >= 2); + REQUIRE(vols[1] < vols[0]); + + // Literal driver path: append all four and recompute once. + CadDocument doc2 = make_centred_box(half, h); + REQUIRE(doc2.recompute()); + for (size_t i = 0; i < ids.size(); ++i) + doc2.add_chamfer(d, ids[i], "C" + std::to_string(i)); + const bool ok = doc2.recompute(); + INFO("single-recompute driver path: ok=" << ok << " error=" << (ok ? std::string() : doc2.error)); + REQUIRE(ok); +} + +// --- Face-drift fingerprint: a FaceAndDirection connector warns when its face index slides --- + +TEST_CASE("a connector records its face fingerprint on first recompute", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + int top_face = find_face_by_normal(doc, 0, Vec3d(0, 0, 1)); + REQUIRE(top_face >= 0); + + int cs = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS"); + doc.features[cs].coordsys_body = 0; + doc.features[cs].coordsys_face = top_face; + REQUIRE(doc.recompute()); + + REQUIRE(doc.features[cs].coordsys_face_kind >= 0); + REQUIRE(doc.features[cs].coordsys_face_edges >= 0); + REQUIRE(doc.mate_conflicts.empty()); +} + +TEST_CASE("a connector whose face index slides onto a different KIND of face is reported", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + int top_face = find_face_by_normal(doc, 0, Vec3d(0, 0, 1)); + REQUIRE(top_face >= 0); + + int cs = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS"); + doc.features[cs].coordsys_body = 0; + doc.features[cs].coordsys_face = top_face; + REQUIRE(doc.recompute()); + REQUIRE(doc.mate_conflicts.empty()); + REQUIRE(doc.features[cs].coordsys_face_kind >= 0); + + // Insert a fillet: the box gains cylindrical faces and the face map is renumbered. + doc.add_fillet(2.0, FaceGroup::All, "Fillet"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Find the cylindrical face the fillet introduced (a different KIND than the recorded + // planar fingerprint), then force the drift by pointing coordsys_face at it. + int cyl_face = -1; + auto faces = GeometryEngine::faces_of(doc.bodies[0].shape); + for (int i = 0; i < int(faces.size()); ++i) { + if (GeometryEngine::cylinder_of_face(faces[i]).ok) { cyl_face = i; break; } + } + REQUIRE(cyl_face >= 0); + + doc.features[cs].coordsys_face = cyl_face; + const bool ok = doc.recompute(); + REQUIRE(ok); // non-fatality is the contract under test + + bool reported = false; + for (const auto& c : doc.mate_conflicts) + if (c.first == cs) { reported = true; break; } + REQUIRE(reported); +} + +TEST_CASE("a resized body does not report drift", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + int top_face = find_face_by_normal(doc, 0, Vec3d(0, 0, 1)); + REQUIRE(top_face >= 0); + + int cs = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS"); + doc.features[cs].coordsys_body = 0; + doc.features[cs].coordsys_face = top_face; + REQUIRE(doc.recompute()); + REQUIRE(doc.mate_conflicts.empty()); + + // Resize upstream: same face, same kind, same edge count — only its dimensions changed. + // This is a legitimate parametric edit and must not raise the drift warning. + for (CadFeature& f : doc.features) + if (f.type == CadFeatureType::Extrude) f.distance = 25.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.mate_conflicts.empty()); +} + +TEST_CASE("mate_options always returns five entries in kind order", "[CadDocument][mate]") +{ + CadDocument doc; + // Completely invalid pair: the stability contract (always five, always in order) must hold. + auto opts = doc.mate_options(-1, -1); + REQUIRE(opts.size() == 5); + for (int i = 0; i < 5; ++i) REQUIRE(opts[i].kind == i); + + // A valid, distinct pair must return the same shape. + int a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "A"); + int b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(1, 0, 0), "B"); + auto opts2 = doc.mate_options(a, b); + REQUIRE(opts2.size() == 5); + for (int i = 0; i < 5; ++i) REQUIRE(opts2[i].kind == i); +} + +TEST_CASE("a connector with no body dims every mate kind, with a reason", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + // Point(world) connectors belong to no body, which is the default the GUI hands you. + int a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "A"); + int b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(30, 0, 0), "B"); + REQUIRE(doc.features[b].coordsys_body < 0); + + // B is the connector whose body MOVES, so without one no kind can apply. The offer used to + // call all five viable and the mate only failed at recompute, after the feature existed. + auto opts = doc.mate_options(a, b); + REQUIRE(opts.size() == 5); + for (int i = 0; i < 5; ++i) { + REQUIRE(opts[i].kind == i); + REQUIRE_FALSE(opts[i].viable); + REQUIRE(opts[i].reason.find("not attached to a body") != std::string::npos); + } + + // A needs no body — it is the fixed reference. Giving B one is enough to revive the offer. + doc.features[b].coordsys_body = 0; + auto opts2 = doc.mate_options(a, b); + REQUIRE(opts2[0].viable); // Fastened is frame-only + REQUIRE(opts2[0].reason.empty()); + + // A body index that no longer resolves is refused too, and says so differently. + doc.features[b].coordsys_body = 7; + auto opts3 = doc.mate_options(a, b); + for (const auto& o : opts3) { + REQUIRE_FALSE(o.viable); + REQUIRE(o.reason.find("no longer exists") != std::string::npos); + } +} + +TEST_CASE("a flat-face pair offers Fastened, Planar and Slider but not the axial types", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int top = find_face_by_normal(doc, 0, Vec3d(0, 0, 1)); + int side = find_face_by_normal(doc, 0, Vec3d(1, 0, 0)); + REQUIRE(top >= 0); + REQUIRE(side >= 0); + + int a = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "A"); + doc.features[a].coordsys_body = 0; + doc.features[a].coordsys_face = top; + int b = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "B"); + doc.features[b].coordsys_body = 0; + doc.features[b].coordsys_face = side; + + // Recompute so the face fingerprints record (both planar => GeomAbs_Plane). + REQUIRE(doc.recompute()); + REQUIRE(doc.features[a].coordsys_face_kind >= 0); + REQUIRE(doc.features[b].coordsys_face_kind >= 0); + + auto opts = doc.mate_options(a, b); + REQUIRE(opts.size() == 5); + REQUIRE(opts[0].viable); // Fastened: frame-only, always viable + REQUIRE(opts[1].viable); // Planar: both faces planar + REQUIRE_FALSE(opts[2].viable); // Revolute: needs a cylindrical face + REQUIRE(opts[3].viable); // Slider: frame-only, always viable + REQUIRE_FALSE(opts[4].viable); // Cylindrical: needs a cylindrical face + + REQUIRE(opts[0].reason.empty()); + REQUIRE(opts[1].reason.empty()); + REQUIRE(opts[3].reason.empty()); + REQUIRE_FALSE(opts[2].reason.empty()); + REQUIRE_FALSE(opts[4].reason.empty()); + REQUIRE_CONTAINS(opts[2].reason, "connector"); + REQUIRE_CONTAINS(opts[4].reason, "connector"); +} + +TEST_CASE("an unrecorded fingerprint does not make a type non-viable", "[CadDocument][mate]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + + int a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "A"); + int b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(1, 0, 0), "B"); + // The MISSING FINGERPRINT is the whole subject here, so nothing else may be missing: B is + // given a body, because a connector without one is refused for that reason instead and the + // case would no longer isolate what this test is named for. + doc.features[a].coordsys_body = 0; + doc.features[b].coordsys_body = 0; + // PointWorld connectors never record a face fingerprint: face_kind stays -1. + REQUIRE(doc.features[a].coordsys_face_kind == -1); + REQUIRE(doc.features[b].coordsys_face_kind == -1); + + auto opts = doc.mate_options(a, b); + REQUIRE(opts.size() == 5); + for (const auto& o : opts) { + REQUIRE(o.viable); + REQUIRE(o.reason.empty()); + } +} + +TEST_CASE("an invalid pair names which side is wrong", "[CadDocument][mate]") +{ + CadDocument doc; + int a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "A"); + int sketch = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "S"); + + // cs_b points at a Sketch feature, not a CoordSys: all five non-viable, reason names B. + auto opts = doc.mate_options(a, sketch); + REQUIRE(opts.size() == 5); + for (const auto& o : opts) { + REQUIRE_FALSE(o.viable); + REQUIRE_CONTAINS(o.reason, "connector B"); + REQUIRE(o.reason.find("connector A") == std::string::npos); + } + + // Out of range on B names B; out of range on A names A. + for (const auto& o : doc.mate_options(a, 9999)) + REQUIRE_CONTAINS(o.reason, "connector B"); + for (const auto& o : doc.mate_options(-5, a)) + REQUIRE_CONTAINS(o.reason, "connector A"); +} + +// A rectangular plate 120 x 160 x 10 centred on the origin with a radius-30 bore through +// the middle: one solid whose volume is the box minus the cylinder, and whose topology is a +// plate-with-a-bore (1 solid, 7 faces) rather than a plate plus a plug. +TEST_CASE("add_extrude_entities builds a plate with a bore", "[CadDocument][holes]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + + const Vec2d A(-60, -80), B(60, -80), C(60, 80), D(-60, 80); + std::vector entities = { + {SketchEntity::Type::Line, A, B}, + {SketchEntity::Type::Line, B, C}, + {SketchEntity::Type::Line, C, D}, + {SketchEntity::Type::Line, D, A}, + }; + SketchEntity bore; + bore.type = SketchEntity::Type::Circle; + bore.center = Vec2d(0, 0); + bore.radius = 30.0; + entities.push_back(bore); + + doc.add_extrude_entities(entities, SketchPlane::XY(), 10.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto mp = doc.body_mass_properties(0); + REQUIRE(mp.valid); + const double expected_vol = 120.0 * 160.0 * 10.0 - M_PI * 30.0 * 30.0 * 10.0; + REQUIRE_THAT(mp.volume, WithinAbs(expected_vol, 1.0)); + + // One solid, seven faces: top + bottom (each a planar face with a hole), four side + // walls, and the single cylindrical bore wall. + int face_count = 0, solid_count = 0; + for (TopExp_Explorer fe(doc.bodies.back().shape, TopAbs_FACE); fe.More(); fe.Next()) ++face_count; + for (TopExp_Explorer se(doc.bodies.back().shape, TopAbs_SOLID); se.More(); se.Next()) ++solid_count; + INFO("volume mm^3 = " << mp.volume << ", faces = " << face_count << ", solids = " << solid_count); + REQUIRE(solid_count == 1); + REQUIRE(face_count == 7); +} + +TEST_CASE("two disjoint circles are refused by name", "[CadDocument][holes]") +{ + CadDocument doc; + + SketchEntity a; + a.type = SketchEntity::Type::Circle; a.center = Vec2d(-50, 0); a.radius = 20.0; + SketchEntity b; + b.type = SketchEntity::Type::Circle; b.center = Vec2d( 50, 0); b.radius = 20.0; + + doc.add_extrude_entities({a, b}, SketchPlane::XY(), 10.0, false, BooleanMode::New, "Extrude1"); + + REQUIRE_FALSE(doc.recompute()); + REQUIRE_CONTAINS(doc.error, "disjoint"); +} + +// Same plate-with-a-bore, but the bore circle is wound CLOCKWISE (geometric winding opposite +// the CCW rectangle). The old wires_to_face reversed every hole wire unconditionally +// (wires[i].Reversed()), which only produced a correct hole when the circle was wound the same +// way as the outer loop; a clockwise circle got reversed into matching the outer boundary and +// the prism swept it solid — measured 220274 mm^3 = box (192000) + disc (28274), the filled-bore +// signature. The current ShapeFix_Face::FixOrientation path is winding-independent. Flipping the +// plane normal reverses gp_Circ's parametrisation (gp_Ax2(center, -Z) sweeps clockwise seen from +// +Z) while the rectangle's 2D coordinates stay CCW. +TEST_CASE("add_extrude_entities builds a plate with a bore (clockwise circle)", "[CadDocument][holes]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + + SketchPlane cw_plane = SketchPlane::XY(); + cw_plane.normal = Vec3d(0, 0, -1); + + const Vec2d A(-60, -80), B(60, -80), C(60, 80), D(-60, 80); + std::vector entities = { + {SketchEntity::Type::Line, A, B}, + {SketchEntity::Type::Line, B, C}, + {SketchEntity::Type::Line, C, D}, + {SketchEntity::Type::Line, D, A}, + }; + SketchEntity bore; + bore.type = SketchEntity::Type::Circle; + bore.center = Vec2d(0, 0); + bore.radius = 30.0; + entities.push_back(bore); + + doc.add_extrude_entities(entities, cw_plane, 10.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + auto mp = doc.body_mass_properties(0); + REQUIRE(mp.valid); + const double expected_vol = 120.0 * 160.0 * 10.0 - M_PI * 30.0 * 30.0 * 10.0; + REQUIRE_THAT(mp.volume, WithinAbs(expected_vol, 1.0)); + + int face_count = 0, solid_count = 0; + for (TopExp_Explorer fe(doc.bodies.back().shape, TopAbs_FACE); fe.More(); fe.Next()) ++face_count; + for (TopExp_Explorer se(doc.bodies.back().shape, TopAbs_SOLID); se.More(); se.Next()) ++solid_count; + INFO("volume mm^3 = " << mp.volume << ", faces = " << face_count << ", solids = " << solid_count); + REQUIRE(solid_count == 1); + REQUIRE(face_count == 7); +} + + +// mtav. A document that has only sketches in it is not a broken document, it is the +// state every design passes through between drawing a profile and extruding it. recompute() +// used to call that "no solid-producing features" and return false, and two things downstream +// read that false as "the document is unusable": the GUI syncs the 3MF recipe only after a +// successful recompute, so a sketch-only design was saved with NO recipe at all and vanished on +// reopen; and deserialize_recipe ends with `return recompute()`, so even a project that did +// carry one was refused on load. The failure has to stay for a document that ASKED for a solid +// and got none — that is a real geometry failure — so both halves are asserted here. +TEST_CASE("A sketch-only document recomputes and round-trips", "[CadDocument]") +{ + CadDocument doc; + std::vector ents{ + {SketchEntity::Type::Line, Vec2d(-60, -40), Vec2d(60, -40)}, + {SketchEntity::Type::Line, Vec2d(60, -40), Vec2d(60, 40)}, + {SketchEntity::Type::Line, Vec2d(60, 40), Vec2d(-60, 40)}, + {SketchEntity::Type::Line, Vec2d(-60, 40), Vec2d(-60, -40)}, + }; + const int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "Profile"); + REQUIRE(sk == 0); + + const bool built = doc.recompute(); // nothing to build is not a failure + INFO("recompute error: " << doc.error); + REQUIRE(built); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.empty()); + + const std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.error.empty()); + REQUIRE(fresh.features.size() == 1); + REQUIRE(fresh.features[0].name == "Profile"); + REQUIRE(fresh.features[0].entities.size() == 4); + for (size_t i = 0; i < ents.size(); ++i) { + REQUIRE(fresh.features[0].entities[i].p0.x() == ents[i].p0.x()); + REQUIRE(fresh.features[0].entities[i].p0.y() == ents[i].p0.y()); + REQUIRE(fresh.features[0].entities[i].p1.x() == ents[i].p1.x()); + REQUIRE(fresh.features[0].entities[i].p1.y() == ents[i].p1.y()); + } + + // The other half of the rule: a feature that MEANT to build a solid and produced none is + // still an error, and must not be swallowed by the change above. + CadDocument bad; + bad.add_sketch_entities(ents, SketchPlane::XY(), "Profile"); + bad.add_extrude(0, 0.0, false, BooleanMode::New, "ZeroDepth"); + REQUIRE_FALSE(bad.recompute()); + REQUIRE_FALSE(bad.error.empty()); +} + +// A body is NOT the feature that created it. Reported 2026-08-23, in these words: "you have +// renamed the feature extrusion, not the body ... this means that you consider the extrusion = +// the body, which is very far from truth as a body can contain several extrusions." The rename +// used to resolve CadBody::source_feature and rename THAT, so naming a body edited one operation +// in its history. A body now carries its own name, and this pins the three properties that make +// it a name rather than a label: it does not touch the features, it survives a recompute that +// adds more features to the same body, and it survives the recipe round trip. +TEST_CASE("a body carries its own name, through recompute and the recipe", "[CadDocument]") +{ + CadDocument doc; + std::vector ents; + auto line = [&](double x0, double y0, double x1, double y1) { + SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(x0, y0); e.p1 = Vec2d(x1, y1); ents.push_back(e); }; + line(0, 0, 40, 0); line(40, 0, 40, 30); line(40, 30, 0, 30); line(0, 30, 0, 0); + + const int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "Profile"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + doc.bodies[0].has_user_name = true; + doc.bodies[0].user_name = "Bracket"; + + // A SECOND feature lands on the same body — the case the report is about. + doc.add_hole(6.0, 5.0, true, 20.0, 15.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + CHECK(doc.bodies[0].has_user_name); + CHECK(doc.bodies[0].user_name == "Bracket"); + + // The features keep their own names: renaming the body renamed nothing else. + REQUIRE(doc.features.size() == 3); + CHECK(doc.features[0].name == "Profile"); + CHECK(doc.features[1].name == "Extrude"); + CHECK(doc.features[2].name == "Hole"); + + // ...and the name is part of what gets saved. Bodies are recomputed, never serialised, so + // without the recipe block a body name would live exactly until the project was reopened. + const std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + CadDocument fresh; + REQUIRE(fresh.deserialize_recipe(blob)); + REQUIRE(fresh.bodies.size() == 1); + CHECK(fresh.bodies[0].has_user_name); + CHECK(fresh.bodies[0].user_name == "Bracket"); +} + +// A feature reference is a reference whatever feature holds it. +// +// remove_feature()/move_feature() used to remap Extrude::sketch_ref and a Mate's two +// connectors, and nothing else — so every later consumer of a Sketch (Revolve, Sweep, Loft, +// Rib, Pattern-on-curve, the Surface* family) kept an index that the erase had just +// invalidated. The failure is quiet by construction: a shifted index still names a real +// feature, recompute() succeeds, and what comes out is built from the wrong profile. +// +// The volume is the assertion. Two different profiles go in; deleting the unused feature in +// front of them must leave the SAME solid behind, which it only can if the reference moved +// with its target. +TEST_CASE("deleting a feature remaps the references of every consumer, not just Extrude", + "[CadDocument]") +{ + using namespace Slic3r; + const SketchPlane xy = SketchPlane::XY(); + + // A rectangle 10x10 centred at v = 15, revolved 360 deg about the plane X axis. + auto rect_at = [&](double v0) { + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = xy; + sk.profile.points = { Vec2d(-5, v0 - 5), Vec2d(5, v0 - 5), + Vec2d(5, v0 + 5), Vec2d(-5, v0 + 5) }; + sk.profile.closed = true; + return sk; + }; + + SECTION("Revolve::sketch_ref survives the deletion of a feature in front of it") { + CadDocument doc; + doc.features.push_back(rect_at(40.0)); // f0: a decoy profile, never consumed + doc.features.push_back(rect_at(15.0)); // f1: the real profile + doc.add_revolve(1, 360.0, /*axis=X*/0, false, BooleanMode::New, "Rev"); + REQUIRE(doc.recompute()); + const double before = double(doc.display_mesh.volume()); + REQUIRE(before == Approx(9424.78).epsilon(0.05)); + + // f0 is consumed by nothing, so this deletes exactly one feature and shifts f1 to 0. + REQUIRE(doc.remove_feature(0)); + REQUIRE(doc.features.size() == 2); + REQUIRE(doc.features[1].type == CadFeatureType::Revolve); + REQUIRE(doc.features[1].sketch_ref == 0); // followed its target + REQUIRE(doc.error.empty()); + REQUIRE(double(doc.display_mesh.volume()) == Approx(before).epsilon(1e-6)); + } + + SECTION("move_feature swaps a Revolve's reference too") { + CadDocument doc; + doc.features.push_back(rect_at(40.0)); // f0 + doc.features.push_back(rect_at(15.0)); // f1 -> consumed + doc.add_revolve(1, 360.0, 0, false, BooleanMode::New, "Rev"); + REQUIRE(doc.recompute()); + const double before = double(doc.display_mesh.volume()); + + REQUIRE(doc.move_feature(0, 1)); // f0 and f1 trade places + REQUIRE(doc.features[2].sketch_ref == 0); // the profile is now at 0 + REQUIRE(double(doc.display_mesh.volume()) == Approx(before).epsilon(1e-6)); + } + + SECTION("deleting a Sketch cascades to a Revolve that consumes it") { + CadDocument doc; + doc.features.push_back(rect_at(15.0)); + doc.add_revolve(0, 360.0, 0, false, BooleanMode::New, "Rev"); + REQUIRE(doc.recompute()); + + // The Revolve has no profile without this Sketch, exactly as an Extrude would not: + // it goes with it rather than being left pointing at nothing. + REQUIRE(doc.remove_feature(0)); + REQUIRE(doc.features.empty()); + } +} diff --git a/tests/libslic3r/test_sketchconstraints.cpp b/tests/libslic3r/test_sketchconstraints.cpp new file mode 100644 index 0000000000..d0c5844b40 --- /dev/null +++ b/tests/libslic3r/test_sketchconstraints.cpp @@ -0,0 +1,396 @@ +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +#include "libslic3r/CAD/SketchConstraints.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" + +using namespace Slic3r; + +namespace { +SketchEntity mk_line(double x0, double y0, double x1, double y1) +{ + SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(x0, y0); e.p1 = Vec2d(x1, y1); return e; +} +SketchEntity mk_point(double x, double y) +{ + SketchEntity e; e.type = SketchEntity::Type::Point; e.p0 = Vec2d(x, y); return e; +} +SketchEntity mk_circle(double cx, double cy, double r) +{ + SketchEntity e; e.type = SketchEntity::Type::Circle; + e.center = Vec2d(cx, cy); e.radius = r; return e; +} +} + +TEST_CASE("Coincident with anchor", "[SketchConstraints]") +{ + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(5, 5); + sc.fix_point(a); + sc.coincident(a, b); + REQUIRE(sc.solve()); + Vec2d pb = sc.get_point(b); + REQUIRE_THAT(pb.x(), Catch::Matchers::WithinAbs(0.0, 1e-4)); + REQUIRE_THAT(pb.y(), Catch::Matchers::WithinAbs(0.0, 1e-4)); +} + +TEST_CASE("Horizontal + distance", "[SketchConstraints]") +{ + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(5, 3); + sc.fix_point(a); + sc.horizontal(a, b); + sc.distance(a, b, 10); + REQUIRE(sc.solve()); + Vec2d pb = sc.get_point(b); + REQUIRE_THAT(pb.y(), Catch::Matchers::WithinAbs(0.0, 1e-3)); + REQUIRE_THAT(std::abs(pb.x()), Catch::Matchers::WithinAbs(10.0, 1e-3)); +} + +TEST_CASE("Rectangle", "[SketchConstraints]") +{ + SketchConstraints sc; + int p0 = sc.add_point(0, 0); + int p1 = sc.add_point(8, 1); + int p2 = sc.add_point(9, 5); + int p3 = sc.add_point(-1, 4); + sc.fix_point(p0); + sc.lock_x(p0, 0); + sc.lock_y(p0, 0); + sc.horizontal(p0, p1); + sc.vertical(p1, p2); + sc.horizontal(p2, p3); + sc.vertical(p3, p0); + sc.distance(p0, p1, 10); + sc.distance(p1, p2, 6); + REQUIRE(sc.solve()); + Vec2d pp1 = sc.get_point(p1); + Vec2d pp2 = sc.get_point(p2); + Vec2d pp3 = sc.get_point(p3); + REQUIRE_THAT(pp1.x(), Catch::Matchers::WithinAbs(10.0, 1e-3)); + REQUIRE_THAT(pp1.y(), Catch::Matchers::WithinAbs(0.0, 1e-3)); + REQUIRE_THAT(pp2.x(), Catch::Matchers::WithinAbs(10.0, 1e-3)); + REQUIRE_THAT(pp2.y(), Catch::Matchers::WithinAbs(6.0, 1e-3)); + REQUIRE_THAT(pp3.x(), Catch::Matchers::WithinAbs(0.0, 1e-3)); + REQUIRE_THAT(pp3.y(), Catch::Matchers::WithinAbs(6.0, 1e-3)); +} + +TEST_CASE("residual_norm after each solve", "[SketchConstraints]") +{ + SECTION("coincident case") + { + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(5, 5); + sc.fix_point(a); + sc.coincident(a, b); + REQUIRE(sc.solve()); + REQUIRE(sc.residual_norm() < 1e-5); + } + SECTION("horizontal+distance case") + { + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(5, 3); + sc.fix_point(a); + sc.horizontal(a, b); + sc.distance(a, b, 10); + REQUIRE(sc.solve()); + REQUIRE(sc.residual_norm() < 1e-5); + } + SECTION("rectangle case") + { + SketchConstraints sc; + int p0 = sc.add_point(0, 0); + int p1 = sc.add_point(8, 1); + int p2 = sc.add_point(9, 5); + int p3 = sc.add_point(-1, 4); + sc.fix_point(p0); + sc.lock_x(p0, 0); + sc.lock_y(p0, 0); + sc.horizontal(p0, p1); + sc.vertical(p1, p2); + sc.horizontal(p2, p3); + sc.vertical(p3, p0); + sc.distance(p0, p1, 10); + sc.distance(p1, p2, 6); + REQUIRE(sc.solve()); + REQUIRE(sc.residual_norm() < 1e-5); + } +} + +TEST_CASE("midpoint", "[SketchConstraints]") +{ + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(10, 0); + int m = sc.add_point(3, 7); + sc.fix_point(a); + sc.fix_point(b); + sc.midpoint(m, a, b); + REQUIRE(sc.solve()); + Vec2d pm = sc.get_point(m); + REQUIRE_THAT(pm.x(), Catch::Matchers::WithinAbs(5.0, 1e-3)); + REQUIRE_THAT(pm.y(), Catch::Matchers::WithinAbs(0.0, 1e-3)); +} + +TEST_CASE("symmetric across Y axis", "[SketchConstraints]") +{ + SketchConstraints sc; + int a = sc.add_point(2, 3); + int b = sc.add_point(-1, 1); + int c = sc.add_point(0, 0); + int d = sc.add_point(0, 1); + sc.fix_point(a); + sc.fix_point(c); + sc.fix_point(d); + sc.symmetric(a, b, c, d); + REQUIRE(sc.solve()); + Vec2d pb = sc.get_point(b); + REQUIRE_THAT(pb.x(), Catch::Matchers::WithinAbs(-2.0, 1e-3)); + REQUIRE_THAT(pb.y(), Catch::Matchers::WithinAbs(3.0, 1e-3)); +} + +TEST_CASE("angle 90 degrees", "[SketchConstraints]") +{ + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(1, 0); + int c = sc.add_point(0, 0); + int d = sc.add_point(1, 1); + sc.fix_point(a); + sc.fix_point(b); + sc.fix_point(c); + sc.angle(a, b, c, d, M_PI / 2); + REQUIRE(sc.solve()); + Vec2d pd = sc.get_point(d); + Vec2d pc = sc.get_point(c); + REQUIRE_THAT(pd.x() - pc.x(), Catch::Matchers::WithinAbs(0.0, 1e-3)); + REQUIRE(pd.y() > pc.y()); +} + +TEST_CASE("point-line distance", "[SketchConstraints]") +{ + SketchConstraints sc; + int a = sc.add_point(0, 0); + int b = sc.add_point(10, 0); + int p = sc.add_point(3, 1); + sc.fix_point(a); + sc.fix_point(b); + sc.lock_x(p, 3.0); + sc.point_line_distance(p, a, b, 5.0); + REQUIRE(sc.solve()); + Vec2d pp = sc.get_point(p); + REQUIRE_THAT(std::abs(pp.y()), Catch::Matchers::WithinAbs(5.0, 1e-3)); + REQUIRE_THAT(pp.x(), Catch::Matchers::WithinAbs(3.0, 1e-3)); +} + +// ---- entity-constraint planner (kernel port of DesignPanel::apply_entity_constraint) ---- + +TEST_CASE("sketch_entity_ends exposes real roles only", "[SketchConstraints]") +{ + std::pair out[2]; + REQUIRE(sketch_entity_ends(mk_point(3, 4), out) == 1); + REQUIRE(out[0].first == SketchPointRole::P0); + REQUIRE(sketch_entity_ends(mk_circle(1, 2, 5), out) == 1); + REQUIRE(out[0].first == SketchPointRole::Center); + REQUIRE_THAT(out[0].second.x(), Catch::Matchers::WithinAbs(1.0, 1e-9)); + REQUIRE_THAT(out[0].second.y(), Catch::Matchers::WithinAbs(2.0, 1e-9)); + REQUIRE(sketch_entity_ends(mk_line(0, 0, 10, 0), out) == 2); + REQUIRE(out[0].first == SketchPointRole::P0); + REQUIRE(out[1].first == SketchPointRole::P1); +} + +TEST_CASE("Coincident on two Points binds P0/P0, not phantom p1", "[SketchConstraints]") +{ + std::vector ents = { mk_point(0, 0), mk_point(5, 5) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Coincident); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::Coincident); + REQUIRE(p.defs[0].ea == 0); + REQUIRE(p.defs[0].ra == SketchPointRole::P0); + REQUIRE(p.defs[0].eb == 1); + REQUIRE(p.defs[0].rb == SketchPointRole::P0); +} + +TEST_CASE("DistanceX on two Points binds real roles with non-negative prefill", "[SketchConstraints]") +{ + // e0 is right of e1, so the raw projected delta is negative: the plan must swap the + // refs so accepting the shown (positive) value is a no-op, not a sign flip. + std::vector ents = { mk_point(5, 1), mk_point(2, 3) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::DistanceX); + REQUIRE(p.kind == ConstraintPlan::Kind::AskValue); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::DistanceX); + REQUIRE(p.defs[0].ra == SketchPointRole::P0); + REQUIRE(p.defs[0].rb == SketchPointRole::P0); + REQUIRE(p.prefill >= 0.0); + REQUIRE(p.defs[0].ea == 1); + REQUIRE(p.defs[0].eb == 0); +} + +TEST_CASE("Horizontal on a Point rejects with NeedALine", "[SketchConstraints]") +{ + std::vector ents = { mk_point(1, 2) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, -1, -1, SketchConstraintType::Horizontal); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedALine); +} + +TEST_CASE("Angle on two Circles rejects with NeedTwoLines", "[SketchConstraints]") +{ + std::vector ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 1) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Angle); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedTwoLines); +} + +TEST_CASE("Parallel on a Line + Circle rejects with NeedTwoLines (new guard)", "[SketchConstraints]") +{ + std::vector ents = { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Parallel); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedTwoLines); +} + +TEST_CASE("Equal on two Circles promotes to EqualRadius", "[SketchConstraints]") +{ + std::vector ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 2) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::EqualLength); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::EqualRadius); + REQUIRE(p.defs[0].ea == 0); + REQUIRE(p.defs[0].eb == 1); +} + +TEST_CASE("Symmetric on two Lines returns two defs with ec set to the axis", "[SketchConstraints]") +{ + std::vector ents = { mk_line(0, 1, 5, 1), mk_line(0, -1, 5, -1), mk_line(0, 0, 0, 1) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, 2, SketchConstraintType::Symmetric); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 2); + for (const auto& d : p.defs) { + REQUIRE(d.type == SketchConstraintType::Symmetric); + REQUIRE(d.ea == 0); + REQUIRE(d.eb == 1); + REQUIRE(d.ec == 2); + } + REQUIRE(p.defs[0].ra == SketchPointRole::P0); + REQUIRE(p.defs[0].rb == SketchPointRole::P0); + REQUIRE(p.defs[1].ra == SketchPointRole::P1); + REQUIRE(p.defs[1].rb == SketchPointRole::P1); +} + +TEST_CASE("Symmetric with no axis rejects with NeedAxisLine", "[SketchConstraints]") +{ + std::vector ents = { mk_point(0, 0), mk_point(5, 0) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Symmetric); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedAxisLine); +} + +TEST_CASE("SymmetricAboutY on two Points returns one def with ec == kSketchRefAxisY", "[SketchConstraints]") +{ + std::vector ents = { mk_point(1, 0), mk_point(-2, 0) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::SymmetricAboutY); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::SymmetricAboutY); + REQUIRE(p.defs[0].ec == kSketchRefAxisY); + REQUIRE(p.defs[0].ea == 0); + REQUIRE(p.defs[0].eb == 1); +} + +TEST_CASE("constraint planner apply/askvalue matrix", "[SketchConstraints]") +{ + struct C { + const char* name; SketchConstraintType type; std::vector ents; + int e0, e1, e2; ConstraintPlan::Kind kind; + }; + const std::vector cases = { + { "Fix", SketchConstraintType::Fix, { mk_point(1, 2) }, 0, -1, -1, ConstraintPlan::Kind::Apply }, + { "Coincident", SketchConstraintType::Coincident, { mk_point(0, 0), mk_point(5, 5) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Horizontal", SketchConstraintType::Horizontal, { mk_line(0, 0, 5, 0) }, 0, -1, -1, ConstraintPlan::Kind::Apply }, + { "Vertical", SketchConstraintType::Vertical, { mk_line(0, 0, 0, 5) }, 0, -1, -1, ConstraintPlan::Kind::Apply }, + { "Parallel", SketchConstraintType::Parallel, { mk_line(0, 0, 1, 0), mk_line(0, 1, 1, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Perpendicular", SketchConstraintType::Perpendicular, { mk_line(0, 0, 1, 0), mk_line(0, 0, 0, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "EqualLength", SketchConstraintType::EqualLength, { mk_line(0, 0, 1, 0), mk_line(0, 1, 2, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Concentric", SketchConstraintType::Concentric, { mk_circle(0, 0, 1), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Tangent", SketchConstraintType::Tangent, { mk_line(0, 0, 1, 0), mk_circle(0, 1, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Midpoint", SketchConstraintType::Midpoint, { mk_point(2, 0), mk_line(0, 0, 5, 0) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Symmetric", SketchConstraintType::Symmetric, { mk_point(0, 0), mk_point(5, 0), mk_line(0, -1, 0, 1) }, 0, 1, 2, ConstraintPlan::Kind::Apply }, + { "SymmetricAboutY", SketchConstraintType::SymmetricAboutY, { mk_point(1, 0), mk_point(-2, 0) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "SymmetricAboutX", SketchConstraintType::SymmetricAboutX, { mk_point(0, 1), mk_point(0, -2) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "EqualRadius", SketchConstraintType::EqualRadius, { mk_circle(0, 0, 1), mk_circle(5, 0, 2) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Collinear", SketchConstraintType::Collinear, { mk_line(0, 0, 1, 0), mk_line(2, 0, 3, 0) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Angle", SketchConstraintType::Angle, { mk_line(0, 0, 1, 0), mk_line(0, 0, 0, 1) }, 0, 1, -1, ConstraintPlan::Kind::AskValue }, + { "Radius", SketchConstraintType::Radius, { mk_circle(0, 0, 2.5) }, 0, -1, -1, ConstraintPlan::Kind::AskValue }, + { "Diameter", SketchConstraintType::Diameter, { mk_circle(0, 0, 2.5) }, 0, -1, -1, ConstraintPlan::Kind::AskValue }, + { "DistanceX", SketchConstraintType::DistanceX, { mk_point(0, 0), mk_point(5, 3) }, 0, 1, -1, ConstraintPlan::Kind::AskValue }, + { "DistanceY", SketchConstraintType::DistanceY, { mk_point(0, 0), mk_point(5, 3) }, 0, 1, -1, ConstraintPlan::Kind::AskValue }, + }; + for (const C& c : cases) { + DYNAMIC_SECTION("apply " << c.name) { + ConstraintPlan p = plan_entity_constraint(c.ents, c.e0, c.e1, c.e2, c.type); + REQUIRE(p.kind == c.kind); + REQUIRE(p.defs.size() >= 1); + for (const auto& d : p.defs) REQUIRE(d.type == c.type); + } + } +} + +TEST_CASE("constraint planner reject matrix", "[SketchConstraints]") +{ + struct C { + const char* name; SketchConstraintType type; std::vector ents; + int e0, e1, e2; ConstraintReject reason; + }; + const std::vector cases = { + { "Fix", SketchConstraintType::Fix, {}, 0, -1, -1, ConstraintReject::NeedOneEntity }, + { "Coincident", SketchConstraintType::Coincident, { mk_point(0, 0) }, 0, -1, -1, ConstraintReject::NeedTwoEntities }, + { "Horizontal", SketchConstraintType::Horizontal, { mk_point(1, 2) }, 0, -1, -1, ConstraintReject::NeedALine }, + { "Vertical", SketchConstraintType::Vertical, { mk_circle(0, 0, 1) }, 0, -1, -1, ConstraintReject::NeedALine }, + { "Parallel", SketchConstraintType::Parallel, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Perpendicular", SketchConstraintType::Perpendicular, { mk_circle(0, 0, 1), mk_line(0, 0, 1, 0) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "EqualLength", SketchConstraintType::EqualLength, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Concentric", SketchConstraintType::Concentric, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoRounds }, + { "Tangent", SketchConstraintType::Tangent, { mk_line(0, 0, 1, 0), mk_line(0, 1, 1, 1) }, 0, 1, -1, ConstraintReject::NeedTangentPair }, + { "Midpoint", SketchConstraintType::Midpoint, { mk_line(0, 0, 1, 0), mk_line(0, 1, 1, 1) }, 0, 1, -1, ConstraintReject::NeedPointAndLine }, + { "Symmetric", SketchConstraintType::Symmetric, { mk_line(0, 0, 1, 0), mk_point(1, 1), mk_line(0, -1, 0, 1) }, 0, 1, 2, ConstraintReject::NeedTwoPointsOrLines }, + { "SymmetricAboutY", SketchConstraintType::SymmetricAboutY, { mk_line(0, 0, 1, 0), mk_point(1, 1) }, 0, 1, -1, ConstraintReject::NeedTwoPointsOrLines }, + { "SymmetricAboutX", SketchConstraintType::SymmetricAboutX, { mk_point(1, 1), mk_line(0, 0, 1, 0) }, 0, 1, -1, ConstraintReject::NeedTwoPointsOrLines }, + { "EqualRadius", SketchConstraintType::EqualRadius, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoRounds }, + { "Collinear", SketchConstraintType::Collinear, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Angle", SketchConstraintType::Angle, { mk_circle(0, 0, 1), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Radius", SketchConstraintType::Radius, { mk_line(0, 0, 1, 0) }, 0, -1, -1, ConstraintReject::NeedRound }, + { "Diameter", SketchConstraintType::Diameter, { mk_point(1, 2) }, 0, -1, -1, ConstraintReject::NeedRound }, + { "DistanceX", SketchConstraintType::DistanceX, { mk_point(0, 0) }, 0, -1, -1, ConstraintReject::NeedTwoEntities }, + { "DistanceY", SketchConstraintType::DistanceY, { mk_point(0, 0) }, 0, -1, -1, ConstraintReject::NeedTwoEntities }, + }; + for (const C& c : cases) { + DYNAMIC_SECTION("reject " << c.name) { + ConstraintPlan p = plan_entity_constraint(c.ents, c.e0, c.e1, c.e2, c.type); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == c.reason); + } + } +} + +TEST_CASE("constraint planner rejects types with no entity binding", "[SketchConstraints]") +{ + const SketchConstraintType unsupported[] = { + SketchConstraintType::Distance, SketchConstraintType::LockX, SketchConstraintType::LockY, + SketchConstraintType::PointOnLine, SketchConstraintType::PointOnObject, + }; + std::vector ents = { mk_point(0, 0), mk_point(1, 1) }; + for (SketchConstraintType t : unsupported) { + DYNAMIC_SECTION("unsupported " << int(t)) { + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, t); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::Unsupported); + } + } +} diff --git a/tests/libslic3r/test_sketchedit.cpp b/tests/libslic3r/test_sketchedit.cpp new file mode 100644 index 0000000000..634e743b99 --- /dev/null +++ b/tests/libslic3r/test_sketchedit.cpp @@ -0,0 +1,730 @@ +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +#include "libslic3r/CAD/SketchEngine.hpp" +#include +#include +#include +#include + +using namespace Slic3r; + +using Catch::Matchers::WithinAbs; + +// CONTRACT: mirror_entities hands the reflected half back REVERSED — the order of the entities +// and the direction of each — because a reflection reverses orientation and the result has to +// CONTINUE the chain it was made from. So a mirrored line's p0 is the reflection of the source's +// p1, not its p0. See [SketchProfile] "a mirrored half continues the original chain". +TEST_CASE("Mirror Line across Y axis (reversed: p0 is the reflection of the source p1)", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(3, 2); + e.p1 = Vec2d(5, 4); + + Vec2d a(0, -1); + Vec2d b(0, 1); + + auto result = SketchEngine::mirror_entities({e}, a, b); + REQUIRE(result.size() == 1); + + const auto& m = result[0]; + REQUIRE(m.type == SketchEntity::Type::Line); + REQUIRE_THAT(m.p0.x(), WithinAbs(-5.0, 1e-9)); // reflection of the SOURCE p1 + REQUIRE_THAT(m.p0.y(), WithinAbs(4.0, 1e-9)); + REQUIRE_THAT(m.p1.x(), WithinAbs(-3.0, 1e-9)); // reflection of the SOURCE p0 + REQUIRE_THAT(m.p1.y(), WithinAbs(2.0, 1e-9)); +} + +TEST_CASE("Mirror Circle across Y axis", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = Vec2d(5, 0); + e.p0 = Vec2d(5, 0); + e.radius = 3; + + Vec2d a(0, -1); + Vec2d b(0, 1); + + auto result = SketchEngine::mirror_entities({e}, a, b); + REQUIRE(result.size() == 1); + + const auto& m = result[0]; + REQUIRE(m.type == SketchEntity::Type::Circle); + REQUIRE_THAT(m.center.x(), WithinAbs(-5.0, 1e-9)); + REQUIRE_THAT(m.center.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(m.radius, WithinAbs(3.0, 1e-9)); + REQUIRE_THAT(m.p0.x(), WithinAbs(-5.0, 1e-9)); + REQUIRE_THAT(m.p0.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Mirror Arc across X axis", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 1.0; + e.start_angle = 0.0; + e.end_angle = M_PI / 2.0; + e.p0 = Vec2d(1, 0); + e.p1 = Vec2d(0, 1); + + Vec2d a(-1, 0); + Vec2d b(1, 0); + + auto result = SketchEngine::mirror_entities({e}, a, b); + REQUIRE(result.size() == 1); + + const auto& m = result[0]; + REQUIRE(m.type == SketchEntity::Type::Arc); + + // Reversed with the rest of the half: the mirrored arc STARTS where the reflection of the + // source's end is, and finishes at the reflection of its start. + REQUIRE_THAT(m.p0.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(m.p0.y(), WithinAbs(-1.0, 1e-9)); + REQUIRE_THAT(m.p1.x(), WithinAbs(1.0, 1e-9)); + REQUIRE_THAT(m.p1.y(), WithinAbs(0.0, 1e-9)); + + // The reflection alone would negate the sweep; walking the arc the other way negates it + // again, so a mirrored CCW arc is CCW once more and a mirrored CCW loop stays CCW. + double sweep = m.end_angle - m.start_angle; + double orig_sweep = e.end_angle - e.start_angle; + REQUIRE(orig_sweep > 0.0); + REQUIRE(sweep > 0.0); +} + +TEST_CASE("Offset Line by positive d", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(0, 0); + e.p1 = Vec2d(10, 0); + + auto result = SketchEngine::offset_entities({e}, 2.0); + REQUIRE(result.size() == 1); + + const auto& o = result[0]; + REQUIRE(o.type == SketchEntity::Type::Line); + REQUIRE_THAT(o.p0.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(o.p0.y(), WithinAbs(2.0, 1e-9)); + REQUIRE_THAT(o.p1.x(), WithinAbs(10.0, 1e-9)); + REQUIRE_THAT(o.p1.y(), WithinAbs(2.0, 1e-9)); +} + +TEST_CASE("Offset Circle: expand and collapse", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = Vec2d(0, 0); + e.p0 = Vec2d(0, 0); + e.radius = 5; + + auto expanded = SketchEngine::offset_entities({e}, 2.0); + REQUIRE(expanded.size() == 1); + REQUIRE_THAT(expanded[0].radius, WithinAbs(7.0, 1e-9)); + + auto collapsed = SketchEngine::offset_entities({e}, -5.0); + REQUIRE(collapsed.empty()); +} + +// CONTRACT CHANGED: +d used to mean "radius + d" for every arc regardless of its sweep, while +// for a line it meant "left of the direction of travel". The two disagreed, so a profile made +// of lines AND arcs (any slot outline) offset with its straights going one way and its caps the +// other, and could never come back closed. The arc now follows the line's rule: +d is left of +// travel, which for this CCW quarter-arc is inward -> r = 3. See [SketchProfile]. +TEST_CASE("Offset Arc by positive d (left of travel: a CCW arc shrinks)", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 4.0; + e.start_angle = 0.0; + e.end_angle = M_PI / 2.0; + e.p0 = Vec2d(4, 0); + e.p1 = Vec2d(0, 4); + + auto result = SketchEngine::offset_entities({e}, 1.0); + REQUIRE(result.size() == 1); + + const auto& o = result[0]; + REQUIRE(o.type == SketchEntity::Type::Arc); + REQUIRE_THAT(o.radius, WithinAbs(3.0, 1e-9)); + REQUIRE_THAT(o.p0.x(), WithinAbs(3.0, 1e-9)); + REQUIRE_THAT(o.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(o.p1.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(o.p1.y(), WithinAbs(3.0, 1e-9)); +} + +TEST_CASE("Fillet right-angle corner", "[SketchEdit]") +{ + SketchEntity a; + a.type = SketchEntity::Type::Line; + a.p0 = Vec2d(0, 0); + a.p1 = Vec2d(10, 0); + + SketchEntity b; + b.type = SketchEntity::Type::Line; + b.p0 = Vec2d(10, 0); + b.p1 = Vec2d(10, 10); + + SketchEntity a_out, b_out, arc_out; + bool ok = SketchEngine::fillet_lines(a, b, 2.0, a_out, b_out, arc_out); + REQUIRE(ok); + + REQUIRE_THAT(a_out.p0.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(a_out.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(a_out.p1.x(), WithinAbs(8.0, 1e-9)); + REQUIRE_THAT(a_out.p1.y(), WithinAbs(0.0, 1e-9)); + + REQUIRE_THAT(b_out.p0.x(), WithinAbs(10.0, 1e-9)); + REQUIRE_THAT(b_out.p0.y(), WithinAbs(2.0, 1e-9)); + REQUIRE_THAT(b_out.p1.x(), WithinAbs(10.0, 1e-9)); + REQUIRE_THAT(b_out.p1.y(), WithinAbs(10.0, 1e-9)); + + REQUIRE(arc_out.type == SketchEntity::Type::Arc); + REQUIRE_THAT(arc_out.radius, WithinAbs(2.0, 1e-9)); + REQUIRE_THAT(arc_out.center.x(), WithinAbs(8.0, 1e-9)); + REQUIRE_THAT(arc_out.center.y(), WithinAbs(2.0, 1e-9)); + REQUIRE_THAT((arc_out.p0 - arc_out.center).norm(), WithinAbs(2.0, 1e-9)); + REQUIRE_THAT((arc_out.p1 - arc_out.center).norm(), WithinAbs(2.0, 1e-9)); +} + +TEST_CASE("Fillet parallel lines returns false", "[SketchEdit]") +{ + SketchEntity a; + a.type = SketchEntity::Type::Line; + a.p0 = Vec2d(0, 0); + a.p1 = Vec2d(10, 0); + + SketchEntity b; + b.type = SketchEntity::Type::Line; + b.p0 = Vec2d(0, 5); + b.p1 = Vec2d(10, 5); + + SketchEntity a_out, b_out, arc_out; + REQUIRE_FALSE(SketchEngine::fillet_lines(a, b, 1.0, a_out, b_out, arc_out)); +} + +TEST_CASE("Fillet arc too big returns false", "[SketchEdit]") +{ + SketchEntity a; + a.type = SketchEntity::Type::Line; + a.p0 = Vec2d(0, 0); + a.p1 = Vec2d(1, 0); + + SketchEntity b; + b.type = SketchEntity::Type::Line; + b.p0 = Vec2d(1, 0); + b.p1 = Vec2d(1, 1); + + SketchEntity a_out, b_out, arc_out; + REQUIRE_FALSE(SketchEngine::fillet_lines(a, b, 5.0, a_out, b_out, arc_out)); +} + +TEST_CASE("Trim right arm", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(-5, 0); + e.p1 = Vec2d(5, 0); + + SketchEntity vc; + vc.type = SketchEntity::Type::Line; + vc.p0 = Vec2d(0, -5); + vc.p1 = Vec2d(0, 5); + + bool ok = SketchEngine::trim_entity(e, {vc}, Vec2d(3, 0)); + REQUIRE(ok); + REQUIRE_THAT(e.p0.x(), WithinAbs(-5.0, 1e-9)); + REQUIRE_THAT(e.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p1.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Trim left arm", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(-5, 0); + e.p1 = Vec2d(5, 0); + + SketchEntity vc; + vc.type = SketchEntity::Type::Line; + vc.p0 = Vec2d(0, -5); + vc.p1 = Vec2d(0, 5); + + bool ok = SketchEngine::trim_entity(e, {vc}, Vec2d(-3, 0)); + REQUIRE(ok); + REQUIRE_THAT(e.p0.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p1.x(), WithinAbs(5.0, 1e-9)); + REQUIRE_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Trim no cut (u out of range)", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(-5, 0); + e.p1 = Vec2d(5, 0); + + SketchEntity other; + other.type = SketchEntity::Type::Line; + other.p0 = Vec2d(0, 3); + other.p1 = Vec2d(0, 8); + + REQUIRE_FALSE(SketchEngine::trim_entity(e, {other}, Vec2d(3, 0))); +} + +TEST_CASE("Extend forward to line", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(0, 0); + e.p1 = Vec2d(2, 0); + + SketchEntity other; + other.type = SketchEntity::Type::Line; + other.p0 = Vec2d(5, -5); + other.p1 = Vec2d(5, 5); + + bool ok = SketchEngine::extend_entity(e, {other}, Vec2d(2, 0)); + REQUIRE(ok); + REQUIRE_THAT(e.p0.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p1.x(), WithinAbs(5.0, 1e-9)); + REQUIRE_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Extend forward to circle", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(0, 0); + e.p1 = Vec2d(2, 0); + + SketchEntity other; + other.type = SketchEntity::Type::Circle; + other.center = Vec2d(10, 0); + other.p0 = Vec2d(10, 0); + other.radius = 3; + + bool ok = SketchEngine::extend_entity(e, {other}, Vec2d(2, 0)); + REQUIRE(ok); + REQUIRE_THAT(e.p0.x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p1.x(), WithinAbs(7.0, 1e-9)); + REQUIRE_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Extend backward", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(0, 0); + e.p1 = Vec2d(2, 0); + + SketchEntity other; + other.type = SketchEntity::Type::Line; + other.p0 = Vec2d(-3, -5); + other.p1 = Vec2d(-3, 5); + + bool ok = SketchEngine::extend_entity(e, {other}, Vec2d(0, 0)); + REQUIRE(ok); + REQUIRE_THAT(e.p0.x(), WithinAbs(-3.0, 1e-9)); + REQUIRE_THAT(e.p0.y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.p1.x(), WithinAbs(2.0, 1e-9)); + REQUIRE_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Extend no target", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(0, 0); + e.p1 = Vec2d(2, 0); + + SketchEntity other; + other.type = SketchEntity::Type::Line; + other.p0 = Vec2d(5, -5); + other.p1 = Vec2d(5, -1); + + REQUIRE_FALSE(SketchEngine::extend_entity(e, {other}, Vec2d(2, 0))); +} + +// --- Arc/Circle subject trim & extend (Fase 4.5 kernel) ------------------- + +TEST_CASE("Trim arc drops the picked (start) side", "[SketchEdit]") +{ + // Upper semicircle r=5, ccw from (5,0) to (-5,0); cutter = vertical axis. + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 5; + e.start_angle = 0.0; + e.end_angle = M_PI; + + SketchEntity cut; + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(0, -10); + cut.p1 = Vec2d(0, 10); + + // Pick the right quarter (phi=pi/4) -> it is removed, left quarter kept. + bool ok = SketchEngine::trim_entity(e, {cut}, Vec2d(5 * std::cos(M_PI/4), 5 * std::sin(M_PI/4))); + REQUIRE(ok); + REQUIRE(e.type == SketchEntity::Type::Arc); + REQUIRE_THAT(e.radius, WithinAbs(5.0, 1e-9)); + REQUIRE_THAT(e.start_angle, WithinAbs(M_PI / 2.0, 1e-9)); + REQUIRE_THAT(e.end_angle, WithinAbs(M_PI, 1e-9)); +} + +TEST_CASE("Trim arc drops the picked (end) side", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 5; + e.start_angle = 0.0; + e.end_angle = M_PI; + + SketchEntity cut; + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(0, -10); + cut.p1 = Vec2d(0, 10); + + // Pick the left quarter (phi=3pi/4) -> removed, right quarter kept. + bool ok = SketchEngine::trim_entity(e, {cut}, Vec2d(5 * std::cos(3*M_PI/4), 5 * std::sin(3*M_PI/4))); + REQUIRE(ok); + REQUIRE(e.type == SketchEntity::Type::Arc); + REQUIRE_THAT(e.start_angle, WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.end_angle, WithinAbs(M_PI / 2.0, 1e-9)); +} + +TEST_CASE("Trim circle opens into an arc excluding the pick", "[SketchEdit]") +{ + // Full circle r=5; vertical axis cuts it at (0,+-5). Pick the right side + // (5,0): the kept arc is the left half, sweeping pi and centred on (-5,0). + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = Vec2d(0, 0); + e.p0 = Vec2d(5, 0); + e.radius = 5; + + SketchEntity cut; + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(0, -10); + cut.p1 = Vec2d(0, 10); + + bool ok = SketchEngine::trim_entity(e, {cut}, Vec2d(5, 0)); + REQUIRE(ok); + REQUIRE(e.type == SketchEntity::Type::Arc); + REQUIRE_THAT(e.radius, WithinAbs(5.0, 1e-9)); + REQUIRE_THAT(e.end_angle - e.start_angle, WithinAbs(M_PI, 1e-9)); + // Midpoint of the kept arc must point left (away from the pick). + double mid = 0.5 * (e.start_angle + e.end_angle); + REQUIRE_THAT(5 * std::cos(mid), WithinAbs(-5.0, 1e-9)); + REQUIRE_THAT(5 * std::sin(mid), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Extend arc forward (end) to a crossing", "[SketchEdit]") +{ + // Quarter arc (5,0)->(0,5); cutter crosses the circle at (-5,0). Picking + // near the end grows the sweep ccw to pi. + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 5; + e.start_angle = 0.0; + e.end_angle = M_PI / 2.0; + + SketchEntity cut; + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(-10, 0); + cut.p1 = Vec2d(0, 0); + + bool ok = SketchEngine::extend_entity(e, {cut}, Vec2d(0, 5)); + REQUIRE(ok); + REQUIRE(e.type == SketchEntity::Type::Arc); + REQUIRE_THAT(e.start_angle, WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.end_angle, WithinAbs(M_PI, 1e-9)); +} + +TEST_CASE("Extend arc backward (start) to a crossing", "[SketchEdit]") +{ + // Quarter arc (0,5)->(-5,0); cutter crosses at (5,0). Picking near the + // start grows the sweep cw to start_angle 0. + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 5; + e.start_angle = M_PI / 2.0; + e.end_angle = M_PI; + + SketchEntity cut; + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(10, 0); + cut.p1 = Vec2d(0, 0); + + bool ok = SketchEngine::extend_entity(e, {cut}, Vec2d(0, 5)); + REQUIRE(ok); + REQUIRE_THAT(e.start_angle, WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(e.end_angle, WithinAbs(M_PI, 1e-9)); +} + +TEST_CASE("Extend circle returns false (closed)", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = Vec2d(0, 0); + e.p0 = Vec2d(5, 0); + e.radius = 5; + + SketchEntity cut; + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(0, -10); + cut.p1 = Vec2d(0, 10); + + REQUIRE_FALSE(SketchEngine::extend_entity(e, {cut}, Vec2d(5, 0))); +} + +TEST_CASE("Trim arc with no crossing returns false", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(0, 0); + e.radius = 5; + e.start_angle = 0.0; + e.end_angle = M_PI / 2.0; + + SketchEntity cut; // far away, never reaches the r=5 circle + cut.type = SketchEntity::Type::Line; + cut.p0 = Vec2d(20, -5); + cut.p1 = Vec2d(20, 5); + + REQUIRE_FALSE(SketchEngine::trim_entity(e, {cut}, Vec2d(5 * std::cos(M_PI/4), 5 * std::sin(M_PI/4)))); +} + +// Regression guard: BEFORE the weld fix this test failed with 4 edges instead of 6. +// BRepLib_MakeWire::Add silently DROPS a disconnected edge (BRepLib_DisconnectedWire + NotDone) +// yet every successful Add ends with BRepLib_WireDone + Done(), so IsDone() reported only whether +// the LAST edge connected. This sketch is a real user loop (2 arcs + 4 lines) given in +// creation order, which is NOT traversal order, and its joint between the 3rd and 4th entity +// below is open by 2.28e-5 mm — larger than OCCT's default vertex tolerance. +TEST_CASE("entities_to_wires keeps every edge of a loop drawn out of order", "[SketchEngine]") +{ + std::vector ents(6); + + ents[0].type = SketchEntity::Type::Line; + ents[0].p0 = Vec2d(-0.537697713190522, -0.0009077462579133498); + ents[0].p1 = Vec2d(99.46230228680926, -0.0009141694814321626); + + ents[1].type = SketchEntity::Type::Arc; + ents[1].p0 = Vec2d(-0.537697713190522, -0.0009077462579133498); + ents[1].p1 = Vec2d(-100.14602636660666, -0.27673132181233495); + ents[1].center = Vec2d(-50.313868583115394, -10.248112903179617); + ents[1].radius = 50.81999999999999; + ents[1].start_angle = 0.20302922018398933; + ents[1].end_angle = 2.944101582158999; + + ents[2].type = SketchEntity::Type::Line; + ents[2].p0 = Vec2d(99.46228668626469, -39.22091416947833); + ents[2].p1 = Vec2d(-0.537864366432629, -39.22420589376945); + + ents[3].type = SketchEntity::Type::Arc; + ents[3].p0 = Vec2d(-100.14602636694521, -38.94673132181234); + ents[3].p1 = Vec2d(-0.5378420354900413, -39.22421049164698); + ents[3].center = Vec2d(-50.31377078666179, -28.975499083790503); + ents[3].radius = 50.82006659345552; + ents[3].start_angle = -2.944104841286737; + ents[3].end_angle = -0.20305921095748136; + + ents[4].type = SketchEntity::Type::Line; + ents[4].p0 = Vec2d(99.46228668626469, -39.22091416947833); + ents[4].p1 = Vec2d(99.46230228680926, -0.0009141694814321626); + + ents[5].type = SketchEntity::Type::Line; + ents[5].p0 = Vec2d(-100.14602636694521, -38.94673132181234); + ents[5].p1 = Vec2d(-100.14602636660666, -0.27673132181233495); + + auto wires = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + + REQUIRE(wires.size() == 1); + + int edge_count = 0; + for (TopExp_Explorer ex(wires[0], TopAbs_EDGE); ex.More(); ex.Next()) + ++edge_count; + REQUIRE(edge_count == 6); + + REQUIRE(wires[0].Closed()); +} + +// Regression guard: this fails at 1e-4 (the wire builder refuses a joint the viewport had +// already shaded closed) and passes at kSketchJoinTol. A 20x10 quad with one joint left open +// by 9e-4 mm — just inside kSketchJoinTol, exactly the case the viewport shades closed — given +// in an order that is NOT traversal order, so the ordering path is covered too. +TEST_CASE("a loop the viewport shades closed is buildable by the kernel", "[SketchEngine]") +{ + std::vector ents(4); + + // (0,0) -> (20,0) -> (20,10) -> (0,10) -> (0.0009, 0): last endpoint misses (0,0) by 9e-4. + ents[0].type = SketchEntity::Type::Line; + ents[0].p0 = Vec2d(0, 0); + ents[0].p1 = Vec2d(20, 0); + + // Index 1 is the FAR side, not the neighbour of index 0: creation order here is + // deliberately not traversal order, so a partial wire would reject it without the + // traversal walk. + ents[1].type = SketchEntity::Type::Line; + ents[1].p0 = Vec2d(20, 10); + ents[1].p1 = Vec2d(0, 10); + + ents[2].type = SketchEntity::Type::Line; + ents[2].p0 = Vec2d(20, 0); + ents[2].p1 = Vec2d(20, 10); + + ents[3].type = SketchEntity::Type::Line; + ents[3].p0 = Vec2d(0, 10); + ents[3].p1 = Vec2d(0.0009, 0); + + auto wires = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + + REQUIRE(wires.size() == 1); + + int edge_count = 0; + for (TopExp_Explorer ex(wires[0], TopAbs_EDGE); ex.More(); ex.Next()) + ++edge_count; + REQUIRE(edge_count == 4); + + REQUIRE(wires[0].Closed()); +} + +// Regression guard for the auto-close preference. Same 20x10 quad, one joint open by 9e-4 mm +// and given out of traversal order, as "a loop the viewport shades closed is buildable by the +// kernel". With auto-close ON the gap welds (one closed wire); with auto-close OFF it must not. +TEST_CASE("auto-close off makes the kernel demand an exact joint", "[SketchEngine]") +{ + std::vector ents(4); + + ents[0].type = SketchEntity::Type::Line; + ents[0].p0 = Vec2d(0, 0); + ents[0].p1 = Vec2d(20, 0); + + ents[1].type = SketchEntity::Type::Line; + ents[1].p0 = Vec2d(20, 10); + ents[1].p1 = Vec2d(0, 10); + + ents[2].type = SketchEntity::Type::Line; + ents[2].p0 = Vec2d(20, 0); + ents[2].p1 = Vec2d(20, 10); + + ents[3].type = SketchEntity::Type::Line; + ents[3].p0 = Vec2d(0, 10); + ents[3].p1 = Vec2d(0.0009, 0); + + auto edge_count = [](const TopoDS_Wire& w) { + int n = 0; + for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) ++n; + return n; + }; + + // ON: the 9e-4 mm gap is inside kSketchJoinTol, so the loop welds into one closed wire. + Slic3r::set_sketch_auto_close(true); + auto wires_on = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + REQUIRE(wires_on.size() == 1); + REQUIRE(edge_count(wires_on[0]) == 4); + REQUIRE(wires_on[0].Closed()); + + // OFF: the joint is not exact, so the gap is NOT welded. entities_to_wires legitimately + // returns open chains (a sweep path is open), so the observable is an OPEN wire — the + // kernel no longer hands back the closed loop the viewport would have shaded. + Slic3r::set_sketch_auto_close(false); + auto wires_off = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + REQUIRE(wires_off.size() == 1); + REQUIRE(edge_count(wires_off[0]) == 4); + REQUIRE_FALSE(wires_off[0].Closed()); + + // OFF + an EXACT joint (last endpoint exactly (0,0)): the quad still builds closed, + // proving "off" means exact rather than broken. + ents[3].p1 = Vec2d(0, 0); + auto wires_exact = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + REQUIRE(wires_exact.size() == 1); + REQUIRE(edge_count(wires_exact[0]) == 4); + REQUIRE(wires_exact[0].Closed()); + + // Restore the default so test order cannot leak OFF into the other cases. + Slic3r::set_sketch_auto_close(true); +} + +// A stray open segment touching nothing must not break a closed profile: the viewport +// discards open chains when it shades a region extrudable, so with closed_only the kernel +// must discard them too — otherwise Revolve/Extrude fail on a sketch that looks perfect. +TEST_CASE("a stray open segment does not break a closed profile", "[SketchEngine]") +{ + auto line = [](double x0, double y0, double x1, double y1) { + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(x0, y0); + e.p1 = Vec2d(x1, y1); + return e; + }; + + std::vector ents; + ents.push_back(line(0, 0, 20, 0)); // 20x10 quad + ents.push_back(line(20, 0, 20, 10)); + ents.push_back(line(20, 10, 0, 10)); + ents.push_back(line(0, 10, 0, 0)); + ents.push_back(line(5, 5, 6, 5.2)); // stray, touches nothing + + auto edge_count = [](const TopoDS_Wire& w) { + int n = 0; + for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) ++n; + return n; + }; + + // Unchanged behaviour: the stray line is its own open wire. + auto wires_all = SketchEngine::entities_to_wires(ents, SketchPlane::XY(), /*closed_only=*/false); + REQUIRE(wires_all.size() == 2); + + // closed_only drops the open chain: one closed quad survives. + auto wires_closed = SketchEngine::entities_to_wires(ents, SketchPlane::XY(), /*closed_only=*/true); + REQUIRE(wires_closed.size() == 1); + REQUIRE(edge_count(wires_closed[0]) == 4); + REQUIRE(wires_closed[0].Closed()); + + // The Revolve path (entities_to_wire) finds the single closed loop. + TopoDS_Wire w = SketchEngine::entities_to_wire(ents, SketchPlane::XY(), /*closed_only=*/true); + REQUIRE_FALSE(w.IsNull()); + REQUIRE(edge_count(w) == 4); +} + +// sketch_open_ends names the two free endpoints of an open chain, so the "does not form a +// single closed wire" failure can say WHERE the sketch is open. +TEST_CASE("sketch_open_ends names where a chain fails to close", "[SketchEngine]") +{ + auto line = [](double x0, double y0, double x1, double y1) { + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(x0, y0); + e.p1 = Vec2d(x1, y1); + return e; + }; + + // Open C shape: three lines, free endpoints at (0,0) and (0,10). + std::vector ents; + ents.push_back(line(0, 0, 10, 0)); + ents.push_back(line(10, 0, 10, 10)); + ents.push_back(line(10, 10, 0, 10)); + + auto got = sketch_open_ends(ents, SketchPlane::XY()); + REQUIRE(got.size() == 2); + + std::sort(got.begin(), got.end(), [](const Vec2d& a, const Vec2d& b) { + if (a.x() < b.x()) return true; + if (a.x() > b.x()) return false; + return a.y() < b.y(); + }); + + REQUIRE_THAT(got[0].x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(got[0].y(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(got[1].x(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT(got[1].y(), WithinAbs(10.0, 1e-9)); +} diff --git a/tests/libslic3r/test_sketchimport.cpp b/tests/libslic3r/test_sketchimport.cpp new file mode 100644 index 0000000000..3e3f7c906e --- /dev/null +++ b/tests/libslic3r/test_sketchimport.cpp @@ -0,0 +1,103 @@ +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) + +#include "libslic3r/CAD/SketchImport.hpp" +#include "libslic3r/Utils.hpp" // resources_dir +#include "test_utils.hpp" // ScopedTemporaryFile + +#include +#include + +using namespace Slic3r; + +// A 10x10 mm filled square, on disk because nanosvg reads from a file. The path +// must come from the system temp dir: a hardcoded /tmp is not writable on +// Windows, where the stream fails silently and the parse then sees no file. +static void write_square_svg(const std::string& path) +{ + std::ofstream f(path); + f << "" + ""; + REQUIRE(f.good()); +} + +TEST_CASE("svg_to_regions parses a filled path into a region", "[SketchImport]") +{ + ScopedTemporaryFile square(".svg"); + write_square_svg(square.string()); + + ImportRegions regs = svg_to_regions(square.string(), 1.0); + REQUIRE(regs.size() >= 1); + // Outer contour present with at least a few vertices. + REQUIRE(regs[0].size() >= 1); + REQUIRE(regs[0][0].size() >= 4); + + // Centred on the origin: bbox half-extent ~5 mm on each side. + double hi = 0.0; + for (const auto& region : regs) + for (const auto& contour : region) + for (const Vec2d& p : contour) + hi = std::max(hi, std::max(std::abs(p.x()), std::abs(p.y()))); + REQUIRE(hi > 3.0); // not collapsed + REQUIRE(hi < 8.0); // ~5 mm half-size after centring +} + +TEST_CASE("svg_to_regions rejects bad input gracefully", "[SketchImport]") +{ + ScopedTemporaryFile square(".svg"); + write_square_svg(square.string()); + ScopedTemporaryFile missing(".svg"); // name reserved, never written + + REQUIRE(svg_to_regions("", 1.0).empty()); + REQUIRE(svg_to_regions(missing.string(), 1.0).empty()); + REQUIRE(svg_to_regions(square.string(), 0.0).empty()); // scale<=0 +} + +TEST_CASE("transform_regions moves and scales independently", "[SketchImport]") +{ + ImportRegions r = {{ {Vec2d(-1,-1), Vec2d(1,-1), Vec2d(1,1), Vec2d(-1,1)} }}; + ImportRegions t = transform_regions(r, Vec2d(10, 20), 2.0, 3.0); + REQUIRE(t.size() == 1); + REQUIRE(t[0][0].size() == 4); + // (-1,-1) -> (-1*2+10, -1*3+20) = (8, 17) + REQUIRE_THAT(t[0][0][0].x(), Catch::Matchers::WithinAbs(8.0, 1e-9)); + REQUIRE_THAT(t[0][0][0].y(), Catch::Matchers::WithinAbs(17.0, 1e-9)); + // (1,1) -> (1*2+10, 1*3+20) = (12, 23) + REQUIRE_THAT(t[0][0][2].x(), Catch::Matchers::WithinAbs(12.0, 1e-9)); + REQUIRE_THAT(t[0][0][2].y(), Catch::Matchers::WithinAbs(23.0, 1e-9)); + // identity is a no-op + ImportRegions id = transform_regions(r, Vec2d(0,0), 1.0, 1.0); + REQUIRE_THAT(id[0][0][1].x(), Catch::Matchers::WithinAbs(1.0, 1e-9)); +} + +TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]") +{ + // Locate the bundled font; resources_dir() may be unset under ctest, so + // fall back to a cwd-relative path (tests run from the repo root). + std::string font = resources_dir().empty() + ? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") + : resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; + { + std::ifstream probe(font); + if (!probe.good()) { + SUCCEED("bundled font not reachable in this environment; covered live on :10"); + return; + } + } + + // Bad input is rejected without throwing. + REQUIRE(text_to_regions("", 10.0, font).empty()); + REQUIRE(text_to_regions("A", 0.0, font).empty()); + + // 'A' has one triangular counter -> a region with an outer + 1 hole. + ImportRegions a = text_to_regions("A", 12.0, font); + REQUIRE(a.size() >= 1); + bool has_hole = false; + for (const auto& region : a) + if (region.size() >= 2) has_hole = true; + REQUIRE(has_hole); + + // Two letters produce more regions than one. + ImportRegions ab = text_to_regions("AB", 12.0, font); + REQUIRE(ab.size() >= a.size()); +} diff --git a/tests/libslic3r/test_sketchinference.cpp b/tests/libslic3r/test_sketchinference.cpp new file mode 100644 index 0000000000..cbdd90628b --- /dev/null +++ b/tests/libslic3r/test_sketchinference.cpp @@ -0,0 +1,174 @@ +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +using Catch::Approx; // v3 scopes Approx into the Catch namespace; v2 had it at global scope + +#include "libslic3r/CAD/SketchInference.hpp" + +using namespace Slic3r; +using K = InferenceSnap::Kind; + +static SketchEntity line(Vec2d a, Vec2d b) +{ + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = a; e.p1 = b; return e; +} +static SketchEntity circle(Vec2d c, double r) +{ + SketchEntity e; e.type = SketchEntity::Type::Circle; e.center = c; e.p0 = c; e.radius = r; return e; +} + +TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}) }; + auto s = infer_point_snap(ents, {10.3, 0.2}, 1.0); + REQUIRE(s.kind == K::Endpoint); + CHECK(s.entity == 0); + CHECK(s.role == SketchPointRole::P1); + CHECK((s.point - Vec2d(10, 0)).norm() == Approx(0.0).margin(1e-9)); +} + +TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]") +{ + std::vector ents = { line({0, 0}, {2, 0}) }; + // Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless. + auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0); + CHECK(s.kind == K::Endpoint); + CHECK(s.role == SketchPointRole::P1); +} + +TEST_CASE("inference: midpoint of a line is detected", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}) }; + auto s = infer_point_snap(ents, {5.1, 0.1}, 0.5, /*include_origin=*/false); + REQUIRE(s.kind == K::Midpoint); + CHECK((s.point - Vec2d(5, 0)).norm() == Approx(0.0).margin(1e-9)); +} + +TEST_CASE("inference: circle centre and rim", "[inference]") +{ + std::vector ents = { circle({0, 0}, 5.0) }; + auto c = infer_point_snap(ents, {0.2, 0.1}, 1.0, false); + CHECK(c.kind == K::Center); + auto r = infer_point_snap(ents, {5.1, 0.0}, 1.0, false); + REQUIRE(r.kind == K::OnEdge); + CHECK((r.point - Vec2d(5, 0)).norm() == Approx(0.0).margin(1e-9)); +} + +TEST_CASE("inference: origin snap when nothing else is near", "[inference]") +{ + std::vector ents = { line({20, 20}, {30, 20}) }; + auto s = infer_point_snap(ents, {0.1, 0.1}, 1.0); + REQUIRE(s.kind == K::Origin); + CHECK(s.entity == -1); + CHECK((s.point - Vec2d(0, 0)).norm() == Approx(0.0).margin(1e-9)); +} + +TEST_CASE("inference: nothing in range returns None and the raw query", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}) }; + auto s = infer_point_snap(ents, {50, 50}, 1.0, /*include_origin=*/false); + CHECK(s.kind == K::None); + CHECK((s.point - Vec2d(50, 50)).norm() == Approx(0.0).margin(1e-9)); +} + +TEST_CASE("inference: axis inference flags horizontal / vertical segments", "[inference]") +{ + CHECK(infer_axis_constraint({0, 0}, {10, 0.05}).value() == SketchConstraintType::Horizontal); + CHECK(infer_axis_constraint({0, 0}, {0.05, 10}).value() == SketchConstraintType::Vertical); + CHECK_FALSE(infer_axis_constraint({0, 0}, {10, 10}).has_value()); // 45 deg + CHECK_FALSE(infer_axis_constraint({0, 0}, {0, 0}).has_value()); // degenerate +} + +TEST_CASE("inference: perpendicular inferred for a connected square corner", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({10, 0}, {10, 7}) }; + auto r = infer_relations(ents, 1); + REQUIRE(r.size() == 1); + CHECK(r[0].type == SketchConstraintType::Perpendicular); + CHECK(r[0].ea == 0); + CHECK(r[0].eb == 1); +} + +TEST_CASE("inference: parallel inferred for connected collinear-ish lines", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({10, 0}, {21, 0.1}) }; + auto r = infer_relations(ents, 1); + REQUIRE(r.size() == 1); + CHECK(r[0].type == SketchConstraintType::Parallel); + CHECK(r[0].ea == 0); + CHECK(r[0].eb == 1); +} + +TEST_CASE("inference: two unconnected parallel lines infer nothing", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({0, 5}, {10, 5}) }; + auto r = infer_relations(ents, 1); + CHECK(r.empty()); +} + +TEST_CASE("inference: a corner outside tolerance infers nothing", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({10, 0}, {15, 7}) }; + auto r = infer_relations(ents, 1); + CHECK(r.empty()); +} + +TEST_CASE("inference: equal radius inferred for near-equal circles", "[inference]") +{ + auto r = infer_relations({ circle({0, 0}, 5.0), circle({30, 0}, 5.02) }, 1); + REQUIRE(r.size() == 1); + CHECK(r[0].type == SketchConstraintType::EqualRadius); + CHECK(r[0].ea == 0); + CHECK(r[0].eb == 1); + + auto r2 = infer_relations({ circle({0, 0}, 5.0), circle({30, 0}, 6.0) }, 1); + CHECK(r2.empty()); +} + +TEST_CASE("inference: tangent inferred for a line meeting a circle tangentially", "[inference]") +{ + std::vector ents = { circle({0, 0}, 5.0), line({0, 5}, {10, 5}) }; + auto r = infer_relations(ents, 1); + REQUIRE(r.size() == 1); + CHECK(r[0].type == SketchConstraintType::Tangent); + CHECK(r[0].ea == 0); + CHECK(r[0].eb == 1); + + std::vector off = { circle({0, 0}, 5.0), line({0, 5}, {10, 9}) }; + CHECK(infer_relations(off, 1).empty()); +} + +TEST_CASE("inference: nothing inferred against a higher index", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({10, 0}, {10, 7}) }; + auto r = infer_relations(ents, 0); + CHECK(r.empty()); +} + +TEST_CASE("inference: degenerate entities are ignored", "[inference]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({10, 0}, {10, 0}) }; + auto r = infer_relations(ents, 1); + CHECK(r.empty()); +} + +// The cap that keeps infer_relations linear rather than quadratic. Without it a drawing with +// many equal holes yields a constraint per PAIR: 200 equal circles produced ~20000 candidates, +// the batch was rejected as over-constrained, and the caller's one-at-a-time fallback then ran +// a solve per constraint -- which pinned the app at 95% of a core with the MCP socket +// unresponsive, and is what the corpus rung caught. +TEST_CASE("inference: at most one relation per rule per new entity", "[inference]") +{ + // 40 circles of the same radius; the 41st must not produce 40 EqualRadius constraints. + std::vector ents; + for (int i = 0; i < 41; ++i) { + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(i * 20.0, 0.0); + c.p0 = c.center; + c.radius = 5.0; + ents.push_back(c); + } + auto rels = infer_relations(ents, 40); + CHECK(rels.size() == 1); + CHECK(rels[0].type == SketchConstraintType::EqualRadius); + CHECK(rels[0].eb == 40); +} diff --git a/tests/libslic3r/test_sketchprofile.cpp b/tests/libslic3r/test_sketchprofile.cpp new file mode 100644 index 0000000000..7f455eb936 --- /dev/null +++ b/tests/libslic3r/test_sketchprofile.cpp @@ -0,0 +1,209 @@ +// Closed-profile harness for the 2D sketch layer. +// +// The existing [SketchEdit] cases check one entity at a time — offset ONE line, mirror ONE +// arc — and every one of them passes while the feature they belong to is unusable. What a +// user actually does is combine 2D features into a CLOSED PROFILE and extrude it, and the +// property that makes that work is topological, not per-entity: after the operation, do the +// pieces still form a single closed loop? +// +// So these cases assert the loop, not the coordinates. That is the invariant every sketch +// operation has to preserve and the only one that predicts whether the GUI can build a solid +// out of the result. +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +#include "libslic3r/CAD/SketchEngine.hpp" +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +namespace { + +SketchPlane xy_plane() { return SketchPlane::XY(); } + +SketchEntity line(const Vec2d& a, const Vec2d& b) +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = a; e.p1 = b; + return e; +} + +// A CCW rectangle as four Line entities sharing endpoints exactly. +std::vector rect(double w, double h) +{ + return { line({0, 0}, {w, 0}), line({w, 0}, {w, h}), + line({w, h}, {0, h}), line({0, h}, {0, 0}) }; +} + +// How many of the wires the sketch resolves to are CLOSED. +int closed_wires(const std::vector& ents) +{ + const auto ws = SketchEngine::entities_to_wires(ents, xy_plane()); + int n = 0; + for (const auto& w : ws) + if (!w.IsNull() && w.Closed()) ++n; + return n; +} + +// Enclosed area of the single closed loop the sketch resolves to. -1 when it is not one +// closed loop — the failure the whole file exists to catch. +double profile_area(const std::vector& ents) +{ + const auto ws = SketchEngine::entities_to_wires(ents, xy_plane()); + if (ws.size() != 1 || ws[0].IsNull() || !ws[0].Closed()) return -1.0; + const TopoDS_Face f = SketchEngine::wires_to_face(ws, xy_plane()); + GProp_GProps props; + BRepGProp::SurfaceProperties(f, props); + return props.Mass(); +} + +SketchEntity arc(const Vec2d& c, double r, double a0, double a1) +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = c; + e.radius = r; + e.start_angle = a0; + e.end_angle = a1; + e.p0 = c + r * Vec2d(std::cos(a0), std::sin(a0)); + e.p1 = c + r * Vec2d(std::cos(a1), std::sin(a1)); + return e; +} + +} // namespace + +TEST_CASE("profile baseline: a hand-built rectangle is one closed loop", "[SketchProfile]") +{ + REQUIRE(closed_wires(rect(40, 20)) == 1); +} + +TEST_CASE("profile: mirroring a closed rectangle keeps it closed", "[SketchProfile]") +{ + const auto m = SketchEngine::mirror_entities(rect(40, 20), Vec2d(-10, 0), Vec2d(-10, 1)); + REQUIRE(m.size() == 4); + REQUIRE(closed_wires(m) == 1); +} + +TEST_CASE("profile: mirroring an open half-profile closes it against the axis", "[SketchProfile]") +{ + // Half a rectangle, open along x=0 — the classic "draw half, mirror it" gesture. + const std::vector half = { + line({0, 0}, {20, 0}), line({20, 0}, {20, 10}), line({20, 10}, {0, 10}) }; + auto all = half; + for (const auto& e : SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1))) + all.push_back(e); + REQUIRE(all.size() == 6); + REQUIRE(closed_wires(all) == 1); +} + +TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProfile]") +{ + const auto out = SketchEngine::offset_entities(rect(40, 20), 5.0); + REQUIRE(out.size() == 4); + REQUIRE(closed_wires(out) == 1); +} + +TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]") +{ + // A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d, + // so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except + // for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible; + // a set of four disconnected segments is not, and that is what this measures. + const double w = 40, h = 20, d = 5; + const auto out = SketchEngine::offset_entities(rect(w, h), d); + const auto ws = SketchEngine::entities_to_wires(out, xy_plane()); + REQUIRE(ws.size() == 1); + REQUIRE(ws[0].Closed()); +} + +TEST_CASE("profile: offset sign is left-of-travel, so +d shrinks a CCW rectangle", "[SketchProfile]") +{ + // The convention has to be pinned by a test, because it is the one thing a caller cannot + // read off the geometry: +d = left of the direction of travel = inward for a CCW loop. + // Miter join on a rectangle keeps the corners sharp, so the result is exact. + const double w = 40, h = 20, d = 5; + REQUIRE_THAT(profile_area(SketchEngine::offset_entities(rect(w, h), d)), + WithinAbs((w - 2 * d) * (h - 2 * d), 1e-6)); + REQUIRE_THAT(profile_area(SketchEngine::offset_entities(rect(w, h), -d)), + WithinAbs((w + 2 * d) * (h + 2 * d), 1e-6)); +} + +TEST_CASE("profile: offsetting a stadium (two lines + two arcs) stays closed", "[SketchProfile]") +{ + // A slot outline: straight top and bottom joined by half-circle caps. This is the case the + // per-entity offset could never repair, because both seams are line-to-arc. + const double L = 30, r = 8, d = 3; + const std::vector slot = { + line({0, -r}, {L, -r}), + arc({L, 0}, r, -M_PI / 2, M_PI / 2), + line({L, r}, {0, r}), + arc({0, 0}, r, M_PI / 2, 3 * M_PI / 2), + }; + REQUIRE(closed_wires(slot) == 1); + const auto out = SketchEngine::offset_entities(slot, -d); // -d = outward for this CCW loop + REQUIRE(closed_wires(out) == 1); + // Offsetting a stadium outward by d gives the stadium with radius r+d: L*2(r+d) + pi(r+d)^2. + // Lines and caps must move the SAME way — that is the assertion this case exists for. + const double rr = r + d; + REQUIRE_THAT(profile_area(out), WithinAbs(L * 2 * rr + M_PI * rr * rr, 1e-6)); +} + +TEST_CASE("profile: an open chain offsets without being forced closed", "[SketchProfile]") +{ + // A sweep path is legitimately open; the repair must join its interior seams and leave + // the two free ends alone. + const std::vector open_chain = { + line({0, 0}, {20, 0}), line({20, 0}, {20, 10}) }; + const auto out = SketchEngine::offset_entities(open_chain, 4.0); + REQUIRE(out.size() == 2); + REQUIRE(closed_wires(out) == 0); + // The interior seam is repaired: the two offset segments still meet. + REQUIRE_THAT((out[0].p1 - out[1].p0).norm(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("profile: a mirrored half offsets as one loop, not two", "[SketchProfile]") +{ + // The classic "draw half, mirror it" gesture on a stadium. mirror_entities emits a half + // that travels the opposite way round, so the concatenation must still chain as ONE closed + // loop and its mirrored cap must offset outward like the original, not inward. + const double L = 30, R = 15, d = 4; + const std::vector half = { + line({0, -R}, {L, -R}), + arc({L, 0}, R, -M_PI / 2, M_PI / 2), + line({L, R}, {0, R}), + }; + std::vector all = half; + for (const auto& e : SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1))) + all.push_back(e); + REQUIRE(closed_wires(all) == 1); + + const auto out = SketchEngine::offset_entities(all, -d); // -d = outward for this loop + REQUIRE(closed_wires(out) == 1); + for (const auto& o : out) + if (o.type == SketchEntity::Type::Arc) + REQUIRE_THAT(o.radius, WithinAbs(R + d, 1e-9)); +} + +TEST_CASE("profile: a mirrored half continues the original chain", "[SketchProfile]") +{ + // The point of emitting the reflected half reversed: appending it to the source must give a + // chain you can WALK, head-to-tail, with no consumer having to notice that half of it came + // from a mirror. The end of the last source entity must be the start of the first mirrored + // one, and the end of the last mirrored one must close back to the very first start. + const std::vector half = { + line({0, -15}, {50, -15}), line({50, -15}, {50, 15}), line({50, 15}, {0, 15}) }; + const auto m = SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1)); + REQUIRE(m.size() == 3); + + REQUIRE_THAT((half.back().p1 - m.front().p0).norm(), WithinAbs(0.0, 1e-9)); + REQUIRE_THAT((m.back().p1 - half.front().p0).norm(), WithinAbs(0.0, 1e-9)); + + for (size_t i = 0; i + 1 < m.size(); ++i) + REQUIRE_THAT((m[i].p1 - m[i + 1].p0).norm(), WithinAbs(0.0, 1e-9)); + + auto all = half; + for (const auto& e : m) all.push_back(e); + REQUIRE(closed_wires(all) == 1); +} diff --git a/tests/libslic3r/test_slvs_constraints.cpp b/tests/libslic3r/test_slvs_constraints.cpp new file mode 100644 index 0000000000..6dfac07348 --- /dev/null +++ b/tests/libslic3r/test_slvs_constraints.cpp @@ -0,0 +1,401 @@ +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +using Catch::Approx; // v3 scopes Approx into the Catch namespace; v2 had it at global scope + +#include "libslic3r/CAD/SketchSolver.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" + +using namespace Slic3r; +using CT = SketchConstraintType; +using R = SketchPointRole; + +static SketchEntity line(Vec2d a, Vec2d b) +{ + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = a; e.p1 = b; return e; +} +static SketchEntity circle(Vec2d c, double r) +{ + SketchEntity e; e.type = SketchEntity::Type::Circle; e.center = c; e.p0 = c; e.radius = r; return e; +} +static SketchEntityConstraintDef con(CT t, int ea, R ra, int eb, R rb, double v = 0.0) +{ + SketchEntityConstraintDef c; c.type = t; c.ea = ea; c.ra = ra; c.eb = eb; c.rb = rb; c.value = v; return c; +} + +TEST_CASE("slvs: distance + horizontal + fix solves a line length", "[slvs]") +{ + std::vector ents = { line({0, 0}, {5, 1}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Horizontal, 0, R::P0, 0, R::P1), + con(CT::Distance, 0, R::P0, 0, R::P1, 10.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(10.0).margin(1e-6)); + CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-6)); + CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-6)); + CHECK(ents[0].p1.y() == Approx(0.0).margin(1e-6)); // horizontal +} + +TEST_CASE("slvs: coincident joins two line endpoints (loop closes)", "[slvs]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({10.3, 0.2}, {10, 10}) }; + std::vector cons = { + con(CT::Coincident, 0, R::P1, 1, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK((ents[0].p1 - ents[1].p0).norm() == Approx(0.0).margin(1e-6)); +} + +TEST_CASE("slvs: parallel + perpendicular on lines", "[slvs]") +{ + std::vector ents = { line({0, 0}, {10, 1}), line({0, 5}, {10, 5.5}), line({0, 0}, {0.5, 10}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Horizontal, 0, R::P0, 0, R::P1), + con(CT::Parallel, 0, R::P0, 1, R::P0), // line1 parallel to line0 + con(CT::Perpendicular, 0, R::P0, 2, R::P0), // line2 perpendicular to line0 + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[1].p1.y() - ents[1].p0.y() == Approx(0.0).margin(1e-6)); // line1 horizontal + CHECK(ents[2].p1.x() - ents[2].p0.x() == Approx(0.0).margin(1e-6)); // line2 vertical +} + +TEST_CASE("slvs: circle radius constraint", "[slvs]") +{ + std::vector ents = { circle({2, 2}, 3.0) }; + std::vector cons = { con(CT::Radius, 0, R::P0, -1, R::P0, 7.0) }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].radius == Approx(7.0).margin(1e-6)); +} + +TEST_CASE("slvs: degrees of freedom reported", "[slvs]") +{ + // One free line with only a Fix on the start: 4 DoF total minus 2 (fix) = 2 remaining. + std::vector ents = { line({0, 0}, {3, 4}) }; + std::vector cons = { con(CT::Fix, 0, R::P0, 0, R::P0) }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(res.dof == 2); +} + +TEST_CASE("slvs: drag pulls a point while constraints hold", "[slvs]") +{ + // A vertical line of fixed length 10, P0 pinned at the origin. Dragging P1 toward + // (10,0) must keep the length (Distance constraint) but rotate the line so the end + // follows the cursor into positive x — the dragged param wins the under-constrained DoF. + std::vector ents = { line({0, 0}, {0, 10}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Distance, 0, R::P0, 0, R::P1, 10.0), + }; + ents[0].p1 = Vec2d(10, 0); // user dropped the endpoint here + auto res = sketch_solve_drag(ents, cons, 0, R::P1); + REQUIRE(res.ok); + CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(10.0).margin(1e-6)); // length held + CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-6)); // P0 still pinned + CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-6)); + CHECK(ents[0].p1.x() > 1.0); // end followed the drag toward +x (not stuck vertical) +} + +TEST_CASE("slvs: over-constrained / inconsistent is detected", "[slvs]") +{ + std::vector ents = { line({0, 0}, {5, 0}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Fix, 0, R::P1, 0, R::P1), + con(CT::Distance, 0, R::P0, 0, R::P1, 99.0), // contradicts the pinned endpoints + }; + auto res = sketch_solve(ents, cons); + CHECK_FALSE(res.ok); // SLVS_RESULT_INCONSISTENT +} + +// yww4. libslvs sizes its System with a compile-time `MAX_UNKNOWNS = 1024`, and the +// solver is handed every entity in the sketch at 2 params per point — so a sketch of about 480 +// lines is the last one that fits and the next comes back TOO_MANY_UNKNOWNS. Because +// try_add_constraints rolls a failed batch back, that turned into: every auto-inferred constraint +// on a large sketch silently dropped, and from then on no dimension could ever be applied to it. +// Constraints only couple entities that share a point, so the sketch is solved component by +// component when the whole system does not fit. +TEST_CASE("slvs: a sketch past the solver's unknown limit still solves", "[slvs]") +{ + // 300 disjoint squares: 1200 lines, 4800 unknowns whole, 8 per component. + const int N = 300; + std::vector ents; + std::vector cons; + for (int i = 0; i < N; ++i) { + const double x = (i % 30) * 10.0, y = (i / 30) * 10.0; + const int b = int(ents.size()); + ents.push_back(line({x, y}, {x + 4.0, y})); + ents.push_back(line({x + 4.0, y}, {x + 4.0, y + 4.0})); + ents.push_back(line({x + 4.0, y + 4.0}, {x, y + 4.0})); + ents.push_back(line({x, y + 4.0}, {x, y})); + for (int k = 0; k < 4; ++k) + cons.push_back(con(CT::Coincident, b + k, R::P1, b + (k + 1) % 4, R::P0)); + } + REQUIRE(ents.size() == size_t(4 * N)); + + std::vector before = ents; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + for (size_t i = 0; i < ents.size(); ++i) { // already satisfied: nothing may move + CHECK(ents[i].p0.x() == Approx(before[i].p0.x()).margin(1e-9)); + CHECK(ents[i].p0.y() == Approx(before[i].p0.y()).margin(1e-9)); + CHECK(ents[i].p1.x() == Approx(before[i].p1.x()).margin(1e-9)); + CHECK(ents[i].p1.y() == Approx(before[i].p1.y()).margin(1e-9)); + } + + // And a dimension typed onto one of them lands exactly, which is what stopped working. + cons.push_back(con(CT::Distance, 0, R::P0, 0, R::P1, 7.0)); + auto res2 = sketch_solve(ents, cons); + REQUIRE(res2.ok); + CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(7.0).margin(1e-9)); + + // A conflict inside ONE component must still be caught, not swallowed by the split. + cons.push_back(con(CT::Distance, 0, R::P0, 0, R::P1, 99.0)); + auto res3 = sketch_solve(ents, cons); + CHECK_FALSE(res3.ok); +} + +TEST_CASE("slvs: equal radius drives two circles to one radius", "[slvs][CadDocument]") +{ + std::vector ents = { circle({0, 0}, 5.0), circle({10, 0}, 12.0) }; + std::vector cons = { + con(CT::EqualRadius, 0, R::P0, 1, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].radius == Approx(ents[1].radius).margin(1e-9)); + CHECK(ents[0].radius > 1e-6); // equal-at-zero would satisfy the line above trivially +} + +TEST_CASE("slvs: equal radius plus a radius dimension pins both", "[slvs][CadDocument]") +{ + std::vector ents = { circle({0, 0}, 5.0), circle({10, 0}, 12.0) }; + std::vector cons = { + con(CT::EqualRadius, 0, R::P0, 1, R::P0), + con(CT::Radius, 0, R::P0, -1, R::P0, 8.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].radius == Approx(8.0).margin(1e-9)); + CHECK(ents[1].radius == Approx(8.0).margin(1e-9)); +} + +TEST_CASE("slvs: collinear makes two offset lines share one line", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({0, 4}, {10, 4}) }; + std::vector cons = { + con(CT::Collinear, 0, R::P0, 1, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + const Vec2d& a0 = ents[0].p0; + const Vec2d ad = ents[0].p1 - ents[0].p0; + for (int k = 0; k <= 1; ++k) { + const Vec2d& pk = (k == 0) ? ents[1].p0 : ents[1].p1; + const double cross = ad.x() * (pk.y() - a0.y()) - ad.y() * (pk.x() - a0.x()); + CHECK(cross == Approx(0.0).margin(1e-9)); + } + // A line collapsed to a point is trivially collinear with anything, so the cross + // products above would pass on a degenerate solve. Both lines must survive intact. + CHECK(ad.norm() == Approx(10.0).margin(1e-9)); + CHECK((ents[1].p1 - ents[1].p0).norm() == Approx(10.0).margin(1e-9)); +} + +TEST_CASE("slvs: collinear on already-collinear lines moves nothing", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {10, 0}), line({20, 0}, {30, 0}) }; + std::vector cons = { + con(CT::Collinear, 0, R::P0, 1, R::P0), + }; + std::vector before = ents; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + for (size_t i = 0; i < ents.size(); ++i) { // already satisfied: nothing may move + CHECK(ents[i].p0.x() == Approx(before[i].p0.x()).margin(1e-9)); + CHECK(ents[i].p0.y() == Approx(before[i].p0.y()).margin(1e-9)); + CHECK(ents[i].p1.x() == Approx(before[i].p1.x()).margin(1e-9)); + CHECK(ents[i].p1.y() == Approx(before[i].p1.y()).margin(1e-9)); + } +} + +TEST_CASE("slvs: distance-x drives the horizontal gap and leaves Y alone", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {3, 7}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::DistanceX, 0, R::P0, 0, R::P1, 10.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + // SIGNED, not abs. PROJ_PT_DISTANCE constrains (pB - pA).dot(unit(dir)), and a + // LINE_SEGMENT's direction is point[0] - point[1] (slvs entity.cpp), so the reference + // line is built head-first to mean +X. Assert on abs and a flipped reference passes + // while every dimension lands the point on the wrong side of its anchor. + CHECK(ents[0].p1.x() - ents[0].p0.x() == Approx(10.0).margin(1e-9)); + CHECK(ents[0].p1.y() == Approx(7.0).margin(1e-9)); // Y must not be disturbed +} + +TEST_CASE("slvs: distance-y drives the vertical gap and leaves X alone", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {3, 7}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::DistanceY, 0, R::P0, 0, R::P1, 10.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p1.y() - ents[0].p0.y() == Approx(10.0).margin(1e-9)); // signed: see above + CHECK(ents[0].p1.x() == Approx(3.0).margin(1e-9)); // X must not be disturbed +} + +TEST_CASE("slvs: distance-x is not the straight-line distance", "[slvs][CadDocument]") +{ + // B is at straight-line distance 10 from A; DistanceX = 6 is already satisfied, so a + // correct projection leaves B untouched. This is the case that fails if the constraint + // were wired to SLVS_C_PT_PT_DISTANCE, which would drag B onto the radius-6 circle. + std::vector ents = { line({0, 0}, {6, 8}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::DistanceX, 0, R::P0, 0, R::P1, 6.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p1.x() == Approx(6.0).margin(1e-9)); + CHECK(ents[0].p1.y() == Approx(8.0).margin(1e-9)); +} + +TEST_CASE("slvs: distance-x plus distance-y fully locates a point", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {1, 1}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::DistanceX, 0, R::P0, 0, R::P1, 4.0), + con(CT::DistanceY, 0, R::P0, 0, R::P1, 3.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p1.x() - ents[0].p0.x() == Approx(4.0).margin(1e-9)); // signed: see above + CHECK(ents[0].p1.y() - ents[0].p0.y() == Approx(3.0).margin(1e-9)); +} + +// The property the GUI's ref-ordering exists to preserve: DistanceX is SIGNED, so applying +// the CURRENT projected delta as the target must not move anything. If the refs are ordered +// so the shown value is positive while the actual signed delta is negative, accepting the +// value a dimension opens with teleports the point to the other side of its anchor. +TEST_CASE("slvs: applying a point's own distance-x is a no-op", "[slvs][CadDocument]") +{ + // p1 sits to the LEFT of p0, so the signed delta p1 - p0 is negative. + std::vector ents = { line({0, 0}, {-4, 7}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::DistanceX, 0, R::P0, 0, R::P1, -4.0), // the CURRENT signed delta + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p1.x() == Approx(-4.0).margin(1e-9)); // stayed left, did not flip to +4 + CHECK(ents[0].p1.y() == Approx(7.0).margin(1e-9)); +} + +static SketchEntity point(Vec2d p) +{ + SketchEntity e; e.type = SketchEntity::Type::Point; e.p0 = p; return e; +} + +TEST_CASE("slvs: coincident onto the origin sentinel pins a point", "[slvs][CadDocument]") +{ + std::vector ents = { point({5, 5}) }; + std::vector cons = { + con(CT::Coincident, 0, R::P0, kSketchRefOrigin, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-9)); + CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-9)); +} + +// NOTE on why these pin the free direction instead of asserting "the other coordinate is +// left alone". sys.dragged[] is populated only while a drag is in progress, so a plain +// sketch_solve of an UNDER-constrained system is free to move any parameter -- solvespace +// runs a Newton iteration, it does not minimise movement. PointOnLine alone is one equation +// in two unknowns, and the point measurably slides along the axis (from (7,4) to (4,0)). +// That is legal, not a defect, so the well-posed test states both coordinates. +TEST_CASE("slvs: point-on-line onto the X axis, located along it from the origin", "[slvs][CadDocument]") +{ + std::vector ents = { point({7, 4}) }; + std::vector cons = { + con(CT::PointOnLine, 0, R::P0, kSketchRefAxisX, R::P0), + con(CT::DistanceX, kSketchRefOrigin, R::P0, 0, R::P0, 7.0), // both sentinels at once + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.y() == Approx(0.0).margin(1e-9)); // driven onto the X axis + CHECK(ents[0].p0.x() == Approx(7.0).margin(1e-9)); // and located along it +} + +TEST_CASE("slvs: point-on-line onto the Y axis, located along it from the origin", "[slvs][CadDocument]") +{ + std::vector ents = { point({4, 7}) }; + std::vector cons = { + con(CT::PointOnLine, 0, R::P0, kSketchRefAxisY, R::P0), + con(CT::DistanceY, kSketchRefOrigin, R::P0, 0, R::P0, 7.0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.x() == Approx(0.0).margin(1e-9)); // driven onto the Y axis + CHECK(ents[0].p0.y() == Approx(7.0).margin(1e-9)); // and located along it +} + +TEST_CASE("slvs: parallel to the X axis levels a line without collapsing it", "[slvs][CadDocument]") +{ + std::vector ents = { line({0, 0}, {10, 3}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Parallel, 0, R::P0, kSketchRefAxisX, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p1.y() == Approx(0.0).margin(1e-9)); // leveled onto y = 0 + // A bare Parallel leaves length free; the solver preserves the endpoint's free + // x-coordinate, so the line lands at (10,0) — length 10, not the original sqrt(109). + // Assert that free coordinate rather than abs(): a flipped/collapsed line would not + // land exactly here. + CHECK(ents[0].p1.x() == Approx(10.0).margin(1e-9)); + CHECK((ents[0].p1 - ents[0].p0).norm() == Approx(10.0).margin(1e-6)); // did not collapse +} + +TEST_CASE("slvs: symmetric-about-Y mirrors two points across x = 0", "[slvs][CadDocument]") +{ + std::vector ents = { point({3, 5}), point({9, 5}) }; + std::vector cons = { + con(CT::SymmetricAboutY, 0, R::P0, 1, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(ents[0].p0.x() == Approx(-ents[1].p0.x()).margin(1e-9)); // mirror across x = 0 + // Neither x may be 0: a both-collapsed-to-the-axis solution also satisfies the mirror + // trivially. Squared, not abs(), so a near-zero x still fails cleanly. + CHECK(ents[0].p0.x() * ents[0].p0.x() > 1e-12); + CHECK(ents[1].p0.x() * ents[1].p0.x() > 1e-12); + CHECK(ents[0].p0.y() == Approx(5.0).margin(1e-9)); // Y values untouched + CHECK(ents[1].p0.y() == Approx(5.0).margin(1e-9)); +} + +TEST_CASE("slvs: reference-based constraint adds no degrees of freedom", "[slvs][CadDocument]") +{ + // A free line with Fix on P0 and Parallel to the X axis: 4 DoF - 2 (fix) - 1 (angle) + // = 1 (length still free). If the G_FIXED reference entities leaked unknowns into the + // solved group, this figure would be wrong. + std::vector ents = { line({0, 0}, {3, 4}) }; + std::vector cons = { + con(CT::Fix, 0, R::P0, 0, R::P0), + con(CT::Parallel, 0, R::P0, kSketchRefAxisX, R::P0), + }; + auto res = sketch_solve(ents, cons); + REQUIRE(res.ok); + CHECK(res.dof == 1); +} diff --git a/tools/orca_cad_mcp_bridge.py b/tools/orca_cad_mcp_bridge.py new file mode 100644 index 0000000000..d54320a9cb --- /dev/null +++ b/tools/orca_cad_mcp_bridge.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Zero-dependency stdio MCP server bridging to the Orca/Orca-CAD control socket. + +Speaks MCP (JSON-RPC 2.0 over newline-delimited stdio) to an MCP client (Claude Code), +and forwards each tool call to the app's Unix-domain control socket (opened by the GUI +when launched with ORCA_CAD_MCP set). The tool list is built *live* from the app's own +`describe_tools` reply — introspection drives the schema, so new kernel methods surface +without touching this file. + +Usage: orca_cad_mcp_bridge.py [SOCKET_PATH] (default /tmp/orca-cad-mcp.sock) +The app must be running with ORCA_CAD_MCP set; if the socket is down, tools/list falls +back to the slice-1 set and tool calls report the connection error (never crash). +""" +import sys, os, json, socket, itertools + +SOCK_PATH = sys.argv[1] if len(sys.argv) > 1 else "/tmp/orca-cad-mcp.sock" +SERVER_INFO = {"name": "orca-cad", "version": "0.1"} +_app_id = itertools.count(1) + +# --- app control-socket round-trip -------------------------------------------- +def app_call(method, params=None): + """One request to the app over the unix socket. Raises on transport failure.""" + req = {"jsonrpc": "2.0", "id": next(_app_id), "method": method} + if params is not None: + req["params"] = params + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(20) + try: + s.connect(SOCK_PATH) + s.sendall((json.dumps(req) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = s.recv(65536) + if not chunk: + break + buf += chunk + finally: + s.close() + return json.loads(buf.decode()) + +# --- describe_tools -> MCP tool schemas --------------------------------------- +_TYPE_MAP = {"number": "number", "integer": "integer", "string": "string", + "boolean": "boolean", "array": "array", "object": "object"} + +def _param_schema(p): + sch = {"type": _TYPE_MAP.get(p.get("type", "string"), "string")} + desc = p.get("description") + if "unit" in p: desc = (desc + " " if desc else "") + f"in {p['unit']}" + if desc: sch["description"] = desc + if "enum" in p: sch["enum"] = p["enum"] + if "min" in p: sch["minimum"] = p["min"] + if "max" in p: sch["maximum"] = p["max"] + if "default" in p: sch["default"] = p["default"] + return sch + +# Fallback when the app socket is unreachable at list time (e.g. the GUI isn't up yet when the +# MCP client lists tools at session start). The LIVE describe_tools reply is authoritative — this +# mirrors its 16-method surface so the agent still sees the full toolset; params without a +# `default` are surfaced as required. Keep in sync with McpControl.cpp describe_tools(). +def _p(name, typ="number", **kw): return dict(name=name, type=typ, **kw) +_PLANE = _p("plane", "string", enum=["XY", "XZ", "YZ"], default="XY") +_FALLBACK_TOOLS = {"tools": [ + {"name": "describe_tools", "summary": "List callable tools and their parameters.", "params": []}, + {"name": "describe_scene", "summary": "Feature tree + per-body bounding boxes.", "params": []}, + {"name": "extrude", "summary": "Extrude a profile (or width x height rectangle) to a depth; Onshape end conditions.", + "params": [_p("width", default=20), _p("height", default=20), _p("distance", default=10), _PLANE, + _p("profile", "array", default=[], description="closed [[x,y],...] overrides width/height"), + _p("boolean", "string", enum=["new", "union", "subtract", "intersect"], default="new"), + _p("end", "string", enum=["blind", "symmetric", "two_sided", "through_all", "up_to_face"], default="blind"), + _p("distance2", default=0, description="second side when end=two_sided"), + _p("up_to_face", "integer", default=-1, description="target face id when end=up_to_face"), + _p("taper", default=0), _p("flip", "boolean", default=False)]}, + {"name": "revolve", "summary": "Revolve a profile about a plane axis.", + "params": [_p("width", default=20), _p("height", default=10), _p("angle", default=360), + _p("axis", "integer", enum=[0, 1], default=0), _p("flip", "boolean", default=False), _PLANE, + _p("profile", "array", default=[], description="closed [[x,y],...] overrides width/height"), + _p("boolean", "string", enum=["new", "union", "subtract", "intersect"], default="new")]}, + {"name": "fillet", "summary": "Round a measured edge of a body.", + "params": [_p("edge", "integer"), _p("radius", default=1), + _p("body", "integer", default=-1, description="target body; omit for last")]}, + {"name": "chamfer", "summary": "Chamfer a measured edge of a body.", + "params": [_p("edge", "integer"), _p("distance", default=1), + _p("body", "integer", default=-1, description="target body; omit for last")]}, + {"name": "hole", "summary": "Drill a circular hole at (x,y) on a plane.", + "params": [_p("diameter", default=5), _p("depth", default=10), _p("through", "boolean", default=False), + _p("x", default=0), _p("y", default=0), _PLANE]}, + {"name": "boolean", "summary": "Combine two bodies: union | subtract | intersect.", + "params": [_p("op", "string", enum=["union", "subtract", "intersect"], default="subtract"), + _p("target", "integer", default=0), _p("tool", "integer", default=1), + _p("keep_tool", "boolean", default=False), _p("tolerance", default=0)]}, + {"name": "pattern", "summary": "Replicate a body: linear or circular.", + "params": [_p("circular", "boolean", default=False), _p("count", "integer", default=3), + _p("spacing", default=10), _p("dir", "integer", enum=[0, 1], default=0), + _p("angle", default=360), _PLANE, _p("body", "integer", default=-1, description="target body; omit for last")]}, + {"name": "shell", "summary": "Hollow a body to a wall thickness; optionally open one face.", + "params": [_p("thickness", default=1), _p("face", "integer", default=-1, description="face id to leave open; omit for closed"), + _p("body", "integer", default=-1, description="target body; omit for last")]}, + {"name": "draft", "summary": "Taper a body face by an angle (pull +Z).", + "params": [_p("face", "integer"), _p("angle", default=5), + _p("body", "integer", default=-1, description="target body; omit for last")]}, + {"name": "query_topology", "summary": "Measured faces and edges of a body.", + "params": [_p("body", "integer", default=0)]}, + {"name": "measure", "summary": "Distance/angle between two refs {face|edge|point} on a body.", + "params": [_p("body", "integer", default=0), _p("a", "object"), _p("b", "object")]}, + {"name": "slice_body", "summary": "Cross-section of a body; ordered closed/open contours.", + "params": [_p("body", "integer", default=0), _PLANE, _p("offset", default=0)]}, + {"name": "import_step", "summary": "Import a STEP file as native B-rep bodies.", + "params": [_p("path", "string")]}, + {"name": "validate_against", "summary": "Volume + bbox + surface deviation of a body vs a reference {step|body}.", + "params": [_p("body", "integer", default=0), _p("reference", "object")]}, +]} + +def list_tools(): + try: + desc = app_call("describe_tools").get("result", {}) + if "tools" not in desc: + desc = _FALLBACK_TOOLS + except Exception: + desc = _FALLBACK_TOOLS + out = [] + for t in desc["tools"]: + params = t.get("params", []) + props = {p["name"]: _param_schema(p) for p in params} + required = [p["name"] for p in params if "default" not in p] + out.append({ + "name": t["name"], + "description": t.get("summary", ""), + "inputSchema": {"type": "object", "properties": props, "required": required}, + }) + return out + +# --- MCP method handlers ------------------------------------------------------ +def handle(req): + m = req.get("method") + rid = req.get("id") + if m == "initialize": + ver = (req.get("params") or {}).get("protocolVersion", "2024-11-05") + return {"jsonrpc": "2.0", "id": rid, "result": { + "protocolVersion": ver, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": SERVER_INFO}} + if m == "ping": + return {"jsonrpc": "2.0", "id": rid, "result": {}} + if m == "tools/list": + return {"jsonrpc": "2.0", "id": rid, "result": {"tools": list_tools()}} + if m == "tools/call": + p = req.get("params") or {} + name = p.get("name") + args = p.get("arguments") or {} + try: + reply = app_call(name, args) + except Exception as e: + return {"jsonrpc": "2.0", "id": rid, "result": { + "content": [{"type": "text", "text": f"control socket unreachable ({SOCK_PATH}): {e}"}], + "isError": True}} + if "error" in reply: + return {"jsonrpc": "2.0", "id": rid, "result": { + "content": [{"type": "text", "text": json.dumps(reply["error"])}], "isError": True}} + return {"jsonrpc": "2.0", "id": rid, "result": { + "content": [{"type": "text", "text": json.dumps(reply.get("result"), indent=2)}]}} + if rid is not None: # unknown *request* + return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"method not found: {m}"}} + return None # notification (e.g. notifications/initialized) -> no reply + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except Exception: + continue + resp = handle(req) + if resp is not None: + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + +if __name__ == "__main__": + main()