Design tab: sketch-first parametric CAD inside the slicer (#15238)

# Description

Adds a sketch-first parametric CAD tab to the slicer: sketch → constrain
→ solid features
→ commit to plate. The feature recipe is persisted inside the 3MF, so
reopening a project
restores an editable model rather than a frozen mesh.

Opening this at @SoftFever's request, so the code is easier to read than
a fork.

**The number worth reading first:** the diff is large, but almost all of
it is new files.
Existing upstream code is touched in **23 files, +622 / -88 total**.
That is the entire
negotiable surface. The largest single one is `GLCanvas3D.cpp` at
+149/-14 (a pick path for
the CAD viewport); everything else is under 60 lines.

One thing about the raw diff: the file count includes everything new,
and the negotiable
surface is the 23 modified files above. Thanks for merging `main` in —
the branch is current
again, and I have kept building on top of it.

The regenerated i18n catalogues (`OrcaSlicer.pot`, `OrcaSlicer_it.po`,
`list.txt`) have been
kept OUT of this branch deliberately — they were 27,314 added lines of
build product standing
between you and the code. They regenerate from source with
`scripts/run_gettext.sh` whenever
you want them refreshed. A Romanian catalogue that had been riding along
was pulled out at
the same time — a translation has no business being reviewed inside a
CAD feature PR.

| | |
|---|---|
| Kernel | OCCT — already linked for STEP import. The dependency delta
is one line: `BUILD_MODULE_ModelingAlgorithms=OFF → ON`. Measured cost
in
[`docs/cad_dependency_weight.md`](https://github.com/tommasobbianchi/Orca-Cad/blob/cad-mainline/docs/CAD/cad_dependency_weight.md)
|
| Constraint solver | vendored SolveSpace `libslvs` subset, 21 files /
~10k lines under `src/libslic3r/slvs/` |
| Build gate | `SLIC3R_CAD` (default ON). With it OFF the tab is not
compiled and the deps prefix matches upstream exactly |
| Persistence | CAD recipe embedded in both the 3MF and BBS-3MF writers
|
| User docs |
[`docs/design_tab.md`](https://github.com/tommasobbianchi/Orca-Cad/blob/cad-mainline/docs/CAD/design_tab.md)
|
| Interaction model | object-driven — point at geometry, it offers the
verbs that apply:
[`docs/cad_ux_guidelines.md`](https://github.com/tommasobbianchi/Orca-Cad/blob/cad-mainline/docs/CAD/cad_ux_guidelines.md)
|

### Why it belongs in the slicer

Every round trip through an external CAD tool costs an export, a
re-import, and the design
intent both steps discard. A part changed after slicing should come back
to its feature
history, not to a mesh. Keeping the model in the slicer preserves that
loop — nozzle
diameter, build volume and material are known at design time. Longer
argument in

[`docs/design_tab_upstream_portability.md`](https://github.com/tommasobbianchi/Orca-Cad/blob/cad-mainline/docs/CAD/design_tab_upstream_portability.md).

### Two things I'd rather you hear from me than find

**Licensing.** The vendored solver is **GPL-3.0**, not LGPL
(`src/libslic3r/slvs/LICENSE`).
The combined work is distributable under AGPL-3.0 and the compatibility
argument is written
out in the portability doc, but this is a project-level decision and I
would like it
confirmed explicitly rather than assumed. If GPL-3.0 in-tree is not
acceptable, the solver
is the separable part — the timeline, features and persistence do not
depend on it.

**One CMake change is larger than it looks.** `CMakeLists.txt` is
+41/-56: it replaces a
hand-maintained list of OCCT DLLs to copy on Windows with a glob plus an
assertion that
every linked toolkit actually has a DLL. The explicit list had already
drifted from what
`libslic3r` links and shipped a portable that died at launch with `error
126`. Happy to
split that out into its own PR if you'd prefer it reviewed separately.

### Not verified

- No automated GUI test. A green kernel run says nothing about the
viewport — synthetic
clicks never drift, so the suite and the UI are two separate realities.
- Card wiring for 9 of the 16 late-wired tools has never been
click-tested.
- The click-test defect rate has not converged: one pass found nothing,
four further days
of work found five more defects. I would not present the quiet pass as
evidence of
  stability.

# Screenshots/Recordings/Graphs

One part, start to finish: sketch it, feature it, print it — without
leaving the slicer.


![1-sketch-dimensioned](https://raw.githubusercontent.com/tommasobbianchi/Orca-Cad/pr-assets/1-sketch-dimensioned.png)

**1. Sketch, constrained and dimensioned.** A 100 × 90 rounded rectangle
drawn straight onto
the bed, R20 corners, live dimensions, and the solver's remaining
degrees of freedom reported
in the panel. The bed is the sketch plane, so the part is sized against
the machine it will be
printed on from the first line.


![2-feature-tree-thread](https://raw.githubusercontent.com/tommasobbianchi/Orca-Cad/pr-assets/2-feature-tree-thread.png)

**2. The feature tree is the part.** `Sketch1 → Extrude2 → Chamfer3 →
Sketch4 → Extrude5 →
Hole6 → Thread7`. Every step stays editable and re-evaluates downstream
— the modelled thread
in the boss is a real helical feature, not a texture.


![3-prepare-plate](https://raw.githubusercontent.com/tommasobbianchi/Orca-Cad/pr-assets/3-prepare-plate.png)

**3. Committed to the plate.** The same body arrives in Prepare as
`Design Body`,
100 × 90 × 78 mm, 581,634 mm³, ready for a Sovol Zero and PETG. No
export, no re-import, no
lost design intent.


![4-preview-sliced](https://raw.githubusercontent.com/tommasobbianchi/Orca-Cad/pr-assets/4-preview-sliced.png)

**4. Sliced.** The thread comes out as real helical toolpaths, and the
estimate is 3h26m /
134.54 g. This is the whole argument for the feature in one frame: the
geometry that was
parametric two screens ago is now G-code, and it is still parametric if
you go back.

## Tests

215 `TEST_CASE` blocks across 6 new test files, plus 2 `SCENARIO`s added
to
`tests/libslic3r/test_3mf.cpp` covering the CAD recipe's round trip
through both 3MF
writers. `scripts/kernel-test.sh` is the headless contract: it builds
only
`libslic3r_tests`, needs no display, and exit 0 means the CAD suite
passed.

Happy to slice this differently — kernel + solver first, GUI second — if
that reviews
better for you.
This commit is contained in:
SoftFever
2026-09-18 14:58:04 +08:00
committed by GitHub
393 changed files with 73044 additions and 77 deletions
@@ -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 <file> orcacad-gui:/OrcaSlicer/<path>
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.
+32 -54
View File
@@ -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()
+7
View File
@@ -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
+16 -1
View File
@@ -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}
+65
View File
@@ -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 $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/libslvs/include>
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})
+13
View File
@@ -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 ()
+160
View File
@@ -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.
+136
View File
@@ -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/<TK>/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=<occt-source>
cat $R/adm/MODULES # module -> toolkits
cat $R/src/<TK>/EXTERNLIB # toolkit -> its dependencies
# archive sizes
ls -l <deps-prefix>/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
```
+704
View File
@@ -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 39 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 (L1L11) 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.
@@ -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.
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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
(713 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` 04 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` 04 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 03 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 220 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 68 facets each facet is 37 px wide,
that is at the aliasing limit … minimum useful size is roughly 3248 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because it is too large Load Diff
@@ -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},
};
File diff suppressed because one or more lines are too long
@@ -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}]}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mate connector glyph — polarity and verse</title>
<style>
:root {
--ground: #eceef1;
--panel: #f8f9fb;
--panel-edge: #d3d8df;
--ink: #171a1f;
--ink-soft: #5a626e;
--ink-faint: #8b93a0;
--viewport: #9aa0a8; /* the grey a CAD viewport actually is */
--viewport-2: #7f858d;
--axis-z: #2f6fed;
--axis-x: #d94a3d;
--axis-y: #3aa757;
--quadrant: #e8a317;
--anchor: #6b7280;
--driven: #2f6fed;
--warn: #c2410c;
}
@media (prefers-color-scheme: dark) {
:root {
--ground: #14171c;
--panel: #1b1f26;
--panel-edge: #2b313a;
--ink: #e8eaee;
--ink-soft: #a6aeba;
--ink-faint: #6e7784;
--viewport: #4a5058;
--viewport-2: #3a3f46;
--axis-z: #6ea2ff;
--axis-x: #ff7a6d;
--axis-y: #5fd07f;
--quadrant: #ffc247;
--anchor: #9aa3b0;
--driven: #6ea2ff;
--warn: #fb923c;
}
}
:root[data-theme="dark"] {
--ground:#14171c; --panel:#1b1f26; --panel-edge:#2b313a; --ink:#e8eaee;
--ink-soft:#a6aeba; --ink-faint:#6e7784; --viewport:#4a5058; --viewport-2:#3a3f46;
--axis-z:#6ea2ff; --axis-x:#ff7a6d; --axis-y:#5fd07f; --quadrant:#ffc247;
--anchor:#9aa3b0; --driven:#6ea2ff; --warn:#fb923c;
}
:root[data-theme="light"] {
--ground:#eceef1; --panel:#f8f9fb; --panel-edge:#d3d8df; --ink:#171a1f;
--ink-soft:#5a626e; --ink-faint:#8b93a0; --viewport:#9aa0a8; --viewport-2:#7f858d;
--axis-z:#2f6fed; --axis-x:#d94a3d; --axis-y:#3aa757; --quadrant:#e8a317;
--anchor:#6b7280; --driven:#2f6fed; --warn:#c2410c;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 40px 24px 72px;
background: var(--ground); color: var(--ink);
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.wrap { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 28px; }
header { display: flex; flex-direction: column; gap: 6px; }
h1 { font-size: 26px; line-height: 1.25; margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
.sub { color: var(--ink-soft); max-width: 62ch; margin: 0; }
.eyebrow {
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink-faint); font-weight: 600;
}
h2 {
font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--ink-faint); margin: 16px 0 0; font-weight: 600;
}
.row { display: flex; flex-wrap: wrap; gap: 16px; }
.card {
background: var(--panel); border: 1px solid var(--panel-edge);
border-radius: 10px; padding: 18px; flex: 1 1 220px; min-width: 220px;
display: flex; flex-direction: column; gap: 10px;
}
.card.wide { flex: 1 1 100%; }
.stage { display: flex; align-items: center; justify-content: center; padding: 4px 0; }
.name { font-weight: 650; font-size: 15px; }
.note { color: var(--ink-soft); font-size: 13.5px; margin: 0; }
.k { color: var(--ink); font-weight: 600; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--panel-edge); vertical-align: top; }
th { color: var(--ink-faint); font-weight: 600; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; }
code { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink-soft); }
.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; color: var(--ink-soft); }
.swatch { display: inline-flex; align-items: center; gap: 7px; }
.dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="eyebrow">Orca Design · assembly</div>
<h1>Mate connector glyph — polarity and verse</h1>
<p class="sub">
Onshape's core (disc + roll quadrant + Z arrow) is adopted unchanged because it is proven and
aligned. The addition is <span class="k">polarity</span> — which connector is anchored and
which one travels — which no surveyed CAD system encodes in its glyph.
</p>
</header>
<h2>The three jobs of the glyph</h2>
<div class="row">
<div class="card">
<div class="stage">
<svg width="150" height="130" viewBox="-75 -95 150 130" aria-label="Disc with origin dot">
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--ink-soft)" stroke-width="2.5"/>
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
</svg>
</div>
<div class="name">Disc — the XY plane</div>
<p class="note">Says “I am a frame, and this is the plane I sit in.” The dot is the exact origin.</p>
</div>
<div class="card">
<div class="stage">
<svg width="150" height="130" viewBox="-75 -95 150 130" aria-label="Disc with one quadrant filled">
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.9"/>
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--ink-soft)" stroke-width="2.5"/>
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
</svg>
</div>
<div class="name">Quadrant — the roll</div>
<p class="note">
The filled sector is the +X/+Y quadrant. It steps 90° with the reorient control, so the
clocking that Fastened and Slider lock is <em>visible</em> before you commit.
</p>
</div>
<div class="card">
<div class="stage">
<svg width="150" height="130" viewBox="-75 -95 150 130" aria-label="Z arrow drawn only upward">
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.9"/>
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--ink-soft)" stroke-width="2.5"/>
<line x1="0" y1="0" x2="0" y2="-58" stroke="var(--axis-z)" stroke-width="3.5" stroke-linecap="round"/>
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="var(--axis-z)"/>
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
</svg>
</div>
<div class="name">Arrow — the verse</div>
<p class="note">
Drawn on <span class="k">+Z only</span>. Nothing below the disc. A double-headed axis is what
makes people ask which way it points; a one-sided arrow cannot be misread.
</p>
</div>
</div>
<h2>Polarity — the part nobody else draws</h2>
<div class="row">
<div class="card">
<div class="stage">
<svg width="170" height="150" viewBox="-85 -105 170 150" aria-label="Fixed connector, open collar">
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.55"/>
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--anchor)" stroke-width="2.5"/>
<line x1="0" y1="0" x2="0" y2="-56" stroke="var(--anchor)" stroke-width="3" stroke-linecap="round"/>
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="none" stroke="var(--anchor)" stroke-width="3" stroke-linejoin="round"/>
<ellipse cx="0" cy="-56" rx="9.5" ry="3.6" fill="none" stroke="var(--anchor)" stroke-width="2.2"/>
<circle cx="0" cy="0" r="3.6" fill="var(--anchor)"/>
</svg>
</div>
<div class="name">Fixed — the socket</div>
<p class="note">
Hollow head, muted colour. This body <span class="k">does not move</span>. It receives.
</p>
</div>
<div class="card">
<div class="stage">
<svg width="170" height="150" viewBox="-85 -105 170 150" aria-label="Driven connector, solid cone">
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.95"/>
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--driven)" stroke-width="2.5"/>
<line x1="0" y1="0" x2="0" y2="-58" stroke="var(--driven)" stroke-width="3.5" stroke-linecap="round"/>
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="var(--driven)"/>
<circle cx="0" cy="0" r="3.6" fill="var(--driven)"/>
</svg>
</div>
<div class="name">Driven — the plug</div>
<p class="note">
Solid head, active colour. This body <span class="k">is the one that jumps</span>. It inserts.
</p>
</div>
<div class="card">
<div class="stage">
<svg width="170" height="150" viewBox="-85 -105 170 150" aria-label="Degenerate roll, hatched quadrant">
<defs>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="var(--warn)" stroke-width="2"/>
</pattern>
</defs>
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="url(#hatch)" opacity="0.85"/>
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--warn)" stroke-width="2.5" stroke-dasharray="5 4"/>
<line x1="0" y1="0" x2="0" y2="-58" stroke="var(--axis-z)" stroke-width="3.5" stroke-linecap="round"/>
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="var(--axis-z)"/>
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
</svg>
</div>
<div class="name">Roll undefined</div>
<p class="note">
Hatched quadrant, dashed disc: a circular face or a seam gave no usable direction. Says
“pick a direction” without a dialog.
</p>
</div>
</div>
<h2>The pair reads as a magnet</h2>
<div class="card wide">
<div class="stage">
<svg width="620" height="230" viewBox="-310 -120 620 230" aria-label="Two connectors facing each other on two plates">
<!-- lower plate (fixed) -->
<path d="M-260,52 L-60,10 L60,44 L-140,86 Z" fill="var(--viewport)" stroke="var(--viewport-2)" stroke-width="1.5"/>
<!-- upper plate (driven) -->
<path d="M-60,-96 L140,-138 L260,-104 L60,-62 Z" fill="var(--viewport)" stroke="var(--viewport-2)" stroke-width="1.5" opacity="0.55"/>
<!-- dashed field line between origins -->
<line x1="-100" y1="48" x2="100" y2="-79" stroke="var(--ink-faint)" stroke-width="2" stroke-dasharray="7 6"/>
<!-- FIXED connector, pointing up (+Z out of the lower plate) -->
<g transform="translate(-100,48)">
<path d="M0,0 L38,0 A38,15 0 0 1 0,15 Z" fill="var(--quadrant)" opacity="0.5"/>
<ellipse cx="0" cy="0" rx="38" ry="15" fill="none" stroke="var(--anchor)" stroke-width="2.4"/>
<line x1="0" y1="0" x2="0" y2="-48" stroke="var(--anchor)" stroke-width="3" stroke-linecap="round"/>
<polygon points="0,-70 -9,-48 9,-48" fill="none" stroke="var(--anchor)" stroke-width="3" stroke-linejoin="round"/>
<ellipse cx="0" cy="-48" rx="9" ry="3.4" fill="none" stroke="var(--anchor)" stroke-width="2"/>
<circle cx="0" cy="0" r="3.4" fill="var(--anchor)"/>
</g>
<!-- DRIVEN connector, pointing down (+Z out of the upper plate's underside) -->
<g transform="translate(100,-79) rotate(180)">
<path d="M0,0 L38,0 A38,15 0 0 1 0,15 Z" fill="var(--quadrant)" opacity="0.9"/>
<ellipse cx="0" cy="0" rx="38" ry="15" fill="none" stroke="var(--driven)" stroke-width="2.4"/>
<line x1="0" y1="0" x2="0" y2="-50" stroke="var(--driven)" stroke-width="3.4" stroke-linecap="round"/>
<polygon points="0,-70 -9,-48 9,-48" fill="var(--driven)"/>
<circle cx="0" cy="0" r="3.4" fill="var(--driven)"/>
</g>
<text x="-100" y="102" text-anchor="middle" font-size="13" fill="var(--ink-soft)">fixed · receives</text>
<text x="100" y="-100" text-anchor="middle" font-size="13" fill="var(--ink-soft)">driven · inserts</text>
</svg>
</div>
<p class="note">
Two arrows nose to nose. Because a magnet's north seeks a south, <span class="k">“facing” is the
self-evident default</span> — 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.
</p>
</div>
<h2>States</h2>
<div class="card wide">
<table>
<thead>
<tr><th>State</th><th>Drawing</th><th>Why</th></tr>
</thead>
<tbody>
<tr>
<td><span class="k">Candidate</span> (hover)</td>
<td>small dot only</td>
<td>Onshape draws plain white dots at every corner and midpoint. Dots propose; the full glyph commits.</td>
</tr>
<tr>
<td><span class="k">Picked</span></td>
<td>disc + quadrant + cone</td>
<td>The committed frame, with roll and verse both readable.</td>
</tr>
<tr>
<td><span class="k">Roll undefined</span></td>
<td>hatched quadrant, dashed disc</td>
<td>Turns requirement R8 from a message nobody reads into a mark you cannot miss.</td>
</tr>
</tbody>
</table>
</div>
<h2>Constraints on the drawing</h2>
<div class="card wide">
<p class="note">
<span class="k">Do not make it a fourth RGB triad.</span> The bed-centre world triad
(<code>DesignCanvas.cpp:65</code>) 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.
</p>
<div class="legend">
<span class="swatch"><i class="dot" style="background:var(--quadrant)"></i> roll quadrant</span>
<span class="swatch"><i class="dot" style="background:var(--axis-z)"></i> Z / driven</span>
<span class="swatch"><i class="dot" style="background:var(--anchor)"></i> fixed</span>
<span class="swatch"><i class="dot" style="background:var(--warn)"></i> roll undefined</span>
</div>
</div>
</div>
</body>
</html>
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -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))
@@ -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")
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+226
View File
@@ -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
@@ -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 <faceted_ridge_key.scad>
// 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();
}
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -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")
@@ -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))
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -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")
@@ -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)}")
@@ -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))
@@ -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("<I", data[80:84])[0]
tris = np.empty((n, 3, 3), dtype=float)
off = 84
for i in range(n):
v = struct.unpack("<12f", data[off:off + 48])
tris[i] = np.array(v[3:12]).reshape(3, 3)
off += 50
return tris
def render(tris, eye, target, path, size=(620, 460), scale=14.0, label=""):
eye = np.array(eye, float); target = np.array(target, float)
f = target - eye; f /= np.linalg.norm(f)
up = np.array([0, 0, 1.0])
if abs(np.dot(f, up)) > 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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -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}")
@@ -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)")
+333
View File
@@ -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 <face> — 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).
+73
View File
@@ -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
<!-- fork-specific: measured against this fork's upstream base; re-run the commands above after mirroring -->
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`.
+162
View File
@@ -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 14 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.*
+151
View File
@@ -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 '<prefix>/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. `<prefix>/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 <target>` writes `/OrcaSlicer/build/src/Release/<binary>`; only `build_linux.sh`
additionally packages to `/OrcaSlicer/build/package/bin/<binary>`. `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 <container>:/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 <fork>-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/<App>/log/<newest>.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/<pid>/exe)`. **Read that line before trusting a screenshot.**
+144
View File
@@ -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.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

Some files were not shown because too many files have changed in this diff Show More