Compare commits

..
Author SHA1 Message Date
Hanif Koh 87f5aa298e Make the Unsaved Changes Dialog Header Span the Full Table Width
The grey header strip carrying the Settings/Old Value/New Value titles was
added to the tab panel without wxEXPAND, so it stayed at the width its own
sizer fitted it to. The panel, the scrolled window and every category and
value row below it are added with wxEXPAND and do follow the dialog width,
so as soon as the dialog came out wider than the table, the header stopped
short and left a bare strip of panel to the right of New Value.

The dialog takes its width from the widest child of the main sizer, which
includes the button row. In the Transfer/Discard/Save variant three Choice
buttons, each with a hard FromDIP(100) minimum, plus the gaps, the checkbox
and the margins come within a few pixels of the table width, and at higher
display scaling they overrun it.

Give the header the panel width and send the surplus to a stretch spacer
after the last column, so the columns stay put: they are hard minimum sizes
on both sides, and the rows below leave that same area empty, so header and
values remain aligned. The Settings column drops to proportion 0 so it does
not absorb the surplus and slide the other two titles out of alignment.

The header is unchanged when the dialog is not wider than its table.

Verified on Linux with a release build: with the dialog widened, the header
goes from ending at x=511 to spanning the full 749, and the pixel difference
between the two builds is confined to the strip that was previously bare. At
normal width the two builds render identically. Not verified on Windows.
2026-09-18 18:35:24 +08:00
SoftFever db91d4b630 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.
2026-09-18 14:58:04 +08:00
SoftFever c4647c44f2 Add support for labeling profile PRs and improve merge conditions 2026-09-18 14:48:57 +08:00
SoftFever b6c0475dcf fix shell check errors 2026-09-18 14:09:21 +08:00
JAYO3D-Official 52a6ff1764 Add official JAYO filament profiles for Bambu Lab and Creality printers (#15481)
Merged by /bot merge on behalf of @JAYO3D-Official (id 320896770).
Grants: resources/profiles/OrcaFilamentLibrary/filament/JAYO, resources/profiles/OrcaFilamentLibrary.json
Head: fb9a6d70a0
2026-09-18 06:05:03 +00:00
SoftFever f1f68ffc3f Merge branch 'main' into cad-mainline 2026-09-18 14:01:23 +08:00
SoftFever a77209af8f Stop requiring a filament id snapshot update when filaments change 2026-09-18 11:20:57 +08:00
Valerii Bokhan 52f4c68c41 addnorth filament profiles: H2C and A2L support (#14764) 2026-09-17 17:50:30 -03:00
Ian Bassi c833ccdf6f Update localizations and improve strings (#15739) 2026-09-17 14:27:53 -03:00
SoftFever f520e9221f Repair shipped default materials and obsolete settings, and validate them (#15741)
* add orca profile skill

* add default material check

Improve validation for default materials and filament profiles

* Fix default materials and obsolete keys

* clarifying orca-profiles skill
2026-09-18 00:39:46 +08:00
Valerii Bokhan 60b4a61854 Fix: Show indexed coFloatsOrPercents options in unsaved changes dialog (#15472) 2026-09-17 10:56:17 -03:00
Ian Bassi 7065fa9eae Fix extruder clearance help link anchor (#15738) 2026-09-17 09:54:42 -03:00
Ian Bassi 59e40a2c2e Print unsupported walls last (#15411) 2026-09-17 09:14:20 -03:00
Ian Bassi 82e91bd472 Port wipe tower BBS improvements (#15485) 2026-09-17 09:08:50 -03:00
packerlschupfer ca668a3bc9 CLI: --inspect-paint — dump per-facet paint state as JSON (#14608)
* CLI: --inspect-paint — dump per-facet paint state as JSON

Reads the per-facet enforcer/blocker/extruder/fuzzy-skin state stored
on every ModelVolume (supported_facets / seam_facets /
mmu_segmentation_facets / fuzzy_skin_facets) and emits a structured
JSON summary to stdout. Machine-readable alternative to opening the
paint gizmos.

Per (object, volume, layer, state): facet count, surface area in
mm², and mesh-local bounding box. Empty layers collapse to
{"empty": true}. Summary at the top level rolls up totals.

One correctness detail worth calling out: FacetsAnnotation::
get_facets_strict returns an indexed_triangle_set whose `vertices`
array is the whole source mesh — only `indices` are filtered to the
painted triangles. A naive bounding_box(its) would report the whole
mesh's bbox even when only a few facets are painted. The helper
its_referenced_bbox() walks only the vertices actually indexed by
the painted triangles, so `bbox` correctly localizes the painted
region.

Rationale: every paint-driven workflow — GUI-painted .3mf verified
in CI, AI agents planning support enforcers, MMU color layout checks
— needs to know what's already painted on a model. Today that's a
GUI-only read. --inspect-paint closes that loop for scripted callers.

New file src/slic3r/Utils/PaintCLI.{hpp,cpp} (~215 lines). Depends
only on Model, TriangleMesh, TriangleSelector, FacetsAnnotation, and
nlohmann::json — all already in tree. No new dependencies, no
signature changes, no behavior change when the flag is absent.

Registered as an action (parallel to --info) so it satisfies the
"needs an action" check and bypasses the GUI fallback; control falls
through the normal post-action path to a clean exit 0.

Verification:
  unpainted STL:     every layer {"empty": true}, summary zero
  GUI-painted .3mf:  enforcer count / area / bbox match painter
  clean JSON:        parseable via jq

* CLI --inspect-paint: exit after printing, reject conflicting actions

- Finish like the end of CLI::run once the JSON is written, as the
  tooltip says. The callback manager is Linux-only, so its use is
  guarded.
- Reject actions that would otherwise be skipped without notice
  (--slice, --export-3mf, ...) before loading. Load-time options such as
  --uptodate are still accepted.
- Replace invalid UTF-8 in object names and paths instead of throwing.
- Report every input file as sources; inputs are merged into one model
  before actions run.

* CLI --inspect-paint: reject a run without input

Without an input file or --load-assemble-list there is nothing to
inspect, and the run printed nothing and exited 0. Reject it up front
with CLI_INVALID_PARAMS, next to the other invalid-parameter checks.
2026-09-17 12:01:49 +08:00
Kris Austin 6b0e190e64 ci: key the Windows compiler cache on the MSVC toolset version (#15729) 2026-09-16 18:30:18 -03:00
Kris Austin 8effa27f4a build: build the dependencies with clang-cl under the Visual Studio generator (#15673)
The deps superbuild passes the Visual Studio generator and platform to
every sub-build but not the toolset, so build_win.bat -d -l without -x
compiled every dependency with cl even though the superbuild had been
configured with -T ClangCL; CMake replaces the forwarded
CMAKE_<LANG>_COMPILER with whatever the toolset ran. The recipes that
adapt to clang-cl then disagreed with what had been built, and
wxInspector told FindwxWidgets to look in lib/clang_x64_lib while the
cl-built wxWidgets had installed into lib/vc_x64_lib:

  Could NOT find wxWidgets (missing: wxWidgets_LIBRARIES
  wxWidgets_INCLUDE_DIRS core base aui propgrid)

Forward CMAKE_GENERATOR_TOOLSET as well, so the dependencies compile
with clang-cl under MSBuild the way they already do under Ninja. Four
of them need more than that:

- OpenSSL always builds with cl, and MSBuild runs its nmake steps in
  the project's toolset environment, where ClangCL puts clang's include
  directory first and cl trips over clang's stdint.h. The project gets
  the default toolset.
- Boost.Container's dlmalloc needs -Wno-incompatible-pointer-types
  under clang. boost_container links as C++, and the Visual Studio
  generator writes only the link language's flags into the project, so
  its C file never saw CMAKE_C_FLAGS. Under that generator the option
  goes through the C++ flags as well, with the defaults kept.
- Draco's tools and NLopt's testopt compile sources their own static
  library also contains. MSBuild lists libraries before objects and
  lld-link resolves archive members as each input arrives, so the
  library's copy is pulled in before the executable's own object and
  the link fails on duplicate symbols; link.exe defers the search and
  Ninja lists the objects first. Nothing uses those executables, so
  they get /FORCE:MULTIPLE there.

The Ninja path is unchanged: the generated configure commands of all
29 dependencies are identical before and after. OCCT's arm64 override
to cl still applies under Ninja but not under the Visual Studio
generator, where the toolset wins; that combination never built and is
left for a follow-up.
2026-09-16 14:28:50 -03:00
Ian BassiandRodrigo Faselli 72774e5398 Toolchange Cyclic Order (#14868)
* Toolchange Cyclic Order

* Apply cyclic order to first layer

* Unit test

* Copilot fixes

---------

Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-16 12:19:03 -03:00
Kris Austin a610d2d899 ci: build Windows with build_win.bat and drop the old scripts (#15721)
* ci: build Windows with build_win.bat and drop the old scripts

The deps and slicer jobs called build_release_vs.bat; they now call
build_win.bat. --deps-dir and --build-dir name the build/build-arm64
directories the cache keys and later steps already use, and the
script's own VsDevCmd call replaces the Enter-VsDevShell blocks.
With both stages configured each way into the same directory, the deps
superbuild is byte-identical and the slicer build files are
byte-identical apart from CMakeCache.txt recording how DEP_BUILD_DIR
was set.

Two changes beyond the script swap:

- The compiler is the clang-cl bundled with Visual Studio, the script's
  default. The old script's bare "clang-cl" resolved to the LLVM on the
  runner image's PATH, 20.1.8 on x64 and 22.1.8 on arm64; both arches
  now build with the 22.1.3 VS 18.9 ships. Cached dependencies are only
  rebuilt when deps/ changes, so they stay on the LLVM they were built
  with; the arm64 leg already links deps built with Clang 19 into a
  Clang 22 slicer.
- The deps job no longer zips the dependencies afterwards. The zip was
  never uploaded and was not in the cached path.

A failed cmake --build now fails the job. The old script returned 0, so
the arm64 failure fixed in #15719 was reported as success and the
half-built dependencies were saved to the cache.

build_release.bat, build_release_vs.bat and build_release_vs2022.bat are
removed; nothing referenced them any more.

* ci: run Build all when the Windows build script changes; tests doc builds the deps

The push filter of build_all.yml never listed a build script, and CI
now depends on build_win.bat, so it and its test suite join the list
the pull_request filter already has.

tests/AGENTS.md told Windows to run build_win.bat --run-tests, which
only implies -s and stops at the dependency check on a clean checkout.
The old build_release_vs.bat tests built the dependencies first, so
the line now says -ds --run-tests.
2026-09-16 09:50:27 -03:00
SoftFever ade9e77b6b Run every profile maintenance job from one tool (#15726)
* Run every profile maintenance job from one tool

orca_id_tool.py becomes orca_profile_tool.py, and orca_extra_profile_check.py
and orca_filament_lib.py fold into it as subcommands: check, generate-id, fix,
trim, update-index and update-snapshot. The three scripts already overlapped --
the checker imported half of its rules from the id tool, which in turn kept a
copy-pasted set of output helpers to avoid the resulting import cycle -- while
disagreeing on how a vendor is enumerated, how a JSON file is read and what the
exit code means. One file settles all three.

check, normalize, trim and update-index reproduce their predecessors exactly; normalize and
update-index were diffed byte-for-byte against the old scripts over a copy of
the whole tree. Deliberate changes: the compatible-printers check no longer
switches itself off when --check-materials is passed, an error exits 1 rather
than -1, update-index honours --profile-type and reports a profile it cannot
place instead of dropping it from the index, fix and update-index gained
--dry-run, trim keeps an unindexed file some surviving profile still inherits
from, and vendors are enumerated as directories with an index -- which is why
blacklist.json, a data file that an unscoped index rebuild once wrote four empty
list sections into, loses them here and will not collect them again. The dead
rename_filament_system() helper is gone.

The suite under scripts/tests now covers the maintenance commands too, and CI
runs it; nothing there ran in CI before. No shipped profile data changes apart
from those four keys.

* update vendor index files with "python3 ./scripts/orca_profile_tool.py update-index" and "python3 ./scripts/orca_profile_tool.py normalize"
2026-09-16 19:53:23 +08:00
packerlschupfer 93c8b3f2b0 CLI: --ground-* orientation from the Lay on Face planes, and --inspect-mesh (#15073)
* CLI: --ground-face-* / --lay-flat / --center-on-bed orientation primitives

Adds the CLI counterparts to the GUI's lay-flat / face-pick gizmos.
Scripted / CI / AI pipelines can now set orientation without rendering
a wxWidgets frame; today the only way is a GUI round-trip.

New CLI actions (all operate in the mesh-local frame so they compose
with prior --rotate-* / --orient flags):

  --ground-largest-face 1     Auto-detect the largest planar-face
   or  --lay-flat 1           cluster (area-weighted), rotate so its
                              normal points -Z. Covers "this part has
                              one obvious flat side" cases.

  --ground-face-normal NX,NY,NZ    Pick the face whose mesh-local
                                   normal best matches the given
                                   vector; ground it. e.g.
                                   `--ground-face-normal 1,0,0`
                                   stands a part on its +X side.

  --ground-face-point X,Y,Z        Find the triangle containing the
                                   given mesh-local point; ground its
                                   face. Disambiguates when several
                                   faces share a normal (largest
                                   containing triangle wins).

  --center-on-bed 1                Translate so the XY bounding-box
                                   centroid lands at the bed center
                                   (derived from printable_area).

New file `src/slic3r/Utils/MeshOrient.{hpp,cpp}`:
- collect_triangles_object / compute_face_clusters — quantize
  per-triangle normals (0.001, ~0.06°) and area-weighted-average
  within clusters. Same clustering logic used by lay-flat.
- apply_ground_rotation — same math as Selection::flattening_rotate
  in the GUI (Selection.cpp:1432): world-space quaternion from the
  transformed normal to -Z, applied as offset * new_rot * old_no_offset
  on every instance of every object, then a per-instance Z-lift so the
  grounded face lands at exactly 0 (avoids "No layers were detected"
  from FP-error z≈-1e-9).
- ground_face_point uses a top-N cluster search + point-in-triangle
  test in local space; largest-area triangle wins on ambiguity.

Rationale: without these, any CLI pipeline that needs a specific
face on the bed must either encode custom rotation math per part or
break out of the pipeline into the GUI. Both are bad for
reproducibility. The --ground-face-* triple + the largest-face
auto-mode cover essentially every orientation intent expressible
in a slicing wizard.

Scope:
- `src/slic3r/Utils/MeshOrient.{hpp,cpp}` — new, ~420 lines
- `src/slic3r/CMakeLists.txt` — 2-line registration
- `src/libslic3r/PrintConfig.cpp` — 5 new CLIMiscConfigDef entries
- `src/OrcaSlicer.cpp` — 58-line handler block + 1 include

No behaviour change when the flags are absent.

(cherry picked from commit c45a9795e1)

* CLI grounding: choose among the Lay on Face planes, per object

Addresses review:
- Move the geometry of GLGizmoFlatten::update_planes() into
  libslic3r/LayOnFace and use it from the gizmo and the CLI, so the
  --ground-* options pick convex-hull faces per object and instance,
  with part transformations (--rotate-x/y) applied.
- Drop --center-on-bed, the --lay-flat alias and MeshOrient; make
  --ground-largest-face a coBool.
- Parse --ground-face-normal and --ground-face-point strictly. A point
  that only some objects contain grounds those and leaves the others.
- Fold in --inspect-mesh from #14603, reporting the same planes.
- Tests in tests/libslic3r/test_lay_on_face.cpp: bounding boxes before
  and after, rotate then ground, two objects, and a ribbed part whose
  parallel inner faces outsum its base.

* CLI --inspect-mesh, --ground-face-*: reject missing input and empty values

- Without an input file or --load-assemble-list, --inspect-mesh printed
  nothing and exited 0. Reject it up front with CLI_INVALID_PARAMS.
- An explicit empty --ground-face-normal or --ground-face-point was
  silently ignored. Only options given on the command line reach the
  transforms loop, so an empty value now fails the strict parse like any
  other malformed value.
2026-09-16 12:56:46 +08:00
packerlschupfer 9321f24959 CLI: --strict, and a warnings array in result.json (#14601)
# Description

Add `--strict` for CI and scripted pipelines, and a structured
`warnings`
array in `result.json`.

## `--strict`

A NON_CRITICAL slicing warning is logged and the slice succeeds: return
code
`0`, G-code written. That suits interactive use, but a pipeline then
ships a
slice with a warning nobody saw. With `--strict`, such a warning fails
the run
with `CLI_SLICING_ERROR` before the G-code is exported. Without the
flag,
nothing changes.

In FFF the warning that reaches this path is "support needed but
disabled"
(`PrintObject::generate_support_material`). `--no-check` skips that
check, so
`--strict --no-check` is rejected with `CLI_INVALID_PARAMS`.

`--strict` is read before any work, so it doesn't depend on argument
order and
`result.json` reports it for early failures as well.

## `result.json`

Two new top-level fields:

- `warnings`: `[{"class", ...details}]`. One class is wired:
`slicing_warning_non_critical` with `plate_id` and `text`, recorded
whenever
such a warning fires, with or without `--strict`. The array also fills
on
  runs that succeed, so `return_code` stays the verdict.
- `strict_mode`: whether `--strict` was on.

`record_exit_reson` writes `result.json` on Linux only, so both fields
exist
only there. The non-zero exit works on every platform.

## Tests

- `tests/fff_print/test_support_material.cpp` (all platforms): an
overhang
sliced with support off raises the NON_CRITICAL support-needed status,
and
  the no-check flag suppresses it.
- `tests/cli/test_cli_strict.sh` (Linux only): runs `orca-slicer`
without
flags, with `--strict`, and with `--strict --no-check`, and checks the
shell
status and `result.json` of each. It runs the built binary, so it
carries the
`RequiresApp` label, which `scripts/run_unit_tests.sh` excludes because
the
  unit-test job only receives `build/tests`. Run it with
  `ctest --test-dir build/tests -C Release -L RequiresApp`.
- CI: `unit_tests.yml` now passes `Release` on Linux too.
`build_linux.sh`
configures Ninja Multi-Config, and without a config ctest drops the
labels of
plain `add_test()` tests, so this test ran as "Not Run" instead of being
excluded. The docs that assumed Linux was single-config are corrected
too.

Built and run locally on Linux (GCC 14) on current `main`: both tests
pass,
and the touched files compile clean under Clang with `-Werror`.
2026-09-16 12:54:48 +08:00
Valerii Bokhan 3e1daccd7c Feature: Add inward wipe for external perimeters (#15407) 2026-09-15 20:01:39 -03:00
SoftFever a0ada1aa88 fix wiki links 2026-09-16 00:43:50 +08:00
Kris Austin 9409598c2a ci: run the unit-test suite under the flatpak build's bounds-checked STL (#14709)
* ci(flatpak): run the unit suite in a separate job, mirroring the other arches

Alternative to the in-job step: split build and test like the Linux/Windows/
macOS legs. The flatpak build now builds the test binaries in-sandbox (the
action's run-tests fires the module's build-only test-commands), prunes the
kept build tree to the test binaries + CTest metadata + data, and uploads it
with /app as a test asset (size reported to the run summary).

A new unit_tests_flatpak matrix job downloads that asset on a native runner,
restores the module-build symlink, and runs the suite via flatpak-builder
--run (which bind-mounts /run/build so TEST_DATA_DIR resolves) against the
GNOME SDK's bounds-checked STL. Results feed publish_test_results.

Costs a per-arch asset upload/download + a runtime install on the test
runner; the trade-off vs the in-job step is a genuine separate graph box.

* ci(flatpak): run tests via `flatpak build` to avoid rofiles-fuse

`flatpak-builder --run` sets up a rofiles-fuse overlay that this CI container
rejects (Failure spawning rofiles-fuse, exit_status: 256), even in a fresh job
with a machine-id and the runtime installed, and --disable-rofiles-fuse is not
accepted in --run mode. `flatpak build` enters the sandbox via bwrap directly,
so it sidesteps rofiles-fuse; bind-mounting the build tree at /run/build gives
the same path the compiled-in TEST_DATA_DIR expects.

* ci(flatpak): slim the test asset (strip binaries, drop source tree)

The first cut shipped ~1 GB: the test exes carried debug info (the SDK builds
with -g and only the app gets stripped) and the packaged module dir included
the whole copied source tree the tests never read at runtime. Strip the test
binaries and keep only build_flatpak/tests, tests/ (TEST_DATA_DIR) and scripts/.
The irreducible remainder is /app, which the exes link against.

* ci(flatpak): extract the test run into a reusable unit_tests_flatpak workflow

Move the flatpak test job out of build_all.yml into a reusable
unit_tests_flatpak.yml, called once per arch (Flatpak x86_64 / aarch64) the
same way the other arches call unit_tests.yml. build_all.yml keeps only the
build + asset packaging; the reusable workflow downloads the asset, runs the
suite via `flatpak build`, and uploads results as test-results-<artifact> for
publish_test_results. Drops the now-unused manifest checkout (flatpak build
does not need it).

* ci(flatpak): trim comments to the non-obvious

No behavior change.

* ci(flatpak): drop redundant caller comment

* ci(flatpak): drop redundant trim comment

* ci(flatpak): drop size-report scaffolding and redundant if-guards

* ci(flatpak): force the app module to rebuild so the test asset always exists

flatpak-builder caches modules by content hash and skips a hit, producing no
build tree and no test asset, so a re-run of the same commit would leave the
separate test job with nothing to download. Inject a per-run cache-buster into
the OrcaSlicer module's build-options (part of its cache key) so it always
rebuilds, mirroring how the other arches cache only deps and always rebuild the
app and tests. The deps modules stay cached.

* ci(flatpak): trim cache-buster comment, fix stale step name

* fix: guard H2C per-filament array reads against short config arrays

The H2C tool-ordering, wipe-tower, and g-code export paths index per-filament
config arrays by filament/tool id. A config with fewer entries than the filament
count (partial or legacy projects, minimal test configs) makes these reads run
past the end of the vector: silent under a normal STL, but UB that aborts under
the flatpak build's bounds-checked STL (_GLIBCXX_ASSERTIONS).

Route the reads through the existing clamping accessors (get_at,
get_filament_category, is_in_same_extruder) and add a small clamp helper for
filament_change_length. The guards are no-ops when the arrays are sized to the
filament count, so correctly specified configs are unaffected.

* ci(flatpak): build filament_group_tests too

The suite landed on main after this branch was cut and arrived via a later merge,
so it was missing from the target list and ctest failed the leg with
filament_group_tests_NOT_BUILT.

Not tests/all, which build_linux.sh uses: that is a Ninja subdirectory target and
this build configures with the default Makefile generator, where it does not exist.

* ci(flatpak): give the embedded-interpreter tests a valid Python home

python_test_support.hpp sets PyConfig.home to <testdir>/python when that
path resolves. WIN32/APPLE populate it with a copied bundled runtime; the
flatpak leg had no such branch, so home resolved to a directory with no
stdlib and all 21 embedded plugin tests failed at "failed to get the
Python codec of the filesystem encoding".

Symlink <testdir>/python to the bundled /app/libpython that already ships
in the flatpak (the test exe links libpython3.12.so from there via rpath),
so the interpreter initializes without duplicating the runtime.

* ci(flatpak): sync the ToolOrdering guard mirror with #14789

Match #14709's build_filament_group_context guard to the version on
#14789 (size filament_info to filament_nums, truncate filament_ids)
so the folded guard is a byte-identical mirror that drops cleanly when
#14789 merges, instead of leaving a stale hunk that conflicts on rebase.

* fix: guard WipeTower per-filament array reads against short config arrays

The BambuStudio WipeTower sync reintroduced raw per-filament array
indexing that reads out of bounds when a config leaves an array shorter
than the filament count: m_physical_extruder_map in format_line_M104/M109
(indexed even when empty), and m_filament_categories in get_wall_skip_points
and get_wall_filament_for_all_layer. Silent on a normal STL, a hard abort
under the bounds-checked STL the Flatpak build uses.

Bounds-check the physical extruder map before indexing (omitting the T
token, as the existing -1 path already does), and route the two raw
m_filament_categories reads through the clamping get_filament_category()
accessor the surrounding code already uses. No change for correctly-sized
configs.

* fix: default-initialize WallToolPathsParams fields

min_length_factor and is_top_or_bottom_layer had no default initializers, and the FillConcentric/FillConcentricInternal callers never set them, so WallToolPaths::removeSmallLines() thresholded on stack garbage. Which short extrusion lines it dropped then depended on memory layout, so concentric solid-infill output was nondeterministic between runs and across machines. Give every member a default, matching the adjacent FillParams. The perimeter path was already fine because it builds the struct via make_paths_params().

* fix: bounds-check the toolchange flush-volume and HRC per-filament lookups

GCode::set_extruder's toolchange flush-volume lookup and
GCodeProcessor::update_slice_warnings's HRC check index per-filament and
per-extruder arrays (flush_volumes_matrix, the filament map, the nozzle list)
by filament/extruder id. When a config leaves one of those arrays shorter than
the filament count (partial or legacy multi-extruder projects, minimal
configs), the reads run off the end: silent on a normal STL, a hard abort under
_GLIBCXX_ASSERTIONS.

Route both reads through bounds checks: the flush lookup falls back to no flush,
matching the existing unknown-old-filament branch beside it, and the HRC check
skips an unmapped filament, mirroring the required_nozzle_HRC guard on the line
above. When the arrays are sized to the filament count the values are unchanged,
so correctly-specified configs are unaffected.

* ci: retrigger checks

* ci: name the flatpak rebuild token after the cache it defeats

Since #15650 the Flatpak job also has a compiler cache, so a bare
"cache-buster" no longer says which cache is meant. Call it
flatpak_builder_cache_buster, and name the build-dir trim step after
the flatpak-builder cache save it keeps lean.

* ci: ship resources/profiles and resources/printers in the flatpak test asset

Two slic3rutils tests added in 4aa0e1d60b read
resources/printers/bambu_filament_ids.json through PROFILES_DIR/.., and
the asset dropped resources/ entirely, so both failed parsing an empty
stream on each Flatpak leg. Keep the two subtrees the tests reach;
test_gcodewriter's shipped-profile case stops skipping on this leg too.

* ci: restore the CRLF line endings of build_all.yml

The last merge from upstream/main rewrote the file with LF endings, which
turns the 60-line change into a whole-file diff on GitHub. Upstream has had
this file as CRLF since it was created, so put it back.

* ci: trigger Build all on changes to the unit-test workflows

The path filters only matched build_*.yml, so an edit to unit_tests.yml or
unit_tests_flatpak.yml could merge without ever running.

* ci: put a timeout on the flatpak unit-test step

Matches the 20 minutes of the regular unit-test workflow; without it a hung
test holds the runner for the six-hour job default.
2026-09-15 12:41:46 -03:00
Kris Austin 7e545651bb deps: compile unicodectype.c unoptimised in the Windows arm64 Python (#15719)
VS 2026's ARM64 code generator needs about 27 GB for
_PyUnicode_ToNumeric, a switch with 1951 cases in
Objects/unicodetype_db.h; the same file takes under 1 GB on x64. The
16 GB CI runner has an 18.9 GB commit limit and only gets through when
Windows grows the pagefile on the temp disk in time, so cold arm64
dependency builds fail at random with C1002 "compiler is out of heap
space". build_release_vs.bat returns 0 on failure, so the job still
reports success and the incomplete dependencies are cached.

A property sheet compiles that one file with optimisation off on arm64;
the rest stays whole-program optimised and x64 is unchanged.
MSBuild reads it from PCbuild/msbuild.rsp, which is now written at
configure time and copied in, so a checkout path with spaces works too.
2026-09-15 11:55:11 -03:00
Kris Austin ac3997c0d1 fix: bounds-check the toolchange flush-volume and HRC per-filament lookups (#15289)
* fix: bounds-check the toolchange flush-volume and HRC per-filament lookups

GCode::set_extruder's toolchange flush-volume lookup and
GCodeProcessor::update_slice_warnings's HRC check index per-filament and
per-extruder arrays (flush_volumes_matrix, the filament map, the nozzle list)
by filament/extruder id. When a config leaves one of those arrays shorter than
the filament count (partial or legacy multi-extruder projects, minimal
configs), the reads run off the end: silent on a normal STL, a hard abort under
_GLIBCXX_ASSERTIONS.

Route both reads through bounds checks: the flush lookup falls back to no flush,
matching the existing unknown-old-filament branch beside it, and the HRC check
skips an unmapped filament, mirroring the required_nozzle_HRC guard on the line
above. When the arrays are sized to the filament count the values are unchanged,
so correctly-specified configs are unaffected.

* ci: retrigger checks
2026-09-15 09:46:31 -03:00
Tommaso Bianchi 4ebac62519 Extrude accepts a negative distance, and the Bodies card gains Boolean 2026-09-15 14:40:14 +02:00
Kris Austin bd1304443c fix: guard per-filament array reads against short config arrays (#14789)
* fix: guard H2C per-filament array reads against short config arrays

The H2C tool-ordering, wipe-tower, and g-code export paths index per-filament
config arrays by filament/tool id. A config with fewer entries than the filament
count (partial or legacy projects, minimal test configs) makes these reads run
past the end of the vector: silent under a normal STL, but UB that aborts under
the flatpak build's bounds-checked STL (_GLIBCXX_ASSERTIONS).

Route the reads through the existing clamping accessors (get_at,
get_filament_category, is_in_same_extruder) and add a small clamp helper for
filament_change_length. The guards are no-ops when the arrays are sized to the
filament count, so correctly specified configs are unaffected.

* fix: size the grouping context's filament_info to the filament count

build_filament_group_context built model_info.filament_info by walking
filament_type, so a config whose filament_type is shorter than the filament
count produced a short vector. FilamentGroup indexes filament_info by filament
id, so clamping the individual reads only moved the out-of-bounds access
downstream. Loop to filament_nums and read all three fields through get_at,
and drop filament_ids entries past the filament count, since the grouping code
pairs filament_ids and filament_info by position.

Adds a regression test with four filaments and one-entry filament_type /
filament_is_support. Without the fix it throws bad_alloc from copying a garbage
std::string read past the end.

* fix: guard the carousel nozzle-change length reads too

The carousel branch added in b90ac13d86/b0dddb4648 reads
m_filaments_change_length by tool id without a bounds check, the same
pattern this branch already routed through filament_change_length_at
a few lines above in both plan_toolchange and plan_tower_new.

* fix: guard WipeTower per-filament array reads against short config arrays

The BambuStudio WipeTower sync reintroduced raw per-filament array
indexing that reads out of bounds when a config leaves an array shorter
than the filament count: m_physical_extruder_map in format_line_M104/M109
(indexed even when empty), and m_filament_categories in get_wall_skip_points
and get_wall_filament_for_all_layer. Silent on a normal STL, a hard abort
under the bounds-checked STL the Flatpak build uses.

Bounds-check the physical extruder map before indexing (omitting the T
token, as the existing -1 path already does), and route the two raw
m_filament_categories reads through the clamping get_filament_category()
accessor the surrounding code already uses. No change for correctly-sized
configs.
2026-09-15 09:03:20 -03:00
Lam Wei Lun bb3a260acb Update YouTube URL for Publish 3MF dialog (#15718) 2026-09-15 18:31:15 +08:00
Lam Wei Lun 2b6eb425e4 Update YouTube URL for publish 3MF guide 2026-09-15 18:04:06 +08:00
HanifKoh 0956b4d8fe Add a Nightly Parity Workflow (#15712)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Adds a nightly workflow that runs the long parity checks from
[orca-test-repo](https://github.com/OrcaSlicer/orca-test-repo), which
are too slow for the per-build "Run external slicer regression tests"
step and are kept out of every PR and merge build.

## What it runs

`.github/workflows/parity_nightly.yml`, three jobs:

| Job | What it does |
|---|---|
| Find the build to test | Picks the latest successful `build_all.yml`
run for the branch (`main` by default) and records its commit. |
| Override sweep effect stage (shard 0 and 1) | Runs orca-test-repo's
override sweep with `--effect-full`: every config option that lands on
the CLI is re-sliced on its own to check that it actually changes the
G-code. Split into 2 shards, each with a 60-minute timeout. |
| GUI-vs-CLI parity harness | Slices a set of fixtures in the GUI
(headless under Xvfb) and on the CLI, compares the exports, and scores
divergences against a known-differences ledger. It reports only and
never fails on a divergence. |

## When it runs

- **Every night at 21:00 UTC,** after `build_all.yml`'s 17:00 UTC run
has finished.
- **By hand** through `workflow_dispatch`, with optional inputs:
  - `build_branch`: the branch whose latest successful build to test;
  - `test_repo_ref`: the orca-test-repo ref;
  - `fixtures`: a subset of harness fixtures;
  - `cli_presets`: `flat` or `raw`.
- **No `push` or `pull_request` trigger,** so nothing here runs on PRs
or merges. The per-build CI step is unchanged.

## How it tests a build

- **Binary:** the Linux x86_64 AppImage from the chosen `build_all` run.
- **Source:** OrcaSlicer checked out at that run's exact commit. The
AppImage only ships packed preset caches, so profiles and the CLI option
list come from this checkout, matched to the binary.
- **Output:** each job writes a summary to the run page and uploads its
report (`override-report-shard*`, `parity-scorecard`) for 30 days.
- **Failures:** a failing effect shard fails the run, and GitHub's usual
failure notification for scheduled workflows applies.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

- Dispatched on this branch against orca-test-repo `main` and #15693's
build ([run
34933239912](https://github.com/OrcaSlicer/OrcaSlicer/actions/runs/34933239912)).
Every job passed:
- **effect shard 0:** 19m31s; 225 options sliced, 151 effective, none
crashed or hung, every fixture sliced;
  - **effect shard 1:** 19m33s; 349 options sliced, 254 effective, same;
  - **harness:** 4m32s; all 13 fixtures, 0 new divergences, 0 errors.
- An earlier dispatch on this branch, testing #15693's build against
orca-test-repo's parity branch ([run
34836468900](https://github.com/OrcaSlicer/OrcaSlicer/actions/runs/34836468900)),
passed: effect shards in 20m28s and 23m30s, and the harness reported 0
new divergences.
- The workflow only runs from the default branch on its schedule, so the
nightly trigger itself takes effect once this is merged.

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-15 15:01:35 +08:00
HanifKoh 5514559feb Load Each Vendor Tree Once When the CLI Resolves System Presets (#15693)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Since #15438, every CLI run that loads a system preset spends about a
second per preset file re-parsing that vendor's entire profile tree. A
slice with a machine, process and filament preset got roughly 2.5 s
slower, and a four-filament slice roughly 4 s slower. This PR loads each
vendor tree once per run instead. Resolved presets and G-code are
unchanged.

The GUI never takes this path, and no release contains #15438, so the
regression only affects CLI runs on current dev and nightly builds. That
includes print farms, slicing services and plugins that call
`orca-slicer --slice`, and CI suites.

## Changes

### Why it was slow

`PresetBundle::resolve_preset_config` resolves a system preset through
its vendor manifest by loading the whole OrcaFilamentLibrary bundle and
the whole vendor tree from JSON, then picking the one preset out. The
CLI did that separately for every `--load-settings` and
`--load-filaments` file, on a fresh `PresetBundle` each time. With BBL
presets, a machine + process + filament run opened `BBL.json` three
times and read BBL's 2,879 profile files and the library's 512 three
times over.

### Load each vendor tree once

- `PresetBundle` keeps every vendor bundle its manifest path loads,
keyed by source root, vendor and substitution rule, and reuses them for
later resolutions on the same bundle.
- OrcaFilamentLibrary is cached the same way, so vendors under one root
share a single library load and the library's own presets resolve from
that same instance. A vendor bundle only reads from its base while
loading, so sharing it is safe.
- A failed or throwing load is not kept, so error reporting is
unchanged.
- The key includes the source root, so presets from two different
profile roots still resolve separately.
- The CLI resolves every system preset through one `PresetBundle` for
the whole run, instead of creating one per file.

The resolved configurations still come from the same canonical vendor
loader, so what a preset resolves to does not change. Only the CLI calls
`resolve_preset_config`, so a long-lived GUI bundle cannot end up
holding profile trees that later change on disk.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

CLI slice of a 20 mm cube with X1 Carbon system presets. Both builds get
the same datadir, best of 3, Linux. "Before" is this PR's base from CI.

| System presets loaded | Before | After |
|---|---|---|
| machine | 0.95 s | 0.87 s |
| machine + process | 1.67 s | 0.92 s |
| machine + process + 1 filament | 2.51 s | 0.97 s |
| machine + process + 4 filaments | 5.00 s | 0.99 s |

Files opened during the machine + process + filament run (`strace -e
openat`):

| | Before | After |
|---|---|---|
| `BBL.json` | 3 | 1 |
| `OrcaFilamentLibrary.json` | 3 | 1 |
| `system/BBL/**/*.json` | 8,634 | 2,880 |
| `system/OrcaFilamentLibrary/**/*.json` | 1,536 | 512 |

Peak memory did not rise: max RSS 306 MB → 286 MB for the three-preset
run, and 305 MB → 285 MB for four filaments. The "before" figure is an
AppImage, so part of that gap is probably packaging.

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

- New test "Manifest-backed resolution reuses the vendor tree it already
loaded" in `tests/libslic3r/test_preset_bundle_loading.cpp`. It resolves
one preset, changes the parent profile on disk, then resolves a sibling.
The same bundle returns the value it already loaded, and a fresh bundle
picks up the change.
- New test "Manifest-backed resolution shares the library between
vendors under one root". It resolves through one vendor, changes a
library profile on disk, then resolves through a second vendor and a
library preset on the same bundle. Both return the value already loaded,
and a fresh bundle picks up the change.
- All `[Preset][Bundle]` tests pass (87 test cases, 1,069 assertions),
including the existing manifest-backed resolution cases for source-root
scoping, malformed vendor loads, missing parents and type mismatches.
- G-code of the three-preset slice is identical before and after, header
lines excluded.
- The external CLI regression suite passes. Two cases report as
unexpectedly passing because #15639 fixed their bug. They pass the same
way on this PR's base without the change.
- A GUI-vs-CLI parity run over 10 fixtures shows no new differences.
- Builds clean on Linux (Release, with tests).

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-15 15:01:24 +08:00
Hanif Koh d5cf1502c4 Share One Library Load Between Vendors in the CLI Preset Resolver
The manifest resolver loaded OrcaFilamentLibrary once per vendor it
resolved through, so a run that mixes vendors parsed the library tree
again for each of them. The library is now cached like any other vendor
tree, keyed on its root and substitution rule, and doubles as the base
every vendor under that root loads against. A vendor bundle only reads
from its base while loading, so sharing the instance is safe.

The cache key carries the substitution rule as its enum, and the lookup
lambdas take a const bundle since they only read.
2026-09-15 13:31:30 +08:00
HanifKoh 37e2b6c928 CLI: let --export-settings - write the merged config JSON to stdout (#15698)
`--export-settings` already writes the merged config as JSON at the
right point in the CLI flow. Passing `-` now writes that same document
to stdout, so scripts can inspect the effective config without a temp
file. This replaces #14605.

- `ConfigBase::save_to_json` gains a stream overload. The file overload
serializes through it before opening the file, so the output format is
unchanged, and a config that cannot be serialized (invalid UTF-8) now
leaves the existing file untouched instead of truncating it.
- On stdout, invalid UTF-8 in string values is written as U+FFFD instead
of ending the process with an uncaught `type_error`. Files keep the
strict behaviour.
- To keep stdout pure JSON, `-` is rejected up front (stderr message,
`CLI_INVALID_PARAMS`, shell status 254) when combined with an action or
transform that can write to stdout or does real work: `--info`,
`--help`, `--orient`, slicing and exporting. Options that do nothing
without a slice (`--uptodate`, `--min-save`, `--pipe`, ...) are still
accepted.
- The one unconditional stdout write on a success path, "skip locked
instance" during arrange, now goes to the log.
- Every other value, including the default `output.json`, behaves as
before.

Tests in `tests/libslic3r/test_config.cpp`: the stream output equals the
file output and keeps the tab-indented format; invalid UTF-8 throws on
the strict path and is replaced when asked; a failed save leaves the
previous file intact.
2026-09-15 13:16:22 +08:00
Kris Austin efc9f253ee fix: resolve relative input paths given on the command line (#14803)
Opening a model with a relative path, for example `orca-slicer ./some.3mf`,
failed with "Loading of a model file failed." and "The file does not contain
any geometry data.", while the same file opened by an absolute path or by
drag and drop worked.

GUI_App::init_app_config() changes the working directory to <data_dir>/log,
and it runs from the GUI_App constructor because the app config is needed
early for instance checking. The input files are opened much later, in
post_init(), so a path still relative at that point resolved against the log
directory instead of the directory OrcaSlicer was started from, and the 3MF
reader failed to open it.

Resolve the input paths in CLI::setup(), which runs before GUI_App is
constructed and therefore before the working directory moves. Absolute paths
are returned unchanged, so the forms that open today are unaffected, and
custom open protocol URLs are passed through since post_init() hands those to
the downloader rather than the file loader.

The working directory change is left alone. It was added in #3248 so the TUTK
logs land in the data directory instead of the working directory (#3209).
2026-09-15 12:47:01 +08:00
Kris Austin 292cf0095e drop the per-frame mouse raycast that only a drag start reads (#15664) 2026-09-14 18:31:23 -03:00
Kris Austin 5c635d5e50 build: scope -Werror to the Clang family so GCC builds again (#15701) 2026-09-14 17:04:03 -03:00
Noprazandyw4z 5496883493 fix(profiles): Snapmaker U1 — cap ABS/ASA/PPS bed temps at 100 °C (#15483)
The U1's heated bed tops out at 100 °C, but these profiles requested
105-110 °C, which leads to print errors unless the user modifies the
printer's firmware configuration.

Affected profiles:
- Snapmaker ABS @U1 base (110/105 → 100)
- Snapmaker ASA @U1 base (110 → 100)
- Fiberon ASA-CF08 @Snapmaker U1 base (105 → 100)
- Fiberon PPS-GF20 @Snapmaker U1 base (105 → 100)

Bumps Snapmaker.json to 02.04.00.10.

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-14 20:44:15 +03:00
packerlschupfer 31eb8a2bd1 CLI: let --export-settings - write the merged config to stdout
--export-settings already writes the merged config as JSON at the right
point in the CLI flow. Passing - writes the same document to stdout.

- ConfigBase::save_to_json gains a stream overload. The file overload
  serializes through it before opening the file, so the format is
  unchanged and a config that cannot be serialized leaves an existing
  file untouched instead of truncating it.
- On stdout, invalid UTF-8 in string values is written as U+FFFD instead
  of ending the process with an uncaught type_error; files keep the
  strict behaviour.
- - is rejected up front when combined with an action or transform that
  can write to stdout or does real work, so stdout carries only the
  JSON.
- The unconditional "skip locked instance" stdout write during arrange
  now goes to the log.
- Tests in tests/libslic3r/test_config.cpp.
2026-09-14 19:35:29 +02:00
Daniel Williams 70247ad298 Extract Layer::choose_ironing_extruder for unit-testable ironing routing (#13467)
* Extract Layer::choose_ironing_extruder for unit-testable ironing routing

The ironing extruder selection in make_ironing() was a 5-line nested
conditional inlined at the top of the loop, with no isolated test
coverage. Pull the gating into a static helper so the routing decision
is unit-testable without spinning up the slicing pipeline.

Pure refactor: the helper preserves the original logic bit-for-bit
(NoIroning -> -1; AllSolid always enabled; TopSurfaces and TopmostOnly
require some top shells or, in spiral mode, more than one bottom shell;
TopmostOnly additionally requires being on the topmost layer; enabled
ironing routes to solid_infill_filament).

Add tests/fff_print/test_choose_ironing_extruder.cpp covering:
- AllSolid regardless of layer position
- TopSurfaces with top_shell_layers > 0
- TopSurfaces with top_shell_layers=0 + spiral mode + bottom_shell_layers>1
- TopmostOnly + topmost layer
- NoIroning short-circuit
- TopSurfaces with top_shell_layers=0 (and not spiral) -> disabled
- TopSurfaces, spiral, but bottom_shell_layers=1 -> disabled
- TopmostOnly on a non-topmost layer -> disabled

* Move ironing routing test into the Fill subsystem file

Rename the test to tests/libslic3r/test_fill.cpp and tag it [Fill] to
match the subsystem it covers, use flat behavioral test cases with
GENERATE for the parameterized ones, and drop the history narration from
the code comments.

* tests: move ironing routing tests into fff_print/test_fill.cpp

Keeps the Fill tests in one file, alongside the existing ironing
rotation-template test.
2026-09-14 09:37:04 -03:00
Hanif Koh d4840901fc Test That Failed Vendor Loads Are Not Kept and the Library Base Is Reused
Cover the two cache paths the first test left open: a vendor tree that fails to load is retried on the next resolution instead of being served from the cache, and a type-probed filament resolved through resolve_preset_config_type reuses the OrcaFilamentLibrary base already loaded for a sibling.
2026-09-14 18:51:48 +08:00
Hanif Koh 5f01f21661 Load Each Vendor Tree Once When the CLI Resolves System Presets
Resolving a system preset through its vendor manifest loaded the whole vendor tree and the filament library from JSON, and the CLI did that separately for every --load-settings and --load-filaments file. A run with machine, process and filament presets parsed BBL's 2,879 profile files and the library's 512 three times over, about a second each.

Keep the library and vendor bundles loaded by the manifest path on the PresetBundle that resolved them, keyed by source root, vendor and substitution rule, and have the CLI resolve every system preset through one bundle for the whole run. A failed load is not kept, so errors are reported as before.

On a cube slice with X1C machine, process and PLA presets: 2.42 s -> 0.93 s, BBL.json opened once instead of three times, identical G-code.
2026-09-14 17:44:17 +08:00
Lam Wei Lun ecbe1b1b90 UI Bug fixes and code cleanup for Publish 3MF Dialog (#15690)
# Description
- Fixes an issue on macOS where the modified indicator can be cut-off.
- Remove unused code
2026-09-14 16:57:41 +08:00
Lam Wei Lun ffb4f192c1 Fix macOS UI issue in publish dialog. Remove item_size helper in TabCtrl and its relevant setter 2026-09-14 14:19:25 +08:00
Hanif Koh 4373bc3697 Add a Nightly Parity Workflow
Runs orca-test-repo's full override-sweep effect stage (two shards) and the GUI-vs-CLI parity harness every night against the latest successful build_all Linux AppImage, with sources checked out at that build's commit. Kept out of the per-build regression step, whose time budget it would exceed, and never gates a build.
2026-09-14 13:38:51 +08:00
Tommaso Bianchi edb6aa1722 Sync cad-mainline with upstream main and carry the value-field + rename work on top 2026-09-10 11:01:24 +02:00
Tommaso Bianchi 31a15cc5e5 Rename snaporca/SnapOrca to orca_cad so the OrcaSlicer PR carries no Snapmaker naming 2026-09-10 10:58:46 +02:00
SoftFever 994d2de5d6 Merge branch 'main' into pr/tommasobbianchi/15238 2026-09-08 21:54:22 +08:00
SoftFever 12d43433dc Merge branch 'main' into pr/tommasobbianchi/15238 2026-09-08 14:28:31 +08:00
Tommaso BianchiandClaude Opus 5 498e92ee35 The gate covers the rounded rectangle, and stops tripping over the plug-in modal
Two harness fixes, both paid for by hours of chasing product bugs that were not there.

ROUNDED RECTANGLE. It is the shape the user reported and the gate could not reach it:
the rectangle family binds R to CornerRect and leaves the other modes in the toolbar
flyout, so the three-step Width -> Height -> Radius chain was never exercised.
rung_rounded_rect() arms it over MCP the way the offer menu does. The verb id is the
OFFER id `sk_rect_rounded` — `design_rect_rounded` is the ACTION name, run_verb
throws on it, and the tool silently stays Select; a run that misses that draws
nothing and still reaches its assertions, so the rung arms AND verifies.

THE NETWORK PLUGIN MODAL. GUI_App::post_init() re-raises "Bambu Network Plug-in
Required" from an IDLE event, after any startup sweep has closed it, and
ShowModal() runs a nested event loop: the app is alive, its window is on screen,
and the MCP socket answers nothing. That is indistinguishable from a hang and was
investigated as one, with gdb, twice — the attached stack finally read
ShowModal <- show_network_plugin_download_dialog <- post_init. Seeding
`installed_networking` false stops the whole networking path, so the dialog never
exists to be swept.

Also: check() returns its verdict, so a rung can abandon itself when a precondition
fails instead of asserting into a dead end.

38 checks hold on behemoth against e5659e0f0f.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 12:22:02 +02:00
Tommaso BianchiandClaude Opus 5 1dff232f0b The gesture ladder stops clicking the field before it types
check-gui-sketching.py located the value field by hunting for a small top-level
window and clicked into it before typing. Both halves are now wrong, and the second
was always a problem:

 - field_win() cannot find a field that is not a window any more, so every check
   built on it silently became one that cannot fail. Replaced by field_open(),
   which asks the app: sketch_describe's `editing` is value_field_open().

 - focus_field() clicked into the field first, and its own docstring said why —
   "WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED". That workaround is exactly
   what made this suite blind to the defect the user reported: a ladder that clicks
   the field first can never notice that typing WITHOUT clicking is broken. Now a
   documented no-op; the field is in the canvas and the canvas has the keyboard.

Measured on behemoth against e5659e0f0f: 64 checks pass, 2 fail. The two are
polygon regularity and area (sides {29.999986, 29.975164, 30.0}, area 2336.98 vs
2338.27) — geometry tolerances, nothing to do with typed values; before this change
the same run failed 5, all of them dimensions and constraints that never received
their value because focus_field() was clicking at a window that no longer exists.
No baseline exists for the remaining two, so they are reported, not claimed as
pre-existing. The suite also still stops at D3 with the app gone; tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 11:26:12 +02:00
Tommaso BianchiandClaude Opus 5 e5659e0f0f The value field stops being a window, and now takes what is typed
Rebases the in-canvas work onto cad-mainline and finishes it. The field is drawn
by ImGui inside the GL canvas instead of being a borderless top-level wxFrame.

WHY THE FLOATING FRAME COULD NOT BE FIXED. Whether a borderless top-level may hold
the keyboard is the window manager's decision, and it differs per desktop: openbox
grants it, mutter refuses it, macOS denies key status outright. Seven workarounds
fought that and one cost a macOS regression. Drawn inside the canvas there is no
second top-level for anyone to refuse, so the question is never asked. The field is
fed exactly like every other ImGui widget in the app — GLCanvas3D::on_char ->
ImGuiWrapper::update_key_data -> io.AddInputCharacter.

MEASURED, on behemoth: the click-edit ladder holds 28 checks — Line, Rectangle,
Circle, Slot, Polygon, Ellipse, Arc, and click-to-edit on a placed dimension label
— typing with NO click into the field first, committed == typed != prefill every
time, and 27 [UX] imgui_char lines showing the characters arriving.

WHAT WAS ACTUALLY WRONG. Not the field. The belief that "characters never reach the
ImGui InputText" came from the harness: the ladder was delivering keys with
`xdotool type --window` (XSendEvent), which GTK discards, so no build of any kind
could have received them. The new probe in ImGuiWrapper::update_key_data — the one
place ImGui is ever handed a character — is what separated that from a real defect,
and it stays, because a canvas-side probe provably cannot answer the question:
GLCanvas3D::on_char is bound later than any constructor-time probe, wx runs handlers
in reverse bind order, and on_char returns without Skip(), so such a probe is silent
whether or not the key arrived. A day was lost reading that silence as evidence.

Also drops DesignPanel's content-based forwarder and DesignCanvas::inline_type_char.
They were the right rule for a field that could not be focused; with the field
inside the canvas there is nothing to forward, and keeping them would have masked
whether the normal path works.

STILL UNVERIFIED: behaviour under mutter itself. Neither focus-stealing-prevention
WM available here survives long enough to judge — metacity SEGVs ~20s in and xfwm4
dies with BadWindow on SetInputFocus, both before the sketch opens and both
unrelated to this field. The design's claim is structural rather than measured: no
second top-level means no focus to refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 11:10:02 +02:00
Tommaso BianchiandClaude Opus 5 f6c551540e The key tracer had been printing one letter of the answer all along
focus= in [KEYTRACE] was never a class name. GetClassName() returns const wxChar* — wchar_t* in
this build — and the line cast that to const char* and printed it with %s, so it emitted the first
byte and stopped at the padding NUL. "wxGLCanvas" came out as "w". So did "wxWindow". Every focus
reading taken from this instrument for a whole day of diagnosis was a single character, and the
one question it existed to answer — WHICH widget has the keyboard — was the one it could not
answer. Printed through wxString now, and it says focus=wxGLCanvas: the canvas does hold wx focus
while the field is open, which removes the focus hypothesis for good.

Also here, and HONESTLY LABELLED AS INCONCLUSIVE: a wxEVT_CHAR probe on the canvas. It logged
nothing (cc=0), and the tempting reading is "the characters never reach the canvas". That reading
is not available, because the probe is bound in the DesignCanvas constructor BEFORE
GLCanvas3D::bind_event_handlers(), and wx runs the most recently bound handler first —
GLCanvas3D::on_char returns without Skip() exactly when ImGui consumes a character, which is
precisely the case under test. A silent probe is therefore consistent with ImGui consuming the
keys correctly AND with them never arriving. It measures nothing. Rebind it after
bind_event_handlers(), or instrument update_key_data itself, before believing anything about it.

Writing this down rather than acting on it: I came within one commit of "fixing" a mechanism I had
inferred from an instrument that could not see it, which is the same mistake as the focus= field
above and the same mistake that cost a whole session in September.

Deployed binary restored to 00d6c191dc (md5 0eeb9a58cef5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 10:58:05 +02:00
Tommaso BianchiandClaude Opus 5 b45d675488 The frames now keep coming; the characters still do not
Third measured step, and the last one I will take without a second pair of eyes.

set_as_dirty() BEFORE Refresh(): GLCanvas3D's paint handler returns without rendering when the
canvas is not marked dirty, so the previous commit's bare Refresh() posted paint events that drew
nothing and the frames stopped anyway. With both halves the pump sustains, and the trace shows the
field holding the keyboard frame after frame:

  [UX] frame want_text=1 want_kb=1 active=1 buf=158.74
  [UX] frame want_text=1 want_kb=1 active=1 buf=158.74   (repeating)

STILL OPEN, and now narrowed to one question: buf never changes. ImGui owns the keyboard and our
InputText is the active item, so what is missing is upstream of ImGui — the characters are not
reaching io.AddInputCharacter at all. The next thing to MEASURE (not to change) is whether
wxEVT_CHAR arrives at the GL canvas in the Design tab: DesignPanel's wxEVT_CHAR_HOOK Skips digits
while inline_busy(), but Skip only helps if the focused widget is the canvas, and nothing has yet
proved that it is at the moment the keys are sent.

Three attempts have now gone into this one point. Per the standing rule that is where solo
iteration stops.

Deployed binary restored to 00d6c191dc (md5 0eeb9a58cef5) — nothing from this branch is installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 10:58:05 +02:00
Tommaso BianchiandClaude Opus 5 d82c8b59c2 The field now owns the keyboard; what it does not yet own is the characters
Measured, not reasoned. A per-frame trace of the ImGui state is what finally named the mechanism,
and it is a deadlock, not a focus problem:

  [UX] frame want_text=0 want_kb=0 active=0 buf=158.74   <- frame 1: the widget is not active yet
  [UX] frame want_text=0 want_kb=0 active=1 buf=158.74   <- frame 2: now it is
  (nothing further)                                       <- and the canvas stops

This canvas repaints ON DEMAND. ImGui decides whether it wants the keyboard at the END of a frame,
from the active item; GLCanvas3D::on_char only calls render() when update_key_data() says ImGui
wants it. So: no frames -> WantTextInput never turns on -> no render on a keystroke -> still no
frames. The characters sit in ImGui's input queue and the field is exactly as deaf as the window
it replaced, for a completely different reason.

request_frame breaks the circle, and the same trace says so:

  [UX] frame want_text=1 want_kb=1 active=1

That is the first time in this file's history that the value field has owned the keyboard without
asking a window manager for it.

STILL OPEN: the typed characters do not reach the buffer (buf stays at the prefill) and the frames
stop after nine. The pump is the suspect — on software GL request_repaint() calls m_canvas->render()
SYNCHRONOUSLY, so this asks for a render from inside a render; it needs to schedule one instead.
That is the next thing to measure, not to guess.

The deployed binary on behemoth is restored to 00d6c191dc (md5 0eeb9a58cef5), byte-identical to
the last good build. Nothing from this branch is installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 10:58:05 +02:00
Tommaso BianchiandClaude Opus 5 5ab3072b9f WIP: the value field stops being a window — renders in-canvas, does not yet take keys
The decision (Tommaso's, put to him with the trade-offs): the field stops being a separate
top-level window, because whether such a window may receive typing is the window manager's
call and not ours. openbox grants it, mutter on his desktop refuses, and seven previous
workarounds fought that — one of them causing a macOS regression, and the test harness ending
up clicking the field before typing, which is a workaround no user can be asked to perform and
is exactly the "label value not editable" report.

DONE and proved on the rig: SketchInlineEditor is no longer a wxFrame + wxTextCtrl. It is state
plus an ImGui overlay drawn by DesignSketchTool::render(), at the same screen anchor, in the same
vocabulary as the dimension labels next to it (draw_dim_label is already an ImGui window). The
field opens where it should — [UX] open title=Length prefill=158.74 from the running app.

NOT DONE: typing does not reach it. ImGui is fed from GLCanvas3D's own key handler, so the keys
have to arrive at the canvas; giving the canvas wx focus when the field opens was not enough.
The remaining question is where a keystroke goes between DesignPanel's wxEVT_CHAR_HOOK and
GLCanvas3D::on_char in the Design tab, and whether the canvas repaints often enough for ImGui to
advance its input state. That is attempt three on this specific point, so it goes to a second
opinion rather than a third guess.

Also here: scripts/CAD/check-gui-click-edit.py, the ladder Tommaso asked for. It types into the
field WITHOUT clicking it first — the click is what check-gui-sketching.py's focus_field() does
and why that suite can never see this defect — and fails when the prefill is what gets committed.
It currently fails, correctly, on the above.

Not on cad-mainline: the deployed binary must stay the last good build until typing works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 10:58:05 +02:00
Tommaso BianchiandClaude Opus 5 0fee4494e2 Never ask for activation with timestamp 0
present_toplevel() already asked for focus with a server timestamp, but only when
the widget happened to be realized. An unrealized widget has no GdkWindow, so
there was nothing to read a timestamp from and control fell through to
wxFrame::Raise() — which asks for activation with GDK_CURRENT_TIME, i.e. 0.

Zero is exactly what focus-stealing prevention discards. metacity says it out loud
when a sketch value field opens:

    Buggy client sent a _NET_ACTIVE_WINDOW message with a timestamp of 0

and mutter, same lineage, refuses it silently on the user's desktop. That refusal
is the reported defect: the field is visible, never receives the keyboard, and
Enter commits the as-drawn prefill.

So realize the widget and retry, and do NOT fall back to Raise() on X11 — a
timestamp-0 activation is refused anyway, and on some window managers it only
marks the window as demanding attention.

Not yet confirmed end to end: both window managers with focus-stealing prevention
available here abort on this frame — metacity at frames.c:1239, xfwm4 with
BadWindow on SetInputFocus as the frame is destroyed under it — so the ladder
cannot yet return a trustworthy verdict under one. Two WMs crashing on the same
borderless, repeatedly re-mapped STAY_ON_TOP frame is its own signal about this
design. Tracked in projects-1p5 and projects-40m.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 10:39:50 +02:00
Tommaso BianchiandClaude Opus 5 9134299233 Sketch value fields: content-based key arbiter + the gate that can judge it
The reported defect: sketch dimension labels are "not editable" — you draw a
rectangle, its Width field opens, you type, and the as-drawn number is committed
instead. It affects every sketch tool, not just the rounded rectangle.

WHAT THIS ADDS

1. The arbiter (DesignPanel CHAR_HOOK -> DesignCanvas::inline_type_char ->
   SketchInlineEditor::type_char). Routes a key by what it IS, not by who the
   window manager focused: digits, sign, decimal separator and Backspace/Delete
   go to the open value field, Enter/Tab commit, letters stay tool shortcuts.
   This is FreeCAD Sketcher's rule (DrawSketchKeyboardManager::
   detectKeyboardEventHandlingMode), and the reason its sketcher behaves the same
   on every desktop: it never asks who has focus.

2. The [UX] trace (SNAPORCA_UXTRACE) in SketchInlineEditor: open/commit/refused/
   cancel, with the prefill and what the control actually held at Enter. It did
   not exist — the ladder below was written against a surface no build emitted,
   so it could only ever report "nothing opened". typed == prefill on a commit is
   the defect's signature and nothing else makes it visible.

3. A draw-then-edit trace in DesignSketchTool: four early returns can swallow the
   value-field chain and from outside they are indistinguishable.

4. scripts/CAD/check-gui-click-edit.py — types WITHOUT clicking the field, as a
   person does, across Line/Rectangle/Circle/Slot/Polygon/Ellipse/Arc plus label
   click-to-edit, and asserts committed == typed != prefill.

5. scripts/CAD/focus-loop.sh — sync/build/assert on behemoth. NOT the orcacad-gui
   rig: its image pins deps 216 non-CAD files behind cad-mainline, so today's CAD
   sources cannot build there without a deps rebuild.

WHAT IS PROVEN, AND WHAT IS NOT

Green under openbox: 28 checks, every tool, committed == typed != prefill.

But openbox CANNOT adjudicate this bug and the ladder says so in place. There the
field always wins the keyboard, so the same ladder also passes against a binary
with the arbiter compiled out — measured twice. Two ways of removing the keyboard
were tried and both are recorded as dead ends: XSetInputFocus loses to the field's
own re-focus CallAfter, and XSendEvent (xdotool --window) is dropped by GTK, which
made every run red regardless of the code.

Under metacity — same focus-stealing-prevention lineage as the user's mutter — the
mechanism appears in the WM's own log:

    Buggy client sent a _NET_ACTIVE_WINDOW message with a timestamp of 0

That is the activation being refused, which is exactly the reported symptom.
present_toplevel() already asks for a server timestamp, so a path is still falling
through to frame->Raise(), which sends time 0. That is the next thing to fix, and
it is tracked; the arbiter alone does not close it. metacity also aborts on this
window (frames.c:1239), so the gate needs a WM that survives before it can return
a verdict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 10:35:22 +02:00
Tommaso BianchiandClaude Opus 5 af4bbe0217 A shape you selected whole had nothing left to click
"Still cannot edit labels in rounded rectangles." Reproduced on the rig in a few minutes, and it
is NOT the window-manager defect the rest of this week has been about — it happens on openbox,
where typing into a value field works perfectly. The value was never the problem. The LABEL was
not there.

render_live_quotes picks the entity to speak for like this:

    else if (m_selection.size() == 1)  ei = m_selection[0];
    if (ei < 0 || ...) return;

A rounded rectangle is EIGHT entities — four lines and four arcs — so selecting the shape makes
m_selection.size() == 8 and the pass returns before drawing anything. Its Width, Height and fillet
Radius are live labels and nothing else, so with them gone there is no affordance at all: no
number to click, no field to open, no value to refuse. The rule hid the characteristic quotes for
precisely the shapes that have nothing but characteristic quotes.

A plain rectangle looked fine only by accident. Typing into its auto-edit chain creates a DRIVEN
dimension, which render_dimensions draws from the annotation list, so its labels survive. The
rounded rect's W/H/R go through set_rounded_rect, which rebuilds the geometry and leaves no
annotation behind. Same for slot, arc-slot and polygon: every grouped feature was in this hole.

A selection that is entirely ONE feature now speaks through any member. The switch below already
keys off feature_of(ei) rather than the entity, so nothing else had to change.

Measured on behemoth :10, before and after, same binary path:
  before  8 selected -> no labels at all
  after   8 selected -> R26.6 / 117.4 / 150.7 drawn; clicking R26.6 opens Radius prefilled 26.60;
          typing 8 gives R8.0 mm and visibly sharper corners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-06 08:14:44 +02:00
Tommaso BianchiandClaude Opus 5 00d6c191dc The chip in the corner was holding the keyboard, and the planes were holding the bed
Two reports, three defects, all three measured on the running app rather than reasoned about.

"Keyboard focus in drawing tool is broken so that now they are slow and cumbersome." After a
dimensioned entity the bottom-right readout chip — 119x31, borderless, a wxFrame — held the X
input focus. Pressing r produced NO [KEYTRACE] line at all: the key never reached the panel's
CHAR_HOOK. One bare canvas click moved focus back to the main window and the identical key armed
the tool. So every shortcut was dead after every dimension, and the way to get the keyboard back
was to click somewhere harmless. That is the whole of "slow and cumbersome".

Its sibling, the status chip, is a wxPopupWindow for exactly this reason and carries a comment
warning against turning it back into a frame. The readout was left a frame on the premise that
"it appears mid-gesture and the next input is the mouse" — which the measurement falsifies: the
chip keeps the last value on screen after the gesture ends, and a frame that has the focus does
not give it back. It is now a popup too, with the placement and the iconise/deactivate lifecycle
its sibling already needed, because an override-redirect window would otherwise sit on the bare
desktop when the app is minimised.

"Planes hide the bed." Literally true, twice over. The reference planes were half-extent 0.6 *
the bed's larger side — a square 1.2x the plate — and all three are drawn with depth testing
off, so they painted over the plate grid from edge to edge. 0.3 puts them inside the bed, which
is also the Onshape look the size was reaching for: a modest square at the origin, not a
tablecloth.

And the other half was mine. 3f52166e32 muted the bed for the duration of a sketch, on the
argument that a plate grid and a sketch grid are the same visual language. The argument is right
and the call was wrong, because there IS no sketch grid to take over. Pick XY, arm Line, and the
viewport was an empty grey field: no bed, no grid, no origin, nothing to judge a length or a
direction against. The plate grid was carrying the ground reference for the whole tab. The banner
already says where you are; taking the floor away as well only made the sketch harder to draw.
The Bed checkbox is the one thing that governs the bed, in every mode.

Verified on behemoth :10 with the rebuilt binary: focus after a dimension chain is the main
window, r arms Rectangle with no click in between, and the plate grid is under the sketch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-05 13:58:40 +02:00
Tommaso BianchiandClaude Opus 5 cf444a6ab6 A field that is logically closed can still be eating every key
Measured on the running app, not deduced. After a queued dimension chain the value field's frame
is left MAPPED on purpose (mutter refuses keyboard focus to a re-mapped window), so there is a
window in which m_open is already false and the frame is still on screen holding the X input
focus. GTK meanwhile reports that window inactive and routes nothing into the text control. Every
key then lands somewhere that cannot use it and will not give it back:

  [KEYTRACE] key=27 ui_mode=1 inline_busy=0     <- the last key the panel ever sees
  === MARK press Delete ===                     <- no trace line at all
  xdotool getwindowfocus -> 0xe00404 86x60      <- the value field, still mapped

Delete, Esc and typing all read as dead, which is exactly the report. And nothing could recover
it: close(), cancel() and do_cancel() all return early on !m_open, so the one window still
receiving keystrokes was also the one window no code could dismiss.

is_mapped() asks the question the flag cannot answer, and dismiss() tears the frame down with no
m_open guard, since m_open is precisely what lies in this state. Esc inside the field falls back
to it — while the frame holds focus that handler is the only code the keyboard can still reach,
so if it refuses, nothing else gets a turn. Every close now hands focus back to the canvas
explicitly, because hiding a window does not move the X input focus off it. And inline_busy()
reports the union of "a value is pending" and "a frame is mapped", so Esc routes to the field
whenever one is on screen at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-05 13:00:23 +02:00
Tommaso BianchiandClaude Opus 5 3f52166e32 A sketch should not look like plate preparation
Three cues, because one is missed. A teal banner across the top of the viewport names the session
("Editing: Sketch N") and where its exits are; the printer bed is muted for the duration, since 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; and N looks straight down the plane normal at the
current zoom, with the plane's own y axis as up, because no hand-orbit lands exactly square and a
sketch read at an angle is one whose right angles do not look like right angles.

The banner is an INDICATOR. Finish and Cancel stay on the single ribbon action bar — the tab had
three competing confirm surfaces once and that is not being reopened for a strip of colour. It
sits above the canvas rather than floating inside it: a child window over a wxGLCanvas is a native
window on GTK with no reliable stacking over GL, and being unmissable beats being clever.

The bed checkbox stays the stored preference and is restored on leaving the sketch; ticking it
mid-sketch still shows the bed, because that is a deliberate act and this is only a default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-05 11:45:29 +02:00
Tommaso BianchiandClaude Opus 5 324b558747 Esc is the safe key again: one press, one level, nothing destroyed
Two presses used to discard a live sketch. The key was answered in four places that could not
see each other — the inline value field, a sketch branch, a feature-card branch, and the canvas
— so a press aimed at one fell through to the next, and request_exit() carried a fourth layer
that deliberately let the SECOND consecutive press through to cancel_sketch(). The warning it
showed first did not help: the two presses are never one decision, the first is aimed at a field
or a tool and the second at whatever was underneath it.

The stack is now explicit. CadLevel (DesignInteraction.hpp) is four levels deep, the enum value
IS the LIFO depth, and cad_escape_level() is a constexpr function over a POD of four booleans —
so the ordering that is the entire contract is checked by static_assert at compile time, with no
window, GL context or event loop. DesignPanel::escape() acts on the one level escape_level()
names and on no other, and every Esc in the tab routes through it.

The destructive layer is gone from request_exit() itself rather than guarded at its callers, so
the guarantee cannot be re-opened by adding a route: a session holding geometry is left only
through Finish (keep) or Cancel (discard). Cancel now asks before discarding — it used to refuse
and tell the user to press the button they had just pressed, which meant a drawn sketch could be
kept but never thrown away.

Right-click also stops rewarding navigation with a menu: the offer needs BOTH budgets, released
within 200 ms and moved no more than 3 px, and the raycast uses the press position, so the menu
describes what was pointed at rather than where the camera stopped. Two budgets because drift
alone still popped a menu at the end of a slow, careful orbit.

docs/ux/interaction-model.md carries the state machine, the routing and the transition table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-05 11:35:12 +02:00
Tommaso BianchiandClaude Opus 5 bb6a1810f6 A stray click must not break a model that looks perfect on screen
Revolve failed on a sketch whose profile was closed. Decoding the reported 3mf: four
entities forming a proper closed loop (joints open by 4.44e-06 mm, well inside
tolerance) plus one stray 1.82 mm Line at (-24.2, 80.3), inside the shaded region,
touching nothing.

The viewport's region_loops discards open chains ON PURPOSE — it exists to find
EXTRUDABLE regions — so the user saw one clean closed region. entities_to_wires kept
the stray as its own one-edge loop, so it returned two wires, and Revolve goes through
entities_to_wire which demands exactly one. Extrude would have failed one step later in
wires_to_face, because a one-edge open wire bounds no face. Same class as the tolerance
split fixed in 8b568b7b: the viewport and the kernel disagreeing about the sketch — this
time about what BELONGS to the profile.

entities_to_wires/entities_to_wire/build_sketch_wire take closed_only. It is not a
blanket rule: a SurfaceExtrude builds a sheet FROM an open profile and a Sweep PATH is
normally open, so all ten call sites are classified individually — true for the face
fallback, Extrude-taper, Revolve, the Sweep PROFILE and Loft profiles; false for
SurfaceExtrude/Revolve/Loft/Fill and the Sweep path.

A component counts as open when some welded node has DEGREE 1. The first attempt used
"the traversal did not return to its starting node", which regressed the bridged C
profile: a closed loop that also carries a second edge across the same two nodes has no
free endpoint, but its Eulerian walk ends elsewhere. Degree-1 is the property that
actually distinguishes a stray segment from a closed profile; the suite caught the
difference.

Behaviour change decided by Tommaso: a stray is IGNORED, not refused. The test that
required refusal dates from when ignoring meant falling through to a default rectangle —
geometry nobody drew. That fallback is gone, so ignoring now builds the circle the user
actually drew. Its assertion is updated with the reason.

The bridge round-trip test extruded an ENTIRELY open chain and "worked" only because
OCCT will make a face from an open wire. It gets a genuinely closed profile: the test is
about serialization, and deserialize_recipe recomputes, so the document has to be one
that legitimately builds.

Failures now say WHERE. sketch_open_ends reports free endpoints under the same weld
tolerance the wire build uses, and open_loop_message is shared by both throws, because
Extrude fails through build_sketch_face and Revolve through build_sketch_wire — enriching
only one would have left the commoner path the less informative one.

Kernel 66115 assertions / 608 cases green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-02 14:58:05 +02:00
Tommaso BianchiandClaude Opus 5 8b568b7b9d The viewport and the kernel now answer "is this joint closed?" with one number
Tommaso asked the question that names the real defect: if the sketch was open, why was
the same sketch shaded closed and offered for extrude? Because the two halves used
different tolerances. region_loops shades a region closed at 1e-3 mm; connected_loop
chained at 1e-3; the kernel welded at 1e-4 and OCCT matched vertices at 1e-7. The
2.28e-5 mm gap in the reported sketch did not cause that disagreement, it only made it
visible — and fixing the gap alone would have left the contradiction in place, ready to
reappear anywhere in (1e-4, 1e-3].

kSketchJoinTol now lives in SketchEngine.hpp and is the only place the number exists.
region_loops, loop_report, connected_loop and entities_to_wires all read it through
sketch_join_tol(). The viewport cannot promise a region the kernel refuses to build.

The welding is optional, because a kernel that silently closes loops should let you say
no: "Auto-close sketch loops" in Preferences, default ON, no restart. OFF means only
exactly coincident endpoints join — and since both halves read the same value, the
viewport simply stops shading the region closed, so an open loop is visible rather than
welded behind your back. No separate UI needed for that; it falls out of sharing one
number.

Details that matter. The kernel defaults to auto-close ON independently of the GUI, so
headless and MCP callers behave like the viewport instead of inheriting an unset
preference. With the tolerance at 0 the comparisons become <=, because OFF must mean
exact, not broken. OCCT never receives a zero vertex tolerance — it is clamped to
Precision::Confusion.

The preference is pushed from EVERY entry that starts a sketch session, not just
begin(): a Constrain session enters through begin_constrain / begin_constrain_entities
and uses region_loops and connected_loop, so a single push site would have left those
sessions running on whatever the previous one set. begin_imported_transform is excluded
deliberately — it works on imported regions, not chained entities.

Tests: a loop with one joint open by 9e-4 mm, given out of traversal order, builds a
closed four-edge wire; with auto-close off the same loop yields no wire; and an exactly
closed loop still builds with auto-close off, proving OFF means exact. Kernel 66104
assertions / 606 cases green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-02 14:34:17 +02:00
Tommaso BianchiandClaude Opus 5 faf4406f89 A wire that lost two edges still called itself done
An extrude built a solid the user never drew: three sides of the handle plus the arc
that bulges outside the outline, with the bowl's second arc and the left edge missing.

Two faults met. entities_to_wires added edges in ENTITY-CREATION order, so a partial
wire rejects the next edge even when the sketch closes perfectly; and one joint of the
reported sketch is open by 2.28e-5 mm, wider than OCCT's 1e-7 vertex tolerance and
wider than this function's own EPS of 1e-6, so that edge was refused on geometry too.

Neither showed up, because BRepLib_MakeWire::Add DROPS a disconnected edge
(BRepLib_DisconnectedWire + NotDone) while every successful Add ends with
BRepLib_WireDone + Done() — overwriting the failure. `if (!wm.IsDone()) return {}`
was therefore asking only whether the LAST edge connected. Six edges in, four out,
IsDone() true.

Endpoints now weld into shared nodes at one tolerance (kSketchWeldTol) used by BOTH
the union-find grouping and the wire build — they disagreed before, which is how a
joint gets united into a loop and then refused by the builder. Each node becomes ONE
TopoDS_Vertex, so the builder matches on identity instead of proximity, with the
vertex tolerance widened because BRepLib_MakeEdge::Init projects a vertex onto the
curve within that tolerance and a welded node sits up to the weld gap off its
neighbour's curve. Members are then walked in traversal order. Finally the result is
counted: IsDone() alone is not evidence, edge_count == members.size() is.

Arc geometry is untouched — the midpoint from (start_angle+end_angle)/2 and the
solver's angle reflow both measured correct and were never part of this.

The regression case carries the reported sketch verbatim, open joint included. It
fails 4 == 6 without the fix, which was measured, not assumed. Kernel 66092
assertions / 604 cases green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-02 13:15:51 +02:00
Tommaso BianchiandClaude Opus 5 9858080aa0 The constraint list follows you into a live sketch
The rows, their ✗ buttons and the click-to-highlight were all built against
m_doc.features[m_constrain_feat].entity_constraints — a COMMITTED feature. A live sketch
has no committed feature, so the card was hidden for the whole session and the list it
would have shown was empty by construction. Every constraint applied while drawing was
nameless: the badge said one existed, nothing said which.

rebuild_constraint_list now picks its source by scope. live_constraint_scope() is the same
discriminator apply_constraint already used to route to apply_live_constraint — both
Constrain modes set m_active, so is_sketching() alone would claim the live scope while the
committed manager is open. delete_constraint and highlight_constraint_entities branch on
it too, and the card shows in Sketch mode as well as Constrain.

Keeping the rows in step needed a signal that did not exist: on_solve_state fires on every
frame of a drag, so rebuilding from it would rebuild the list continuously. The tool now
fires on_constraints_changed only when the constraint SET changes — one added by
try_add_constraints, one removed by remove_constraint (the indexed form the badge click and
the ✗ row now share).

The rebuild is deferred through CallAfter. One of its callers is the ✗ button's own click
handler, and rebuild_constraint_list destroys those buttons: deleting the window whose
handler is still on the stack is a use-after-free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-01 06:45:18 +02:00
Tommaso BianchiandClaude Opus 5 b250a2b858 Tell the user the badge is a button, and refresh the DoF when one is deleted
A glyph reads as decoration until something says otherwise, so the badges shipped
last commit were discoverable only by accident. Two places now say it, chosen because
they are where the eye already is:

- the moment of applying, which is the one the user is watching ("Applied constraint ·
  its badge is on the sketch — click the badge to remove it"). The hint line could not
  carry this alone: it only refreshes when the (mode, step, picks) tuple changes, and
  applying a constraint changes none of them.
- the Select-mode hint line, appended only while the live sketch actually holds a
  constraint, so it never advertises a badge that is not on screen.

Also fixes what the previous commit got wrong: remove_constraint_near solved through
solve_sketch_entities directly, which relaxes the geometry but leaves m_dof and the
per-entity conflict flags untouched and never fires on_solve_state. Deleting a
constraint therefore left the DoF readout describing the system as it was BEFORE the
deletion, and any red over-constrained tint stranded on screen. It goes through
resolve_live() now, the same path every other live edit uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-01 06:41:31 +02:00
Tommaso BianchiandClaude Opus 5 3da0af38c3 Constraints you apply while sketching are finally visible, and clicking one removes it
The constraint badges existed and had never once been drawn where they were needed.
build_constraint_glyphs read m_constrain_cons, a vector only the COMMITTED-feature
Constrain mode fills, and the draw call sat inside `if (m_mode == Mode::Constrain)`.
Every constraint applied during a live sketch — which is the path the Constrain buttons
take while drawing, the one added in "Constrain while you sketch" — went into
m_constraints and was rendered by nothing. You could not see that Parallel had applied,
so "nothing happens" was indistinguishable from "applied and invisible".

The glyph builder now takes its constraint list as a parameter: Constrain mode passes
m_constrain_cons as before, the live session passes its own m_constraints. Same glyphs,
same teal.

Seeing them is half of it. A constraint's entire state is exists / does not exist, so the
toggle is a delete, and there was no way to reach one during a session — the ✗ rows in
the panel list are bound to the committed feature. Each badge now records where it landed
(m_glyph_hits) and a plain left click in Select mode within its cell drops that constraint
and re-solves. Shift/Ctrl clicks are left alone so multi-select still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-01 06:23:53 +02:00
Tommaso BianchiandClaude Opus 5 ea32f8dc2f A selected sketch line stops being white, and a refused constraint says why
Two reports, one root: the sketch tab could not show what was selected.

The bed grid landed last commit, and selection was painted pure white — a freshly
drawn line is auto-selected by the creation tool, so the first thing a new line did
was disappear into the grid. Selection now wears design_selection_color(), the same
cyan a picked solid already wears. White is kept for the hover handle alone.

That invisibility is also why "I apply Parallel and NOTHING HAPPENS": drawing two
lines leaves exactly ONE selected (the last), Parallel needs two, so the planner
correctly refused — but the status text named only the requirement, never the current
pick, which reads as a dead button. It now reports how many are selected and how to
pick the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FbJKJAJxxkhDTs9XdZzKA
2026-09-01 05:39:28 +02:00
Tommaso Bianchi 741682f874 The Design tab gets a grid a modeller can read, centred on the origin
In CAD the centre IS the sketch origin -- GLCanvas3D already moves the axis
triad there for exactly that reason. The grid under it did not agree: it comes
from PartPlate::calc_gridlines, generated from m_origin, the plate's front-left
corner, with an adaptive step meant for a print bed.

Measured on a screenshot from the user's machine: the nearest grid line was 10 px
from the origin in a 23 px pitch. The origin floated mid-cell, in both axes.

Corner-origin is CORRECT for Prepare -- a print bed starts at a corner -- and the
plate list is SHARED with the plater, so re-centring it there would change the
bed for every user of the app to fix one tab. The seam used instead already
existed: _render_platelist takes show_grid, and m_axes_at_bed_center is already
the "this is the Design canvas" flag. The Design canvas suppresses the plate's
grid and draws its own.

Minor every 10 mm, major every 50 mm, both generated from bed_center() so a line
passes exactly THROUGH the origin in each axis. Two GLModels, rebuilt only when
the bed shape changes, not per frame.

White majors, grey minors, the SAME in both themes. There is no white bed to
vanish against: the plate is dark grey either way (DEFAULT_MODEL_COLOR
{0.326,0.337,0.337} light, DEFAULT_MODEL_COLOR_DARK {0.255,0.255,0.283} dark),
a difference of 0.07. An earlier draft inverted the palette on the light theme;
that was a branch buying nothing. For contrast with what this replaces: the
plate's grid draws BOTH its thin and bold families in one 0.43 grey, which is
most of why the stock grid reads as a flat mesh with no scale to it.

z = -0.26, the same value as PartPlate::GROUND_Z_GRIDLINE -- below the bed fill
at -0.03, above the bed model at -0.41, so no z-fighting. Matched by
construction, since that constant is file-static in another TU.

Known limit, commented: the grid is clipped to the bed's BOUNDING BOX, not its
polygon. Identical on a rectangular bed; on a circular one it would spill past
the round edge. The target printers are rectangular.

Verified on the rig, not just compiled: white majors over a fine grey mesh, and
a white line through the origin in both axes.

snaporca-kha0
2026-09-01 04:49:09 +02:00
Tommaso Bianchi 8b9ff36685 The eleven constraint buttons no rung had ever pressed
Twenty buttons on the CONSTRAIN bar, nine of them exercised. The other eleven
were "implemented" in the sense that the kernel builds the right def for them --
which is exactly what was true of Parallel yesterday morning, right up until a
user pressed it and got nothing.

What a kernel test cannot see is whether the BUTTON is wired to the index its
name claims. CON_BTN is 449 + 42*i over a hand-written name list, and it has
drifted once already: six buttons were inserted, everything from index 6 on
pointed at the wrong control, and nothing caught it for months because no rung
pressed past index 5. D13 presses index 6; D14 through D22 press 8 to 19. The
map turned out to be intact, which is worth knowing rather than assuming.

D12 vertical, D13 equal_radius (its own button, not Equal's promotion),
D14 concentric, D15 tangent, D16 midpoint, D17 symmetric (the three-pick form),
D18 sym_h, D19 radius, D20 diameter, D21 fix, D22 dist_y.

Three are shaped around a specific way the code could be wrong rather than
around "does something happen":

  D20 exists for a factor of two. Diameter wired to the Radius handler gives
  r = 30 for a typed 30, and nothing on screen looks wrong.

  D21 -- Fix alone is unfalsifiable: nothing moved, so nothing proves the
  constraint exists. It only becomes observable when a SECOND constraint would
  otherwise move the fixed point, so the rung drives the pair to a 70 mm gap and
  checks which end travels.

  D14 asserts the radii did NOT change. Concentric is about centres; a solve
  that also equalised the radii would pass a naive check.

D17 failed on its first run and the rung was wrong, not the app: all three
entities are free, so the solver is entitled to satisfy the mirror by moving the
AXIS instead of the points -- and it did, landing the pair symmetric about
x = -8.18. It now measures signed perpendicular distance to the axis where the
axis actually is, which is the stronger property anyway.

Coverage: 20/20 buttons pressed, up from 9. Ladder 135 -> 177 properties across
43 rungs, all holding.

snaporca-l2vm
2026-09-01 04:19:48 +02:00
Tommaso Bianchi 8f3f835636 Constrain while you sketch, and stop losing work to Esc and to invisible points
Five defects from ten minutes of real use, and the mode split behind the worst
of them. One commit because the changes overlap in the same functions; the
pieces are separable in the diff, not in the file.

CONSTRAINING NO LONGER NEEDS A COMMITTED SKETCH. A constraint could only be
applied by committing the sketch, selecting it in the feature tree, pressing the
padlock, and only then picking. While drawing, the CONSTRAIN toolbar was not even
on screen (set_ui_mode showed it in UiMode::Constrain alone) and apply_constraint
answered "Press Constrain on a sketch first" -- in a status line nobody looks at.
Draw two lines, press Parallel, get nothing: that is what a user reported as "the
UX is a mess", and they were right.

apply_constraint now takes the live session first, reading the picks from the
selection model the sketch tool already had (click, ctrl-click to extend,
double-click for the loop) and applying through try_add_constraints, which
already did append -> solve -> keep-or-rollback. The committed Constrain path
stays for editing an old sketch; it is no longer the only way in. The twenty
constraint buttons now show in Sketch mode as well.

The discriminator is is_sketching() && !is_constraining() &&
!is_constraining_entities(). Both begin_constrain and begin_constrain_entities
set m_active, so is_sketching() alone is true DURING a constrain session and the
new path would hijack the old one -- compiling perfectly and failing in
behaviour.

ONE PLANNER, NOT TWO. A second caller meant duplicating the logic that decides
whether a constraint is legal, which roles it binds and whether it needs a typed
value. That duplication is how today's Coincident bug survived: fixed in one
branch, alive in the next one down. plan_entity_constraint() now lives in the
kernel -- pure, no wx, no translation -- and both UI paths call it. DesignPanel
loses 304 lines and gains 155.

Being in the kernel makes it TESTABLE. The Parallel defect existed because a
constraint type met an entity type nobody had tried, and the only instrument was
a 13-minute GUI ladder. 19 new kernel cases cover the matrix: 264 -> 283 cases,
7648 -> 7867 assertions.

Parallel, Perpendicular and EqualLength gain the two-line guard they never had.
On non-lines they used to emit a def the solver silently dropped -- the sketch
reported itself constrained when it was not, the same class as Horizontal on a
Point. EqualLength on two rounds still promotes to EqualRadius first.

Symmetric is planned completely, including its axis pick: the plan carries a
VECTOR of defs because Symmetric on two lines is two constraints (P0/P0 and
P1/P1). A single def would have half-applied it -- one end pinned, one free,
looking correct until something moves.

A PLACED POINT SURVIVES THE COMMIT. Type::Point was created correctly and never
drawn once committed: both renderers skip it, correctly, since entity_polyline
gives a point nothing. What was missing is the vertex-marker path the live
session already used. rung_point passed throughout because it asserts the
document, and the point was always in the document -- the pixels lied.

ESC STOPS EATING AN UNSAVED SKETCH. The third press reached cancel_sketch(),
clearing m_entities with no warning and nothing to undo. live_sketch_has_work()
existed and was never consulted. The exit layer refuses once when there is work
and lets a second consecutive Esc through; the refusal re-arms on a button press,
never on mouse motion, or Esc could never exit while the hand moves.

TWO NEW RUNGS. D10 drives Parallel through the committed path -- it passes on
the PRE-fix binary, which is how we know the user's failure was the mode and not
the constraint. D11 is the acceptance for the collapse: draw, pick both, press
Parallel, no commit and no padlock. Ladder 126 -> 135 properties, all holding.

CON_BTN_SKETCH is measured, not derived: in Sketch mode the group renders after
the sketch toolbar, so the first button is at 677, not 449. Pitch 42, twenty
buttons, read off a screenshot. Deriving it by offset is how that table drifted
the last time.

Known limit, commented at the call site: a constraint added to a LIVE sketch is
not on the document undo stack, so Ctrl+Z will not take it back until the sketch
is committed.

snaporca-itp4, snaporca-oyhx, snaporca-l2vm
2026-08-31 22:12:26 +02:00
Tommaso BianchiandClaude Opus 5 cd30fb891e the same phantom-endpoint bug in Coincident, and the two unguarded branches next to it
Port of snaporca da8d011b87; parity holds (DesignPanel.cpp still exactly 32 divergent
lines). Verified independently on this fork's own rig: full ladder 126/126 against
BuildID 96c697a3, built from this tree.

Reviewing the DistanceX/Y fix for OTHER members of its class found three more live defects
on the constrain toolbar. All four share one root: a branch assumes every picked entity has
two endpoints, and the solver's refusal to resolve a role it cannot find is silent.

COINCIDENT had the identical closest-pair walk over {P0,p0},{P1,p1}. For two Points the
phantom (0,0) pair sits at distance 0, which is the smallest distance there is, so it
ALWAYS won: ptOf(Point,P1) -> 0, ref_ok fails (SketchSolver.cpp:185), constraint dropped.
Not sometimes -- every press.

HORIZONTAL/VERTICAL hardcoded ra=P0, rb=P1 with no type check. With a Point picked the
constraint is dropped by the same mechanism but still STORED: constraints goes 0 -> 1 after
the commit and nothing moves, so the Constraints list shows a dimension that can never do
anything. Worse than refusing -- the panel claims the sketch is constrained when it is not.

ANGLE computed p1-p0 on whatever was picked. On a circle that is (0,0)-centre, so two
circles pre-filled the field with the angle between their centre POSITION VECTORS (178.83
deg for two on the x axis), and accepting it emits SLVS_C_ANGLE on two circle prims.

Both branches now refuse with a message. entity_ends()/closest_ends() are file-scope and
shared by Coincident and DistanceX/Y, so there is one implementation instead of two that
drift.

Two smaller findings from the same review: infer_auto_constraints' roles_of omitted
EllipseArc while heal_coincidences' identical copy has it; and set_point(Circle, Center)
wrote e.center and not e.p0, breaking the "p0 mirrors centre" invariant for the duration of
a live drag.

New rungs D8 and D9, both RED against the shipped binary and green here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-31 13:52:12 +02:00
SoftFever 507b45431c Keep the CAD recipe in the one 3mf backend that actually runs
Format/3mf.cpp also saved and loaded it, but nothing calls its store_3mf and
its load_3mf only sees files fingerprinted as PrusaSlicer's, which never carry
a recipe. Its round-trip test only exercised that dead loop. The BBS backend,
which every save and load goes through, is untouched.
2026-08-31 18:31:37 +08:00
SoftFever 493befecc5 Tear down the Design canvas at shutdown
The Design tab's viewport is the fourth GLCanvas3D on the shared GL context and
the only one the plater does not own, so unbind_canvas_event_handlers() and
reset_canvas_volumes() never reached it — the macOS Command+Q and Debian cases
those calls exist for. Its frame-level handlers become members so they can be
unbound.
2026-08-31 18:04:12 +08:00
SoftFever 5a4f7f4c3c Re-anchor the Design status chip on canvas resize
The bind sat above GLCanvas3D::bind_event_handlers(), and on_size never Skips,
so wx's reverse-order dispatch stopped before it and the handler never ran.
2026-08-31 18:04:12 +08:00
Tommaso BianchiandClaude Opus 5 afb17e8889 D3 was testing luck: the pair started 3 degrees from square, inside inference's snap
Port of snaporca b3221f8a12; this is the fork the fault surfaced on.

The perpendicular rung drew its two lines 93.5 degrees apart and then asserted they did
NOT start perpendicular. On this rig, whose camera maps the same click a pixel differently,
they arrived at exactly 90.000000 -- inference had already done the job the rung exists to
test, so the precondition failed while every later check passed. Held on the other fork and
failed here from identical source: the rung depended on where a click happened to land, not
on the app.

The second point now starts the pair 56 degrees off, well outside any snap tolerance, so
the button has real work to do.

That immediately exposed a second, milder fault in the same rung. From a 51 degree start
the LIVE solve converges to its own tolerance and lands at 89.999999991; the old 1e-9
assertion held only because the correction used to be tiny -- it was measuring how little
work the solver had to do, not whether the lines came out perpendicular. It is 1e-6 degrees
now, which is 1.7e-8 radians. The round-trip check still demands exactly 90 and gets it,
because the committed feature re-solves from scratch.

Full ladder 118/118 on BOTH rigs after this, each driving its own fork's binary. This fork
had never had a green gesture ladder before today (snaporca-eoj1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-31 11:29:35 +02:00
Tommaso BianchiandClaude Opus 5 d35d33971a the feature-tree row needs CHROME_DY too — the last chrome constant that did not carry it
Four of this fork's five absolute chrome coordinates were shifted by CHROME_DY when the
ladder was first brought up here (DESIGN_TAB, CONSTRUCTION_CHECKBOX, CON_BTN_Y,
CONFIRM_BTN). TREE_ROW0 was not, because it is declared above the CHROME_DY block and was
simply never in view.

The unshifted click lands 26 px below the first tree row, just past its 23 px height, so
the row is never selected and Delete does nothing. reset_document then spends 40 rounds on
it and dies with "could not empty the feature tree" — a message that names the feature
tree, which is not the fault. The same 26 px is why confirm_and_reopen's double-click did
not reopen the sketch, which surfaced as "sketch_describe: no sketch is open" three frames
away from the cause.

Measured, not inferred: the Sketch1 row centre reads y=241 on the rig at 1920x1080 with
the window at (0,0), against the constant's 215. CONFIRM_BTN was checked in the same pass
from a screenshot taken in CONSTRAIN mode and is correct at (1751, 101).

With this, the four new constraint rungs hold 20/20 on this fork's rig, driving the binary
built from 648b930e75 (BuildID 56417445) — so the DistanceX/Y fix ported here is now
exercised, not merely parity-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-31 10:52:34 +02:00
Tommaso BianchiandClaude Opus 5 648b930e75 gesture rungs for the six new constraint buttons, and the DistanceX/Y bug they found
Port of snaporca 2951e3c60b; parity holds (17 identical, DesignPanel.cpp still exactly 32
divergent lines, so the hunk landed on the right side of the DropDown divergence).

The sketch-constraint epic added six toolbar buttons and covered all six with kernel
tests. Not one of them was ever clicked. The gesture ladder only pressed 'perpendicular'
and 'equal' -- and CON_BTN, which locates buttons by index, was silently wrong for every
entry past index 5 for the whole epic. The untested half of the toolbar was exactly the
broken half (snaporca-rqsy).

Four rungs now drive them: D4 Equal on two circles (must mean equal RADIUS, not the
equal-length no-op the epic fixed), D5 Collinear on two oblique lines, D6 a horizontal
distance, D7 symmetric about the implicit vertical axis. Full ladder 118/118 on snaporca;
this fork's rig has not been rebuilt against the change yet, so here it is reviewed,
parity-checked and NOT exercised.

D6 found a shipped defect. apply_entity_constraint enumerated {P0,p0},{P1,p1} for BOTH
entities regardless of type, but a Point's p1 is unused and reads (0,0), as does a
Circle's. The closest-pair search then picked those two phantom origins, distance 0: the
field opened pre-filled 0.00 and the solver dropped the constraint, because ptOf(Point,P1)
resolves to no handle. Nothing errored -- the dimension simply did nothing. ends_of() now
enumerates only the roles an entity actually exposes, and the pair with no point at all is
refused with a message instead of a silent no-op.

Five kernel tests, a 7/7 ladder, a review and a fork port all passed over this, because
every one of them exercises the kernel, where the geometry was always right.

Two rig faults fixed in the same pass, both of which produce a green-looking session that
tests nothing: start-headless-gui.sh never exported SNAPORCA_MCP or SNAPORCA_KEYTRACE, so
a freshly launched rig comes up healthy and every ladder dies on "Connection refused"; and
it never dismissed the "Restore" dialog a killed session leaves behind, which grabs every
synthetic click afterwards.

The value field also takes no keyboard focus from the WM -- typed digits go to the canvas
and Return commits the pre-filled number (typed 40, got 54.94). focus_field() finds it as
its own top-level window and clicks it first. The no-op tolerance is now 5e-3, the field's
own two-decimal display resolution, not 1e-6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-31 10:40:38 +02:00
Tommaso Bianchi d0791c3b8a Make the ladder runnable on this fork's rig — 6 of 7 rungs now hold
This fork had never been gated end to end. Five separate things stopped it, none
of them a defect in the CAD code itself, and each failure named the wrong
subsystem — which is why they survived.

1. run-all-checks.sh invoked docs/ux/mockups/gen_offer_table.py. The design docs
   moved to docs/CAD/ (bbd1989e1e) and this path did not follow, so the rung
   failed on a missing file.
2. gen_offer_table.py then resolved REPO one dirname short, because it now sits a
   level deeper. OUT pointed at docs/src/.../DesignOffer.hpp, which does not
   exist, so --check diffed the generated table against an EMPTY file and
   reported all 189 lines as a difference.
3. DISPLAY was never passed into the container. The check scripts fall back to
   ":10", which is the other fork's rig; this one's Xvfb is :11. Symptom:
   "FATAL no app window on :10", which reads like a dead app.
4. Every absolute chrome coordinate was written in the Snapmaker fork's layout.
   This fork keeps mainline's top row (File / save / undo / redo / Calibration
   with the title), so the whole chrome sits 26 px lower. At the unshifted y the
   Design-tab click landed in the toolbar and the app stayed on the Home page,
   reported as "no sketch opened after plane click + Shift+S"; the unshifted
   Construction checkbox reported "0 construction axis". The three constants now
   derive from CHROME_DY. Canvas coordinates were never affected -- clickmm()
   computes them from live canvas geometry -- which is why dozens of geometric
   properties passed exactly on a GUI that had never been driven.
5. CON_BTN was stale for every index after 5, from the sketch epic's six new
   buttons. Fixed in both forks; see the companion commit on snaporca.

pdftocairo was also missing from the rig image (installed there, not a repo
change), without which every corpus sheet threw.

RESULT: offer-table, kernel, engine, corpus, corpus-scale and offer all hold.
The gesture ladder now reaches D2 and applies Equal length through the Constrain
toolbar, both sides landing at 99.928133658; it then fails re-entering the sketch
after commit, filed as snaporca-eoj1 with the evidence and the next measurement
to take. Kernel here is 7701 assertions / 270 cases.
2026-08-31 08:40:06 +02:00
Tommaso Bianchi ca8f813994 Add assimp to the deps image, so this fork can run its own tests
This fork's kernel suite has never run. scripts/CAD/run-kernel-tests.sh died at
CMake CONFIGURE time on find_package(assimp REQUIRED), before a single source
file compiled, so every kernel change ported here was parity-checked against
snaporca and never independently tested (snaporca-w80c).

WHY IT WAS MISSING. OrcaSlicer mainline gained assimp (glTF/GLB/FBX import for
texture-to-colour) after the orcacad-deps image was baked: the image's deps/ tree
has no Assimp directory at all and nothing named assimp anywhere in it. On the
host, deps/build/dep_Assimp-prefix carries only `patch` and `update` stamps -- no
build, no install -- so the dependency was fetched and then never built, inside
the image or out of it. The Snapmaker fork never hit this because its base
requires neither assimp nor OpenCV.

WHY A LAYER. A full deps rebuild is hours and would rewrite artifacts that
currently work; this adds the one missing package on top. It is a Dockerfile
rather than a `docker commit` so that what was done stays reviewable and
repeatable instead of being an undocumented image mutation.

The flags are the project's own recipe (deps/Assimp/Assimp.cmake) plus the
standard superbuild arguments from orcaslicer_add_cmake_project
(deps/CMakeLists.txt:158) and DEP_CMAKE_OPTS (deps/deps-linux.cmake). The file
says to keep them in step with that recipe: it stands in for the superbuild, it
is not a separate opinion about how to build assimp.

The tarball's SHA256 was checked against the recipe's URL_HASH before this was
written and is re-checked inside the build, and the build asserts the installed
cmake config exists rather than trusting an exit code. OpenCV was confirmed
already present, so it is not a second wall behind this one.

RESULT, and it is the point: orca_cad kernel now runs and is GREEN at 7701
assertions / 270 cases. snaporca is 7700 / 270 -- same cases, one more assertion
here, which is the tolerated test_caddocument.cpp divergence. The "this fork's
kernel suite cannot run" caveat carried by the five commits of the sketch
constraint epic no longer applies.
2026-08-31 07:14:26 +02:00
Tommaso Bianchi b5ead4b29f Pull a body's edges into a sketch as construction references
Port of snaporca e635627b81. Parity OK: 17 files identical, 8 diverging at their
expected counts.

The last reference an industrial sketcher offers that this one did not: Onshape's
Use, SolidWorks' Convert Entities. Project already turned a body's 3D edges into
2D Lines, Circles and Arcs on a plane, but the result landed in its OWN feature,
so while drawing in one sketch you could not borrow an existing body's edge and
constrain to it.

No new geometry code: Project's per-edge conversion loop is factored into
project_edges_to_entities() and called from a second entry point that appends
into an EXISTING sketch with construction = true. The loop appears once now,
not twice.

Construction is what makes it cheap and safe: SketchEngine already skips
construction entities when building wires, so the references guide without being
built, and being otherwise ordinary entities every constraint from this epic --
Collinear, EqualRadius, the axis-projected distances, PointOnLine, Symmetric --
works against them for free.

Part of this refactors working code, so Project's behaviour identity is the
invariant; the existing [CadDocument][project] cases guard it and a new case
asserts a Project feature still emits construction == false.

project_edges_into_sketch returns the number of entities appended, or -1 on a bad
reference rather than throwing.

VERIFICATION LIMIT, as with the previous four commits: this fork's kernel suite
still cannot run (find_package(assimp) at configure time, snaporca-w80c). Shared
sources are byte-identical to snaporca's, where kernel is 7700 assertions / 270
cases and ALL LADDERS HELD 7/7.
2026-08-31 04:15:05 +02:00
Tommaso Bianchi fac3cf44df Infer parallel, perpendicular, equal radius and tangent while drawing
Port of snaporca 2d36d28770. Parity OK: 17 files identical, 8 diverging at their
expected counts.

infer_axis_constraint returned only Horizontal or Vertical. On the
CAD-1000-hours corpus the top two transitions are sketch_dim -> sketch_draw
(5896) and back (5756): the signature of geometry that does not self-constrain
as it is drawn.

Every rule requires the relation to be ALREADY TRUE within tolerance, so nothing
the user drew is moved; parallel/perpendicular and tangent additionally require a
shared endpoint.

TWO LIMITS THE CORPUS RUNG FORCED, neither visible to the unit tests:

1. At most ONE constraint per rule per new entity, not one per PAIR, and no
   one-at-a-time fallback for the relations batch. EqualRadius has no locality
   restriction, so 200 equal holes produced ~20000 candidates; the rejected batch
   then cost a solve per constraint and pinned the app at 95% of a core with the
   MCP socket unresponsive.

2. Relations only for gesture-sized batches. "A scripted add is not a drawn
   gesture" is already this file's rule at its bulk call site (snaporca-8xg1), and
   EqualRadius also couples geometrically distant entities, merging independent
   connected components and defeating the partitioning that makes large sketches
   solvable (snaporca-yww4). With the cap alone geometry stayed correct (32/32
   sheets clean) but seven of the largest timed out, including MPD681 -- the sheet
   that call site's own comment names.

Also fixes the tolerance leak behind 2: the bulk path asks for exact inference
with ang_tol_rad = 0 but len_tol_frac kept its 0.01 default.

ALSO independent of this feature: run-kernel-tests.sh defaulted to
TAGS=[CadDocument] while four CAD test files carry their own tags and nothing
selected them (2624 assertions / 206 cases reported, 7648 / 264 actual). All 58
dark cases were passing; the coverage was never exercised.

VERIFICATION LIMIT, as with the previous three commits: this fork's kernel suite
still cannot run (find_package(assimp) at configure time, snaporca-w80c). Shared
sources are byte-identical to snaporca's, where kernel is 7651 assertions / 265
cases and ALL LADDERS HELD 7/7.
2026-08-31 03:43:08 +02:00
Tommaso Bianchi 45494c6035 The origin and the two axes become things you can constrain to
Port of snaporca 5b1294de59. Parity OK: 17 files identical, 8 diverging at their
expected counts.

Every industrial sketcher gives you the origin and the axes as references. Here
the origin was only a SNAP target and the axes did not exist, so Symmetric needed
a third picked ENTITY as its mirror axis: symmetry about the sketch's vertical
axis first required drawing a construction line.

Every constraint reference resolves through four lambdas in the solver
(valid/ptOf/primOf/coordOf), so teaching those about three negative sentinel
indices makes the origin and both axes available to EVERY constraint type at
once. No new SketchEntity type, no serialization change; -1 still means "unset".
The references live in G_FIXED and add no degrees of freedom, which a test
asserts via the reported DoF.

SymmetricAboutY / SymmetricAboutX are two buttons that need no third pick and no
construction line. Making the axes clickable in the viewport is deliberately left
out: that is canvas hit-testing work with its own risks.

Recorded in the tests because it will catch the next person: sys.dragged[] is
populated only during a drag, so a plain sketch_solve of an UNDER-constrained
system may move any free parameter -- solvespace runs Newton, it does not
minimise movement. PointOnLine onto an axis is one equation in two unknowns and
the point legitimately slides along it. Those tests pin the free direction
instead of asserting the other coordinate is untouched.

VERIFICATION LIMIT, as with the previous two commits: this fork's kernel suite
still cannot run (find_package(assimp) fails at configure, snaporca-w80c). The
shared sources are byte-identical to snaporca's, where kernel is 2624 assertions
/ 206 cases and ALL LADDERS HELD across all seven rungs.
2026-08-31 02:29:24 +02:00
Tommaso Bianchi a63bba2d55 Horizontal and vertical distance dimensions
Port of snaporca 9fa304c77a. Parity OK: 17 files identical, 8 diverging at their
expected counts.

The everyday dimension in SolidWorks and Onshape, and this kernel had no form of
it. Distance constrains the straight-line gap; LockX/LockY pin one point's
ABSOLUTE coordinate. Neither relates two points along an axis.

DistanceX/DistanceY emit SLVS_C_PROJ_PT_DISTANCE against two unit direction
lines built in the solver's G_FIXED group, so they add no degrees of freedom.

THE DIRECTION IS SIGNED, and getting it backwards is silent. libslvs defines a
LINE_SEGMENT's direction as point[0] - point[1] (entity.cpp) and
PROJ_PT_DISTANCE constrains (pB - pA).dot(dir) (constrainteq.cpp:234), so the
reference lines are built head-first to mean +X and +Y.

The same signedness was a real defect in the GUI: the inline editor was
pre-filled with |delta|, so when the closest endpoint pair ran right-to-left,
opening the dimension and accepting the number shown would flip the point to the
other side of its anchor. Opening a dimension and accepting its own value must
be a no-op. The refs are now ordered so the shown value is positive.

On the CAD-1000-hours corpus, dimensioning and constraining is 31.9% of all
observed CAD time -- the largest single class, 7.6x feature operations. This is
the item in the constraint epic that lands most directly on it.

VERIFICATION LIMIT, as with the previous commit: this fork's kernel suite still
cannot run (find_package(assimp) fails at configure time, snaporca-w80c). The
shared sources are byte-identical to snaporca's, where kernel is 2603 assertions
/ 200 cases and ALL LADDERS HELD across all seven rungs.
2026-08-31 01:46:09 +02:00
Tommaso Bianchi a1f2a2687a Equal radius and Collinear, and one Equal button that knows what it picked
Port of snaporca 9ec6405e2d. Parity OK: 17 files identical, 8 diverging at their
expected counts (DesignPanel.cpp 32, test_slvs_constraints.cpp 3).

Measured on the CAD-1000-hours corpus: 51.5% of observed CAD time is 2D sketch
work, and dimensioning/constraining alone is 31.9% -- the largest single class.
Two constraints every industrial sketcher has were missing here.

EqualRadius fixes a dead end rather than adding a feature. Picking two circles
and pressing Equal emitted EqualLength, which maps to SLVS_C_EQUAL_LENGTH_LINES
and constrains nothing on a curve: a silent no-op with no error. Equal is now
one button with two meanings, as in Onshape and SolidWorks.

Collinear emits PARALLEL plus PT_LINE_DISTANCE=0 rather than PT_ON_LINE, whose
internal valP param this libslvs port leaves at 0, drifting an already-collinear
pair.

Both types are appended at the END of SketchConstraintType: cereal serializes it
positionally, so inserting elsewhere reinterprets every saved recipe.

VERIFICATION LIMIT, stated rather than implied: this fork's kernel suite could
NOT be run. scripts/CAD/run-kernel-tests.sh fails at CMake configure time on
find_package(assimp), before any source compiles -- a pre-existing deps gap
(snaporca-w80c), not this change. The shared sources are byte-identical to
snaporca's, where the full gate passed: kernel 2588/195 and ALL LADDERS HELD
across all seven rungs.

Also fixes two defects in this fork's scripts/CAD/run-all-checks.sh:
  - `cd $(dirname $0)/..` landed in scripts/ instead of the repo root, so every
    rung looked for itself under scripts/scripts/. Broken since the script moved
    into scripts/CAD/; the three sibling scripts were fixed then and this was
    missed, so the gate has not run since.
  - C defaulted to snaporca-gui, the OTHER fork's rig container, so this fork's
    gate would drive snaporca's app and report green about the wrong binary.
    run-kernel-tests.sh:31 documents the identical defect being fixed once
    already for the build volume; this is the third instance.
2026-08-31 01:09:43 +02:00
SoftFever 937437907d Use Plater's existing background_process() accessor
It was already public and used elsewhere, so the new get_background_process()
and its duplicate forward declaration were redundant; Plater.hpp is now untouched.
2026-08-29 18:52:15 +08:00
SoftFever bbd1989e1e move design doc to CAD subfolder 2026-08-29 18:52:12 +08:00
Tommaso Bianchi 884a382a48 Esc leaves the sketch from the state you are actually left in
exussum12 on PR #15238: "Esc hardly ever works". He was right, and the word
that matters is "hardly" — it works while you are mid-entity and stops working
the moment you finish one.

The branch asked whether a DRAW TOOL was armed, not whether a SKETCH SESSION
was open:

    if (key == WXK_ESCAPE && m_viewport && m_viewport->is_sketching())

is_sketching() is DesignSketchTool::is_active(), true only between arming a
tool and finishing with it. Commit an entity and you are left at
ui_mode=Sketch with no tool armed, and from there Esc did nothing at all, no
matter how many times you pressed it — the Cancel button was the only way out.
Reproduced on the headless rig with SNAPORCA_KEYTRACE, which is what settled
it rather than reading: the key ARRIVES and focus is fine,

    [KEYTRACE] key=27 ui_mode=1 is_sketching=0 in_text=0 inline_busy=0 focus=w

so this was never the focus problem it looks like from the outside.

Gate on the session instead. request_exit() is already layered — abort the
in-progress entity, else drop the tool to Select, else exit to Feature — so
widening the gate adds no new behaviour, it just lets the ladder be reached
from its own last rung. is_sketching() stays in the condition as an OR, so
nothing about the armed-tool path changes.

This is the same mistake snaporca-0ud fixed thirty lines above in the same
handler, where gating the sketch key MAP on is_sketching() made all 17 keys
read as dead. The comment there now has a sibling.

VERIFIED ON THE RIG, before and after, same sequence (Design > XY > Shift+S >
click canvas): before, two Escapes left the toolbar on SKETCH with zero pixels
changed; after, the toolbar reads FEATURES — one Esc drops the armed tool to
Select, the second exits the session.

Kernel suite green, 2568 assertions in 191 test cases. libslic3r_gui builds
clean on both forks. Parity 17 identical / 8 diverging as expected.
2026-08-29 08:52:21 +02:00
SoftFever 8f014de84c Load the CAD recipe from projects saved before it was renamed
The recipe's 3MF entry moved from Metadata/SnapOrca_cad.bin to
Metadata/orca_cad.bin, so projects saved by earlier builds opened with an
empty Design tab. Both 3MF backends now read either name and write only the
new one; the recipe version advances to 6 to mark the move.
2026-08-29 01:56:48 +08:00
SoftFever 0fc62d03b9 Hide the Design tab behind an experimental CAD preference
The tab is now gated on a new enable_cad_feature app-config key, off by
default, exposed in Preferences under General > Features. When off, the
page is never created, so the Design-only camera option is hidden too.
Takes effect on restart, like the other feature toggles.
2026-08-29 01:56:48 +08:00
Tommaso Bianchi 13d5eac891 Move the Design-tab scripts into scripts/CAD/ and name them by role
Requested by SoftFever on PR #15238: ten of these had accumulated loose in
scripts/ next to ~20 unrelated upstream ones, with names that only meant
something to whoever wrote them. They now sit in scripts/CAD/, mirroring the
src/libslic3r/CAD/ and src/slic3r/GUI/CAD/ split, and the verb in the name is
the role: build- produces a binary, start- brings something up, run- runs a
suite, check- asserts one thing against a live app.

  kernel-test.sh        -> CAD/run-kernel-tests.sh
  ladder-all.sh         -> CAD/run-all-checks.sh
  sketch-ladder.py      -> CAD/check-sketch-engine.py
  ladder-corpus.py      -> CAD/check-sketch-engine-corpus.py
  gui-ladder.py         -> CAD/check-gui-sketching.py
  offer-ladder.py       -> CAD/check-gui-context-menu.py
  mcp-sketch-smoke.py   -> CAD/check-mcp-sketch.py
  rig-build.sh          -> CAD/build-gui.sh
  docker-iter-build.sh  -> CAD/build-gui-incremental.sh
  gui-session.sh        -> CAD/start-headless-gui.sh

"Ladder" was the worst of them: it named the shape of the test (rungs of
increasing difficulty) rather than what the test proves, so nothing in the
directory listing told you which one needed a GPU and which was pure kernel.

Every reference rewritten -- the docs, the cross-calls between the scripts,
Dockerfile.deps, and the container-side /OrcaSlicer/scripts paths. The three
shell scripts resolve REPO relative to themselves and now sit one level
deeper, so that walk went from /.. to /../.. . The copies these push into a
container's /tmp were renamed to match, or the container would have kept the
old names alive.

Two runtime paths deliberately NOT renamed. /tmp/orca-rig-build.lock is a
cross-fork contract -- both forks take the same lock so two concurrent builds
serialise instead of OOMing the box, and renaming it on one side silently
removes that guard. /tmp/gui-session.log is a runtime artefact, not a script.

Added scripts/CAD/README.md: what each script proves, what it needs, and the
two constraints that have each cost a session (never build inside the GUI
container; a window manager is required or synthetic keys are ignored).

On CI, which was the other half of the request: the kernel suite is already
there and always has been. The cases are registered in
tests/libslic3r/CMakeLists.txt under if (SLIC3R_CAD), which defaults ON and no
workflow turns off, so they build into libslic3r_tests and run under ctest on
every platform via unit_tests.yml -- like any other unit test, needing no new
job. They have simply never been seen to run, because the workflows on this PR
are still awaiting maintainer approval. run-kernel-tests.sh is the local loop
over the same cases, and it is the only script here CI could run: the other
six need an OpenGL canvas and synthetic input.

Verified: scripts/CAD/run-kernel-tests.sh from its new location, all tests
passed, 2562 assertions in 190 test cases.
2026-08-28 19:34:03 +02:00
Tommaso Bianchi cdd41e230d The Design tab's MCP socket must not wait for someone to click the tab
Building the Design tab on first use (1750c52211) leaves m_design_panel null
until a human selects the tab. McpControl::handle_on_main refused every verb
while it was null, so start_mcp_control_if_enabled() opened the socket and
then answered "Design panel not ready" to everything — for the whole session
if nobody clicked. That is precisely the headless case the socket exists for:
the click-test rig drives this app over it with no window manager and no user.

MainFrame::ensure_design_panel() now builds the panel on demand and returns
it; the tab activation and the MCP dispatcher both go through it, so there is
one construction site rather than two. Safe to build wx controls there: that
handler is dispatched on the main thread by CallAfter, as its own comment
says.

The startup saving is untouched — a launch that never opens the tab and never
speaks MCP still builds nothing.

Verified: libslic3r_gui builds clean 745/745; fork parity green with snaporca,
which carries the same fix (McpControl.cpp is a byte-identical shared source).
2026-08-28 14:11:23 +02:00
SoftFever 149ae6c7fe Merge branch 'main' into cad-mainline 2026-08-28 19:08:46 +08:00
SoftFever 41365736ff Fix the Windows build 2026-08-28 19:06:55 +08:00
SoftFever f130b713c8 fix shellcheck errors 2026-08-28 16:01:42 +08:00
SoftFever 7fc97a81bd Build only the OCCT and solver pieces the Design tab needs
SLIC3R_CAD=OFF now builds without SolveSpace or OCCT's ModelingAlgorithms
module, leaving the dependency set identical to upstream's, and the Design
tab's mate connector preference no longer appears in builds without the tab.
When the deps prefix and the project disagree about the option, the configure
fails naming the cause, rather than failing at link time or at first launch
on Windows. The Windows packaging step stages exactly the toolkits libslic3r
links.
2026-08-28 12:59:39 +08:00
SoftFever f693b8d9fe List the Design tab headers and drop the unrelated build changes 2026-08-28 12:06:37 +08:00
SoftFever 2efb29d9c7 Make the CAD recipe tests self-contained and cover the importer 2026-08-28 02:48:02 +08:00
SoftFever 62f49bd105 Restore the upstream idle-loop dirty handling 2026-08-28 02:19:41 +08:00
SoftFever b9f8e825ac add missing files to list.txt 2026-08-28 02:08:25 +08:00
SoftFever 0eae703030 Pass plate chrome suppression as a render argument 2026-08-28 02:07:25 +08:00
SoftFever 72639ee12a wrong place 2026-08-28 01:26:15 +08:00
SoftFever 806db473de Give the Design tab its own camera view
The Camera is Plater-owned and shared by every canvas, and Design sits
outside the panel switch that saves and restores it for Prepare,
Preview and Assemble — so orbiting in Design moved what the editor
tabs showed, and Design lost its own view on every switch. Trade the
live camera for a parked one on the way in and back on the way out, so
Design keeps its view the way Assemble already does.
2026-08-28 01:05:01 +08:00
SoftFever ba1df32e88 Reset the CAD document with the project, and track its changes
New Project and Open Project went through Plater::priv::reset, which
drops ModelObjects but not the Model-level recipe, and never touched
the Design panel's document at all — so the previous design stayed
loaded and its next edit wrote itself into the new project.

A design that has not been committed to the plate has no ModelObjects,
so the project also read as clean: no autosave, and no unsaved-changes
prompt before the reset threw the design away.
2026-08-27 23:35:18 +08:00
SoftFever a1110b0050 Revert unrelated Stealth mode and cloud login changes 2026-08-27 23:13:41 +08:00
SoftFever 1750c52211 Build the Design tab on first use instead of at startup
DesignPanel's constructor creates several hundred controls and its own GL
canvas, which every launch paid for whether or not the user ever opened the
tab. The notebook page is now an empty placeholder and the panel is built
into it the first time the tab is selected, so m_design_panel stays null
until then.

This also lifts the ordering constraint that had pushed the plater's
background colour and Hide() below the panel construction; they go back
where they were.
2026-08-27 22:50:30 +08:00
SoftFever a24ec03e87 fix flatpak build error 2026-08-27 18:50:35 +08:00
SoftFever ea373c653e delete test result 2026-08-27 18:50:09 +08:00
SoftFever 5a170520d3 refactor: rename SnapOrca references to Orca in CAD components to avoid confusion and update recipe versioning 2026-08-27 18:49:50 +08:00
SoftFever 0aeb6df122 Merge branch 'main' into cad-mainline 2026-08-27 17:06:37 +08:00
Tommaso BianchiandClaude Opus 5 3fd5c3353a A reference is a reference whatever feature holds it
Port of snaporca 1bb9825db0. Four defects from an independent 20-agent audit,
each verified in the code first; two further findings from the same report were
verified OUT and are not in this commit.

remove_feature()/move_feature() remapped Extrude::sketch_ref and a Mate's two
connectors and nothing else, leaving seven of the nine index-bearing fields —
sweep_path_ref, loft_profile_refs[], pattern_curve_sketch, rib_sketch_ref, and
sketch_ref on Revolve, Sweep, Rib and the Surface* family — pointing at whatever
slid into the slot. Quiet by construction: the shifted index still names a real
feature, recompute() succeeds, the solid is built from the wrong profile. The
comment above the loop already required "EVERY field holding a feature index"; the
code under it handled two, because a type switch is only correct on the day it is
written. for_each_feature_ref() visits the FIELDS instead, so a feature type added
later is covered the moment it reuses one. plane_base and axis_plane_a/b are
excluded on purpose and documented at the helper — they encode an ordinal into the
datum-plane list, not an index into features[], and are filed separately. The
delete cascade got the same field-based treatment.

The regression test was run against the pre-fix code to prove it bites: all three
sections fail there, and move_feature returns TRUE while leaving sketch_ref == 1
where it must be 0 — success with the wrong answer, which is what makes this class
expensive.

apply_constraint, commit_entity_constraints and delete_constraint mutated the
recipe with no checkpoint() and no sync_recipe_to_model(), alone among seventeen
mutation sites in that file: Ctrl+Z reached past the constraint edit and discarded
unrelated work, and saving persisted the pre-constraint blob. A rejected constraint
now calls abandon_checkpoint() rather than leaving an undo step that does nothing.

MCP: params["generation"].get<uint64_t>() sat outside the try inside a bare
CallAfter lambda, so one malformed string terminated the process through the wx
event loop; it is type-checked now and the lambda lets nothing escape. The socket
bound with no mode of its own in a world-writable directory — umask around bind()
plus chmod, and it refuses to listen rather than listen wide. The reply write is no
longer a bare write(), which could SIGPIPE the app when a client hung up.

Kernel suite on this fork: 190 cases / 2562 assertions, green. GUI target compiles.
The full ladder gate ran on snaporca (ALL LADDERS HELD — gestures 98/98, offer
108/108, corpus and corpus-scale green) and fork-check parity holds at 17 identical
/ 8 diverging as expected, which is what makes that gate transferable here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-24 19:00:45 +02:00
Tommaso Bianchi 6e7f6429fa A body carries its own name, because a body is not its first feature
User report 2026-08-23, and it is right: "you have renamed the feature extrusion,
not the body. i clicked rename on the body feature tree and the feature extrude
changed name. this means that you consider the extrusion = the body. this is very
far from truth as a body can contain several extrusions."

That is exactly what the previous commit did. It resolved a selected body to
CadBody::source_feature and renamed THAT feature, on the reasoning that a body has
no name of its own. The reasoning described an implementation detail — CadBody::
name is derived and restamped on every recompute — and mistook it for the user's
model. An Extrude, a Cut and a Fillet all land on the same body: the maker is one
operation in its history, and renaming it renames the wrong object.

A body now has a name of its own. CadBody::user_name, set only by a rename, is:
  - carried across recompute() by body index, next to the per-body colour override
    and under the same index contract the GUI already relies on for visibility and
    Move — without which a name would survive exactly until the next feature;
  - written into the recipe, because bodies are recomputed and never serialised, so
    a name has nowhere else to live and would otherwise vanish on reopen;
  - shown on the Bodies row ahead of the derived maker name, as "Body N — name",
    with the number still leading because every status line, the interference
    report and the mate errors identify a body that way.

The recipe block is APPENDED after the variables block rather than given a version
bump. A build that predates it reads features and variables, returns, and never
looks at the trailing bytes — so yesterday's projects open here and today's
projects still open there. A bump would have cost every project written today its
readability by the previous build, for one optional field.

Renaming a FEATURE is unchanged. The feature tree renames features; the Bodies
list renames bodies; neither reaches into the other.

WHAT THIS COST, and why it is written down. Getting here took two wrong turns
inside one fix, both mine:

  1. UnselectAll() -> Unselect(). Both trees are wxTR_SINGLE, where UnselectAll()
     — the MULTI-selection call — does nothing. That was the one-word reason the
     rename had been vetoed everywhere (BEGIN_LABEL_EDIT refuses while
     tree_body_selection() >= 0). Fixing it turned a pair of harmless no-ops into
     a real loop: the two lists clear each other so "the target" is unambiguous,
     so clicking a body row ran apply_body_row -> m_tree->Unselect() -> the feature
     tree's SEL_CHANGED -> m_parts->Unselect(), which cleared the row just clicked.
     The handler now clears the other list only when it actually holds a selection.
  2. Trusting a screenshot taken after a polluted run. A leftover Rib card had
     shifted the whole panel, so a click measured against it landed nowhere near
     the row. Relaunch, then measure.

VERIFIED, on the rig and in the kernel:
  body renamed          ('Extrude', user_name=False) -> ('Bracket', user_name=True)
  features untouched    ['Sketch', 'Extrude'] before and after
  survives a recompute  add a Hole to the same body: features become
                        ['Sketch', 'Extrude', 'Hole'], body stays 'Bracket'
  survives the recipe   serialize -> deserialize -> 'Bracket'

New kernel test "a body carries its own name, through recompute and the recipe"
pins all three properties; the suite is 189 cases / 2547 assertions.

Gate green: ALL LADDERS HELD — offer table matches the atlas, kernel suite,
engine rungs 1-8, 977-sheet corpus + the heaviest sheets, gesture ladder 98/98,
offer ladder 108/108.
2026-08-23 19:09:17 +02:00
Tommaso Bianchi 0ac9ac91f7 A body can be renamed, and the one-word reason it could not
User report 2026-08-23: "clicking on a body row in feature tree, I cannot find
rename on right click", then "still i cannot rename body1 in custom name".

TWO SEPARATE CAUSES, one in data and one in a single method call.

THE MISSING ROW WAS DATA. The `rename` verb's accepts list in the tool atlas was
["sk_loop"] alone, so the offer built for a selected BODY carried no Rename row.
Right-clicking a body row already opens the offer — that is the designed gesture,
bound on m_parts as wxEVT_TREE_ITEM_MENU — so the menu the user was looking at
was the right menu, and it was simply missing the verb. accepts is now
["sk_loop", "body_solid"], the generated table regenerated with it (the accept
mask moves 0x00004000 -> 0x00004080), and the offer trace confirms the row:
"[OFFER] row=7 Modify > rename".

THE RENAME ITSELF WAS BLOCKED BY UnselectAll(). Both trees are wxTR_SINGLE, and
wxTreeCtrl::UnselectAll() is the MULTI-selection call: on a single-selection tree
it leaves the row selected. So every path that tried to open the label editor
while a body row was selected hit the BEGIN_LABEL_EDIT guard — which vetoes while
tree_body_selection() >= 0, the rule that stops a body taking a name it cannot
keep across a recompute — and the editor never opened. Unselect() is the call
that works, and it fixes every route at once.

That took five attempts, four of them wrong, and the reason they were wrong is
worth more than the fix: each one addressed a plausible cause that the evidence
did not actually support — the popup's nested event loop, keyboard focus,
deferring with CallAfter, dispatching through a different verb. What settled it
was a DISCRIMINATOR rather than another fix: pressing F2 on a selected body row
takes the same handler with no menu and no nested loop. F2 failed identically,
which ruled out every menu-shaped theory in one measurement and left only the
state the veto reads.

A body still has no name of its own — it is recomputed from the recipe on every
change and CadBody::name is derived from the feature that builds it — so the verb
resolves the body to CadBody::source_feature and renames THAT, saying so on the
status line: "A body takes its name from the feature that makes it — renaming
'Extrude'". The Bodies row now reads "Body 1 — Extrude" so the rename is visible
where it was made; the positional "Body N" leads, because every status message,
the interference report and the mate errors identify bodies that way. Confirmed
as the wanted format by the user.

Also here, from the same report: the Bodies card keeps its own action row (Move,
Show / hide, Delete, Colour), and the competing context menu an earlier pass had
added to body rows is REMOVED — right-clicking a body belongs to the offer, and
two menus on one gesture is how the offer ended up being blamed for a veto.

Verified on the rig, both routes, with a body selected:
  F2 on the body row        -> ['Sketch', 'Extrude'] became ['Sketch', 'Base block']
  the offer's rename verb   -> {'applies': True, 'dispatched': True,
                                'selection_kind': 7, 'ok': True} and the same rename

Gate green: ALL LADDERS HELD — offer table matches the atlas, kernel 188 cases /
2532 assertions, engine rungs 1-8, 977-sheet corpus + the heaviest sheets,
gesture ladder 98/98, offer ladder 108/108.

RIG DISCIPLINE, repeated twice in one session and now written down: ladder-all.sh
does not relaunch the app, so hand-driving the rig immediately before a gate
leaves state its reset_document() cannot clear — both times the first rung drew
nothing and reported "sides []", which reads exactly like a broken rectangle
tool. Relaunch before gating.
2026-08-23 18:15:11 +02:00
Tommaso Bianchi b0657fb2c9 The row menu carries the whole row, and Move body goes where bodies live
Two decisions from the user, 2026-08-23, after the first pass at making rename
reachable.

ONE SURFACE CARRIES THE VOCABULARY, and it is the row's own menu: Rename (F2),
Edit, then Move up, Move down, Show / hide, then Delete. Offered as a choice
between completing the menu or completing the header icons; the menu won because
the element you click answering with what applies to it is this fork's charter,
and because a menu grows without spending an icon nobody recognises. The header
icons stay exactly as they are — a quick bar for the common three — so nothing
that worked yesterday moved.

The menu is grouped rather than listed: what the row IS (name, contents), where
it SITS (order, visibility), and what removes it. Right-click SELECTS what it
points at before opening, so the menu can never act on a row other than the one
under the cursor.

MOVE BODY LEAVES THE FEATURE-TREE HEADER. It never belonged there: that header
sits over the FEATURE tree, and the button had to guess its subject from
whatever happened to be selected — a feature row got answered with "Select a
body to move it", an instruction about a different kind of object in a list that
does not contain one. A body now has its own action row on the Bodies card:
Move, Show / hide, Delete, Colour. The last three are deliberate COPIES of
feature-tree actions, not a reorganisation: a body row is a different subject,
and someone working in the Bodies list should not travel to another card to hide
or recolour what they have just selected. Each handler already resolves the body
row itself (on_toggle_visibility, on_delete_body, on_set_body_color), so the
card gives them a home and decides nothing.

The offer already carried Move for a selected body (verb `transform`, accepts
body_solid), so that half of the requirement was in place and is untouched.

One piece of the old button was function, not clutter: it also scaled imported
Text/SVG artwork, which IS a feature-row action. That moved to the row's own
menu as "Scale artwork", shown only on a row that has imported regions — so the
capability survives the split instead of disappearing with the button.

Verified on the rig, by the gestures themselves: right-click a row -> the six
items in their three groups; choosing Move down turned ['Sketch1', 'Sketch2']
into ['Sketch2', 'Sketch1']; and with a body present the Bodies card shows its
four actions while the feature header no longer shows Move.

Gate green: ALL LADDERS HELD — offer table OK, kernel 188 cases / 2532
assertions, engine rungs 1-8, 977-sheet corpus + the heaviest sheets, gesture
ladder 98/98, offer ladder 108/108.

RIG DISCIPLINE, learned the expensive way in this session: ladder-all.sh does
NOT relaunch the app, so hand-driving the rig immediately before it leaves state
the ladder's reset_document() does not clear — here the first rung drew nothing
at all and reported "sides []", which reads exactly like a regression in the
rectangle tool. Relaunch the app before a gate, and re-run before believing a
failure that appears in rung one.
2026-08-23 16:53:43 +02:00
Tommaso Bianchi 3d3324d663 A feature-tree row can be renamed by someone who does not already know F2
User report 2026-08-23: "on feature tree, i cannot rename sketch name".

The rename was not broken. Verified on the rig before changing anything: select
the row, press F2, type, Enter — Sketch1 becomes Base, the name reaches
m_doc.features[idx].name and sync_recipe_to_model() persists it. The offer's
btn:rename verb does the same. What was missing was any way to find that out.

Everything a person would try did something else:
  - the pencil in the section header is EDIT (on_edit_feature)
  - a double-click fires wxEVT_TREE_ITEM_ACTIVATED, which is also Edit
  - none of the seven header icons renames
  - right-clicking a row did nothing at all
and the comment above the label-edit handlers claimed a "slow double-click"
renames, which does not survive wxGTK: the activation wins and the sketch opens
for editing instead. So the only route was an undocumented function key, and the
report is exactly right from where the user stands.

The row now answers the gesture people actually use on a named row: right-click
gives Rename (F2) / Edit / Delete, with Rename opening the in-place editor on
that row. Right-click SELECTS what it points at first, so the menu can never act
on a different row than the one under the cursor.

And selecting a row now says what the row can do: "Sketch1 selected — F2 or
right-click renames it, double-click edits it". Cheaper than a tooltip nobody
hovers, and it uses the status line that already exists for exactly this.

A note on the surface, since it is a design call: the CANVAS right-click is the
offer, this fork's single adaptive menu, and this is not that. A tree row is a
different surface, and its menu is three items about the row. Routing tree rows
through the offer would mean teaching the offer a selection kind that is not
geometry, which is a larger change and not what this report needed.

Verified on the rig by the gesture it names: right-click the row, choose Rename,
type, Enter -> ['Sketch1'] becomes ['Base profile'] in describe_scene.

Gate green: ALL LADDERS HELD — offer table OK, kernel 188 cases / 2532
assertions, engine rungs 1-8, 977-sheet corpus + the heaviest sheets, gesture
ladder 98/98, offer ladder 108/108.
2026-08-23 16:21:23 +02:00
Tommaso Bianchi 1e51b54239 Mirror stops destroying arcs, and the Construction box converts what you picked
Two user reports from the same session on the deployed build, 2026-08-23.

FIRST: "if I select a shape (es a circle draw in construction lines) and then I
try to toggle contruction to obtain a full line, does not work". Reproduced: Q
converts the selection and so does the offer's Reference > Construction row —
both run m_keys_sketch['Q'] — but the CHECKBOX, the one control actually
labelled Construction, only ever called set_sketch_construction(), which arms
the mode for the NEXT entity. So the obvious control was the one route that
could not convert existing geometry, and it failed silently while also flipping
the draw mode behind the user's back. It now carries Q's meaning.

Scoped to Select mode, and that scoping is not cosmetic: drawing AUTO-SELECTS
what was just drawn (draw-then-edit), so with a draw tool armed "there is a
selection" does not mean the user picked anything — it means they finished a
line. The first version converted there and turned the box into a trap: arm
construction, draw the axis, click the box to go back to real geometry, and
instead of disarming the mode it converted the axis just drawn. The gesture
ladder's C4 rung does exactly that and reported three construction entities
where it wanted one. In Select mode the intent is unambiguous.

SECOND, and this one destroyed work: "after creation of a circle, a round angled
rectangle and a slot, and mirror of those shapes on a vertical line inside a
outer rectangle, preview is ok but application creates errors: the circle is
mirrored, but rectangle and slot are redrawn as pieces of circles screwing both
the original shapes and the copies." A screenshot came with it, and it showed
more than the words did: the ORIGINALS were wrecked too — the rounded rectangle
was drawn as a four-lobed cloud, each corner fillet having gone the long way
round, and the slot had ballooned into two near-full circles.

Measured on the rig, a slot mirrored about a vertical line:

    rails   62.873 / 62.873  ->   2.082 / 62.913
    caps    r=21.554 sweep=-180.00  ->  r=32.214 sweep=-237.66
    and all four sources moved, the axis line with them

Cause: confirm_op's Mirror branch bound an Arc copy to its source with a
Symmetric constraint on the CENTRE ALONE. An arc has five degrees of freedom;
pinning two of them leaves the endpoints and the sweep free while the shape's
own coincidences still pull on them, and the solver answers with a different,
internally consistent sketch — which is what a reflex cap and a 2 mm rail are.
A circle came through the same code untouched because a circle HAS no endpoints
to leave free, which is exactly why the failure reads as "circles fine, rounded
rectangles and slots destroyed".

Three parts, and each one is here because the measurement caught the previous
one being half a fix:

  1. Arcs are bound by BOTH ENDPOINTS. Endpoints before centre in the ladder:
     {p0, p1} is four equations against five DoF and pins the sweep, while
     {centre, p0, p1} is six and is refused — the refusal is what silently
     degraded the batch to a set that left the sweep free.
  2. Every copy is reflected from the PRE-BATCH source, so a batch that disturbs
     the sketch cannot hand the next copy already-moved geometry.
  3. THE APPLIED RESULT IS THE PREVIEW — checked on the sources AND the copies,
     and on violation the whole constraint web is dropped and both halves are
     restored to the reflection the preview drew. try_add_constraints rolls back
     only when a solve FAILS, and every failure here came from a solve that
     succeeded at something else. Guarding only the sources fixed the slot and
     left the rounded rectangle's copies at a 13.8 mm rail and a 308 degree cap:
     the original was safe and the copy was still wrong, which is half a fix.

The parametric link is kept whenever it provably holds the geometry, and dropped
when it does not. A wrong shape is worse than an unlinked one.

WHY NOTHING CAUGHT THIS: the gesture ladder's mirror rung reflects three
straight LINES. It sat green through the whole defect. C4b now mirrors a slot,
so the reflection has arcs in it, and grades the property the user actually
stated: the copy is the source reflected, the source does not move, and no cap
comes back reflex.

VERIFIED against the user's own scene, rebuilt gesture by gesture on the rig —
outer rectangle, circle, rounded rectangle and slot, a vertical CONSTRUCTION
line as the axis, all 17 entities mirrored in one gesture:

    ok  the mirror axis is a construction line
    ok  picked the axis and all 17 entities
    ok  originals unchanged (moved: [])
    ok  every copy is the exact reflection (worst 0.000000000)
    ok  no source arc turned reflex — the 'cloud' failure
    ok  no copied arc turned reflex (6 arcs checked)

One grader correction worth recording, because it cost a round and would cost
the next one too: a reflection REVERSES ORIENTATION, so a copy legitimately
stores p0/p1 the other way round. Comparing p0 to p0 grades the storage order,
not the geometry, and reported a perfect mirror as an 8.98 mm error. Endpoints
are compared as an unordered pair.
2026-08-23 15:33:47 +02:00
Tommaso Bianchi 65e2b6f626 The sketch says what to do next, and construction geometry looks like it
User report, 2026-08-23, after using the freshly deployed build: "selection and
removal of existing elements of the 2d sketch is not intuitive, and the bottom ui
text does not illustrate what the user has to do to properly use the selected
tools. Mirror, for example, does not indicate: first select mirror line then
entities to be selected, and there is no UI indication of what is being selected.
normally, costruction lines are dotted." Four defects, all of them in the 2D
vocabulary this fork's charter puts at the centre, and all four fixed here.

snaporca-1c0c (P1) — the prompt was written ONCE, when the tool was armed.
DesignPanel's select_tool lambda set a sentence and nothing ever revised it, so
every step after the first was unguided: Mirror said "pick axis, then entities"
and then never said which of the two you were on; Escape silently downgraded an
armed tool to Select (request_exit is layered: anchors, then tool, then session)
while the line still named the tool you had left; and nothing ever mentioned that
Del removes a selection. The fix moves the line off the arm event and onto the
tool's LIVE state. DesignSketchTool::emit_step_hint() reports (mode, step, picks)
whenever that triple moves, from render() — the one place every state change in
this tool passes through. Putting it there instead of in the thirty-odd branches
of on_mouse is the whole point: a per-call-site notification is a thing the next
tool forgets to add, and it costs three int comparisons a frame. DesignPanel owns
the words, in ONE table (sketch_step_prompt), whose step numbers are the same ones
render() previews and on_mouse consumes, so the description cannot drift from the
code that reads the clicks. Every tool now names the gesture that ENDS it, because
none of them was discoverable: an empty click applies an edit-op or a transform,
right-click cancels it, Esc goes back to Select.

snaporca-vd6v (P2) — Mirror mirrors its axis pick and its target picks into
m_selection, so both painted white and the picture could not answer "what did I
select as what". The edit-op's first pick — Mirror's axis, Fillet/Chamfer's first
line — now paints violet. Violet and not cyan: cyan means SELECTED in this canvas
and nothing else may wear it, a rule this file already carries in writing.

snaporca-imlq (P2) — construction geometry drew as a solid grey line. Every CAD
dashes it, and grey alone does not read as "reference" against the
under-constrained orange. dash_polyline() chops the polyline before it reaches
draw_quad_strip, with the dash and gap in world units scaled by units-per-pixel,
so a dash keeps its size on screen instead of becoming a solid line when you zoom
out and three dashes when you zoom in.

snaporca-oql1 (P2) — Backspace now deletes as Del does. On every laptop this runs
on, Del is a chord and Backspace is what a hand reaches for. The Select-mode
prompt states the rest (Shift-click adds, double-click takes the loop, Del
removes), and the first step of every armed tool names the Esc route back to
Select, which was the invisible half of "selection is not intuitive".

Also, on the same report: the sketch stroke half-width goes 0.6 -> 0.3 mm. At 1.2
mm wide the orange line swallowed a short segment and hid which of two near
parallel lines the cursor was on. One constant, because all twenty call sites of
draw_quad_strip are sketch strokes.

Retired on the way: the on_sketch_selection_changed status writer. It said "N
selected — Delete removes them" while an edit-op mirrored its picks into the
selection, i.e. in the middle of a Mirror gesture, where Delete does nothing of
the sort. on_sketch_step says the true thing for Select and says nothing false
anywhere else. And the live length/angle readout is now APPENDED to the step
guidance rather than replacing it: it fires on every mouse move, so it used to
erase the instruction for the step in progress one move after the click that
started it.

Two false trails, recorded so the next session does not walk them again:
  - DesignPanel.hpp deliberately does not include DesignSketchTool.hpp, so the
    panel's handler takes the mode as an int and the .cpp casts it back. The
    first attempt put Mode in the header signature and the build said only
    "expected ',' or '...' before 'mode'".
  - The offer ladder failed six properties against a perfectly good binary
    because I had relaunched the rig myself without SNAPORCA_KEYTRACE=1, and its
    [OFFER] trace lines ARE its instrument. A ladder with no instrument reports
    "None", which reads exactly like a regression in the offer. ladder-all.sh
    launches it correctly; a hand relaunch must too.

VERIFIED, not merely compiled. Driven on the headless rig with synthetic mouse
and keyboard, and photographed at each step: "Mirror — first click the LINE to
mirror about (a construction line works)  ·  Esc goes back to Select" ->
"Mirror — axis set  ·  now click the entities to mirror  ·  right-click cancels"
-> "Mirror — axis set  ·  1 to mirror  ·  click another to add or remove it  ·
click empty space to apply", with the axis violet, its target white, and the
construction lines dashed while real geometry stays solid.

Full gate green afterwards (scripts/ladder-all.sh, ALL LADDERS HELD):
  offer table vs the atlas          OK
  kernel suite                      188 test cases, 2532 assertions
  engine ladder                     rungs 1-8, ALL RUNGS HELD
  corpus rung                       977-sheet drawing corpus, every 20th -> 49
                                    sampled, 39 gradeable, 39 clean
  corpus scale rung                 the 6 heaviest sheets, all clean
  gesture ladder                    93/93 properties, real mouse and keyboard
  offer ladder                      108/108 properties, through the right-click
                                    menu and the verbs behind it

That harness is the reason a UX change of this size can be made in one pass and
believed: 93 + 108 properties are driven the way a person drives the app, and the
977-sheet corpus keeps the engine underneath them honest against real drawings
rather than against my own arithmetic.
2026-08-23 13:46:21 +02:00
Tommaso BianchiandClaude Opus 5 3c7105202a The offer ladder stops depending on what ran before it
It passed alone and failed inside scripts/ladder-all.sh, twice, on the same property: "right-click
with two picked -> None". The diagnostic it now prints said what a screenshot could not — editing=True
with an empty selection after two clicks that should have picked two entities. Three rig facts, all
about the DRIVER, none of them a product defect:

A LEFT-CLICK ON A LINE'S MIDDLE OPENS ITS LENGTH FIELD. The Select branch tests m_live_quotes before
it picks, with a ~24 px label tolerance, and a line's Length quote sits at its midpoint — so the
click that was meant to select it promoted a dimension and froze the canvas instead. Everything
after it landed on nothing. It bit only when the previous step had left that line selected, because
live quotes are drawn for the SELECTION: hence passing alone and failing in the gate. Lines are now
picked at 0.3 along, clear of the label.

A FIELD THAT HAS NOT OPENED YET READS LIKE ONE THAT NEVER WILL. The queue opens each field from a
CallAfter, so a driver that looks once, sees nothing and moves on gets frozen by the field that
arrives a moment later. keep_as_drawn() now waits for QUIET — two consecutive clear readings — and
draw_line_at drains stragglers before handing back.

A KEY CANNOT CANCEL A SESSION WHOSE CANVAS IS FROZEN. gui-ladder's enter_sketch dismisses the old
sketch with Escapes, which an open field swallows, so the session survived and the four calibration
probes landed in it on top of what was already there: "calibration expected 4 points, got 7". Every
rung now enters through fresh_sketch(), which cancels through the socket first — that cannot be
swallowed.

Measured after the fix, in the sequence that failed: gesture ladder 93/93 then offer ladder 108/108,
back to back on the same app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 07:51:03 +02:00
Tommaso BianchiandClaude Opus 5 96f9261425 The offer table is generated again, and its last verb was unreachable
snaporca-ziam said gen_offer_table.py would silently delete the model-mode "Constrain sketch"
row, because that row lived in the generated header and not in tool_atlas.json. Running it found
more than that: FOUR rows existed only in the header — constrain, rename, and the three typed-
value rows sk_length / sk_radius / sk_angdist — and sk_delete's action had drifted, pointing the
sketch row at btn:delete, the FEATURE delete.

All five are now in the atlas, so the header regenerates byte-identically from it. Verbs may carry
a `note`, emitted as a C++ comment above the row: a rationale written into a generated file is
deleted by the next regeneration, which is how this started.

snaporca-z8rs (P1), found by making that true: after the atlas held all 92 verbs, the regenerated
header differed from the checked-in one by EXACTLY ONE LINE — kOfferVerbCount, 91 against 92.
Every consumer loops i < kOfferVerbCount, so the last row of the table was invisible: never listed
by show_offer_menu, never findable by mcp_run_verb. The verb that fell off the end is sk_angdist,
"Angle / distance…" — the typed-value row for a two-entity selection. On the one selection where
you would ask for the angle between two lines, the row that types it was not in the menu.

It survived because nothing compared the Sk2Ent menu against the table: sk_angdist accepts Sk2Ent
and nothing else, so an off-by-one that dropped the LAST verb was invisible from every other
selection. The vocabulary rung now covers Sk2Ent too, and picking the pair taught it one more
rig fact — shift-clicking a circle at its +X point grabs the RADIUS GRIP, which replaces the
selection with that one entity, so the pair silently collapsed to one and the offer answered
SkLine. Correctly, for the selection that actually existed.

gen_offer_table.py --check proves header == atlas and changes nothing; it is now the first step of
scripts/ladder-all.sh, and the only one that needs no rig.

Offer ladder 107/107, gesture ladder 93/93, both on the rig.

snaporca-ziam snaporca-z8rs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 06:14:48 +02:00
Tommaso BianchiandClaude Opus 5 1bde448f51 Every 2D verb without a shortcut, driven from the offer — and three more defects
The 2D vocabulary is 46 verbs: 22 have a shortcut and the gesture ladder drives them, 24 have
none and nothing had ever exercised those. They are reachable only from the right-click offer, so
a key-driven ladder could not have touched them whatever it did. Four new rungs drive all 24, and
the coverage claim itself is now arithmetic against DesignOffer.hpp (rung O8) rather than a
sentence in a comment that rots when a verb is added.

The assertions are CONSTRUCTION invariants wherever a click cannot be exact — a regular polygon's
sides are equal to 1e-9 and its vertices lie on one circle; a tangent arc's radius at the shared
endpoint is perpendicular to the line to 1e-9 (measured cos 5.97e-17); the three clicks of a
3-point circle all lie on it; a circumscribed pentagon's circumradius is the inscribed one's over
cos(pi/5), 1.236067977 against 1.236067977. Where a value field opens, the typed value is graded
exactly: a moved line travels +25.000000000 in X and 0 in Y, a rotation turns 30.000000000 deg
and leaves the length alone, a scale multiplies it by exactly 3, a linear array's pitch is
[20.0, 20.0, 20.0] and a polar one's spokes are 60 deg apart all the way round.

Three defects found doing it, all fixed here:

snaporca-ua9g (P1) — delete_selected left three things behind. The AUTO-EDIT QUEUE, so a queued
field opened on a deleted entity and its commit went nowhere: draw a rounded rectangle, delete
everything, draw a 2-point circle, type 30 — the field opens, the digits are accepted, and the
radius stays 32.992020763. reset_autoedit() exists for exactly this and its own comment says so;
it was simply never called from here. The FEATURE GROUPS, whose [begin,end) ranges all shift on a
delete, so feature_of() answered with a group the user never drew — survivors are now remapped
and any group that lost a member is dropped, the rule the placed quotes already followed. And the
SOLVER STATE: no re-solve, so sketch_describe reported dof=16 for a document holding one circle.

snaporca-ekt9 (P2) — the read-back could not see three of its seven entity types. Ellipse,
EllipseArc and BSpline serialised as a bare type name: no centre, no semi-axes, no rotation, no
sweep, no poles. gui-ladder's ellipse rung had to grade the faceted area of the loop at 2e-2 —
that tolerance IS the faceting error — and its spline rung could only count entities. Now they
carry their parameters, and the ellipse arc's ends are asserted to satisfy (x/a)^2+(y/b)^2 = 1 to
1e-9.

Also read-only, and the reason the other two were found at all: sketch_describe now reports the
armed TOOL, the count of PENDING anchors, and whether a value field is EDITING. A menu walk that
lands one row off arms a neighbouring tool and then draws something plausible — the first run of
the authoring rung drew a circle of area 45238.93 and graded it as a rectangle. Every menu pick
now asserts which tool it armed, and the polyline rung (a per-segment Length field freezes the
canvas after every click) could only be written once the driver could ask whether a field was open.

Offer ladder 102/102 -> 105/105 with coverage. Gesture ladder 93/93 and the kernel suite
188 cases / 2532 assertions, both unchanged.

snaporca-ua9g snaporca-ekt9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 05:11:15 +02:00
Tommaso BianchiandClaude Opus 5 8c4b05ae9d The offer ladder: drive right-click, and fix the two things it found
The gesture ladder proved the TARGET — a complex closed profile, exact in vertices, lengths, arcs
and symmetry, voids correctly attributed. It proved it by arming every tool with a letter key,
which leaves the goal's own MECHANISM untested: the design logic pivots on right-click, and the
verbs offered are supposed to adapt to the element under the cursor. 47 of 86 Design-tab verbs
have a GUI action and no shortcut, so a key-driven ladder cannot reach more than half of them.

scripts/offer-ladder.py drives the menu. It asserts nothing from pixels: show_offer_menu emits an
[OFFER] trace from the same loop that builds the rows (behind the existing SNAPORCA_KEYTRACE), so
what the ladder reads cannot drift from what the user is shown, and the expected row set is
predicted by parsing DesignOffer.hpp rather than transcribed by hand. 25 properties, four rungs:
what each element type offers, that the menu equals the table for four selections AND that the
four differ, a 120 x 80 profile authored entirely through the menu, and a tool with no keyboard
route at all driven from the only door it has.

Two real defects, both found by it, both fixed here:

snaporca-ghcz (P1) — right-click was a black hole while any draw tool was armed. Every draw case
ended with `if (evt.RightDown()) { m_points.clear(); return true; }` and returned true even with
nothing to abandon; on_mouse records that in m_right_consumed and DesignCanvas suppresses the
offer whenever it is set. Measured: with Line armed, two right-clicks in a row produced no menu
and no tool change; only Escape freed it. Same rule snaporca-xmh6 wrote for the selection —
clearing nothing is not a gesture terminator. One shared right_abandon() now consumes the click
only when an anchor was really down; 16 sites, plus Polyline/BSpline (which end a chain, correct
only when there IS one) and Point (which has no anchor at all).

snaporca-lnri (P2) — right-clicking a sketch point offered the empty vocabulary. select_at_screen
tests hit_test_point first and records the hit in m_point_sel, but the offer counts m_selection
only, so a Point entity could never reach the entity branch and SkPoint was unreachable by
construction. A Point IS its own handle, so it is selected as an entity; other entities keep the
handle pick, since a line's endpoint is a drag target, not a vocabulary.

Offer ladder 25/25, gesture ladder 93/93 (no regression), both on the rig. The offer ladder joins
scripts/ladder-all.sh as the fifth rung.

snaporca-ghcz snaporca-lnri

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 03:45:33 +02:00
Tommaso BianchiandClaude Opus 5 12047e4085 A bulk sketch_add no longer freezes the next gesture
Draw-then-edit is armed from a jump in the entity count: render() sees n > m_autoedit_seen,
selects the last entity and schedules open_primary_autoedit. A scripted add made while a
creation tool was armed looked exactly like a drawn gesture, so it opened that tool's value
field — and an open field freezes the canvas (on_mouse_impl returns early on m_awaiting_length)
and swallows every letter (in_text includes inline_busy()). Measured on the rig: after
sketch_add, 'p' + click added nothing (4 entities before, 4 after); one Escape and the identical
sequence gave 5. It also explains the selection = [last index] that sketch_describe reported
although action_sketch_add never selects anything — the render pass wrote it.

Escape worked because it sequences two set_tool calls: the pending CallAfter fires between them,
so the second one commits the field it finds open. Arming a tool directly is one call, and the
CallAfter fires after it.

Fix: resync m_autoedit_seen at the end of add_entities_scripted, so a scripted add is not read as
something the user just drew. An already-open field is left alone. Covers sketch_add,
sketch_mirror and sketch_offset — the three callers.

The scale rung's Escape workaround is deleted, which is the issue's acceptance criterion; it is
now the regression test. Gesture ladder 93/93 on the rig.

snaporca-j7gc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 03:00:37 +02:00
Tommaso Bianchi bc1606a4d0 Port from snaporca ab22482e40: say why a sheet was skipped, and stop calling an encrypted PDF an engine failure
Over the whole 977-sheet corpus: 767 graded, 767 fully clean, 0 failures. The 210 skips are sheets whose part outline is not a closed stroked path at all (largest loop 5 to 132 mm2, measured), and one of them — MPD133 — is password-protected, which was being reported as an engine ERROR. Both now say what they are.

snaporca-j6sr
2026-08-23 02:19:30 +02:00
Tommaso Bianchi 4693542d0d Port from snaporca: the solver's 1024-unknown cliff, and the scale rungs
Two commits carried across (snaporca 579a9a9162, f68613cfc5).

Past about 480 entities a sketch had NO constraints at all and said nothing: libslvs
declares MAX_UNKNOWNS = 1024 and is handed every entity in the sketch at two params
per point, so the whole system came back TOO_MANY_UNKNOWNS and try_add_constraints
rolled the entire inferred batch back. From there no dimension could ever be applied.
Constraints only couple entities that share a point, so the solver now falls back —
only on TOO_MANY_UNKNOWNS — to solving connected components separately and committing
all-or-nothing. The auto-constraint pass batches its Horizontal/Vertical constraints
instead of one solve each, which is what kept the bulk path fast once solves started
succeeding: a 1204-entity load went 1585 ms -> 562 ms.

Plus the scale rungs (a thousand-entity plate drawn on by hand; the heaviest real
drawings graded and timed), the --step 1 fix that used to select nothing while
reporting a clean run, and scripts/ladder-all.sh as the one-command gate.

Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases /
2532 assertions, including "a sketch past the solver's unknown limit still solves".

snaporca-yww4, snaporca-x6v7, snaporca-j6sr
2026-08-23 02:04:03 +02:00
Tommaso Bianchi 82db99f337 Port from snaporca: sketch-only projects save, scripted geometry arrives exact,
and a ladder that draws with the mouse

Three commits carried across (snaporca 4ffd60eacb, 421055c2ec, b71216ce0b):

1. A design made only of sketches must survive being saved. CadDocument::recompute
   returned false with "no solid-producing features" for a document that has no
   solid, and two callers read that as "unusable": the GUI syncs the 3MF recipe
   only after a successful recompute, so a sketch-only design was saved with no
   recipe at all, and deserialize_recipe ends with `return recompute()`, so even a
   project that carried one was refused on load. Having nothing to build is now a
   success; a feature that MEANT to build a solid and produced none still fails.
   DesignPanel::refresh_tree syncs the recipe too, for the paths that call
   m_doc.recompute() directly.

2. Scripted geometry arrives exact. The Horizontal/Vertical inference window and
   the endpoint weld window both close to zero for add_entities_scripted; void
   attribution probes from a point strictly inside each loop instead of from its
   first vertex. Corpus rung 39 graded / 39 fully clean, was 35 with 6 failures.

3. scripts/gui-ladder.py — 17 rungs, 84 properties, all driven by synthetic clicks
   and typed values rather than through the socket.

Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases /
2532 assertions.

snaporca-mtav, snaporca-8xg1, snaporca-5hvl, snaporca-730j
2026-08-23 01:22:32 +02:00
Tommaso Bianchi 05ce2607a8 Ladder rung 9: grade the engine against 50 real drawings, not against my taste
Ported from snaporca 5b82c846f3.
2026-08-22 23:28:36 +02:00
Tommaso Bianchi 1274d97983 Sketch: closing a polyline is now a previewed snap, not an invisible bubble
Ported from snaporca 982968b1af.
2026-08-22 23:03:47 +02:00
Tommaso Bianchi 55a7baf067 Sketch usability: no invented geometry, no silent refusals, no stranded field
Ported from snaporca aab4248db8.
2026-08-22 22:51:28 +02:00
Tommaso Bianchi 1935ebb363 Sketch: a tool switch must not leave the rest of the queue armed
Ported from snaporca 8f5adda891. See that commit for the full analysis.
2026-08-22 16:05:18 +02:00
Tommaso BianchiandClaude Opus 5 fbacdeca7b Port: only the visible canvas may consume the 3D-mouse queue
Carries snaporca 9f0a6656bc.

Mouse3DController::apply DRAINS the input queue and every BOUND canvas idles and calls it, but a
hidden canvas's render() early-returns on _is_shown_on_screen() — so it swallows motion, applies
it to the shared plater camera, and draws nothing. The next visible frame jumps by more than one
state change. The plater keeps exactly one of its three views bound; the Design canvas binds once
at construction and never unbinds, so two canvases drain the same queue.

Guarded at the apply site so only the canvas actually on screen takes motion off the queue,
whatever happens to be bound, and without touching the plater's view-switching state machine.

Reported by exussum12 on PR #15238 as lag and jerkiness in the Design tab against "really smooth
on the other tab"; he guessed the mechanism correctly. NOT verified with a device — there is no
SpaceMouse here and the rig has no HID, so this is a mechanism traced in source and matched to a
user's description, not a measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:10:18 +02:00
Tommaso BianchiandClaude Opus 5 942f6c28c7 Port: mirror emits a half that continues the chain
Carries snaporca 0231bd5b68. Parity holds: 17 files identical, 8 diverging as expected.

A reflection reverses orientation, so mirror_entities now hands the reflected half back reversed
in ORDER and flipped per ENTITY — an arc swapping its angles as well as its ends, a spline
reversing its control points. Appending it to the source then yields one walkable chain instead
of two halves meeting head-to-head, and a mirrored CCW loop stays CCW.

This is the producer half of the confusion that cost three defects; the consumers (offset, and
the exact loop area) keep their defensive handling, because that is what makes them correct for
hand-built and imported sketches rather than only for geometry this function produced.

Contract change, carried with the reason: a mirrored line's p0 is the reflection of the SOURCE's
p1, and a mirrored CCW arc keeps a POSITIVE sweep — the reflection negates it, walking it the
other way negates it again. Both [SketchEdit] cases updated, and a new [SketchProfile] case
"a mirrored half continues the original chain" pins the property directly.

Kernel here: all tests passed, 2687 assertions in 232 test cases. GUI target builds and links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:00:50 +02:00
Tommaso BianchiandClaude Opus 5 cbbd24dcb4 Port the exact loop area, the offset traversal fix, and the 2D sketch ladder
Carries snaporca 572f794c84, d0f9a0052a, 9f7e4e3627 and 3974f8a170. Parity holds: 17 files
identical, 8 diverging by their expected counts.

EXACT AREA. A loop's area is now integrated entity by entity in traversal order — Green's
theorem — instead of being shoelaced over the render polyline, which faceted every arc into 24
chords and lost 2.02 mm2 on a 3706.86 mm2 stadium. 0.054%, invisible on screen, and wrong in a
number reported as "the area".

OFFSET FOLLOWS THE TRAVERSAL. Offsetting a mirrored profile put one half on the wrong side and
split the loop in two, because the chainer only followed p1->p0 links and each entity's offset
side was taken from its stored direction. Chains are now orientation-aware, seeded at a free end,
offset by `reversed ? -d : d`, and normalised head-to-tail on the way out — so offset is correct
for any input ordering and its own output cannot reintroduce the problem.

Both are the same underlying lesson, which has now cost three separate defects: an entity's
STORED direction is not its direction of TRAVEL around the loop.

THE LADDER. scripts/sketch-ladder.py is a graded suite of 2D sketches judged the way a person
judges them — VERTEX, LENGTH, ARC, TANGENT, SYMMETRY, CLOSED — with area only as a cross-check,
because area is derived and nobody can confirm it by eye. Eight rungs from a rectangle up to
MPD5 from the StudyCadCam corpus, a dia 27 x 95 pin reproduced as its revolve half-profile with
the R5 fillet tangency solved exactly. Entirely 2D: no extrude or any solid feature.

Kernel here: all tests passed, 2681 assertions in 231 test cases, including the new
"profile: a mirrored half offsets as one loop, not two". GUI target builds and links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:26:46 +02:00
Tommaso BianchiandClaude Opus 5 510e63dff2 Port the sketch usability fixes: Enter/Esc, rename, stale picks
Carries snaporca 95e59289f9, faec177d42 and 20df726ecb. Parity re-verified: 17 files identical,
8 diverging by their expected counts — DesignCanvas.cpp back to 16 and DesignPanel.cpp back to
32, which is the proof each hunk landed on the right side of the FeatFlyout and TAB_ID_PREPARE
divergences rather than on top of them.

All three answer exussum12's review on OrcaSlicer PR #15238.

ENTER/ESC IN THE VALUE FIELD. The field is a borderless always-on-top frame, and whether it may
hold keyboard focus is the platform's decision — a borderless NSWindow can never be key, and
mutter refuses a re-mapped window. When focus is denied the keys reach the panel instead and the
queued-dimension chain (a line queues Length then Angle) cannot be walked. The CHAR_HOOK now
forwards Enter/Numpad-Enter/Tab/Esc to the field when it is open and unfocused, and stays out of
the way when it is focused.

ESC FROM ANYWHERE. Separately and more simply: `dismissable` is false throughout sketch mode
because m_active is the FEATURE tool, so Esc fell through to whatever widget had focus. Click
any toolbar button or the Construction checkbox first and Esc did nothing at all — the likelier
reading of "Esc hardly ever works", and platform-independent. DesignCanvas exposes
request_sketch_exit() and the hook calls it whenever a sketch is live, after the inline-field
forwarding so an open field still takes Esc first.

RENAME. wxTR_EDIT_LABELS plus the two label-edit events write through to CadFeature::name and the
recipe, with a Rename verb in the offer and F2. The rebuild is deferred with CallAfter because
refresh_tree() destroys the very wxTreeItemId wx is holding during END_LABEL_EDIT — inline, it
killed the process.

STALE PICKS. set_tool now drops the Dimension tool's first pick, the Constrain picks and
m_point_sel, and delete_selected clears the pending dimension reference that could otherwise
dereference a renumbered entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:11:53 +02:00
Tommaso BianchiandClaude Opus 5 fbf858ba47 Port the MCP verb surface: run_verb / list_verbs / sketch_set_value
Carries snaporca 39fac9b725. Parity re-verified: 17 files identical, 8 diverging by their
expected counts, DesignPanel.cpp still at 32 — the mirrored files were copied and the two
divergent ones patched hunk by hunk, so the counts returning to their expected values is the
proof each landed on the right side.

All 90 offer verbs are now firable by name over the socket, which matters because a deck key
can only send a keystroke and 49 of them have no shortcut at all. sketch_set_value calls the
same apply_dimension the in-canvas value field calls, so a typed dimension can be asserted with
no window manager in the way.

Three guards came with it, each confirmed against the source: on_mass_properties bounds-checks
m_sel_solid_body (it defaults to -1, and run_verb bypasses the menu grey-out that used to hide
that); sketch_set_value validates its value at the boundary because apply_dimension records a
driving constraint even for values it refused to apply; and run_verb refuses btn:/fly: verbs
that do not apply to the selection while leaving key: verbs alone, so the socket offers exactly
what the GUI offers. Dispatch is deferred through CallAfter so no modal verb can wedge the
socket thread.

GUI target builds and links against the rebuilt deps image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 09:53:09 +02:00
Tommaso BianchiandClaude Opus 5 5d7fc8c545 Port the sketch layer work from snaporca: offset chains, right-click, MCP verbs
Carries snaporca 971320e129, 6b049f0dc6, 4aae782029, 444d59f212, 74cf3d7e54 and the build
guards from 597557a6e4. Parity re-verified after every hunk: 17 files identical, 8 diverging by
their expected counts — DesignPanel.cpp still 32, DesignCanvas.cpp still 16, which is the proof
each hunk landed on the right side rather than being copied over a real divergence.

OFFSET OFFSETS THE CHAIN. Per-entity offsetting returned a closed rectangle as four parallel
segments that no longer touch, so entities_to_wires gave four OPEN wires and nothing could be
extruded. offset_entities now chains by shared endpoints and repairs each seam by mitering the
neighbours to their intersection. Second bug, invisible to any single-entity test: +d meant
"left of travel" for a line but "radius + d" for an arc regardless of sweep, so a slot outline
offset with its straights going one way and its caps the other. The convention is now written on
the declaration and pinned by a test.

tests/libslic3r/test_sketchprofile.cpp is new and asserts the LOOP rather than coordinates —
the property that decides whether a profile can be built, and the one the existing single-entity
[SketchEdit] cases cannot see. Its include is catch2/catch_all.hpp here: this fork ships Catch2
v3 while snaporca is on v2, which is why the test files are a tolerated divergence.

RIGHT-CLICK PICKS WHAT YOU POINTED AT, so a line's own verbs are offered instead of the
empty-selection vocabulary; sk_delete stops sharing btn:delete with the feature tree; and an
element's defining number (length / radius / diameter / angle / distance) can be typed, from the
menu or from V.

TWELVE MCP SKETCH VERBS. The socket had ~40 verbs and none touched a sketch, so the 2D layer
could only be exercised by driving a GUI with synthetic clicks. sketch_describe reports each
closed loop, the loops it encloses as voids, exact areas, and where a chain is still open;
sketch_validate/sketch_heal are FreeCAD's ValidateSketch — find vertices that overlap within a
tolerance but carry no coincidence, then weld them AND record the constraint, so a loop closed
by floating-point luck becomes one closed by construction. scripts/mcp-sketch-smoke.py is the
loop that asserts all of it.

Kernel suite on this fork: all tests passed, 2677 assertions in 230 test cases. The GUI target
links against the rebuilt deps image (the wxInspector blockage is gone) and the binary carries
the new verbs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 09:00:26 +02:00
Tommaso Bianchi df45edb13d deps image: carry the rig's X runtime itself
Building orcacad-deps from this Dockerfile removes the snaporca-deps base, which is the point
(Trap 1: the old image's baked tree was project(Snapmaker_Orca)) — but that base was also where
Xvfb, openbox, xdotool and scrot came from. Without them gui-session.sh reports display DOWN and
orca-slicer dies with a trace trap on no display. Verified: the session now comes up with
display up, wm up, and the Untitled - OrcaSlicer window present.
2026-08-22 08:49:39 +02:00
Tommaso Bianchi 9764815cc3 rig-build: -j12, and a deps image that has wxInspector
Raises the bound from -j8 to -j12: the incident dump measured 1.17 GB average per cc1plus,
so 12 in flight is ~14 GB typical, well inside the 40 GB cgroup ceiling that is the real
guarantee. Keeps the file byte-identical to snaporca's copy, which the header requires.

Dockerfile.deps builds the deps with -j 12 for the same reason, and symlinks
deps/build/destdir -> deps/build/OrcaSlicer_dep: this tree installs under the latter name
while the orcacad_buildcache volume has the former baked as absolute paths in its CMakeCache.
Same tree, two names — without the link, one directory rename costs a full cold rebuild.
2026-08-22 08:49:39 +02:00
Tommaso BianchiandClaude Opus 5 8906bfa72b rig-build: bound the memory a build can take
Both forks ran this script at once on 2026-08-21, each with ninja -j$(nproc)=16.
36 cc1plus held 42 GB of a 62 GB box, the kernel OOM-killed for 2h28m, ssh went
unreachable, lightdm was destroyed (2946 session kill events), and neither build
produced a single object file.

Three bounds, weakest to strongest: a flock on a path SHARED by both forks so
they serialise instead of summing; -j8 so the box stays usable while it
compiles; and --memory on the container, which is the actual guarantee — a
runaway build now dies inside its own cgroup instead of taking the host with it.
--memory-swap is pinned equal to --memory because swap thrash is what made ssh
hang rather than fail.

Kept byte-identical to the copy in the snaporca fork, as the header requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 08:49:39 +02:00
Tommaso BianchiandClaude Opus 5 3ad6d2fd50 Give Commit to Plate and the bed toggle a keyboard, on a Ctrl+Shift layer
Both were mouse-only: Commit to Plate is a toolbar button bound to wxEVT_BUTTON,
the bed is a CheckBox, and neither had an accelerator. That put them out of
reach of anything driving the keyboard, and out of reach of a hand that had not
already left the model to find them.

Ctrl+Shift, because the Shift+letter space is full to the last letter and
because the char hook deliberately ignores every Ctrl-combo -- which is exactly
what leaves this layer free to claim. P is Plate and B is Bed; neither collides
with OrcaSlicer own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate), and
nothing else in the tree binds either.

The lookup goes ahead of the guard that drops Ctrl-combos, and nothing already
bound changes meaning: a plain Shift+letter still resolves as before, because
the new layer only answers when Ctrl is held as well.

The bed toggle drives the checkbox rather than the viewport alone, so the
control and the view cannot disagree about what is shown, and it says which it
did in the status line.

Both verified on the running build: Ctrl+Shift+B toggles the grid and the
checkbox together, Ctrl+Shift+P commits to Prepare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:25:29 +02:00
Tommaso BianchiandClaude Opus 5 eb66e45b7b Design: confine the mapped-frame workaround to GTK, so macOS stops hanging
Reported on PR #15238: on macOS, drawing a corner rectangle and pressing Enter
on the first dimension opens the second field and then wedges the entire
application — no typing, no Escape, dead menu bar, no tab switching, and the
field stays composited over the desktop after minimising. That last detail is
what identifies it: an already-drawn window keeps being composited by the
WindowServer once the process stops answering. The main thread is stuck.

4c8c93b512 is the only commit that ever changed the second-queued-field path.
It stopped unmapping the value field between two queued dimensions, and its
whole justification is mutter: focus-stealing prevention refuses keyboard focus
to a window that was just re-mapped, which left the second field visible but
dead on GNOME (snaporca-p8uw). It was applied with no platform guard, so macOS
re-activates an already-visible borderless NSWindow at NSModalPanelWindowLevel
from inside wxOSX's pending-event drain — which on macOS runs from a
CFRunLoopObserver at kCFRunLoopBeforeTimers (evtloop_cf.cpp) over an unbounded
`while (!m_handlersWithPendingEvents.IsEmpty())` (appbase.cpp).

One constexpr now carries that choice, and both halves of the contract read it,
because the failure mode of this fix is the two halves drifting apart: open()'s
reuse-if-shown and do_commit()'s deferred hide are one decision, not two.

Not a macOS guess I could not check: the non-GTK branch was exercised on the
Linux rig by forcing the constant to false and rebuilding. A corner rectangle
drew, its first field committed at 60 mm, the SECOND field opened, committed at
40 mm, and the sketch ended at 60.0 x 40.0 mm with the field closed and no
freeze — so the map-afresh path is functionally complete, not merely different.
On GTK the constant is true and every generated instruction is unchanged.

What is still unproven is the exact line where macOS wedges; the reporter has
been asked for a `sample` of the hung process. This fixes the cause the evidence
points at without waiting for that, and cannot regress the GTK behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:37:01 +02:00
Tommaso Bianchi 52d16e4218 Merge remote-tracking branch 'prfork/cad-mainline' into cad-mainline 2026-08-20 17:45:15 +02:00
Tommaso Bianchi 2b02a8e3dd Design tab: move the CAD sources into their own folder
Review request on PR #15238: "Place CAD-related files (e.g. CadDocument/
GeometryEngine) into a separate folder."

  src/libslic3r/CAD/     the kernel — CadDocument, GeometryEngine, the four
                         Sketch* units, SketchSolver, ThreadStandards
  src/slic3r/GUI/CAD/    the tab — DesignPanel, DesignCanvas, DesignSketchTool,
                         SketchInlineEditor, McpControl, generated DesignOffer

Pure relocation: no line of logic changes. Two include rewrites follow from it —
files that moved re-spell their own neighbours against src/ (already on the
include path), and files that did not move pick up the new folder. docs and
docs/ux/mockups/gen_offer_table.py follow the same paths.

Verified: libslic3r, libslic3r_gui and libslic3r_tests all build, CAD suite green
at 2518 assertions in 194 test cases, and the sibling fork builds identically —
17 shared sources still byte-identical, 8 diverging by their expected counts.
2026-08-20 17:44:34 +02:00
Tommaso Bianchi 5d120921d5 deps: build libslvs as a dependency instead of vendoring it in src
Review request on PR #15238: "Move the SLVS to deps if the source code remains
unmodified. If any changes were made to the source code, move it to deps_src."

It is unmodified — all 20 files under src/libslic3r/slvs were byte-identical to
JacobStoren/SolveSpaceLib@4d87045, the extraction of solvespace.com's libslvs.
So deps/ it is, fetched by hash like every other dependency.

Only the CMakeLists is ours: upstream's builds a demo executable and installs
nothing, so deps/SLVS/CMakeLists.txt.in replaces it via PATCH_COMMAND — the same
shape deps/OpenCSG already uses. The public header keeps its spelling, so
SketchSolver.cpp still says `#include <slvs.h>` and needs no edit.

The CI deps cache is keyed on hashFiles('deps/**'), so it rebuilds itself.

Verified: dep_SLVS builds and installs, libslic3r links against SLVS::slvs, and
the CAD suite is unchanged at 2518 assertions in 194 test cases.
2026-08-20 17:44:19 +02:00
SoftFever 6a0524ea85 Merge branch 'main' into pr/tommasobbianchi/15238 2026-08-19 19:15:38 +08:00
SoftFever 7d8c024da5 Merge branch 'main' into cad-mainline 2026-08-18 15:33:31 +08:00
Tommaso Bianchi 6fc3c99e31 Mate connectors: draw the dashed pair line between the two origins
The last unbuilt element of snaporca-wgsc. Two connectors a mate binds are one
object with a gap still in it; drawn as two separate frames they read as
unrelated, and "which two of these five frames are the mate?" had no answer on
screen at all.

Dashed, grey, drawn IN WORLD along the segment joining the origins -- so it
foreshortens with the model and its length is the gap the mate has left to
close. A Fastened mate therefore draws nothing, which is correct: the gap is
zero. Screen-constant dash pitch (6 px dash, 4 px gap) like every other gizmo
here, with the pitch opening up beyond 400 dashes so a mate across a large
assembly cannot emit thousands of segments. Depth test off: the line's job is
to say "these two belong together", and it has to say it even when a part sits
between the camera and one end.

Fed from BOTH sources of polarity truth, the same two the role colours already
use: every committed Mate feature (connectors named by feature index), and the
live pick of an open Mate card (named by combo ordinal). Only resolved frames
are eligible, so an unresolved end draws no line rather than a line to the
origin of the world.

RIG-VERIFIED on :10 with the fresh binary (/OrcaSlicer/build/src/Release,
2026-08-17 12:43): two imported bodies, a face-and-direction connector on each,
Planar mate offset 40. Sampling the segment between the two origins gives a
regular dash/gap alternation of 13:10 sample units -- the 6:4 px pitch -- in the
stroke grey (107,117,133), over both solids. The grey end keeps its open collar
head and the blue end its filled one, so polarity and pair now read together.

Refs snaporca-wgsc
2026-08-17 14:48:05 +02:00
Tommaso BianchiandClaude Opus 5 b4d6abc57a Design: draw the mate connector as a bear face, with the disc kept behind a preference
Tommaso's decision (snaporca-x0kd): face orientation is hardwired perception -- a toddler reads
a face's roll and verse with no instruction -- so the connector is a face by default and the
conventional disc + roll quadrant stays, selectable, for users who expect it.

  Preferences > Control > Camera > "Draw mate connectors as a face", default ON, key
  design_connector_face_glyph. Read every frame rather than latched, so toggling takes effect on
  the next repaint -- a look you cannot A/B without restarting will not get compared. Verified on
  the rig: unchecking it switches the viewport to the disc live, no restart.

WHY A RELIEF AND NOT A DRAWING. A flat face in the connector's plane foreshortens by
sin(elevation) and collapses at a grazing view exactly like the quadrant it replaces -- measured,
the quadrant falls 89 -> 20 -> 3 -> 0 lit pixels from 47 degrees to edge-on. The relief does not:
its silhouette carries the information. So the glyph is a small shaded solid, painter-sorted,
lambert-shaded against a light fixed in CAMERA space so orbiting does not swing the shading.

THE MUZZLE, AND THE MISTAKE THAT NEARLY LOST IT. It is the only feature standing along +Z, so it
says which way the connector points and it is all that survives edge-on. Two errors on the way:

  1. I built its footprint from height*tan(draft) and got a needle. The real base OVERHANGS the
     crest at both ends (0.062 nose, 0.034 tail) and that overhang is what makes it a wedge. Base
     now lifted straight off the mesh.

  2. Worse, I chased fidelity. Scaled honestly the ridge is 11.3 mm on an 83.3 mm face -- 13.6 %
     of the width -- and at 22-48 px that is a scratch. Tommaso looked at it and could not find
     the muzzle at all, which is the only test that counts. A glyph is a symbol, not a scale
     model, so it now gets two deliberate exaggerations, and COLOUR does most of the work:
     muzzle share of lit pixels at 90/16/6 deg -- body tone 14.8/11.3/17.5 %, accent gold
     18.3/19.2/23.9 %, accent gold at 1.8x width 23.5/25.2/31.2 %.

  The accent is the same gold the disc spends on its roll quadrant, so it stays this tab's "here
  is the direction that matters" colour. Polarity is still on the Z arrow's head; nothing collides.

A connector whose ROLL COULD NOT BE DERIVED keeps the disc treatment whatever the preference says.
A face asserts a definite orientation, and asserting one for a roll that was never derived is the
same confident lie that got billboarding rejected.

Geometry is emitted from the part by docs/design/mate-connectors/emit_glyph_table.py, not
hand-drawn, so glyph and printed connector cannot drift: 12-vertex outline, two eyes, chin bar,
cheek dot, and the snout wedge. Crest 29.0 mm / 6.58 mm drop / 13.1 deg against the review's
28.3 / 6.61 / 13.1 on the B-rep.

Also fixes extract_outline.py, which walked w.Edges: OCC returns them in storage order, not ring
order, ignoring per-edge orientation, so the outline was scrambled -- 45 points and perimeter
6.380 where a clean ring gives 31 and 3.335. Every measurement in the design notes was re-run.
The correction reversed one earlier finding: handedness does NOT read on its own (5.4/8.0/9.1 %
different from its mirror, not the 32-35 % the scrambled ring produced), so the cheek dot is
required rather than merely nice.

RIG-VERIFIED on Xvfb :12 against a 60x40x10 box with a face+edge connector: the face renders with
both eyes, ears, chin bar, cheek dot and a gold muzzle standing proud; the Z arrow degenerates to
its ring when viewed down the axis; and the preference switches to the disc live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:26:50 +02:00
Tommaso BianchiandClaude Opus 5 555af98474 Mate connectors: bring the design record and the BearConnector pair into the repo
The connector work has lived outside the code since 2026-08-05, in a workspace repo with no
remote. It is the basis of a decision that now shapes the Design tab, so it belongs here.

  docs/design/mate-connectors/

  DESIGN_MATE_CONNECTORS.md   seven CAD systems surveyed; the frame-pair model this kernel
                              already matches; sections 8b/8c on the glyph, and section 9's
                              four open decisions (D1-D4) still awaiting Tommaso.
  bear.step                   the male, Onshape 2026-08-05T08:27Z, md5 faf228326ee3f971
  BearConnector_Female*.step/.stl, BearConnector_Cutter.step
                              built by make_female.py FROM the real male B-rep rather than
                              re-modelled, so the pocket is complementary by construction
                              including every deliberate asymmetry. Fit measured at exactly
                              0.2000 mm, zero interference, mated hosts proven coplanar.
  BEAR_CONNECTOR_REVIEW.md    the symmetry-group result: identity 81/81 edges, mirror-x 0/81,
                              mirror-y 0/81, rot180Z 0/81, rot90Z 0/81, diagonal 0/81 at
                              0.1 mm. Trivial group, so every PARTIAL view fixes orientation.
  extract_outline.py, simplify_study.py, relief_sheet.py, handedness.py, make_female.py,
  trim_female.py, fit_check.py, verify_trimmed.py, coplanar_test.py + their sheets

THE DECISION THIS SUPPORTS (snaporca-x0kd): the mate connector is drawn as a simplified BEAR
FACE by default, with the standard disc + roll quadrant + Z arrow kept behind a preference.
Face orientation is hardwired perception -- a toddler reads a face's roll and verse with no
instruction -- and no abstract glyph earns that. Measured against the alternative: the disc's
gold quadrant+tick falls 89 -> 66 -> 37 -> 20 -> 3 -> 0 lit pixels as the camera drops from
47 deg to edge-on, and is a shapeless blob by 16 deg.

WHAT THE SIMPLIFICATION STUDY SETTLED (snaporca-wi3z), all measured off the real B-rep:

  The eyes are load-bearing. Same outline and muzzle with the eyes removed stops reading as
  a face at every size. Whatever else goes, they stay.

  45 -> 22 outline vertices with no loss of read at 22 / 32 / 48 px; the muzzle reduces to
  one filled triangle. Three marks plus a cheek dot.

  Drawn FLAT the face fails exactly where the disc fails: in the connector's plane everything
  foreshortens by sin(elevation). Rendered as its real relief instead, lit pixels at 32 px go
  164 -> 210 at 16 deg and 69 -> 120 at 6 deg, and the snout ridge stands proud as a profile
  rather than smearing. The glyph must be a shaded relief, not an outline.

  Handedness already reads without any added mark -- 32 to 35 % of lit pixels differ from the
  mirror, and re-registering by best whole-pixel translation returns offset (0,0), so it is
  real shape asymmetry. But it reads only BY COMPARISON. A dot on one cheek makes it local:
  34.5 / 37.0 / 36.4 %, and unlike uneven eyes (42 %) it does not read as a defect.

Tommaso's calls: it stays a bear, and handedness must read.

The scripts were repointed at the co-located male and extract_outline.py re-run from here to
prove it -- same 45 outline points, same three inner wires, same 3829.5 mm2 back plate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:05:09 +02:00
Tommaso BianchiandClaude Opus 5 799f840218 Thicken Surface: fill the corners of a closed-loop wall
snaporca-wm4s. Thickening the 4-walled open box (60x60 in plan, 40 tall, no caps)
by 5 produced volume 29648.15 where the geometry requires (60^2-50^2)*40 = 44000
— about 67% of it. The corner material at the four vertical edges was simply
absent.

CAUSE. MakeThickSolidBySimple offsets each face along its own normal and sews;
it never extends neighbours to meet, so wherever two faces join at an angle the
corner is empty. A flat sheet has no such join and was always exact (18000.000),
which is why the defect looked like a measurement artefact.

WHY THE TWO EARLIER ATTEMPTS COULD NOT HAVE WORKED. Both switched to ByJoin —
plain, then with Intersection/GeomAbs_Intersection — and both returned a shell,
not a solid, so the body lost its volume entirely and both were reverted. That is
not a parameter problem: in OCCT, BRepOffset_MakeOffset::MakeThickSolid builds a
solid only inside `if (!myFaces.IsEmpty())` (BRepOffset_MakeOffset.cxx:1115).
Handed an open sheet with no closing faces, it stops after the offset shell and
returns it, reporting IsDone() with a non-null shape containing no TopAbs_SOLID.
ByJoin hollows a CLOSED solid by removing faces; an open sheet is outside its
contract.

FIX. Close the sheet, then use the call that mitres: cap the free rims
(ShapeAnalysis_FreeBounds -> MakeFace), sew shell+caps into a closed shell, make
a solid, and hollow it inward passing the caps as the faces to remove — the caps
come back off and leave the wall. Two details, each found by measurement rather
than reasoning:

* A shell sewn from an extruded sheet carries no guarantee of outward
  orientation, and MakeSolid does not fix it. Inside-out, the inward offset goes
  OUTWARD: measured bbox 70x70x40 and volume 339141.59, larger than its own
  bounding box because the result overlaps itself. A negative GProp mass is
  exactly that inversion, so it is the test; Reverse() on it.
* A SINGLE face has no neighbour to mitre and must keep the BySimple path. It
  does have a free boundary, so "has free wires" is the wrong question — capping
  a lone face with its own rim sews a zero-thickness shell and measures 6000
  against 18000.

Also: IsDone() is not a success test here, since both failed attempts had it
true. The code now explores for TopAbs_SOLID and refuses a shell.

Tests: new case asserts 44000 with the wall's bbox at 60x60x40 (catching the
inverted-orientation shape, which has the right volume nowhere near the right
place), plus the flat-sheet control at 18000 that must not regress. Full kernel
suite green: 2502 assertions in 187 test cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:13:32 +02:00
Tommaso BianchiandClaude Opus 5 98135cf529 Don't block cloud sign-in because the setup wizard is unfinished
Follow-up to the previous commit, found by driving the app: clicking Login /
Register on a fresh install pops "You are currently in Stealth Mode. To log
into the Cloud, you need to disable Stealth Mode first." — to a user who has
never touched the toggle, whose config says stealth_mode: false.

It is the same pre-wizard latch one step earlier. handle_web_request() gates the
login commands on get_stealth_mode(), which reports stealth while
firstguide/finish is unset, so the app blocks sign-in because the wizard is
unfinished — backwards, since signing in is how a user leaves that state.

Worse, the escape it offers does not work. "Quit Stealth Mode" writes
stealth_mode = false, which was ALREADY false, and never touches the latch: the
config is byte-identical afterwards and get_stealth_mode() still returns true.
The user clicks the button, believes stealth is off, signs in, and finds every
cloud feature still dead. That is the state the reporter of #15239 described.

So the login guard now reads the user's OWN setting via the new
get_stealth_mode_setting(), not the pre-wizard default. A user who deliberately
enabled Stealth mode still gets the dialog and the working Quit button; a user
who merely closed the wizard goes straight to the login page.

Measured on Xvfb with a fresh datadir (firstguide absent, stealth_mode false):
before, clicking Login produced a "Stealth Mode" window; after, it opens the
"Login" window directly. And with the previous commit's latch release, a real
Orca Cloud sign-in on that same unfinished-wizard profile now runs the whole
post-login flow — the sync prompt fires (sync_user_preset lands in the config),
the per-user preset folder is created, and Sync Presets syncs with no refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:18:34 +02:00
Tommaso BianchiandClaude Opus 5 7d313159df Fix cloud features staying off after login when the setup wizard was closed
Reported on #15239: after signing in to Orca Cloud on a fresh install, no sync
prompt appears and File > Sync Presets is greyed out with nothing to explain why.

CAUSE. AppConfig::get_stealth_mode() returns true whenever `firstguide/finish` is
unset, and that flag is written in exactly one place — GuideFrame::SaveProfile(),
i.e. only when the setup wizard is COMPLETED. Closing the wizard is what people do
today to reach the login (the wizard never offers it, which is #15239 itself), so a
new user ends up permanently in a stealth mode they never chose. Every cloud gate
keyed on get_stealth_mode() then switches off silently, including:

  * GUI_App::on_user_login_handle(), which returns EARLY on stealth — so the whole
    post-login flow is skipped: preset migration, plugin fetch, user-preset load and
    show_sync_dialog(). That is the missing sync prompt.
  * the Sync Presets item in both the top menu and the File menu, whose enable
    lambda was `is_user_login() && !get_stealth_mode()`. That is the greyed item.

The result is indistinguishable from real Stealth mode, and nothing in the UI says
so, because the one place that DOES explain it — the "Quit Stealth Mode" dialog in
handle_web_request() — only covers the homepage login commands.

FIX, two parts.

1. The pre-wizard value is a DEFAULT for "the user has not been asked yet", not a
   setting, so it must not survive the user answering. Signing in to a cloud account
   is that answer. AppConfig now carries a session-only `m_cloud_logged_in` mirrored
   from the network agent (on login, on logout, and at agent start so a restored
   session counts), and get_stealth_mode() consults it before falling back to the
   pre-wizard default. An explicit Stealth mode setting is untouched and still wins:
   a user who turned it on deliberately stays offline whether or not they sign in.

2. Sync Presets no longer greys itself out. Both refusal paths already had a message
   to show — "You must be logged in…" and now one for Stealth mode naming the
   Preferences toggle — and the enable lambda was making both unreachable. A disabled
   item that cannot say why is the reason this took a bug report to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:14:35 +02:00
Tommaso BianchiandClaude Opus 5 8e5d0d195c Design: double-click a feature row to edit it, and preview Transform live
snaporca-x1k7 filed three Transform defects. Two were real and are fixed here;
the third does not reproduce and is withdrawn with its measurement.

(1) The feature tree had no ITEM_ACTIVATED binding at all, so double-clicking any
row only highlighted it. Double-click is the documented edit gesture elsewhere
(a committed sketch opens that way on the canvas), which made every feature look
dead until the user found the Edit button in the section header. Bound to
on_edit_feature(), so the gesture now works for every feature type, not just
Transform.

(3) The Transform card's typed fields called refresh_preview(), but preview_fields
returns {} for Transform and the solid-preview path has no ghost to build for a
feature that moves an existing body — so typing a distance changed nothing on
screen until Confirm. The fields now drive the same channel the gizmo drag already
uses: the body's display transform. In EDIT mode the committed transform is
already baked into the kernel geometry, so the preview undoes it first; without
that term, re-opening a committed Z=20 and typing 40 would show the body at 60.

(2) NOT REPRODUCED. Dragging a rotation ring does fill the field: a tangential
drag on the red ring gave Rotate axis = X, Angle = 27.44 deg, plus the translation
that rotating about the card's pivot implies (Y 17.60, Z -62.11). The original
reading came from a drag that never grabbed the 7 px ring; this run took its
candidate points from the rendered ring pixels themselves and 6 of 6 answered.

Rig-measured (docker snaporca-gui, Xvfb :10, llvmpipe), vertical screen shift of
the body by image correlation:
  commit Translate Z 0 -> 20        : +140 px
  double-click the Transform row     : +0 px, and the card re-opens showing 20.00
  step the re-opened card 20 -> 40   : +142 px  (not +280 -> the undo term is right)
  Cancel                             : +0 px vs the committed frame, residual 0.46
Add mode: stepping Z moves the body immediately (viewport diff bbox
200,143-1181,999); Cancel puts it back with only the status strip differing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:16:08 +02:00
Tommaso BianchiandClaude Opus 5 f4a0bf8845 CAD: give Rib a shortcut, completing the pick-the-line work (snaporca-3648)
Ported from snaporca ea0e11e49d. See that commit for the acceptance measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:43:54 +02:00
Tommaso BianchiandClaude Opus 5 dcbda7d42c CAD: deliver the picked sketch ENTITY to the panel, and point Rib at it (snaporca-3648)
Ported from snaporca 94b6b564de. See that commit for what is and is not measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:25:21 +02:00
Tommaso BianchiandClaude Opus 5 315a35e2ea CAD: Sweep path and Loft profiles fill from a viewport sketch pick (snaporca-ysm2, e1p item 6)
Ported from snaporca 314d30c660. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:16:14 +02:00
Tommaso BianchiandClaude Opus 5 7920e55413 CAD: give the keyboard back when the action bar hides (snaporca-ehrm)
Ported from snaporca ace5778d4c. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:05:57 +02:00
Tommaso BianchiandClaude Opus 5 31548a230d CAD: Mirror takes its body from the viewport (snaporca-gtd3, e1p item 6)
Ported from snaporca 2516961a32. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:36:39 +02:00
Tommaso BianchiandClaude Opus 5 2643c778dc CAD: Boolean takes its two operands from the viewport (snaporca-310o, e1p item 4)
Ported from snaporca 572eb56d0e. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:12:38 +02:00
Tommaso BianchiandClaude Opus 5 dd6363f536 CAD: clicking a reference plane's label selects THAT plane (snaporca-uw3c)
Ported from snaporca 373ef325d8. See that commit for the full rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:50:27 +02:00
Tommaso BianchiandClaude Opus 5 920f0bd126 CAD: keep the pick trace, stop paying for it when it is off (snaporca-txp8)
Ported from snaporca 3710d34568. See that commit for the full rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:30:27 +02:00
Tommaso BianchiandClaude Opus 5 457610108e CAD tests: pin the sheet-body mass properties with the rig's own numbers (snaporca-lu27)
Ported from snaporca 35befd965c. See that commit for the full rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:10:40 +02:00
Tommaso BianchiandClaude Opus 5 4c8c93b512 Design: keep the inline dimension frame mapped across queued fields (snaporca-p8uw)
Ported from snaporca e4e0e21581. See that commit for the full analysis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 20:49:37 +02:00
Tommaso Bianchi d9abadc88f Revert "CAD: type a sketch dimension without clicking the field first"
This reverts commit ab15f386e4.
2026-08-14 17:55:39 +02:00
Tommaso BianchiandClaude Opus 5 ab15f386e4 CAD: type a sketch dimension without clicking the field first
On a Wayland session the in-canvas value field never took the keyboard focus, so
after drawing a rectangle the first keystrokes went nowhere and the field had to
be clicked before a number could be typed. open() already did Show, Raise,
SetFocus on both frame and control, SelectAll, and re-asserted all of it in a
CallAfter — none of it works here, and no amount of re-asserting would: under
Wayland a client cannot focus itself, and mutter ignores gtk_window_present()
without an activation token as focus-stealing prevention. The earlier fix
recorded in this file (dropping wxFRAME_FLOAT_ON_PARENT, whose GTK _UTILITY_
hint made an xrdp session refuse focus) addressed a different compositor.

Stop needing WM focus. The canvas keeps the focus and feeds the field:
SketchInlineEditor::feed_key() types into the control directly — Enter commits,
Esc cancels, Backspace/Delete edit, digits and '-' '.' ',' are accepted, and
anything else is handed back so a stray letter cannot vanish into a numeric
field. A m_fresh flag reproduces the SelectAll semantics the field already had,
so the first digit replaces the prefill. It returns false when the control
genuinely holds the focus, so X11 keeps wx's normal routing and no character is
typed twice.

The CHAR_HOOK gates on the editor's own is_open(), NOT on inline_busy().
inline_busy is a freeze flag for the sketch tool: cleared on commit, re-set only
when the next queued field opens, with a CallAfter between them. Gating on it
left a window where the field was on screen and the flag was false — typing
worked for a rectangle's Width and not its Height.

VERIFIED at the machine on behemoth: typing the first dimension directly, with
no click, works. NOT yet confirmed: the Width -> Height handover; the is_open()
gate is diagnosed from the handover code, not observed. The hook's
SNAPORCA_KEYTRACE=1 switch logs each key with the focused widget if it needs
chasing further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:53:41 +02:00
Tommaso BianchiandClaude Opus 5 614824ce89 CAD: right-click in a sketch offers verbs for what is selected, instead of for nothing
Select a line in a sketch, right-click, and every sketch verb was greyed: Trim,
Extend, Fillet, Chamfer, Offset, Mirror, the arrays, Constrain. The menu was
right and the selection was gone — two independent faults, each of which hid
the other.

FIRST, the Select-mode RightDown branch called clear_selection() before handing
the click back. Handing it back is correct: the m_right_consumed flag means "the
tool USED this right-click", and a plain right-click is not a gesture
terminator, so the offer should open. Clearing first is not: the offer describes
WHAT IS SELECTED, so wiping the selection guaranteed it could only ever describe
nothing. Deselection keeps its own gesture — left-click on empty space, a few
lines above in the same handler.

SECOND, offer_selection_kind() returned SkNone for every sketch state. The offer
table has always carried verbs for a selected line, arc, point or pair, but
nothing ever RETURNED those kinds, so fourteen rows were gated on selection bits
no code path could set. Classify the selection instead: SkLine / SkArc / SkPoint
/ Sk2Ent, via a first_selected_type() accessor on the tool and two forwarders on
the canvas.

Either fix alone measures as a failure — the classification is handed an empty
selection, or the preserved selection has no kind to match — which is why both
land together.

This is the second half of the report behind 3eb6e5d608: a user comparing the
Design tab with Onshape said "adding constraints seems to be missing"
(OrcaSlicer PR #15238). Constrain was one of the fourteen dead rows, and the
gesture that would have shown it threw the selection away first.

Verified at the machine on behemoth by Tommaso: select a line of a rectangle,
right-click, and the sketch verbs are live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:37:37 +02:00
Tommaso BianchiandClaude Opus 5 eb52972a8e CAD: the offer menu speaks one language, not two
On a non-English desktop the offer menu came out mixed: "Create / Add material /
Rimuovi / Fillet / chamfer / draft / Repeat / Transform / Reference / Modify",
and under Modify, "Elimina" beside "Constrain sketch".

Nothing was mistranslated. The row names went through a bare wxGetTranslation(),
which searches EVERY loaded catalogue — including wxWidgets' own wxstd. That
catalogue is loaded in the desktop's language whether or not the application has
one, and it happens to contain exactly two of our eight row names:

    wxstd it: 'Remove' -> 'Rimuovi', 'Delete' -> 'Elimina'

Create, Add material, Repeat, Transform, Reference and Modify are not wx
vocabulary, so they stayed English. Two words in one language, six in another,
in the same menu — and the same trap is set for every other locale wx ships:
Supprimer, Löschen, Eliminar.

Name the domain: wxGetTranslation(s, SLIC3R_APP_KEY). These strings are now
translated by our own catalogue or not at all, which is consistent either way.

Left deliberately alone: the accelerator still renders as "Canc" rather than
"Del" on an Italian system. That is wx naming the physical key, and on an
Italian keyboard the key really is marked Canc — telling that user to press
"Del" would name a key they do not have.

Verified on the rig with LANG=it_IT: the menu now reads Remove and Delete, and
the submenu shows "Delete    Canc" beside "Constrain sketch".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:10:48 +02:00
Tommaso BianchiandClaude Opus 5 3eb6e5d608 CAD: constraints are reachable from the offer menu, not only from a toolbar icon
A user evaluating the Design tab against Onshape reported that "adding
constraints seems to be missing" — with nineteen constraint types and a solver
shipped behind it (OrcaSlicer PR #15238, exussum12).

They were not wrong about what they could see. The only ways in were an
icon-only toolbar button whose tooltip you have to hover to read, and an offer
row gated on sketch_mode with a sketch ENTITY selected, filed under "Reference".
Right after finishing a sketch — the moment you want to constrain it — neither
was in front of the user, so a shipped headline feature read as absent.

Add a model-mode row: "Constrain sketch", offered under Modify when a sketch
region is selected, routed through the new btn:constrain verb action.

on_begin_constrain() also gains a fallback to m_sel_sketch_feat. The offer
reaches it from a SkLoop selection, which carries no TREE selection, and the
function read only tree_selection() — so the new row would have answered
"Select a sketch in the tree first" about a sketch the user had visibly
selected. It now adopts the region's owning sketch and syncs the tree to match.

Verified on the rig: draw a rectangle, finish the sketch, click the region,
right-click -> Modify -> "Constrain sketch" enters Constrain mode with
"Pick 1-2 lines, then a constraint" and the Constraints (8) card listing the
sketch's inferred constraints. That path did not exist before.

Does NOT address the other half of the report: there is still no Pierce
constraint, so a sweep profile cannot be tied to its path. Tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:00:30 +02:00
Tommaso BianchiandClaude Opus 5 9013f530fa CAD: a sheet body reports no volume, instead of a confident wrong one
mass_properties on an open shell returned volume 96000 with an inertia diagonal
of [-4.2e7, -4.2e7, -6.9e7] for a 60x60x40 four-walled box — negative principal
moments, which no real body can have. BRepGProp::VolumeProperties integrates the
divergence theorem over whatever faces exist; on an open shell that is not a
volume at all, and the old code hid the only obvious tell by taking std::abs()
of the mass. "valid: true" then asserted the number was trustworthy.

This matters because mass_properties is what an agent or a user reaches for to
confirm a cut removed the right material. Silent nonsense there means the check
passes on garbage.

MassProps gains is_solid. For a sheet we compute surface area only — that stays
exact — and report volume 0 with the inertia left zeroed. The MCP verb returns
is_solid plus a note saying volume and inertia are not defined for an open
shell; the GUI's Mass command says "sheet body — N cm² of surface, no volume"
rather than quoting material that is not there.

Verified on the rig: the sheet now returns volume 0.0, surface_area 9600.0
(exactly 4 x 60 x 40), is_solid false. The solid controls are unchanged and
exact — a 60 mm cube reports 216000.0 and 21600.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:35:03 +02:00
Tommaso BianchiandClaude Opus 5 1545fb7946 CAD: an armed Plane or Axis pick captures the face, instead of escalating to the body
Clicking a face that already happened to be selected, while a Plane or Axis pick
was armed, read as a repeat pick: the click escalated to "whole body", the
capture was lost, and the card's label stayed "(none)" with nothing on screen to
explain it. On a cube it is easy to hit — the face under the cursor is often the
one already selected from the previous step.

The capture path in on_solid_picked already restores the flag for all three
tools, and reset_plane_refs()/reset_axis_refs() restore it when a pick is
abandoned — both were written as if the arm side disabled escalation. Only
CoordSys actually did (that was snaporca-u0wd). Plane and Axis never had it.

Verified on the rig: Midplane on a 60 mm cube now captures Face A (#5, top) and
Face B (#3, side) on the FIRST click each, and the resulting plane renders as
the 45-degree bisector between them, which is what a midplane of two
perpendicular faces should be. Before this, the first pick escalated to the body
and Face A stayed "(none)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:45:25 +02:00
Tommaso BianchiandClaude Opus 5 f6f2edb906 CAD: the Plane card refuses a method it cannot build, instead of quietly building another one
Every method in CadDocument's plane dispatch falls back to offset_angle_plane()
when its references are missing. Picking Tangent and confirming with nothing
selected therefore produced an OFFSET plane, announced as "Plane added — pick it
as a sketch plane". The user asked for one construction and silently received a
different one, with nothing on screen to reveal the substitution.

Validate at the GUI boundary instead: Angle needs an edge, Midplane two faces
(and not the same face twice — that yields a plane coincident with the face,
which is well-defined and useless), Tangent a face, Two-edges two edges. Offset
and Coincident are unchanged: both are meaningful with no reference, since they
fall back to the base plane by design.

on_add_plane() now returns false when it refuses, and confirm_tool() skips
close_tool() in that case — a refusal that also threw away the picks the user
had already made would be worse than the bug.

The kernel keeps fallback_offset(): it must return something. It should just
never be reachable from a user gesture without a warning.

Verified on the Xvfb rig: Tangent with no pick refuses and creates no feature
(it created one before), the card stays open with the type preserved, Midplane
with no faces refuses with its own message, and Offset with no picks still
creates a plane as it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:18:13 +02:00
Tommaso Bianchi 9a5e9dfc36 CAD: the wheel scrolls the card panel, it does not edit the field under it
wxSpinCtrlDouble takes the mouse wheel whenever the pointer is over it. A card
taller than the panel therefore could not be scrolled past without silently
incrementing whatever field the cursor happened to be over — measured on the
rig: eight notches over the Coord Sys card turned an X hint from 1,00 into 6,00
while the panel did not move at all. The same gesture over an Extrude distance,
a Hole diameter or a mate Offset is a silent model change made by someone who
believed they were navigating, and nothing on screen reports it.

Every spin in this panel comes from one factory, so the guard goes there: an
unfocused spin hands the wheel to its parent, and the scrolled cards panel gets
it. A spin the user has deliberately focused still takes the wheel, which is the
one case where editing is what was meant.

Verified on the rig with an Extrude card: eight notches over an unfocused
Distance leave it at 10,00; clicking into it first and scrolling takes it to
13,00.
2026-08-14 10:33:28 +02:00
Tommaso Bianchi 03fb81020e CAD: picking a face for a Coord Sys must also make it a face-based frame
A mate between two connectors committed cleanly, recomputed without error, and
moved nothing. apply_mate was reached and computed a translation of exactly
(0, 0, 0).

The connectors were the cause, not the mate. Picking a face stores
coordsys_face and coordsys_body but left the Type combo on its default,
Point (world) — and datum_frame ignores the pick entirely for that type,
resolving the connector to coordsys_point, which is (0,0,0) unless the user
typed otherwise. Two connectors built that way share one frame, so the mate
between them is an identity transform: everything reports success and the
assembly never moves.

Capturing a face or an edge now switches the type to FaceAndDirection. Picking
a face IS the choice of a face-based frame; asking for it twice, with no hint
that the second half is required, is what made every mate a silent no-op.

Verified on the rig, two bodies with a face-based connector each:
  before  Extrude4 [-71.6, 402.6, 156.5] .. [-8.8, 412.6, 237.3]
  after   Extrude4 [-31.4, -40.4, -10.0] .. [ 31.4,  40.4,  -0.0]
with the mate transform now (40.19, -196.88, 402.56) instead of (0, 0, 0).
That is the first mate in this tree that assembles anything.

Not the kernel: recompute applies mates exactly as preview does, proven by an
A/B harness over all five kinds — the two paths give identical bounding boxes.
2026-08-14 10:01:28 +02:00
Tommaso Bianchi c45f84edf4 CAD: three Design-tab fixes — consumed holed loop, constraint rows, hover ghost
1. A consumed loop WITH HOLES was never dropped from the sketch overlay.
   sync_sketch_display compares each region's entity list against what a per-loop
   extrude stored, but rebuilt the candidate from the region's OWN entities while
   the extrude stores the region's entities PLUS every hole's (see
   selected_loop_entities). For a plate with one bore that is 4 against 5, so the
   match never fired and the extruded rectangle stayed drawn on top of the solid
   it had become. Adds region_entity_indices_with_holes, which returns the same
   order the extrude uses, and compares against that; a matching region now drops
   its holes' entities too. Hole-less regions are unaffected.

   This is also the artefact that made a correct plate-with-a-bore read as a
   plate with a plug in it during rig testing.

2. The Constraints card drew its header and its first row on top of each other.
   The rows and the delete buttons were parented to m_form rather than m_cards,
   so they were laid out in the wrong window's coordinate space and started at
   the card's top edge. Re-parented; nothing else about the card changed.

3. The mate hover ghost never appeared. The highlight handler asked for a repaint
   with request_repaint(), which only queues a Refresh — and a wxMenu popup runs
   its own modal loop, so the paint was not serviced until the menu closed, by
   which time the ghost had been dropped. Adds DesignCanvas::repaint_now(), which
   flushes the paint immediately, mirroring the m_status->Update() the status line
   in the same function already needed for the same reason.

Delegated to opencode (DeepSeek V4 Pro) and reviewed by diff. Fix 1 needed an
accessor on DesignSketchTool because region_loops/RegionLoop are private — that
was outside the file list it was given, and it said so rather than working around
it.

Verified on the rig: a rectangle-plus-circle sketch extrudes to a plate with a
bore and NO overlay left on top of it, and the Constraints card shows its header
clear of eight readable rows.
2026-08-14 09:15:51 +02:00
Tommaso Bianchi 9340d4c7dc CAD: the DoF readout comes back in Constrain mode
Leaving Sketch clears the "N degrees of freedom" line, which is right — it
describes a sketch's constraint state and means nothing in Feature mode. But
entering CONSTRAIN left it blank too, and Constrain is where the number is the
whole point: the readout is fed only by a live solve, and no solve fires merely
because the mode changed, so the line stayed empty until the user happened to
change something.

The solve callback now caches its last result and the labelling is split into
apply_dof_status(), which set_ui_mode re-applies on entry to Constrain.

Verified on the rig: a rectangle reports "4 degrees of freedom" in Sketch, and
the same line is there after K enters Constrain.
2026-08-14 08:36:19 +02:00
Tommaso Bianchi 1631963ba1 CAD: pick tolerances scale with the face, so a narrow face is reachable
The edge and vertex tolerances were fixed at 8 and 11 px. On a face that is
barely wider than that on screen — a thin plate, or any part once you zoom out —
every point on it lies within the edge budget, so the pick alternated edge and
whole body and the FACE level could never be reached at all. That is not just
awkward: a face pick is what gives a Coord Sys its owning body, and a mate needs
one, so thin parts could not be assembled.

Both tolerances are now capped at a third of the face's shorter on-screen side,
measured from the edge samples the picker already walks. They only ever shrink,
so a face with room keeps the full budget and nothing changes for ordinary
geometry; a narrow one keeps its middle for itself.

Verified on the rig on a 16.7 mm-wide plate at three zoom levels including one
far enough out to make the face a thin sliver: the middle reports "face 5
selected" every time, while points near the rim still take the edge.
2026-08-14 08:32:19 +02:00
Tommaso Bianchi 76d7dd6946 CAD: an armed face/edge pick must not lose its click to the body escalation
A card that asks for a face ("Click a solid FACE in the viewport") could not be
satisfied. Clicking the same sub-element twice deliberately escalates to the
whole body — right for free picking, wrong here: the user clicks the very face
the card is pointing at, the escalation turns it into a whole-body pick, and the
armed capture rejects it and leaves "(none)". Both orders failed, so a
face-based Coord Sys was reachable only by accident of ordering. That mattered
beyond the card: a mate needs a connector with an owning body, and a face or
edge pick is the only thing that sets one.

Adds DesignSketchTool::set_escalate_on_repick, off while the Plane, Axis or
CoordSys card has a pick armed and back on as soon as it is captured or
abandoned. While armed, clicking a face means "this face", which is what the
prompt already says.

Verified on the rig: arm Pick Face, click the face, and it reads "#5" on the
first click. With nothing armed the escalation still alternates whole <-> face
as before.
2026-08-14 08:26:24 +02:00
Tommaso Bianchi d8103f794b CAD: a mate needs B to have a body, so stop offering one when it does not
The mate palette called all five kinds viable on connectors created as
Point(world), which belong to no body. Clicking one built the Mate feature and
the RECOMPUTE then failed with "mate: mate_cs_b has no associated body" — the
refusal arrived one step too late, after the feature was already in the tree,
and the user is told about it by an error line rather than by the palette that
offered the thing.

mate_options now checks the same two conditions the apply path throws on: B must
have a body (B is the connector whose body moves; A is the fixed reference and
needs none), and that body must still resolve. Either way all five kinds go
non-viable with a reason, so the offer and the kernel cannot disagree.

Verified on the rig: with two Point(world) connectors, every row is now dimmed
and reads "connector B is not attached to a body — a mate moves B's body". That
also exercises the dimmed-with-a-reason presentation for the first time, which
until now had nothing to show because every kind was always viable.

Tests: a new [mate] case covering no body, a body that no longer resolves, and
the revival once B is given one. One existing case needed its setup widened
rather than its assertion weakened: "an unrecorded fingerprint does not make a
type non-viable" built two body-less connectors, so it was asserting a side
effect of the old permissiveness instead of the property it is named for. Its
connectors now have a body, leaving the missing fingerprint as the only variable.
Full [CadDocument] suite: 2458 assertions in 185 cases.
2026-08-14 07:45:32 +02:00
Tommaso Bianchi 08ef43fad8 CAD: the mate palette fired nothing — two menu handlers were shadowing it
Right-clicking a document with two coordinate systems builds the mate palette
exactly as designed: a separator, a disabled "Mate: A -> B" header, then five
rows in fixed order. Hovering a row showed no ghost and left the previous status
message on screen; clicking one created nothing at all. The palette enumerated
perfectly and fired nothing, which put the whole M8 mate epic out of reach from
the UI.

The mate handlers were never invoked. show_offer_menu binds two pairs of
handlers to the SAME wxMenu: the mate pair (by mate_base, at base + 500) and the
generic verb pair. wxWidgets pushes dynamic entries to the FRONT of the handler
list, so the pair bound LAST runs FIRST for every id — and the generic pair was
last. A mate id lands outside the verb table's range, so both generic lambdas
returned early WITHOUT e.Skip(), which wx reads as "handled", and the mate
handlers behind them never saw the event.

Binds the two generic handlers to the verb id range [base, base + 499] so each
half only sees its own ids regardless of bind order.

Verified on the rig with a purpose-built assembly (two bodies 60 mm apart, a
Coord Sys on each): before, clicking Fastened produced no feature and no status
change; after, it produces a Mate feature. The verb rows keep working — the same
run created both coordinate systems through Reference > Coord Sys.

Note the mate then fails its recompute with "mate: mate_cs_b has no associated
body", because a Point(world) coordinate system belongs to no body while the
palette still advertises all five kinds as viable. That is a separate defect and
is filed; this commit is about the palette being reachable at all.
2026-08-14 07:34:33 +02:00
Tommaso Bianchi 7b953d564d CAD: a project whose model is a recipe is not an empty file
Reopening a saved CAD design ended on a modal "The file does not contain any
geometry data." warning. A CAD project legitimately carries no mesh — the model
lives in the feature tree (Metadata/SnapOrca_cad.bin) until Commit to Plate — so
the warning is false for one, and it is the last thing the user sees after
opening a design they spent an hour on. It reads as "your work is gone" at the
exact moment the recipe HAS just loaded and the Design tab is about to rehydrate
it, and the main window sits disabled behind the dialog until it is dismissed.

Counts a non-empty model.cad_recipe as geometry.

Verified on the rig: save without Commit to Plate, reopen, and the Design tab
comes up with Sketch1 -> Extrude2 -> Body 1 editable, with no dialog at all.
2026-08-14 00:15:46 +02:00
Tommaso Bianchi 5889f6640f CAD: extrude a sketch region with its holes, and stop crashing at startup
A rectangle with a circle inside it, drawn in ONE sketch, could not be extruded
to a plate with a bore from the GUI. Five defects were in the way. Each was
found by driving the app on a headless rig and measuring the result — the code
reads correctly at every one of these points, which is why they survived.

1. Wire orientation (kernel). SketchEngine::wires_to_face added every hole as
   wires[i].Reversed(), which is only right when the sketch happens to wind both
   loops the same way. A circle drawn clockwise inside a counter-clockwise
   rectangle came out matching the outer boundary, OCCT swept it as a SECOND
   contour, and the prism was the plate with its bore filled and the disc's
   volume counted twice. Measured: bbox 67.17 x 219.67 x 10 with volume
   152088 mm3 against a solid box of 147542 — a body larger than its own
   bounding box, which is the signature. Holes are now added as-is and
   ShapeFix_Face::FixOrientation() classifies them; that is winding-independent
   and is the idiom make_extrude_regions already used for imported glyphs, which
   is why holed TEXT always extruded correctly while a holed SKETCH never did.
   After the fix: 142996 mm3, implied bore radius 12.03 mm against the circle
   drawn.

2. The live-sketch click threw the picked region away. region_at() served only
   as a yes/no gate and on_face_selected() carried no argument, so Extrude fell
   back to whichever loop the resolver found first — clicking the material of a
   plate-with-a-hole extruded the disc. The region is now carried through, and
   DesignPanel also hands it to the tool with set_loop_pick(), AFTER open_tool()
   because that re-derives selection state, since extrude_uses_loop() reads
   selected_loop_entities() and that lives on the tool.

3. region_at() had no hole awareness and no innermost preference: it returned
   the first polygon containing the point. It now skips a region when the point
   lies inside one of that region's holes, and picks the smallest containing
   loop, so a click in the bore selects the disc and a click on the material
   selects the plate.

4. Startup segfault. DesignCanvas::request_repaint probed the GL backend via
   OpenGLManager::get_gl_info().get_renderer() before the canvas had initialised
   GL — glGetString with no context current and, before init_opengl(), no loaded
   function pointers. Anything that asked for a repaint while the panel was
   still being built landed there, with no window and nothing in the log. It now
   bails at the top on !is_initialized() and asks for a Refresh instead. Note
   the crash was in the PROBE, not in render(), which already guards itself.

5. A holed sketch on a plane whose normal points -Z came out as the full box PLUS
   a disc — 220274 mm3 where 163726 was due (192000 + 28274). wires_to_face took
   a SketchPlane parameter it never used and let OCCT infer a surface from the
   outer wire; when the inferred normal disagreed with the sketch's, the hole
   classification produced no hole. Every face is now built on the sketch's own
   gp_Pln.

Also in this change, from the same rig session:

- A right-click that only clears the sketch selection no longer reports itself
  as consumed, so it stops suppressing the offer menu. With any geometry in a
  live sketch there was no menu route left to add a second entity.
- Escape no longer discards a live sketch that holds drawn geometry; it says so
  and keeps the work (live_sketch_has_work()).
- The holed-region fill is an even-odd scanline instead of a keyhole bridge, so
  no corridor triangle leaks from the bore to the nearest corner.
- Cyan is reserved for the selection: an unselected region no longer wears a
  shade one step off the selected one.
- The origin planes follow the mode, so pressing Sketch on a document that
  already has a body offers them again instead of naming a plane you cannot see.

Tests: three [holes] cases over add_extrude_entities asserting the plate-with-bore
volume, solid and face counts on both a +Z and a -Z sketch plane, and the
by-name refusal of two disjoint regions. Full [CadDocument] suite green.
2026-08-13 23:43:36 +02:00
Tommaso Bianchi e27a44e6ef Merge remote-tracking branch 'prfork/cad-mainline' into cad-mainline 2026-08-13 17:37:10 +02:00
Tommaso Bianchi 57b42bc059 Offer the origin planes while choosing a sketch plane, not only before the first body
Delete a sketch on a document that still has a body and you could not start a new
one. Pressing Sketch said "click a face or a reference plane in the viewport" —
while update_reference_planes had already called clear_base_pick, because a body
existed. The instruction named something that was no longer there, and short of
finding a face to click there was no way back into sketching at all.

The planes are now offered when there is no solid yet OR while the UI is in
Sketch mode, and set_ui_mode refreshes them so they appear the moment you press
Sketch rather than at the next tree rebuild — which is not an event that pressing
Sketch causes.

Deliberately not always-on. m_dbp_active both RENDERS and picks, so leaving it
set would float three translucent planes over every finished model. Tying them to
the mode shows them exactly when they are the thing being chosen and takes them
away again on Finish.

Safe against stealing clicks: a base-plane pick is the last resort in on_mouse,
firing only on a click that hit no geometry, so solids and committed sketches
still win where they overlap.

Found on the rig by Tommaso: "if i remove a sketch, i cannot create sketches
anymore".
2026-08-13 17:36:24 +02:00
SoftFever 2179f5f670 fix build errors on Windows 2026-08-13 23:26:33 +08:00
Tommaso Bianchi 81876a6ce6 Sketch regions understand holes, so the plate with the hole can be selected and extruded
A rectangle with a circle inside it extruded to a plain box. Tommaso reported it
exactly right on the first attempt — "no intersection selectable, hence no plate
with hole" — and it was a causal chain, not a guess.

Two things were wrong and they compounded.

region_loops() returned N independent filled polygons with no notion of nesting,
so the only selectable things were the rectangle alone and the circle alone. The
region a user actually wants — the bounded area WITH its hole — did not exist to
be pointed at. Worse, the first polygon containing the click won, so clicking
inside the circle selected the rectangle.

And extrude_uses_loop() hands selected_loop_entities() to add_extrude_entities,
which copied only that one loop's entities into the feature. So the circle never
reached the kernel, build_sketch_face saw a single loop, and the multi-loop path
added in 5c4ced91e7 never ran. Proven from his saved project: Sketch1 held 5
entities, Extrude2 held 4, and the committed mesh was 8 vertices — a box of
260.40 x 220.91 x 10.00. Eight vertices cannot describe a bore.

A RegionLoop now carries the loops nested inside it. Containment is decided by
testing one vertex, which is sufficient because loops in a well-formed sketch do
not cross, and each loop is assigned to the SMALLEST loop containing it so a hole
belongs to the region that actually bounds it. Picking respects holes: a click in
the plate selects the plate, a click in the bore selects the disc. The selection
hands over the region's own entities plus its holes', which is what finally
reaches the kernel. The highlight lights the holes with their region, because it
has to show what will be extruded.

The status line said "Loop selected"; it now says "Region selected". What is
selected is a bounded area that may contain holes, not a single closed curve —
the old wording described the old, broken behaviour.

snaporca-txp8, and it is what makes snaporca-88v reachable from the GUI at all:
the kernel could build the holed face all along (verified on his own recipe:
2 closed loops, wires_to_face OK, area 74812.119 mm2), but nothing could ask it to.

Reviewed and compiled (RC=0). NOT exercised — the rig check is the point.
2026-08-13 17:22:55 +02:00
Tommaso Bianchi b80f4e3036 Persist the CAD recipe on every save, not only on Commit to Plate
Modelling in the Design tab and pressing Ctrl+S saved a project with no feature
history at all, and the app reported success. Found on the rig: a project saved
after drawing a rectangle and a circle contained twelve archive entries, none of
them Metadata/SnapOrca_cad.bin, and a 3dmodel.model with zero vertices.

plater->model().cad_recipe was assigned in exactly one place — on_commit(),
immediately after load_mesh_object. The 3MF exporter was never at fault: it
faithfully wrote whatever the Model held, and on a save that had not gone through
Commit to Plate that string had never been set. The recipe reached the Model only
as a side effect of a different user action.

It now tracks the document instead. sync_recipe_to_model() is called after a
successful recompute, after tree edits (deletes, reorders and suppressions bypass
recompute_guarded), and from on_commit, which delegates rather than repeating the
rule. Only on success — a failed recompute leaves the document mid-edit, and
persisting that would save a model the user never had. An empty document still
clears it, so a non-CAD project carries no stale recipe.

Doing it here rather than in the save path is deliberate: Ctrl+S, Save As,
autosave and crash recovery all read model.cad_recipe, so keeping it current
after each change makes every one of them correct at once, instead of teaching
each save path to ask the Design tab. Commit to Plate means "send this to the
slicer" — making saving depend on it was the bug, not the cure.

Cost is one serialization per recompute, tens of KB against an OCCT rebuild that
has just run.

Why nothing caught it: the kernel round-trip tests serialize a CadDocument
directly, and the 3MF tests exercise the exporter with a recipe already present.
Neither can observe that the GUI never populates it, and every save in testing
happened to follow a Commit to Plate.

snaporca-vjk5. Reviewed and compiled (RC=0); persistence NOT yet confirmed on the
rig — that check is the reason the issue stays open.
2026-08-13 15:54:57 +02:00
SoftFever 6fd425505e fix build error 2026-08-13 16:28:28 +08:00
Tommaso Bianchi 56eebe3398 kernel-test: stop configuring the GUI, which the kernel suite never needed
This script builds only libslic3r_tests, which links libslic3r and no GUI code —
but cmake still processed the whole if(SLIC3R_GUI) block and every find_package
inside it, so the kernel suite silently depended on the GUI's dependency set.

That came due the moment upstream added wxInspector as a REQUIRED find_package:
the orcacad-deps image predates it, so configure died pointing at
src/CMakeLists.txt:92 with nothing about the kernel having changed. Turning the
block off is not a workaround for that one dependency — it is the suite finally
declaring what it actually needs, so the next GUI-side dependency added upstream
cannot break it either.

Surfaced by taking SoftFever's merge of main into the PR branch.
2026-08-13 10:00:10 +02:00
Tommaso Bianchi 358c331cc6 Merge SoftFever's main-into-cad-mainline update
He merged upstream main into the PR branch himself on 2026-08-13. Taking it into
the local branch rather than force-pushing over it: the fork copy is what PR
#15238 shows, and discarding a maintainer's merge to make my own push
fast-forward would be both rude and a loss of 130 upstream commits.

Brings the branch far closer to main than the 2026-07-24 merge-base the PR body
describes, which is most of what snaporca-36u9 was filed for.
2026-08-13 09:44:30 +02:00
Tommaso Bianchi 0e7fcb3daf Recipe v5: length-frame every feature, so the format stops orphaning projects
Every version bump so far has permanently orphaned every project saved before
it. deserialize_recipe refused anything that was not exactly the current
version, and with no migration path v2 and v3 projects are unopenable today —
the 3MF still carries the mesh, so the user gets a frozen solid and no feature
history, which is the whole point of the subsystem silently absent.

The cause was the shape of the data, not the gate. save/load is one flat
symmetric list of ~90 fields with no framing, so a reader has no way to know
where a feature ends unless it agrees on every field.

Each feature is now written as its own cereal stream behind a length prefix, and
the same few lines handle both directions of mismatch. Older file, newer build:
the sub-stream ends early, the read throws, and the fields already assigned are
kept while the rest default — cereal assigns sequentially, so a mid-list throw
leaves the earlier fields set, and that is what makes this work. Newer file,
older build: the sub-stream holds more bytes than the reader knows; it reads what
it knows and stops, and the outer stream is untouched because the length prefix
was consumed in full. A field a project predates is not a corrupt project, so
neither case is an error.

v4 keeps its own pre-framing flat path and opens exactly as before —
cad_recipe_v4.bin is untouched and now serves as the witness for that. v2 and v3
stay refused, by name: their field lists no longer exist in this code. This fixes
the future, not the past, and the comment says so rather than implying otherwise.

From here a new field only needs appending to save/load — no bump, no orphaned
projects. That removes the cost that had blocked snaporca-44m and snaporca-dgv.

The helix round-trip test was reading the blob back flat, reaching into the
format instead of through it; framing necessarily breaks that, so it now goes
through deserialize_recipe, which is a stronger assertion than it made before.
Every field check it carried is unchanged.

Tests: four new [CadDocument][recipe] cases, including the one the change exists
for — a deliberately truncated feature blob must LOAD, keeping what it could read.
Suite 177 -> 181 cases, 2366 -> 2411 assertions.

snaporca-2txy.
2026-08-13 09:36:21 +02:00
SoftFever 030e5f469e Merge branch 'main' into cad-mainline 2026-08-13 15:22:08 +08:00
Tommaso Bianchi 4b3ff99004 Project load: say WHY the CAD model could not be restored
deserialize_recipe distinguishes three cases that matter very differently to the
person reading the message — saved by a NEWER build, saved by an OLDER one, or
genuinely unreadable — and names the version in each. load_recipe threw all of
that away and printed one generic sentence, so the user could not tell "update
SnapOrca" from "your file is damaged", and had no way to find out.

Same error-loss class as the 31 McpControl sites fixed in 1de72de9ed: the message
existed, it was simply not passed on. The generic sentence stays as the fallback
for the case where the kernel really has nothing to say.

This does not make old projects loadable — that is snaporca-2txy, which the audit
behind this change opened. It only stops the reason being withheld.

snaporca-2txy (partial). Reviewed and compiled (RC=0), not exercised.
2026-08-13 09:01:18 +02:00
Tommaso Bianchi 7245415af7 Sketch: a profile may hold more than one closed loop — a plate with a hole extrudes
entities_to_wire handled exactly two shapes of sketch: one lone Circle/Ellipse, or
any number of Line/Arc/EllipseArc/BSpline pushed into a single MakeWire. Everything
else fell off the end as a null wire, so a circle drawn inside a rectangle — the
most ordinary thing in this whole program — refused with "not supported yet". Two
separate closed polygons were quietly worse: both went into one MakeWire, which
does not mean "two loops" to OCCT.

entities_to_wires now returns one wire per loop. A Circle or Ellipse is a loop on
its own; chain entities are grouped by shared endpoints (union-find, 1e-6 in sketch
coordinates), and an open chain still comes back as a wire because a sweep path is
legitimately open. It is all-or-nothing: one loop that fails to build poisons the
whole result, because a partial profile would extrude a shape the user did not draw
— the failure 2e6a8f9e91 was written to stop.

entities_to_wire survives as a two-line wrapper returning the single wire when
there is exactly one loop and a null wire otherwise, so all nine of its call sites
keep their exact contract and Revolve/Sweep/Loft/Surface* are untouched. What a
holed profile means for each of those is a separate question.

wires_to_face takes the largest-area loop as the outer boundary and adds the rest
reversed, which is how OCCT is told a wire is a hole. Containment is CHECKED with
BRepClass_FaceClassifier, not assumed: a loop outside the largest one is a second
island, and one sketch producing several solids is a much bigger feature, so it is
refused by name ("two disjoint regions") rather than guessed at.

Only the Extrude case consumes the new face. Tapered extrudes of a holed profile
are refused — offsetting inner loops has to go the opposite way — and the guard
counts wires on the face already built rather than rebuilding every wire to ask how
many there are, which is also the more honest test: what matters is the profile
being extruded.

Tests: six new [CadDocument][sketchwire] cases, proved by VOLUME rather than by not
throwing — plate-with-hole, two holes, and two regression guards that a lone circle
and a lone polygon extrude exactly as before. Suite 177 cases / 2366 assertions.
No serialized field, recipe version untouched, golden fixtures unchanged.

snaporca-88v.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi ad8b5a73fe Hover pre-highlight: show what a click would take, before it is taken
Third and last piece of the selection model. The other two turned out to be
built already — the rubber band is pick_bodies_in_rectangle and vertex picking
is SolidSel::Vertex with its camera-facing square, both live — so this closes
what the issue actually still described.

Vertex beats edge beats face is a rule the user cannot see until after they have
committed to a click. Showing the outcome under the pointer is what makes the
precedence learnable at all, and is the charter's L5 read honestly: one click,
one visible change means the change has to be predictable BEFORE the click, not
only explicable after it.

The resolution is now one function, resolve_solid_pick, const and writing only
into its out-parameter. The click applies it and then runs its escalation
unchanged; the hover applies nothing. Split this way the promise cannot drift
from the act — a second implementation of "what is under the cursor" would
eventually disagree with the first, and the disagreement would look like a
picking bug rather than a duplication one.

Rendering is likewise one function called twice. The pre-highlight draws first
so the committed selection paints over it, and is suppressed entirely when the
two are the same thing: two coats of the same colour reads as a rendering fault,
and a promise about a click that would change nothing is not worth making. It is
desaturated toward white rather than given its own hue — a distinct colour would
read as a distinct KIND of selection, when it is the same selection one moment
earlier.

Two things that would have been silent bugs. The edge ribbon and the vertex
square render with GL_BLEND off, so an alpha below 1 there is ignored; those two
are quietened by a muted rgb and only the blended face fill takes the alpha
multiplier. And the pre-highlight is cleared in clear_solid_selection, because it
names a face by an index into a shape a recompute has just rebuilt — left behind,
it would keep glowing on whatever now sits at that index, a real entity but not
the one meant.

Hover runs on plain motion only, with no button down and no band running: during
a drag the pointer is doing something else and a promise about clicking would be
a lie. It returns false so the event still reaches the camera — it asks for a
repaint, it does not consume the gesture.

snaporca-9xw. Reviewed and compiled (RC=0), not exercised.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi 498c7ff8d1 Pattern/Cut/Boolean: grey the button when there is no body, and say why
Tommaso reported the array controls as missing. They were not — Shift+N opens a
Pattern card with every control correct — but the report was fair. With no body
the button accepts the click, opens nothing, and writes its refusal somewhere
other than where the click happened. From the user's seat that is
indistinguishable from a dead button, and the icon is one unlabelled glyph among
fourteen, which is how I mis-clicked it into Section view while reproducing this.

A control that cannot act should look like it cannot act, before it is pressed.
The three FEATURE buttons carrying a body-count guard — Pattern and Cut at one
body, Boolean at two — are now greyed below their threshold with a tooltip
naming what is missing.

Only those three. The same guard shape also appears on rows INSIDE the flyouts,
and those stay live: a drawer holds sketch-only entries too, so disabling the
drawer would hide tools that are perfectly usable. The keyboard shortcuts keep
running the guarded action rather than being gated — a key press has no
greyed-out state to see, so the sentence is the only feedback there is.

Re-evaluated in feed_bodies(), before its viewport early-return since this is
about the toolbar and not the canvas, and once after the toolbar is built: an
empty document is the state the bug was reported in and feed_bodies has not run
yet on a fresh tab.

snaporca-o9j. Reviewed and compiled (RC=0), not exercised.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi 13922a52b6 Design status: clear the DoF line on leaving sketch mode, and wrap the HUD chip
Two independent leftovers, both in the same status area.

snaporca-752: the "N degrees of freedom" line described a sketch's constraint
state and stayed on screen after Confirm, Cancel and the Escape downgrade, in
Feature mode where it means nothing — visible in every Feature-mode screenshot of
the 2026-07-27 sweep. Cleared in set_ui_mode rather than at those three exits,
because that is the one place all of them pass through and a fourth exit added
later would otherwise reintroduce it. Constrain mode keeps the readout: that is
where the number is the whole point.

snaporca-8cc: moving the status out of the panel and into the viewport HUD
removed the clipping, but not the underlying problem. The chip is a top-level
popup that Fit()s to its text, so a long sentence grew past the right edge of the
canvas and hung over the window instead of being cut off inside it — the same
silent length limit wearing a different hat. The label now wraps to the room
actually available (canvas width minus the view-cube inset), which is what makes
the earlier promise that "a sentence can be a sentence" true at 1366 as well as
at 1920.

SetLabel + Wrap + Fit are now one function called from both the text change and
the placement. Wrap() rewrites the label it is handed, so it has to follow a
fresh SetLabel every time, and the placement path runs on resize — a chip wrapped
for the old width either overhangs a narrowed canvas or wastes a widened one.
The left inset is one constant now because the wrap width and the anchor have to
agree, or the chip wraps to a width it is not then given.

snaporca-752, snaporca-8cc. Reviewed and compiled (RC=0), not exercised.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi 8737ff701e i18n: drop regenerated catalogues from the PR branch
The .pot, the Italian .po and list.txt are build product: 27,314 of the added
lines in this branch were regenerated catalogues rather than code, and a reviewer
running git diff --shortstat met that number before anything else. Restored to
the merge-base so their diff is zero; they regenerate from source with
scripts/run_gettext.sh whenever the maintainers want them refreshed.

The Romanian catalogue goes with them, for a different reason: it is a complete
new translation and deserves its own PR rather than riding along inside a CAD
feature, where nobody qualified to review it would think to look.

Nothing here changes what the Design tab does. The strings are still marked for
translation in the sources; only the generated catalogues are out.
2026-08-13 08:44:44 +02:00
Tommaso Bianchi 1a6252c88c Hole/Thread re-edit: restore the face latch from the feature, not from the last pick
m_hole_on_face and m_thread_on_face are cleared only by their tool's flyout and by
their plane combobox, so after any on-face hole or thread the flag stays true for
the rest of the session. load_feature_into_dialog restored the stored plane into
the dropdown but never touched the latch, so re-editing from the feature tree
ignored the plane it had just restored: hole_plane() returned the still-latched
face plane, which may belong to a different face, a different body, or a body
since rebuilt. Silent until snaporca-200 added the "On face" row, which then read
as a confidently wrong answer rather than as nothing.

The latch is now rebuilt from the stored feature, which is the only source that
describes THIS hole. Not from the dropdown row: index_from_plane snaps an
arbitrary face plane to the nearest XY/XZ/YZ, so driving the re-edit from the row
would MOVE a hole drilled on a slanted or offset face — that was the reason the
other candidate fix was rejected.

is_base_plane() decides which of the two a stored plane is. It compares the origin
as well as the axes (a plane parallel to XY but 12 mm up snaps to row 0 and would
come back at z=0), and adds modeling_origin before comparing, because hole_plane()
and thread_plane() add it to the dropdown plane before the feature stores it — a
document with a shifted origin would otherwise mistake every dropdown hole for a
face pick. Vector norms, not isApprox, which is relative to magnitude and useless
against the zero origin.

The face's (u,v) extent is not serialized, so m_hole_has_bounds is cleared: the
gizmo's footprint clamp goes unbounded, which is honest, where another face's
bounds are not. The label says which body the face belongs to instead of a face
number the feature does not carry; "(none — uses Hole plane)" is the one thing
that is definitely false there.

snaporca-uif9. Reviewed and compiled (RC=0), not exercised.
2026-08-13 07:35:39 +02:00
Tommaso Bianchi 6b3711fb08 Mate preview: hover a mate row and see the assembly move, commit nothing (G3)
refresh_preview() listed Tool::Mate among the features that produce no solid and
cleared the ghost, with a comment saying a mate has no 3D ghost. The kernel never
agreed: preview() routes a Mate candidate through apply_mate on a throwaway copy
of the bodies, and build_candidate already filled the mate fields. That one early
return was the whole of epic gap G3.

A mate makes no NEW geometry but it MOVES a body, and the moved assembly is the
ghost worth showing. Both the Mate card and the offer's mate palette now show it:
hovering a palette row previews that kind, leaving the row drops it, and choosing
one commits. Nothing is written to the document until the click.

The committed bodies are hidden while the ghost is up — it is the whole assembly
in its post-mate pose, not an added lump, so leaving them visible would draw the
mated body twice and z-fight every other body against its own copy. Same reason
Dressup and Draft hide them.

Cleanup is after PopupMenu rather than on a close event: PopupMenu is modal, so by
then the menu is gone and any command it raised has run. A flag distinguishes a
ghost this menu put up from a preview that was already on screen.

snaporca-b4sp. Reviewed and compiled (RC=0), not exercised.
2026-08-13 07:28:21 +02:00
Tommaso Bianchi 8e15ad23e2 Mate palette: five types on the offer, dimmed with the reason, naming the pair
snaporca-lukg part B. The issue describes building a contextual viewport palette
with a stable icon set, non-viable options dimmed and explained rather than
hidden, and edge-aware placement. show_offer_menu() already does all three — its
dead-row branch appends a disabled row with "   —   " and a reason, and wxMenu
places itself against the screen edge. So this is not a new widget. It is one
section added to that menu, fed by mate_options().

The header row names the pair: "Mate: A → B". That is epic gap G4 — the mate card
is abstract dropdowns and never says which body moves. B is the connector on the
body that MOVES, so B is the arrow's destination; the parameter order invites the
opposite guess, which is why it is commented at the point of use.

Five rows in one loop over the kernel's result, never reordered and never
filtered. The palette addresses rows by position, so a shorter list would move
every row below it — which is the whole argument for dimming instead of hiding.

The pair comes from the Mate card's combos when that card is open, so the offer
and the card cannot disagree about what they are acting on; otherwise the first
two enabled connectors, which is defensible only because the header names them.
An offer acting on an unnamed pair would be worse than no offer.

REVIEW CATCH: the five type names arrived as an array indexed by kind, read as
_L(table[i]). That compiles and is silently untranslatable — _L is a gettext
macro and the extractor scans SOURCE for literals, so five strings would have
shipped that are never in the catalogue. These names appear nowhere else in the
tree, so that would have been their only occurrence. Now a switch of literal
_L() calls.

G3 INVESTIGATED, NOT BUILT, as specified. preview() DOES handle a Mate candidate:
it copies the committed bodies to a temporary and routes the candidate through
apply_mate on that copy, committing nothing, and build_candidate already fills
the mate fields. The only blocker to a hover preview is refresh_preview()'s
Tool::Mate early return, which clears the preview on the belief that a mate has
no ghost. Nothing in the kernel refuses it. Filed rather than built.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-lukg.
2026-08-12 23:55:24 +02:00
Tommaso Bianchi 6b3642fa53 Mate viability: which of the five apply, and why the others do not
snaporca-lukg wants a palette offering all five mate types with the non-viable
ones DIMMED AND EXPLAINED rather than hidden — its reasoning being that a menu
changing shape between invocations destroys the motor memory experts rely on.
That needs an answer this document could not give. This is that answer, and
nothing else: mate_options(cs_a, cs_b) returns five MateOption{kind, viable,
reason}, always five, always in kind order, never filtered.

The geometry test rides on the fingerprint added for snaporca-kqih, which is why
it costs no new serialized field: coordsys_face_kind already records the surface
type. Revolute and Cylindrical need a cylindrical face at both ends because they
need an axis to turn about; Planar needs flat faces; Fastened and Slider
constrain frames rather than surfaces, so no geometry test applies to them.

UNKNOWN IS PERMISSIVE. A fingerprint of -1 means PointWorld or a connector that
has not resolved yet, and it does NOT make a type non-viable. Refusing on missing
information is the false-alarm behaviour that gets a whole feature ignored — the
same reasoning already recorded on kqih for the drift warning, applied again
because it is the same trade.

Reasons name WHICH connector is the problem when only one is. "needs a
cylindrical face at both ends" tells the user what the rule is; "connector A is
on a flat face" tells them where to look, and the second half is the one that
saves the time.

The stability contract has its own test, asserting five entries in kind order
even for a completely invalid pair. That matters more than any individual
verdict: the palette addresses rows by position, so a shorter list would move
every row below it.

Golden fixture unchanged — this is a pure query. Suite 167 -> 171 cases,
2284 -> 2348 assertions, green. snaporca-lukg part A; the palette is part B.
2026-08-12 23:45:37 +02:00
Tommaso Bianchi a3398c6609 MCP: let a caller find out its face/edge ids went stale
snaporca-rgbj measured the damage: four chamfers on a box remove
0.400/0.397/0.397/0.395 mm3 when each id is re-read, and
0.400/0.008/0.397/0.280 when the four ids are captured up front. The kernel is
right in both runs — the second one asks for the wrong edges. Neither errors,
because a stale id still resolves to a real edge, just not the one that was
measured.

That makes it an API problem rather than a script bug. Reading the scene once and
then issuing several operations is the natural way to drive a socket, it is what
every agent will write, and it produced silently wrong geometry with nothing
anywhere reporting it.

CadDocument::topo_generation is bumped where the bodies are replaced — the single
line in recompute() where the face and edge maps actually change, so a feature
type added later cannot forget to bump it, which a per-mutator counter would
invite. describe_scene and query_topology return it. A caller may pass it back as
"generation" on any call, and a mismatch is refused with a message that says what
to do about it.

Two deliberate choices:

OPTIONAL, not mandatory. Every existing script keeps working unchanged; passing
the generation is what buys the guarantee. Making it required would break every
caller to fix a mistake only some of them make.

CHECKED AT THE DISPATCHER, not in each handler. One site covers fillet, chamfer,
shell, draft, coordsys, thicken, cut, project, delete_face and everything added
after them. A per-handler check is a list that goes stale the first time someone
adds a method in a hurry.

Not serialized: an id means something only within the run that produced it, so
persisting the counter would promise a stability the ids themselves do not have.
No recipe version change.

describe_tools now carries an id_lifetime note, because the guard only helps a
caller who knows to ask for it.

Kernel suite 167 cases / 2277 assertions green; libslic3r_gui builds. The guard
itself is NOT exercised — it needs the socket, so it is on snaporca-bdco.
snaporca-o1l2.
2026-08-12 23:27:11 +02:00
Tommaso Bianchi 9125e0b4f8 Connector face drift: warn without crying wolf — and bump the recipe version
kqih option (c). A FaceAndDirection connector stores a global face index, and an
upstream edit can renumber faces so the index silently names a different one. The
DANGLING case already threw; this is the in-range-but-wrong case, which nothing
detected.

Fingerprint the face on first resolve, compare afterwards, and report a mismatch
into mate_conflicts — the channel that already marks the tree row — never as an
error. A drift warning must not abort the recompute, because the alternative
makes a legitimate Draft on a mated face fatal.

WHAT THE FINGERPRINT IS, AND WHAT IT IS NOT. Surface type plus edge count. Not
centroid or area: legitimate parametric edits move and resize faces, which is the
entire point of the model, so either would fire on every dimension change. Not
the normal, which is the tempting one — Draft deliberately tilts a face and
Transform reorients a body, both legitimate. Type and edge count survive rigid
motion, tilting and resizing, and catch the case that actually happens: a planar
index sliding onto a fillet's cylindrical face after a dress-up inserts faces.
The accepted cost is that a slide between two planar 4-edge faces is invisible. A
partial detector that never cries wolf beats a total one that does, because a
false alarm on a valid connector teaches people to ignore the warning.

Connectors with no fingerprint record one on first recompute, so old recipes
self-heal and both writers (DesignPanel, McpControl) get it without changing.

THE VERSION BUMP IS THE IMPORTANT HALF. The task was specified with "do not
change the recipe version" — that was wrong, and the rule is written in the
header three lines above the constant: bump whenever save/load gains a field.
deserialize_recipe() gates on v == VERSION and then reads a FLAT symmetric field
list. A v3 blob under a v3 build that has grown two fields passes the gate and
reads two ints past the end of every connector, into the next feature's bytes.
That is silent corruption of a saved project, which is worse than any load error.
Now v4, and v3 gets the existing clean refusal.

cad_recipe_v3.bin is KEPT, unregenerated, with a test asserting it is refused and
that nothing half-read is left behind. It is the only artefact that can prove the
gate works, because it was written by an older build — regenerating it with
today's code would destroy the evidence, which the test says in as many words.

Suite 163 -> 167 cases, 2248 -> 2277 assertions, green. Fixture v4 34928 bytes.
snaporca-kqih.
2026-08-12 23:04:49 +02:00
Tommaso Bianchi 13c702bed3 Chamfer drift is the driver's, not the kernel's — measured, not argued
Two tests that separate a hypothesis nobody had tested. The socket showed four
chamfers on a filleted rim removing 29.6 / 20.0 / 10.3 / 7.5 mm3, falling
steadily. That could be the chamfer maths degenerating on a filleted rim, or it
could be how the driver captured its edge ids. Those have completely different
fixes, so the first job was to find out which.

dressup_edge is a global index into TopExp::MapShapes(shape, TopAbs_EDGE),
resolved against the body AS IT STANDS at that feature's position, and every
dress-up rewrites that map. So the two usage patterns are:

  ids re-read after each chamfer:  0.400, 0.397, 0.397, 0.395 mm3  (max/min 1.01)
  four ids captured up-front:      0.400, 0.008, 0.397, 0.280 mm3  (max/min ~48)

The kernel chamfers uniformly when handed a fresh id. It degrades only when
handed ids snapshot against an earlier shape — and the second chamfer's stale id
landed on a nearly-consumed edge and cut two percent of what was asked. That is
the accumulating-drift signature the socket showed.

Conclusion: driver artefact. apply_chamfer and OCCT are not at fault.

The part that makes this worth a test rather than a note: IT DOES NOT THROW.
ok=1, error empty. A stale id still resolves to a valid edge — just the wrong
one — so nothing anywhere reports it. Silent wrong geometry, which is the class
this project does not tolerate, reachable by any caller that reads the scene once
and then issues several dress-ups.

Test 2 asserts the non-uniformity as CURRENT BEHAVIOUR and says so in the code:
it documents a defect, it does not bless one. When the driver contract is fixed
it should be rewritten, not deleted.

Suite 161 -> 163 cases, 2217 -> 2248 assertions, green. Tests only, no
production code. snaporca-rgbj.
2026-08-12 22:36:20 +02:00
Tommaso Bianchi 488c94e957 SurfaceOffset and ThickenSurface: the arrow stands on a face, the tool still takes the sheet
These were the last tools from the charter audit with no handle at all, and the
issue filed against them offered three options, all of which changed the tool.
Reading on_add_surface_offset() dissolved the question instead.

The premise was that a distance handle needs a frame, a sheet body has no single
normal, and therefore the tool must start demanding a face. But the face was
never needed for the OPERATION — only for the ARROW. Both tools still offset or
thicken the entire sheet named in the combo. The picked face only says where to
stand the handle.

So the arrow appears whenever a face of that sheet is under selection, and its
absence costs nothing: the card alone works exactly as before. Purely additive —
no existing flow changes, and there is no new precondition for the user to learn.
That is strictly better than any of (a) anchor on the first face and be wrong on
a curved sheet, (b) sample a normal at the bbox centre and be arbitrary on a
folded one, or (c) require a face pick and change what the tool demands.

The arrow is refused when the picked face belongs to a DIFFERENT body than the
sheet in the combo. An arrow standing on one body while the tool acts on another
would name the wrong thing, which is worse than no arrow.

ThickenSurface was not on the audit's list — it is a distinct tool from Thicken,
with its own card and its own sheet-body combo, and it has exactly the same
shape. Fixing one and not the other would have left the same gap under a
different name.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-9fel.
2026-08-12 22:16:31 +02:00
Tommaso Bianchi 416e7f7321 Mate conflicts: mark the row that carries them, and name the way out
detect_mate_conflicts() has been filling m_doc.mate_conflicts on every recompute
since the kernel half landed, and nothing read it. The diagnostics existed and
were invisible — a conflicting assembly looked exactly like a working one.

The tree row is where they go, because the tree is where the user is already
looking for which feature to change. Three states, in precedence order:

  disabled  -> dim. A SUPPRESSED mate is the user's answer to a conflict, so it
               must read as suppressed rather than keep shouting about it.
  conflict  -> warn.
  otherwise -> normal.

Selecting a marked row puts the reason on the status line — "Mate3 already
positions Body 2", the cycle, the self-mate — and names the eye as the way to
suppress it. A message that describes a problem with no action is a message that
gets ignored; the action here is already one click away on the row just selected.

Deliberately NOT a modal, and deliberately not treated as a document error. The
document still evaluates with a conflict present: the mate graph merely has more
than one answer for a body, and which one wins is the thing the user needs to
see. Blocking the loop to say so would interrupt without helping.

The dimming of non-involved bodies from the original UX proposal is still not
implemented, on purpose: under transform composition a failure mid-chain
propagates, so "not involved" is not a well-defined set, and dimming the wrong
bodies would hide the context needed to understand the conflict.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-bioq.
2026-08-12 21:53:38 +02:00
Tommaso Bianchi 7311cb12cb Body-focus picking: fail open, and keep the combo and the viewport as one state
Two defects found by auditing the body-focus x-ray path, which shipped compiled
but never exercised. Neither is reachable from the happy path its test plan
walks, which is why compiling it proved nothing.

1. A STALE FOCUS KILLED THE VIEWPORT. The focus is a body INDEX held by the panel
   across recomputes, so it outlives the body it names: delete a body and the
   stored index can point past the end. body_pickable() then rejected EVERY body,
   because none of them equals an index that no longer exists — a viewport that
   silently accepts no clicks at all, with nothing on screen saying why. Out of
   range now means no restriction. Fail open, never dead.

2. THE COMBO AND THE FOCUS COULD DISAGREE. refresh_cs_body_choice() rebuilds the
   Body combo and, when the body list shrank, silently reset the selection to
   "(all)" — while the viewport stayed focused on the old index. Every other body
   kept its 25% alpha and picking stayed restricted to a body that might be gone.
   That is the exact mirror of the open_tool ordering bug this feature already
   fixed once: that one showed "Body N" over an opaque scene, this one shows
   "(all)" over a dimmed one. They are one state and are now written together.

   Guarded on CoordSys being the active tool, since it is the only card that owns
   this focus. In the edit path the function runs BEFORE open_tool with the
   previous tool still active, so the guard is false and the caller's explicit
   set_xray_focus still wins.

Also confirmed while reading, since the header asserts it: set_solid_pick() does
NOT touch m_pick_only_body, so the focus really does survive the mesh feed. That
claim now has a check behind it rather than a comment.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-bgvk.
2026-08-12 21:49:08 +02:00
Tommaso Bianchi 23585382ab Mate connector: a roll mark that survives a grazing view, and a quieter warning
Two open findings from the first rig judgement of the connector glyph.

F4 — the quadrant collapses to a blob at grazing angles, which is exactly when
the roll is hardest to read. Adds a radial tick along +X extending past the disc
rim. As the disc flattens to a line the sector loses all its area, but a radial
spoke keeps its length and its direction along the one axis that still projects.

The alternative on the issue was to billboard the quadrant while the disc stayed
in-plane. Rejected, and not on taste: at true grazing the view direction lies IN
the connector's plane, so every in-plane direction projects onto the same screen
line and the roll is geometrically unrecoverable. Billboarding would not recover
it — it would face the camera and read as a definite orientation that is not the
frame's. Degrading to a direction that can still be trusted beats drawing a
confident lie. The tick is additive, so unlike billboarding it cannot make the
non-grazing case worse; it still wants judging on the rig at a true grazing view
before F4 is called closed.

F5 — roll-undefined was a loud red: the strongest colour in the viewport spent on
the least important connector, pulling the eye off the mate being made. It marks
"this one could not be derived", not an error. Muted amber says look-here without
shouting.

No tick is drawn when the roll is undefined — a tick there would assert a
direction that does not exist, which is the silent guess the hatched quadrant
exists to avoid.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-wgsc.
2026-08-12 21:46:19 +02:00
Tommaso Bianchi e6a14b39c9 Rib: the thickness gets its handle, so the whole tool is draggable
Rib's depth already reused the Extrude arrow. Its thickness could not: the arrow
points along the plane normal, and thickness is an offset either side of the rib
line, IN the plane. Different direction, different handle.

Two square handles at mid ± perp·half, plus the slab's actual footprint drawn as
a thin closed rectangle — the footprint matters more than the dots, because what
a rib thickness means is how wide that slab lands on the body, and until now
there was no way to see it before committing.

A drag on either handle sets the FULL thickness, twice the perpendicular distance
from the line, because the slab is centred on the line and the handle sits at
half. Both handles behave identically for the same reason, so they share one
colour rather than pretending to be two different actions.

A zero-length line has no direction to grow a slab perpendicular to, so the
shared rib_frame() helper returns false and render and drag both draw nothing
rather than dividing by zero. Non-Line entities clear the gizmo instead of
guessing: the kernel is line-only and a gizmo that guesses would be lying about
what Confirm will build.

Unlike the helix callback this one goes through refresh_preview(), because Rib
builds a real solid ghost that has to rebuild. The helix has none and skips it
deliberately.

Both gizmos coexist and resolve the sketch and entity the same way, so the depth
arrow and the thickness handles can never disagree about which line they are on.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-plew.
2026-08-12 21:34:59 +02:00
Tommaso Bianchi b9d6b59f90 A feature that destroys a body must say so, not ship a phantom
Driving the control socket: hexagon prism, six vertical fillets, four chamfers
on the already-filleted rim, an M8 hole. Afterwards describe_scene reported
bodies=3 and error='' — entirely healthy — while body 2's TopoDS_Shape was null.
Only mass_properties on that one body revealed anything was wrong.

So a feature destroyed a body, recompute() returned true, and the document went
on advertising it. Any downstream consumer — slicing, STEP export, a mass
properties report — met a null shape with no warning. That is the silent
corruption class, which is the one class this project does not tolerate.

recompute() now scans the freshly built bodies for a null shape, names the body
and the feature that destroyed it, and returns false. Returning false rather than
just setting error is the point: it hands the caller its normal rollback path, so
the operation that destroyed the body is undone instead of committed.

The message says "an unidentified feature" when source_feature is -1. "feature 0"
would be a lie, and a message that exists to tell you where to look has to be
trusted.

TEST IS A POSITIVE CONTRACT, AND THE REASON MATTERS. The reported order was
driven headlessly first, as the better test: it does NOT reproduce. The dress-up
step throws "fillet radius too large", which is an already-loud already-caught
path, so recompute fails honestly and never nulls a body. No public-API sequence
found so far reaches the guard's branch without a GUI, and faking a null into
`bodies` after the fact would not exercise it — the guard runs on `built`, before
the swap. So the test asserts what can be asserted: a box + fillet recomputes
true, error is empty, and no body is null. The guard's own branch is defensive
and currently unexercised; that is stated here rather than implied by a green
suite.

Kernel suite: 2217 assertions in 161 test cases, all passing. No existing test
relied on a null body surviving a recompute, so hardening this broke nothing.

snaporca-5425 (part a). Part b — why the chamfer chain degenerates on an
already-filleted rim — is untouched and stays open.
2026-08-12 20:57:35 +02:00
Tommaso Bianchi e8306a6e9a Helix: draw the thing, then let the numbers be dragged
grep -i helix over the viewport code returned nothing at all. The tool was four
coupled numbers and a Confirm button — you typed radius, pitch, height and taper
blind and pressed OK to find out what you had made. So this is not only the
charter's L2 failure; the tool had no visible state whatsoever while it was open.

Adds a plane-anchored helix gizmo built on the datum-plane gizmo as its template,
being the closest existing thing: also plane-anchored, also driven by a card while
the sketch tool is inactive, also a render / hit-test / drag triad.

It draws the live curve and the axis, and puts a handle on each of the three
lengths: radius on the base circle, height at the top of the axis, pitch at the
end of the first turn — which is exactly where one pitch of rise lands, so the
handle means what it is standing on. Below one full turn the pitch handle moves
to the end of the curve rather than floating off a curve that does not exist yet.

Taper and handedness stay on the card. One is a shape modifier and the other a
flag; L2 governs numbers you can point at.

A drag reports the whole (radius, pitch, height) triple rather than one value,
because pitch and height are coupled through the turn count and writing one alone
would redraw a stale curve. The callback re-feeds the gizmo directly instead of
going through refresh_preview(), since Helix takes the produces-no-solid early
return and refresh_preview would rewrite the status line on every mouse move.

REVIEW CATCH, fixed here: the first cut read taper as a fraction of the radius
consumed over the turn count. It is an ANGLE IN DEGREES — helix_spine() builds a
Geom_ConicalSurface of half-angle taper and takes the top radius as R+H*tan(taper),
growing with the height risen. The wrong reading drew a preview that collapsed to
a point for any non-zero taper while the committed feature was perfectly fine. A
preview that lies is worse than no preview, which is what this commit replaced.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-i3jc.
2026-08-12 20:40:21 +02:00
Tommaso Bianchi a5bb41e340 Rib: the depth is the same arrow again
Rib's depth is a distance along the sketch plane normal, so it is the Extrude
arrow for the fourth time — anchored at the midpoint of the line the rib is
built on, because a rib's line IS its profile.

This is half of Rib's L2 failure. The thickness is an in-plane offset either
side of that line and no existing gizmo draws that; it needs a handle that does
not exist yet, filed as snaporca-plew rather than left implied. One of two
numbers draggable is strictly better than neither, and saying which half is
missing is the point.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-i3jc.
2026-08-12 20:12:01 +02:00
Tommaso Bianchi 4ed14eb0be SurfaceExtrude and Thicken: drag the distance instead of only typing it
Both tools produce exactly one number — a distance along a known normal — and
neither had a handle for it. That is the same shape as the Extrude depth arrow,
which was already written, already draggable and already had an editable label
on the geometry. So this adds no gizmo: it points the existing one at two more
tools.

SurfaceExtrude anchors on its sketch's plane, at the profile centroid.
Thicken anchors on the picked face, and reuses the face-as-profile recipe from
the Extrude path verbatim — including the two things that path learned the hard
way: look the face up on its OWNER body rather than the whole-document compound,
and carry that body's display Move transform onto both the origin and the
normal, or the arrow draws on the bed instead of on the face.

The drag callback routes by active tool. `second` stays Extrude's alone: it is
the two-sided pair, and the other two have a single distance each.

SurfaceOffset is the third tool in this group and is deliberately NOT here. Its
target is an arbitrary sheet body, which has no single normal to anchor an arrow
on — that is a design decision, not typing, and it stays on the audit.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-i3jc.
2026-08-12 20:09:29 +02:00
Tommaso Bianchi b7397b48bd Transform: drag the body, the numbers follow
The Placement > Transform verb opened a card of spin controls — dx/dy/dz, an
axis combo, an angle — with nothing on the geometry. The 3-axis drag gizmo the
charter asks for already existed and was fully implemented (arrows, rotation
rings, click-to-type per axis), reachable only from a small icon button in the
tree card header. The prominent verb opened the form; the geometry-first
control was hidden behind an icon. That was backwards.

Transform now arms that same gizmo on the target body. The card stays as L2's
typed half: the drag writes dx/dy/dz, the axis and the angle, and the pivot is
seeded from the body's centroid so the parametric feature reproduces exactly
what was dragged.

Decomposition is exact for the interaction that matters — the gizmo's rings are
per-world-axis, so a ring drag is an axial rotation. A pose composed from two
rings is not axial and the card can only name one axis, so it reports the
dominant one rather than refusing to answer.

Three things this had to get right:

- The gizmo bakes its drag into the display transform so the body follows the
  cursor, and the feature performs the same motion parametrically. Committing
  without reverting first would move the body twice.
- tool_confirm() and tool_cancel() both tested moving_body() BEFORE the active
  tool, so with the gizmo armed Confirm would have dropped the gizmo and never
  created the feature. Both are now guarded on Tool::None.
- close_tool() is the single revert point. Esc, Cancel and switching tools all
  pass through it, so a Transform that was never committed cannot leave the body
  displaced.

Edit mode is untouched: re-seeding the gizmo from a stored feature is a separate
problem, so editing an existing Transform still gets the card alone.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-qtf4.
2026-08-12 20:04:45 +02:00
Tommaso Bianchi 3f62d4d58d Bodies: colour that survives selection, hide that toggles twice, Delete that acts
Three defects behind one report ("bodies cannot be moved or hidden/shown or
deleted, colour does not work"). They are unrelated to each other; only the
symptom was shared.

1. The Color tool wrote a per-body override that was correct end to end —
   stored on CadBody, carried across recompute (CadDocument.cpp:3381), read
   back by DesignCanvas::body_color() — and then overpainted every frame.
   m_body_selected is a DOCUMENT-WIDE flag raised whenever a non-Sketch
   feature row is selected, which is the resting state after any modelling
   operation, and while it was true every body rendered gold. An explicit
   colour now outranks the selection tint; unpainted bodies still tint, which
   is all the tint was ever for.

2. The eye toggle re-selected the body row through m_tree, using item ids that
   belong to m_parts. The row came back unselected, so the second press found
   tree_body_selection() == -1 and fell through to the feature-level branch
   instead of un-hiding. Hide worked exactly once. The sibling call in
   refresh_parts() had it right.

3. The tree card's Delete button answered a selected body row with "select the
   FEATURE that created this body" — an instruction the user cannot act on,
   because the tree does not say which feature that is. on_delete_body()
   already resolves CadBody::source_feature and confirms by name; it was
   reachable only from the right-click offer. The button now routes to it.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised — needs a session at
the machine to confirm all three in the viewport. snaporca-zjvg.
2026-08-12 19:57:14 +02:00
Tommaso BianchiandClaude Opus 5 606026a920 Home: axonometric view, fitted
DesignCanvas::set_view() and fit_view() were both written and then never called
from anywhere in the tree. The Design viewport has had no way back to a standard
view since it existed: no key, no button, nothing but orbiting by hand until the
model happens to drift into frame.

That is worse than a missing convenience. A camera left pointing along the bed
plane renders a scene that looks exactly like a failed renderer — geometry
present, nothing visible — and an hour went into blaming the software GL stack
before the real cause turned out to be two uncalled functions.

Home rather than a letter: every letter A-Z is already a Shift+letter tool
shortcut. Home is also the reset-the-view key most users arrive with. The
dispatcher needed no change, it keys on the raw wx keycode. set_view() already
does select_view + zoom_to_volumes, so this is fit and orient in one call.

Doc row added to the View toggles table in docs/design_tab.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:59:22 +02:00
Tommaso BianchiandClaude Opus 5 b305e8b154 tests: compile the five CAD test files that were never wired
tests/libslic3r/CMakeLists.txt added only test_caddocument.cpp under
SLIC3R_CAD. The other five shipped in the tree and were never compiled, so
49 TEST_CASE blocks looked like coverage and were not: sketch constraints,
sketch editing, sketch import, inference, and the libslvs constraint set.

They also still targeted Catch2 v2 — mainline is on v3, where the umbrella
header is catch2/catch_all.hpp and Approx lives in the Catch namespace rather
than at global scope. Both fixed; nothing else in the files changed.

Found by building the tree rather than reading it. Suite goes from 374 to 423
test cases, 54,424 to 54,620 assertions, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:05:02 +02:00
Tommaso BianchiandClaude Opus 5 d584d66003 Mate conflicts: name what silently wins, don't call it over-constraint
Two enabled mates driving the same body is not an error today — the later one
just wins, and the earlier mate looks ignored with nothing said. A cycle in the
mate graph is worse: composition still produces a result, but an arbitrary,
order-dependent one.

recompute() now fills a mate_conflicts vector of (feature index, reason) before
the geometry pass, so it survives a throw further down. It catches a second mate
on the same target body, a mate positioning a body against itself, and a cycle,
via an iterative three-colour DFS over the body graph. Broken mates are skipped
silently — apply_mate() already errors on those.

Deliberately non-fatal: recompute() still returns true and error stays empty.
Deliberately not "over-constraint" — that word promises DOF analysis from a
solver this kernel does not have.

Port of snaporca ec4ffeb979. Kernel half of snaporca-bioq.
Suite: 2213 assertions / 160 cases green on this fork too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:06:17 +02:00
Tommaso BianchiandClaude Opus 5 3eec65f2bd CoordSys: pick a body first, x-ray the rest
In an assembly the face you want for a mate connector is nearly always behind
another body, and a click only ever returns the frontmost hit. Hiding the
occluder from the Parts list works but means leaving the tool mid-pick.

The CoordSys card now carries a Body chooser. Pick a body and every other one
drops to 0.25 alpha AND stops catching clicks, so the wanted face is both
visible and reachable in one gesture. "(all)" restores normal picking.

Deliberately NOT hit cycling: repeated-click cycling was removed from solid
picking as a charter L5/§10 violation (DesignSketchTool.cpp, "NO CYCLE"), and
re-introducing it here would make "click a face" a multi-click gesture again.

Mechanics: DesignSketchTool::set_pick_only_body() gates body_pickable(), which
every pick path already consults; DesignCanvas::set_xray_focus() drives both it
and the per-body alpha in reload(). The chooser stays a pick FILTER only --
coordsys_body still comes from the actual pick, so nothing in the kernel moves.

Body focus follows the CoordSys card: open_tool() reads it back from the combo
rather than clearing outright, because editing a CoordSys feature loads the card
(and its body) before open_tool runs.

snaporca-bgvk. NOT COMPILED: deps/build lacks OpenVDB so the GUI tree will not
configure here; reviewed by diff only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:45:37 +02:00
Tommaso BianchiandClaude Opus 5 8ff7ba440d CadDocument: reindex mate connectors on feature delete and reorder
mate_cs_a/mate_cs_b are feature indices. remove_feature() remapped sketch_ref
through the deletion but not the mate connectors, and move_feature() swapped
sketch_ref but not the mate connectors. Deleting or reordering any feature
ahead of a connector slid both references onto whatever features landed on
those slots.

Nothing reported it. recompute() only rejects out-of-range and non-CoordSys
targets, and a shifted index normally lands on the assembly's other CoordSys —
an assembly carries at least two by construction. So the mate resolved against
the wrong frames and moved the wrong body, silently.

Extracted a remap lambda in remove_feature() and a swap_ref lambda in
move_feature(), applied to sketch_ref and both mate connectors.

Two tests, both confirmed red before the fix. [mate] tags green here:
395 assertions / 29 cases — the first end-to-end kernel compile of this fork.

Ported from snaporca; CadDocument.cpp is byte-identical across forks again.

Refs: snaporca-kqih

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:14:10 +02:00
Tommaso Bianchi 0a2faedc32 Design: draw the mate connector, so its verse and polarity are visible
A mate connector was visible only to a program. resolve_datum_coordsys had exactly ONE consumer
in the whole tree -- McpControl.cpp, the agent socket -- so the frame every mate is built on
could not be seen at all, and the two questions a connector has to answer on sight had no
answer in the viewport: which way does Z point (the VERSE), and which of the pair is anchored
versus about to move (the POLARITY).

The glyph is Onshape's proven core plus the part nobody ships. Disc for the XY plane, one gold
quadrant for the roll -- the only in-glyph answer to "where is X", which matters because
Fastened and Slider lock the clocking -- and a Z arrow drawn on +Z ONLY, never double-headed.
Polarity is carried by the head: a filled cone travels, an open collar receives. Onshape,
Fusion, Inventor and FreeCAD all draw both ends of a mate identically, which is why "which part
moves?" is a standing complaint; nothing here invents new semantics, it just stops hiding them.

Polarity is read from the committed Mate features, not only from the open card. A connector some
mate drives must read as driven whenever it is on screen, or the glyph tells the truth only while
a dialog happens to be open. The card, when open, still wins -- that is the live intent.

Judged on the rig rather than in a mock, which changed three decisions:

- Three RGB axis arms lose to one Z arrow. Rendered side by side (SNAPORCA_GLYPH=A selects the
  Onshape-style trio), the three heads are as large as the 22 px disc, they bury the quadrant, and
  at an oblique angle they pile into a smudge -- and the trio is indistinguishable from the move
  gizmo and the bed triad, which are already RGB arrow trios in this viewport.

- Depth off floats, depth on tears. With GL_DEPTH_TEST off, connectors on faces pointing AWAY from
  the camera drew their discs over the solid, so the part looked covered in frames that were on its
  back. Turning depth on fixed that and immediately z-fought: the disc is exactly coplanar with its
  face and came out a broken dotted arc. Depth ON plus a 0.7*upp lift along Z buys both, and scaling
  the lift by upp keeps it sub-pixel instead of opening a visible gap on zoom-in.

- Foreshortening degenerates an arrow into a dot when the axis points at the camera. It now draws a
  ring instead of silently vanishing, which is what a naive projection does.

Everything is sized in screen pixels via upp = 1/zoom, like every other gizmo here: a connector is
a symbol, not a part, so it must not shrink with the model.

Research and the empirical findings are written up in DESIGN_MATE_CONNECTORS.md section 8b;
rig images in artifacts/shots/g-0*.png, vendor reference glyphs in artifacts/glyphs/.

Not fixed here, and recorded rather than papered over: the quadrant collapses to a blob at a
grazing angle, which is exactly when the roll is hardest to read (F4); roll-undefined in red makes
the least important connector the loudest thing on screen (F5); and a true grazing view, a curved
face, and overlap with the move gizmo are still untested. Also surfaced while testing and unrelated
to drawing: add_mate accepted a mate between two connectors on the SAME body and duly transformed
the body relative to itself -- a concrete instance of the missing validation already filed as G6.

Fork parity unchanged: DesignCanvas.cpp 16, DesignPanel.cpp 30, the other four files 0.
2026-08-05 09:50:09 +02:00
Tommaso Bianchi 24f4076bb5 Design: the Hole and Thread cards say which face they are holding
snaporca-200 asked which of the two models of "the card's face input" is right,
because clicking empty canvas now clears the selection (snaporca-od0) and made
them visibly disagree: Thicken / Shell / Draft read the LIVE selection and their
label reverts to "(pick a solid face)", while Hole / Thread LATCH the face they
were opened or picked on and keep it. The complaint was that Hole then drills a
face you can no longer see selected.

Taken to the rig, that turns out to be the wrong half of the story. With Hole
open and its face picked, a click on empty canvas leaves the Ø6.0 ghost and its
dimension gizmo drawn on that exact face — the card was never operating in
secret, it was showing its target the strongest way a CAD tool can. Meanwhile
Draft, whose behaviour was held up as the honest one, threw the pick away and
had to be told the face again.

So neither model replaces the other. They are different in kind: Thicken /
Shell / Draft are operations whose operand IS the selected face, and Hole /
Thread are placement tools with their own plane state that a pick merely seeds.
The latch is also the kinder of the two now that empty clicks are a deliberate
gesture — a stray one costs Thicken a pick and costs Hole nothing.

What was genuinely missing is that nothing in those two cards NAMED the latched
face, so after such a click the only words on screen were the viewport's
"Nothing selected" over a ghost about to drill. Both cards now carry an "On
face" row, the way the other three already do:

  Hole    Face 5 | (none — uses Hole plane)
  Thread  Face 1 | Edge 2 | (none — uses Thread plane)

Thread names an edge when the cylinder came from a circular rim rather than a
cylindrical face, which the code already distinguished internally and never
said out loud.

Verified on both rigs, every state driven through the GUI: face pick on open
and on live pick, survival across a click on empty canvas, and the fallback
after choosing XY/XZ/YZ from the plane dropdown. Thread's edge branch was
exercised on a revolved tube's rim, its face branch on the same tube's outer
wall.

Filed while here, surfaced by the new row rather than caused by it —
snaporca-uif9: re-editing a stored Hole/Thread from the feature tree restores
f.plane into the dropdown but never clears m_hole_on_face, so the re-edit
silently reuses the PREVIOUS card's latched face. Now visible by name instead
of invisible.

Fork parity unchanged: DesignPanel.cpp 30, DesignPanel.hpp 0.
2026-08-03 15:06:17 +02:00
Tommaso Bianchi 93395b7888 Design: escalate on the entity that was picked, and let the chip follow the window
Both found by Kimi reviewing the previous two commits, both then reproduced here
before being touched.

snaporca-97z. The re-pick escalation required m_solid_sel, m_sel_body, m_sel_face AND
m_sel_edge to all match the previous pick. That looked stricter and was wrong: the
edge branch sets only m_sel_edge and m_solid_sel, leaving m_sel_face as whichever face
the ray happened to enter through — and a shared edge is entered through a different
face depending on which side you view it from. So picking an edge and picking that
same edge again from the other side compared equal edges, unequal faces, and refused
the escalation the status line had just promised. Now the comparison is made at the
level that was picked and nothing else. The edge id is already the stable global one
from edge_index_of, so it identifies the edge without help from the face.

Reproduced on the rig without needing to orbit, since two clicks 8px apart across an
edge enter through different faces:

  pick -> sel=3 body=0 face=5 edge=3
  ray  -> body=0 face=0
  re-pick -> escalated to whole body 0
  pick -> sel=1 body=0 face=-1 edge=-1

Same edge, face 5 then face 0, escalation fires. The old condition could not.

The frame-move case. The chip is anchored at an absolute screen position, and until now
nothing told it the window had moved — only a resize, a status change or a tab switch
re-placed it. Dragging the window by its title bar left it stranded where it was,
verified on the rig by moving the frame and watching it stay put. wxEVT_MOVE on the
top-level frame, alongside the ICONIZE and ACTIVATE binds from the previous commit.

Two related cases are filed rather than bound, because the list of window-geometry
events to chase is exactly what snaporca-lcq argues should stop: a layout change that
translates the canvas without resizing it, and wxEVT_DPI_CHANGED.

Not fixed, deliberately, and recorded on snaporca-97z: clicking the same FACE but
landing within the vertex or edge tolerance resolves to a different kind and so does
not escalate — that is the "smallest thing under the cursor" rule working as
documented; and a vertex re-pick after moving the body compares stale world
coordinates.

Verified on both rigs. The cross-face edge case was exercised on orca_cad;
DesignSketchTool.cpp is byte-identical across the forks, so snaporca inherits it, and
its face-level escalation and empty-click clear were re-checked there directly.

Fork parity unchanged: DesignSketchTool.cpp 0, DesignCanvas.cpp 16.
2026-08-03 14:17:57 +02:00
Tommaso Bianchi 6d1a4078ca Design: the status chip goes away with the window, not just with the page
Found by minimising the app on the rig with a face selected: the whole screen goes
black and the chip is still drawn on the bare desktop. A wxPopupWindow is
override-redirect — the window manager does not own it — so it neither iconises with
its frame nor stacks behind other applications. IsShownOnScreen does not catch this
either: an iconised frame still counts as shown, which is why the guard added for the
tab case sails straight past it.

So the frame has to say so itself: ICONIZE and ACTIVATE, both routed through the same
show_status_hud the page change already uses. Restoring is safe — a popup cannot take
focus, so our own Show() cannot re-trigger either event — and restoring while some
other page is up still leaves the chip down, because show_status_hud(true) goes
through place_status_hud's IsShownOnScreen guard.

Verified on both rigs: chip up, minimise -> screen black and empty, restore -> chip
back with its text and the face still selected. Restore while on Prepare -> chip stays
down.

This is the fourth defect from the same root, so snaporca-lcq now asks the question
these binds keep deferring: whether the line should be canvas content, like the view
cube and the round view buttons, rather than a window that has to be told about every
way a window can stop being visible.

Fork parity unchanged: DesignCanvas.cpp 16.
2026-08-03 13:36:09 +02:00
Tommaso Bianchi 9c3ce9b45e Design: clicking empty space lets go of the selection, and the status line follows its tab
Two things a click on nothing should already have done.

snaporca-od0. A click that hit no geometry left the solid selection standing. A
rubber band swept over empty space has always cleared it (pick_bodies_in_rectangle),
and the two gestures cannot disagree about the same outcome. The visible cost was in
the escalation that landed last commit: "click the face, click away, click the face
again" arrived as the SECOND click on the same face and took the whole body, when the
click away was the user letting go of it. Now the miss clears and says so.

This gives up something real, deliberately: Thicken / Shell / Draft hold their input
face in the panel's selection, so a stray click on empty canvas with one of those
cards open hands that face back. Their handlers already write the "(pick a solid
face)" placeholder and rebuild the ghost when the selection empties, so the card SAYS
it lost the pick rather than confirming against a face the viewport has stopped
highlighting. An orbit drag never reaches this branch — it exits at the 8px budget —
so panning the view still does not deselect.

snaporca-dlj. The status line is a wxPopupWindow, which is a TOP-LEVEL window: hiding
the Design page does not hide it. Select a face, switch to Prepare, and the chip was
still there reading "selected (whole body) — right-click for what applies to it" on a
tab with no such selection and no such menu. Same cause, second symptom: a status
update arriving while the page is hidden anchored against a client size that is not
the size the page will have, and parked the chip on the tab bar. So: an
IsShownOnScreen guard in place_status_hud, show_status_hud(bool) to take it down and
bring it back with its text intact, driven from the page-changed handler.

Verified on both rigs, not by reasoning about it: face 5 selected -> click bed ->
"Nothing selected", tint gone -> click the same face -> face 5 again, NOT the body ->
click it again with no click away -> whole body, so snaporca-gem is intact. Prepare ->
chip gone; back to Design -> chip returns. KEYTRACE across the round trip shows
shift+S then R still reaching the canvas (ui_mode 0 -> 1, Rectangle armed), which is
the focus theft this popup replaced a wxFrame to avoid.

Fork parity unchanged: DesignPanel.cpp 30, DesignCanvas.cpp 16, headers and
DesignSketchTool.cpp 0. MainFrame.cpp is outside that set and was edited per fork.
2026-08-03 11:05:45 +02:00
Tommaso Bianchi c32aa3f8ba Design: clicking the same face twice takes the body, and the status line moves onto the viewport
A click could point at a face, an edge or a vertex, but never at the body those
belong to: offer_selection_kind() can only return BodySolid when all three are
clear, which a viewport click never produces. The rubber band was the only door,
and the status line said "face 5 selected" while the user believed they had taken
the body. A second click on the SAME sub-element now escalates to it (snaporca-gem).

Not the pick cycle that was removed in bc2b741ce9 -- that one was silent and three
deep, so no click had a predictable meaning. Here the status line names the next
click before you make it, and a further click just takes the face under the cursor
again, which needs no teaching. Double-click is untouched: wx sends Down/Up/DClick/Up
and only the first Up carries a pending press, so a fast double-click still zooms to
fit and picks once.

The status line itself moved to the base of the viewport. In the side panel it was
clipped at ~73 characters with no warning and no wrap -- set_status()'s Wrap() never
took effect (snaporca-8cc) -- which silently length-limited every hint in the tab; the
first version of this change lost a clause to it. m_status is kept, hidden, as the
owner of the text and its colour, and the line is drawn in a bottom-left twin of the
readout HUD where there is a whole window's width.

Three defects found driving it on the rig, none of which the build could see:

  * the HUD as a wxFrame took the WM's keyboard focus every time it was raised, and
    the canvas then received NO key events -- every sketch shortcut silently dead.
    Caught with SNAPORCA_KEYTRACE: shift+S logged a line, the following R logged
    nothing. It is a wxPopupWindow now, which cannot be focused. SetFocus() on the
    canvas does not fix it: focus was on another toplevel.
  * zero vertical padding fits the popup tighter than the font's line box and clips
    the glyphs; 6 (what the readout uses) reads as a two-line box. 3 is right.
  * "has a caller chosen a colour?" compared the label's foreground against its
    PARENT's, which differ by default, so every line counted as chosen and the
    neutral text came out the panel's dark grey -- invisible on a dark chip. Compare
    against the colour the label was created with, captured before any caller writes.

Verified on both rigs against fresh binaries: sketch -> extrude -> click face ->
click again -> whole body tinted, offer opens with the body rows live and Create /
Add material correctly greyed. Keyboard drives the whole sequence.

Filed and NOT fixed here: snaporca-od0 -- a bare-plate click does not deselect the
solid, so "click away, click back" escalates. Pre-existing; clearing there would also
drop the face the Thicken/Shell/Draft cards hold, which needs its own pass.

Refs: snaporca-gem, snaporca-8cc, snaporca-od0
2026-08-02 12:12:50 +02:00
Tommaso Bianchi 7e5994b8cb Design: right-click a body row opens the offer, and taking a body always means the same thing
The third door onto the offer, after the viewport right-click and the Menu key. A body ROW is
an unambiguous body, so the offer reports BodySolid and the body verbs act on the row you can
see highlighted — the confirmation a face pick cannot give, since pointing at a face lights the
face and never the body the verb will change. The status line has been promising exactly this
("Body N selected — right-click for what applies to it") since before any handler existed on
that list; the product was advertising a gesture that did nothing.

WHAT THE RIG CAUGHT THAT THE BUILD DID NOT. The first version hung the state normalisation off
wxEVT_TREE_SEL_CHANGED. But SelectItem() on a row that is ALREADY selected fires no selection
event, so a stale vertex from an earlier viewport pick survived — and offer_selection_kind()
tests vertex FIRST, so right-clicking the body row served the VERTEX offer while the row sat
highlighted: Fillet/chamfer/draft greyed, Mirror standing where Repeat belongs, "vertex
selected" still in the status line and the cyan marker still on screen. The happy path (fresh
row, nothing else picked) looked perfect, which is why only the deliberate stale-state sequence
exposed it. Reading the code would not have shown it — SelectItem looks like it selects.

So the normalisation is no longer a selection handler. apply_body_row() is called
UNCONDITIONALLY by both doors, because taking a body from the list means the same state change
however it was asked for. It also clears m_sel_solid_vertex, which the original handler never
did — latent while nothing opened the offer from that list, and immediately fatal once
something did.

Verified on both rigs with the failing sequence itself: pick a vertex, then right-click the
already-selected row. Fillet/chamfer/draft enabled, Repeat back in place, status reads "Body 1
selected", vertex marker gone.

Does NOT touch the feature tree. That needs new selection kinds (offer_selection_kind has no
notion of "a feature is selected") plus verbs the atlas does not contain — Suppress, Rename,
Reorder, Roll back — and is filed separately.
2026-08-02 10:31:21 +02:00
Tommaso Bianchi 6c59898ac0 Design: pointing at part of a body is pointing at the body
Tommaso: "i deleted a body using rubber band selection, but this is not intuitive as all
the ux revolves around clicking". Correct on both counts, and a correction to what I said
last round: the rubber band IS implemented and shipping (pick_bodies_in_rectangle, m_rubber,
the drag branch in on_mouse). What is unbound is whole-body picking via CLICK; I read the
comment about the click path and wrongly generalised it to the gesture as a whole.

The handlers were never the problem either. Move, Mirror, Cut, Mass and Colour all resolve
their target through selected_body_default() / m_sel_solid_body, and that is already set when
you click a FACE — level >= 1 records the body. They would have worked from a click all
along. The only thing keeping them out was the atlas gate: accepts listed body_solid and no
face kind, so offer_selection_kind() returning FacePlanar filtered the rows away. This is
therefore an atlas-only change, no handler edits.

Cut, Split, Mirror, Transform, Mass and Colour now accept face/edge/vertex as well, matching
what Delete Body already did. Edges and vertices are included deliberately, not just faces: a
click resolves to a vertex, an edge or a face depending on where inside the pixel it lands,
so accepting only faces would make Move vanish whenever you clicked near a corner — a flicker
that reads as a bug and gets reported as "sometimes it works".

NOT widened: Extrude on a face means push/pull THAT face, and Thicken consumes the face you
point at. Both have genuine face-specific meaning, so widening them would change what they
do rather than where they can be reached from.

The rubber band keeps its job — it is still the only way to take a body without also naming
one of its faces. It just stops being the only door.

Verified on both rigs from a plain face click: Transform > Move opens with Body = Extrude2
(resolved from the face pick), Modify > Edit / Delete Face / Colour / Delete Body, and
Reference > Mass.
2026-08-02 09:57:53 +02:00
Tommaso Bianchi b2654ebd8a Design: a body knows what made it, so "Delete Body" can exist
Reported by Tommaso: select a body, and there is no Delete in the offer. Two independent
faults stacked behind that.

FIRST, clicking a body never selects the body. Whole-body picking is deliberately unbound
(DesignSketchTool.cpp) pending the rubber band, so a viewport click only ever yields
Face/Edge/Vertex. The offer therefore saw face_planar, and "delete" accepted body_solid but
no face kind, so the row was filtered out entirely — while the status line read "Body 1
face 0 selected", which actively teaches the wrong model.

SECOND, even selecting the body from the Bodies list, Delete refused in red: "Select the
FEATURE that created this body". CadBody had no link back to its maker, so the offer was
advertising a verb it could not perform — worse than the action:null rows fixed earlier this
session, because this one is ENABLED and its refusal reads like user error.

CadBody::source_feature fixes the second. It is stamped in ONE place, the recompute loop,
and the rule is just "still unset?". That is sufficient because of an invariant worth
stating: no feature ever replaces a whole CadBody. Every in-place op writes only .shape
(boolean, cut, mirror-fuse, transform, dress-up — all 8 sites checked), so a body keeps the
stamp it was born with; a consumed body is erased outright, taking its stamp with it; and
the only bodies still at -1 are the ones the current feature just pushed. A feature type
added later needs no change here as long as it keeps to that invariant.

"Delete Body" fixes the first, sitting beside "Delete Face" in Modify and reachable by
pointing at any face/edge/vertex. The two names cannot be confused, and "delete" gave up the
body kinds so both can never appear for one selection. Deleting a body removes the feature
that made it, which is a real edit to the recipe, so it asks first and NAMES the feature — a
body vanishing from the viewport is not evidence of which feature went, and this is the one
action here that cannot be eyeballed.

Multi-body delete is NOT offered. bodies_2 was in the first draft of the verb; the handler
deletes exactly one body, so a two-body selection would have silently deleted whichever was
m_sel_solid_body. Caught before it reached a binary, at the cost of one rebuild.

Verified on BOTH rigs, full round trip: click a face -> Modify > Delete Body -> "Delete
Extrude2?" -> body gone, Sketch1 correctly left behind, panel falls back to the idle hint ->
Undo -> Extrude2 and Body 1 restored.
2026-08-02 09:41:58 +02:00
Tommaso Bianchi 34eb4224a1 Design: fix a wrong issue ref in the Thicken comment
The previous commit cites snaporca-y7q, which does not exist — I wrote the ID from memory
instead of reading it back from the bug I had just filed. The real one is snaporca-kgx,
"Offer: Thicken (and peers) open with the picked face discarded". The comment is corrected
here; the commit message above it cannot be, so this note is the pointer.
2026-08-02 09:03:49 +02:00
Tommaso Bianchi 9d47280a19 Design: a card opened from a face must use, and show, that face
snaporca-y7q. Thicken's opener cleared m_sel_solid_face outright. That was right when the
only door was a toolbar button — a button carries no selection, so pressing Thicken had to
clear and ask you to point at something. The offer inverted it: the verb is now invoked ON
a face, and the same line threw away the only thing the user had said. The card opened
reading "(pick a solid face)" over an immediate "thicken: face not found" — you pointed at
the face and were told none could be found.

Keep the pick when the body combo landed on the body it came from (the index is per-body,
and selected_body_default() returns exactly that body when it is valid).

Two neighbours had the mirror-image flaw, both invisible for the same reason — the value
was right and the ghost updated, so only the label lied:
  - Thicken had NO live label update at all. Nothing outside the opener ever wrote
    m_thicken_face_label, so while the card was open you could pick face after face and it
    still read "(pick a solid face)".
  - Shell and Draft wrote theirs ONLY from the pick handler, which runs while a card is
    already open — so opened from a selection they showed the previous pick, or the
    placeholder over a face they were about to use.

So the label is now written once in open_tool(), which every door goes through. The
edit-feature path already restores m_sel_solid_face from the stored feature BEFORE calling
open_tool, so it agrees rather than fights.

Verified on the snaporca rig: face 4 of an extruded plate, offer > Add material > Thicken
now opens "Face: Face 4" with "Preview — 24 triangles" and confirms to a real Body 2. Draft
opened from a face shows "Face 3" and previews the taper. This fork is code-identical here
bar the two permitted DropDown divergences; it still owes a build of its own (snaporca-5pl).

Project keeps its clear: there "(all edges)" is a legitimate default mode rather than a
failure, so changing it would alter behaviour with no reported problem behind it.
2026-08-02 09:02:52 +02:00
Tommaso Bianchi cfc2555c3a Design: a verb's address is data, so the toolbar widget can stop existing
snaporca-7ih's remaining half. Both flyout factories registered their verbs INSIDE the
widget-building loop, so the ~40 retired tool buttons had to be constructed and then
Hide()n: skipping construction would have deleted 42 offer verbs (26 fly:<family>#<row>
+ 16 Shift+keys) while their rows still rendered and did nothing when picked.

Register first, build second. The addresses are pure data; the widget is one door onto
them, not their owner. A family absent from kBarKeep now returns before any wxWindow is
made. The keep-list stays a one-line data decision, not a structural one.

And close the class of bug for good: the constructor now verifies, once, that every verb
the atlas marks wired resolves to a real registration, logging each break and asserting in
debug. Rows that render and do nothing have shipped three times (edit_feature and sk_move
with action:null, then this) and are invisible from either side alone.

Verified on the snaporca rig by walking the offer, not by reading the code — all four
at-risk address kinds run with no widget behind them: fly:design_rect#2 drew an OBLIQUE
rectangle (the third variant, not the family's first), key:S+E opened Extrude with its
10 mm gizmo, fly:material#4 opened Thicken. Hover hints, icons and nesting intact. This
fork is code-identical here bar the two permitted DropDown divergences; it still owes a
build of its own (snaporca-5pl).

Two hints were wrong and are fixed: Cut said "Split the body with a plane", colliding with
the Split verb one row away and pointing at a card for a value the canvas already offers as
a draggable arrow; Split never said its plane comes from a picked face.

Also, because it blocked the verification and will block the next one: gui-session.sh
killed by full path while its own app_pid() matched by basename, so a differently-pathed
instance survived, held the single-instance lock, and got reported as a healthy session —
a Jul-30 binary nearly passed as this build. It now kills by basename and prints which
binary is actually on screen. Traps 6 and 7 documented.
2026-08-02 08:37:44 +02:00
Tommaso BianchiandClaude Opus 5 96816f725c Design: every offer verb has a hint, shown on hover — and the status line wraps
Mirror of snaporca 2b3e890165 (DesignPanel.cpp applied as a patch; parity 30 / 16, shared
files byte-identical).

All 86 verbs now carry a hint: 55 extracted from the C++ tool definitions so the offer and
the armed-tool hint cannot drift, 31 written by hand. One wxEVT_MENU_HIGHLIGHT binding
shows the hovered verb's hint in the status line. The generator asserts that no wired verb
lacks one.

Also: all 200 status writes go through set_status(), which wraps instead of clipping at the
panel edge; and the empty-document hint is called from on_tab_shown() as well, since
after_tree_edit() never runs on a freshly opened tab.

Verified on the rig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 19:46:49 +02:00
Tommaso BianchiandClaude Opus 5 3037e55f44 Design: hints name the gesture that works, and an empty document says how to start
Mirror of snaporca 033347d062 (parity 30 / 16).

Retiring the toolbar made ten hints untrue: each named an action whose door had moved to
the offer, or a button no longer on the bar. They now name the gesture. An empty document
blanked the status line entirely and now says how to start.

Known and not fixed here: m_status does not wrap, so long hints clip; and the 86 offer
verbs still have no per-verb hint of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 19:31:33 +02:00
Tommaso BianchiandClaude Opus 5 811719b7aa Design: Construction goes back on the sketch bar — a mode must show its state
Mirror of snaporca b3d4cf85af (parity 30 / 16).

Hiding it with the drawing tools was wrong: Construction is a persistent MODE, not a tool —
the Bed checkbox, not the Line button. Q and the offer's Construction row kept toggling a
checkbox nobody could see, so you could not tell whether the next line would be construction
geometry.

Scoping unchanged and already correct: m_tb_sketch is shown only in UiMode::Sketch, so it
appears exactly while a sketch is open or being edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 18:36:18 +02:00
Tommaso BianchiandClaude Opus 5 86f1f96c50 Design: the toolbar is chrome — every tool is reached from the offer
Mirror of snaporca 809aa9df87 (DesignPanel.cpp applied as a patch; parity 30 / 16, shared
files byte-identical).

fadd() and sadd() now gate what reaches the bar: file operations, Bed, Undo/Redo, Delete
selected, Commit to Plate, Confirm/Cancel, plus Place on Face and Section view — the last
two because they are chrome_only in the atlas and have no offer row to fall back on.

The tool buttons are still built and then hidden, deliberately: their fly: addresses and
Shift+key bindings are registered inside the widget-building loops, so not building them
would silently drop 42 verbs from the offer while they still rendered. snaporca-7ih covers
hoisting the registrations so the construction can go too.

Four separators whose groups are now empty were dropped; they rendered as stray rules.

Verified on the rig in both modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 18:17:34 +02:00
Tommaso BianchiandClaude Opus 5 a535b0cb76 Design: the offer draws each verb's icon — set the bitmap BEFORE Append, not after
Mirror of snaporca bcab67f8ce (DesignPanel.cpp applied as a patch; parity 30 / 16, shared
files byte-identical).

tool_atlas.json now names an icon for 80 of 86 verbs, derived from the toolbar's own
definitions rather than invented, and every one of the 54 distinct names was checked to
exist in resources/images first.

The first attempt drew nothing despite a green build: wxGTK builds the GtkMenuItem inside
Append() and reads GetBitmap() there, so setting the bitmap on the returned item is a
silent no-op. append_offer_item() constructs, sets, then appends — the same order Orca's
own append_menu_item() uses.

Verified on the rig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 17:55:52 +02:00
Tommaso BianchiandClaude Opus 5 bc4fb3b680 Design: Text and SVG draw INTO the open sketch instead of beside it
Mirror of snaporca 1fb7786d9a (DesignPanel.cpp and DesignCanvas.cpp applied as patches;
parity 30 / 16, shared files byte-identical).

With a sketch open, Text/SVG outlines become ordinary Line entities via push_closed_lines()
instead of a separate Sketch feature carrying rigid imported_regions — so the letters can
be constrained, trimmed and extruded like anything drawn by hand. The buttons and offer
actions arm Select first when in Sketch mode, since begin_sketch() does not run until a
tool is armed.

add_imported_regions() calls reset_autoedit(): without it the glyph contours entered the
draw-then-edit queue and opened a Length field on the first segment, which freezes the
canvas. Caught on the rig, not by reading.

No sketch open: unchanged — a new Sketch feature, still dropped on a picked face.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 17:38:44 +02:00
Tommaso BianchiandClaude Opus 5 447c71a0d2 Design: Text and SVG join Create — they were excluded on a premise that is not true
Mirror of snaporca 6724ea27c5 (DesignPanel.cpp applied as a patch; parity 30, shared files
byte-identical).

chrome_only's rule is "acts on the DOCUMENT, not on a selection". Text and SVG both call
add_imported_sketch(), which drops the art on a picked solid face via
SketchPlane::from_face() — a selection-consuming profile creator, like Sketch. Now
sk_text / sk_svg in the sketch half's Create row, where their toolbar buttons already sit.

Verified on the rig: Create ends Point, Text, SVG.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 17:14:13 +02:00
Tommaso BianchiandClaude Opus 5 d5c5d5675e Design: a body tool acts on the body you picked, not on the first one
Mirror of snaporca e5e223a794 (DesignPanel.cpp applied as a patch; parity 30, hpp
byte-identical).

Every body combo opened on index 0, so picking a body and pressing Mirror acted on a
different solid while the card showed that other body as the target. Nine sites now read
the viewport selection; Boolean takes the picked body as target and a different one as
tool, since defaulting both to the same body is a no-op.

Verified functionally on the rig: picked the 20x20 body, mirrored, and the new body
measures 20x20 — not the 80x50 one it would have used before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 12:15:46 +02:00
Tommaso BianchiandClaude Opus 5 273cf067e8 Design: record that Shell stays in Remove — decided, not overlooked
Mirror of snaporca e2ef3018cd. Data only, and only the `why` prose in two slots — the
generated DesignOffer.hpp is byte-identical, so there is nothing to rebuild.

Ratified 2026-08-01: the offer deliberately splits the toolbar's dressup family. Shell
hollows a solid so it sits in Remove; Delete Face edits an existing solid so it sits in
Modify. Recorded in both slots' `why` so either half explains the split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 10:01:34 +02:00
Tommaso BianchiandClaude Opus 5 f6e6cd83c1 Design: the fillet row is named for the tools it actually holds
Mirror of snaporca 352cf1c259 (data only — tool_atlas.json + the regenerated
DesignOffer.hpp; both byte-identical across the forks).

"Fillet / chamfer / draft" rather than naming shell too: Shell is in Remove and Delete
Face in Modify. Only the toolbar's dressup dropdown groups all five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 09:08:21 +02:00
Tommaso BianchiandClaude Opus 5 0c2b643b0b Design: the card says which tool it is, and "Dress-up" stops being a word we use
Mirror of snaporca 3677937964 (DesignPanel.cpp applied as a patch; parity 30, shared
files byte-identical).

The card header read "Fillet 1" over a chamfer because the offer's Chamfer address opened
the tool before setting the type, and open_tool() titles the card from that combo. Choose
first, then open.

"Dress-up" removed from the offer row (-> "Fillet / chamfer"), the card field (-> "Type",
it was a label reading Dress-up whose value said Chamfer) and the toolbar tooltip.

Verified on the rig: header "Chamfer 1", field "Type: Chamfer", row "Fillet / chamfer".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 09:00:23 +02:00
Tommaso BianchiandClaude Opus 5 3c998b8a62 Design: a tool's options come from the tool, not from a card on the left
Mirror of snaporca 6d5510734b (DesignPanel.cpp applied as a patch; parity re-checked at
30 lines, shared files byte-identical).

Polygon's Sides/Circumscribed card is deleted — the choice is made in Create > Polygon,
which names the counts and the two fits, because the side count cannot be recovered after
drawing. Dress-up, Combine and Pattern were single verbs hiding several behind a combo
and now name each one in the offer. Fixes fillet and chamfer both carrying key:S+F, which
made the offer's Chamfer open a Fillet.

Built green and verified on the rig: the Dress-up card opened from Chamfer reads Chamfer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 08:48:05 +02:00
Tommaso BianchiandClaude Opus 5 d1d61ce997 Design: every sketch tool has an address in the offer, not just its family
Mirror of snaporca 0c83f59f13 (DesignPanel.cpp applied as a patch, not copied, so this
fork's 30 permitted divergent lines survive; parity re-checked at 30/16 with the shared
files byte-identical).

The sketch dropdown never registered "fly:<family>#<row>" addresses the way
feat_dropdown does for model verbs, so the offer could name a family but only ever arm
its first tool — Rectangle always gave a corner rectangle. Adds the registration, 14
atlas verbs (including the entire array family, which was absent, and rotate/scale), an
action for the sk_move row that previously did nothing when picked, and a second submenu
level so variants nest under their family instead of flattening 19 create tools.

Built green and verified on the rig: Oblique rectangle arms oblique, not corner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 08:20:47 +02:00
Tommaso BianchiandClaude Opus 5 fd1bc092d8 Design: slot Radius caption, keyboard offer, plane combo removal, mass props, docs
Mirror of snaporca ac85277bac..0e7cb3ec78 (six changes, applied as a patch to
DesignPanel.cpp rather than copied, so this fork's 30 permitted divergent lines survive
— parity re-checked afterwards: the five shared files are byte-identical, DesignCanvas.cpp
and DesignPanel.cpp differ by exactly 16 and 30 lines).

- The straight slot's inline field says Radius, which is what it sets. It stores the
  half-width and passed the typed number through unchanged, so 30 produced a 60 mm slot.
- The offer opens from the keyboard (Menu, Shift+F10), anchored on the viewport rather
  than wherever the pointer happens to be. The card hint names the new route.
- The sketch card's Plane combo is gone; the plane comes from the viewport. Also stops
  build_candidate collapsing a face plane to a base plane while editing.
- Mass properties and the dead Edit row are wired into the offer; DesignOffer.hpp is
  regenerated from tool_atlas.json, verified by re-running the generator and diffing.
- docs/rig_build_traps.md + scripts/rig-build.sh, which derives its fork identity from
  project() so it cannot be pointed at the other fork's image or volume.
- docs/design_tab.md refreshed (44 commits stale) + a PR description, with this fork's
  own merge-base and diff shape rather than snaporca's.

Built green in the deps container with the new script and verified on the rig: Menu and
Shift+F10 both open the offer at the viewport centre with the pointer parked off-canvas,
and the sketch card shows no Plane row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 06:21:08 +02:00
Tommaso BianchiandClaude Opus 5 6e9910303b Design: a sketch takes its floating chrome with it when it ends
Confirming a sketch while an inline value field was open left the field behind. The
editor is a top-level frame, so it survived the session that owned it, and inline_busy
stayed set with it — on_mouse_impl then returned true at its first branch for every
later click and the viewport was simply dead. No refusal, no message: exactly the
"click the geometry, nothing happens" the pick bugs above it were mistaken for.

finish() and cancel() now call close_session_chrome(): dismiss the open field
(keep-as-drawn, the same contract the polyline terminators already use), drop the
queue of fields behind it, and clear the corner readout — which had the same defect
for the same reason, sitting on 336.8° over a committed sketch because nothing redraws
the HUD once the tool stops.

Verified on the rig on the exact reported sequence: line on XZ, Return to accept the
length, Confirm with the Angle field still open. The field goes, the sketch commits,
and the next click reaches the pick (pick trace shows down/up consumed) and selects
Sketch1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 02:35:29 +02:00
Tommaso BianchiandClaude Opus 5 1addba6ea0 Design: editing a quote UPDATES its dimension instead of appending a rival to it
Type a new length into a sketch quote and the number changed while the geometry
sat still, with the solver dropping to "Conflicting constraints" — a broken
constraint state the user never asked for, arrived at by doing the one thing the
status line invited.

An experiment separated the two candidate causes. Same gesture, one variable:
a line committed WITH a driving length refused the edit and conflicted; a line
committed with none (Esc keeps it as drawn) accepted it and visibly shrank. So
the value was never the problem and the solver was not wrong — it was being
handed two contradicting constraints and correctly declining to choose.

The quote-edit path appended unconditionally:

    a.con = int(m_constraints.size());
    m_constraints.push_back(constraint_for(a));

Accepting the length at draw time creates Distance(P0,P1) = 64.9. Clicking the
quote later creates a SECOND Distance on the same two points asking for 30. Over
-constrained by construction. A line with no dimension yet only ever gets one
constraint, which is exactly why this looked intermittent rather than total.

upsert_constraint() finds an existing constraint with the same type and operands,
overwrites its value and returns its index; it appends only when there is none.
Operand order is ignored — a Distance from A to B is the same constraint as B to
A, and so is an Angle. Returning the index matters as much as the update: it
keeps the annotation's `con` pointing at the constraint that is actually live, so
the NEXT edit is an update too rather than reverting to appending after one good
round. upsert_dimension() applies the same rule to the visible quote, which had
been stacking labels reading different values on the same pixel, and keeps the
existing label position so a placed quote does not teleport.

set_dimension_value already did the right thing through a.con. The machinery
existed; these two call sites never consulted it.

Verified on :11 on the exact failing case — draw a line, Return to lock the
length, Confirm, double-click to re-open, click the line, type 30 into the quote:
the line shrinks, the quote reads 30.0 mm, one label not two, and the solver
stays at "3 degrees of freedom" with no conflict.

NOT included: record_dimension_constraint() has the same unconditional push_back
in all six of its branches. It belongs to the legacy Constrain mode with
different selection semantics and I could not exercise it, so it is flagged
rather than changed blind.

Refs snaporca-e1p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 21:35:41 +02:00
Tommaso BianchiandClaude Opus 5 a1fdb9f217 Design: double-click a sketch stroke to edit it — the gesture belongs on the geometry
Selecting a committed sketch line lit the right tree row and then told the user
to go and press Edit in the panel. That is the side-panel dependency this tab
exists to remove, and it made "selectable" true while "editable from the
geometry" stayed false.

on_edit_feature already does the whole job — re-open the entities in the sketch
UI with handles and live quotes — and was only ever reachable from a tree row.
A double-click on a committed stroke now calls it for that feature. Double-click
on empty space still fits the view, so nothing is taken away.

The stroke hit test is now one hit_display_sketch() shared by the click and the
double-click. Two copies of "what is under the pointer" drift, and a double-click
acting on a different entity than the click before it is a miserable thing to
chase. It also reports the entity index, which the tracer prints, so a pick that
lands on the wrong stroke can be seen rather than inferred.

Verified on :11 end to end: draw an open line, commit, double-click it. The
tracer prints "double-click -> edit sketch feature 0 (entity 0)", the panel
reads "Editing sketch — drag a handle or click a quote to edit", and a click
inside the session selects the line with endpoint handles, live quotes and
"1 selected — Delete removes them".

NOT delivered by this commit, found while verifying it: typing a new value into
a length quote is accepted and displayed but the geometry does not move and the
solver drops to "Conflicting constraints". Filed separately — it lives in the
constraint layer, not in selection, and nothing here touches it.

Refs snaporca-e1p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 21:10:08 +02:00
Tommaso BianchiandClaude Opus 5 c5404507c7 Design: an open sketch line can be clicked — region membership is not a licence to be pointed at
Reported from the rig: a committed sketch holding a single open line rendered on
the plate and could not be selected, so it could not be edited or deleted from
the viewport at all.

The viewport pick for committed sketches iterated region_loops(). That function
exists to find EXTRUDABLE regions and, as its own walk comment says, an open
chain "stalls" and is discarded. So for a sketch of open entities it returns an
empty list, the pick loop has nothing to iterate, and every click falls through
to bare plate. Not a tolerance problem and not a focus problem: there was no
candidate geometry to test against.

Whether a stroke bounds a closed region has nothing to do with whether the user
can point at it. The stroke test now covers every non-construction entity, and a
separate entity->region map preserves what a hit REPORTS, so a click inside a
closed loop still names that loop exactly as before. Region membership decides
the report, not whether the hit can happen.

The rest of the path was already written and simply unreachable: the panel's
handler has a region < 0 branch that selects the feature, highlights it in the
tree and says "Sketch selected — Extrude it, or Edit / Delete from the tree".
This makes existing behaviour reachable rather than adding new behaviour.

Verified on :11 with the pick tracer (SNAPORCA_PICK_TRACE=1), which is what
distinguished the two failure modes: before, the click reached the handler and
fell through to handle_solid_click ("no solid data"); after, it is consumed by
the display-sketch test and never reaches it, and the panel reads "Sketch
selected" with Sketch1 lit in the tree.

Construction geometry stays unpickable, matching region_loops' own filter. It is
the same class of bug — a construction line cannot be selected to delete it —
but including it risks construction stealing picks from real geometry, so it is
left as a separate decision.

Refs snaporca-e1p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 19:55:56 +02:00
Tommaso BianchiandClaude Opus 5 9f2b2bc511 Design: sketch means a tool — the offer works inside a sketch, and the app stops
contradicting itself about the plane

Reported from the rig: pick a plane, press Sketch, and you are told to pick a
plane. The app prescribed a sequence and then refused to acknowledge that you
had followed it. Right-click did nothing, so there was no way to reach a
drawing tool except the toolbar this tab exists to retire.

act_sketch was two lines: set the mode, then print "Click a face or a reference
plane in the viewport, then a sketch tool" — unconditionally, without ever
asking whether a plane was already chosen. The plane was never lost; m_ref_plane
held it and begin_sketch captures it when the first tool is armed. The sentence
was simply false.

It now asks. sketch_plane_target() is a companion to sketch_plane_from_selection
that distinguishes "the user chose XZ" from "nothing chosen, falling back to XY"
— a distinction m_ref_plane cannot express on its own, being always a valid
index, so m_plane_picked carries it. With a target the readout names it and the
offer opens on the Create row; without one the old prompt stands, because then
it is true. The card above the status line was a local wxStaticText that nothing
could update, so it went on asking for a plane two inches from a line saying the
plane was chosen. It is a member now and the two are written together.

Right-click inside a sketch was excluded wholesale so that it could end a
polyline chain, abandon an anchor, exit a tool. That made every sketch row in
the atlas unreachable. The honest test is not which mode we are in but whether
the tool actually USED this right-click, and only the tool knows: on_mouse now
wraps on_mouse_impl and records that once, for every terminator, instead of
threading a flag through the twenty-odd sites that consume a RightDown. The
canvas read-and-clears it on the matching release.

Underneath all of it was one confusion — MODE versus SESSION — at four sites.
begin_sketch does not run until the first tool is armed, so is_sketching() is
false for exactly the interval between "press Sketch" and "pick a tool", which
is precisely when the drawing tools must be on offer. The keyboard learned this
once already (snaporca-0ud, whose comment states the rule) and I reintroduced it
in offer_selection_kind and again in show_offer_menu, where the offer built from
the FEATURE map and rendered nine rows that all refused the sketch selection.
Both now call sketch_map_applies(), so they cannot drift apart again. The
keyboard keeps its own split: its "sketching" gates undo and delete-last-entity,
which genuinely need a live session.

Verified on :11 against a fresh build. Pick XZ, press Sketch: card reads
"Drawing on XZ", status reads "Sketching on XZ — pick a tool", offer opens with
Create and Reference live and the six rows needing geometry greyed. Right-click
while idle opens the offer. Right-click as a terminator does NOT — the chain
ends, the line lands on XZ, its length field arms at 49.36 mm. That last one is
the regression the blanket exclusion was buying and the reason this shape of fix
was chosen over a mode test.

Refs snaporca-6vs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 19:21:42 +02:00
Tommaso BianchiandClaude Opus 5 bb403b82cf Design: left-drag sweeps a rubber band, and it takes the whole body
The pick cycle died two commits ago and left no viewport route to a whole
body at all: one click resolves vertex, edge or face, double-click is
already zoom-to-fit, and the only way to take a body was the Bodies list —
a geometry-first violation for as long as it stood. The rubber band is that
route.

Left-drag is the gesture, as asked. That button was orbit, so this canvas
now maps the mouse the way every CAD the user already knows does: left
selects, middle orbits, right pans. The change is a single flag on
GLCanvas3D set only by DesignCanvas, so Prepare and Preview keep the mouse
their users learned. Sketch mode inherits the same mapping, which is the
consistent reading — Design is one modality, not two.

Past an 8 px budget a press becomes a sweep, anchored at the ORIGINAL press
point rather than at the frame where the threshold was crossed, so the
first few pixels are not lost. Below the budget it is still a click and the
existing vertex/edge/face pick runs untouched. Sampling is the display
mesh's triangle vertices plus centroids — the same points the ray pick
tests, already in world coordinates — and the body with the most samples
inside wins, because the selection callback downstream carries one body.
Crossing semantics: touching selects. Enclosed-only for left-to-right and
crossing for right-to-left is the fuller CAD convention and is deferred,
not forgotten; with one selectable body it would have bought nothing.

Two defects fixed on the way, both found by exercising this:

Right-drag pans, and every pan ended by popping the offer over wherever the
camera stopped — the context menu arriving as the reward for moving the
view. The offer is now the release of a STATIONARY right-click, at the same
8 px budget the pick uses.

The selection handler wrote m_status twice. Only the later write ever
reached the screen, so the earlier block had been dead since it was
written, and its labels drifted out of step with the live ones unnoticed —
including a vertex fix I made this morning in the branch that never
renders. Deleted, with a note saying why, rather than left as two writers
for the next person to pick the wrong one.

Verified on :11 against a fresh build: click takes face 5; left-drag across
the body reports "selected (whole body)" with the whole solid tinted and
the camera unmoved; left-drag over empty space clears; stationary
right-click opens the offer; right-drag pans with no menu; middle-drag
orbits. Precedence re-checked after the deletion — face at 25 px from the
corner, vertex from 10 px in.

Refs snaporca-9xw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 18:11:59 +02:00
Tommaso Bianchi 33f97d259b Design: vertex picking — a click near a corner takes the corner
Completes the precedence Tommaso asked for: vertex, then edge, then face, all
from ONE click, all decided in screen pixels. Verified on :11 by sweeping into
the right corner of a plate — x=1250 and 1290 report "face 5 selected", x=1308,
1318 and 1323 report "vertex selected" — and the cyan marker lands exactly on
the corner in the render.

The vertex tolerance (11 px) is deliberately LARGER than the edge one (8 px). A
corner lies ON its edges, so equal radii would make vertices unreachable: every
click near one would resolve to the edge underneath. Bigger-wins-first is what
makes the smallest entity actually pickable.

Vertices come from the sampled edge polylines' endpoints rather than a separate
topology walk — every corner of a face is the end of one of its edges, so the
data was already in hand.

The highlight is a camera-facing square scaled by 1/zoom, the same trick the
edge ribbon uses, so it reads as a constant dot at any zoom. This is why vertex
picking did not ship with the previous commit: the Edge render path billboards
a ribbon and degenerates on a two-point input, and a selection you cannot see
is not a selection (L5). Better to add the primitive than to fake the feature.

DesignPanel now distinguishes level 4: a picked corner sets neither face nor
edge, because a corner is not its face, and the offer classifies it as
OfferSel::Vertex — where Plane, Axis and Coord Sys already accept it.

snaporca-9xw (rubber band still open — see the issue).
2026-07-31 12:48:15 +02:00
Tommaso Bianchi b003d20e37 Design: kill the pick cycle — one click selects what is under the cursor
Tommaso, correctly: fix selection before building on it. I had taken the
whole→face→edge click cycle as terrain and hung the tool offer off it, when §10
of the charter already listed that cycle as an L5 violation. An offer can only
ever be as truthful as the selection beneath it, so this is the foundation and
it should have come first.

NOW: one click selects the SMALLEST thing under the pointer — the edge if the
cursor is within tolerance of one, otherwise the face. No repeat clicks, no
state, no memory of what was picked before. Verified by sweeping a column of
single clicks down a plate on :11: y=800..915 all report "face 5 selected", and
y=925/935 — within a few pixels of the front edge — report "edge 3 selected".
One gesture, one deterministic result, which is what L5 asks for.

TOLERANCE IS IN SCREEN PIXELS. The old edge step compared a ray-to-segment
distance in millimetres, so the same gesture meant different things at
different zoom levels. The pointer is a screen object; its tolerance has to be
one too. 8 px, measured against the edge polyline projected through the camera.

WHAT IS NOT HERE, AND WHY IT IS NOT FAKED. Whole-body selection has no viewport
gesture in this commit. Double-click is ALREADY zoom-to-fit, bound earlier in
the same on_mouse, and this pick runs on LeftUp where LeftDClick() can never be
true — so a double-click branch here would have been dead code that reads like
a working feature. I wrote one, found it unreachable, and deleted it rather
than leave it. The body gesture is the rubber band, which is its own piece of
work; until it lands bodies are selected from the Bodies list, and the hole is
named in a comment at the site instead of being left for someone to trip over.

Six status strings that promised the cycle ("click again for a face", "click
again for an edge", "click again to reset") are gone — they described a
behaviour that no longer exists, and a hint that lies is worse than none.

Both forks build. Parity: DesignSketchTool.cpp byte-identical, DesignPanel.cpp
30 divergent lines — the invariant exactly.

snaporca-6vs.
2026-07-31 12:37:21 +02:00
Tommaso Bianchi 7114e316ea Design: the offer ships — right-click the geometry, get what applies to it
Row order is RATIFIED (charter 4.1, 2026-07-31) and this is the first working
implementation of it: right-click in the Design viewport and a vertical list
opens at the pointer with the eight families in their fixed order, the verbs
that apply live, and the ones that do not disabled IN PLACE carrying their
reason.

THE MAP EXISTS ONCE. DesignOffer.hpp is GENERATED from docs/ux/tool_atlas.json
by docs/ux/mockups/gen_offer_table.py — the same file the 113 mockups are drawn
from. A drawing and the product therefore cannot drift apart, which is the only
way row constancy survives contact with a codebase. Never hand-edit the header.

NOTHING IS RE-IMPLEMENTED. Each row routes to the code that already runs that
verb: "key:S+E" through m_keys_feature, "key:L" through m_keys_sketch,
"fly:material#4" through the feature flyout's own action, "btn:colour" through
the standalone button. The offer is a second door onto the same room, so the
toolbar, the shortcuts and the menu cannot drift into three behaviours. The 8
verbs with kernel support but no GUI path show disabled, which is honest and
matches section 10 of the charter.

Right-click only fires the offer when the canvas is IDLE. Right-click already
ends a polyline chain and finishes the move gizmo; taking those over would
break two working interactions to add a third.

Two things the running build corrected, both found by looking at screenshots:

- THE REASON MUST BE TRUE FOR WHAT IS IN FRONT OF THE USER. Taking the first
  refusal in a family printed "Transform needs a body — add or import one
  first" on a document that HAS a body, because the real obstacle was that
  nothing was selected. Now the reason comes from a verb that accepts the
  current selection and fails only on document state; if no verb in the family
  accepts this selection at all, it says "select something first" or says
  nothing. A menu whose whole value is telling the truth cannot ship a lie.
- Classification follows the level the pick cycle has REACHED, not the face the
  ray happened to hit, so the header cannot name a face while the whole body is
  lit. Sketching on the face you merely clicked is untouched — that path is
  sketch_plane_from_selection (snaporca-3a2).

Verified on :11 end to end: nothing selected shows Sketch live with Shift+S and
seven greyed rows each explaining itself; a selected solid shows Move directly
with Shift+Y (one applicable verb, so no submenu and no extra click) and five
families as submenus. Both forks compile and link.

Fork parity re-checked after the port: DesignPanel.cpp 30 divergent lines,
DesignCanvas.cpp 16, every other CAD file byte-identical — the invariant exactly.

snaporca-96r.
2026-07-31 12:20:07 +02:00
Tommaso Bianchi 00948cf767 docs: the offer is a vertical list, and it opens on every machine
Folds the form-factor decision into the charter. 4.1 is rewritten around
Tommaso's proposal — left-click selects, right-click opens a vertical list of
icon / name / shortcut — and the radial is demoted to a "Rejected" subsection
rather than deleted, because it is a good idea that loses on evidence and
somebody will propose it again. The evidence is recorded with it: mean fill of
3.45 of 8, only two live slots on a fresh document, sketch Create needing nine
addresses on an eight-slot ring, names that do not fit around a circle in
translation, and a 380px disc over the model on a 1366x768 screen.

The invariant survives the change of geometry, which is the useful proof: same
eight families, same fixed order, nothing re-sorted or compacted. It is now
stated as ROW constancy, and the one substantive gain is that unavailable verbs
are disabled IN PLACE carrying their own reason, in strings the product already
ships. An empty ring slot was mute; a greyed row teaches. On a first-run
document the offer stops being a mostly-blank control and becomes a map of what
the product does and what you must do first — which is the section 2 audience
in one picture.

Opening it is now a table rather than an assumption, because "right-click" is
not a universal gesture: two-button mouse right-clicks, trackpads two-finger
tap, a one-button Mac LONG-PRESSES or Ctrl-clicks, touch and pen long-press,
and the keyboard uses the Menu key or Shift+F10. The long-press is explicitly
an ADDITIONAL route — 6.2 forbids press-and-hold as a sole path and that
stands, so the rule now names its own exception and closes it — and it must
show that it is charging, or a user who lets go early concludes the product is
broken (L5).

Consequently: 6.2's keyboard bullet describes opening and walking the offer by
key rather than by compass direction; the gate gains question 13 (every new
pointer gesture declares its keyboard equivalent and what a one-button Mac, a
trackpad and a touch screen do) and question 12 now asks whether tool_atlas.json
was updated and the atlas regenerated; section 10 points at the rendered atlas
and says the outstanding thing is ratifying row order, not drawing the map.

snaporca-96r.
2026-07-31 11:57:53 +02:00
Tommaso Bianchi c4990ee956 docs/ux: draw the offer as a vertical list too, and it wins
Tommaso was not sure about the ring and proposed a vertical list: left-click
selects, right-click exposes icon / name / shortcut. Drawn, it is better, and
the reasons are visible in the renders rather than arguable.

THE DISABLED ROW CAN SPEAK. This is the one that decides it. A ring slot that
does not apply is an empty circle: it says nothing, and on a fresh document six
of the eight are empty. A list row that does not apply is greyed IN PLACE with
its own name and its own reason — "Create a sketch, or pick a solid face,
first", "Create a solid body to pattern first" — which are strings the product
already ships and which tool_atlas.json already carries. The first-run picture
stops being a mostly-empty ring and becomes a map of what the product does and
what you must do first. For the audience section 2 puts first, that is the
whole ballgame.

THE OVERFLOW DISAPPEARS. Sketch Create needs nine addresses; a ring of eight
pushed Polygon and Point behind a "More" slot. Nine rows is just nine rows. The
one measured defect in the ring design is not a defect in this one.

SHORTCUTS READ AS A COLUMN. Right-aligned in a list they stack into something
the eye learns passively, which is exactly the graduation path 4.1 claims —
and it is the mechanism by which the power user Tommaso describes stops opening
the menu at all. Around a ring the same keys are eight loose chips.

Also, unglamorously: long translated names fit, arrow keys and screen readers
work natively where a radial needs special handling, and a 324px box costs the
1366x768 machine far less than a 380px disc over the model.

What the ring keeps: equidistant targets and a future flick gesture. Since the
brief is that power users live on the keyboard, that buys less than it looks.

The invariant is untouched — same eight families, same fixed order, nothing
re-sorted, nothing compacted. Only the geometry changed, which is the point:
the map survived a change of form factor, so it was a real map.

Both forms are now rendered side by side for the same states, and the atlas
opens with the pairs.

snaporca-96r.
2026-07-31 11:50:40 +02:00
Tommaso Bianchi eb2fc986a4 docs/ux: the offer atlas — every tool, every state, drawn
The charter fixed the slot-constancy invariant but carried one hand-written
eight-cell table as an illustration, and nothing of the offer exists in the
product. Before any GUI code, the group needs the map itself: what verbs there
are, what each one needs before it can be offered, and what the ring actually
looks like in every situation a user can put it in.

tool_atlas.json is the source of truth and it was extracted from the code, not
from memory: verbs, shortcuts and the exact refusal strings from DesignPanel's
six feat_dropdown call sites and the sk_key table, kernel coverage checked
against CadFeatureType, headless coverage against McpControl's dispatch, and
the selection kinds taken from the callbacks DesignCanvas actually exposes. It
carries each verb's preconditions, so "why is that slot empty" has an answer
already written in the product's own words. The eventual C++ table generates
from this file too — the map exists once.

gen_offer_mockups.py renders it: 20 selection kinds x 2 document states = 40
primary rings, plus 73 sub-rings, plus comparison sheets. 113 states, none of
them hand-drawn, because a human drawing 113 rings is exactly how an address
quietly changes. Everything is framed at 1366x768, the charter's own reach
target, so L11 is tested in the mockups before it is tested in code.

Three things the drawing found that the prose had not:

- MEAN FILL IS 3.45 OF 8. The empty-slot rule is cheap in argument and
  expensive on screen; on a fresh document exactly two slots are live. That
  picture is the anti-clutter thesis made literal and it is the strongest image
  in the set.
- SUB-RINGS MUST ANCHOR ON THEIR PARENT. Fanning them from north put Extrude at
  N, which is Create's address in the primary map, so the second level
  contradicted the first. Anchored, an address is two consistent strokes: Add
  material is NE and its first verb is NE again.
- ONE FAMILY OVERFLOWS, AND ONLY ONE. Sketch-mode Create needs nine addresses
  on an eight-slot ring. That is the ninth-position pressure the charter
  predicted, arriving on schedule and measured rather than argued: either Point
  moves family, or the tail goes to a third level, or the ring is not eight.
  Every model-mode family fits. The generator refuses to wrap a tenth verb onto
  a first — silent collision is the one outcome worse than an ugly ring — and
  reports the overflow instead.

Also fixed while looking at renders: the selection pill sat on top of the north
slot's shortcut chip and hid it, and the scrim at 0.55 swallowed the very face
the ring had been opened on, which is 4.1 failing inside its own mockup.

Fork-neutral: the generator and everything it emits name no product, so both
forks carry byte-identical copies.

snaporca-2is.
2026-07-31 11:40:45 +02:00
Tommaso Bianchi c3d286070e docs: the offer is a fixed address space, not a context menu
Tommaso's requirement, and it changes what the offer IS: a tool must sit in the
same physical position whatever you selected. Click a face, an edge or a text
and fillet is in fillet's place every time. Position becomes an address the hand
learns, and the eye stops being needed.

That kills the ordering rule this section had two commits ago. "Most-used first
for that kind of selection" is adaptive ordering, and adaptive ordering destroys
the one property that makes a spatial menu fast — worse, it destroys it exactly
for the user who has just started to learn the layout. Office 2000 shipped that
idea and withdrew it. So: NO adaptive ordering, ever, in any form.

The invariant, written to survive every future feature: every tool has exactly
one address; that address is identical in every selection type where the tool
appears; slots for inapplicable tools are left EMPTY rather than compacted; and
adding a tool never re-addresses an existing one. Empty slots are the price of
constancy and they are cheap — a compacted offer is denser and unlearnable, a
sparse one is memorised in a week. An empty slot also answers a question ("this
cannot be done to this thing") that a silently-inert tool does not.

Radial rather than a strip, reversing what I proposed last time and for a reason
that only appears once constancy is the requirement: a direction from the click
point is an absolute address that survives the offer opening anywhere on screen
and survives being clamped at a screen edge, while "third item down" does not.
Centre is a hole so the picked geometry stays visible, and it names what is
selected, so a mis-pick is caught before a verb is chosen.

Every slot carries its keyboard shortcut beside the icon and the word. This is
the graduation path and it is why power users never see a conflict: you reach
for the place, the place says "F", and one day your hand types F before the ring
finishes drawing. The offer is the mechanism by which a beginner stops needing
the offer — one interface at two speeds, no advanced mode in between.

Also here: a proposed eight-position compass map across face/edge/body/text
(create, add, remove, dress-up, repeat, transform, reference, modify) offered as
the group's first ratification, with families opening a secondary ring under the
same rule; arrow/numpad direction addressing so the spatial map works from the
keyboard; a gate question 12 that treats re-addressing an existing tool as a
breaking change to every user's muscle memory.

snaporca-2is.
2026-07-31 11:10:46 +02:00
Tommaso Bianchi d7583f0a2d docs: the grammar becomes object-driven, and commit stops being invisible
Two changes to section 4, both from Tommaso.

FIRST: the selection does not merely feed the tool, it DETERMINES WHICH TOOLS
EXIST. Point at a planar face and the product offers the small set of things a
planar face can become; point at an edge and it offers fillet, chamfer and the
sketch tools that can reference it. Nothing else, because nothing else is
possible. This is the largest single thing available to us for a first-time
user, and the reason is worth writing down: a beginner's difficulty is not
operating a tool, it is not knowing which tools apply to what they are looking
at. Sixty icons answer a question they cannot yet ask; a face that offers its
own five verbs teaches the product by being used. It also deletes a whole class
of failure — a tool that silently does nothing because the selection was wrong
becomes unreachable.

The offer is an accelerator, not a toll gate: toolbar and single-letter
shortcuts keep working unchanged and consume the same selection, so an expert
never looks at the offer and a beginner never needs the toolbar. Both routes
land in the same place, which is how one interface serves all three audiences.

SECOND: "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 still being adjusted. Exactly what L5 forbids. A pending
feature now carries a confirm/cancel puck attached to its own geometry, beside
its handles, with Enter/Escape mirroring it; empty space reverts to the safe
meaning, clear the selection. The puck is an object in the scene, not a dialog:
the camera orbits, the values stay editable, nothing is blocked (L4 intact).

Two cases the rule has to get right or it damages the inner loop: continuous
tools (line, rectangle, circle) still commit each entity on its own gesture — a
tick per line would be miserable — and Enter/Escape end the tool rather than
confirm an entity. And ambiguity resolves toward keeping work: starting another
operation with a valid feature pending commits it rather than discarding it,
because undo reaches everything and the recoverable direction is the right
default.

Section 10 gains the two honest consequences: today a selection offers nothing
(the largest single item of new work this charter asks for) and committing is
still the invisible empty-space click.

snaporca-2is.
2026-07-30 21:20:27 +02:00
Tommaso Bianchi 610acfcd37 docs: reach is the first accessibility, and it gets a law
The charter had accessibility only in the assistive sense — keyboard, contrast,
colour, targets — and said nothing about who can get through the door in the
first place. That was the larger omission. The premise of an OSS CAD tool is
that a kid on a school laptop, with no licence, no account, no fast machine and
nobody to teach them, can open it and make a real thing; a tool that only the
equipped can run reaches people who were already going to design something.

So the fourteen-year-old is now the FIRST of three audiences, ahead of the maker
and the mechanical designer, with an explicit rule that when audiences conflict
the earlier one wins unless someone writes down why not.

L11 states the floor: runs completely on a low-end laptop with integrated
graphics at 1366x768, offline, no account, and no capability withheld behind a
tier, a plugin or a cloud service. Section 6 splits into 6.1 reach and 6.2 the
assistive floor: the reference machine, the small screen as the layout target
rather than the stretch case, files that belong to the user, learnable with no
documentation, plain language at the entry tier, and exploration that is never
punished — undo reaches everything, nothing asks the user to be sure.

Consequences elsewhere: the screen budget is set by the smallest screen we
serve, not the reviewer's monitor; the PR gate gains a reach question; B6 joins
the benchmark (the inner loop on the reference machine, offline, fresh install)
and every task is measured there rather than on a workstation. The side-panel
debt now fails L11 as well as L1 — on that screen the cards leave the model a
strip.

One role addition: the absent audience needs a seat. The kid cannot file an
issue, so someone owns B5/B6 and the group watches real first-timers quarterly.
Approachability is the one thing here that cannot be argued from principle.

snaporca-2is.
2026-07-30 21:17:22 +02:00
Tommaso Bianchi 76690f66e9 docs: the UX charter names only this fork's product
The doctrine is shared but the document is not: each fork's copy now speaks
about its own product only, so it reads as that project's own charter rather
than as a note about a sibling repository. This is a deliberate divergence —
the two copies must NOT be reconciled by a parity sweep. The CAD sources stay
byte-identical; only this doc branches.

snaporca-2is.
2026-07-30 21:11:50 +02:00
Tommaso Bianchi 8cc08845a8 docs: UX guidelines and charter for the Orca-CAD design group
The call with SoftFever settled that Orca-CAD is one of the branches to be
implemented and that a design+dev group forms around it. A group without a
written doctrine reviews by taste, and a CAD reviewed by taste becomes
FreeCAD one locally-reasonable side panel at a time.

So the doctrine is written first, as something a reviewer can FAIL a pull
request against: ten laws each with its own test, the interaction grammar
they compose into, the accessibility floor as a merge requirement, and a
ten-question gate answered in every UI pull request.

The position is Shapr3D's interaction economy, not its feature list —
direct, gestural, almost no chrome, depth revealed by what you touch. Depth
for mechanical designers arrives as progressive disclosure of tools that
never move, in three tiers, non-modal, with assemblies and exploded views
obeying the same point-then-act grammar as a beginner's extrude.

The one thing neither Shapr3D nor FreeCAD has is that we live inside a
slicer: plate, nozzle, material and build volume are known at design time,
so print-domain failures are warnings on the geometry, not a report.

Section 10 is an honest inventory: what already complies, and the six
things that violate the laws today, none of them defended. The appendix
keeps the anti-patterns we have already paid for, because each one is
cheap to reintroduce.

snaporca-2is.
2026-07-30 21:05:33 +02:00
Tommaso Bianchi f14d31d956 scripts/gui-session.sh: bring the headless GUI up without clicking blind
The relaunch sequence was an ad-hoc pile of docker exec one-liners, and it
had a real bug: it dismissed the first-run dialogs by computing the
titlebar close box from `xdotool getwindowgeometry --shell` and clicking
it. When the dialog had already closed, that eval left the geometry
variables stale or empty, the click landed at a garbage coordinate, and it
kept hitting the Sketch button in the toolbar underneath — so the app came
up in sketch mode with a stray Sketch feature that then had to be
cancelled by hand. Three times in one session.

The fix is not a different mechanism. `xdotool windowclose` looks cleaner
and KILLS THE APP: it destroys the GdkWindow out from under the dialog and
the process dies with "GdkWindow unexpectedly destroyed", three
GLib-GObject criticals and a segfault. Measured, not guessed — that is
what the first version of this script did. Escape does not close the Setup
Wizard either, which is why it needs handling at all. So the titlebar
click stays, and what changes is that it refuses to click geometry it has
not validated: the window id is re-resolved immediately before, all four
geometry variables are unset first and must come back numeric, and the
computed point must be inside the screen. Any of those failing logs why
and clicks nothing.

Two further honesty fixes in the status output, both caught by reading it
rather than by it failing: app_pid skipped nothing, so with a container
full of <defunct> instances it printed a dead pid as though the session
were healthy — it now walks /proc/<pid>/stat and ignores zombies. And a
container without x11vnc reported "vnc: DOWN" as if something had broken,
when nothing was ever installed; it now says so, and does not try to start
what is not there.

Also replaces the fixed post-launch sleep with a wait for the main window,
because cold starts under software GL vary by a lot, and adds --status for
diagnosis: "no windows but the desktop is up" means the app died, "cannot
connect at all" means the desktop did. That distinction cost real time to
work out by hand.

Verified on both containers: one run each, wizard closed on validated
geometry, no stray sketch mode, live pid reported, and the app still up.

snaporca-e1p adjacent (tooling, not the tab itself).
2026-07-30 14:35:35 +02:00
Tommaso Bianchi 3f8f46f93f Delete the sketch plane dropdown; the viewport decides
The plane combo is gone. A sketch takes its plane from what is picked in
the viewport: a face on a solid, or one of the reference-plane ghosts,
clicked in 3D. The card is now a single line of instruction instead of a
control.

The combo had become worse than redundant. Once a picked face could be
the plane it displayed a row that CONTRADICTED the actual target — it
still said XY while the sketch went onto the face — so the one place a
user could look to confirm where they were drawing was the one place
guaranteed to be wrong.

What replaces it is state, not UI: m_ref_plane records which reference
plane was last clicked in 3D (0/1/2 = XY/XZ/YZ, >=3 indexes the datums)
and ref_plane_name() turns it into text for the on-geometry hint. Clicking
a ghost plane while a session is live re-planes it immediately, which the
combo's own handler used to do; that behaviour is kept, just driven from
the geometry instead of the widget. A plane click also drops a stale face
pick, so last pick wins in both directions.

populate_plane_choices() stays — seven other pickers use it (Plane base,
Axis A/B, Helix, Project, Mirror, Cut). Those are the next candidates,
tracked on snaporca-e1p; this commit only removes the one that had become
actively misleading.

Also: tessellation now matches Orca's OWN STEP importer, linear deflection
0.003 instead of 0.01 (Format/STEP.hpp default; angular was already 0.5
rad and unchanged). The Design viewport was never using a different
rendering technique — it hosts a real GLCanvas3D, builds a real
Model/ModelVolume and goes through the same reload/GLVolume path and the
same shaders as Prepare and Preview. What differed was the mesh handed to
it: 3.3x coarser than anything else in the application, which is why a
curved face read as faceted beside an imported part. Suite unaffected at
154 cases / 2125 assertions, so nothing depended on the old density.

Verified on :10: the card shows no dropdown, one click on a face then a
sketch tool still reports "on the picked face", and the circle is drawn in
that face's plane (artifacts/shots/h3a2-02-sketch.png, h3a2-03-drawn.png).

snaporca-e1p, snaporca-3a2.
2026-07-30 14:24:29 +02:00
Tommaso Bianchi 3c0843c68c Sketch on the face you clicked, not the one you clicked twice
The previous commit made a picked face the sketch plane and I verified it
by clicking the face TWICE. That was the wrong test. handle_solid_click
cycles whole -> face -> edge, and "First click on a (new) body/face
selects the WHOLE solid; refine on repeat clicks" — so at level 1
m_sel_solid_face is -1, and one click on a face, which is what selecting
a face means to anyone, still fell through to the plane combo. The fix
was real and unreachable, which from the outside is indistinguishable
from no fix at all.

The face id was never missing. handle_solid_click resolves it by ray on
the FIRST click and passes it to on_solid_selection_changed regardless of
the cycle level; the panel simply discarded it whenever level < 2. Keep
it in m_pick_face/m_pick_face_body and let a sketch use it, preferring an
explicit face-level selection when there is one. Nothing about the cycle
changes, so body operations that rely on whole-body selection are
untouched.

The new state is dropped wherever the existing picks are, so a stale face
cannot come back: choosing a body from the Bodies list (an explicit
choice with nothing pointed at), picking a committed sketch loop (last
pick wins), undo/redo (recompute invalidates topology ids), and when a
sketch consumes the face.

Verified on :10 with ONE click, which is the flow that was broken: build
a box, single-click its top face, S then C, and the hint reads "Circle —
click center, then radius · on the picked face" with the circle drawn in
that face's plane (artifacts/shots/g3a2-01-one-click.png,
g3a2-02-sketch.png, g3a2-03-drawn.png).

GUI-only, so the kernel suite is unaffected — plane_of_face and its 154
cases / 2125 assertions are unchanged from the previous commit.

snaporca-3a2.
2026-07-30 14:09:23 +02:00
Tommaso Bianchi 6ce20d78c3 Sketch where the user pointed: a picked face is the sketch plane
Selecting a face and sketching on it is the most common gesture in solid
modelling, and it was impossible. The plane came from a combo holding
XY/XZ/YZ plus datums, and plane_from_choice had no face branch at all, so
the only route onto a face was to build a Coincident datum plane on it
first, confirm that, reopen the sketch and find the datum in the
dropdown. Three extra steps and a junk feature in the tree.

The fix is not another combo row. A new sketch now takes its plane from
what is SELECTED IN THE VIEWPORT: a picked planar face wins outright, and
only when nothing is picked does it fall back to the reference plane —
which is itself normally set by clicking one of the ghost planes in 3D,
not by opening the combo. The tool hint names the target ("Circle — click
center, then radius · on the picked face") so the choice is visible on the
geometry side rather than needing a control to read back.

CadDocument::plane_of_face is the shared derivation, so the sketch path
and the Coincident datum method cannot drift apart. It refuses
non-planar faces: face_normal_world evaluates at the mid parameter, which
on a cylinder or a fillet is a tangent plane at one arbitrary point —
fine for offsetting a datum, wrong as a sketch plane, and silently
sketching on a tangent is worse than declining.

Picking the face also CONSUMES it. Leaving the pick live meant the next
Extrude saw a selected face and push/pulled it instead of extruding the
sketch just drawn — the same trap the imported-art path already guards
against.

Verified on :10 end to end with no combo interaction: build a box, click
its top face twice to cycle whole -> face, press S then C, and the circle
is drawn in the plane of that face with its Radius tab on the geometry
(artifacts/shots/f3a2-03-face.png, f3a2-04-sketch-on-face.png,
f3a2-05-circle-drawn.png). Kernel side: 154 cases / 2125 assertions green
on both forks, including that a cylinder resolves exactly its two flat
caps and refuses the barrel.

Still side-panel-shaped and to be dealt with separately: the Plane combo
remains on the card and now merely displays a stale row when a face is
the real target. It should show the actual target or go away.

snaporca-3a2.
2026-07-30 13:42:11 +02:00
Tommaso Bianchi 7f0a8c85ee An entity sketch that forms no wire fails, instead of extruding a default box
entities_to_wire handles exactly two shapes: one lone closed entity
(Circle/Ellipse), or a chain of open ones (Line/Arc/EllipseArc/BSpline).
Anything else -- a circle coexisting with a line, two circles -- returns a
null wire. build_sketch_wire answered that by falling through to its
legacy tail, which ends in a rectangle built from width/height. For an
entity sketch those fields are whatever they were initialised to, so the
extrude produced a box the user never drew, silently and with ok:true.
That is how the ellipse+stray-arc case in the P2 Tier-B.1 verification
turned into a default-rectangle solid.

Throw there instead. The legacy profile/shape paths below are still
reached by sketches that legitimately carry no entities at all, so the
enum and profile constructors are untouched -- only the case where
entities exist and cannot be turned into a wire now fails, which is
exactly the case that was fabricating geometry.

This does NOT implement the multi-loop support the issue asks for. Doing
that properly means deciding containment -- a circle inside a rectangle is
a hole, a circle beside it is a second region -- and make_extrude_regions
cannot be reused because it takes flattened Vec2d contours for imported
Text/SVG art and would discard the analytic circle. Guessing containment
would trade a visible failure for a wrong solid, which is the opposite of
the point. Left scoped on snaporca-88v.

Also converts the three float comparisons in the two test cases added this
session from Approx to WithinAbs/WithinRel, per tests/CLAUDE.md, which
rules Approx out for being asymmetric and double-only. The rest of the
file's pre-existing Approx uses are left alone.

153 cases / 2090 assertions green on both forks; no existing test depended
on the default-rectangle fallback.

snaporca-88v (partial: the silent-fallback half).
2026-07-30 09:56:18 +02:00
Tommaso Bianchi 5f1811c2c5 A subtraction that removes nothing is an error, not a silent success
A boolean cut whose tool misses the target is a perfectly legal
operation: OCCT reports IsDone(), the shape comes back unchanged, and the
feature lands in the recipe reporting ok:true. Driving the MCP socket,
that produced two consecutive {ok: true, bodies: 1, error: ''} responses
for a hole that was never drilled -- same viewport, same 46939.11 mm3 --
and the tree grew two Hole features that will never cut anything. A
caller, an agent especially, has no signal at all that the thing it asked
for did not happen.

Measure the volume across the op in route_feature's in-place branch and
refuse the no-op. Only for removals: Hole, Thread, and Extrude / Revolve
/ Sweep / Loft in Cut mode. Everything else may legitimately leave the
volume alone -- a Transform certainly does. The tolerance is relative,
because an absolute epsilon is wrong across the mm-to-metre range of real
parts, and a cut that shaves a numerically invisible sliver is a miss
too. The existing rollback in the MCP actions already preserves the
reason, so a missed hole now answers ok:false with the error and undoes
the feature.

The confusion underneath was not itself a bug: hole's x/y are in the
sketch plane's frame, whose origin is describe_scene's modeling_origin,
and describe_tools documented them only as "number, unit mm". Passing the
world centre put the hole 135 mm clear of the solid. All six x/y params
on hole / hole_styled / hole_standard now say which frame they are in,
since the wrong guess was silent.

Regression test drives the reported failure directly: a hole at x=135 on
a 20x20x20 box is rejected and leaves the body untouched, the same hole
at the origin still removes exactly pi*4^2*20, and a cut-mode extrude
whose profile sits at x=200 is rejected too. 152 cases / 2083 assertions
green on both forks.

snaporca-daf.
2026-07-27 07:21:06 +02:00
Tommaso Bianchi 5d05ca30ca Sketch shortcuts: pick the key map by mode, not by whether a session exists
All 17 single-letter sketch shortcuts were dead. The dispatch gate was
circular: the sketch key map was consulted only when
m_viewport->is_sketching() was already true, but is_sketching() is a
whole-session flag whose only riser is begin_sketch(), called from
select_tool() -- which is precisely what every sketch key closure calls.
So the first letter after entering sketch mode fell through to the
feature map, where the keys are Shift+letter, matched nothing, and did
nothing. The mouse worked only because the toolbar flyout row reaches
select_tool() directly, bypassing the gate.

Which key MAP applies is a question about the mode. Split the flag:
'sketching' (live session) still drives undo/redo, Delete and the
section-view branch, where a session genuinely has to exist; a new
'sketch_mode' (m_ui_mode == UiMode::Sketch alone) drives the map choice.

Verified headless end to end, keyboard only: Shift+S, then R draws a
143.4 x 133.7 rectangle on XY reporting 4 degrees of freedom, then F and
L arm Fillet and Line (artifacts/shots/0udb-01..03). Note the toolbar
strip does NOT change when a tool is armed by keyboard -- the family
buttons are flyouts and only show their own pressed state -- so the
pixel diff on that strip, which is how this was originally measured,
reads 0 for a tool that is live. The status line is the surface that
actually reflects the armed tool.

Also fixed, from snaporca-d9i's list: the plane-pick status line said
"press Sketch to draw on it", naming a button that exists only in
Feature mode. It is now mode-aware.

Adds a SNAPORCA_KEYTRACE=1 trace in the CHAR_HOOK printing key, ui mode,
is_sketching, in_text and the focused window's class. It is what
separated "the fix does not work" from "the surface being measured never
moves", and it costs a full GUI build to re-add, so it stays.

snaporca-0ud, partial snaporca-d9i.
2026-07-27 00:40:25 +02:00
Tommaso Bianchi 347b83d887 Sketch fillet: never write a failed solve's geometry back, and commit the op
Two P0s in the same gesture. Filleting a corner of a parametric rectangle
produced either nothing at all or a sharp corner with a stray arc floating
above it.

Trigger (snaporca-cq2): the only routes that ever reached confirm_op were
finishing the whole sketch and an unsignposted click on empty space.
set_tool() dropped a ready op, so typing a radius or dragging the arrow and
then touching any other tool threw the value away. Commit a ready op on tool
change (before m_mode is reassigned — op_ready() and confirm_op() both switch
on it), commit on Enter in the radius editor, and drop the pending op before
Esc's tool downgrade so Esc still cancels rather than applies.

Substitution (snaporca-pl5): libslvs writes its last Newton iterate into the
params whether or not it converged, and SketchSolver read them back
unconditionally, so every REJECTED solve deformed the sketch. The fillet
ladder tries a deliberately over-constrained rung first (a tangent on each
leg, against the legs' own H/V); it is correctly rejected, but its wreckage
then failed rungs 2 and 3, which solve cleanly on their own. The arc ended up
with no constraints at all, the rigid loop won, and the corner snapped shut.
Measured: from pristine geometry rung 1 gives result=INCONSISTENT with 3 bad
constraints, rung 2 gives dof=6 with the arc's radius intact.

Read the geometry back only on success. try_add_constraints then needs no
"restore" re-solve — the entities still hold the prior solved state.

Kernel suite 151 cases / 2072 assertions green on both forks; the GUI check
ran on the Snapmaker fork (9d72c4377a).

Ported from the Snapmaker fork. snaporca-pl5 snaporca-cq2
2026-07-26 23:33:50 +02:00
Tommaso Bianchi 50577d66d0 Dress-up card: say whether Confirm will round the picked edge or the group
The card decides between a single picked edge and a whole face-group from a
viewport pick it never mentioned. With no edge picked the user saw only the
group combo and concluded per-edge rounding did not exist; with an edge picked
the combo still read "All" — the opposite of what Confirm would do.

Adds a Target row that names the actual target and greys the group combo out
while an edge is picked, wired at the same three points Shell already uses:
the selection-changed handler, the re-edit load, and open_tool.

Ported from the Snapmaker fork (33771a0e95). snaporca-40d
2026-07-26 23:33:50 +02:00
Tommaso Bianchi d5dee45acf Port the four DesignPanel fixes that never crossed from the Snapmaker fork
The CAD sources are meant to be byte-identical across the two forks, with exactly
two permitted divergences: DesignPanel.cpp's flyout plumbing (30 lines — mainline's
DropDown is Item-based where the other fork takes three parallel vectors) and
DesignCanvas.cpp's Bed3D::set_shape signature (16 lines). DesignPanel.cpp had drifted
to 105.

Nothing failed to announce this. The kernel suite does not compile the GUI, and all
four defects are interaction-level, so both forks stayed green while only one of them
had the fixes. The parity diff is what found it.

  * combo_append_index and its five call sites. Orca's ComboBox keeps client data in
    its own vector, so Append()'s clientData argument never reaches wxItemContainer
    and m_clientDataItemsType stays wxClientData_None. GetClientData() opens with a
    wxCHECK_MSG, which is an early return — so every read came back NULL and every
    caller resolved it to index 0, silently, because 0 is a legal answer. Affects the
    rib sketch picker, the sheet-body picker, both mate coordinate-system pickers and
    the sweep path picker.

  * Wrap(240) over the card labels. wxStaticText never wraps itself, so a one-sentence
    hint sets its card's minimum width to the width of the whole sentence and every
    control in that card is clipped at the sidebar's right edge.

  * Shell and Draft face-label initialisation in open_tool. Both read the face from the
    live pick at Confirm time, but only the pick handler wrote their labels, so picking
    a face and then opening the card left the card describing one operation while
    Confirm performed another.

  * SurfaceOffset and ThickenSurface added to build_candidate's negative list. Both read
    a sheet body from their own combo and both had it overwritten by the picked solid's
    index. The list is a negative one, so a tool that picks its own body breaks by
    omission — noted in a comment now.

Also drops a stray <cstdio> left behind by a debug probe.

Verified by building the GUI here for the first time since these landed: 461/461,
liblibslic3r_gui.a links. DesignPanel.cpp is back to exactly 30 divergent lines and
every other CAD file is byte-identical again.

snaporca-7xx snaporca-aqu snaporca-gu9 snaporca-c03 snaporca-5pl
2026-07-26 18:05:13 +02:00
Tommaso BianchiandClaude Opus 5 faed169a01 Sketch dimensions: make Tab commit, instead of silently dropping what you typed
The inline editor special-cased Escape and Skip()ped every other key, so Tab fell
through to wx's default navigation. Its popup frame holds exactly one control, so
focus came straight back to that control with its text re-selected.

Type 60, Tab, 40, Enter — expecting to fill two dimensions — and the 60 is gone: Tab
neither committed it nor advanced, so the 40 just replaced the re-selected text. A
re-selected field is pixel-identical to a freshly opened one, so nothing on screen says
a number was dropped. Tab-to-next-dimension is what Onshape, SolidWorks and Fusion do,
which is exactly why it is the key a user reaches for.

Tab now calls do_commit(), the same path Enter takes; the caller's on_commit is already
what walks to the next dimension. Verified by driving the GUI: 37 Tab 24 Enter now
produces a 37.0 x 24.0 rectangle, where before it produced 24 and a mouse-derived value.

snaporca-xah

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 17:48:11 +02:00
Tommaso Bianchi 695932ad87 Drop the degenerate triangle OCCT emits at every filleted corner
A filleted solid arrived on the plate as a broken model: the slicer reported
"8 non-manifold edges" on an 80x50x12 box with r=3 on all edges, and advised
repairing it in another CAD application -- the exact round trip the Design tab
exists to remove. The same box without the fillet committed cleanly.

Measured rather than guessed. Each of the 8 bad edges is degenerate, both
endpoints the same vertex:

    open tri=145  edge=1 face=5 v59(3.000000 3.000000 0.000000) v59(3.000000 3.000000 0.000000)
    open tri=538  edge=1 face=6 v87(3.000000 3.000000 12.000000) v87(...)
    ... one per corner, 8 corners

OCCT triangulates a degenerate surface parameterization with a triangle at the
pole; a corner sphere patch has exactly one. Its two pole nodes are distinct in
the per-face triangulation and collapse to a single vertex when the faces are
welded, leaving a zero-area triangle whose v->v edge can never pair with a
neighbour. its_face_neighbors counts it as open, and the field the object panel
prints as "non-manifold edges" is in fact stats.open_edges.

So the geometry was never wrong -- the B-rep volume matches the Steiner formula
for a box dilated by a ball to 0.016%. Only the bookkeeping was.

Dropping those triangles after the weld removes 8 of 3492 and takes open_edges
to 0. Zero area, so nothing about the shape changes. tri_face is compacted in
the same pass, since it must stay index-aligned with the triangle list that the
face picking and per-body colouring both index into.

Guarded by a new [CadDocument] case that asserts open_edges == 0, no degenerate
triangle survives, and both per-triangle maps still match the triangle count.
The existing suite only ever checked B-rep volumes and areas, which is why a
mesh defect this visible went unnoticed: 150 cases / 2049 assertions green on
both forks.

snaporca-agw
2026-07-26 17:27:40 +02:00
Tommaso BianchiandClaude Opus 5 2c5ddd4102 OCCT link order: put TKFillet/TKOffset before their dependencies, not after
OCCT_LIBS is an explicit single-pass static link order — dependents first, TKernel
deliberately last. The CAD block appended its two extra toolkits to the END of that list,
which puts them after everything they depend on:

    list(APPEND OCCT_LIBS TKFillet TKOffset)

TKOffset references BRepAlgo_Loop, and nm against the built deps prefix shows TKBool is the
only toolkit that defines it (TKTopAlgo, TKBO, TKPrim, TKFillet and TKOffset all define it
zero times). TKBool sits first in the list, so a single-pass linker has passed it long before
it reaches the appended TKOffset and will not go back:

    libTKOffset.a(BRepOffset_MakeLoops.cxx.o): undefined reference to
    BRepAlgo_Loop::BRepAlgo_Loop()

Only one configuration ever objected — the Snapmaker fork Flatpak (aarch64). Ordinary Linux,
macOS and Windows links resolve it regardless, and the mainline fork Flatpaks pass, so six
green platform legs said nothing about whether this list was correct.

Prepended via set() rather than list(PREPEND), which needs CMake 3.15 while this project
supports 3.13.

Worth knowing for later: TKFillet and TKOffset are mutually dependent, 20 symbols needed in
each direction, so a stricter single-pass link could still trip on that pair. It does not on
any current platform, so no --start-group or duplicate entry is added here; if something ever
complains about ChFi or BRepFill symbols, that cycle is the reason.

snaporca-2kj

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 15:28:46 +02:00
Tommaso BianchiandClaude Opus 5 295c030309 DesignPanel.hpp: declare the three wx types it uses but never named
The header forward-declares a long list of wx types and omitted wxBoxSizer, wxTextCtrl and
wxListCtrl. All three are used as pointer members only (m_expr_text, m_var_list,
m_parts_hdr, m_hdr_tree_row), so a forward declaration is all they need — but there was
none. Every ordinary build compiled anyway because the wx/panel.h + wx/scrolwin.h chain
happens to pull the real headers in transitively.

The Snapmaker fork Flatpak build (aarch64) has a wx that does not, and it failed outright:

    DesignPanel.hpp:511: error: 'wxTextCtrl' does not name a type; did you mean 'wxTreeCtrl'?
    DesignPanel.hpp:521: error: 'wxListCtrl' does not name a type; did you mean 'wxFileCtrl'?
    DesignPanel.hpp:663: error: 'wxBoxSizer' does not name a type; did you mean 'wxSizer'?

plus a cascade of "m_var_list / m_expr_text / m_parts_hdr was not declared in this scope".
Not an environment quirk: the header was simply not self-contained, which is exactly what
breaks a reviewer building in an unfamiliar configuration. The mainline fork Flatpaks passed
on both arches, so only that one manifest exposed it.

Audited the rest of the header afterwards: every other wx pointer type is either
forward-declared or genuinely included — only wxScrolledWindow and wxWindow are undeclared,
and both come from the real wx/scrolwin.h include.

snaporca-4dn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 12:27:02 +02:00
Tommaso BianchiandClaude Opus 5 c1b0484495 3mf test: give the BBS save a writable temp dir, instead of the filesystem root
store_bbs_3mf reaches Model::get_backup_path(), which builds
temporary_dir() + "/orcaslicer_model/" + timestamp. temporary_dir() returns a file-static
that ONLY OrcaSlicer.cpp's startup sets, so in a test binary it is the empty string and the
backup path becomes "/orcaslicer_model/..." — absolute, at the filesystem root. An
unprivileged process cannot create that, so the save returned false and the scenario died
on REQUIRE(store_bbs_3mf(sp)).

This was the SINGLE failure in this fork's Unit Tests — 1 of 566, on Linux x86_64, Linux
aarch64 and macOS arm64 — from CI run 30191490709:

    Failed to create backup path "/orcaslicer_model/Sun_Jul_26/08_49_41#5398#1":
    boost::filesystem::create_directories: Permission denied [system:13]

It hid because that job had never run to completion on this branch before: every earlier
run was cancelled by the concurrency group first. It also passed on Windows x64, where the
drive-root path is writable, and it passes in the local build container, which runs as root.
Verified against the same defect in the Snapmaker fork by running the built binary as
uid 1000: permission denied before, 4 assertions passing after.

snaporca-vg8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 12:22:38 +02:00
Tommaso BianchiandClaude Opus 5 5026dd11a6 Fix the solver abort on circle-line tangency; the CAD suite now runs complete
snaporca-tkz, the last quarantined test. Root cause read out of the vendored
source rather than guessed: slvs/constrainteq.cpp, Type::ARC_LINE_TANGENT does

    ExprVector ap = SK.GetEntity(arc->point[other ? 2 : 1])->PointGetExprs();

so it dereferences the ARC'S ENDPOINTS. A full circle entity carries only
point[0], its centre. point[1] and point[2] are zero handles, FindById throws
"Cannot find handle", and the process ABORTS rather than failing the solve —
taking every later test in the binary with it. That is also the wrong equation
for a circle regardless: it only makes the line perpendicular to the radius at
an endpoint that does not exist.

CT::Tangent no longer hands a full circle to that constraint. For a circle it
emits PT_LINE_DISTANCE(centre, line) = radius, which is precisely what tangency
to a circle means. Arcs keep the ARC_LINE_TANGENT path they are built for.

One limitation, stated rather than buried: the slvs C API takes a constant
distance and offers no way to reference the circle's radius parameter, so the
radius is captured when the constraint is emitted. That is exact whenever the
radius is fixed or is simply not driven by another constraint in the same
solve, and re-solving restores tangency if something else moves it. Tying them
would need an auxiliary point constrained onto both the circle and the line.

With this and eeca6794e7, both quarantined tests are gone and the exclusion in
kernel-test.sh goes with them. A green run now means the whole CAD suite
passed, not "everything except the two we gave up on":

    149 cases / 2043 assertions, no filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:33:50 +02:00
Tommaso BianchiandClaude Opus 5 f599ff0ff7 kernel-test.sh: one quarantined case now, not two
Follow-through from eeca6794e7. The header claimed two pre-existing failures
are excluded and named both; the internal-thread case now runs like any other,
so only the solver SIGABRT is left. A comment that lists a test which is no
longer excluded sends the next reader looking for something that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:33:21 +02:00
Tommaso BianchiandClaude Opus 5 df6ef85614 Un-quarantine the internal-thread test: the geometry was right, the test was not
snaporca-kzy was filed as "internal thread cuts too little material". It does
not. Measured on the test's own fixture, a 40x40x20 box:

  plain Ø12 bore   removes 2261 mm3
  internal thread  removes 2157 = 1571 (minor bore) + 586 (groove)

apply_thread bores at the MINOR radius (radius - depth = 5) and then carves the
groove out to radius + depth = 7. A tapped hole therefore keeps the crests
between turns and holds MORE material than a plain clearance hole at the
nominal radius — which is what every real tapped hole does. The test asserted
the opposite, so it was asking for something physically wrong and had been
quarantined for it since it was written.

One hypothesis discarded on the way: that the shortfall was a tessellation
artefact, since chords on a helical surface undercut a concave bore. Exact
BRepGProp::VolumeProperties agreed with the tessellated volume to within
2.5 mm3, so that was not it and is not offered as a hedge.

The reference is now the tap-drill bore the thread actually starts from (Ø10),
against which the groove's 586 mm3 is the meaningful quantity — that is what
"the thread cuts" means. Test re-tagged [CadDocument][thread], so CI covers the
thread path again instead of skipping it.

Also documented the (void)internal in make_thread_profile. It reads like a bug
and is not: the V is the same shape either way and the caller decides, fusing
it onto a shaft or cutting it out of a wall. Someone "fixing" it to point
inward for the internal case would make the groove sweep already-empty bore
space and cut nothing — the exact failure the old comment described.

Suite 148 cases / 2035 assertions, with this test now among them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:32:47 +02:00
Tommaso BianchiandClaude Opus 5 a95e8ee701 Re-edit: list the bodies as of the feature's timeline slot, not the final ones
Found by sweeping the index-space defect class deliberately rather than by
hitting it: that class produced 4 of the 8 defects found by hand yesterday, so
it was worth auditing every combo in the panel that maps a row selection onto
a document index.

Most of it came back clean — the loft sidecar vectors are consistent at all
four read sites, the sheet-body pickers go through the helper everywhere, mate
connectors carry client data. Six did not. A stored target_body indexes the
body list AS IT WAS just before that feature ran during replay, but Transform,
Mirror, Thicken, Rib, Project and DeleteFace all populated their combo from
the live m_doc.bodies. Boolean and Cut already replayed to the right slot.

The failure is concrete: model a body, Thicken it, then Cut something later in
the tree. A Cut replaces one body with two, so every index at or after it
shifts. Reopen the Thicken and the combo lists the post-cut bodies while
selecting the pre-cut index — showing, and on confirm re-targeting, a
different body than the feature actually used. A Boolean that consumes its
tool body shifts them the other way for the same result.

fill_body_choice() does the truncated replay populate_body_choices() already
did, for the single-combo tools. Six call sites, and 60 lines of duplicated
population loops go with them.

Visible change when testing: re-editing an early feature now lists FEWER
bodies, because it lists only those that existed then. That is correct — you
cannot target a body that did not exist yet — and it is what Boolean and Cut
have always done.

The new kernel test pins the invariant the GUI now leans on: a Cut turns one
body into two, and replaying to just before it yields the earlier, shorter
list. If body ordering after a split ever changes, that assumption fails
loudly here instead of silently in a dialog.

NOT click-tested — GUI wiring, compile-verified only. Filed as snaporca-oz7
and added to snaporca-cfi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:32:47 +02:00
Tommaso BianchiandClaude Opus 5 8f06b9dfd8 Design tab: make Cut re-editable, and stop misdescribing Import
on_edit_feature had two types falling into default: with "This feature type
can't be edited yet". Boolean was already handled, so the follow-up note was
stale on that point; the real gap was Cut and Import.

Cut now re-edits like any other feature: plane, offset and target body are
restored and the generic replace_feature path commits the change. The body
list is rebuilt with populate_body_choices(m_edit_index) for the same reason
Boolean does it — a Cut splits one body into two, so the live body list no
longer matches the one this feature's target index was recorded against.
Replaying to just before the feature makes the stored index land on the right
entry.

Import deliberately gets no dialog. An imported solid has no parameters to
re-edit: its geometry is rigid data read from a file, not something rebuilt
from numbers, and moving it is what the Transform feature already does.
Building an "edit" for it would duplicate Transform behind a second name. So
it now says that instead — the previous message implied a dialog was coming
that should not.

Imported 2D Text/SVG art is a different thing and stays re-editable; it
arrives as a Sketch feature carrying imported_regions and is handled above.

The default: arm is kept as a guard so a feature type added later announces
itself rather than silently swallowing the click.

NOT click-tested — GUI wiring, compile-verified only. Added to snaporca-cfi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:32:47 +02:00
Tommaso BianchiandClaude Opus 5 9c199e4a38 Fix the Shellcheck CI job, red since the CAD kernel-test loop landed
The Shellcheck workflow has failed on every push and every scheduled run since
2026-07-24, on exactly one finding: SC2029 in scripts/kernel-test.sh, the file
the CAD branch added. So the CAD work is what turned that job red, and a PR
arriving with a red job is a bad way to open a conversation with a maintainer.

Client-side expansion of $REMOTE is the intended behaviour — it is derived from
$VOL locally and the remote has no such variable, exactly as the rsync
destination two lines down relies on. So this is a disable with a reason, not a
silencing: the note says why the warning does not apply.

Verified by running the workflow's own command over all 24 matched scripts:
exit 0, no findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:46:35 +02:00
Tommaso BianchiandClaude Opus 5 e2b921745d Add user documentation for the Design tab
docs/design_tab_upstream_portability.md explains the subsystem to a
maintainer; nothing explained it to a user. This is that: what the tab is,
how to get a first solid out of it, every tool grouped the way the toolbar
groups them, and the keyboard shortcuts read out of the source rather than
remembered.

The limitations section is deliberate. Rib needing a sketch with an explicit
open line, Surface Loft and Surface Fill having no hands-on verification, mates
composing transforms instead of solving simultaneously, move-face and
replace-face being absent, and the two quarantined kernel tests are all things
a user would otherwise discover by hitting them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:44:27 +02:00
Tommaso BianchiandClaude Opus 5 f347afd22e Document the CAD subsystem's real dependency weight
Maintainers will ask what the Design tab costs before they will look at the
diff, so measure it rather than assert it.

The headline correction: the OCCT delta is THREE toolkits, not two. The
comment in deps/OCCT/OCCT.cmake claimed "TKFillet + TKOffset (3.77 MiB,
Windows only)". Walking OCCT's own adm/MODULES and each toolkit's EXTERNLIB
shows ModelingAlgorithms holds twelve toolkits, that eight of them are built
either way because DataExchange (the STEP path upstream already ships) depends
on them, and that the true delta is TKFeat, TKFillet, TKOffset and TKXMesh —
of which TKXMesh is never produced. So three archives are built: 7.40, 5.38
and 4.42 MiB.

TKFeat is the interesting one. 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. On static-link
platforms that is build time and zero shipped bytes.

Two numbers are deliberately absent, marked as absent, and not approximated:
the Windows DLL delta needs a Windows build (snaporca-gix), and a clean-build
time delta needs the deps prefix built twice on one machine. The old 3.77 MiB
figure is withdrawn rather than reused — it covered two of the three toolkits.

Also recorded: the vendored solver is 9,339 lines under GPLv3 with its LICENSE
preserved, which combines into this AGPLv3 fork without difficulty (AGPLv3
§13), and it is live code driving every sketch constraint — not a carried
corpse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:41:50 +02:00
Tommaso BianchiandClaude Opus 5 e2b58a17f7 Design i18n: extract the tab's strings at all, and translate them into Italian
DesignPanel.cpp was never listed in localization/i18n/list.txt, and xgettext
extracts only what that file names. All 934 of the Design tab's _L() calls
were therefore invisible to every translator in every language — not merely
untranslated, unextractable. Adding the one line is the actual fix; the rest
follows from it.

Regenerating the .pot brings the catalogue from 6007 to 6536 msgids. 549 of
the new ones are now translated into Italian: 5230 to 5779 translated
messages. Verified no existing work was destroyed — every msgid that survived
into the new .pot kept its translation, and the 36 that lost one are genuinely
gone from the sources. msgfmt --check-format passes, which matters here
because a large share of these carry %d / %s / %zu.

CAD terms follow Italian CAD convention rather than literal glosses: Fillet ->
Raccordo, Chamfer -> Smusso, Draft -> Sformo, Rib -> Nervatura, Mate ->
Accoppiamento, Shell -> Svuotamento, Pattern -> Serie, Sheet body -> Corpo
superficie. Strings identical in both languages are deliberately left
untranslated so gettext falls back to the msgid.

The long mixed-filament / Local-Z dithering tooltips are left untranslated on
purpose: slicer internals, outside a Design i18n task, and untranslated before
this commit too.

One string changed rather than translated. The Coord Sys hint read "Without an
edge the frame's rotation about its normal follows world X, not the body" —
that described the defect fixed in 1726e93760, so it was a lie as of that
commit. It now says X comes from the face's first edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:33:53 +02:00
Tommaso BianchiandClaude Opus 5 1726e93760 Mate connectors: derive a face-only frame's X from the face, not from world
datum_frame took a FaceAndDirection frame's Z from the face normal, which
follows the body, but its X from coordsys_x_hint, a world constant, whenever
no explicit edge reference was set. Spinning a body about its own face normal
therefore left the frame bit-identical: the connector could not encode that
rotation at all, so Fastened and Slider mates claimed to fix an orientation
the frame could not see.

X now comes from the face's own first usable edge, which rotates with the
body. The hint survives only as a last resort, for faces that offer no
in-plane direction — a full circular edge has coincident endpoints, and a
seam projects to nothing in-plane.

The new test spins a box 90 degrees about its top-face normal and asserts the
frame's X turned with it. Reverting just the X_tent derivation and rerunning
makes it fail with "1.0 is within 0.000001 of 0.0" — cos(angle) between the
before and after X is exactly 1, i.e. the frame did not move — and that is the
only failure in 2019 assertions, so the test discriminates this defect and
nothing else.

Note for anyone replaying an older document: a face-only connector's frame
can now differ from what that recipe produced before, so a mate built on one
may place its body differently. Nothing in the suite or the golden v3 fixture
changed, but the semantics did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:18:42 +02:00
Tommaso BianchiandClaude Opus 5 f13f2876f6 Tag the two known-broken tests [NotWorking] so CI stops being red on every commit
This fork's Unit Tests job failed on every single commit, because CI runs the
whole ctest suite including the two cases tagged [known-broken] that
scripts/kernel-test.sh has always excluded locally. A job that is red
unconditionally is worse than no job: it trains everyone to ignore it, so the
next genuine regression arrives invisible.

No workflow change was needed. scripts/run_unit_tests.sh already passes
-LE NotWorking, and tests/CMakeLists.txt registers Catch2 tags as ctest labels
via catch_discover_tests(ADD_TAGS_AS_LABELS) — so the exclusion upstream
already ships works as soon as the cases carry the tag. Verified against the
built test tree: 337 tests unfiltered, 335 with -LE NotWorking, i.e. exactly
these two dropped and nothing else.

The second cause recorded in the issue, the test-reporter step failing with
"Resource not accessible by integration: 403" on a fork, is already fixed
upstream: the Publish Test Results step now carries continue-on-error: true.

The comment these cases carried claimed CI kept the bugs visible by reporting
them forever. That is now false and was never a good mechanism anyway, so
visibility moves to the tracker: snaporca-tkz for the solver SIGABRT, and
snaporca-kzy, filed now, for the thread groove volume. Neither is fixed;
neither is forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:13:08 +02:00
Tommaso BianchiandClaude Opus 5 b25335e1b3 Rib: accept a Project feature as its sketch ref
Rib guarded with `sk.type != CadFeatureType::Sketch`, while every other
sketch consumer — Extrude, SurfaceExtrude, SurfaceRevolve, the loft paths —
tests `!= Sketch && != Project`. A Project feature carries a plane and Line
entities, which is all a rib reads, so the guard blocked "project a body
edge, then rib along it" for no stated reason.

The picker in the Design tab offered Sketch features only, so it is widened
to match: a kernel that accepts Project refs and a GUI that never lists them
would have left the path unreachable anyway.

Worth recording for whoever hits this next: Rib also needs a sketch carrying
EXPLICIT entities. A parametric Rectangle sketch (add_sketch with
width/height) has an empty entities vector — build_sketch_wire synthesises
its profile on demand — so rib_entity 0 is out of range there and it fails
with "rib: bad entity". That is why Rib could not be driven headlessly at
all before this change; a Project feature is now the one programmatic way to
produce a ribbable line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:48:16 +02:00
Tommaso BianchiandClaude Opus 5 1cb80f7f9f Project: implement "(all edges)"; stop discarding the failure reason
Two defects found while driving the tools that Phase B wired but nobody had
exercised yet.

apply_project had no all-edges branch: with no face picked and no explicit
edge list it threw "no edges or face selected". That is precisely the state
the Project card opens in, and its label reads "(all edges)" — so the card's
default could never be confirmed. It now projects every edge of the source
body. Edges perpendicular to the target plane collapse to a point when
projected, so segments whose endpoints coincide are dropped instead of being
emitted as zero-length lines that would poison the sketch downstream.

The second defect is why the first one was invisible. 29 of the 31 rollback
sites in McpControl ran `if (!ok) doc.undo();`, and undo() recomputes the
restored feature list — which succeeds and clears doc.error. Every failing
command therefore reported `error: ""`. Yesterday's fix covered 2 sites and I
treated the file as done; it was not. All 31 now capture the reason before
the rollback and restore it after. Failures that read as `""` now read as
"rib: bad entity" / "surface-revolve: revolve failed".

Verified on the running GUI through the control socket: the Project call that
previously returned ok:false now returns ok:true, and failures carry a reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:04:15 +02:00
Tommaso BianchiandClaude Opus 5 08e37296b9 Design tab: give every drawer entry a distinct icon
Six of the surface entries, both Thicken variants, Rib, Axis, Coord Sys,
Mate and Delete Face all reused a sibling's glyph, so a drawer opened as a
column of identical faces and the card that opened rarely matched the entry
clicked. Fixed both halves: entry icons are now unique within their drawer,
and each card header uses the icon of the entry that opens it.

Two new glyphs, design_thicken and design_rib, are the only ones added —
everywhere else an existing icon already carried the right meaning
(design_revolve, design_offset, design_line, design_point,
design_c_coincident, design_delete).

The Surface drawer BUTTON deliberately keeps design_surface; only its
entries may reuse the solid glyphs, because a menu row carries its own
text label while two adjacent toolbar buttons do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 06:44:16 +02:00
Tommaso BianchiandClaude Opus 5 77f2f4d4ad Design tab: datum and curve tools were rejected by the solid-preview check
refresh_preview() exempted only Sketch and Plane from the ghost-preview path.
Axis, CoordSys, Helix and Project produce no solid either, so they fell through
to it, found nothing, and reported "invalid: preview produced no geometry" —
which also DISABLED Confirm, so all four tools were unusable rather than merely
noisy. Guaranteed on an empty document; with a body present the ghost path
finds something and masks it, which is why it survived until the tools were
tried on a fresh project.

All six non-solid tools are now exempt, each with its own ready message. The
list is not a guess: recompute() skips Sketch, Helix, Plane, Axis and CoordSys
outright and routes Project through apply_project(), which emits sketch entities
and no solid — so the panel and the kernel now agree on exactly what is not a
solid. Mate already had its own branch, since it needs Confirm gated on having
two distinct CoordSys features.

Introduced when Axis/CoordSys (batch 1) and Helix/Project (batch 3) were wired
without extending this exemption. Confirmed fixed on hardware: Axis ->
Plane Intersection with XY and XZ now resolves on an empty document, which also
exercises 60b04feea1.

Compiles clean; kernel untouched, suite unaffected at 143 cases / 1980
assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 06:20:40 +02:00
Tommaso BianchiandClaude Opus 5 4d331099de CAD: Axis PlaneIntersection read its plane refs in the wrong index space
axis_plane_a/b are filled by the GUI from populate_plane_choices(), whose rows
are XY / XZ / YZ followed by the datum planes, and the row is stored verbatim.
The kernel's base_plane() indexed datum_planes[ref] directly, so the two spaces
were off by three: picking XY resolved to datum plane 0, and picking the first
datum ran past the end and failed with "plane ref not found". The
PlaneIntersection axis type could not work from the GUI at all.

base_plane() now uses the encoding CadFeature::plane_base already uses — 0/1/2
are the base planes through the modeling origin, >=3 indexes datum_planes[ref-3]
— so there is one convention for plane references instead of two. That also
makes two base planes usable, which the previous code rejected as out of scope
even though XY x XZ is an ordinary way to define the X axis.

Removed the dead find_plane lambda directly above it. It was never called and
half-anticipated this exact offset ("if (ref >= 3) // base plane offset"),
which is presumably where the confusion started.

Tests: the existing parallel-planes case encoded the OLD convention, passing
axis_plane_a = 0 to mean "datum 0" — values the GUI cannot produce — so it is
re-based onto rows 3 and 4. Two new cases cover what the GUI actually emits:
base x base (XY x XZ -> X) and base x datum, the latter pinning the +3 offset.
Both verified to FAIL against the previous indexing, at test_caddocument.cpp
:2325 and :2346.

Found by auditing the remaining tools for the index-space defect class that had
already produced three bugs in the GUI; this is the first instance of it
crossing the GUI/kernel boundary.

Suite 143 cases / 1980 assertions (was 141/1972). GUI compiles clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 05:17:53 +02:00
Tommaso BianchiandClaude Opus 5 0455b0bf96 Design tab: give the Surface drawer its own icon
The Surface drawer used design_extrude, the same face as the Add-material
drawer, so the two buttons were indistinguishable in the feature bar.
design_surface.svg is a draped patch — deliberately unlike design_plane (a flat
parallelogram) and design_extrude (a box with an up-arrow) — with a faint
interior rule so it reads as a skin rather than a solid face. Same visual
language as the other 71: 24x24, no fill, #b6b6b6, stroke-width 0.85, round
caps and joins.

The five surface ENTRIES that also used design_extrude now use it too. That is
not cosmetic tidying: a flyout button's face follows the last-picked entry
(SetBitmap_(icon_names[i])), so changing only the drawer's default icon would
have been undone the moment the user picked anything. Surface Loft keeps
design_loft, which already suits it.

The six rows still share one glyph between them, so they are told apart by
label alone inside the flyout. Per-entry icons belong with snaporca-vrg
(Draft/Shell reusing design_dressup), not here.

Compiles clean; kernel untouched. Icon confirmed legible on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:58:10 +02:00
Tommaso BianchiandClaude Opus 5 c565d6ba86 CAD: undo() never rolled back variables, so a bad one bricked the recipe
checkpoint() snapshotted `features` and undo() restored `features`, but
`variables` is a separate member of CadDocument. Every caller of the documented
checkpoint -> mutate -> recompute -> undo-on-failure pattern therefore failed to
roll a variable back: the bad value stayed in the document and every later
recompute failed, which is exactly the corruption the pattern exists to prevent.
Feature `expr` bindings were unaffected only because expr lives inside
CadFeature and rode along in the features snapshot — which is why the feature
side appeared to work.

This was a kernel gap, not a GUI one: McpControl::action_set_variable has the
same sequence and was equally broken.

The undo/redo stacks now hold a {features, variables} Snapshot. Nothing here is
serialized, so no recipe version change and no golden-fixture regeneration.

Two tests, both verified to FAIL against a faithful reproduction of the bug
(undo() leaving `variables` untouched) at test_caddocument.cpp:4420 and :4441:
one covers restoring a variable's previous value, the other covers removing a
variable that did not exist before the checkpoint. Worth recording that the
first mutation attempt was NOT faithful — it dropped the restore but kept
std::move(variables) into the redo stack, which empties the map as a side effect
and made the second test pass for the wrong reason. A mutation has to reproduce
the original defect, not merely break the code.

Second defect, same area: undo() calls recompute(), which succeeds and clears
doc.error, so the reason an edit was rejected was destroyed before anything
could display it. Six sites — four in DesignPanel, two in McpControl — now carry
the message across the rollback. on_remove_variable additionally asserted
"referenced by a feature expression" as fact; it now offers that as the likely
cause and appends the real error, since that diagnosis is wrong for any other
failure.

Suite 141 cases / 1972 assertions (was 139/1960). GUI compiles clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:56:34 +02:00
Tommaso BianchiandClaude Opus 5 33275f2c74 Design tab: sheet pickers targeted the wrong body; guard Delete Face's face list
The sheet-only pickers filtered correctly and then threw the filtering away. Their
rows are the SHEET bodies, but GetSelection() was passed straight through as an
index into m_doc.bodies. With a solid at 0 and a sheet at 1 — the normal order,
since you extrude a solid before making a surface — the single row is row 0 but
body 1, so Surface Offset and Thicken Surface targeted the SOLID. The kernel then
refused with "target is not a sheet", which reads as a kernel bug rather than a
picker bug, and the row's own label ("Body 2") disagreed with what was targeted.
Four sites per tool were wrong, including the re-edit path, which compared a body
index against the sheet-only row count and so restored the wrong row.

populate_sheet_body_choices() now carries the real body index in client data, and
two helpers make the row/body distinction hard to get wrong again:
sheet_choice_body() reads it back, select_sheet_choice() finds the row holding a
given body. No caller touches GetSelection()/SetSelection() on these pickers.

This is the third instance of the same index-space confusion in this file, after
the 0-based body labels in the interference report and the Rib sketch picker. The
kernel suite cannot catch any of them: the kernel receives whatever index the GUI
computed, and its own tests pass correct ones.

Delete Face was structurally right — its picker uses the all-bodies populate, so
its indices genuinely match, and accumulation appends with a running list. Two
gaps closed: clicking "Add picked face" with nothing picked was a silent no-op,
indistinguishable from a broken button, and the same face could be added twice,
putting a duplicate id into delete_faces that the defeaturing has no reason to
cope with. Re-adding is now a no-op with a message, not an error.

Both confirmed working on hardware. Kernel untouched: 139 cases / 1960 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:46:14 +02:00
Tommaso BianchiandClaude Opus 5 8621f1168e Design tab: show/hide the printer bed; give Placement its own toolbar slot
Port of snaporca ca25352d74. Hand-applied rather than cherry-picked: unlike the
Phase B commits, which only touched DesignPanel.{cpp,hpp} and transfer verbatim,
this one reaches into GLCanvas3D and DesignCanvas, where the forks genuinely
differ — mainline passes m_show_world_axes to _render_bed where Snapmaker passes
a local show_axes, and the surrounding code sits ~90 lines further down. git am
refused, correctly; the six edits were applied against mainline's own context and
the DesignPanel half came across as a patch.

Bed toggle: a "Bed" checkbox in the document/view row, on by default, in that row
rather than in a card because a view option must stay reachable with no tool open.
It drives GLCanvas3D::m_show_bed (default true, so Prepare and Preview are
untouched) and gates _render_platelist as well as _render_bed — hiding the bed
while leaving its grid and outline floating would read as a rendering fault.

Bound to wxEVT_TOGGLEBUTTON, not wxEVT_CHECKBOX: Orca's CheckBox derives from
wxBitmapToggleButton, so a wxEVT_CHECKBOX handler never fires.

Also gives the Placement drawer its own "placement" toolbar slot. put("place")
already holds the Place-on-Face button, and put() formats slot item 0 as the
control and later items as its chevron, so sharing the slot bottom-aligned the
drawer's button like a chevron.

196/196 targets, 0 compile errors, orca-slicer links (165 MB). The build script
still exits non-zero at the AppImage bundling step on libpython3.12.so.1.0 —
that is snaporca-96t, packaging only, and does not affect the binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:33:33 +02:00
Tommaso BianchiandClaude Opus 5 d4904b8e2e Design tab: stop eight tool cards rendering at startup; regroup the drawers
The sidebar opened with eight tool cards stacked in it — Transform, Mirror,
Thicken, Rib, Project, Delete Face, Helix and Mate. A card added to the cards
sizer is visible until something hides it, and close_tool()'s hide-all only
runs on a tool SWITCH, so anything missing from the construction-time hide
block is on screen from the moment the tab opens. Those eight were wired into
close_tool() but never added here. All 37 cards are now hidden at startup.

Worth stating because it invalidates a check I ran while diagnosing this: every
card IS hidden somewhere in the file, so grepping for "hidden anywhere" says
nothing. The block that matters is the one in the constructor.

Second, the drawers mixed unrelated operations, and two group tooltips no
longer described their contents — Dress-up listed eight tools spanning three
different kinds of operation, and Add material still claimed to hold only
extrude/revolve/sweep/loft after Thicken and Rib were added to it.

One concept per drawer now:

  Add material   extrude, revolve, sweep, loft, thicken, rib
                 -> grows new solid material, whether from a profile, a face or
                    a line
  Surface        unchanged; already coherent
  Datum / Curve  plane, axis, coord sys, helix, PROJECT
                 -> reference geometry and derived curves. Project consumes a
                    body but PRODUCES sketch entities, so it is curve creation,
                    not a finishing operation
  Placement      TRANSFORM, MIRROR, MATE                              (new)
                 -> moves a body without changing its shape; a mate places one
                    body relative to another
  Dress-up       fillet/chamfer, draft, shell, delete face
                 -> finishing on the faces and edges of an existing solid
  Hole / thread  unchanged

The new drawer costs no toolbar width: the layout order already contained an
empty put("place") slot between "material" and "plane" with nothing registered
to it. Shift+Y and Shift+Z follow Transform and Mirror; every tool still
appears exactly once.

Compiles clean; kernel untouched, so the suite is unaffected at 139 cases /
1960 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:39:53 +02:00
Tommaso BianchiandClaude Opus 5 6372b3b505 Design tab: M6 variables panel + per-feature expression bindings
M6 landed the kernel side as two plain public maps — CadDocument::variables and
CadFeature::expr — reachable only through MCP's set_variable / set_feature_expr.
Nothing in the GUI could create a variable, so the parametric layer was
unreachable from the Design tab.

Variables get a wxListCtrl (name, expression) with add/edit/remove below the
feature tree, since they are document-scope and must not live in a card that
only exists while a tool is open. Expression bindings get one generic row in the
feature-edit path — a field-name combo plus an expression box — rather than an
extra control on each of 32 cards.

Every mutation copies McpControl's sequence exactly, and the rollback is the
part that matters: checkpoint, mutate, recompute, undo() on failure. Without it
one typo leaves the recipe permanently unrecomputable, since load() replays the
whole list. Removing a variable a feature still references fails that recompute,
so it reports the reference rather than a bare evaluation error.

The field-name combo is deliberately editable: only 11 feature types get a
curated field list, and free text is what makes the other 21 reachable. That is
safe because assign_field() throws "unknown parameter: <name>" for anything it
does not know, inside recompute()'s try block — so a wrong name gives a clear
message and a rollback, never a silently dead binding.

Two fixes on top of the generated wiring:
- make_combo() passes wxCB_READONLY, under which Orca's ComboBox HIDES its text
  ctrl (ComboBox.cpp:51). There is no SetEditable() to undo that, so the field
  combo is constructed directly with style 0; that shows the ctrl with
  wxTE_PROCESS_ENTER and makes GetValue() return typed text.
- the field-list helper had been made a file-static function taking
  DesignPanel::Tool, which required moving Tool out of private and into the
  public API. It is now a private static member instead: 32 values of internal
  card state should not be published to satisfy a signature.

Compiles clean (0 errors); kernel suite unchanged at 139 cases / 1960
assertions. Phase B is complete on this fork — all 16 previously GUI-less tools
plus the variables panel. Not yet exercised on a display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:14:32 +02:00
Tommaso BianchiandClaude Opus 5 cf97b7aba8 Design tab: Mate tool + interference report — the 16 tools are now reachable
Mate completes the set: every CadDocument feature type now has a card. It is the
one that needed CoordSys wired first, since a mate connector IS a CoordSys
feature and cs_a/cs_b are feature indices into the recipe.

The card states what each kind constrains rather than just naming it, because
the five kinds are not distinguishable from their labels — the useful fact is
which DOF each PRESERVES: Fastened fixes all six, Planar leaves in-plane
sliding, Revolute leaves spin, Slider leaves axial travel, Cylindrical leaves
both. The offset/angle spins retitle per kind, since offset is a plane distance
for Planar and a position along the axis for the three joint kinds.

Confirm is blocked, with the reason in the status line, when fewer than two
CoordSys features exist or when A and B are the same one: a mate with cs_a ==
cs_b is meaningless and a dangling index recomputes to nothing useful.

check_interference() gets a button in the feature-tree header behind a rule, not
a tool card — it adds no feature, so it must not checkpoint(), recompute(), or
touch the undo stack, and a card would imply it does. Results go to the status
line as count + worst volume, with the per-pair list in a message box, named as
the parts tree names them.

Fixes on top of the generated wiring:
- the button's sizer adds sat after the closing brace of the block declaring
  trow, so trow was out of scope ("'trow' was not declared in this scope");
- the CoordSys client data was typed const void*, which Append rejects;
- the report labelled bodies 0-based while all 28 other body labels in this
  panel (and the parts tree) are 1-based, so it would have called the tree's
  "Body 2" an interference on "Body 1" — and it was the only unlocalised label.

Compiles clean (0 errors); kernel suite unchanged at 139 cases / 1960
assertions. Still to come: the M6 variables panel. The GUI has not yet been
exercised on a display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:14:32 +02:00
Tommaso BianchiandClaude Opus 5 9f01c78456 Design tab: GUI for 15 tools that only had an MCP method
Every CAD feature added across M1-M8 got a kernel API and an MCP method, and
almost none got a card. The MCP path was the only way to reach them, so from
the Design tab these features did not exist: add_axis and add_coordsys have
been callable since M1 with no UI at all.

Wired here, in three batches:

  datum    Axis (5 construction types), CoordSys
  surface  Surface Extrude / Revolve / Loft / Fill / Offset / Thicken Surface
  body     Transform, Mirror, Thicken, Rib, Project, Delete Face, Helix

Grouped into existing dropdowns rather than widening the 16-slot toolbar:
surfaces get one new "Surface" dropdown, body ops join Dress-up, Thicken/Rib
join Add-material, Helix joins Datum (renamed "Datum / Curve"). Shift+Y and
Shift+Z went to Transform and Mirror; the remaining five are shortcut-less
rather than getting invented chords.

Three places where the UI has to encode a kernel distinction, not just expose
a field:

- Surface Offset and Thicken Surface consume a SHEET body and fail with "target
  is not a sheet" on a solid, so their pickers filter on
  CadDocument::is_sheet_shape() and say so when no sheet exists. Thicken (solid
  face -> plate) is a different tool and is kept visibly separate.
- delete_faces is a vector, so Delete Face accumulates picks via "Add picked
  face" and shows the running list. Supporting one face would have been a
  silent downgrade of the kernel field.
- CoordSys labels its edge pick with the consequence of omitting it: without an
  edge, datum_frame() takes x from coordsys_x_hint (world constant) and the
  frame cannot express rotation about its own normal — which is snaporca-en4,
  and is why a Fastened mate built on a face-only connector cannot fix spin.

The Rib sketch picker needed the 3-arg Append(text, wxNullBitmap, clientdata):
ComboBox's own Append(text, bitmap) hides wxItemContainer's (text, void*), so
the 2-arg call resolves to the bitmap overload and fails with "conversion from
void* to const wxBitmap is ambiguous". The Sweep picker already documents this;
Rib now matches it.

Compiles clean (0 errors) against snaporca-deps; kernel suite unchanged at
139 cases / 1960 assertions. Mate, the interference report and the M6 variables
panel are still to come; the GUI itself has not been exercised on a display yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:14:32 +02:00
Tommaso BianchiandClaude Opus 5 afe6d11375 Make this fork's GUI link: OpenCV was reusing snaporca's JPEG-enabled build
First successful link of orca-slicer in this fork's history (158 MB, 738/738
objects, plus OrcaSlicer_profile_validator). 1633005bba got the test binary
green; the GUI had still never linked.

The blocker was in the deps image, not the code. That commit's message says
OCCT V7_6_0, Boost 1.84.0 and OpenCV 4.6.0 "are pinned identically in both
forks and were reused as-is". That is true of the VERSIONS and false of the
FLAGS: this fork's deps/OpenCV/OpenCV.cmake passes -DWITH_JPEG=OFF,
-DWITH_TIFF=OFF and -DBUILD_TIFF=OFF, and snaporca's does not. So the reused
build shipped lib/opencv4/3rdparty/liblibjpeg-turbo.a, which collides with the
deps' own lib/libjpeg.a — wx pulls that in via wxUSE_LIBJPEG=sys — on
jpeg_stdio_dest. Under -flto that duplicate is fatal, not a warning. snaporca
never sees it because its GUI does not link libopencv_world.a at all.

Fixed by rebuilding OpenCV 4.6.0 in orcacad-deps with this fork's own flags
(read out of the recipe rather than retyped), from the source already cached in
the image, into a clean build dir so no stale cache entry survived, and deleting
the orphaned bundled jpeg archive. The rebuilt libopencv_world.a has no jpeg or
tiff symbol references and its CMake config no longer names either library.
--allow-multiple-definition would have hidden this while leaving OpenCV carrying
codecs mainline deliberately turns off.

Lesson for the next dep: comparing deps recipes by version is not enough, diff
the CMAKE_ARGS.

Two more mounts, same root cause as the CMakeLists.txt mount this script
already documents — the baked tree is snaporca's:

- build_linux.sh, which builds `--target Snapmaker_Orca`; here the target is
  OrcaSlicer and its output name is orca-slicer, so configure passed and ninja
  then died on "unknown target". The binary check was looking for the wrong
  name too.
- scripts/, because the packaging step needs scripts/appimage_lib_policy.sh;
  without it a fully successful link still exited non-zero with "missing
  AppImage helper" and the binary check never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:09:26 +02:00
Tommaso BianchiandClaude Opus 5 ed9d02093e Default both build scripts to orcacad-deps, not the other fork's image
Both scripts defaulted IMAGE to snaporca-deps. That image is Snapmaker-based
and lacks Eigen 5.0.1, CGAL 5.6.3, wx 3.3.2 and Python 3.12 Development.Embed,
so running either script here without an explicit IMAGE= dies at CMake
configure — which is a large part of why this fork reached M8 having never once
compiled (1633005bba). The volume defaults were fixed in that commit; the image
default was missed in both files.

docker-iter-build.sh also never mounted deps_src, so it could not have
configured even with the right image: root CMakeLists.txt:947 FATAL_ERRORs
when deps_src/pybind11/include/pybind11/pybind11.h is absent, and
src/CMakeLists.txt pulls semver/hints/imgui/imguizmo/hidapi from the same tree.
kernel-test.sh got that mount in 1633005bba; this is the same fix for the GUI
build path, needed before the Phase B GUI work can be ported here.

CadDocument.hpp: mate_kind comment, mirrored verbatim from snaporca dbaa104f62
so the header stays byte-identical across forks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:35:25 +02:00
Tommaso BianchiandClaude Opus 5 1633005bba Make this fork actually compile: first green Catch2 run in its history
206/206 targets built, 139 [CadDocument] cases / 1960 assertions passing —
identical to snaporca's suite. Until now this fork had never compiled at all:
CMake died at configure, so the M1-M8 "suite green" figures were snaporca's
alone and the ports rested on patch-apply plus byte-identical sources.

Three fixes here; the deps work is in the orcacad-deps image (see below).

1. kernel-test.sh mounts deps_src. pybind11 is vendored in-tree and CMakeLists
   requires its headers; without the mount the container fell back to the
   image's baked tree, which predates it.

2. tests/libslic3r/test_3mf.cpp: repair the upstream-merge conflict resolution.
   Resolving it as a union dropped the three closing braces of our SCENARIO, so
   upstream's SCENARIO opened inside ours ("a function-definition is not allowed
   here", plus 12 cascading catch2 registry errors). Restored from the pre-merge
   file; whole-file brace balance is now 0 and the case count reconciles as
   5 (ours) + 8 (upstream) - 3 (shared) = 10, with both CAD recipe tests intact.

3. tests/libslic3r/test_caddocument.cpp: REQUIRE_CONTAINS / CHECK_CONTAINS.
   Catch2 v2 (snaporca) spells substring-match Matchers::Contains; v3 (here)
   spells it ContainsSubstring and gives Contains an incompatible meaning,
   range-contains-ELEMENT, which fails to COMPILE against std::string. Four
   sites had been hand-adapted long ago, but M2-M8 kept porting in un-adapted
   Contains calls — 16 of them — and nothing objected because nothing compiled.
   Both forks now use the same find()-based macros, so the assertion lines are
   byte-identical again and future format-patch ports carry across unchanged.
   Five orphaned `using Catch::Matchers::Contains;` lines removed with them.

The deps gap that blocked configure needed five additions on top of
snaporca-deps, built into image orcacad-deps: Eigen 5.0.1, Python 3.12.13
(exact, with Development.Embed), wxWidgets 3.3.2 (was 3.1.5), CGAL 5.6.3
(was 5.4 — mainline's own MeshBoolean.cpp calls CGAL::parameters::default_values,
added in 5.5), plus the pybind11 mount above. OCCT V7_6_0, Boost 1.84.0 and
OpenCV 4.6.0 are pinned identically in both forks and were reused as-is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:46:07 +02:00
Tommaso BianchiandClaude Opus 5 113e75e7b3 kernel-test.sh: stop defaulting to the other fork's build volume
BUILD_VOL defaulted to snaporca_buildcache — snaporca's volume — so running
this fork's kernel test wrote into the other fork's build cache. Defaults to
orcacad_kerneltest now.

docker-iter-build.sh had the identical defect and was fixed to
orcacad_buildcache; this script was missed at the time.

Observed rather than theorised: an orca_cad run under a different deps image
overwrote snaporca_buildcache's CMakeCache.txt, after which snaporca's own
kernel test failed to configure ("Cannot find NLopt library 'nlopt_cxx' in
.../lib/cmake/nlopt/lib") because it inherited the foreign cached paths. No
foreign object files were written — the run died at configure — but the cache
was poisoned, and the volume had to be wiped and rebuilt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:01:59 +02:00
Tommaso BianchiandClaude Opus 5 8c6df84acc Merge upstream/main into cad-mainline (530 commits)
Catches the fork up from 449a4cf9fc (2026-06-28) to d6cb667b89 (2026-07-24).
Upstream touched 2326 files; 17 of them overlap the 164 this branch touches.

16 of the 17 auto-merged, including all three CMakeLists.txt, the build_all.yml
CI workflow, and every GUI file. The CAD core never conflicts: CadDocument,
SketchEngine, SketchSolver, McpControl and test_caddocument are files this fork
adds, so upstream does not touch them.

The one conflict, tests/libslic3r/test_3mf.cpp, was purely additive in all three
hunks and is resolved as a union: our test pinning that store_bbs_3mf embeds the
CAD recipe as Metadata/SnapOrca_cad.bin, upstream's multi-nozzle plate-metadata
round-trip tests, and both sets of includes. All three were verified present
after resolution rather than assumed.

NOT BUILD-VERIFIED, for a reason that predates this merge and is not caused by
it: this fork cannot be configured on nativedev at all. Its CMakeLists has
required Eigen3 5.0.1 since before the merge (line 592 pre-merge), while the
only deps image on the machine is snaporca-deps, built for snaporca's
find_package(Eigen3 3.3). CMake fails at configure, so nothing compiles.

That means this fork's Catch2 suite has never run. Every "suite green" figure
recorded for M1-M8 was snaporca's suite; the ports were verified by patch-apply
plus the CAD sources being byte-identical to snaporca's. Building an orca_cad
deps image with Eigen 5.0.1 is what would finally close that gap.

Pre-merge state is preserved at branch cad-mainline-pre-upstream-2026-07-25
(30d54f0074).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:28:33 +02:00
Tommaso BianchiandClaude Opus 5 30d54f0074 M8c: interference detection
check_interference(min_volume) reports every pair of solid bodies whose
intersection encloses more than min_volume, as {body_a, body_b, volume}. It
reports only — no geometry is mutated, so calling it cannot disturb mates or
placements. Read-only at the MCP surface too: no checkpoint, no recompute.

Sheet bodies are skipped up front: an intersection involving one encloses no
volume, so the boolean would be wasted work. Bodies that merely touch share a
face and enclose nothing, so face-to-face contact is not an interference.

A boolean that fails on one pair must not lose the report for every other pair,
so each pair is guarded — and OCCT raises Standard_Failure, which is not a
std::exception and would otherwise escape.

No new serialized fields, no recipe bump: this reads `bodies`, which is
recompute output and was never serialized.

No separate MCP listing for instances and mates: describe_scene already emits
the feature tree, and Mate has rendered there correctly since M8a fixed
feature_type_name.

Tests assert the exact overlap volume (20*20*4 = 1600 mm^3), both negative cases
(clearly apart, and exact face contact), that sheets are skipped, that the
min_volume gate silences a real overlap, and that a clash created by a Fastened
mate is detected — which ties the M8b placement work to this report.

Suite 139 cases / 1960 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:15:30 +02:00
Tommaso BianchiandClaude Opus 5 6a0031c9e5 M8b: Revolute / Slider / Cylindrical mates
Each kind constrains the DOFs it owns and PRESERVES the rest from the body's
current pose, following the pattern Planar established in M8a. Resolved instead
as "Fastened with a parameter", all three would have been geometrically
identical to Fastened — relabelling rather than behaviour.

  Revolute     fixes position on the axis line; rotation about it survives
  Slider       fixes orientation and perpendicular position; axial position survives
  Cylindrical  fixes the axis line only; rotation and axial position both survive

No new serialized fields, no recipe bump, no fixture regeneration: mate_kind is
already an int and mate_offset / mate_angle already exist.

The minimum-rotation z-alignment (including the antiparallel 180 deg case fixed
in M8a) is now a shared make_z_align lambda rather than a second copy.

Fixes a rotation-about-pivot bug found by the no-op tests: R_full was built as a
rotation about the origin with a translation to oB appended, instead of a proper
rotation about oB (translation = oB - R*oB). It moved bodies that were already
correctly placed, and accounted for three of the seven initially failing cases.

Testing notes, both of which cost real debugging time here:

- Mates are defined on connector FRAMES, but the convenient thing to measure is
  CentreOfMass(), and the two coincide only when the body is symmetric about its
  connector. Five expectations in this milestone asserted the centroid while
  meaning the connector. These tests assert on the mated face's centroid.

- A CoordSys built from a face ALONE takes its z from the face normal (which
  follows the body) but its x from coordsys_x_hint, a world constant. Such a
  frame cannot see rotation about its own normal, so no mate can correct or
  preserve a spin it does not encode. The Slider and Cylindrical rotation tests
  pin coordsys_edge to an edge of their own body; without that both passed
  vacuously, one of them for a wrong implementation.

The Cylindrical rotation test was verified to fail when its mate kind is mutated
to Slider, and the Slider test failed at axis_aligned == 2 before the connectors
were edge-pinned. Neither is green by accident.

Known wart: mate_angle is silently ignored for Slider, whose rotation is fully
constrained. Defensible but undiagnosed at the API surface.

Suite 134 cases / 1927 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:30:37 +02:00
Tommaso BianchiandClaude Opus 5 b13ca01ccc M8a: assembly mates — Fastened + Planar (recipe v3)
An assembly is a multi-body document. An instance is already expressible as
Transform with xf_copy=true, and a mate connector is already a CoordSys
feature, so this adds exactly one feature type: Mate.

No constraint solver. A mate rigidly transforms the body carrying connector B
so that B's frame lands on connector A's, applied in feature order like every
other feature. Chains resolve by composition; closed kinematic loops do not
converge (last mate wins) and are out of scope.

The vendored SolveSpace in src/libslic3r/slvs/ was evaluated for 3D extension
and rejected: it is built and linked but has zero callers, and SketchEngine's
solver is hand-rolled. Extending it would mean adopting a dependency to write
more code than the alternative.

- CadFeatureType::Mate appended; six fields (mate_kind, mate_cs_a, mate_cs_b,
  mate_offset, mate_angle, mate_flip) appended at the END of both cereal lists
- SNAPORCA_CAD_RECIPE_VERSION 2 -> 3; v2 blobs are rejected, as by design there
  is no migration path. Golden fixture renamed to cad_recipe_v3.bin and
  regenerated once, extended with two CoordSys + one Mate so the new fields are
  tripwired by the field-order assertions
- datum_frame() extracted from resolve_datum_coordsys() so a mate can resolve
  its connectors against the in-progress bodies vector during replay
- apply_mate dispatched early-return, so Mate is deliberately absent from
  starts_new (unreachable for that dispatch style)
- Planar: the degenerate branch splits on the sign of zB.z_target — antiparallel
  needs a 180 deg rotation about a perpendicular axis, which an earlier revision
  silently skipped, leaving the body's normal inverted
- MCP: mate command, named bare to match the other 38 methods

Drive-by: feature_type_name() was missing Mirror, ThickenSurface, SurfaceOffset,
SurfaceLoft and SurfaceFill, which reported as "Unknown" to MCP clients.

Suite 122 cases / 1741 assertions green. Note that kernel-test.sh builds only
libslic3r_tests, so McpControl.cpp is reviewed but not compiled here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:51:22 +02:00
Tommaso BianchiandClaude Opus 4.8 9c28be5860 M7c: SurfaceLoft + SurfaceFill (open skins from profiles / a boundary)
The two remaining ways to create a sheet body. SurfaceLoft skins 2+ profile
sketches without end caps via a new SketchEngine::make_loft_surface — a
sibling of make_loft with the ThruSections solid flag false, so no existing
call site changes. SurfaceFill patches a single closed boundary wire into a
smooth face with BRepOffsetAPI_MakeFilling, adding each boundary edge as a
C0 constraint.

Purely additive: two enum values appended to CadFeatureType, reusing the
existing loft_profile_refs/loft_ruled and sketch_ref fields. No new cereal
fields, recipe stays v2, golden fixture unchanged (30773). MCP
surface_loft/surface_fill added as pure additions. Suite 107 cases / 1553
assertions green, including a test contrasting the open skin against the
solid loft of the same profiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 09:56:46 +02:00
Tommaso BianchiandClaude Opus 4.8 2da75d28ca M7b: thicken-from-surface + surface offset
Two features bridging sheet bodies back to solids and to other sheets:
ThickenSurface feeds a whole sheet shell to MakeThickSolidBySimple (the same
OCCT recipe the face-level Thicken already uses) and appends the result as a
solid; SurfaceOffset offsets a sheet's shell along its normals via
MakeOffsetShape::PerformBySimple, keeping it open. Both refuse a non-sheet
target with a clear error.

Purely additive: two enum values appended to CadFeatureType, reusing the
existing target_body / thicken_thickness / thicken_flip / plane_offset
fields. No new cereal fields, recipe stays v2, golden fixture unchanged
(30773). MCP thicken_surface/surface_offset added as pure additions.
Suite 103 cases / 1520 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 09:41:51 +02:00
Tommaso BianchiandClaude Opus 4.8 01e474e17c M7a: surface bodies — SurfaceExtrude + SurfaceRevolve (open shells)
Two body-producing features that emit an open shell instead of a capped
solid: SurfaceExtrude (prism of a sketch wire, no end caps) and
SurfaceRevolve (revolve of a wire about an in-plane axis, no caps). Each
appends a new sheet body whose TopoDS_Shape has no TopAbs_SOLID.

Purely additive: two enum values appended at the end of CadFeatureType,
reusing existing serialized fields (sketch_ref/distance,
revolve_angle/revolve_axis). No new cereal fields, recipe stays v2, golden
fixture unchanged. is_sheet_shape() derives sheet-ness from the OCCT shape
type (bodies are not serialized). MCP surface_extrude/surface_revolve added
as pure additions. Suite 99 cases / 1474 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 09:19:02 +02:00
Tommaso BianchiandClaude Opus 4.8 15ea0a813f M6: Variables & equations — parametric expressions driving feature dimensions
Add named document variables (CadDocument::variables) and per-feature
expression bindings (CadFeature::expr, field-name -> expression). On
recompute(), variables are evaluated topologically (cycle detection), then
each feature's expr entries are evaluated and written into its numeric fields
before geometry runs. Self-contained shunting-yard evaluator (+ - * /, parens,
unary minus, sqrt/abs/sin/cos/tan(deg)/min/max, pi). assign_field allow-lists
the 33 dimension fields + pattern_count; unknown names error loudly.

Additive: recipe stays v2 (fields appended to both cereal lists, golden
fixture regenerated). MCP set_variable / set_feature_expr are pure additions.
Suite 95 cases / 1440 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 08:11:06 +02:00
Tommaso BianchiandClaude Opus 4.8 aa30575369 M5c: pattern-on-curve — replicate a body along a sketch curve
Extend CadFeatureType::Pattern (no new enum) with a curve mode: when
pattern_curve_sketch >= 0 it takes precedence over linear/circular. The guide
entity is sampled at equal-parameter points via a file-local sample_entity_2d()
(Line lerp, Arc angle-lerp, cubic-BSpline Bernstein, p0->p1 fallback), and each
seed copy is translated by (P_i - P_0) and fused. Two serialized fields
(pattern_curve_sketch/pattern_curve_entity) appended to both symmetric cereal
lists (version stays 2, golden fixture regenerated 30269->30517). MCP:
pattern_on_curve. 3 new [CadDocument][pattern] tests; suite 89/1395.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 04:15:31 +02:00
Tommaso BianchiandClaude Opus 4.8 a606dfe00a M5b: rib — thin stiffening wall grown from an open sketch line
New CadFeatureType::Rib (appended). A straight open Line entity in a sketch
is offset ±thickness/2 along its in-plane perpendicular into a thin rectangle,
extruded rib_depth along the sketch-plane normal, and fused to the target body.
Line-only for now (ponytail; polyline/arc ribs are a later extension) — a
non-line entity fails cleanly at recompute. Four serialized fields
(rib_sketch_ref/rib_entity/rib_thickness/rib_depth) appended to both symmetric
cereal lists (version stays 2, golden fixture regenerated 29525->30269). MCP:
rib. 3 new [CadDocument][rib] tests; suite 86/1376.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 04:03:52 +02:00
Tommaso BianchiandClaude Opus 4.8 0bbf22ceae M5a: hole standards library — counterbore/countersink + ISO/ANSI table
Extend CadFeatureType::Hole (no new enum value) with a style flag
(simple/counterbore/countersink) and the matching geometry: a coaxial
shallow cylinder cut for counterbores, a cone-frustum cut for countersinks.
A file-local hole_std_lookup() resolves screw designations (ISO 273/4762/
10642 metric M3–M10 + common ANSI unified) into clearance/cbore/csink dims;
add_hole_standard() fills the feature from it, add_hole_styled() takes them
explicitly. Six serialized fields appended to both symmetric cereal lists
(recipe version stays 2, golden fixture regenerated 28161->29525). MCP:
hole_styled, hole_standard. 4 new [CadDocument][hole] tests; suite 83/1351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 03:52:01 +02:00
Tommaso BianchiandClaude Opus 4.8 e19e51b150 M4: delete-face direct edit — remove faces and heal via OCCT defeaturing
New CadFeatureType::DeleteFace: removes a set of global face ids from target_body
and heals the gap via BRepAlgoAPI_Defeaturing (TKBO, already linked), mirroring the
Shell/Draft body-modifying pattern. delete_faces appended to both symmetric cereal
lists (recipe version stays 2, golden fixture regenerated 27913->28161). MCP
delete_face method (pure additions). 3 new [CadDocument][deleteface] tests: remove a
fillet face restores the sharp-box volume, bad index fails safely, round-trip.
Full kernel suite green (79 cases, 1309 asserts).

Move-face / replace-face deferred to snaporca-3c4 / snaporca-tc6 (no clean shipping
OCCT direct-modeling primitive; need research, and replace-face depends on M7 surfaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 03:28:25 +02:00
Tommaso BianchiandClaude Opus 4.8 65caa2ea6e M3c: 2D bridging curve — cubic-Bezier G1 connector between sketch endpoints
Adds SketchEngine::make_bridge (4-pole cubic Bezier, G1-tangent to Line/Arc
endpoints, straight-line fallback for other types) emitted as the existing
BSpline SketchEntity — no new geometry type, no serialized-field change, golden
recipe fixture untouched. CadDocument::add_bridge appends it (non-parametric,
index-validated, throws on bad refs). MCP `bridge` method mirrors action_project.
4 new [CadDocument][bridge] tests; full kernel suite green (76 cases, 1280 asserts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 01:59:47 +02:00
Tommaso BianchiandClaude Opus 4.8 62d39fba27 test(cad): lock construction-geometry flag with real regression tests
The construction flag was already honored (excluded from the extrude wire in
SketchEngine.cpp, participates in the solver, and serialized) but the existing
"construction line excluded" test was a false tripwire: its construction line
ran corner-to-corner inside the square, so the bbox was unchanged whether or not
the line was excluded.

- Strengthen that test: the construction line now runs (-30,0)->(30,0) outside
  the profile, so an exclusion regression breaks the closed wire / bbox.
- Add a serialize/deserialize round-trip test asserting construction survives.
- Lock the flag on-disk: add Sketch_Ctor to the golden fixture with a real edge
  + a construction edge, and assert both flags survive the binary recipe.

Test-only; no kernel change. Recipe version stays 2 (construction was already a
serialized field). Suite: 72 cases / 1246 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 00:27:16 +02:00
Tommaso BianchiandClaude Opus 4.8 161d006e92 CAD: Project feature — convert solid edges into a parametric sketch
Onshape-style "Use / Convert entities": pick edges (or a whole face) of an
existing solid and get sketch geometry projected onto a target plane, then
extrude/revolve/edit it like any sketch. Parametric: apply_project re-derives
the feature's entities from the source body on every recompute, so editing the
source updates the projection.

Line edges -> Line entities (exact); circles/arcs whose plane is parallel to
the sketch plane -> Circle/Arc (exact); everything else (incl. non-parallel
circles that project to ellipses) -> sampled Line chain.

Append-only: new enum value Project + project_source_body/project_edges/
project_face fields at the end of save/load; recipe version stays 2. Recompute
loop made non-const solely so apply_project can write back f.entities.

Suite 67->71 cases, 1178->1229 assertions, RC=0. Fixture 25493->26835 B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 23:42:40 +02:00
Tommaso BianchiandClaude Opus 4.8 07b39d45cb CAD: split a body by a picked face; add the missing both-halves cut test
Split-by-face reuses the existing Cut feature rather than adding a new type:
two appended fields (cut_face_body, cut_face) let apply_cut derive the cut
plane from a picked face via SketchPlane::from_face when cut_face >= 0,
otherwise it keeps using the base `plane`. cut_offset / cut_flip still apply
along the derived normal, so the same square-wire split machinery handles
both cases. add_split_by_face() is the convenience entry point; MCP gains a
`split` method (body / face_body / face / keep_upper / keep_lower).

Serialization stays append-only — cut_face_body, cut_face appended to
save/load, recipe version unchanged at 2.

Tests: the previously-missing both-halves plane cut (keep_upper && keep_lower
=> two bodies whose volumes sum to the original), split-by-face via a
top-face plane offset into the interior, keep-upper-only, and a round-trip.
Golden fixture regenerated with a GoldenSplit cut-by-face feature and exact
field-value assertions. Suite 63 -> 67 cases, 1119 -> 1178 assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 21:46:24 +02:00
Tommaso BianchiandClaude Opus 4.8 9da5851534 CAD: Thicken feature — offset a face into a thin solid plate
Pick a face of an existing body, offset it by a wall thickness along its
normal, and append the resulting thin solid as a new body. Onshape-parity
Tier-2 item; the kernel had Shell (hollow a whole solid) but no way to turn
a single face into a plate.

Kernel: CadFeatureType::Thicken, add_thicken()/apply_thicken() as a
body-level op next to Transform/Mirror. The picked face is wrapped in a
TopoDS_Shell and offset via BRepOffsetAPI_MakeThickSolid::MakeThickSolidBySimple;
the result is orientation-normalised to positive volume (same convention as
apply_mirror). Serialization stays append-only — thicken_face,
thicken_thickness, thicken_flip appended to save/load, recipe version
unchanged at 2.

MCP: `thicken` method (body/face/thickness/flip) plus the missing
feature_type_name() case.

Tests: 6 new [CadDocument] cases (plate volume within 1%, flip direction,
bad face id, zero thickness, fuse-with-source, round-trip). Golden fixture
regenerated with a GoldenThicken feature and exact field-value assertions.
Suite 57 -> 63 cases, 1054 -> 1119 assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 18:34:15 +02:00
Tommaso BianchiandClaude Opus 4.8 0100c95ed1 CAD: Transform feature — move/rotate a body as a real B-rep operation
Until now move and rotate lived only in the GUI as m_body_xform, a display
transform. That made them a correctness hole, not a missing tool: a moved body
recomputed and booleaned at its ORIGINAL position, and the move was not in the
recipe at all, so it vanished on save/reload. Only export_step consulted the
transform, which is why the discrepancy stayed hidden.

CadFeatureType::Transform makes it a real feature: rotate angle_deg about
xf_axis through xf_pivot, then translate by xf_translate, applied to the target
body with BRepBuilderAPI_Transform. xf_copy=true keeps the source and appends
the transformed body instead of mutating in place, which covers Onshape's
Transform/copy in the same feature.

Rotation is composed before translation (trsf = tr * rot) so the pivot means
what a user expects — the point the body turns about, not a point that then
drifts with the translation. A rotation with a degenerate axis is refused
rather than silently skipped; a zero angle skips the rotation entirely so a
pure move needs no axis at all.

The decisive test is not the bbox arithmetic but "moved body participates in a
later boolean at its new position": two coincident boxes, one moved to partial
overlap, fused. The fused volume must be strictly greater than one box (the
move took effect in the kernel) and strictly less than both (they still
intersect). With a display-only transform the first assertion fails.

Serialization stays append-only; recipe version unchanged at 2. Golden fixture
regenerated with a GoldenTransform feature carrying distinctive literals so a
field reorder shows up as obviously wrong values. Also fills in the Helix arm
of feature_type_name(), missing since the helix commit.

Kernel suite 51 -> 57 cases, 985 -> 1054 assertions, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 17:50:08 +02:00
Tommaso BianchiandClaude Opus 4.8 017bb08a1d CAD: helix / spiral curve, consumable as a Sweep path
Adds CadFeatureType::Helix — the missing input for Sweep. Sweep already
existed but could only follow a sketch, so springs, coils, augers and
non-standard-pitch threads were unreachable. Helix + the existing Sweep now
gives all of them with no further work.

Built the OCCT way: a 2D line on a Geom_CylindricalSurface (Geom_ConicalSurface
when helix_taper_deg != 0) turned into an edge and lifted to 3D with
BRepLib::BuildCurves3d — a true analytic helix, not a sampled polyline, so a
swept spring is smooth rather than faceted. The axis is the plane normal
through the plane origin, matching how Revolve and Plane already work.

Sweep's path resolution is widened to accept either a Sketch (unchanged
behaviour) or a Helix, and rejects anything else with a clear error. Helix
itself is skipped in route_feature and recompute — like the datum features, it
produces no body and exists to be consumed.

Invalid input is refused rather than approximated: non-positive radius or
pitch, negative height, a turn count above 10000 (which would hang OCCT), and
a taper that would drive the radius negative before reaching the top all fail
with a specific error.

Serialization: helix_radius/pitch/height/left_handed/taper_deg appended at the
very end of both save and load, identical order, after the coordsys block.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Helix is appended to the end of
CadFeatureType. Golden fixture regenerated with distinctive literals and
field-value assertions; all pre-existing assertions pass unchanged.

Tests assert analytic values. The one that actually proves it is a helix and
not a circle or a spiral: arc length of r=5 pitch=2 height=10 measured with
BRepGProp::LinearProperties against 5*sqrt((2*pi*5)^2 + 2^2), WithinRel 1e-3.
Plus bounding box (2r in X and Y, height in Z), the conical top radius, the
left-handed winding compared at equal parameter, and the integration test:
a circle r=1.5 swept along a 5-turn helix gives one valid solid of ~1115 mm^3
(WithinRel 0.1 — pipe sweeping is not exact).

MCP: `helix` method registered in describe_tools().

Ported from snaporca 6cdc6b5459. Two fork-specific adjustments: the two new
error-message assertions use Catch2 v3's ContainsSubstring (v2's Contains does
not exist here), and the golden fixture is copied rather than regenerated
because this fork cannot be compiled locally — make_golden_doc_v1() is
byte-identical across both forks, so the blob is provably the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 16:39:36 +02:00
Tommaso BianchiandClaude Opus 4.8 93e80bc407 CAD: datum axis and datum coordinate system
Adds CadFeatureType::Axis and ::CoordSys — reference geometry that produces
no solid, modelled on the existing Plane datum feature.

Axis construction methods (AxisType): TwoPoints, FaceNormal,
CylinderCenterline, PlaneIntersection, AlongEdge. The centreline case is the
useful one: it gives a real axis through an existing hole or boss.

Coordinate systems (CoordSysType): PointWorld and FaceAndDirection. The
latter Gram-Schmidts the picked references, so the stored frame is
orthonormal even when the user's X hint is not perpendicular to the face
normal; the third axis is derived by cross product rather than stored, so it
cannot drift out of sync.

Revolve and pattern are deliberately NOT rewired to consume these — this
commit adds the reference geometry only and changes no existing behaviour.

Serialization: all axis_*/coordsys_* fields appended at the very end of both
CadFeature::save and load, identical order, after mirror_keep_original.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Axis and CoordSys are appended to the
end of CadFeatureType so existing type ordinals are unchanged. Golden fixture
regenerated with distinctive non-default literals and field-value assertions
for every new field; all pre-existing assertions pass unchanged.

Tests assert analytic values: two-point axis direction exactly +Z with unit
length, cylinder centreline collinear with Z and on the true axis, parallel
planes fail cleanly, and the Gram-Schmidt frame is orthonormal to 1e-9 with
X x Y == Z. Degenerate input (identical points) fails with a non-empty error
rather than producing NaNs.

MCP: `axis` and `coordsys` methods registered in describe_tools().

Ported from snaporca 242d4efecb. The golden fixture is copied rather than
regenerated because this fork cannot be compiled locally (Eigen 5.0.1 vs the
build image's 3.3); make_golden_doc_v1() is byte-identical across both forks,
so the two fixtures are provably the same blob.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 15:58:13 +02:00
Tommaso BianchiandClaude Opus 4.8 c980725e9d CAD: mirror body feature (reflect a solid about a plane)
Adds CadFeatureType::Mirror: reflect a target body about a plane using
gp_Trsf::SetMirror + BRepBuilderAPI_Transform. BooleanMode::New keeps the
mirrored copy as its own body (mirror_keep_original decides whether the
source survives); BooleanMode::Add fuses it back into the source, so an
overlapping mirror does not double-count volume.

Serialization: mirror_keep_original is appended at the very end of both
CadFeature::save and load (append-only contract). The mirror plane reuses
the existing `plane` member and the body selector reuses `target_body`,
as Cut already does. Golden fixture regenerated at the current
SNAPORCA_CAD_RECIPE_VERSION = 2; the existing field-value assertions all
still pass unchanged, and the reorder tripwire was re-verified after
regeneration (swapping draft_face/draft_angle in `load` alone still
fails the golden test).

Tests assert analytic values: mirrored volumes equal (8000 each) with the
reflected centroid, Add on a non-overlapping asymmetric body gives exactly
2x volume, Add across an intersecting plane gives strictly less than 2x,
and an invalid body index fails cleanly with a non-empty error.

MCP: `mirror` method registered in describe_tools().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 11:52:29 +02:00
Tommaso BianchiandClaude Opus 4.8 b807be3c4a CAD mass properties: volume / area / centre of mass / inertia
Port of snaporca c2821af783. New GeometryEngine::mass_properties over BRepGProp
(separate VolumeProperties/SurfaceProperties), bounds-checked
CadDocument::body_mass_properties, and a mass_properties MCP method. Query-only:
no CadFeature, no serialization, no version change. Analytic tests
(WithinRel/WithinAbs): cube 8000/2400/COM(0,0,10)/inertia 533333, cylinder
500pi/300pi, hollow = solid-500pi, invalid index -> valid=false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 23:18:42 +02:00
Tommaso BianchiandClaude Opus 4.8 8edc12c8f1 CAD recipe v2: legible version-mismatch errors, refuse old files cleanly
Port of snaporca 04c3d579a7. Bump SNAPORCA_CAD_RECIPE_VERSION 1 -> 2;
deserialize_recipe sets a user-facing error distinguishing too-new / too-old
/ corrupt instead of a silent bare false. No per-version migration by design.

Golden fixture regenerated at v2 (v1 retired), field-value reorder tripwire
unchanged. Fork adjustment: Catch2 v3 string matcher ContainsSubstring
(not v2's Contains) in the two new version-mismatch tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 22:32:56 +02:00
Tommaso Bianchi d04b02cd0e test: golden on-disk fixture that actually detects a serialization reorder
CadFeature::save/load is append-only by contract, and ~20 planned features
each append fields. The existing roundtrip test cannot police that: it writes
and reads with the same code, so any self-consistent ordering passes. Only a
blob written by older code and stored on disk can detect that the format moved.

The first attempt at this test passed while the defect was present. I proved
it by swapping draft_face (int) with draft_angle (double) in both save() and
load() -- a genuine byte-layout change -- and it still reported 613 assertions,
exit 0. It asserted only derived geometry: body count, per-body volume, feature
types. The golden document had no Draft feature, so those fields sat at their
defaults, the reorder scrambled values nothing read, and the recomputed solids
came out byte-identical.

So the fixture now asserts the DATA, not what the data produces:

- make_golden_doc_v1() builds 22 features across 14 types (Draft, Shell,
  Revolve, Pattern, Cut, Hole, Chamfer, Fillet, Extrude taper/symmetric,
  Thread, Sweep, Loft, Boolean, Plane) with distinctive non-default literals
  (draft_angle 7.25, shell_thickness 1.375, revolve_angle 217, pattern_count 5)
  so a reorder produces visibly wrong values rather than swapped defaults.
- Layer 1 reads the committed blob with raw cereal and asserts field by field,
  independent of recompute, so a geometry regression cannot mask a format break.
- Layer 2 keeps the geometry checks as a separate concern.

Verified to trip, twice, by deliberate breakage rather than by assertion:
  draft_face   <-> draft_angle   -> draft_face reads 1075642368 (0x401d0000),
                                    the high half of double 7.25
  revolve_angle <-> revolve_axis -> revolve_angle reads 0.0, not 217.0
Both revert clean to 758 assertions / 30 cases.

Also scoped the "regenerate the fixture" hint to the feature-count check only.
It was in scope for every assertion in the block, so a detected reorder told
you to run [.regen] -- which would bake the corrupted layout in as the new
golden and permanently disarm the test. A guard must not advise disabling
itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q

Ported from snaporca f9e0f99bcb. The patch needed fuzz: this fork's copy of
test_caddocument.cpp carries a Catch2 v3 include, a `using Catch::Approx`, and
an extra [Deviation] case appended after line 1611 -- exactly where these hunks
land. Verified after applying: new symbols present, the [Deviation] case
intact, braces balanced, and only WithinAbs/WithinRel used (both exist in v3).
Compile and test verification here is CI, not local: this fork needs Eigen
5.0.1 while the local deps image ships 3.3.
2026-07-23 14:41:31 +02:00
Tommaso BianchiandClaude Opus 4.8 3621785984 build: headless CAD-kernel test loop, and quarantine two broken tests from it
Adds scripts/kernel-test.sh: build the libslic3r_tests target and run the CAD
kernel tags, exit code as the whole contract. Clean build 4m36s, incremental
18s, no display needed -- every [CadDocument] case builds a CadDocument,
recompute()s it and asserts on geometry.

Four things this had to get right, each found by it going wrong first:

- SLIC3R_GTK=3 and BUILD_TESTS=ON are mandatory. src/CMakeLists.txt turns
  SLIC3R_GTK into 'wx-config --toolkit=gtk<N>', so omitting it asks for
  toolkit "gtk", nothing matches, and configure dies with the thoroughly
  misleading "Could NOT find wxWidgets" -- while wx-config sits right there
  in the deps prefix, working. BUILD_TESTS=ON is what creates the target.
- Configure runs unconditionally. Guarding on "CMakeCache.txt exists" is
  wrong because a FAILED configure writes that file too, after which the
  guard skips reconfiguring forever and every later run silently reuses the
  poisoned cache, ignoring corrected flags.
- A new build volume is always built clean. Cloning a warm cache from
  another tree is a correctness trap: rsync preserves source mtimes, ninja
  compares them against foreign object timestamps, concludes everything is
  current and relinks stale objects. That produced a binary containing NO
  [CadDocument] tests at all -- while exiting 0. A green run that tests
  nothing is worse than a red one.
- --host builds where the deps image already lives, staged per volume so
  parallel workers never share a tree.

The two [known-broken] tags: "entity constraints: tangent/midpoint/symmetric/
angle" aborts inside the vendored solver (slvs/dsc.h FindById, "Cannot find
handle"), and SIGABRT is fatal to the Catch2 process -- that single case took
the suite down at 12 of 31, so a green baseline was unreachable. The thread
groove case is a plain pre-existing assertion failure. Both are excluded from
the dev loop's default filter ONLY; ctest in CI still runs and reports them,
so neither bug is hidden. Baseline is now 603 assertions / 29 cases green,
which is what makes "I broke nothing" a meaningful statement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 13:47:25 +02:00
Tommaso BianchiandClaude Opus 4.8 52a8ca965c docs: capability gap analysis of the Design tab against Onshape
Enumerated from the source rather than from recollection: CadFeatureType and
add_* in CadDocument.hpp, Tool in DesignPanel.hpp, Mode in
DesignSketchTool.hpp, SketchConstraintType + SketchEntity::Type in
SketchEngine.hpp, and the JSON-RPC dispatch in McpControl.cpp.

Findings worth stating up front:

- The 2D sketcher is at or near Onshape parity -- 19 constraints, every entity
  type including B-splines and elliptical arcs, trim/extend/offset/mirror and
  both array kinds. Very little is missing there.
- The gaps are all breadth beyond sketching: assemblies/mates, surface
  modelling, sheet metal, drawings, and variables/configurations.
- The most defensible criticism is the absence of variables and expressions.
  Every dimension is a literal double, so the feature tree is parametric in
  structure but not in value -- "change one number and the model updates" is
  only half delivered. It is also the cheapest Tier 1 item to close.

The doc separates platform capabilities (version control, FeatureScript, FEA,
rendering, cloud PDM) into their own tier rather than counting them as missing
tools: that is Onshape-the-platform, not Onshape-the-modeller, and holding a
slicer tab to it would not be a fair comparison.

One entry is a correctness gap rather than a missing feature: move/rotate body
(m_body_xform) is display-only and never enters the B-rep, so a moved body
exports and booleans at its original position while the viewport shows it
moved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 13:10:24 +02:00
Tommaso BianchiandClaude Opus 4.8 8aea63a919 docs: rewrite the upstream brief from measurement, correcting two errors
The 2026-06-21 assessment was written before the persistence work landed and
got two load-bearing facts wrong. Both are corrected here against the branch
itself rather than from recollection:

1. It called OCCT "a dependency mainline OrcaSlicer has never carried" and
   built its whole conclusion on that. False: deps/OCCT/ exists at the
   merge-base, and upstream already links it from Format/STEP.cpp,
   Format/svg.cpp and Shape/TextShape.cpp. The real dependency diff is one
   line -- BUILD_MODULE_ModelingAlgorithms OFF -> ON -- costing a measured
   3.77 MiB of Windows DLLs (TKFillet + TKOffset; TKBool already arrives
   transitively via DataExchange).

2. It described the vendored SolveSpace solver as LGPL. False:
   src/libslic3r/slvs/LICENSE is GPL-3.0. Harmless for us, but a licence
   must not be misstated in a document aimed at upstream.

It also claimed no changes to Model, which stopped being true when 3MF
recipe persistence added a std::string there.

The rewrite replaces prose estimates with counted figures: 138 new files,
23 modified upstream files at +457/-75, nothing deleted, 99.3 % of the diff
in new files. That reframes the ask from "adopt a CAD kernel" to "widen a
build flag you already carry", which is the argument that actually has a
chance upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-22 20:16:31 +02:00
Tommaso BianchiandClaude Opus 4.8 2db59bb85a Design: trace the solid-pick path behind SNAPORCA_PICK_TRACE
Selection failures on a real desktop kept looking identical from the UI
whether the ray missed the solid, the click was rejected as a drag, or the
press never reached the tool at all. The status line added in edb1adbfa3
reports WHICH body/face was picked, so it distinguishes "picked" from
"silence" and nothing finer -- not enough to tell those three apart.

This narrates the whole press -> release -> ray path on stderr, one distinct
line per failure mode:

  down x= y=                      the press reached the tool
  up with no pending press        the press was eaten upstream
  up ... drift=N / rejected       the click-vs-drag threshold decided
  ray tris=N -> body= face= t=    the ray reached the solid (or missed)
  no solid data (bodies= mesh=)   the pick pointers were never wired

Gated on getenv("SNAPORCA_PICK_TRACE"), cached in a function-local static,
so a normal build pays one load and prints nothing. It ships enabled-on-
demand because the failing environment is a real X session with a real
mouse, which the headless rig cannot reproduce.

First use retired a wrong theory of my own: the trace showed drift=0 on
three consecutive picks, so the widened threshold in edb1adbfa3 was not
what fixed anything. The reported symptom is best explained by a stale
pre-orcawidgets binary left running alongside the current one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-22 19:06:05 +02:00
Tommaso BianchiandClaude Opus 4.8 edb1adbfa3 Design: widen the solid-pick click threshold, and report what got picked
Ports snaporca-cad f5c7e74e9b.

The LeftUp pick discarded anything moving more than 4 px total since the
press. A hand-held mouse drifts that much during an ordinary click, so
real clicks were thrown away as drags and it read as "selection does not
work". Use 8 px per axis, GTK's own drag threshold.

Also report the pick on the status line (body / face / edge). A solid
pick previously set no text at all, so its only feedback was the viewport
highlight, and a pick that registers but draws faintly looked identical
to one that never fired.

DesignSketchTool.cpp copied verbatim (identical between the forks apart
from this change). DesignPanel.cpp took the status hunk only, since this
fork keeps mainline's Item-based DropDown in feat_dropdown/ToolFlyout.

Not confirmed on hardware yet, on either fork; this fork remains
uncompiled (needs Eigen 5.0.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-20 20:57:46 +02:00
Tommaso BianchiandClaude Opus 4.8 7858cd6b6a Sketch: open the dimension field on the right monitor, and stop it freezing the view
Ports snaporca-cad c741fb9677.

SketchInlineEditor::open() clamped its position with
wxGetClientDisplayRect(), which describes only the PRIMARY monitor. On a
multi-head desktop (the reporting machine runs 5760x1080 across screens
at +0, +1920 and +3840) a field anchored on the left or right screen was
clamped onto the middle one and left invisible, while m_awaiting_length
made the sketch tool consume every mouse event until it was answered:
orbit and pan died after any sketch, with Enter the only way out. Clamp
to the display the anchor is actually on instead.

Also let drags and the wheel through that freeze, so a field that lands
somewhere unexpected degrades to odd placement rather than a dead
viewport.

Both files were byte-identical between the forks apart from this change,
so they are copied verbatim. Verified on the snaporca side by the user;
this fork is still uncompiled (needs Eigen 5.0.1, which the available
deps image does not provide).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-20 10:14:02 +02:00
Tommaso BianchiandClaude Opus 4.8 b3e7d65cca Design: port the Orca-widget rebuild and the mouse-press fix from snaporca
Mirrors snaporca-cad a016dffb48 + 4b828c4798 so the two forks do not drift:

- Sidebar on Orca's widget set: ComboBox for all 25 pickers, StaticBox
  frames for the tool cards / feature tree / bodies list, framed double
  spins, and no stack of full-width action buttons (Prepare's left panel
  is parameters only).
- Toolbar: document group + undo/redo + mode-gated tools, commit far
  right, ordered via keyed slots, at Prepare's 40 px / 4 px geometry.
- Polygon options and a new Move/Rotate card (distance, axis, angle) in
  the sidebar instead of inline in the toolbar row.
- DesignSketchTool: stop consuming the LeftDown over a solid. Orca starts
  a rotate drag on the press, so swallowing it killed orbit/pan whenever a
  body was on screen. The pick now resolves on LeftUp within 4 px.

DesignSketchTool.{cpp,hpp} were byte-identical across the forks and are
copied verbatim. DesignPanel.cpp needed one hand-merge: mainline's
DropDown is Item-based (DropDown::Item{text,tip,icon}, DropDown(items&))
where snaporca passes parallel vectors, so feat_dropdown/ToolFlyout keep
the mainline form. Checked field-by-field against this fork's
Widgets/DropDown.hpp.

scripts/docker-iter-build.sh: mount the root CMakeLists.txt and cmake/
rather than inheriting the baked copies, which silently drops the
SLIC3R_CAD gate, and stop defaulting to snaporca's build volume - sharing
it made the two forks overwrite each other's cache and binary.

NOT COMPILED. This fork needs Eigen 5.0.1 (find_package(Eigen3 5.0.1
REQUIRED)) while the available snaporca-deps image supplies 3.3, so it
needs its own deps build to verify. The behaviour above was verified only
on the snaporca side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-20 07:48:36 +02:00
Tommaso BianchiandClaude Opus 4.8 f4469b8450 Design: use Orca's teal CheckBox instead of raw wxCheckBox
Design drew raw OS wxCheckBoxes (grey square + inline text) where Prepare shows
Orca's teal check, one of the most visible reasons the two panels looked
unrelated. The six sidebar checkboxes (extrude flip, hole through, thread
internal, revolve flip, boolean keep-tool, loft ruled) now use Widgets/CheckBox:
the widget carries no text, so each label moves into the row's left column,
which is also Prepare's row idiom and matches the label/control grids.

CheckBox is a wxBitmapToggleButton, so its per-control Binds and the panel-wide
preview refresh listen for wxEVT_TOGGLEBUTTON as well; boolean/loft get a
label+control row instead of a bare full-width control. Checkboxes align to the
left edge of the control column, as Prepare aligns its own.

The two sketch-toolbar checkboxes are left alone — they live in the top toolbar,
not the left panel.

Verified on :10: Flip direction renders as a teal check with a white tick and
toggles correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 13:15:35 +02:00
Tommaso BianchiandClaude Opus 4.8 d25ff88d76 Design tools: label-left/control-right rows, and drop the empty-state gaps
Second half of the Prepare alignment. The 14 tool forms were plain 2-column
wxFlexGridSizers whose control column never grew, so every control sat at its
natural width right next to its label — nothing lined up, and it looked nothing
like Prepare's "label ......... [value]" rows.

- two_col_form() builds the grid with a growable control column; all 14 forms use
  it and their 56 controls are added with wxEXPAND, so controls fill one aligned
  column at the panel edge.
- The sketch-session Plane row is a box sizer, not a grid, so it gets a stretch
  spacer for the same effect.
- The DoF readout collapses when empty, and the Bodies block starts hidden — both
  reserved a blank line on a fresh document, leaving dead space above the action
  buttons that Prepare does not have.

Verified on :10: Extrude edit shows Extrude dist / End / 2nd dist / Taper /
Result as aligned label+control rows; Plane row right-aligns; no Bodies box on an
empty document.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 12:23:15 +02:00
Tommaso BianchiandClaude Opus 4.8 7d2a63ffcb Design sidebar: align with Prepare's visual language
The two tabs used different idioms for the same concepts, which reads as two
different apps: Design was 264 px against Prepare's ~467 (the canvas edge jumped
on every tab switch), used bare micro-labels where Prepare uses icon + Head_14
card headers with a rule, hung its row actions on a loose strip under the tree
instead of in the section header, and drew raw OS-default wxButtons next to
Prepare's Orca-styled ones.

- Width now tracks Prepare's sidebar at runtime (sync_sidebar_width() reads the
  live width on tab activation) rather than being hardcoded, so the two cannot
  drift apart if Orca changes its sidebar.
- "Feature tree" and "Bodies" use the card_header() helper the panel already had
  (icon + Label::Head_14) plus a wxStaticLine, exactly as the tool cards do.
- The six row actions moved into the Feature tree header, Prepare-style, at
  header weight (24 px) instead of 36 px control weight.
- Buttons are Orca Buttons (ButtonType::Expanded, full width); Commit to Plate
  gets ButtonStyle::Confirm as the tab's primary action.
- Margins/spacing come from SidebarProps (ContentMargin/TitlebarMargin/
  ElementSpacing) instead of hardcoded 12/6/4.

Verified on :10: sidebars are the same width and share the header idiom.
Still to do: label-left/control-right rows inside the tool dialogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 12:09:44 +02:00
Tommaso BianchiandClaude Opus 4.8 b9e927876d Design: move bodies into their own Parts list (Onshape-style)
Bodies were appended INSIDE the feature tree, after the features, and only when
there was more than one body. Two consequences:

- With a single body — sketch + extrude, the common case — no body row existed
  at all, so the solid could not be selected from the tree. That also blocked
  Move (it requires a selected body), the show/hide eye and every body-targeted
  op; the viewport was the only way to select.
- With several features the Bodies group was pushed past the tree's auto-sized
  height (capped at 9 rows) and clipped out of view, so bodies became
  unreachable as history grew.

Bodies now live in their own list under the feature tree, mirroring Onshape's
Features + Parts split that the rest of the tab already follows. The list is
hidden while empty, sizes to its content (scrolls past 6), keeps the selected
row across a recompute, and greys hidden bodies as before. Selecting in either
list clears the other, so only one thing is ever "the target".

Verified on :10: single body -> Body 1 listed, selectable, Move opens the gizmo
on it (previously impossible); two imported solids -> both listed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 11:35:11 +02:00
Tommaso BianchiandClaude Opus 4.8 8d529ad413 Size the Design move/rotate gizmo from the body, like Orca's Prepare gizmos
The Design gizmo already had both move arrows and rotation rings, but drew them
at a fixed 70 px arm regardless of the body: on a 40 mm cube everything
collapsed into a ~100 px tangle buried inside the solid, so the rings were
effectively invisible and the tool read as "move only, no rotate".

Orca's Prepare gizmos size themselves from the selection's bounding sphere
(GLGizmoRotate3D: m_radius = Offset + sphere radius) so the handles always clear
the object. Same rule here: DesignPanel passes the body's bounding-sphere radius
(scale-aware) into the gizmo, and move_gizmo_arm() returns
max(70 px, 1.25 * radius) — the screen-space floor keeps it grabbable on a tiny
body or when zoomed far out. Ring radius and BOTH hit-tests derive from that one
helper, so picking cannot drift from what is drawn.

Verified on a 40 mm cube: rings now encircle the body, arrow drag moves with a
live mm readout, ring drag rotates live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 08:36:26 +02:00
Tommaso BianchiandClaude Opus 4.8 f4160595b0 Kill the remaining Design-tab UI freezes
query_topology: indexing a body face-by-face was quadratic — face_by_index
re-walks the explorer and edge_by_index rebuilds the whole indexed map on every
single call. On a 15.7k-face / 25.6k-edge imported solid this blew past the
MCP 15 s main-thread timeout with the UI frozen throughout. GeometryEngine
gains faces_of()/edges_of(), which enumerate once in the very same order (ids
stay interchangeable with the _by_index accessors, so fillet/up_to_face targets
are unaffected). Measured on that body: 15 s timeout -> 0.46 s.

Feature ops: every commit-time m_doc.recompute() (fillet, cut, shell, boolean,
extrude, ...) now goes through recompute_guarded(), which runs the rebuild on a
worker thread. Live-preview/drag paths stay inline on purpose — yielding inside
a drag would be worse than the stall.

run_off_ui_thread(): the progress dialog is now created only after 300 ms, so a
fast op does not flash a dialog, while input stays blocked (wxWindowDisabler)
for the whole operation since the worker owns the document.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-15 00:13:10 +02:00
Tommaso BianchiandClaude Opus 4.8 01aa6903f0 Harden the GL canvases against dropped frames and UI-thread freezes
Viewport: GLCanvas3D::on_idle() cleared m_dirty even when
_refresh_if_shown_on_screen() rendered nothing because the canvas was not on
screen yet — a frame requested while the notebook was still showing a page got
silently swallowed and the viewport stayed blank until some later event dirtied
it again. _refresh_if_shown_on_screen() now reports whether it rendered, and
on_idle keeps the canvas dirty when it did not. Covers Design/Prepare/Preview.

Freeze: a big STEP (17.8 MB) spent ~40-50 s inside OCCT on the UI thread
(read_step_solids + recompute), so the window stopped repainting and the
compositor marked the app unresponsive. Both now run on a worker thread behind
an app-modal pulsing progress dialog: the UI keeps painting and the document
cannot be touched while the worker owns it. OCCT's Standard_Failure is not a
std::exception, so the worker catches it explicitly — an escaping exception
would terminate the process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-15 00:04:25 +02:00
Tommaso BianchiandClaude Opus 4.8 6d9368276c Fix Design tab not painting on hardware GL after a page switch
request_repaint() only invalidates the canvas (Refresh()) on the hardware-GL
path and relies on a wxEVT_PAINT to follow. When the notebook re-shows the
Design page, that invalidation is issued mid-show and dropped: no paint event
arrives, the canvas never renders, and the pane stays blank until another tab
switch forces an expose. Software GL renders directly, so it never showed there.

force_repaint() defers past the show, then Refresh() + Update() for a
synchronous paint. Called from on_tab_shown().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-14 23:26:58 +02:00
Tommaso BianchiandClaude Opus 4.8 343a0439f1 Import a triangle mesh as an editable B-rep body (mesh2step port)
Opening an STL/OBJ in the Design pane now rebuilds it into a real OCCT B-rep
solid that the face/edge feature tools can operate on, instead of a print mesh.

GeometryEngine::mesh_to_brep is a native C++ port of mesh2step
(github.com/tommasobbianchi/mesh2step): vertices and edges are shared across
triangles at construction time (vertex cache by deduped index, edge cache by
unordered index pair), so no BRepBuilderAPI_Sewing pass is needed to rebuild the
topology afterwards, and watertightness falls out of the edge-usage counts for
free. An open mesh is returned as a shell and reported as such — never dressed up
as a fake solid.

It runs in-process on the OCCT kernel libslic3r already links, so no STEP file is
written or re-read. That is not an optimisation but the whole point: a faceted
STEP of a 62k-triangle mesh is ~149 MB and OCCT's STEPControl_Reader takes >300 s
to parse it back, so routing this through a file would hang the GUI.

Coplanar neighbours are merged (ShapeUpgrade_UnifySameDomain, 5° default) so the
body arrives with pickable CAD faces rather than one face per triangle — on the
20,656-triangle test part that is 20,614 faces down to 4,784. Without it the
import is technically a solid but nothing you can meaningfully fillet or extrude.

- Design pane: "Import mesh" button + Shift+M; warns above 50k triangles.
- MCP: import_mesh {path, tolerance, merge_angle_deg}, returning the full
  conversion stats so a caller can tell an honest solid from an open shell.
- Catch2: cube round-trip (exact volume, 12 faceted faces, 6 after merge), open
  mesh stays a shell, and the scale-independent sliver rule that a naive
  area < tolerance^2 test would get wrong.

Verified end-to-end on the real 20,656-triangle ir3v2 hotend STL: reproduces
mesh2step's Python run exactly (20,614 kept, 42 degenerate, 0 boundary edges,
2 non-manifold edges, not watertight) and the resulting body's bbox matches the
one FreeCAD reports for the same part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-11 07:02:33 +02:00
Tommaso BianchiandClaude Opus 4.8 c618965a4c Add -DSLIC3R_CAD compile-time gate for the Design/CAD tab
Gate the entire parametric Design/CAD subsystem behind a single CMake
option so the fork can be built with or without it. With SLIC3R_CAD OFF
the build is behaviour-neutral against upstream OrcaSlicer; this is the
"parallel build" for upstream integration discussion.

Gated surface:
- option(SLIC3R_CAD) + add_definitions(-DSLIC3R_CAD)
- deps/OCCT/OCCT.cmake: BUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD}
  (OFF matches upstream OCCT exactly; ON adds only TKFillet + TKOffset)
- TKFillet/TKOffset link, add_subdirectory(slvs) + libslvs
- CAD kernel + GUI sources, GLGizmoPrimitive (needs GeometryEngine),
  CAD Catch2 tests
- 11 upstream C++ hook sites (GLCanvas3D, MainFrame, GLGizmosManager)
- TabPosition and gizmo EType enums switched to implicit numbering so
  OFF reproduces upstream indices exactly

Verified on behemoth both ways: OFF and ON link snapmaker-orca +
libslic3r_tests (exit 0); ON shows the Design tab and passes 8 [design]
+ 1 [Deviation] tests, OFF omits them and shows upstream's tab layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 18:12:53 +02:00
Tommaso BianchiandClaude Opus 4.8 546cef5f42 Windows packaging: assert every linked OCCT toolkit has a DLL to ship
The previous guard only caught an empty glob. That is the wrong invariant.
The glob ships whatever the deps prefix holds, which is not the same as what
libslic3r links.

This fork sets BUILD_MODULE_ModelingAlgorithms=ON; upstream OrcaSlicer sets it
OFF. Upstream's DataExchange module pulls in most ModelingAlgorithms toolkits
transitively, but not TKFillet and TKOffset -- those two exist only because we
turned the module on. So a source build against a deps tree carried over from
upstream has 40 of the 42 OCCT DLLs. The glob copies all 40, the build
succeeds, and the slicer dies at launch with "error 126 (dependency not
found)". Reported by SoftFever, who named exactly those two DLLs.

CI is unaffected: the deps cache key is hashFiles('deps/**'), so flipping the
OCCT flag invalidated it and every shipped artifact has all 42.

Publish OCCT_LIBS from libslic3r as the single source of truth and check each
toolkit has a DLL, so the list can never drift from what we link. Verified
against a simulated stale prefix: the configure fails naming exactly
TKFillet.dll;TKOffset.dll, and passes when both are present.

Windows-only: every call site of the copy function is inside if (WIN32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 08:48:11 +02:00
Tommaso BianchiandClaude Opus 4.8 d568e89c34 Windows packaging: fail the configure when the OCCT glob matches nothing
The glob that replaced the hand-maintained OCCT DLL list traded a loud
failure for a silent one. The old code named each DLL explicitly, so an
unpopulated deps prefix made file(COPY) error out at configure time. A glob
that matches nothing instead yields an empty list, copies nothing, and leaves
the install manifest without a single TK*.dll -- producing a package that dies
at launch with "error 126 (dependency not found)".

This only bites source builds whose CMAKE_PREFIX_PATH lacks bin/occt (OCCT's
install layout varies by version); CI populates it, so every shipped artifact
is intact. Restore the loudness rather than ship a slicer without OpenCASCADE.

Windows-only: every call site of the copy function is inside if (WIN32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 08:39:56 +02:00
Tommaso BianchiandClaude Opus 4.8 3d9b36f108 Add Romanian (ro_RO) localization
Romanian was absent from this fork and from upstream, so there was no
catalog to reuse. Machine-translated with a local qwen3.6 model against the
union of both forks' .pot files; 99.0% coverage (5472 singular + 9 plural).
Header credits it as machine translation pending native review.

Registering wxLANGUAGE_ROMANIAN in supported_languages[] is what actually
exposes the language: the Preferences dropdown filters the installed
dictionaries against that allowlist, so a valid .mo alone would ship a
language nobody could select.

Translations that failed to preserve their printf/boost placeholders were
left empty rather than shipped, since msgfmt --check-format is fatal in
run_gettext.sh and would break the build on all three platforms. Those 57
strings fall back to English. Verified: run_gettext.sh exits 0 and the
compiled catalog has 0 placeholder mismatches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 07:14:13 +02:00
Tommaso BianchiandClaude Opus 4.8 5e272d4f5c CI: build on push to cad-mainline
The push trigger listed main/release/belt-printer but not our working branch
cad-mainline, so pushes to it never auto-built. Add cad-mainline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-03 07:06:18 +02:00
Tommaso BianchiandClaude Opus 4.8 493acb6059 Windows packaging: bundle ALL OCCT DLLs via glob (fix launch error 126)
The portable/installer hardcoded an OCCT DLL list that drifted from what
libslic3r actually links (OCCT_LIBS) + their transitive OCCT deps. The
CAD build links TKFillet/TKOffset/TKBool (and pulls TKFeat/TKBin*/TKIGES/
TKRWMesh/TKSTL/TKVRML/TKXDEIGES/TKXml* transitively) which were absent
from the shipped zip -> OrcaSlicer.dll failed at launch with error 126
(dependency not found). Replace both hardcoded lists (the file(COPY) and
the install manifest) with a glob over the built occt/ dir so the package
can never drift from the build again. Windows-only: OCCT is Shared solely
on Windows (deps/OCCT/OCCT.cmake); macOS/Linux static-link it. Reported by Marc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-03 00:52:05 +02:00
Tommaso BianchiandClaude Opus 4.8 f8175fc9a4 Design: fix hole placement (top-face default) + invisible internal thread
Hole: with no face explicitly picked, the tool fell back to the XY datum at
z=0 (the model's underside), so placing a hole from a top view read parallax-
shifted. Default to the solid's top face (top_face_index_of) so the footprint
sits on the surface being viewed; the XY/XZ/YZ dropdown still overrides.

Internal thread: the bore was re-cut at the nominal radius, which coincides
with an existing hole's wall — the coincident faces fouled the groove boolean
so it removed ~nothing (invisible thread). Cut the bore at the minor diameter
(radius - depth) instead: strictly inside any existing wall, leaving it clean
for the groove; on solid stock it forms the tap-drill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-02 20:18:41 +02:00
Tommaso BianchiandClaude Opus 4.8 2a9a433127 Design: rename 'near' lambda to 'is_near' (MSVC windows.h macro clash)
Windows minwindef.h defines legacy 'near'/'far' as empty macros, so MSVC
mangled `auto near = ...` (C2513) and every near(...) call (C2679/C2678 on
Vec2d). gcc/clang were unaffected, so only the Windows build failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-02 14:04:55 +02:00
Tommaso BianchiandClaude Opus 4.8 3ba6feb7a7 tests: fix hex-escape-out-of-range in CAD recipe 3mf test
gcc reads "\x10cad..." as one escape (c/a/d are hex digits → 0x10CAD > 255).
Split the string literal so the \x10 escape terminates. clang let it slide;
gcc (Linux/Windows CI) errored and blocked the OrcaSlicer build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-02 13:06:28 +02:00
Tommaso BianchiandClaude Opus 4.8 1673c2c760 Design tab: keyboard shortcuts, plane/axis toggles, section view
- Keyboard shortcuts (Onshape-style, three scoped layers): feature tools on
  Shift+letter, 2D sketch tools on single letters (in-sketch), view/nav on
  single letters (out-of-sketch). Dispatched via the DesignPanel CHAR_HOOK.
- View toggles: P = origin planes, A = world axis triad (render_view_helpers).
- Section view (non-destructive): a single horizontal clip that hides half the
  model to inspect inside, showing only the solid remaining half (no ghost).
  Left-panel "Section View" button or X toggles it; PageUp/PageDown move the
  plane; "Flip Section" button or F shows the opposite half. Never a body/Cut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-01 23:19:18 +02:00
Tommaso BianchiandClaude Opus 4.8 de16d566b2 Design tab: value-label titles + 5 sketch/UX fixes
Six fixes to the Design tab, all live-verified on :10:
- Datum-plane re-pick: wxEVT_CHOICE on m_draw_plane re-planes the live sketch
- Delete-feature dismisses its lingering settings card (on_delete_feature)
- Revert rotation-orbit regression (no feed_bodies/reload on close_tool)
- Sketch undo/delete via focus-independent CHAR_HOOK (Ctrl+Z/Y, Delete)
- Fix undo hang: reset_autoedit() clears dangling auto-edit sequence
- Titled value fields: each inline dim field shows its role (Width, Height,
  Radius, Angle, Length, Side, Distance, Major, Minor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-01 19:19:23 +02:00
Tommaso BianchiandClaude Opus 4.8 5a005d4728 Design: in-canvas gizmos for Draft/Cut + operand highlight for Boolean/Sweep/Loft
Closes the gizmo-parity gap (bd snaporca-4h5): the five solid features that were
card-only now give in-canvas feedback like their siblings.

Draft — angle-arc drag gizmo (clone of the Revolve gizmo): once a side face is
picked, a cyan arc anchored at the face centroid shows the taper angle; drag the
tip or type the angle, the live ghost tapers with it. Axis = world +Z (the neutral
pull direction), clamped [-89, 89].

Cut — plane offset-arrow + cutting-plane rectangle (clone of the Shell arrow, adds
a wire rectangle in the cut plane sized to the target body bbox). The arrow drags
the signed offset along the plane normal; the rectangle rides at the cut position;
the ghost splits live. (Also covers bd snaporca-1gh / snaporca-mmr.)

Boolean / Sweep / Loft — operand highlighting (new by-index highlight infra):
- Boolean tints the target body teal-green and the tool body orange (per-index
  body tint added to the DesignCanvas GLVolume colour loop + set_operand_bodies).
- Sweep tints the profile sketch cyan and the path sketch magenta.
- Loft tints every selected profile sketch green.
  Sketch tints reuse the DisplaySketch overlay via a feature-index -> colour map
  (sketch_hl_color); DisplaySketch struct unchanged. All self-gate by active tool
  and clear on close_tool.

Wiring mirrors the existing gizmo pattern 1:1 (m_*_active / render_* / set_* /
clear_* / update_* in refresh_preview / DesignCanvas passthroughs / render dispatch
+ on_mouse drag branch). Both forks; DesignSketchTool.{cpp,hpp} + DesignCanvas.hpp
+ DesignPanel.hpp byte-identical across forks. Built clean on both. Draft, Cut and
Boolean highlight live-verified on :10; Sweep/Loft sketch tint is code-complete and
build-clean (visual check pending — needs hand-drawn profile/path sketches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-01 10:19:33 +02:00
Tommaso BianchiandClaude Opus 4.8 8cbf5b393b Design: Measure-style dimension labels (+ projection fix), New Design, tree auto-fit, i18n pin, UX
Sketch dimension labels — now identical to the Prepare/Preview Measure gizmo:
- draw_text repurposed to draw_dim_label: white ImGui text in a translucent-white box,
  mirroring GLGizmoMeasure::render_dimensioning exactly (push_common_window_style sets the
  text colour, BringWindowToDisplayFront, imgui_internal.h).
- ROOT-CAUSE FIX: world_to_screen_px multiplied two Eigen Transform3d objects
  ((proj * view).matrix()); a projection is not affine so Eigen mangled it -> garbage screen
  coords, so labels never appeared. Now proj.matrix() * view.matrix() like Measure. (That
  helper was previously [[maybe_unused]] dead code, never exercised.)
- Leaders: offset clear of the sketch line (no longer coincident with the geometry), single
  point-to-point dimension line + arrows, neutral colour, width 0.6 -> 0.2.
- dim_text appends mm/in on linear dims (angles keep the degree sign).

New Design + delete:
- New "New Design" button wipes the whole document (confirm dialog) — the clear-all the
  per-row Delete can't give. CadDocument::clear() now also clears bodies + display_body_meshes
  (it left them stale, so solids lingered after a clear).
- on_delete_feature: a Body-row selection now shows a helpful hint (bodies are recomputed
  results with no directly-removable feature) instead of silently doing nothing.

Feature tree: auto-fits its content (refresh_tree clamps height 1..9 rows, scrolls past),
instead of a fixed 140px block.

i18n (Design tab pinned English, per the UX contract):
- Restore the lost #undef _L / #define _L(s) wxString::FromUTF8(s) override atop DesignPanel.cpp;
  wrap all ~54 dropdown options in _L so the single lever governs them. feature_type_name left
  untranslated (machine-facing MCP JSON).

UX: per-card Value Confirm/Cancel buttons removed — the single ribbon action bar owns value
confirm/cancel via an m_value_cont guard in tool_confirm/tool_cancel. "needs a body" status
messages unified.

Both forks; DesignSketchTool.{cpp,hpp} + CadDocument.cpp byte-identical across forks. Built
clean; New Design / feature-delete / rotation / tree auto-fit live-verified on :10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 23:21:04 +02:00
Tommaso BianchiandClaude Opus 4.8 6e11cfc49b Design: Measure-style dimension labels + restore English-only pin + UX consistency
Dimensions (uniform with Prepare/Preview):
- Repurpose DesignSketchTool::draw_text -> new draw_dim_label that renders each
  sketch dimension as the exact Prepare "Measure" gizmo label: white ImGui text
  in a translucent-white box, positioned via the existing world_to_screen_px
  projection inside the active ImGui frame. All 29 label call sites convert with
  no churn; the bespoke Hershey vector font is retired.
- dim_text appends mm/in on linear dims (angles keep the degree sign).
- Placed-dimension leaders simplified to a single point-to-point line + arrows in
  a neutral colour (no extension lines), matching the Measure look.

i18n (Design tab pinned English, per the UX contract):
- Restore the lost "#undef _L / #define _L(s) wxString::FromUTF8(s)" override atop
  DesignPanel.cpp so one lever de-translates the whole tab, ending the half-EN/IT
  state. Wrap all ~54 dropdown options in _L so the single lever governs them.
- feature_type_name left untranslated (it feeds the MCP JSON, machine-facing).

UX consistency:
- Remove the per-card Confirm/Cancel buttons from the Value card; the single ribbon
  action bar now owns value confirm/cancel via an m_value_cont guard in
  tool_confirm/tool_cancel (one confirm surface, per contract).
- Unify the eight divergent "needs a body" status messages to one template.

Both forks; DesignSketchTool.{cpp,hpp} byte-identical across forks. Built clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 22:01:54 +02:00
Tommaso BianchiandClaude Opus 4.8 f341ee2677 MCP bridge: expose all 16 methods live + fix optional-param required-ness
- snaporca_mcp_bridge.py: _param_schema maps array/object types + carries
  per-param description; _FALLBACK_TOOLS expanded slice-1 -> full 16-method
  surface (socket-down at client startup still shows the whole toolset)
- describe_tools: give genuinely-optional params a default (body -1, profile [],
  shell.face -1, extrude.distance2 0 / up_to_face -1) so the bridge no longer
  marks them required (draft.face/edge/path/a/b/reference stay required)

Product code byte-identical to snaporca (md5 match). orca_cad slicer builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 18:05:54 +02:00
Tommaso BianchiandClaude Opus 4.8 b93b9d558c Mirror [Deviation] surface_deviation test into test_caddocument.cpp
orca_cad's test_geometry.cpp is upstream Catch2 v3 with no CAD suite, so the
surface_deviation check lives alongside the CAD tests here. Built + ran green
in orca_cad's kernel-test docker: All tests passed (4 assertions). Both forks
now carry the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 17:55:06 +02:00
Tommaso BianchiandClaude Opus 4.8 94cde2af21 MCP slice 5: body-targeted fillet/chamfer, ordered slice contours, surface-deviation validate, widen Build
- fillet/chamfer accept optional `body` (sets target_body; edge id resolved against THAT body)
- slice_body chains section segments into ordered contours, each flagged closed/open
- validate_against adds surface_deviation (one-sided Hausdorff via BRepExtrema_DistShapeShape)
- new actions pattern/shell/draft; extrude gains end=blind|symmetric|two_sided|through_all|up_to_face + taper/flip/distance2
- GeometryEngine::surface_deviation helper (byte-identical to snaporca; Catch2 [Deviation] test lives in snaporca, whose test_geometry.cpp carries the CAD suite)

Product code byte-identical to snaporca (md5 match). Both forks build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 17:52:08 +02:00
Tommaso BianchiandClaude Opus 4.8 d2f97a752b MCP slice 4: widen Build (revolve, fillet, chamfer, hole, boolean) + profile
Reconstruction is no longer box-only. Five new actions, all thin
wrappers over existing CadDocument::add_* (the same kernel the GUI
calls): revolve (sketch -> add_revolve), fillet/chamfer targeted at a
measured edge id from query_topology, hole (add_hole), and boolean
(union/subtract/intersect over two bodies). extrude and revolve also
accept an optional closed profile=[[x,y],...] -> add_sketch_profile,
the Measure->Build bridge: feed a measured/sliced contour straight back.

Shared helpers plane_from / bool_from / profile_from. Each action is
transactional (checkpoint -> add -> recompute -> undo on failure ->
refresh) and returns post-state. Live-verified: fillet, chamfer (clean
edge), hole, profile-extrude (L), revolve (ring), boolean union (3->2
bodies). MCP method catalogue now 13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 16:45:01 +02:00
Tommaso BianchiandClaude Opus 4.8 8db9544a1a MCP slice 3: import_step + validate_against (close the RE loop)
Add the Build-input and Validate ends of the reverse-engineering loop.
import_step loads a STEP as native B-rep bodies (the reference part to
measure), reusing the GUI Import path (read_step_solids -> Import
features -> recompute). validate_against compares a body to a reference
({step:path} | {body:id}) and reports volume delta %, bbox delta, and
centroid offset via OCCT GProp + Bnd_Box — the RE skill's actual
acceptance metric (the "scarto %"); surface-deviation heat-map is the
upgrade path.

With this the full Understand -> Measure -> Build -> Validate loop is
live: 8 methods (describe_tools/describe_scene/query_topology/measure/
slice_body/import_step/validate_against/extrude). Verified: import a STEP
then validate body vs same STEP = 0.0%% delta / 0.0mm offset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:52:20 +02:00
Tommaso BianchiandClaude Opus 4.8 5b46b1ed1f MCP slice 2 (Measure): query_topology, measure, slice_body
The "evidence" half of the reverse-engineering loop — read-only
perception that turns a body into measured numbers. query_topology
lists faces (centroid/normal, cylinder radius+axis when round) and
edges (length, circle radius); measure returns distance (and angle when
both refs have a direction) between two {face|edge|point} refs;
slice_body cross-sections a body by a base plane at an offset and
returns world polylines (sections-as-evidence). All reuse GeometryEngine
topology accessors; slice_body adds one BRepAlgoAPI_Section call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:42:44 +02:00
Tommaso BianchiandClaude Opus 4.8 294bbccd0b Add stdio MCP bridge for the control socket
Zero-dependency Python stdio MCP server that exposes the app's control
socket as first-class MCP tools. Builds the tool list live from the
app's own describe_tools reply (introspection drives the schema), and
forwards tools/call to the Unix socket. Falls back to the slice-1 tool
set and reports a clear error when the app socket is down — never
crashes. Registered via a workspace .mcp.json (ssh stdio to the build
host); activates on the next session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:37:39 +02:00
Tommaso BianchiandClaude Opus 4.8 4f8a0d133f Add MCP control surface (slice 1): socket server + describe/extrude
Predispose the Design tab to run under an external MCP agent. New
McpControl.{cpp,hpp} embeds a line-delimited JSON-RPC 2.0 server over a
Unix domain socket, off unless env SNAPORCA_MCP is set. Requests are
marshalled onto the wx main thread and run through the SAME CadDocument
kernel the GUI uses, via two thin DesignPanel hooks (mcp_doc /
mcp_after_change) — no parallel engine.

Slice-1 methods: describe_tools (introspection -> the bridge builds tool
schemas), describe_scene (feature tree + per-body bounding boxes), and
extrude (centred rectangle sketch -> new solid). Every op returns its
full post-state. POSIX-only; Windows compiles to a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:32:51 +02:00
Tommaso BianchiandClaude Opus 4.8 5ecb319dee Reject .f3d import with a STEP redirect hint
Fusion 360 .f3d uses Autodesk's closed ShapeManager kernel — no offline
reader exists and it is input-only even in Autodesk's own cloud API, so
native import/export is infeasible. Intercept .f3d at priv::load_files
(the single funnel for drag-drop and File > Open) and show a dialog
pointing the user to export STEP from Fusion, then load any other files
in the batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:00:05 +02:00
Tommaso BianchiandClaude Opus 4.8 aa2907f730 Design: STEP export of the CAD model
Add an "Export STEP…" button to the Design panel that writes every body to a
.step file as native B-rep (not mesh).

- CadDocument::export_step: compound all bodies (applying their per-body Move
  display transform so the STEP matches what Commit ships) and write via OCCT
  STEPControl_Writer (AsIs). Full error handling incl. OCCT Standard_Failure.
- DesignPanel::on_export_step: bake any open preview, wxFileDialog save, export
  at the displayed body positions, status feedback.

Kernel write path verified with a standalone OCCT box->STEP->readback check
(1 solid, non-null) against the same OCCT build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 08:21:03 +02:00
Tommaso BianchiandClaude Opus 4.8 30713f0f23 Design: re-editable Boolean features
Boolean features can now be re-edited from the feature tree (op, target/tool
body, keep-tool, fuzzy tolerance), funnelling through the same replace_feature
path as every other editable feature.

- on_edit_feature: route CadFeatureType::Boolean to the Boolean card.
- load_feature_into_dialog: populate the Boolean card from the saved feature.
- populate_body_choices(as_of_feature): when re-editing, list the bodies as they
  existed just before the boolean (replay the recipe truncated to that slot) so a
  consumed tool body still appears and the saved target/tool selections round-trip
  instead of collapsing to one entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 07:56:30 +02:00
Tommaso BianchiandClaude Opus 4.8 71b7a73e86 Design tab: reference planes at bed centre, slot dims, hole cube handle, real threads
Port of the snaporca CAD work to the mainline fork.

- Onshape default planes (XY/XZ/YZ) at the bed centre (transparent, labelled);
  modeling origin unified to the bed centre (CadDocument::modeling_origin);
  world-axis triad moved to the bed centre on the Design canvas only.
- Datum plane: clickable ghost-plane base pick + draggable offset arrow.
- Slot: dims reassessed to inter-centre distance / radius / angle; fixed the
  duplicate cap-arc radius quote.
- Hole: 3D cube move-handle on the face; binds to the face on the first click;
  decluttered side-distance construction lines.
- Thread: derive the M spec (diameter/pitch/depth) from a picked cylindrical
  surface or circular edge (GeometryEngine::circle_of_edge); fuse the helical
  ridge onto the existing body; MakePipeShell fixed-binormal sweep (uniform, no
  twist) + self-intersection/param guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 00:24:39 +02:00
Tommaso BianchiandClaude Opus 4.8 852804450c Design re-edit: seed build_candidate from the edited feature (fix new-box bug)
Mirror of snaporca-cad 75045ff. Re-editing an Extrude spawned a NEW misplaced
box: build_candidate() rebuilt the feature from live tool state, but GUI
extrudes store their profile as `entities` (sketch_ref = -1) which the edit
card never restores, so the candidate had an empty profile and replace_feature
swapped in a degenerate extrude. Fix: when editing, seed the candidate from the
feature being edited and skip add-time structural re-derivation (profile source,
up-to-face, target body); the card still overrides scalar params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 07:49:32 +02:00
Tommaso BianchiandClaude Opus 4.8 b355d04d3a CAD re-edit: mid-timeline rebuild tests + non-silent Edit fallback
Mirror of snaporca-cad a82cb0d. snaporca-88g — three [CadDocument] tests
proving recompute() replays the whole timeline, so editing any feature (not
just the last) rebuilds downstream: mid-timeline extrude edit -> fillet
rebuilds; first-feature sketch edit propagates the chain; the same survives
serialize_recipe()/deserialize_recipe(); a sketch->extrude->hole->chamfer
chain rebuilds hole+chamfer on a mid-edited extrude. All 3 pass (34 assertions,
Catch2 v3). on_edit_feature edits the tree-selected feature at any position
(13/16 types); Import/Boolean/Cut get a status message instead of a silent
no-op (full dialogs = follow-up snaporca-nu9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 06:30:01 +02:00
Tommaso BianchiandClaude Opus 4.8 a121e30a13 Design canvas: gate direct-render hacks behind sw-GL detection
Mirror of snaporca-cad f3b665b. DesignCanvas had ~38 call-sites doing
`set_as_dirty(); render()` directly, tagged "llvmpipe: force repaint" —
software-GL workarounds from the headless :10 test box. On hardware GL this
bypasses the dirty/refresh cycle and burns frames outside the paint event.
Funnel all 38 through a new request_repaint() helper that branches on the
cached GL renderer string: hardware GL gets set_as_dirty()+wxGLCanvas::Refresh()
(render() runs inside the paint cycle), software GL keeps the direct render(),
undetected backend takes the safe direct path. Behaviour on the llvmpipe :10
box is byte-identical; hardware GL invalidates the canonical editor way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 06:11:22 +02:00
Tommaso BianchiandClaude Opus 4.8 2929f66881 Design tab i18n: remove _L override, route strings through gettext
Mirror of snaporca-cad 0f53a09. DesignPanel.cpp pinned every _L() to
wxString::FromUTF8 via a TU-local #define, hardcoding English and bypassing
the gettext catalog — a hard review-blocker for upstream (OrcaSlicer ships
~20 locales). Remove the override so the 463 _L("...") call-sites route
through the real Slic3r::GUI::I18N::translate (wxGetTranslation). All args
are string literals; untranslated strings fall back to the source msgid, so
English is byte-identical while other locales now translate when .po entries
exist. xgettext scans source text, so .pot extraction is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 06:04:42 +02:00
Tommaso BianchiandClaude Opus 4.8 36b2bd8bfc CAD persistence: implement recipe round-trip on the BBS 3mf backend (mirror)
Mirror of snaporca-cad ad822bf. The earlier mirror (2c0140afb9) implemented
persistence in the PrusaSlicer 3mf.cpp, but the GUI saves/loads projects via the
BBS-native backend (store_bbs_3mf / load_bbs_3mf), so the recipe was never
written to nor read from GUI-saved projects.

- bbs_3mf.cpp: BBS_CAD_RECIPE_FILE = "Metadata/SnapOrca_cad.bin"; writer
  _add_cad_recipe_file_to_archive (called after layer-height in store_bbs_3mf);
  reader branch in _load_model_from_file's metadata dispatch loop (iterate +
  iequals on m_filename — mz_zip_reader_locate_file does not work on these
  archives).
- Plater.cpp: load_files carries the Model-level cad_recipe onto q->model().
- test_3mf.cpp: [3mf] test asserting store_bbs_3mf embeds the recipe entry
  byte-for-byte (read back via miniz, Catch2 v3).

Verified on behemoth: libslic3r_tests + the new [3mf] BBS test pass; orca-slicer
GUI links clean. Round-trip verified live on snaporca-cad (identical code path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 21:53:36 +02:00
Tommaso BianchiandClaude Opus 4.8 e985d95dfb Add missing color_palette.svg (Design colour-tool icon)
DesignPanel's per-body colour button references icon "color_palette" but the
asset was never ported with the CAD subsystem. On mainline OrcaSlicer the
missing bitmap throws during MainFrame construction → segfault at startup
(create_scaled_bitmap "Could not load bitmap: color_palette"). Copied from
snaporca-cad resources; runtime-loaded, no rebuild needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 19:37:50 +02:00
Tommaso BianchiandClaude Opus 4.8 2c0140afb9 Mirror CAD persistence (K1+K2+G1) from snaporca-cad onto mainline OrcaSlicer
Dual-fork mandate: port the parametric-recipe 3MF persistence from
snaporca-cad commits ed19eac+147e1c4 so a saved project reopens with the
editable CAD feature tree, not just the baked mesh.

Shared kernel/format/GUI (identical to snaporca-cad):
- CadDocument serialize_recipe/deserialize_recipe (cereal BinaryArchive,
  versioned) + CadFeature split save/load + imported_solid<->BRep string.
- Model::cad_recipe carried through 3MF zip entry Metadata/SnapOrca_cad.bin
  (writer + binary-verbatim reader branch).
- DesignPanel on_commit() stamps the recipe; on_tab_shown() rehydrates a
  loaded project via load_recipe() (deserialize -> feed_bodies + refresh_tree).

Mainline-only adapters (no snaporca-cad counterpart — Catch2 v3 vs v2):
- tests/libslic3r/test_caddocument.cpp: <catch2/catch_all.hpp> +
  `using Catch::Approx;` (v3 scopes Approx under Catch::).
- tests/libslic3r/CMakeLists.txt: register test_caddocument.cpp (the
  original CAD port had left it out of the test build).

Verified on behemoth (snaporca-deps toolchain): libslic3r_tests clean;
[CadDocument] 17/18 (only the pre-existing tangent-to-circle SIGABRT fails,
identical to snaporca-tkz); K1 serialize round-trip + version-reject pass
(13 assertions); new [3mf] "CAD recipe blob survives a 3mf save/load cycle"
passes byte-for-byte; orca-slicer GUI links clean (186/186, DesignPanel.cpp
compiled). Interactive :10 click-through pending (no Design-tab automation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 17:05:44 +02:00
Tommaso BianchiandClaude Opus 4.8 c22351f63a Fix GUI build on mainline OrcaSlicer: DropDown Item API + Bed3D::set_shape
Two Snapmaker->mainline API divergences surfaced once the ported CAD
subsystem compiled (the whole libslic3r CAD kernel + vendored slvs solver
built clean):

- DesignPanel: Snapmaker's DropDown took 3 parallel vectors
  (texts/tips/icons); mainline's is Item-based (std::vector<DropDown::Item>).
  Collapsed both flyout structs (FeatFlyout, ToolFlyout) to one Item vector.
  Event/selection path unchanged (both emit wxEVT_COMBOBOX + SetInt).
- DesignCanvas: mainline Bed3D::set_shape added extruder_areas/heights
  params before custom_model; pass empty vectors.

Build verified: links clean (orca-slicer, 280/280). Runtime reaches the GTK
event loop equivalently to the proven-good snaporca binary; full visual
Design-tab verification pending an interactive :10/x11vnc session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 15:14:15 +02:00
Tommaso BianchiandClaude Opus 4.8 0f4060c0a9 Orca-Cad: port SnapOrca Design (parametric CAD tab) onto mainline OrcaSlicer
Grafts the sketch-first CAD environment from snaporca-cad onto the mainline
OrcaSlicer/OrcaSlicer base (vs snaporca's Snapmaker/OrcaSlicer base):
- 133 new files: CadDocument/SketchEngine/GeometryEngine/SketchConstraints/
  SketchSolver/SketchInference/ThreadStandards + vendored libslvs solver;
  DesignPanel/DesignCanvas/DesignSketchTool/SketchInlineEditor GUI; GLGizmo
  Primitive/Sketch; 75 design icons; Catch2 tests.
- Integration hooks ported to mainline's diverged versions: Design tab in
  MainFrame, embedded design viewport + sketch overlay + per-canvas chrome
  suppression in GLCanvas3D/PartPlate, gizmo registration, Plater accessors,
  CMake wiring (libslvs subdir, CAD sources, OCCT ModelingAlgorithms=ON).

Structural integration complete; build verification pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 12:40:38 +02:00
2864 changed files with 189555 additions and 128302 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.
+117
View File
@@ -0,0 +1,117 @@
---
name: orca-profiles
description: Use when creating, modifying, reviewing or debugging OrcaSlicer FFF system profiles under resources/profiles, including printer/vendor/nozzle/material additions, bundle indexes and versions, preset renames, setting_id and filament_id. Also use for missing presets or vendors, ignored profile settings, ambiguous AMS filament matches, and failures from orca_profile_tool.py, check_profile.sh/.bat, OrcaSlicer_profile_validator or the Check profiles CI job.
---
# OrcaSlicer system profiles
A bundle is `resources/profiles/<Vendor>.json` plus `<Vendor>/`. The vendor id is the
filename stem, not the index's display `name`. The index is the loader's only entry point:
unindexed presets never load. `OrcaFilamentLibrary` is the shared filament bundle;
`blacklist.json` is data, not a bundle.
## Choose the reference for the task
Read the relevant reference before editing; load others only when the task crosses those areas.
Paths below are relative to this skill. Commands run from the repository root.
| Task | Read |
| --- | --- |
| Add or tune a filament, brand or material; fix compatibility / alias shadowing | [filament-profiles.md](references/filament-profiles.md) |
| Add a printer or nozzle; change models, variants, assets or extruder vectors | [machine-profiles.md](references/machine-profiles.md) |
| Add a quality tier or tune a process | [process-profiles.md](references/process-profiles.md) |
| Create a vendor bundle; diagnose loading or inheritance; migrate preset names | [vendor-bundle.md](references/vendor-bundle.md) |
| Change ids; diagnose AMS identity | [ids.md](references/ids.md), then `docs/HLSD/filament_id.md` for identity changes |
| Review a profile diff | [review-checklist.md](references/review-checklist.md) |
| Run checks, interpret failures, test another tree or verify in the app | [validation.md](references/validation.md) |
## Golden rules
1. **Bump every changed bundle's `version`**, including `OrcaFilamentLibrary.json` when affected.
Increment the last component; carry `.99` into the third component (`02.04.00.99`
`02.04.01.00`). The updater requires a strictly newer version. CI does not check this.
2. **Register every preset, bases included, parents before children.** `update-index` generates
the four `*_list` arrays; `check` requires its output. Index names must equal file `name` fields.
3. **Generate ids; never invent or copy them.** Keep existing ids during ordinary tuning. New
presets normally omit them until `generate-id`; bases must have no `setting_id`.
BBL's authoritative `setting_id` and a wrongly inherited `filament_id` need the explicit
handling in [ids.md](references/ids.md).
4. **Load failures can discard a whole vendor bundle.** Broken `inherits`, missing indexed files,
duplicate names, invalid model/variant references and unresolved filament ids affect more than
the edited preset. Inheritance stays within a bundle, except filaments may inherit the library.
5. **Preserve shipped selectable names.** Renaming, deleting or changing `instantiation` from
`"true"` to `"false"` needs `renamed_from` on a selectable successor. It is a `;`-separated string;
update in-tree references too. See [migration rules](references/vendor-bundle.md#renamed_from).
6. **Compatibility uses exact printer variant names.** Every instantiated non-library filament
needs a non-empty `compatible_printers` in its own file. Library fallbacks may omit it;
library printer-specific tunes use a non-empty list. Keep same-product tunes disjoint.
7. **Preset values are strings or arrays of strings.** Use `"instantiation": "false"`, not `false`.
Model `nozzle_diameter` is a `;`-separated string; machine `nozzle_diameter` is an array.
Wrong types can abort loading; see [failure scopes](references/vendor-bundle.md#failure-modes-ranked-by-blast-radius).
8. **Verify setting keys against the code.** Unknown keys are silently discarded. Check
`PrintConfig.cpp` definitions and `PrintConfigDef::handle_legacy`; neighbours can contain dead
keys. `normalize` removes known obsolete keys, but does not detect arbitrary misspellings.
9. **Run the full profile checks before reporting completion.** A vendor-scoped pass is only a
development loop. Review also covers version bumps, assets, non-default processes and hardware
tuning that CI cannot establish.
## Creating or modifying a profile
1. **Inspect the diff and neighbouring presets.** Read their `name`, parent chain and children;
edits to a base or a leaf with descendants propagate. Match the bundle's structure and write
only overrides. New files use tab indentation, LF and a trailing newline; preserve unrelated
formatting in existing files. Match filename case exactly and use cross-platform names.
2. **Author explicit metadata.** Set `type` yourself, especially for `machine` vs `machine_model`.
Use `"from": "system"` and string `instantiation` on config presets. Omit ids on new presets
unless [ids.md](references/ids.md) requires special handling; retain them on existing ones.
Complete compatibility, defaults, assets and any rename migration using the task reference.
3. **Bump the version**, then run the authoring commands in order for each affected bundle:
```bash
python3 scripts/orca_profile_tool.py normalize --vendor "<Vendor>"
python3 scripts/orca_profile_tool.py update-index --vendor "<Vendor>"
python3 scripts/orca_profile_tool.py generate-id --vendor "<Vendor>"
python3 scripts/orca_profile_tool.py check
```
Writing commands support `--dry-run`. Inspect their diffs: `normalize` changes content and can
reformat entire files. Stop and resolve command errors before proceeding.
**Do not use `trim` in this workflow:** it can delete newly authored, unindexed profiles.
Do not use `normalize --force` for routine edits.
4. **Validate:**
```bash
./scripts/check_profile.sh --vendor "<Vendor>" # development loop
./scripts/check_profile.sh # full tree before the PR
```
On Windows use `py -3` instead of `python3`, and `scripts\check_profile.bat -Vendor "<Vendor>"`
/ `scripts\check_profile.bat`. Logs: `.test/check_profiles/logs/<check>.log`.
Id checks remain tree-wide under `--vendor`; filament-only bundles skip the default slice check.
See [validation.md](references/validation.md) for flags, coverage and error remedies.
5. **Verify the changed behavior.** Slice newly added non-default processes explicitly, and
[test in the app](references/validation.md#testing-in-the-app) for selection or UI behavior.
Report checks actually run, failures/skips and any hardware tuning still unverified.
## Symptom → first reference
| Symptom | Start here |
| --- | --- |
| A vendor disappears | Loader log / `validate_system`; [bundle failure scopes](references/vendor-bundle.md#failure-modes-ranked-by-blast-radius) |
| A setting has no effect | Key spelling/type, `handle_legacy`, or a config key placed on a `machine_model` |
| A preset exists but is not selectable | Index registration, `instantiation`, installation and compatibility |
| A filament is missing, duplicated, or matches the wrong spool | [Compatibility and alias shadowing](references/filament-profiles.md#compatible_printers); [ids](references/ids.md) |
| A bed temperature is ignored | [Plate-specific temperature keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) |
| A change is absent from the running app | Version bump and [installed profile location](references/validation.md#testing-in-the-app) |
| A check fails | [Error → remedy](references/validation.md#error--remedy) |
## Source of truth
When guidance and behavior disagree, inspect the current checkout:
`scripts/orca_profile_tool.py` for tooling and flags; `src/libslic3r/Preset*.cpp` for loading and
compatibility; `src/libslic3r/PrintConfig.cpp` for setting types and legacy handling;
`src/dev-utils/OrcaSlicer_profile_validator.cpp` and `.github/workflows/check_profiles.yml` for
validation coverage. `docs/HLSD/filament_id.md` defines filament identity. The
[profile development guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/how_to_create_profiles.md)
is a tutorial; confirm loader and CLI details against these sources.
@@ -0,0 +1,207 @@
# Filament profiles and OrcaFilamentLibrary
`OrcaFilamentLibrary` is the filament-only bundle the loader reads **first**; its config map
becomes the base bundle, so any vendor may inherit a library preset by name. It is the only cross-bundle
parent — vendor-to-vendor inheritance always fails.
## Where a filament goes
| Contribution | Location |
| --- | --- |
| Generic material for all printers | `OrcaFilamentLibrary/filament/Generic <mat> @System.json` |
| A brand's product, all printers | `OrcaFilamentLibrary/filament/<Brand>/` |
| A brand's tune for one printer | `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/` — recommended; `<PrinterVendor>/filament/<Brand>/` also works |
| A printer vendor's tune of a generic or its own product | `<Vendor>/filament/` |
Both locations for the last-but-one row are supported: `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/<Name>.json`
(the shape the wiki shows) and `<PrinterVendor>/filament/<Brand>/`. The library path is the one a
filament vendor should contribute to — `OrcaFilamentLibrary/filament/<Brand>/` is the brand's own
folder, while a printer vendor's folder belongs to that printer vendor. Brand tunes do ship under
printer vendors' folders today (Polymaker and SUNLU among others).
Library layout: `filament/base/fdm_filament_*.json` type roots, root-level `Generic <mat> @System.json`
generics, and one subfolder per brand, which may nest printer-specific tunes one level deeper. Adding
a brand means adding a folder here; the folder name is a directory label only — `filament_vendor` inside the JSON is the real vendor string.
## The three-part shape
```jsonc
// Fiberon PA6-CF @base.json — the product root, holds identity + material values
{ "type": "filament", "name": "Fiberon PA6-CF @base", "from": "system",
"instantiation": "false", "inherits": "fdm_filament_pa",
"filament_id": "OFkOviHk", // generated here; variants inherit it
"filament_vendor": ["Polymaker"], "filament_type": ["PA6-CF"], /* */ }
// Fiberon PA6-CF @System.json — the selectable shim, 7 keys
{ "type": "filament", "name": "Fiberon PA6-CF @System", "from": "system",
"instantiation": "true", "inherits": "Fiberon PA6-CF @base",
"setting_id": "…", "compatible_printers": [] }
// <PrinterVendor>/filament/Polymaker/Fiberon PA6-CF @BBL X1C.json — a printer tune
{ "inherits": "Fiberon PA6-CF @base", "filament_max_volumetric_speed": ["14"],
"compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", ] }
```
- `@base` is the convention for a root. A base carries **no** `setting_id`, no `compatible_printers`, no
`filament_settings_id`. Only the `setting_id` half is enforced, and nothing violates it; the other two
are unchecked and plenty of bases still carry them. Do not copy that from a neighbouring file.
- Every `@System` must be `"instantiation": "true"`. DREMC ships `@System` presets set to `"false"`,
which therefore ship but can never be selected; no check catches it.
- A duplicated brand `@base` across bundles is normal and intentional (`Fiberon PA6-CF @base` exists in
both the library and BBL with the same id, differing only in MVS) — bases never enter the preset
collection, so there is no duplicate-name error.
- You may inherit from an instantiated preset as well as from a base; it is common.
## The two most common contributions
**A printer vendor tuning a generic.** Keep the `Generic X` base name so the alias shadows the library
preset on your printers, inherit `Generic X @System`, declare **no** `filament_id` (inheriting the
library's is correct — the product really is the library's generic), and give it a non-empty
`compatible_printers` in its own body:
```jsonc
// <Vendor>/filament/Generic PETG @Acme One 0.4 nozzle.json
{ "type": "filament", "name": "Generic PETG @Acme One 0.4 nozzle", "from": "system",
"instantiation": "true", "inherits": "Generic PETG @System",
"filament_flow_ratio": ["0.95"], "filament_max_volumetric_speed": ["10"],
"compatible_printers": ["Acme One 0.4 nozzle"] }
```
**A printer vendor's own branded product.** Give it a `@base` root so `generate-id` can mint the id (see
[ids.md](ids.md) — inheriting `Generic X @System` directly makes the id unfixable by the tool), then one
instantiated leaf per printer in the same bundle. No `@System` shim: that is only for a product entering
OrcaFilamentLibrary.
```jsonc
// <Vendor>/filament/Acme Aura PETG @base.json — instantiation false, no setting_id
{ "type": "filament", "name": "Acme Aura PETG @base", "from": "system",
"instantiation": "false", "inherits": "fdm_filament_pet",
"filament_vendor": ["Acme"], "filament_type": ["PETG"] } // filament_id minted here
// <Vendor>/filament/Acme Aura PETG @Acme One 0.4 nozzle.json
{ "type": "filament", "name": "Acme Aura PETG @Acme One 0.4 nozzle", "from": "system",
"instantiation": "true", "inherits": "Acme Aura PETG @base",
"filament_max_volumetric_speed": ["11"],
"compatible_printers": ["Acme One 0.4 nozzle"] }
```
Omit `filament_settings_id` from new presets — it is runtime bookkeeping the app rewrites to the preset
name.
## `compatible_printers`
- **Library fallbacks:** empty `[]` or absent, so they are offered on all printers except where
[alias shadowing](#alias-shadowing) supplies a printer-specific tune.
- **Library printer-specific tunes:** non-empty, listing exact printer **variant** names. These can
supersede a same-alias fallback just like a tune in a printer vendor's bundle.
- **Instantiated filaments in every other vendor:** non-empty, listing exact printer **variant** names.
Enforced twice but not identically: the C++ `has_errors` reads the *flattened* config, so an inherited list satisfies it,
while the Python check reads the file's **own** key. Write the list in the file itself. This is the
most common filament CI failure.
- Emptying it to "make it apply everywhere" fails that check *and* creates a duplicate-`filament_id`
collision against the library generic on every printer.
- Copying a base's full printer list onto a nozzle-specific variant produces duplicate combobox entries —
a real shipped bug twice over.
## Alias shadowing
A printer-specific filament in either the library or a vendor bundle supersedes the library fallback
on the printers it lists. The matching key is the **alias**: the preset name up to the **first** `@`,
right-trimmed (no `@` → the whole name). So
`QIDI ABS-GF@Q2-Series` aliases to `QIDI ABS-GF`.
A library preset with an empty `compatible_printers` collects, into `m_excluded_from`, every printer named
by any same-alias preset that *has* a non-empty list, and is then hidden on those printers.
Two consequences:
- **Only an unrestricted library fallback can be shadowed.** Two printer-specific presets sharing
an alias do not exclude each other — overlapping lists for the same product trip the
duplicate-`filament_id` check instead.
- This is why adding `Generic PLA @<printer>` to a vendor silently removes the library `Generic PLA`
from that printer. Intended — and the reason a vendor tuning a generic must **keep the `Generic X`
base name**.
The literal spelling `Generic <mat> @System` is load-bearing beyond shadowing: `find_preset2` rewrites an
unresolved name containing "Generic" into that form and retries against the library, which is how 3MF
and project recovery works.
## `filament_id`, `filament_vendor`, `filament_type`
`filament_id` is minted from the triple `(filament_vendor, filament_type, name-before-first-@)`.
`filament_vendor` and `filament_type` are therefore **identity, not decoration** — editing either
re-mints the id. Read `docs/HLSD/filament_id.md` before changing any of them, and see
[ids.md](ids.md) for the tooling.
A filament with no resolvable `filament_id` anywhere in its `inherits` chain is a **hard load error** that
discards the vendor bundle. The id inherits across bundles, so a vendor's `Generic ABS @X` inheriting
`Generic ABS @System` gets the library's id for free; a vendor's own product must resolve its own.
- `filament_type` **must be a JSON array** — the one vector key the Python check enforces. A scalar
`"PP"` once hung the filament/printer selection UI.
- It is an **open** enum: an unlisted value is accepted silently and falls back to 190300 °C defaults
and adhesion 1.0. Off-list values do ship. Prefer a value from `MaterialType::all()` in
`src/libslic3r/MaterialType.cpp`, or add a row there.
- Generics use `filament_vendor: ["Generic"]`, which `fdm_filament_common` already defaults to.
## `"nil"`
Legal in any key whose `ConfigOptionDef` is `nullable`. In a filament preset that is most of the
`filament_*` family, plus `long_retractions_when_ec` and `retraction_distances_when_ec`. About half are
the extruder overrides (`filament_retraction_length`, `filament_z_hop`, `filament_wipe`,
`filament_retract_*`, `filament_retraction_speed`, `filament_deretraction_speed`,
`filament_retraction_minimum_travel`, `filament_wipe_distance`, `filament_long_retractions_when_cut`,
`filament_retraction_distances_when_cut`, …), where `nil` means *keep the printer/extruder's own value*.
The rest are ordinary nullable options (`filament_flow_ratio`, `filament_flush_temp`,
`filament_adaptive_volumetric_speed`, …) where it means *unset*.
Anywhere else it throws `Deserializing nil into a non-nullable object`. To not set a non-nullable key,
omit it — do not write `nil`.
## What to review per nozzle
Across `@X` / `@X 0.N nozzle` sibling pairs the keys that differ, most often first, are
`filament_max_volumetric_speed`, `filament_retraction_length`, `slow_down_min_speed`,
`filament_flow_ratio`, `slow_down_layer_time`, `nozzle_temperature` and `pressure_advance`.
`filament_cost`, `filament_density`, `filament_type` and `filament_vendor` belong on the `@base` and
should not appear in a printer tune.
Use measured values for the material, hotend, extruder and nozzle combination. Neither maximum
volumetric speed nor pressure advance has a universal nozzle-only lookup table. When cloning a
0.4 preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure-advance value
or a required direction of change from diameter alone.
## Style
Overrides, not full copies: a typical instantiated filament preset carries around a dozen non-meta keys,
and a library leaf two or three. Presets that restate fifty-plus keys from their parent do still ship —
Phrozen's single filament preset is that style — but they are the pattern to move away from, not to
copy. Commit `6943b6ddc3` is the stated model (flip true bases to `instantiation: "false"`, strip
`compatible_printers`/`setting_id`/`filament_settings_id`, add `renamed_from` on the survivor).
Prefer the library's `fdm_filament_*` bases over a vendor-local copy. Phrozen's local
`fdm_filament_common` has drifted from the library's.
Canonical key order, written by `orca_profile_tool.py normalize` when it rewrites a file: `type`, `name`,
`renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`, `instantiation`, then everything else in
the order you wrote it. Not enforced — a file that leads with `compatible_printers` passes `check`.
**Every vector-typed (`co*s`) key must be a JSON array.** Only `filament_type` is an outright error, but
`normalize` silently arrayifies five more (`filament_cost`, `filament_density`,
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`) and `check` fails when
it would. Every other vector key is on you — including `filament_start_gcode`, `filament_end_gcode`,
`filament_extruder_variant`, `compatible_printers` and the plate temperatures.
## Bed temperature is twelve keys, not one
There is no single "bed temperature". Which plate key applies depends on `curr_bed_type`, whose six
selectable values (`btPC`, `btEP`, `btPEI`, `btPTE`, `btPCT`, `btSuperTack`; `btDefault` maps to no key)
`get_bed_temp_key()` turns into `cool_plate_temp`, `eng_plate_temp`, `hot_plate_temp`,
`textured_plate_temp`, `textured_cool_plate_temp` and `supertack_plate_temp` — each with an
`*_initial_layer` twin.
`textured_cool_plate_temp` is the one most often forgotten. A printer with `support_multi_bed_types` off
hides the selector, and the printer preset's
`default_bed_type` decides which plate is selected for it, but `curr_bed_type` can still hold a stale
value carried over from another printer — so set every plate the printer plausibly has, as the sibling
presets in the bundle do.
@@ -0,0 +1,149 @@
# `setting_id` and `filament_id`
Orca-generated ids are deterministic hashes of identity. **Never invent an id or copy a sibling's
`setting_id`.** Use `scripts/orca_profile_tool.py`; the two special cases are
[a wrongly inherited filament id](#what-generate-id-does-and-does-not-fix) and
[BBL's authoritative setting ids](#bbls-exception-precisely).
`docs/HLSD/filament_id.md` is the authoritative design document for `filament_id` — the id landscape, the
checks CI runs, and the Bambu catalog map. This page is the tooling half.
| | `setting_id` | `filament_id` |
| --- | --- | --- |
| Identifies | one selectable preset | one filament **product** |
| Key hashed | `<vendor folder>/<type>/<name>` | `filament_product/<filament_vendor>/<filament_type>/<name-before-@>` |
| Shape | 16 base62 chars | `OF` + 6 base62 chars |
| Required on | every `instantiation: "true"` preset | every **instantiated** filament, own or inherited |
| Forbidden on | bases (`instantiation != "true"`) | — (a base is exactly where it belongs) |
| Scope | globally unique across the tree | shared by every variant of the product, in every bundle |
`<type>` is `machine` / `process` / `filament` — the vendor is the **folder** name (`BBL`), not the
display name (`Bambulab`). Renaming a preset changes its `setting_id`; renaming a filament, or editing
its `filament_vendor` or `filament_type`, also changes its `filament_id`.
## The tool
Use `scripts/orca_profile_tool.py` with a subcommand:
| Command | Does |
| --- | --- |
| `check` | everything CI's `profile_tool` step runs — see [validation.md](validation.md) |
| `generate-id` | writes `setting_id` and `filament_id` |
| `normalize` | rewrites profile files into their canonical shape |
| `trim` | deletes profile files no `<vendor>.json` list references |
| `update-index` | rebuilds the `*_list` sections from the files on disk |
The order after adding, renaming or deleting files — each step feeds the next, so it is not
interchangeable — is `normalize``update-index``generate-id``check`.
The [authoring workflow](../SKILL.md#creating-or-modifying-a-profile) has the commands.
> **`trim` deletes.** It removes every profile file the index does not list — including the one you just
> added and have not registered yet. Register first, or skip `trim` entirely; it is a cleanup sweep, not
> part of landing a profile. Preview with `--dry-run`.
**Register, then mint.** The `filament_id` pass reads `<Vendor>.json`'s `filament_list`, not the
filesystem (the `setting_id` pass walks the filesystem, so a bundle whose index has not landed yet is
still assignable). A new filament file is therefore invisible to `generate-id`'s filament_id pass until
it is registered — its `setting_id` is written regardless.
- `--dry-run` works on every writing command (`generate-id`, `normalize`, `trim`, `update-index`)
and writes nothing.
- `--filament-id` / `--setting-id` narrow `generate-id`; they exclude each other, and passing neither
writes both.
- `--vendor` is repeatable and narrows **only what is written** — the id is a function of the triple
alone, so a narrowed run writes exactly what a full run would. An unknown vendor exits 1 before any
write. `--vendor` on `check` narrows the per-vendor checks only; the `setting_id` and `filament_id`
passes stay tree-wide.
- `--profiles DIR` points any command at another tree — see
[Checking a copy of the tree](validation.md#checking-a-copy-of-the-tree).
- `--profile-type` narrows `normalize`, `trim` and `update-index` to `machine_model`, `process`,
`filament` or `machine`.
- Exit codes: 0 clean, 1 errors found (`generate-id` still writes what it could), 2 argparse misuse.
- Output is ANSI-coloured; searching for the literal `[ERROR]` still works.
`generate-id` is **idempotent and byte-preserving** — BOM and CRLF kept, one key line touched per pass.
A legitimate `generate-id` diff is one or two changed lines per file: a new instantiated filament gets
both a `filament_id` and a `setting_id`, and a BBL file with a misspelled `settings_id` has that line
dropped and its value restored under the right key. `normalize` is the opposite by design — it rewrites
whole files into canonical shape — which is why `check` demands it already be a no-op. Some bundles have
CRLF committed (OrcaFilamentLibrary, Anycubic and RH3D among them), so a `normalize` pass there rewrites
every line — read the diff before committing it.
On a clean tree `check` and `generate-id --dry-run` both exit 0 with zero findings. That is the
baseline to restore before opening a PR.
## What `generate-id` does and does not fix
Writes:
- a `setting_id` into any instantiated preset that lacks one, or whose value does not match the formula;
- strips a `setting_id` from a base;
- deletes the misspelled `settings_id` key;
- a `filament_id` into the id-less **root(s)** of an instantiated filament that resolves none;
- rewrites a **declared** `filament_id` that is not the mint of its own triple.
Refuses to write (reports only): a base62 collision between two products, an empty `filament_vendor` or
`filament_type`, a broken `inherits` chain, roots of one filament resolving divergent `(vendor, type)`
pairs.
**Does not fix: a preset that *inherits* a wrong `filament_id`.** This is check 2b, and it is the trap
most likely to bite. It happens when a branded filament inherits a generic for its settings:
```jsonc
{ "name": "Phrozen Aura PETG @Phrozen Arco 0.4 nozzle",
"inherits": "Generic PETG @System" } // resolves the OFL generic's id — wrong product
```
The preset resolves *an* id, so `generate-id` neither inserts nor rewrites, and `check` fails with
`inherits filament_id "X" but its own triple "V/T/N" mints "Y"`.
Two fixes, in order of preference:
1. **Give the product a `@base` root** inheriting a material base (`fdm_filament_pet`,
`fdm_filament_pla`, …). No `fdm_filament_*` base carries a `filament_id`, so the filament now resolves
none and `generate-id` mints it for you. This is also the shape the rest of the tree uses.
2. **Declare the tool-computed key on the preset itself.** Use the expected value reported by `check`
or compute it with the function below; this is not a manually chosen id. Make sure the preset
resolves the right `filament_vendor` and `filament_type` first — with
neither set, the triple resolves through the generic parent and the branded product is minted
under vendor `Generic`. If you need the id before the file exists:
```bash
python3 -c "import sys; sys.path.insert(0,'scripts'); from orca_profile_tool import generate_filament_id as g; print(g('Polymaker','PLA','PolyLite PLA'))"
# -> OF5CgdDq
```
The quoting works unchanged in cmd and PowerShell; only swap `python3` for `py -3`.
The `setting_id` equivalent is `generate_preset_setting_id('<vendor folder>', '<type>', '<name>')`.
## BBL's exception, precisely
`RESERVED_VENDORS = {"BBL"}` covers **`setting_id` assignment only**, keyed on the *folder* name:
- The tool never mints or replaces a BBL `setting_id`. A new instantiated BBL preset with no
`setting_id` therefore **cannot be fixed by the tool**, yet the presence rule still applies to it —
carry over Bambu's authoritative id by hand.
- BBL is not exempt from anything else: bases still get their `setting_id` stripped, ids must still be
globally unique, and BBL `filament_id`s are minted like everyone else's — every one of them is an
`OF*`.
## Ids other systems compose
No id from another system is the mint of a triple, so `check` rejects it like any other bad id — same
error, same remedy, whoever wrote it. Three such spaces exist near the tree; recognise them so you do
not copy one into a profile:
- **Bambu's `GF*` catalog** — external and opaque, correlated to Orca's ids by the generated
`resources/printers/bambu_filament_ids.json`. `GF` is a *prefix*, not a spelling the tree avoids: most
BBL `setting_id`s start with `G`, and `blacklist.json` and
`BBL/filament/filaments_color_codes.json` both reference Bambu catalog ids by design. The rule is
about `filament_id` and nothing else.
- **Qidi's `QD_*`** — composed at runtime by the box (`QD_<series>_<vendor>_<typeidx>`), not a preset id.
- **`P` + 7 hex, and `"null"`** — what `CreatePresetsDialog.cpp` gives a *user*-created filament.
## Tests
`python3 -m unittest discover -s scripts/tests -t scripts` (`py -3 -m …` on Windows). Note the
`-t scripts` argument; without it the imports fail. CI runs them as the first, non-`continue-on-error`
step of the profile job — see [validation.md](validation.md#ci).
@@ -0,0 +1,194 @@
# Printer models and variants
Both live in `resources/profiles/<Vendor>/machine/*.json`; models go in `machine_model_list`, variants
and shared bases in `machine_list`. Every one of them is registered. Some vendors (Elegoo, Eryone,
InfiMech, FlyingBear) nest a further subfolder under `machine/`, so recurse rather than globbing
`machine/*.json`.
## A `machine_model` is not a config preset
It is parsed by a hand-written key switch, and only these keys are stored (`version` and `url` are
matched and discarded):
`name`, `model_id`, `nozzle_diameter`, `machine_tech`, `family`, `bed_model`, `bed_texture`,
`hotend_model`, `default_materials`, `not_support_bed_type`, `image_bed_type`,
`bottom_texture_end_name`, `bottom_texture_rect`, `bottom_texture_rect_longer`, `middle_texture_rect`,
`use_double_extruder_default_texture`.
**Everything else is silently dropped.** Only `name` and `nozzle_diameter` are required. Dead keys ship
on real models today — `url`, `default_bed_type`, even a `desciption` typo — so a neighbour carrying a
key is no evidence it does anything. Printer config options belong on the `machine` preset, never here.
```json
{
"type": "machine_model",
"name": "Phrozen Arco",
"machine_tech": "FFF",
"family": "Phrozen",
"model_id": "Phrozen Arco",
"nozzle_diameter": "0.4",
"bed_model": "Phrozen Arco_buildplate_model.stl",
"bed_texture": "Phrozen Arco_buildplate_texture.svg",
"hotend_model": "",
"default_materials": "Generic PLA @Phrozen Arco 0.4 nozzle"
}
```
| Field | Notes |
| --- | --- |
| identity | **the `name` of the `machine_model_list` entry**, which is what a variant's `printer_model` must equal. `check_name_consistency` forces it to equal the file's `name`, so they coincide. |
| `model_id` | a *separate* cloud/device printer type. Optional, and not required to be unique. Not the model's identity. Changing it changes device matching. |
| `machine_tech` | only `starts_with("SL")` means SLA; everything else is FFF. Write `FFF`; a few models write `FGF`, which is a label with no effect. |
| `nozzle_diameter` | `;`-separated string, one token per available size. Order is free (Qidi writes `0.4;0.2;0.6;0.8` to put the default first). This list is the authoritative set of legal `printer_variant` values. |
| `default_materials` | `;`-separated filament **preset names**. Used to preselect in the wizard *and* by `PresetBundle::load_installed_filaments` to auto-install a printer's filaments on first run, so a dangling entry costs a real user a filament. Not `,`; case-sensitive (`@System`). `check` fails on a dangling name here or in `default_filament_profile`. |
| `family` | a wizard grouping label only; give every model one. |
### Assets
`bed_model`, `bed_texture` and `hotend_model` are paths relative to the **vendor folder** (by id).
Majority convention: `<Model>_buildplate_model.stl` and `<Model>_buildplate_texture.svg`. An empty string
is the legal "none", and is the norm for `hotend_model`.
**Nothing checks that the file exists.** A missing `hotend_model` falls back to
`resources/profiles/hotend.stl`; a missing `bed_model`/`bed_texture` just renders nothing. Broken
references already ship. Verify by hand.
Every model also has a `<Model>_cover.png` in the vendor folder — treat it as required, not optional.
240×240 is the cap `scripts/optimize_cover_images.py` enforces and the size most covers already use.
A missing cover degrades to a placeholder in both the wizard and the sidebar.
## The `machine` variant
```json
{
"type": "machine",
"name": "Phrozen Arco 0.4 nozzle",
"inherits": "fdm_machine_common",
"from": "system",
"setting_id": "lvaYKTUZr5C9jSwk",
"instantiation": "true",
"printer_model": "Phrozen Arco",
"printer_variant": "0.4",
"nozzle_diameter": ["0.4"],
"default_print_profile": "0.20mm Standard @Phrozen Arco 0.4 nozzle",
"default_filament_profile": ["Generic PLA @Phrozen Arco 0.4 nozzle"],
"printable_area": ["0x0", "300x0", "300x300", "0x300"],
"printable_height": "300"
}
```
Minimum viable key set: `type`, `name`, `from`, `instantiation`, `setting_id`, `inherits`,
`printer_model`, `printer_variant`, `nozzle_diameter`, `printable_area`, `printable_height`,
`default_print_profile`. The four keys without which the preset will not load at all are `name`,
`instantiation`, `printer_model` and `printer_variant`; `default_filament_profile` is an array
(`["Generic PLA @System"]`) and the model's `default_materials` a `;`-separated string. Unlike a
`machine_model`, a `machine` **is** config-loaded, so a key belonging to another preset type is a
reported error (a misspelled key is still silent).
### `printer_variant` — three hard rules
1. Non-empty, and an exact member of the model's `;`-separated `nozzle_diameter` list.
2. `printer_model` non-empty and naming a model of this vendor.
3. In validation mode, for instantiated presets only: split `printer_variant` on `+`, each token must
start with a number (a trailing non-numeric suffix such as `HF` is ignored), and the resulting **set**
must equal `set(nozzle_diameter)`.
Rules 1 and 2 are loader-enforced — failing either drops the preset *and* the whole bundle. Rule 3 only
raises a validation error: the preset still loads, but the validator exits non-zero.
`nozzle_diameter` lists one entry **per physical nozzle**; `printer_variant` lists the **distinct**
diameters joined with `+`. Snapmaker U1 is the worked case: `["0.4","0.4","0.6","0.6"]` against
`"0.4+0.6"` — it passes because the comparison is on sets.
The conventional values are `0.2`, `0.25`, `0.4`, `0.5`, `0.6`, `0.8` and `1.0`. Suffixed forms
(`0.4HF`, `0.6HF`, `0.8HF`, `0.4HS`) are Flashforge-only and the `+` form is rare. A variant is **not**
required to be unique within a model — Volumic ships `EXO42 IDRE`, `… COPY MODE` and `… MIRROR MODE` all
at `0.4` under the one model `EXO42 IDRE`.
The converse is **unchecked**: a nozzle size in the model's list with no matching variant is offered in
the wizard and resolves to nothing. `Wanhao France`'s `D12 500 PRO M2 DIRECT` ships that bug today.
### Other fields worth knowing
- `default_print_profile` is a **scalar**, matched by exact preset name. Not a `;` list. The named
process must be compatible with this printer through its resolved list or condition.
`validate_slice` attempts to select it and rejects generic Default fallbacks, but compatibility
updates can choose another compatible preset. Check the exact default reference yourself.
- `default_filament_profile` is an **array**, one name per element.
- `printable_area` is an array of `"XxY"` strings — four points for a rectangle, one per segment for a
delta or circular bed.
- `gcode_flavor` is usually set once in the base; `klipper`, `marlin`, `marlin2` and `reprapfirmware`
cover nearly every shipped printer.
- `printer_settings_id` is junk — most files carrying it disagree with their own name. Do not copy it
when cloning a bundle.
- `min_layer_height` / `max_layer_height` are **machine** keys (per extruder), never process keys.
## Bases
Nearly every machine-bearing vendor registers a base literally named `fdm_machine_common`, and Klipper
vendors add `fdm_klipper_common` on top of it. Two levels is the usual depth.
**There is no leading-underscore convention for bases.**
## Adding a printer to an existing bundle
1. Choose the names first — model, variant(s), process(es); everything else references them.
2. Add the model (`machine_model_list`) and one `machine` variant per nozzle; the minimum key sets are
above. Bed assets and `<Model>_cover.png` go directly in `<Vendor>/`.
3. Add at least one process per variant naming it in `compatible_printers`
([process-profiles.md](process-profiles.md#adding-a-quality-tier-or-a-nozzles-processes)).
4. Register everything (or run `update-index`), bump the version, run the id tool, validate.
## Adding a nozzle variant
1. Extend the model's `nozzle_diameter` (`"0.4"``"0.4;0.6"`).
2. Add the variant preset. Either inherit the shared base (the usual choice) or the 0.4 sibling (Elegoo,
BBL, Prusa and Qidi do this — smaller diff, but the sibling's edits now reach this file too).
3. Override what actually changes with nozzle: `nozzle_diameter`, `printer_variant`,
`default_print_profile`, `default_filament_profile`, `min_layer_height`/`max_layer_height`, and
retraction if the vendor tunes it.
4. Add at least one process for the new nozzle — see [process-profiles.md](process-profiles.md).
5. Register both, bump the version, run the id tool, validate.
## Multi-extruder, IDEX and tool-changers
Per-extruder vectors are **silently resized** to the nozzle count, with no error. Padding repeats the
**first** value, not the last — `["0.4","0.6"]` on a 4-nozzle machine becomes `0.4, 0.6, 0.4, 0.4`.
Longer vectors are truncated.
Note the two sizing families: the plain per-extruder keys (`extruder_offset`, `extruder_colour`,
`extruder_printable_height`, `min_layer_height`, `max_layer_height`, `nozzle_diameter`) are sized to the
extruder count, while `printer_options_with_variant_1` (`retraction_length`, `z_hop`, `wipe`,
`nozzle_type`, the rest of the retraction family) is sized to `printer_extruder_variant` instead.
- Give **one entry per extruder** for ordinary per-extruder vectors such as `extruder_offset`,
`extruder_colour`, `min_layer_height` and `max_layer_height`; size the variant-dependent family
to `printer_extruder_variant` instead.
A single `["0x0"]` `extruder_offset` on a dual or multi-tool machine — which already ships — pads every
toolhead to the same offset, so the offset never applies.
- Overriding `nozzle_diameter` to a different count without re-stating every per-extruder vector is the
other half of the trap — `Snapmaker U1 (0.4+0.6 nozzle)` inherits 5-entry vectors against 4 nozzles.
Copy targets: `Custom/machine/fdm_toolchanger_common.json` + `Custom/machine/MyToolChanger 0.4
nozzle.json` (a clean minimal variant on a base that gives every vector five entries), and
`Ratrig/machine/RatRig V-Core 4 IDEX 300 0.4 nozzle.json` for IDEX. The BBL extruder-variant machinery
(`extruder_variant_list`, `printer_extruder_id`, `default_nozzle_volume_type`) is used by a handful of
vendors — do not copy it into a new bundle (`nozzle_volume_type` itself is not a machine-preset key).
## Custom G-code
The keys are `machine_start_gcode`, `machine_end_gcode`, `change_filament_gcode`,
`machine_pause_gcode`, `before_layer_change_gcode` and `layer_change_gcode`. Both a single string with
embedded `\n` and a JSON array of lines are legal and both are in use — do not convert one into the
other. Conditionals are `{if …}` / `{elsif …}` / `{else}` / `{endif}`; `{elsif}` is rare but real (Qidi's
`layer_change_gcode` uses it).
Placeholder errors only surface when the config is actually expanded, which means `validate_slice`:
```bash
./scripts/check_profile.sh --vendor "<Vendor>" validate_slice
# Windows: scripts\check_profile.bat -Vendor "<Vendor>" validate_slice
```
What the sweep covers is in [validation.md](validation.md#validate_slice); no `CP TOOLCHANGE START` in
the output means `change_filament_gcode` never expanded.
@@ -0,0 +1,145 @@
# Process profiles
Processes live in `resources/profiles/<Vendor>/process/` — selectable leaves and shared bases alike, and
every one of them is registered in `process_list`. There are no global processes shared across vendors.
## Naming
`"<layer height>mm <quality> @<target>"` — near-universal, so match it.
Follow the bundle's existing quality vocabulary. BBL's common ladder relates the quality word to
the layer-height / nozzle ratio; it is a naming convention, not a loader constraint:
| Quality | Ratio | 0.2 nozzle | 0.4 | 0.6 | 0.8 |
| --- | --- | --- | --- | --- | --- |
| Extra Fine | 0.2× | — | 0.08 | — | — |
| Fine | 0.3× | 0.06 | 0.12 | 0.18 | 0.24 |
| Optimal | 0.4× | 0.08 | 0.16 | 0.24 | 0.32 |
| Standard | 0.5× | 0.10 | 0.20 | 0.30 | 0.40 |
| Draft | 0.6× | 0.12 | 0.24 | 0.36 | 0.48 |
| Extra Draft | 0.7× | 0.14 | 0.28 | 0.42 | 0.56 |
This is the `fdm_process_single_<lh>_nozzle_<n>` ladder; 0.4 is commonly the unsuffixed nozzle default.
Match neighbouring names rather than renaming shipped tiers to fit the table.
The `@target` is a human label, not a reference: most do not equal any real printer variant name.
Compatibility comes from the resolved list or condition, not this label.
## Shape
A selectable leaf's only truly universal keys are `type`, `setting_id`, `name` and `instantiation`;
`inherits` and `from` are near-universal — plus compatibility. No slicing key is universal; even
`layer_height` is more often inherited than restated. A base has `type`, `name`, `instantiation`, almost
always `from`, and **no** `setting_id`.
**Target shape: a 7-key leaf.** `OrcaArena` is the cleanest model —
`fdm_process_common``fdm_process_arena_common``fdm_process_arena_<lh>_nozzle_<n>` → leaf, where the
leaf carries only `type`, `name`, `inherits`, `from`, `setting_id`, `instantiation`,
`compatible_printers`, and the per-nozzle base holds the layer height and all eight line widths.
BBL, WonderMaker and Z-Bolt are uniform in *layering* — every leaf inherits a base, names its printers
directly and holds no layer height of its own — but not in key count. Imitate BBL's layering, not its
content: its leaves carry doubled `print_extruder_variant` arrays that no single-variant vendor needs.
Nearly every vendor ships its own `fdm_process_common` as the inherits-less root. Those files are not
identical; copying another vendor's version into a new bundle is normal.
Beware leaf-inherits-leaf: Prusa chains several levels deep through sibling leaves, and Elegoo and
Flashforge do it too, so editing one selectable process silently changes others. Check a leaf's children
before editing it.
## Compatibility
Most leaves set `compatible_printers` directly; some inherit it from a base, and Prusa's fall through to
`compatible_printers_condition`. After resolving `inherits`, **every selectable process has one or the
other** — that is the invariant to review against. Unlike filaments, inheriting `compatible_printers` is
legitimate for a process, and no check enforces its presence.
- A non-empty `compatible_printers` makes `compatible_printers_condition` **dead code**. Use one or
the other.
- A condition that fails to parse means *compatible with everything* — a warning, not an error. A typo
widens compatibility instead of narrowing it.
- Matching is `boost::regex` **`regex_match`** — a full-string match, which is why every shipped
condition wraps its keyword in `.*`. Because it is boost rather than `std`, `.` also spans the newlines
inside `printer_notes`.
- A `printer_notes` keyword that prefixes another model's keyword matches both. Prusa guards it:
```
printer_notes=~/.*PRINTER_MODEL_COREONE[^_a-zA-Z0-9].*/ and nozzle_diameter[0]==0.4 and printer_notes=~/.*HF_NOZZLE.*/
```
The `[^_a-zA-Z0-9]` exists because `PRINTER_MODEL_COREONE_L` also contains `PRINTER_MODEL_COREONE`.
`compatible_printers` is almost always one element. A leaf listing a whole model family is where a newly
added printer is usually forgotten.
## What to review per nozzle
| Key group | Review |
| --- | --- |
| `line_width` and per-region widths | resolved widths suit the nozzle and layer height |
| `layer_height`, `initial_layer_print_height` | within the printer's limits |
| print speeds | consistent with flow limits and hardware tuning |
| shell layers, wall loops, accelerations, support Z distances | preserve the intended thickness, motion and support behavior |
**A common starting pattern is nozzle + 0.02 mm**: 0.22 / 0.42 / 0.62 / 0.82 / 1.02. In that pattern, at 0.4,
`inner_wall_line_width`, `sparse_infill_line_width`, `skin_infill_line_width` and
`skeleton_infill_line_width` widen to 0.45 and `initial_layer_line_width` to 0.5; at 0.2,
`initial_layer_line_width` widens to 0.25. Also derived, and easily missed:
`ironing_inset = line_width / 2` (0.11 / 0.21 / 0.31 / 0.41).
These are examples, not required values; preserve intentional vendor tuning and percentage/automatic
widths, and validate their resolved values.
`min_layer_height` and `max_layer_height` are machine keys — no process file sets them.
## Slice-time content checks
`Print::validate()` enforces four rules at slice time:
1. `initial_layer_print_height` ≤ min `nozzle_diameter`
2. `layer_height` ≤ min `nozzle_diameter` — *"Layer height cannot exceed nozzle diameter."*
3. `line_width` and the seven per-region widths (inner/outer wall, sparse infill, internal solid infill,
top surface, skin, skeleton) > `layer_height` — *"Line width too small"*. `support_line_width` only
when the object has support or a raft; `initial_layer_line_width` is never checked.
4. every width ≤ 5 × max `nozzle_diameter` — *"Line width too large"*
Two further rules cover `bridge_line_width` (≤ nozzle diameter; > `layer_height` unless `thick_bridges`
and `thick_internal_bridges` are both on). The sweep starts from printer defaults rather than
enumerating every process. **A new non-default process gets no dedicated slice coverage in CI.**
## What CI checks on a process
Structure, not content: `process_list` name consistency **and** index coverage the other way, two files
claiming one process name, the `extruder_clearance_radius` / `extruder_clearance_max_radius` conflict
pair, duplicate JSON keys, a file `normalize` would rewrite, and the five `setting_id` rules (the fifth
rejects the misspelled key `settings_id`). `compatible_printers` presence is checked for **filaments
only**.
Note the C++ loader derives a missing `setting_id` on the fly, so the validator will not fail a process
without one — only `orca_profile_tool.py check` catches it. Running the validator alone gives a false
all-clear.
## Silent failures specific to processes
- **Unknown or misspelled keys are discarded with no error and no warning.** They ship all over the
process tree, both plain typos (`inital_layer_height`, `tree_support_bramch_diameter_angle`,
`sparse_infill_patter`) and keys copied from other slicers that Orca never defined.
- Keys on the tool's `OBSOLETE_KEYS` list (`adaptive_layer_height`, `overhang_totally_speed`, …) are
rejected by `check`'s normalization pass across preset types; `normalize` removes them.
The additional per-key obsolete warnings read `filament/` only.
- A dangling `compatible_printers` inside an `instantiation: "false"` base is invisible to
`check_preset_references`: a base never becomes a `Preset` at all (its config goes into `config_maps`
and the loader returns early), so it is in no collection for the check to walk.
- Orphan bases that nothing inherits are scattered through the tree — usually the leftover of a
half-finished nozzle addition.
## Adding a quality tier or a nozzle's processes
1. Choose the layer height and quality label using the vendor's existing ladder.
2. If the vendor has per-nozzle bases, add one (`fdm_process_<vendor>_<lh>_nozzle_<n>`) with the layer
height, nozzle-appropriate line widths, `initial_layer_print_height` and `ironing_inset`.
3. Add the leaf: 7 keys, `compatible_printers` naming the exact printer variant(s).
4. Register both in `process_list`, parent first. Bump the version, run the id tool, validate.
5. Slice this process explicitly with its intended printer; the sweep gives non-default tiers no
dedicated coverage. If it is a printer's `default_print_profile`, verify the exact name and
resolved compatibility too — the sweep may fall back or select another compatible process.
@@ -0,0 +1,177 @@
# Reviewing a profile change
Start with delivery, identity and backward compatibility, then check the affected preset types.
The table highlights gaps that need human review. What CI *does* run:
[validation.md](validation.md).
| Not checked by CI | Consequence |
| --- | --- |
| The `version` bump | The change never reaches an upgrading user |
| A misspelled setting key | Setting silently has no effect |
| A filename Windows cannot check out, or one that differs from its `sub_path` only in case | Works on the author's machine, breaks the bundle on another platform |
| `bed_model` / `bed_texture` / `hotend_model` pointing at a missing asset | Bed renders as Custom, hotend falls back to the generic model |
| A nozzle size in a model's list with no matching variant | The size is offered and resolves to nothing |
| A non-default process | `validate_slice` gives non-default quality tiers no dedicated coverage |
| Whether the intended default survived compatibility selection | The sweep can select a different compatible preset |
| A dangling `compatible_printers` inside an `instantiation: "false"` base | A base never becomes a `Preset`, so the reference check never sees it (a bad `inherits` in a base *is* caught) |
| A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name |
| Per-extruder vector length on a multi-nozzle printer | Silently padded (with the **first** value) or truncated |
## 1. Was the vendor `version` bumped?
For **every** bundle whose folder the diff touches, `resources/profiles/<Vendor>.json` must have its
`version` incremented — last component, carrying `.99` into the third component. A library change
means bumping `OrcaFilamentLibrary.json`.
*Why:* nothing in CI checks it, and `PresetUpdater` reinstalls only when `vendor_ver < resource_ver`
without a bump the change reaches neither an upgrading user nor the author's own running app.
## 2. Was the index rebuilt, and does the diff contain only this change?
`check` now fails on an unregistered file, on an index `update-index` would reorder, and on a file
`normalize` would rewrite — so a PR that skipped them arrives red, and you do not have to spot the
omission yourself. Three things are still yours:
- **The index diff belongs to this change.** `update-index` rewrites whole `*_list` sections. If the
bundle had drifted, the author's PR now carries someone else's reordering; ask for it in a separate
commit rather than reviewing it inline.
- **A deleted selectable preset needs a successor** as in item 4. `update-index` removes its
registration; `validate_custom` detects the break only for names covered by released fixtures.
- **`normalize` edits content, not just layout.** It drops `version` and `is_custom_defined` from preset
files, removes obsolete keys, deletes six print-speed keys from filament profiles, and resolves
`extruder_clearance_radius` against `extruder_clearance_max_radius` by keeping the larger.
Check that the keys it removed were meant to go.
Obsolete keys fail `check`'s normalization pass and should be removed with `normalize`.
`check` also reports per-key obsolete warnings for filament profiles in the selected vendors.
*Why:* the index is the loader's only entry point. Out-of-order entries fail with `can not find inherits`
and take the whole vendor bundle down; an unindexed file gets reviewed, merged and never loads.
## 3. Are ids generated, not written?
No hand-typed or copied `setting_id` / `filament_id`. Instantiated presets have a `setting_id`; bases do
not. `check` enforces all of that; what it cannot tell you is whether the identity *should* have moved.
A rewritten or removed `filament_id` means a product's identity moved — a rename, or an edited
`filament_vendor` / `filament_type` — and the old id is not forwarded anywhere. Confirm that was
intended, and that a new id is not a rename in disguise.
*Why:* a duplicate `filament_id` on one printer makes AMS spool matching a coin toss; a copied
`setting_id` breaks preset identity. See [ids.md](ids.md).
## 4. Does anything disappear for existing users?
A rename, a deletion, or a flip of `"instantiation": "true"``"false"` on a shipped preset removes the
name from the preset collection. It needs `renamed_from` on a successor — and only one preset may claim a
given old name. The claimed old name must **not** still be a live preset; the redirect is inert if it is.
*Why:* user presets inheriting it die with `can not find parent <name> for config <file>!`; 3MF-embedded
presets are dropped with no error at all. Commit `33923464ae` reverted exactly this for Cubicon;
`6943b6ddc3` redid it correctly. CI's `validate_custom` catches the shipped-name case — but not an inert
`renamed_from`.
## 5. Is `compatible_printers` right?
Exact printer **variant** names, non-empty on every instantiated filament outside OrcaFilamentLibrary
and written in the preset's own file — golden rule 6, with the flattened-vs-own-key trap in
[filament-profiles.md](filament-profiles.md#compatible_printers). Watch for a nozzle-specific variant that
inherited or copied the base's full printer list, and for two presets of one product with overlapping
lists — duplicate combobox entries and an ambiguous AMS match.
*Why:* real shipped bugs twice (`b7b3418baf` "showing up everywhere", `ff83aa41ef` duplicate Flashforge
entries).
## 6. Model ↔ variant ↔ process consistency
- New nozzle size → the model's `nozzle_diameter` list extended, a variant with a matching
`printer_variant`, and at least one process listing that variant.
- `default_print_profile` is one exact name (not a `;` list), and that process's resolved
compatibility list or condition includes this printer.
- `default_filament_profile` is an array of names that exist.
*Why:* an unlisted `printer_variant` is a hard bundle-load failure. Default process selection is
weaker: the sweep attempts the named default, then updates compatibility and rejects generic Default
fallbacks. Another compatible process can conceal a bad reference, so inspect it even after a pass.
## 7. Types and spellings
Every value a string or an array of strings; `filament_type` an array; `instantiation` the string
`"true"`/`"false"` — golden rule 7. Check index metadata and model `nozzle_diameter` especially;
wrong types there can abort loading for **every** vendor.
The part only a reviewer can do: check new setting keys against `src/libslic3r/PrintConfig.cpp`. A
misspelled key is silently discarded (rule 8), the single most common way a profile edit does nothing
while CI stays green.
## 8. Blast radius of a base edit
A change to `fdm_*_common.json` reaches every child at once. Ask which presets it touches — several
reverts in this repo are exactly this (`41d1b0d3c8`, `dc491166a8`). Also check whether the edited leaf has
children of its own: Prusa, Flashforge and Elegoo all chain leaf-inherits-leaf several levels deep.
## 9. Do the numbers make sense for the nozzle?
Check resolved widths and layer heights against the nozzle, and flow limits / pressure advance
against the actual hardware and material. The patterns in [process-profiles.md](process-profiles.md)
are examples, not mandatory values; [filament-profiles.md](filament-profiles.md) explains what to
revisit for a nozzle change. A cloned preset's unchanged MVS needs particular scrutiny.
Settings tuned for real hardware cannot be verified by reading the diff. Say so rather than approving
numbers nobody measured.
## 10. Asset references (not checked anywhere)
`bed_model`, `bed_texture`, `hotend_model` and `<Model>_cover.png` exist under
`resources/profiles/<vendor folder>/`. Broken references already ship; nothing checks them.
## 11. `default_materials` (checked by CI)
`check` fails on a `default_materials` / `default_filament_profile` name that resolves to no system
filament, so a dangling entry no longer reaches review. Scope the run while working on one vendor:
```bash
python3 scripts/orca_profile_tool.py check --vendor "<Vendor>" # py -3 on Windows
```
## 12. Per-extruder vector lengths (not checked)
One entry per extruder for the plain per-extruder vectors; the `printer_options_with_variant_1` keys are
sized to `printer_extruder_variant` instead. A wrong length is silently padded — repeating the **first**
value, not the last — or truncated. The two sizing families and the worked cases are in
[machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers).
## 13. Non-default processes get no slice coverage
`validate_slice` starts from printer defaults; it does not enumerate every process. Slice a new or
changed non-default tier explicitly with its intended printer.
## 14. Housekeeping worth a nit, not a block
`"from"` other than `"system"` (the preset-bundle loader ignores it, though the CLI's config-file loader
rejects anything but `system`/`user`/`User`), `printer_settings_id` copied from another
vendor, and a filename that disagrees with the preset's `name` (common; the loader keys off `name`).
## 15. Cross-platform filenames and paths (not checked)
Check for Windows-invalid characters, reserved device names, trailing path-component spaces/dots,
and case mismatches in `sub_path` or asset paths. See [cross-platform paths](validation.md#cross-platform-paths).
---
## Reporting the review
A finding is: **one defect**, its file, what breaks at runtime or in CI, and the fix. Split independent
defects into separate findings even when they live in one file — five id problems in one bullet get one
fix and four survivors.
Severity discriminates only if it is earned:
| Severity | Means |
| --- | --- |
| blocker | the bundle fails to load, or a preset is unreachable at runtime |
| major | CI fails, or existing users lose a preset |
| minor | wrong-but-working: dead keys, `from`, naming, redundant overrides |
Compute every number and id (`orca_profile_tool.py`, a scripted count) or omit it — one invented count
makes a reader stop trusting the right ones. Report a command's result only if you ran it.
@@ -0,0 +1,255 @@
# Validating profiles
```bash
./scripts/check_profile.sh # everything CI runs
./scripts/check_profile.sh --vendor "<Vendor>" # fast loop
./scripts/check_profile.sh profile_tool validate_slice # named checks only
```
```bat
scripts\check_profile.bat :: the same three, on Windows
scripts\check_profile.bat -Vendor "<Vendor>"
scripts\check_profile.bat profile_tool validate_slice
```
`check_profile.bat` is a shim around `check_profile.ps1` — same checks, same order, same logs;
the flags take PowerShell spellings (`-Vendor`, `-ProfilesDir`, `-Validator`, `-Download`, `-Refresh`,
`-WorkDir`, `-LogLevel`) and positional check names are unchanged. `-p`, `-v` and `-l` are aliases, so
`-v Elegoo -l 2` reads the same on both platforms. It passes `-ExecutionPolicy Bypass` because a
default Windows client refuses to run a checked-out `.ps1` at all. The `.ps1` finds Python itself,
probing `py -3`, then `python`, then `python3`; run the tool by hand with `py -3` for the same reason.
Every check in the run happens even after an earlier one fails; the script exits non-zero if any did, and writes
`.test/check_profiles/logs/<check>.log` plus, on failure, `.test/check_profiles/pr_comment.md` — the same
report CI posts on the PR. A stale `.test/check_profiles/.lock` after a crash must be removed by hand.
## The five checks
| Check | Command it runs | Catches |
| --- | --- | --- |
| `profile_tool` | `python3 scripts/orca_profile_tool.py check` | index coverage **both ways**, preset-name collisions, files `normalize`/`update-index` would still rewrite, duplicate JSON keys, filament `compatible_printers`, `filament_type` array, conflict keys, id length, **all `setting_id` and `filament_id` rules** |
| `validate_system` | `validator -p resources/profiles -l 2` | load errors, missing filament `compatible_printers`, dangling `inherits`/`compatible_*`, duplicate `filament_id` per printer |
| `validate_slice` | `validator -p … -s -l 2` | custom G-code expansion, unresolvable printer defaults |
| `validate_filament_subtypes` | `validator -p … -l 2 -f` | nothing extra — see below |
| `validate_custom` | `validator -p <tree+fixture> -l 2` | a shipped preset name that a past release offered no longer resolving |
**`-f` is a no-op.** It is declared `po::bool_switch()->default_value(true)`, so the duplicate-`filament_id`
check runs whether or not you pass it — `validate_system` already fails on duplicates. The binary's own
`--help` ("Off unless this flag is present") does not reflect that default.
### `validate_custom` — the backward-compatibility gate
Downloads one fixture archive per past release (v1.9.0 onwards) of *generated mock* user presets —
a `<vendor>_<preset>_orca_test` copy of every system preset that
release shipped, cut with the validator's own `-g 1` mode — unpacks each over a copy of the current tree
and loads it. Each entry holds only `inherits` plus a canned diff, so the one failure it adds over
`validate_system` is a shipped preset name disappearing. (The whole current tree sits under each fixture,
so every `validate_system` error fails it too.) This is what makes a rename or an
`instantiation` flip a CI failure rather than just a user complaint, and the reason `renamed_from` is
mandatory.
### `validate_slice`
Slices a two-colour cube on every instantiable printer in the tree, sequentially, forcing the prime tower.
It selects `default_print_profile` and the first `default_filament_profile`, then updates compatibility;
that update can select a different compatible preset. Confirm the intended defaults yourself rather
than treating a passing sweep as proof that those exact presets were sliced.
A printer fails if it cannot be selected, falls back to a Default preset, throws, produces no g-code, or
emits no `CP TOOLCHANGE START`. It cannot be scoped to a filament-only vendor
(`No instantiable printer presets found for vendor OrcaFilamentLibrary`); `check_profile.sh` records it
as SKIP for a vendor with no `machine/` folder.
## `orca_profile_tool.py check`
`check` is one subcommand of the tool that also owns
`generate-id`, `normalize`, `trim` and `update-index`; see [ids.md](ids.md) for the writing half.
| Per vendor | Catches |
| --- | --- |
| `check_preset_name_uniqueness` | two files in one bundle claiming one type + name — indexed or not |
| `check_index_coverage` | a file on disk that no `*_list` references (**an error, not a warning**) |
| `check_name_consistency` | an index entry whose `name` disagrees with the file, or whose `sub_path` is missing |
| `check_normalized` | a file `normalize` would rewrite, and an index `update-index` would rebuild |
| `check_filament_compatible_printers` | an instantiated non-library filament with no `compatible_printers` of its own |
| `check_conflict_keys` | `extruder_clearance_radius` alongside `extruder_clearance_max_radius` |
| `check_vector_type_keys` | a vector option written as a scalar (`"filament_type": "PLA"`) |
| `check_filament_id_length` | a declared `filament_id` longer than 8 characters |
| `check_machine_default_materials` | every `default_materials` / `default_filament_profile` name resolves |
| `check_obsolete_keys` | per-key warnings for ignored options; **filament files only** |
Tree-wide, **ignoring `--vendor` entirely**: `check_setting_id_uniqueness` and `check_filament_ids`. So a
vendor-scoped run can and does fail on another vendor's files — and it saves seconds, not minutes.
Unscoped, the per-vendor pass covers every bundle. The only exclusion is the stray `user/` directory
(see below); `OrcaFilamentLibrary` is held to the same rules as any vendor, its sole exemption being
that a library filament may leave `compatible_printers` empty — exactly what
`check_filament_compatible_printers` allows. `check_normalized` covers every bundle with an index.
Notes that matter:
- Exit codes: **0** clean, **1** errors found, **2** argparse misuse. Warnings never change the exit code.
- A nonexistent `--vendor` is a hard error — `[ERROR] unknown vendor "<V>" in <dir>`, exit 1.
- `--vendor ""` means all vendors; `check_profile.sh` relies on that. `--vendor` is repeatable.
- A **stray directory** under `resources/profiles/` still gets counted as a vendor by the per-vendor pass
and warned about (`No profiles found for vendor: <dir> at …/<dir>.json`, and the "Checked vendors" count
goes up by one). The one exception is `user/`, the validator's data dir, which an unscoped `check`
skips by name; `--vendor user` still checks and warns about it. Warnings never change the exit code.
`normalize`, `trim` and `update-index` ignore strays too — they define a bundle as *a directory with a
matching index file*.
- Each remedy is printed once for the whole run, not once per file, as a `[WARNING]` under the errors
("2 unreferenced file(s) above: delete them, or run … update-index"). Read those lines: they name the
command that fixes the batch.
- The trailing summary always suggests `normalize`. That is right for the shape errors and misleading for
everything else — an id error needs `generate-id`, a dangling `default_materials` needs a human.
- `resources/profiles/check_unused_setting_id.py` is a legacy BBL-only diagnostic, not part of
profile CI. Use `orca_profile_tool.py check` for current id validation.
### Obsolete-key diagnostics
`check` always reports per-key warnings for obsolete options in filament profiles.
The normalization check also rejects obsolete keys across preset types; `normalize` removes them.
### Default-material references
The materials check finds `default_materials` / `default_filament_profile` entries naming a preset
that does not exist. The three authoring errors it surfaces are `,` instead of `;`, wrong case
(`@system`), and a whole `;`-joined string stuffed into one array element.
### `normalize` and `update-index` are part of the check
`check` fails when either command would still change something, so they are not optional polish — the
file that gets reviewed has to be the file that ships. What `normalize` changes is narrow and fixed:
adds a missing `type`, deletes a `version` or `is_custom_defined` key from a *preset* file, deletes six
print-speed keys from filament profiles (`initial_layer_print_speed`, `outer_wall_speed`,
`inner_wall_speed`, `infill_speed`, `top_surface_speed`, `travel_speed`), deletes the
obsolete keys in `PrintConfigDef::handle_legacy`'s `ignore` set across preset types, resolves the
`extruder_clearance_*` conflict pair by keeping the larger, arrayifies five filament options besides
`filament_type`, and hoists `type`, `name`, `renamed_from`, `inherits`, `from`, `setting_id`,
`filament_id`, `instantiation` to the front. A file it changes is then rewritten whole — tab-indented,
LF, one trailing newline, keys reordered.
**Set `type` explicitly when authoring.** For a file in `machine/` without it, normalization guesses
`machine` only if its name contains `nozzle`, otherwise `machine_model`. That heuristic cannot
reliably classify shared machine bases or unusually named variants.
The Python obsolete-key set is checked against the C++ source by a unit test. Active options
and legacy aliases that the loader migrates (such as `extruder_type` and
`extruder_clearance_max_radius`) are preserved.
Two things it therefore does **not** enforce:
- **Formatting and key order on their own.** A file with none of those problems is skipped entirely, so
4-space indent, a missing trailing newline, and a file that leads with `compatible_printers` all pass
`check`. They stay latent until something else trips `normalize` and the whole file reformats inside an
unrelated diff. (`normalize --force` rewrites every file, which is not something to run on a shipped
bundle.)
- **A misspelled setting key.** `inital_layer_height` and `sparse_infill_densiti` pass `check` cleanly.
Verify new keys against `PrintConfig.cpp` and `PrintConfigDef::handle_legacy`.
## The validator binary
Built from `src/dev-utils/OrcaSlicer_profile_validator.cpp` (`-DORCA_TOOLS=ON`).
Both scripts find a local build under `build*/``check_profile.sh` tries Release, RelWithDebInfo, then
Debug, and `check_profile.ps1` adds MinSizeRel — else they download the nightly into
`.test/check_profiles/validator`. Pass `--download` / `-Download` to match CI exactly, since a stale
local build is used silently. Windows looks for `OrcaSlicer_profile_validator.exe`.
If your build lives somewhere else entirely, point at it with `--validator` / `-Validator`, or set
`ORCA_PROFILE_VALIDATOR` (`$env:ORCA_PROFILE_VALIDATOR` in PowerShell).
| Flag | Meaning |
| --- | --- |
| `-p <dir>` | profile tree (also becomes the data dir) |
| `-l <n>` | log level; CI uses 2 |
| `-v <Vendor>` | load only that vendor **plus** OrcaFilamentLibrary |
| `-s` | slice sweep |
| `-f` | no-op (see above) |
| `-g 1` | regenerate user-preset fixtures; takes a value, and wipes the user preset dir first |
On ARM64 Linux the nightly is x86-64 only — the script warns and downloads anyway, producing a binary
that will not run. Build it locally instead.
Running the validator directly uses the profile tree as its data directory and can create `user/`
there. Prefer the wrappers, which stash existing user presets and restore them afterward. After a
direct run, inspect `user/` and remove only empty directories created by that run; fixtures or
pre-existing user files may be present.
## Checking a copy of the tree
Use `--profiles DIR` on the Python tool and `-p DIR` on the validator. The wrappers' `--profiles` /
`-ProfilesDir` passes the tree to both, so one run validates a copy fully:
```bash
./scripts/check_profile.sh --profiles "<tree>"
```
On Windows use `scripts\check_profile.bat -ProfilesDir "<tree>"`.
## Testing in the app
Editing this checkout's `resources/profiles` does not update a separately installed application.
Test with a build using the edited resources and a bumped bundle version; the updater installs newer
bundles under `<data_dir>/system/`, and the preset cache also depends on the bundle version.
Use Help ▸ Show Configuration Folder to locate the active data directory:
| Platform | Default data directory |
| --- | --- |
| macOS | `~/Library/Application Support/OrcaSlicer` |
| Linux | `$XDG_CONFIG_HOME/OrcaSlicer`, or `~/.config/OrcaSlicer` when unset |
| Windows | `%APPDATA%\OrcaSlicer` |
A portable `data_dir` next to the executable takes precedence. Use a separate test configuration
for a clean-install check; preserve the normal configuration and user presets.
## Cross-platform paths
Match the exact case of each `sub_path` and asset filename; Linux filesystems commonly distinguish
case even when a macOS or Windows checkout does not. Preset-name references are case-sensitive
on every platform. Avoid Windows-invalid characters (`< > : " | ? *`), reserved device names
such as `CON` / `NUL` (including with extensions), and trailing spaces or dots in path components.
Keep stems tidy too, but a space immediately before `.json` is not a trailing path-component space.
## Error → remedy
| Message | Fix |
| --- | --- |
| `can not find inherits <parent> for <preset>` | parent missing, unregistered, or listed **after** the child |
| `can not find filament_id for <name>` | nothing in the chain declares one — run `generate-id` |
| `can not find parent <name> for config <user preset>!` | a shipped name disappeared — add `renamed_from` |
| `Missing instantiation attribute for <name>` | key absent **or** not the string `"true"`/`"false"` |
| `contains incorrect keys: <keys>, which were removed` | a key valid for a different preset type |
| `defines invalid printer variant "<v>"` | not in the model's `nozzle_diameter` list |
| `has printer_variant "<v>" that does not match its nozzle_diameter` | the set comparison in [machine-profiles.md](machine-profiles.md) |
| `references unknown compatible_printers "<p>"` | the printer was renamed or deleted; fix the reference |
| `references renamed compatible_printers "<old>" (now "<new>")` | in-tree references must name the current preset; `renamed_from` does not excuse them |
| `Filament preset "<f>" is missing compatible_printers setting` | non-library filaments need a non-empty list in their **own** file — the flattened-vs-own-key trap is in [filament-profiles.md](filament-profiles.md#compatible_printers) |
| `Ambiguous AMS filament match: N presets share filament_id "X" … printer "Y"` | make the lists disjoint, or fix an `inherits` pointing at another material's `@base` |
| `Layer height cannot exceed nozzle diameter.` / `Line width too small` | `Print::validate()` flow rules |
| `[ERROR] … no <V>.json list references it, so it never loads` | `update-index`, or delete the file |
| `[ERROR] … references it and it declares no profile type` | set the correct `type` explicitly, then `normalize` and `update-index` |
| `[ERROR] … normalize would <change>` / `<V>.json: update-index would rebuild <lists>` | run that command and commit the result |
| `[ERROR] <V> has N <type> profiles named "<name>"` | identify the intended preset and remove or rename the duplicate; use `trim --dry-run` only for deliberate unindexed-file cleanup |
| `[ERROR] … must not have a setting_id` / `is missing a setting_id` | `generate-id --setting-id` |
| `inherits filament_id "X" but its own triple … mints "Y"` | `generate-id` will **not** fix this — see [ids.md](ids.md) |
| `vendor <V>'s config version: <s> invalid` | the `version` string is not Semver-parseable |
| `[json.exception.type_error.302] type must be string` | locate the non-string value in the index or model; see [failure scopes](vendor-bundle.md#failure-modes-ranked-by-blast-radius) |
| `Printer "<p>" fell back to a default preset` | final process or filament selection is a generic Default preset; check named defaults, visibility and available compatible presets. An incompatible default may instead be replaced without this error |
| `Printer "<p>" sliced but the filament change never fired` | `change_filament_gcode` never expanded |
## CI
`.github/workflows/check_profiles.yml`, job **"Check profiles"**, on `pull_request` into `main` or
`release/*`, paths `resources/profiles/**`, `resources/printers/**`, `scripts/**` and the workflow itself.
There is no push trigger — a direct push to main runs no profile validation.
The job opens with `python3 -m unittest discover -s scripts/tests -t scripts`, the tool's own unit
tests. That step is deliberately **not** `continue-on-error`: a broken tool makes everything it then says
about the profiles worthless. Every check after it is `continue-on-error` with a final gate, so one run
reports all five results. On failure a second workflow posts or replaces a single PR comment marked
`<!-- profile-validation-comment -->`, with each failing log truncated to 30 KB; it deletes the comment
once the run is green.
The job name is also the required check for the delegated-merge bot, which lets a vendor maintainer
self-merge a `resources/profiles/<Their vendor>/` PR with no human review — so whatever CI does not check
is what ships unreviewed. Its denied patterns refuse `^scripts/` and any `.py`, so a PR that touches the
tooling always needs a maintainer.
@@ -0,0 +1,175 @@
# The vendor bundle and the loader
A bundle is `resources/profiles/<Vendor>.json` (the index) plus `resources/profiles/<Vendor>/`.
The **vendor id is the filename stem**, not the `name` inside — several differ (`BBL.json` is named
"Bambulab"). Asset paths and the `setting_id` formula use the id; the `validate_custom` fixture prefix
uses the `name`.
## The index
```json
{
"name": "Phrozen",
"version": "02.04.00.03",
"force_update": "0",
"description": "Phrozen configurations",
"machine_model_list": [ { "name": "...", "sub_path": "machine/....json" } ],
"machine_list": [ ... ],
"process_list": [ ... ],
"filament_list": [ ... ]
}
```
The loader reads `name`, `version`, `url` and the four `*_list` arrays.
`description` is only logged. `force_update` is read by `PresetUpdater`, never by the loader.
`sub_path` is relative to the **vendor folder**.
| List | Holds |
| --- | --- |
| `machine_model_list` | `machine_model` records (the printer product) |
| `machine_list` | printer variants **and** shared machine bases |
| `process_list` | selectable processes **and** shared process bases |
| `filament_list` | selectable filaments **and** shared filament bases |
### Three registration rules
1. **Everything is registered, bases included.** Every preset file on disk has exactly one entry in the
matching list, and no unindexed preset file is left in the tree.
2. **Parents before children.** `inherits` resolves against a per-kind map filled as the list is walked
(`configs.clear()` then process, filaments, printers). A parent listed after its child produces
`can not find inherits <parent> for <child>` and the bundle is discarded.
3. **The index entry's `name` must equal the `name` inside the sub_path file.** `check_name_consistency`
walks the index looking for the files; `check_index_coverage` walks the files looking for them in the
index. The `renamed_from` escape hatch `check_name_consistency`'s docstring promises is commented out.
All three are `check` errors now, and `update-index` writes an index that satisfies all three from the
files on disk — including the parents-first ordering, by topological sort. Hand-editing the index is
fine for a one-line addition, but the committed result must equal what `update-index` writes, because
`check` compares them.
The loader itself reports none of this: an unregistered file, or an entry with a typo'd key
(`"subpath"`), is silently dropped. (A typo'd `sub_path` is a `check` error naming the entry.)
`BBL/cli_config.json` and `BBL/filament/filaments_color_codes.json` are auxiliary data loaded by path,
not presets. The tool's `NON_PROFILE_FILES` excludes these basenames from preset maintenance.
## `version`
Parsed by a four-component Semver where the 4th is folded in as `patch = patch*100 + value`. Write it
zero-padded, `MM.mm.pp.bb`; a couple of bundles drop a component or the padding, but do not imitate them.
- **Bump the version for every bundle the PR touches.** `PresetUpdater` installs bundled resources
only when their version is newer than the installed version; the `.opc` preset cache is also
versioned. Nothing in profile CI checks the bump.
- **Keep the last component ≤ 99.** `02.04.00.100` and `02.04.01.00` both parse to `2.4.100`. A bundle
that reaches `.99` carries into the third component (`02.03.02.99``02.03.03.00`).
- An **absent** version is worse than a stale one: the validator still passes, but `Semver::valid()`
excludes `0.0.0`, so the vendor is dropped from the configuration wizard entirely and the preset cache
is disabled for it. An *unparseable* version is not silent — it throws and discards the whole bundle
(see the failure table below).
## Common preset keys
| Key | Value |
| --- | --- |
| `type` | `machine_model` / `machine` / `process` / `filament` |
| `name` | the preset name; the filename is *not* authoritative |
| `inherits` | the parent's exact `name` — no path, no `.json` |
| `instantiation` | the **string** `"true"` (selectable) or `"false"` (base) |
| `from` | `"system"` by convention; the vendor loader never reads it |
| `setting_id` | required on instantiated presets, forbidden on bases — generated |
| `renamed_from` | `;`-separated list of old names this preset supersedes |
These are config-preset keys; `machine_model` records have their own
[schema](machine-profiles.md#a-machine_model-is-not-a-config-preset). Keep `from` as `"system"`
for shipped presets. The vendor loader ignores it, but the CLI config-file loader accepts only
`system`, `user` or `User` and handles their inheritance differently.
`instantiation` is the one metadata key that is hard-gated: a missing key or any value other than the
strings `"true"`/`"false"` is an error (`Missing instantiation attribute for <name>`). A JSON boolean
`true` fails harder — it throws inside `load_from_json` and takes the **whole vendor bundle** down.
### `inherits`
Resolution is an exact-name lookup **within the same bundle**, plus one exception: filaments may inherit
from `OrcaFilamentLibrary`, which is loaded first and becomes the base bundle. Vendor-to-vendor
inheritance always fails. You can inherit from an instantiated preset as well as from a base; it is
common.
### `renamed_from`
One JSON string, `;`-separated for several old names.
- Write `"A;B"`, never `"A ; B"` — an unquoted item keeps its trailing space and can never match.
- When `renamed_from` is **absent** and the name contains `@`, the loader auto-adds the `@`-removed form
(`X @Y``X Y`) as a rename alias. Declaring an explicit `renamed_from` **suppresses** that, so a
preset that needs both the `@`-removed form and a real old name must list both. No shipped profile
currently does, which means any preset that gained a `renamed_from` quietly lost its `X Y` alias.
- It rescues names stored **outside** the tree: user presets and 3MF projects. It does **not** rescue
in-tree `inherits` (exact lookup), it does **not** satisfy `check_name_consistency`, the validator
reports an in-tree reference that only resolves through it (`references renamed compatible_printers
"OLD" (now "NEW")`), and `machine_model` records never read it at all.
- Only one preset may claim a given old name — two that do is a counted error
(`… was marked as renamed from "Y" … as well`). But the redirect is **inert while a live preset still
carries that name**, and nothing checks *that*; Z-Bolt ships a folder of such dead entries.
## Failure modes, ranked by blast radius
| Scope | Cause |
| --- | --- |
| **All vendors, zero system profiles** | a non-string `version`, `name` or `url` at the top level of a vendor index (`"version": 2`), or non-string `nozzle_diameter` on a model — `nlohmann::type_error` escapes the per-vendor `std::runtime_error` catch |
| **The whole vendor bundle** | unparseable `version`; index JSON parse error; a `sub_path` file missing or unparseable; unresolvable `inherits`; duplicate preset name within the vendor; empty/unknown `printer_model` or `printer_variant`; a filament resolving no `filament_id` |
| **One preset** | `instantiation` missing or a wrong string; keys belonging to another preset type (`contains incorrect keys: …, which were removed`); a non-string inside a `*_list` entry (`invalid value type for <key>`) |
| **Logged, not counted** | a raw JSON number in a preset — `invalid json type for <key>`, the value is dropped and the exit code stays 0 |
| **Nothing reported by the loader** | unregistered file; misspelled setting key; missing bed/hotend asset. Only the first of those is a `check` error; the other two reach users |
Deleting a file the index still lists surfaces as a *parse error* on line 1, not "file not found" — the
loader `ifstream`s the missing path and nlohmann reports `unexpected end of input`.
Preset names are a **single global namespace across every vendor**: a duplicate within one vendor is a
hard bundle failure, a duplicate across vendors is reported as `Found duplicated preset: <name> in
vendor: <vendor>` and still counts as an error. `check_preset_name_uniqueness` catches the within-bundle
case earlier and more precisely — including an *unindexed* twin, which is one `sub_path` edit away from
silently becoming the parent every child resolves to (`std::map::emplace` keeps the first insertion, so
index order decides). Base names, by contrast, repeat across bundles by design: `fdm_process_common`
exists in nearly all of them.
## Starting a whole new vendor bundle
Nothing generates one; copy the smallest bundle that resembles the hardware. **`Voxelab` or `M3D`** are
the minimal shape — a shared machine base, the model, one variant, a shared process base, two
processes, and an empty `filament_list` that takes the library generics. Do *not* start from `Phrozen`:
it carries local `fdm_filament_*` copies that have drifted from the library, and a filament preset that
restates most of its parent — the style this skill advises against.
Write the machine files **last**, so you only visit them once:
1. **Choose the names first** — model, variant(s), process(es). Everything else references them.
2. `resources/profiles/<Vendor>.json`: `name`, `version` (`01.00.00.00`), `force_update: "0"`,
`description`, and all four `*_list` arrays (an empty `filament_list` is fine).
3. The shared bases — `<Vendor>/machine/fdm_machine_common.json` and
`<Vendor>/process/fdm_process_common.json`, both `"instantiation": "false"` with no `setting_id`.
For a Klipper printer add your own `<Vendor>/machine/fdm_klipper_common.json` inheriting the machine
base; there is no shared one, because a `machine` preset can only inherit inside its own bundle.
4. One selectable process per variant, each naming its variant in `compatible_printers`.
5. Bed assets and `<Model>_cover.png`, all directly in `<Vendor>/`. None of them is needed for the
bundle to load, and nothing in CI checks them — but the bed files are inert unless the `machine_model`
names them in `bed_model` / `bed_texture`, and the cover is found by convention as
`<the name you gave the model in machine_model_list>_cover.png`.
6. The `machine_model` record and the `machine` variants, now that every value they reference exists —
the minimum key sets and the `default_*` shapes are in
[machine-profiles.md](machine-profiles.md#the-machine-variant).
7. Run the tool and validate — follow
[Creating or modifying a profile](../SKILL.md#creating-or-modifying-a-profile). `generate-id` is not
optional for a new bundle: the validator loads presets that have no `setting_id`, but `check` fails
every one of them. `update-index` will fill the four `*_list` arrays for you once the files exist, so
step 2 only needs the bundle metadata to be right.
## `resources/profiles_template/`
A separate tree (`Template.json` + `Template/`) holding filament and process templates. It is **not** a
scaffold for shipped profiles — `CreatePresetsDialog.cpp` reads it for the in-app "create a custom
printer/filament" wizard, so editing it changes what users get when they create a custom preset.
`check_profile.sh`'s validator checks default to `resources/profiles` (redirectable with `-p`), and so
does `orca_profile_tool.py` (redirectable with `--profiles`);
neither covers this tree.
+61 -5
View File
@@ -14,6 +14,9 @@ on:
- 'localization/**'
- 'resources/**'
- ".github/workflows/build_*.yml"
- ".github/workflows/unit_tests*.yml"
- 'build_win.bat'
- 'scripts/test_build_win.ps1'
- 'scripts/build_preset_cache.*'
- 'scripts/flatpak/**'
- 'scripts/msix/**'
@@ -30,9 +33,8 @@ on:
- '**/CMakeLists.txt'
- 'version.inc'
- ".github/workflows/build_*.yml"
- ".github/workflows/unit_tests*.yml"
- 'build_linux.sh'
- 'build_release_vs.bat'
- 'build_release_vs2022.bat'
- 'build_win.bat'
- 'scripts/test_build_win.ps1'
- 'build_release_macos.sh'
@@ -207,7 +209,7 @@ jobs:
./validator-bin/OrcaSlicer_profile_validator -p "${{ github.workspace }}/resources/profiles" -s -l 2
publish_test_results:
name: Publish Test Results
needs: [unit_tests_linux_x86_64, unit_tests_linux_aarch64, unit_tests_windows_x64, unit_tests_windows_arm64, unit_tests_macos_arm64]
needs: [unit_tests_linux_x86_64, unit_tests_linux_aarch64, unit_tests_windows_x64, unit_tests_windows_arm64, unit_tests_macos_arm64, unit_tests_flatpak_x86_64, unit_tests_flatpak_aarch64]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
@@ -324,9 +326,16 @@ jobs:
sed -i '/^build-options:/a\ no-debuginfo: true\n strip: true' \
scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
shell: bash
- name: Inject git commit hash into Flatpak manifest
# flatpak-builder reuses a module from its cache when the definition and
# sources are unchanged, so a re-run of the same commit would skip the
# OrcaSlicer module and ship no test asset. A per-run value in that module's
# env keeps it rebuilding; orca_deps stays cached, and the compiler cache
# still serves the rebuild.
- name: Inject commit hash and flatpak-builder cache buster into Flatpak manifest
env:
flatpak_builder_cache_buster: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n git_commit_hash: \"$git_commit_hash\"|}" \
sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n flatpak_builder_cache_buster: \"$flatpak_builder_cache_buster\"\n git_commit_hash: \"$git_commit_hash\"|}" \
scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
shell: bash
# flatpak-builder's --ccache only wraps cc and gcc, and the manifest builds
@@ -372,6 +381,10 @@ jobs:
save-cache: false
arch: ${{ matrix.variant.arch }}
upload-artifact: false
# run-tests fires the module's build-only test-commands; keep-build-dirs
# retains the binaries for the packaging step below.
run-tests: true
keep-build-dirs: true
# The build has just touched everything it can use, so an object untouched
# for a week is dead, usually orphaned by a flag change.
- name: Compiler cache statistics
@@ -425,3 +438,46 @@ jobs:
asset_name: OrcaSlicer-Linux-flatpak_nightly${{ env.nightly_suffix }}_${{ matrix.variant.arch }}.flatpak
asset_content_type: application/octet-stream
max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted
# The asset is /app (the exes link it at runtime) plus the build tree
# slimmed to what ctest needs.
- name: Package flatpak test asset
shell: bash
run: |
d=$(ls -d .flatpak-builder/build/OrcaSlicer-* | tail -1)
find "$d/build_flatpak" -mindepth 1 -maxdepth 1 ! -name tests -exec rm -rf {} +
# Strip debug info (the SDK builds with -g, only the app gets stripped);
# the bounds checks are compiled in, so a stripped exe still catches them.
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
# resources/ the shipped profiles (PROFILES_DIR) and the printers/ maps.
find "$d" -mindepth 1 -maxdepth 1 -type d \
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers -exec rm -rf {} +
tar -cf flatpak-test-asset.tar flatpak_app "$d"
- name: Upload flatpak test asset
uses: actions/upload-artifact@v7
with:
name: ${{ github.sha }}-flatpak-tests-${{ matrix.variant.arch }}
path: flatpak-test-asset.tar
retention-days: 1
# keep-build-dirs would otherwise land in the flatpak-builder cache saved post-job.
- name: Drop the kept build dirs before the flatpak-builder cache saves
if: always()
shell: bash
run: rm -rf .flatpak-builder/build
unit_tests_flatpak_x86_64:
name: Flatpak x86_64
needs: flatpak
if: ${{ !cancelled() && success() }}
uses: ./.github/workflows/unit_tests_flatpak.yml
with:
os: ubuntu-24.04
artifact: ${{ github.sha }}-flatpak-tests-x86_64
unit_tests_flatpak_aarch64:
name: Flatpak aarch64
needs: flatpak
if: ${{ !cancelled() && success() }}
uses: ./.github/workflows/unit_tests_flatpak.yml
with:
os: ubuntu-24.04-arm
artifact: ${{ github.sha }}-flatpak-tests-aarch64
+2 -1
View File
@@ -41,7 +41,8 @@ jobs:
# restores one it cannot use. Linux amd64 passes no arch deliberately, so
# 'linux-clang' keeps the cache it already has.
cache-os: ${{ runner.os == 'macOS' && format('macos-{0}', inputs.arch) || (runner.os == 'Windows' && format('windows-{0}-{1}', inputs.arch, inputs.compiler) || format('linux-clang{0}', inputs.arch && format('-{0}', inputs.arch) || '')) }}
# ARM64 builds use the build-arm64 tree (see build_release_vs.bat); x64/other use build.
# The Windows ARM64 deps build in build-arm64, all others under build;
# build_deps.yml and build_orca.yml pass the Windows directory to build_win.bat.
dep-folder-name: ${{ runner.os == 'macOS' && format('/{0}', inputs.arch) || (runner.os == 'Windows' && inputs.arch == 'arm64') && '-arm64/OrcaSlicer_dep' || '/OrcaSlicer_dep' }}
output-cmd: ${{ runner.os == 'Windows' && '$env:GITHUB_OUTPUT' || '"$GITHUB_OUTPUT"'}}
run: |
+5 -19
View File
@@ -138,25 +138,11 @@ jobs:
if (-not "${{ vars.SELF_HOSTED }}") {
choco install strawberryperl
}
$arch = "${{ inputs.arch }}"
# -l selects clang-cl and -x Ninja; together they build the deps with clang.
$clang = "${{ inputs.compiler }}" -eq "clang"
$flags = if ($clang) { "-l", "-x" } else { @() }
if ($clang) {
# OpenSSL builds with nmake, which needs a VC environment.
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$vs = & $vswhere -latest -property installationPath
$devArch = if ($arch -eq "arm64") { "arm64" } else { "amd64" }
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=$devArch"
}
if ($arch -eq "arm64") {
.\build_release_vs.bat deps arm64 @flags
.\build_release_vs.bat pack arm64
} else {
.\build_release_vs.bat deps @flags
.\build_release_vs.bat pack
}
# cache-path is the install directory inside the deps build directory.
$deps = (Split-Path "${{ inputs.cache-path }}").Replace('\', '/')
# -l compiles with Visual Studio's clang-cl and -x builds with Ninja; --msvc --msbuild is cl under the Visual Studio generator.
$flags = if ("${{ inputs.compiler }}" -eq "clang") { "-l", "-x" } else { "--msvc", "--msbuild" }
.\build_win.bat -d --arch ${{ inputs.arch }} --deps-dir $deps @flags
shell: pwsh
- name: Build on Mac ${{ inputs.arch }}
+15 -14
View File
@@ -85,6 +85,15 @@ jobs:
shell: bash
run: |
leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}"
# clang-cl refuses a precompiled header from another cl.exe build and ccache
# does not hash that build, so each one gets its own cache. The build number
# is read from cl.exe itself; the toolset directory keeps its name across patches.
if [ "${{ runner.os }}" = Windows ]; then
vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe'
toolset=$(tr -d '\r\n' < "$("$vswhere" -latest -products '*' -find 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt' | tr -d '\r')")
cl=$("$vswhere" -latest -products '*' -find 'VC\Tools\MSVC\'"$toolset"'\**\cl.exe' | tr -d '\r' | head -1)
leg="$leg-vc$("$cl" 2>&1 | grep -o -E 'Version [0-9.]+' | cut -d' ' -f2)"
fi
echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV"
echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV"
@@ -450,21 +459,13 @@ jobs:
# env:
# WindowsSdkDir: 'C:\Program Files (x86)\Windows Kits\10\'
# WindowsSDKVersion: '10.0.26100.0\'
# "tests" builds the unit tests too; the unit_tests_windows_* jobs run them.
# --tests builds the unit tests too; the unit_tests_windows_* jobs run them.
run: |
$arch = "${{ inputs.arch }}"
# -l selects clang-cl and -x Ninja; together they build the slicer with clang.
$clang = "${{ inputs.compiler }}" -eq "clang"
$flags = if ($clang) { "-l", "-x" } else { @() }
if ($clang) {
# Build against the same VC toolchain and SDK as the dependencies.
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$vs = & $vswhere -latest -property installationPath
$devArch = if ($arch -eq "arm64") { "arm64" } else { "amd64" }
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=$devArch"
}
if ($arch -eq "arm64") { .\build_release_vs.bat slicer arm64 @flags tests } else { .\build_release_vs.bat slicer @flags tests }
# cache-path is the install directory inside the deps build directory.
$deps = (Split-Path "${{ inputs.cache-path }}").Replace('\', '/')
# -l compiles with Visual Studio's clang-cl and -x builds with Ninja; --msvc --msbuild is cl under the Visual Studio generator.
$flags = if ("${{ inputs.compiler }}" -eq "clang") { "-l", "-x" } else { "--msvc", "--msbuild" }
.\build_win.bat -s --tests -i --arch ${{ inputs.arch }} --build-dir $env:BUILD_DIR --deps-dir $deps @flags
shell: pwsh
- name: Build system preset cache (Windows)
+27 -14
View File
@@ -9,8 +9,9 @@ on:
- release/*
paths:
- 'resources/profiles/**'
# The extra JSON check also validates resources/printers/bambu_filament_ids.json,
# and lives in scripts/, so a PR touching only those must still run this workflow.
# orca_profile_tool.py also validates resources/printers/bambu_filament_ids.json, and
# both it and its tests live in scripts/, so a PR touching only those must still run
# this workflow.
- 'resources/printers/**'
- 'scripts/**'
- ".github/workflows/check_profiles.yml"
@@ -36,12 +37,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
- name: Run extra JSON check
id: extra_json_check
# Deliberately not continue-on-error, unlike every check below: if the tool itself is
# broken, nothing it then reports about the profiles is worth reading.
- name: Run the profile tool's own unit tests
run: python3 -m unittest discover -s scripts/tests -t scripts
# What the validator below cannot see. It loads the tree the way the slicer does, so
# it never notices a profile no <vendor>.json indexes, a preset name two files claim,
# an id that is not the mint of its own triple, or a file that normalize and
# update-index would still rewrite.
# The step id is the handle the PR comment and the failure gate below use; renaming it
# silently disables them.
- name: Check profiles (orca_profile_tool.py)
id: profile_tool
continue-on-error: true
run: |
set +e
python3 ./scripts/orca_extra_profile_check.py 2>&1 | tee ${{ runner.temp }}/extra_json_check.log
python3 ./scripts/orca_profile_tool.py check 2>&1 | tee ${{ runner.temp }}/profile_tool.log
exit ${PIPESTATUS[0]}
# download
@@ -68,8 +80,8 @@ jobs:
set +e
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -s -l 2 2>&1 | tee ${{ runner.temp }}/validate_slice.log
exit ${PIPESTATUS[0]}
# All vendors' filament_id collisions were fixed (see scripts/filament_id_snapshot.json),
# so the duplicate-filament-subtype check runs tree-wide.
# All vendors' filament_id collisions were fixed, so the duplicate-filament-subtype
# check runs tree-wide.
- name: validate filament subtype check
id: validate_filament_subtypes
continue-on-error: true
@@ -186,7 +198,7 @@ jobs:
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
- name: Prepare comment artifact
if: ${{ always() && github.event_name == 'pull_request' && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: |
{
# Marker matched by check_profiles_comment.yml to delete prior comments.
@@ -194,11 +206,11 @@ jobs:
echo "## :x: Profile Validation Errors"
echo ""
if [ "${{ steps.extra_json_check.outcome }}" = "failure" ]; then
echo "### Extra JSON Check Failed"
if [ "${{ steps.profile_tool.outcome }}" = "failure" ]; then
echo "### Profile Check Failed (orca_profile_tool.py)"
echo ""
echo '```'
head -c 30000 ${{ runner.temp }}/extra_json_check.log || echo "No output captured"
head -c 30000 ${{ runner.temp }}/profile_tool.log || echo "No output captured"
echo '```'
echo ""
fi
@@ -240,7 +252,7 @@ jobs:
fi
echo "---"
echo "*Please fix the above errors and push a new commit.*"
echo '*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
} > ${{ runner.temp }}/profile-check-results/pr_comment.md
- name: Upload comment artifact
@@ -252,7 +264,8 @@ jobs:
retention-days: 1
- name: Fail if any check failed
if: ${{ always() && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
if: ${{ always() && (steps.profile_tool.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: |
echo "One or more profile checks failed. See above for details."
echo "One or more profile checks failed; see the step logs above."
echo 'Reproduce the whole run locally with scripts/check_profile.sh (scripts\check_profile.bat on Windows).'
exit 1
+219
View File
@@ -0,0 +1,219 @@
# Nightly parity checks from OrcaSlicer/orca-test-repo, kept out of the
# per-build "Run external slicer regression tests" step because they take far
# longer than that step's budget:
# effect - the CLI override sweep's full effect stage: every landed option
# re-sliced on its own to see whether it changes the G-code
# harness - the GUI-vs-CLI parity harness (metrics only, never fails)
# Both test the latest successful build_all.yml Linux AppImage from main, with
# sources checked out at the commit that build was made from. Nothing here
# gates a build or a PR.
name: Parity Nightly
on:
schedule:
# build_all.yml starts at 17:00 UTC and has finished by ~20:00
- cron: "0 21 * * *"
workflow_dispatch:
inputs:
test_repo_ref:
description: "orca-test-repo ref to run"
required: false
default: "main"
build_branch:
description: "branch whose latest successful build_all artifact to test"
required: false
default: "main"
fixtures:
description: "harness fixture ids, space-separated (empty = all)"
required: false
default: ""
cli_presets:
description: "harness lane C presets: flat = flatten inherits first, raw = leaf profile as-is"
required: false
default: "flat"
permissions:
contents: read
actions: read
jobs:
build:
name: Find the build to test
# Don't run scheduled checks on forks
if: github.event_name != 'schedule' || github.repository == 'OrcaSlicer/OrcaSlicer'
runs-on: ubuntu-24.04
outputs:
run_id: ${{ steps.find.outputs.run_id }}
head_sha: ${{ steps.find.outputs.head_sha }}
steps:
- id: find
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh run list --workflow build_all.yml \
--branch "${{ inputs.build_branch || 'main' }}" \
--status success --limit 1 --json databaseId,headSha \
--jq '"run_id=\(.[0].databaseId)\nhead_sha=\(.[0].headSha)"' \
>> "$GITHUB_OUTPUT"
cat "$GITHUB_OUTPUT"
effect:
name: Override sweep effect stage (shard ${{ matrix.shard }})
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# orca-test-repo's parity/effect_routing.json holds a 2-way split,
# ~12.5 min a shard on this runner
shard: [0, 1]
steps:
- &checkout-suite
name: Check out the test suite
uses: actions/checkout@v7
with:
repository: OrcaSlicer/orca-test-repo
ref: ${{ inputs.test_repo_ref || 'main' }}
path: orca-test-repo
# The AppImage ships only packed preset caches, so profiles and the CLI
# option surface come from the sources the build was made from
- &checkout-slicer
name: Check out OrcaSlicer at the build's commit
uses: actions/checkout@v7
with:
ref: ${{ needs.build.outputs.head_sha }}
path: slicer
lfs: 'false'
- &extract-appimage
name: Download and extract the Linux AppImage
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh run download "${{ needs.build.outputs.run_id }}" --dir appimage \
--pattern "OrcaSlicer_Linux_ubuntu_2404*"
appimage=$(find appimage -name "*.AppImage" ! -name "*aarch64*" | head -1)
[ -n "$appimage" ] || { echo "no x86_64 AppImage in run ${{ needs.build.outputs.run_id }}"; exit 1; }
chmod +x "$appimage"
"$appimage" --appimage-extract > /dev/null
# The bare binary cannot find the AppImage's bundled libraries; AppRun
# sets them up and execs it, so exit codes and signals pass through
[ -x squashfs-root/AppRun ] || { echo "no AppRun in the AppImage"; exit 1; }
echo "ORCA_BIN=$PWD/squashfs-root/AppRun" >> "$GITHUB_ENV"
echo "ORCA_SOURCE=$PWD/slicer" >> "$GITHUB_ENV"
- name: Install the AppImage's host runtime dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libopengl0 libglu1-mesa libgl1 libegl1 libwebkit2gtk-4.1-0
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install suite dependencies
run: pip install -r orca-test-repo/requirements.txt
- name: Run the override sweep with the full effect stage
id: run
continue-on-error: true
working-directory: orca-test-repo
run: |
set -o pipefail
# -rA keeps the per-stage summaries, which pytest otherwise swallows
# for passing tests
python -m pytest test_cli_overrides.py -c pytest.ini -v -rA \
--effect-full --effect-shard ${{ matrix.shard }}/2 \
--orca-bin "$ORCA_BIN" --orca-source "$ORCA_SOURCE" \
2>&1 | tee ../sweep.log
- name: Publish job summary
if: always()
run: |
{
echo "## Override sweep effect stage, shard ${{ matrix.shard }}/2"
echo "Build ${{ needs.build.outputs.head_sha }} (run ${{ needs.build.outputs.run_id }})"
echo '```'
grep -E "\[override sweep" sweep.log || echo "no stage summaries, see the log"
grep -E "^=+ .*(passed|failed)" sweep.log | tail -1 || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload the override report
if: always()
uses: actions/upload-artifact@v7
with:
name: override-report-shard${{ matrix.shard }}
path: |
orca-test-repo/.pytest_cache/override_report.json
sweep.log
if-no-files-found: warn
retention-days: 30
# The sweep step continues on error so the summary and report still get
# published; this puts the failure back on the job
- name: Fail the job if the sweep failed
if: steps.run.outcome == 'failure'
run: |
echo "the override sweep failed, see the job summary and the uploaded report" >&2
exit 1
harness:
name: GUI-vs-CLI parity harness
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 180
steps:
- *checkout-suite
- *checkout-slicer
- *extract-appimage
- name: Install display tooling and the AppImage's host runtime
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb xdotool imagemagick openbox mesa-utils \
libopengl0 libglu1-mesa libgl1 libegl1 libwebkit2gtk-4.1-0
- name: Run the parity harness
run: |
set -euo pipefail
fixtures=()
for f in ${{ inputs.fixtures || '' }}; do
fixtures+=(--fixture "$f")
done
# 2 GUI displays: ~1.5 cores peak / ~1.9 GB on this 4-vCPU runner,
# and each fixture is fully isolated, so results match a serial run
python3 orca-test-repo/parity/run_parity.py \
--slicer-root "$ORCA_SOURCE" --bin "$ORCA_BIN" \
--cli-presets "${{ inputs.cli_presets || 'flat' }}" \
--gui-workers 2 --out "$PWD/parity-out" "${fixtures[@]}"
- name: Publish job summary
if: always()
run: |
if [ -f parity-out/report.md ]; then
cat parity-out/report.md >> "$GITHUB_STEP_SUMMARY"
else
echo "the harness produced no report, see the log" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Drop per-lane datadirs before upload
if: always()
run: rm -rf parity-out/*/seed parity-out/*/datadir-* || true
- name: Upload the scorecard and evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: parity-scorecard
path: parity-out/
if-no-files-found: warn
retention-days: 30
+407 -6
View File
@@ -12,6 +12,13 @@ name: PR Merge Bot
# PR targets main or release/*, and CI is green on the head commit. Otherwise it
# comments naming the files that fell outside the grant.
#
# When a PR touching resources/profiles/** is opened, two labels are applied
# independently of the merge command:
# profile every changed path is inside resources/profiles/
# orca profile partner the PR author holds a grant covering every changed
# path, plus a one-time comment explaining /bot merge
# Neither label changes what the merge command checks.
#
# Grants come from the FOLDER_MERGERS variable in the `merge-delegation`
# environment: one per line, `account: path`, `#` comments and blank lines
# allowed. Paths may contain spaces. A vendor takes two grants, the folder and
@@ -32,10 +39,18 @@ on:
issue_comment:
types:
- created
# Labels profile PRs on open, without waiting for a /bot merge command.
pull_request_target:
types:
- opened
paths:
- 'resources/profiles/**'
# One merge attempt per PR at a time, so two quick comments cannot race.
# Labels run under their own group, so a queued label run is not replaced by
# a merge run for the same PR.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
@@ -43,6 +58,7 @@ jobs:
# Skips the job unless a PR comment mentions the command.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '/bot merge')
permissions:
@@ -53,7 +69,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
# delegated merge would wait for a human reviewer.
# delegated merge and partner label run would wait for a human reviewer.
environment: merge-delegation
steps:
- name: Merge PR on behalf of a folder delegate
@@ -76,7 +92,6 @@ jobs:
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
const MERGE_METHOD = 'squash';
const REQUIRED_CHECK = 'Check profiles'; // job name in check_profiles.yml
const MAX_CHANGED_FILES = 500; // policy cap, well under listFiles' 3000
const LISTFILES_CAP = 3000;
const MAX_REPORTED_FILES = 12;
const MERGEABLE_ATTEMPTS = 5;
@@ -304,9 +319,6 @@ jobs:
'so the file list is truncated and I cannot verify the folder scope. A maintainer must merge this one.'
);
}
if (pr.changed_files > MAX_CHANGED_FILES) {
return refuse(`it changes ${pr.changed_files} files; delegated merges are capped at ${MAX_CHANGED_FILES}.`);
}
const deniedFiles = [];
const outsideFiles = [];
@@ -508,3 +520,392 @@ jobs:
} catch (error) {
core.warning(`Merged successfully, but dispatching build_all.yml failed: ${error.message}`);
}
label-profile:
# Independent of the merge rules: any PR that changes only files inside
# resources/profiles/ is labeled `profile`.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'pull_request_target'
permissions:
contents: read
pull-requests: read
issues: write
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Label profile-only PRs
uses: actions/github-script@v9
with:
script: |
function isPermissionDenied(error) {
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
}
const PROFILE_ROOT = 'resources/profiles/';
const LABEL = 'profile';
const LISTFILES_CAP = 3000;
const ATTEMPTS = 3;
function profileOnlyProblem(pr, files) {
if (!files.length) {
return 'PR changes no files; not labeling.';
}
// A truncated list, or a count that disagrees with the PR, cannot
// prove "only profile files".
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
return `PR reports ${pr.changed_files} changed files but the API listed ${files.length}; not labeling.`;
}
// Both endpoints of a rename count, so a move out of the profile
// root is not mistaken for a profile-only change.
const paths = files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean));
const outside = paths.filter((path) => !path.startsWith(PROFILE_ROOT));
if (outside.length) {
return `${outside.length} changed path(s) fall outside ${PROFILE_ROOT}; not labeling.`;
}
return null;
}
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
// The event payload is frozen at `opened`; listFiles is not. Read
// fresh PR metadata and retry if either side of the diff changes.
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.state !== 'open') {
core.info(`PR is ${pr.state}; not labeling.`);
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100
});
const problem = profileOnlyProblem(pr, files);
const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (
after.state !== 'open' ||
after.head.sha !== pr.head.sha ||
after.base.ref !== pr.base.ref ||
after.base.sha !== pr.base.sha
) {
core.info('PR changed while listing files; retrying.');
continue;
}
if (problem) {
core.info(problem);
return;
}
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [LABEL]
});
core.info(`Applied the "${LABEL}" label.`);
} catch (error) {
if (isPermissionDenied(error)) {
core.warning(`Cannot add the "${LABEL}" label because the token cannot write.`);
return;
}
throw error;
}
return;
}
core.warning('PR kept changing during verification; not labeling.');
label-profile-partner:
# Labels a profile PR whose author holds a grant covering every changed
# path, and explains the /bot merge command to them once.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'pull_request_target'
permissions:
contents: read # delegatable subtree, for file modes
pull-requests: read
issues: write # label + comment
runs-on: ubuntu-latest
timeout-minutes: 10
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
# qualifying PR open would wait for a human reviewer.
environment: merge-delegation
steps:
- name: Label profile PRs from delegated maintainers
uses: actions/github-script@v9
env:
# Read as an env var, never interpolated into the script body.
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
with:
script: |
function isPermissionDenied(error) {
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
}
// Never prints the grant list: this job posts public comments and
// its logs are public too.
async function bestEffort(call, warning) {
try {
await call();
} catch (error) {
if (isPermissionDenied(error)) {
core.warning(warning);
return;
}
throw error;
}
}
const MARKER = '<!-- profile-partner-bot -->';
const LABEL = 'orca profile partner';
const ATTEMPTS = 3;
// ---- scope rules, mirrored from the merge job above ----
// Change both together: these decide whether a delegate could merge.
const DELEGATABLE_ROOT = 'resources/profiles/';
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
const LISTFILES_CAP = 3000;
const REGULAR_FILE_MODES = new Set(['100644', '100755']);
const DENIED_PATTERNS = [
/^\.github\//,
/(^|\/)\.git(attributes|modules|ignore|config)$/,
/^(?:src|deps|deps_src|tests|tools|cmake|sandboxes|scripts|docs?|localization|bbl)\//,
/(^|\/)cmakelists\.txt$/,
/\.cmake$/,
/^build_[^/]*\.(?:sh|bat)$/,
/^version\.inc$/,
// Executables, including those inside the delegatable root.
/\.(?:sh|bash|bat|cmd|ps1|py|js|mjs|cjs|ts|rb|pl|php)$/
];
function parseGrants(raw) {
// GitHub login: 1-39 chars, alphanumerics with single interior hyphens.
const loginPattern = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
const grantsByLogin = new Map();
const problems = [];
(raw || '').split(/\r?\n/).forEach((rawLine, index) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return;
}
// Splits on the first colon only, so paths may contain ':' and spaces.
const separator = line.indexOf(':');
if (separator === -1) {
problems.push(`line ${index + 1}: expected \`account: path\``);
return;
}
const login = line.slice(0, separator).trim().replace(/^@/, '');
const path = line.slice(separator + 1).trim().replace(/\/+$/, '');
if (!loginPattern.test(login)) {
problems.push(`line ${index + 1}: \`${login}\` is not a valid GitHub account name`);
return;
}
if (/[\\*?\u0000-\u001f\u007f]/.test(path) || path.split('/').includes('..') || path.includes('//')) {
problems.push(`line ${index + 1}: invalid path (no globs, \`..\`, \`//\`, backslashes or control characters)`);
return;
}
// Rejects anything outside the root, and the bare root itself.
if (!path.startsWith(DELEGATABLE_ROOT) || path.length <= DELEGATABLE_ROOT.length) {
problems.push(`line ${index + 1}: \`${path}\` is not inside \`${DELEGATABLE_ROOT}\``);
return;
}
const key = login.toLowerCase();
grantsByLogin.set(key, (grantsByLogin.get(key) || []).concat(path));
});
return { grantsByLogin, problems };
}
function isDenied(path) {
if (/[\\\u0000-\u001f\u007f]/.test(path) || path.startsWith('/') || path.split('/').includes('..')) {
return true;
}
const normalized = path.normalize('NFKC').toLowerCase();
return DENIED_PATTERNS.some((pattern) => pattern.test(normalized));
}
// Byte-exact match on directory boundaries, so a grant of
// `.../Acme` covers neither `.../Acme Labs/x.json` nor `.../Acme.json`.
function isGranted(path, grants) {
return grants.some((grant) => path === grant || path.startsWith(`${grant}/`));
}
// Both endpoints of a rename; both must satisfy the grant.
function pathsFor(file) {
return [file.filename, file.previous_filename].filter(Boolean);
}
// ---- end mirrored rules ----
function scopeProblem(pr, files, grants) {
if (!files.length) {
return 'PR changes no files; not labeling.';
}
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
return `PR reports ${pr.changed_files} changed files but the API listed ${files.length}; not labeling.`;
}
let outsideCount = 0;
for (const file of files) {
for (const path of pathsFor(file)) {
if (isDenied(path) || !isGranted(path, grants)) {
outsideCount += 1;
}
}
}
if (outsideCount) {
return `PR has ${outsideCount} path(s) outside @${author}'s grants; not labeling.`;
}
return null;
}
// ---- file modes: rejects symlinks and submodules ----
function modeProblem(files, tree) {
if (tree.truncated) {
return 'The profile tree is too large to verify file modes; not labeling.';
}
const modesByPath = new Map(tree.tree.map((entry) => [`${DELEGATABLE_ROOT}${entry.path}`, entry.mode]));
const hasIrregularFile = files.some((file) =>
file.status !== 'removed' && !REGULAR_FILE_MODES.has(modesByPath.get(file.filename)));
if (hasIrregularFile) {
return 'PR adds symlinks, submodules or files whose modes cannot be verified; not labeling.';
}
return null;
}
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
const { grantsByLogin, problems } = parseGrants(process.env.FOLDER_MERGERS);
// Only the count: the malformed lines may name grant holders.
if (problems.length) {
core.warning(`FOLDER_MERGERS has ${problems.length} malformed line(s); not labeling.`);
return;
}
const grants = grantsByLogin.get(author.toLowerCase()) || [];
// Says nothing to accounts with no grant, so it cannot be used to spam.
if (!grants.length) {
core.info(`Ignoring PR from @${author}: not listed in FOLDER_MERGERS.`);
return;
}
// Read current PR metadata for the file list and head tree. Retry
// if either side of the diff changes during verification.
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.state !== 'open') {
core.info(`PR is ${pr.state}; not labeling.`);
return;
}
if (!ALLOWED_BASE_BRANCH.test(pr.base.ref)) {
core.info(`PR targets "${pr.base.ref}", not main or release/*; not labeling.`);
return;
}
// Checked before listing files, so a PR too large to list is
// rejected in one call.
if (pr.changed_files >= LISTFILES_CAP) {
core.info(`PR changes ${pr.changed_files} files, more than the API can list; not labeling.`);
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100
});
const scopeIssue = scopeProblem(pr, files, grants);
let modeIssue = null;
if (!scopeIssue) {
const { data: tree } = await github.rest.git.getTree({
owner,
repo,
tree_sha: `${pr.head.sha}:${DELEGATABLE_ROOT.replace(/\/$/, '')}`,
recursive: 'true'
});
modeIssue = modeProblem(files, tree);
}
const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (
after.state !== 'open' ||
after.head.sha !== pr.head.sha ||
after.base.ref !== pr.base.ref ||
after.base.sha !== pr.base.sha
) {
core.info('PR changed while verifying; retrying.');
continue;
}
const problem = scopeIssue || modeIssue;
if (problem) {
core.info(problem);
return;
}
// ---- label + one-time comment ----
await bestEffort(
() => github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }),
`Cannot add the "${LABEL}" label because the token cannot write.`);
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100
});
if (comments.some((comment) => (comment.body || '').includes(MARKER))) {
core.info('Partner notice already present; skipping comment.');
return;
}
await bestEffort(
() => github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body:
`${MARKER}\n` +
`Hi @${author}, this profile PR is covered by your delegated merge grant.\n\n` +
`Once it is ready for review and CI is green, you can merge it yourself:\n\n` +
`- \`/bot merge\` - squash-merge into \`main\` or \`release/*\`\n` +
`- \`/bot merge --dry-run\` - report the verdict without merging\n\n` +
`The bot re-checks the scope, the file modes and the \`Check profiles\` check at merge time.`
}),
'Cannot post the partner notice because the token cannot write comments.');
core.info(`Applied the "${LABEL}" label and posted the /bot merge notice.`);
return;
}
core.warning('PR kept changing during verification; not labeling.');
+4 -2
View File
@@ -54,8 +54,10 @@ jobs:
shell: bash
run: |
tar -xvf build_tests.tar
# Multi-config generators (Windows/macOS) need a config; Linux is single-config.
scripts/run_unit_tests.sh "${{ inputs.test-dir }}" "${{ runner.os != 'Linux' && 'Release' || '' }}"
# Every platform builds with a multi-config generator (build_linux.sh uses Ninja
# Multi-Config), so ctest needs the config: without it, plain add_test() tests
# lose their labels and report "Not Run".
scripts/run_unit_tests.sh "${{ inputs.test-dir }}" Release
- name: Upload Test Logs
if: ${{ failure() }}
uses: actions/upload-artifact@v7
+67
View File
@@ -0,0 +1,67 @@
name: Flatpak Unit Tests
# Run the flatpak build's test asset inside the sandbox, once per arch. The
# GNOME SDK's _GLIBCXX_ASSERTIONS gives a bounds-checked STL that catches
# out-of-bounds reads no other test leg does.
on:
workflow_call:
inputs:
os:
required: true
type: string
artifact:
description: Test asset uploaded by the flatpak build leg
required: true
type: string
jobs:
unit_tests_flatpak:
name: Flatpak Unit Tests
runs-on: ${{ inputs.os }}
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
options: --privileged
steps:
- name: Restore test asset
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact }}
- name: Run unit tests (bounds-checked sandbox)
timeout-minutes: 20
shell: bash
run: |
tar -xf flatpak-test-asset.tar
# Recreate the stable module symlink so /run/build/OrcaSlicer resolves.
d=$(ls -d .flatpak-builder/build/OrcaSlicer-* | tail -1)
ln -sfn "$(basename "$d")" .flatpak-builder/build/OrcaSlicer
# The runtime + SDK + the llvm extension the app metadata references,
# which `flatpak build` mounts; best-effort, the image may have them.
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y --noninteractive flathub \
org.gnome.Platform//50 org.gnome.Sdk//50 org.freedesktop.Sdk.Extension.llvm21//25.08 || true
# `flatpak build` uses bwrap (no rofiles-fuse, which this container
# rejects); bind-mount the build tree so the baked TEST_DATA_DIR resolves.
flatpak build --die-with-parent --share=network \
--bind-mount=/run/build="$PWD/.flatpak-builder/build" \
flatpak_app \
bash -c 'cd /run/build/OrcaSlicer && scripts/run_unit_tests.sh build_flatpak/tests'
- name: Collect test results
if: always()
shell: bash
run: |
d=$(ls -d .flatpak-builder/build/OrcaSlicer-* 2>/dev/null | tail -1 || true)
[ -n "$d" ] && [ -f "$d/ctest_results.xml" ] && cp "$d/ctest_results.xml" ctest_results.xml || true
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ inputs.artifact }}
path: ctest_results.xml
retention-days: 5
if-no-files-found: warn
- name: Delete Test Asset
if: success()
uses: geekyeggo/delete-artifact@v6
with:
name: ${{ inputs.artifact }}
failOnError: false
+6 -11
View File
@@ -15,19 +15,14 @@ cmake --build build --config RelWithDebInfo --target all --
cmake --build . --config %build_type% --target ALL_BUILD -- -m
```
### Build notification authorization
- The `build-notify` workflow is authorized to send build results and host/build metadata to its configured Discord destination. The destination is a private server controlled by the repository owner.
- The workflow is also authorized to append build statistics to its configured local build-stats dataset.
## Testing
Catch2 framework. Tests in `tests/`; see [tests/AGENTS.md](tests/AGENTS.md) for where a new test belongs and the conventions to follow.
```bash
cd build && ctest --output-on-failure # all tests
ctest --test-dir ./tests/libslic3r # individual suite
ctest --test-dir ./tests/fff_print
cd build && ctest -C Release --output-on-failure # all tests
ctest --test-dir ./tests/libslic3r -C Release # individual suite
ctest --test-dir ./tests/fff_print -C Release
```
## Documentation
@@ -69,16 +64,16 @@ ctest --test-dir ./tests/fff_print
- Keep code concise and clear. Manually simplify AI generated bloated codes before review.
- Include targeted tests or documented verification for behavior changes, especially in slicing logic, profiles, formats, and GUI defaults.
- For profile changes (`resources/profiles/<Vendor>/**`), check that `version` in the sibling `resources/profiles/<Vendor>.json` was bumped.
- For translation changes (`localization/i18n/**/*.po`), check that recurring terms match the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_glossary.md) for that language.
- For translation changes (`localization/i18n/**/*.po`), check that recurring terms match the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md) for that language.
## Localization & translations
Catalogs live in `localization/i18n/<lang>/OrcaSlicer_<lang>.po`; the template is `OrcaSlicer.pot`.
See the [Localization guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_guide.md) for the human-facing version of these principles.
See the [Localization guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_guide.md) for the human-facing version of these principles.
### Terminology
- Use the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_glossary.md) as the source of truth for recurring terms, so the same English term is always rendered the same way within a language, and terms that must stay in English (brand/product names, acronyms, materials, file formats, G-code tokens, macros/variables/identifiers) are not translated.
- Use the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md) as the source of truth for recurring terms, so the same English term is always rendered the same way within a language, and terms that must stay in English (brand/product names, acronyms, materials, file formats, G-code tokens, macros/variables/identifiers) are not translated.
- If a term's established translation changes, update both the affected `.po` files and the glossary (`localization_glossary.tsv`, then regenerate) so they stay in sync.
- Translate the *meaning*, not the words. Check what the string actually controls before translating it — English reuses one word for different things. `Flow ratio` (multiplier), `Flow Rate` (throughput) and `Flow Dynamics` (pressure compensation) are three different terms; `extruder` may mean the toolhead, the feeder motor, or the nozzle depending on the string.
- Reuse one template per recurring message shape (`Failed to connect to …`, `Are you sure you want to …?`), even where the English wording varies.
+41 -72
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 ()
@@ -587,10 +592,15 @@ if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR
add_compile_options(-Wno-${w})
endforeach ()
# Turn everything else into an error. Dependency headers are exempt because the SYSTEM
# include flag (-imsvc on clang-cl, -isystem elsewhere) keeps their diagnostics out,
# apart from GCC's maybe-uninitialized, demoted below.
add_compile_options(-Werror)
# GCC is not built in CI, so don't throw errors CI won't catch.
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
add_compile_options(-Werror=return-type)
else ()
# Turn everything else into an error. Dependency headers are exempt because the
# SYSTEM include flag (-imsvc on clang-cl, -isystem elsewhere) keeps their
# diagnostics out.
add_compile_options(-Werror)
endif ()
# Demoted. Remove a name once its category is cleared on every compiler.
set(warnings_demoted)
@@ -612,20 +622,6 @@ if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR
cast-function-type-mismatch
)
endif ()
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
list(APPEND warnings_demoted
# maybe-uninitialized runs after inlining and reports inside boost/variant,
# boost/tuple and the bundled clipper header even with -isystem.
maybe-uninitialized
# array-bounds is reported once, where ConfigOptionVector::set_at inlines
# into OrcaSlicer.cpp on a branch the preceding type test rules out.
array-bounds
# template-id-cdtor is a GCC 14+ warning in the bundled Clipper2 headers.
template-id-cdtor
)
endif ()
if (CMAKE_CXX_COMPILER_ID MATCHES "Clang")
list(APPEND warnings_demoted
# enum-constexpr-conversion is a Clang warning that defaults to an error,
@@ -1085,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
@@ -1118,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
@@ -1157,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()
-52
View File
@@ -1,52 +0,0 @@
set WP=%CD%
set debug=OFF
set debuginfo=OFF
if "%1"=="debug" set debug=ON
if "%2"=="debug" set debug=ON
if "%1"=="debuginfo" set debuginfo=ON
if "%2"=="debuginfo" set debuginfo=ON
if "%debug%"=="ON" (
set build_type=Debug
set build_dir=build-dbg
) else (
if "%debuginfo%"=="ON" (
set build_type=RelWithDebInfo
set build_dir=build-dbginfo
) else (
set build_type=Release
set build_dir=build
)
)
echo build type set to %build_type%
cd deps
mkdir %build_dir%
cd %build_dir%
set DEPS=%CD%/OrcaSlicer_dep
set "SIG_FLAG="
if defined ORCA_UPDATER_SIG_KEY set "SIG_FLAG=-DORCA_UPDATER_SIG_KEY=%ORCA_UPDATER_SIG_KEY%"
if "%1"=="slicer" (
GOTO :slicer
)
echo "building deps.."
echo cmake ../ -G "Visual Studio 16 2019" -A x64 -DCMAKE_BUILD_TYPE=%build_type%
cmake ../ -G "Visual Studio 16 2019" -A x64 -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target deps -- -m
if "%1"=="deps" exit /b 0
:slicer
echo "building Orca Slicer..."
cd %WP%
mkdir %build_dir%
cd %build_dir%
echo cmake .. -G "Visual Studio 16 2019" -A x64 -DCMAKE_BUILD_TYPE=%build_type%
cmake .. -G "Visual Studio 16 2019" -A x64 -DCMAKE_BUILD_TYPE=%build_type% %SIG_FLAG%
cmake --build . --config %build_type% --target ALL_BUILD -- -m
cd ..
call scripts/run_gettext.bat
cd %build_dir%
cmake --build . --target install --config %build_type%
-190
View File
@@ -1,190 +0,0 @@
@REM OrcaSlicer build script for Windows with VS auto-detect
@echo off
set WP=%CD%
set _START_TIME=%TIME%
@REM Default target architecture to the host CPU arch; override by passing
@REM "x64" or "arm64" as an argument. PROCESSOR_ARCHITEW6432 covers a 32-bit
@REM shell running on a 64-bit OS, where PROCESSOR_ARCHITECTURE reads "x86".
set arch=x64
if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" set arch=ARM64
if /I "%PROCESSOR_ARCHITEW6432%"=="ARM64" set arch=ARM64
if /I "%1"=="arm64" set arch=ARM64
if /I "%2"=="arm64" set arch=ARM64
if /I "%1"=="x64" set arch=x64
if /I "%2"=="x64" set arch=x64
@REM Check for Ninja Multi-Config option (-x)
set USE_NINJA=0
for %%a in (%*) do (
if "%%a"=="-x" set USE_NINJA=1
)
@REM Check for clang-cl option (-l). Combined with -x it also builds the deps with
@REM clang-cl; on the Visual Studio generator it applies to the slicer only, because
@REM the dependency sub-builds have no toolset to inherit and stay on MSVC.
set CLANG_ARG=
set TOOLSET_ARG=
for %%a in (%*) do (
if "%%a"=="-l" (
set CLANG_ARG=-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl
set TOOLSET_ARG=-T ClangCL
)
)
@REM Check for unit-tests option ("tests")
set BUILD_TESTS=OFF
for %%a in (%*) do (
if /I "%%a"=="tests" set BUILD_TESTS=ON
)
if "%USE_NINJA%"=="1" (
echo Using Ninja Multi-Config generator
set CMAKE_GENERATOR="Ninja Multi-Config"
set VS_VERSION=Ninja
goto :generator_ready
)
@REM Detect Visual Studio version using msbuild
echo Detecting Visual Studio version using msbuild...
@REM Try to get MSBuild version - the output format varies by VS version
set VS_MAJOR=
for /f "tokens=*" %%i in ('msbuild -version 2^>^&1 ^| findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"') do (
for /f "tokens=1 delims=." %%a in ("%%i") do set VS_MAJOR=%%a
set MSBUILD_OUTPUT=%%i
goto :version_found
)
@REM Alternative method for newer MSBuild versions
if "%VS_MAJOR%"=="" (
for /f "tokens=*" %%i in ('msbuild -version 2^>^&1 ^| findstr /r "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"') do (
for /f "tokens=1 delims=." %%a in ("%%i") do set VS_MAJOR=%%a
set MSBUILD_OUTPUT=%%i
goto :version_found
)
)
:version_found
echo MSBuild version detected: %MSBUILD_OUTPUT%
echo Major version: %VS_MAJOR%
if "%VS_MAJOR%"=="" (
echo Error: Could not determine Visual Studio version from msbuild
echo Please ensure Visual Studio and MSBuild are properly installed
exit /b 1
)
if "%VS_MAJOR%"=="16" (
set VS_VERSION=2019
set CMAKE_GENERATOR="Visual Studio 16 2019"
) else if "%VS_MAJOR%"=="17" (
set VS_VERSION=2022
set CMAKE_GENERATOR="Visual Studio 17 2022"
) else if "%VS_MAJOR%"=="18" (
set VS_VERSION=2026
set CMAKE_GENERATOR="Visual Studio 18 2026"
) else (
echo Error: Unsupported Visual Studio version: %VS_MAJOR%
echo Supported versions: VS2019 (16.8+^), VS2022 (17.x^), VS2026 (18.x^)
exit /b 1
)
echo Detected Visual Studio %VS_VERSION% (version %VS_MAJOR%)
echo Using CMake generator: %CMAKE_GENERATOR%
:generator_ready
@REM Pack deps
if "%1"=="pack" (
setlocal ENABLEDELAYEDEXPANSION
cd %WP%/deps/build
if "%arch%"=="ARM64" cd %WP%/deps/build-arm64
for /f "tokens=2-4 delims=/ " %%a in ('date /t') do set build_date=%%c%%b%%a
echo packing deps: OrcaSlicer_dep_win-!arch!_!build_date!_vs!VS_VERSION!.zip
%WP%/tools/7z.exe a OrcaSlicer_dep_win-!arch!_!build_date!_vs!VS_VERSION!.zip OrcaSlicer_dep
goto :done
)
set debug=OFF
set debuginfo=OFF
if "%1"=="debug" set debug=ON
if "%2"=="debug" set debug=ON
if "%1"=="debuginfo" set debuginfo=ON
if "%2"=="debuginfo" set debuginfo=ON
if "%debug%"=="ON" (
set build_type=Debug
set build_dir=build-dbg
) else (
if "%debuginfo%"=="ON" (
set build_type=RelWithDebInfo
set build_dir=build-dbginfo
) else (
set build_type=Release
set build_dir=build
)
)
if "%arch%"=="ARM64" set build_dir=%build_dir%-arm64
echo build type set to %build_type%, arch=%arch%
setlocal DISABLEDELAYEDEXPANSION
cd deps
mkdir %build_dir%
cd %build_dir%
set "SIG_FLAG="
if defined ORCA_UPDATER_SIG_KEY set "SIG_FLAG=-DORCA_UPDATER_SIG_KEY=%ORCA_UPDATER_SIG_KEY%"
if "%1"=="slicer" (
GOTO :slicer
)
echo "building deps.."
if defined CLANG_ARG if "%USE_NINJA%"=="0" echo Note: -l needs -x for the dependencies; building them with MSVC.
echo on
REM Set minimum CMake policy to avoid <3.5 errors
set CMAKE_POLICY_VERSION_MINIMUM=3.5
if "%USE_NINJA%"=="1" (
cmake ../ -G %CMAKE_GENERATOR% %CLANG_ARG% -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target deps
) else (
cmake ../ -G %CMAKE_GENERATOR% -A %arch% -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target deps -- -m
)
@echo off
if "%1"=="deps" goto :done
:slicer
echo "building Orca Slicer..."
cd %WP%
mkdir %build_dir%
cd %build_dir%
echo on
set CMAKE_POLICY_VERSION_MINIMUM=3.5
if "%USE_NINJA%"=="1" (
cmake .. -G %CMAKE_GENERATOR% %CLANG_ARG% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target all
) else (
cmake .. -G %CMAKE_GENERATOR% -A %arch% %TOOLSET_ARG% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target ALL_BUILD -- -m
)
@echo off
cd ..
call scripts/run_gettext.bat
cd %build_dir%
cmake --build . --target install --config %build_type%
:done
@echo off
for /f "tokens=1-3 delims=:.," %%a in ("%_START_TIME: =0%") do set /a "_start_s=%%a*3600+%%b*60+%%c"
for /f "tokens=1-3 delims=:.," %%a in ("%TIME: =0%") do set /a "_end_s=%%a*3600+%%b*60+%%c"
set /a "_elapsed=_end_s - _start_s"
if %_elapsed% lss 0 set /a "_elapsed+=86400"
set /a "_hours=_elapsed / 3600"
set /a "_remainder=_elapsed - _hours * 3600"
set /a "_mins=_remainder / 60"
set /a "_secs=_remainder - _mins * 60"
echo.
echo Build completed in %_hours%h %_mins%m %_secs%s
-80
View File
@@ -1,80 +0,0 @@
@REM OrcaSlicer build script for Windows
@echo off
set WP=%CD%
@REM Pack deps
if "%1"=="pack" (
setlocal ENABLEDELAYEDEXPANSION
cd %WP%/deps/build
for /f "tokens=2-4 delims=/ " %%a in ('date /t') do set build_date=%%c%%b%%a
echo packing deps: OrcaSlicer_dep_win64_!build_date!_vs2022.zip
%WP%/tools/7z.exe a OrcaSlicer_dep_win64_!build_date!_vs2022.zip OrcaSlicer_dep
exit /b 0
)
set debug=OFF
set debuginfo=OFF
@REM Default target architecture to the host CPU arch; override with x64/arm64 arg.
set arch=x64
if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" set arch=ARM64
if /I "%PROCESSOR_ARCHITEW6432%"=="ARM64" set arch=ARM64
if "%1"=="debug" set debug=ON
if "%2"=="debug" set debug=ON
if "%1"=="debuginfo" set debuginfo=ON
if "%2"=="debuginfo" set debuginfo=ON
if /I "%1"=="arm64" set arch=ARM64
if /I "%2"=="arm64" set arch=ARM64
if /I "%1"=="x64" set arch=x64
if /I "%2"=="x64" set arch=x64
if "%debug%"=="ON" (
set build_type=Debug
set build_dir=build-dbg
) else (
if "%debuginfo%"=="ON" (
set build_type=RelWithDebInfo
set build_dir=build-dbginfo
) else (
set build_type=Release
set build_dir=build
)
)
if "%arch%"=="ARM64" set build_dir=%build_dir%-arm64
echo build type set to %build_type%, arch=%arch%
setlocal DISABLEDELAYEDEXPANSION
cd deps
mkdir %build_dir%
cd %build_dir%
set "SIG_FLAG="
if defined ORCA_UPDATER_SIG_KEY set "SIG_FLAG=-DORCA_UPDATER_SIG_KEY=%ORCA_UPDATER_SIG_KEY%"
if "%1"=="slicer" (
GOTO :slicer
)
echo "building deps.."
echo on
REM Set minimum CMake policy to avoid <3.5 errors
set CMAKE_POLICY_VERSION_MINIMUM=3.5
cmake ../ -G "Visual Studio 17 2022" -A %arch% -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target deps -- -m
@echo off
if "%1"=="deps" exit /b 0
:slicer
echo "building Orca Slicer..."
cd %WP%
mkdir %build_dir%
cd %build_dir%
echo on
set CMAKE_POLICY_VERSION_MINIMUM=3.5
cmake .. -G "Visual Studio 17 2022" -A %arch% -DORCA_TOOLS=ON %SIG_FLAG% -DCMAKE_BUILD_TYPE=%build_type%
cmake --build . --config %build_type% --target ALL_BUILD -- -m
@echo off
cd ..
call scripts/run_gettext.bat
cd %build_dir%
cmake --build . --target install --config %build_type%
+8
View File
@@ -27,8 +27,15 @@ endif ()
# Boost.Container's bundled dlmalloc passes int* where the Win32 Interlocked API
# takes volatile long*; cl compiles that with a warning, clang errors out.
set(_boost_c_flags_line "")
set(_boost_cxx_flags_line "")
if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang")
set(_boost_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-incompatible-pointer-types")
# The Visual Studio generator applies only the link language's flags to a
# project, and boost_container links as C++, so its C file never sees
# CMAKE_C_FLAGS. The C++ flags reach every file; keep CMake's defaults.
if (CMAKE_GENERATOR MATCHES "Visual Studio")
set(_boost_cxx_flags_line "-DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS} -Wno-incompatible-pointer-types")
endif ()
endif ()
orcaslicer_add_cmake_project(Boost
@@ -46,6 +53,7 @@ orcaslicer_add_cmake_project(Boost
"${_context_arch_line}"
"${_context_impl_line}"
"${_boost_c_flags_line}"
"${_boost_cxx_flags_line}"
)
set(DEP_Boost_DEPENDS ZLIB)
+12
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")
@@ -184,6 +185,11 @@ function(orcaslicer_add_cmake_project projectname)
if (_dep_msvc_gen)
set(_gen CMAKE_GENERATOR "${DEP_MSVC_GEN}" CMAKE_GENERATOR_PLATFORM "${DEP_PLATFORM}")
# The toolset picks the compiler here, not the CMAKE_<LANG>_COMPILER
# forwarded below, so without it a clang-cl superbuild builds with cl.
if (CMAKE_GENERATOR_TOOLSET)
list(APPEND _gen CMAKE_GENERATOR_TOOLSET "${CMAKE_GENERATOR_TOOLSET}")
endif ()
else()
set(_gen "")
endif()
@@ -358,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)
@@ -447,6 +458,7 @@ set(_dep_list
dep_NLopt
dep_OpenVDB
dep_OpenCSG
${SLVS_PKG}
dep_OpenCV
dep_Eigen
dep_CGAL
+3
View File
@@ -7,4 +7,7 @@ orcaslicer_add_cmake_project(Draco
${_options}
URL https://github.com/google/draco/archive/refs/tags/1.5.7.zip
URL_HASH SHA256=27b72ba2d5ff3d0a9814ad40d4cb88f8dc89a35491c0866d952473f8f9416b77
CMAKE_ARGS
# The encoder and decoder tools duplicate draco.lib; see deps-windows.cmake.
"${DEP_LLD_FORCE_MULTIPLE}"
)
+2
View File
@@ -8,6 +8,8 @@ orcaslicer_add_cmake_project(NLopt
-DNLOPT_GUILE:BOOL=OFF
-DNLOPT_SWIG:BOOL=OFF
-DNLOPT_TESTS:BOOL=OFF
# testopt is built regardless of NLOPT_TESTS; see deps-windows.cmake.
"${DEP_LLD_FORCE_MULTIPLE}"
)
if (MSVC)
+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}
+6
View File
@@ -80,6 +80,12 @@ ExternalProject_Add(dep_OpenSSL
INSTALL_COMMAND ${_install_cmd}
)
if (CMAKE_GENERATOR MATCHES "Visual Studio")
# OpenSSL builds with cl, but MSBuild runs nmake in this project's toolset
# environment, and ClangCL's puts clang's headers first. Use the default.
set_target_properties(dep_OpenSSL PROPERTIES VS_PLATFORM_TOOLSET "$(DefaultPlatformToolset)")
endif ()
ExternalProject_Add_Step(dep_OpenSSL install_cmake_files
DEPENDEES install
+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 ()
+9
View File
@@ -42,6 +42,15 @@ else ()
message(FATAL_ERROR "Unsupported OS architecture: ${DEPS_ARCH}")
endif ()
# Draco's tools and NLopt's testopt compile sources that are also in their
# static library. MSBuild passes the library before the objects and lld-link
# resolves as it goes, so the library's copy wins and the object then reads as
# a duplicate. Nothing uses those executables, so let lld keep the first one.
set(DEP_LLD_FORCE_MULTIPLE "")
if (CMAKE_GENERATOR MATCHES "Visual Studio" AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
set(DEP_LLD_FORCE_MULTIPLE "-DCMAKE_EXE_LINKER_FLAGS:STRING=${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
endif ()
if (${DEP_DEBUG})
set(DEP_BOOST_DEBUG "debug")
else ()
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Compiles Objects/unicodectype.c without optimisation. VS 2026's ARM64 code
generator needs about 27 GB for _PyUnicode_ToNumeric, a switch with 1951
cases. CPython has the same workaround (python/cpython#153668). -->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<ClCompile Update="..\Objects\unicodectype.c">
<Optimization>Disabled</Optimization>
<WholeProgramOptimization>false</WholeProgramOptimization>
</ClCompile>
</ItemGroup>
</Project>
+11 -1
View File
@@ -88,8 +88,18 @@ if(WIN32)
list(APPEND _python_env_args "PreferredToolArchitecture=${_python_tool_arch}")
endif()
# MSBuild reads extra switches from PCbuild/msbuild.rsp.
set(_python_rsp "/p:PlatformToolset=${_python_platform_toolset}\n")
# VS 2026's ARM64 code generator needs about 27 GB for one function in
# Objects/unicodectype.c (python/cpython#153668); the property sheet compiles
# that file without optimisation.
if(_python_pcbuild_platform STREQUAL "ARM64")
file(TO_NATIVE_PATH "${CMAKE_CURRENT_LIST_DIR}/arm64-unicodectype.props" _python_arm64_props)
string(APPEND _python_rsp "/p:ForceImportAfterCppTargets=\"${_python_arm64_props}\"\n")
endif()
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/python3-msbuild.rsp" "${_python_rsp}")
set(_conf_cmd
cmd /c "echo /p:PlatformToolset=${_python_platform_toolset}>PCbuild\\msbuild.rsp"
${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_BINARY_DIR}/python3-msbuild.rsp" <SOURCE_DIR>/PCbuild/msbuild.rsp
)
set(_build_cmd
${CMAKE_COMMAND} -E env ${_python_env_args}
+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

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