Compare commits

...
Author SHA1 Message Date
Ian Chua f6ccc7a9f9 Merge branch 'main' into feat/printer-agent-impl 2026-10-01 15:09:51 +08:00
SoftFever 3384daa6bc Fix bundled Python crashing on macOS 26 and older when built with Xcode 27 (#16035)
# Description

With Xcode 27, building deps on macOS 26 fails at the Python install
step with a segfault, and a libpython built with Xcode 27 crashes on
macOS 12–26 the first time anything calls `os.pipe()`, which every
plugin `subprocess` call does. The macOS 27 SDK declares `pipe2()` and
`dup3()` as macOS 27-only, and CPython 3.12 calls them without a runtime
check once configure finds them, so on older systems they resolve to
NULL. This keeps CPython on the `pipe()`/`dup2()` fallbacks it already
uses with older SDKs; upstream fixed it in 3.13+
([python/cpython#153711](https://github.com/python/cpython/issues/153711))
but not in 3.12.

No change for builds with Xcode 26 or older, or on Linux and Windows.

# Screenshots/Recordings/Graphs

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

## Tests

Rebuilt deps with Xcode 27 on macOS 26.6: the Python install step now
completes, the installed libpython no longer imports `pipe2`/`dup3`, and
CPython's `test_os`, `test_subprocess` and `test_posix` pass, apart from
one test that needs `_testcapi`, which `--disable-test-modules` leaves
out. Running CPython's configure against the macOS 26.5 and 27.0 SDKs
gives a byte-identical `pyconfig.h` for 26.5 with and without this
change, and for 27.0 with it. The x86_64 cross-build path was configured
on arm64 to confirm both of its configure runs pick up the change.

<!--
> 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-10-01 14:57:30 +08:00
HanifKoh 6842d9c778 Keep the Plugin Tests' Python Packages Out of the Working Directory (#15981)
The plugin test fixtures start the interpreter before the test points
data_dir at its temporary directory, so PythonInterpreter creates
{data_dir}/python/packages and {data_dir}/log with an empty data_dir: a
python/ and log/ folder in whatever directory the tests run from. When that
is the test binary's folder, the next run's embedded-interpreter tests took
the stray python/ as their home and failed to start Python.

Give each fixture that initializes the plugin manager its own temporary
data directory, set up before initialize(), and only use the python/ folder
next to the test binary as the interpreter's home when it holds a standard
library.
2026-10-01 14:42:20 +08:00
HanifKoh 1a5bc8982d Stop Logged-Out Login Polling from Hitting the System Keychain (#15979)
With stealth mode off the home page asks for the login status every 2 s,
and while nobody is logged in that ends in clear_user_secret(), which
opened the system keychain and deleted the OrcaSlicer/Auth entry on the
UI thread every tick. A working keychain cost a D-Bus round trip per tick;
a keychain that never answers blocked the UI for 25 s per tick. It also
deleted a login another running instance had just saved, and ignored
use_encrypted_token_file, so opting out of the keychain did not help.

Remember whether this process read a secret from the store or wrote one,
and only then touch the store when a logged-out poll asks for a logout.
A logged-out instance never touches the keychain, and in encrypted-file
mode the poll no longer opens the keychain at all. A secret this process
cannot read, such as a token file encrypted for another OS user sharing
the data directory, is left alone by the poll. An explicit logout still
wipes both backends, so a token stranded by switching the token storage
option cannot sign the account back in later.
2026-10-01 14:41:50 +08:00
Ian Chua 6194c4f8bb chore: port add_platform_root_certificates from 16027 2026-10-01 14:21:26 +08:00
SoftFever 8aa5b1130a Allow nozzle variants to specify different cooling parameters (#16007)
# Description

Include variant-aware validation, temperature and pressure controls,
named nozzle selection, and filament sidebar refresh.

## Support nozzle-specific filament cooling and tuning

Filament presets can require different cooling and tuning for Standard
and High Flow nozzle variants. This change makes part and auxiliary
cooling, pressure advance, temperature limits, and multitool ramming
settings follow the selected variant, with matching UI controls and
profile validation.

The Snapmaker U1 profiles in #15755 / Snorca illustrate why this
matters:

| Profile | Standard cooling | High Flow cooling |
|---|---|---|
| Snapmaker ABS | Part fan: 15–15% | Part fan: 10–60% |
| Snapmaker PETG HF, 0.4 mm | Part fan: 20–40% | Part fan: 30–60% |
| Snapmaker PETG-CF | Part fan: 0–20%; auxiliary: 0% | Part fan: 5–40%;
auxiliary: 20% |
| Snapmaker PLA Matte | Auxiliary: 80% | Auxiliary: 100% |

These differences need to survive variant selection, editing, and
slicing. Shared single values continue to apply across variants, and
short filament arrays fall back to element zero, matching the loader.

### Additional changes

- Preserve named nozzle selections when refreshing the diameter
selector.
- Enable or disable the pressure-advance input for the selected variant.
- Initialize profile-validation slicing with the printer’s declared
nozzle volume type.
- Refresh filament controls after preset loading.

Profile changes remain separate in `u1-hf`.

### Validation

- All 13 focused variant-tool tests passed.
- Broader checks encountered an existing Windows path-separator
assertion failure, reproduced with upstream code.
- C++ build and GUI validation have not been run.

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-01 13:53:22 +08:00
SoftFever ba361d9882 Texture displacement (#14662)
## Summary

Adds a new paint-style **Texture Displacement** gizmo that stamps
grayscale
height-map textures onto a model's surface and turns them into real
relief -
engraved or embossed detail - either as a live preview or baked into
actual mesh
geometry.

You paint where a texture applies, stack up to **8 blended texture
layers**
(image-editor semantics: Add / Subtract / Multiply / Divide), choose how
each is
projected onto the surface (Triplanar / Cylindrical / Spherical / LSCM
unwrap /
From-view), and - for the LSCM projection - lay the charts out by hand
in a new
dockable 2D **UV Editor** pane. Coarse models can be **Subdivided** or
**Remeshed** first so there are enough vertices to carry fine detail,
and the
result is committed with **Bake**, restricted to the painted area only.

The tool only ever affects the **painted** region; everything left
unpainted
keeps its original surface, and bake blends the relief seamlessly into
it with no
remeshing or hole-filling at the seam.

---

## User-facing features

- **Paint the affected area** with Brush (circle/sphere), single-Face,
or
Connected-area flood fill; or **Select whole model** - reusing the
existing
`TriangleSelector` / `FacetsAnnotation` painting machinery, one full
mask per
  layer slot.
- **Up to 8 texture layers**, each with its own paint mask, texture, and
  parameters, combined in slot order like image layers.
- **Per-layer controls:** Depth, Tile size, Rotation, Midlevel
(bidirectional
  emboss/engrave), Smoothing, Edge smoothing, Invert, Blend mode, Tile
  (Repeat / Mirrored-repeat / decal), and Projection.
- **Projection methods:** Triplanar (blended, seam-free across sharp
edges),
  Cylindrical, Spherical, **Unwrap (LSCM)** - a real CGAL conformal
  parameterization - and **From view** (slide-projector decals).
- **UV Editor pane** for LSCM layers: move / rotate / scale / snap / cut
/ join
islands, average texel density, Checker and Distortion overlays, manual
mark-
  seam workflow, live model update while dragging.
- **View modes:** Normal (true displaced geometry = what Bake produces),
Fast
(GPU bump-shaded approximation of the active layer), Checker, Distortion
  heatmap, and an independent Wireframe toggle.
- **Mesh prep:** Subdivide (1–5* uniform 1->4 split) with cyan-wireframe
preview,
  and CGAL isotropic **Remesh** to a target edge length.
- **Texture library:** 10 shipped seamless 512×512 grayscale height
maps, plus
import of any PNG/JPG/BMP (converted to an 8-bit grayscale map and
copied into
  the user data dir so app updates can't clobber it).
- **Bake** runs in the background (off the UI thread); the preview is
free to
  explore and only Bake changes the real mesh.

---

## How it works (implementation)

- **Bake is accumulate-then-displace and topology-preserving.** The
output mesh
  has exactly the input's vertices and triangles in the same order; only
displaced vertex positions differ. Each layer is evaluated against the
**base
mesh** and folded per-vertex into a shared accumulator via its blend
mode, then
every touched vertex moves once along its precomputed undisplaced
normal. This
replaced an earlier sequential re-mesh-per-layer design that was the
root cause
of the "second layer never applies" bug and made blend modes impossible.
- **Boundary vertices are pinned.** Any vertex shared with an unpainted
triangle
is never displaced, which is what keeps bakes seamless with zero
remeshing.
- **LSCM unwrap** uses a new `MeshBoolean::cgal::parameterize_lscm()`
built on
CGAL's already-vendored `Surface_mesh_parameterization` package - **no
new
  external dependency**.
- **Fast GPU preview** perturbs the shading normal from the height
gradient
(analytic mm-per-mm slope for triplanar; Mikkelsen's
screen-space-derivative
method for the conformal LSCM path), so apparent depth matches the bake.
- **Background jobs:** preview compute and bake both run off the UI
thread on the
shared job worker (queued, not `replace_job`, so a preview never cancels
an
  in-flight bake); a generation counter discards stale results.
- **UV Editor** shares the app's single real `wxGLContext` and reuses
the
registered `flat`/`flat_texture` shaders; island drags update one affine
matrix
  per island rather than re-uploading geometry.


---


## Backward compatibility & constraints

- **Feature is fully gated behind the new gizmo** - it adds no new
default
behavior and does not touch existing slicing, profiles, or defaults.
Models
  that never open the tool are unaffected.
- **Cross-platform** - pure `libslic3r` / `libslic3r_gui` /
`libslic3r_cgal`
code; no new dependency and no `deps/` rebuild. (Built and tested on
Windows;
  no platform-specific APIs introduced.)
- **`.3mf` compatibility:** **baked** relief round-trips fine, since it
becomes
ordinary mesh geometry via the existing serialization path. **Unbaked**
paint
masks and layer definitions are **not yet serialized** - see
Limitations. No
  existing project data is affected.

---

## Testing

Unit tests in `tests/libslic3r/test_texture_displacement.cpp` - **run
and
passing** (7 cases, 116 assertions). Coverage:

- `decode_height_texture` round-trip
- Empty-layer no-op
- Full-cube uniform displacement
- Boundary-vertex pinning on a hand-built fan mesh
- Regression: a **second layer over the same area actually contributes**
(the
  bug the bake rewrite fixed)
- Table-driven check of all four blend modes
- The lowest painted layer ignoring its blend mode

`BUILD_TESTS` is `OFF` in the checked-in cache; enable to run:

```bash
cmake -S . -B build -DBUILD_TESTS=ON
cmake --build build --config Release --target libslic3r_tests -- -m
./build/tests/libslic3r/Release/libslic3r_tests.exe "[TextureDisplacement]" --order rand
```

---
2026-10-01 12:40:33 +08:00
SoftFever 2a9cb32c1f Fix bundled Python crashing on macOS 26 and older when built with Xcode 27
Building deps with Xcode 27 on macOS 26 failed at the Python install step,
and a libpython built with Xcode 27 segfaulted on macOS 12-26 whenever a
plugin started a subprocess.
2026-10-01 12:29:57 +08:00
SoftFever 059e171954 Merge branch 'main' into nozzle-variant-cooling 2026-10-01 11:21:59 +08:00
SoftFever d849b30906 Make BBL and library filaments pass the variant width check
Fan speeds, the recommended nozzle temperature range, minimal purge and
multi-tool ramming flow now take one value per extruder variant. Each
single value is repeated for every variant the filament lists, so every
preset loads exactly what it did before.
2026-10-01 11:21:16 +08:00
Kris Austin 865e9963c3 perf: speed up G-code export by 7-26% via typed config apply (#16026) 2026-09-30 18:58:22 -03:00
SoftFever c278de3b10 Merge branch 'main' into pr/nuclearmistake/16007 2026-10-01 02:57:24 +08:00
SoftFever 35a4d941b8 Sync the AMS recommended temperature range with the tray's nozzle variant 2026-10-01 02:54:18 +08:00
SoftFever e1da47a72b Keep the printer preset when reselecting its nozzle diameter
Printers without a named nozzle variant no longer switch profiles when their
current diameter is picked in the sidebar. The filament tab reads the selected
variant through one helper.
2026-10-01 02:54:18 +08:00
SoftFever c03f4925a9 Apply per-variant cooling, ramming and temperature range on nozzle-rack slices
Fan speeds, multi-tool ramming, the tower interface and flush temperature
fallbacks and the custom G-code placeholders now use the extruder variant a
filament prints with on each layer, instead of reading by filament id.
2026-10-01 02:54:18 +08:00
Ian Bassi ffbbd62355 Clean design Docs and move context (#15803) 2026-09-30 14:33:35 -03:00
SoftFever 0028e65763 revert profile changes 2026-10-01 00:51:50 +08:00
peachismomo 5af7140cb0 Merge remote-tracking branch 'origin/main' into feat/printer-agent-impl 2026-10-01 00:51:05 +08:00
SoftFever e493289b62 Merge branch 'main' into nozzle-variant-cooling 2026-10-01 00:34:32 +08:00
a5413efc37 refactor: printer agent infrastructure changes to work with other printer agents aside from bambu (#15710)
* Add developer flag for printer agents

* Parse user print info on the UI thread to prevent heap corruption (#119)

get_user_print_info()'s HTTP fetch can run on a worker thread (e.g. BindJob),
but parse_user_print_info() mutates userMachineList (insert/erase/delete
MachineObject). on_machine_alive (SSDP) mutates the same maps on the UI thread
without locking, so parsing off-thread races the map and frees MachineObjects
out from under it -> heap corruption.

Keep all device-list mutation on the UI thread: parse inline when already on
the main thread, otherwise marshal via CallAfter so it stays serialized with
on_machine_alive.

* Prevent loss of user access code on LAN reselect

Keep user access code intact to maintain access rights even if
device slot is unpopulated, ensuring continuous connection
and status message reception.

* Harden send flow and separate upload failure recovery (#111)

* fix(send): harden FT send path + IP pre-flight UX

* Remove early returns

* Working Moonraker and Qidi printer agent transport (#104)

Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.

* Add support for runtime error status in plugins

Distinguish a loaded plugin whose
capability errored (RuntimeError,
warn-styled, stays checked) from a
load-time Error. Status now derives
via resolve_plugin_status(); enum
ordinal keeps dialog sort priority.
Unloading clears stale errors.

* Resolve duplicate agent ID conflicts

Reject a printer-agent capability
whose agent ID is already owned by
another capability or built-in:
flag the plugin error, disable the
capability, and warn the user
instead of silently ignoring it.

* fix: checkbox should depend on plugin is_loaded status

* Replace fake-enum printer agent dropdown (#121)

A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.

* Reset device selection on agent swap or unload (#124)

set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.

* Gate agent mode behind use_printer_agents toggle

Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.

* Track BBLPrinterAgentPlugin.py

* Add printer-agent and plugin status tests

Ports the agent lifecycle, duplicate
agent-id, built-in-id clash and
status-resolution tests. The loader
runs on a detached worker thread, so
the lifecycle tests live in their own
executable. Tests install the
production unload-side registry
wiring themselves (no GUI in the
test binary) and register agents
manually so concurrent loads stay
deterministic.

* fix: pin HTTP to prevent connection refusal

Set `use_ssl` to false to ensure Moonraker
connectivity, as the service uses HTTP rather
than HTTPS, preventing connection issues. Initialize
device info early for reliable name resolution.

* Bring Moonraker device panel to feature parity

The monitor panel showed wrong or missing
data for Moonraker printers, and its
controls did nothing.

Push payload now carries layer number and
total layers. Remaining time replaces the
wrong total_duration - print_duration
formula. The chamber light toggle maps to
Klipper SET_PIN / SET_LED, and pause,
resume and stop post to
/printer/print/{action}. Task thumbnails
resolve via /server/files/thumbnails onto
a new MachineObject thumbnail url.

Filament sync switches to pull mode so the
agent is queried on demand.

Not compiled or run.

* Stop blocking print on unreported nozzle data

* fix: make Klipper macro lamp control reliable

* Surface Moonraker webcams and gate unrunnable controls

* Keep Bambu AMS dialect out of the agent waist

M620 is Bambu firmware dialect, not a
neutral command. Composing it in
MachineObject let non-Bambu agents
(Moonraker/Klipper) forward it and
report success on firmware that
cannot run it.

Agents now own the dialect: the
default refusal on IPrinterAgent
returns not-supported so the UI
can say so; BBLPrinterAgent keeps
the byte-identical composition.

* Fix multi-color filament logic

Reuse color decoding across functions to improve
code readability and maintain consistency in
multi-color filament handling.

* Move Moonraker commands off the UI thread

Pause/resume/stop, g-code sends, temps, and
light ran synchronous HTTP on the UI thread,
freezing the app up to 10s per click on slow
or unreachable printers.

Run them on a single agent-owned FIFO worker
so g-code ordering is preserved, while command
translation stays synchronous so unsupported-
command dialogs still work.

Add a pending-disabled state to the pause,
resume, and abort buttons for Moonraker-family
printers: the icon only flips once the
WebSocket reports the real state, which also
rules out double-click races.

* Show Snapmaker U1 camera in Device tab

The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.

Start the camera when the camera view is
shown and renew every 300 s: the printer
retires the capture task at ~362 s and
stop_monitor is accepted but ineffective,
so teardown is simply to stop renewing.

Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.

* fix: start stream when camera URL changes

* fix: stop Qidi slot parse throwing on null

* Keep printer-agent progress in sync

Keep the shared task progress aligned with agent
reports that lack Bambu cloud task identity.

Release the lazily allocated task during reset to
avoid leaks when machine objects reconnect.

* docs: document the printer-agent subsystem

* fix: merge access codes into one

* Reconcile implementation split with PR tip

* feat: abstract remaining gcode commands in devicemanager

* refactor: abstract bambu specific protocol to printer agent

* refactor: push bbl workflows to bbl printer agent

* remove unused

* fix: default impl

* fix callback error

* fix: remove redundant cache

* specify api for getting file transfer url

* revert file transfer abstraction

* fix: ams filament mapping workflow

* feat: update qidi to use subscription based filament sync mode

* fix: resolve stubgen byte header conflict

* fix: ams sync info and periodic ams sync via subscription workflow

* fix: skip filament sync dialog if filamentSyncMode is none

* feat: parse nozzle information for qidi and moonraker printer agents

* fix: extend access code requirements t 0, 8 or more characters.

* remove irrelevant docs

* fix: remove heavy includes from IPrinterAgent

* fix: defer filesystem and camera abstractions

* fix: remote do_fetch_filament_info from tests

* cleanup moonraker and snapmaker printer agents

* fix: access codes regression

* fix: tests

* fix: printer agent switching on preset change

* fix: remove unused variable

* fix: snapmaker U1 SelectMachineDialog blocking print

* fix: merge artifact

* fix: clear up some unrelated changes

* feat: connect to cloud printer and monitor

* feat: connect to cloud printer and monitor

* feat: generic camera stream support for http snapshot and rtsp

* fix: build & access code UI

* feat: generic camera stream support for http snapshot and rtsp

* fix: build & access code UI

* feat: connect to cloud printer and monitor

* feat: connect to cloud printer and monitor

* fix: build errors

* feat: camera via webrtc

* fix: build

* fix: cmake

* feat: remove frame assembler and change config to set protocol

* fix: orcaprinteragent refactor

* fix: LAN paths and camera stream

* feat: use ffmpeg to render http camera stream

* fix: make model_id/dev_type optional instead of blocking

* fix: connect via ip dialog

* feat: LAN impl for Orca Printer Agent

* fix: model_id resolution method for non bambu printers

* fix: ffmpeg http camera stream jittering due to incomplete frames

* fix: revert sdcard check

* feat: check printer storage status before sending

* fix: moonraker printer agent hang on printer power cut

* fix: shim layer for any compatibiliity changes

* fix: cloud printers were using the wrong MQTT endpoint

* feat: cloud download via HTTP

* temp: doc for intended change

* fix: warnings

* fix: warnings

* fix: camera auto-play on startup

* fix: split infra from impl

* fix: uninitialized ams state blocking print

* Fixes nullptr deref

* Log first before std::move

* fix: printer agent virutal optional functions

* fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests

* Revert "fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests"

This reverts commit 2f566e3779.

* Guard libdatachannel. Remove unused code

* fix: unit tests & unused variables

* fix(ci): deps build order for datachannel

* Resolve printer agent first before getting cloud printer agent

* fix(ci): set depends openssl

* fix: re-include apply header guarded by ifdef __APPLE__

* fix(ci): add libdatachannel to flatpak manifest

* fix: add internal_developer_mode chekc back to MediaPlayCtrl::load()

* fix: invoke js clearInterval on WebMediaController::stop

* fix: change rtc log level

* fix: inject provider, agent id and generation to get_user_print_info to ensure correct metadata

* fix: revert moonraker specific behavior

* fix: remove stale comment

* fix: use ORCA_CLOUD_PROVIDER instead of hardcoded string

* fix: remove hardcoded ICE servers

* feat: enable https camera stream mode

* fix: move non-mandatory printer agent function stubs to IPrinterAgent

* fix: dedupe compatible printer type check

* fix: stop the correct media controller

* fix: scope get_my_machine_list to printers listed under the current printer agent

* fix: move printer agent plugin tests into test_plugin_lifecycle.cpp

* fix: always build bundled DataChannel dep

* fix: disable unused DataChannel media support

* revert: filament sync work

* fix: wrap command_* with small wrapper

* fix: regression bug, connecting to bambu needs bblp username

* fix: default impl for vendor agnostic gcode commansd

* refactor: media controller playback routing and ownership

* fix: bump libdatachannel ver & update flatpak to use tar instead

* fix: stop flatpak DataChannel build from re-cloning over the sandboxed network

* fix: update windows ffmpeg prebuild

* fix: update printer agent plugin API

* fix: shift camera signaling channel to network agent

* fix: follow external-packages for flatpak libdatachannel deps & add flatpak path to use source_dir

* feat: add printer-agent.md doc to HLSD

* fix: guard DeviceManager command dispatch when no printer agent is bound

* fix: port BBL implementations from #15711

* refactor: connect_printer api and dialog

* fix(tests): make omitted printer agent operations answer like a missing agent

* fix: preserve printer agent defaults in PrinterAgentPluginCapabilityTrampoline

* test: cover printer agent default command dispatch

* fix: validate windows FFmpeg avformat library

* feat: extend optional printer model warnings to calibration & ams workflows

* fix: handle malformed printer progress values safely

* fix: clear webview document on stop

* chore: reduce diagnostic logging level to trace

* refactor: centralize printer compatibility checks

* tests: add device manager integration coverage

* tests: cover WebMediaController lifecycle with wxWebView stub

* fix: make integration tests headless

* refactor: collapse command_ams_refresh_rfid and command_ams_refresh_rfid2

* refactor: make printer connection SSL agent-specific

* fix: persist input printer host and port

* fix: use correct device id for Moonraker connections

* fix: make moonraker gcode commands asynchronous

* fix: preserve moonraker device names

* fix: add include for non BBL_RELEASE_TO_PUBLIC path in BBLPrinterAgent

---------

Co-authored-by: Andrew <159703254+andrewsoonqn@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
Co-authored-by: Lam Wei Lun <weilun.lam@gmail.com>
2026-10-01 00:30:44 +08:00
peachismomo 1cac04eae8 refactor: separate out networking infra 2026-10-01 00:28:57 +08:00
Rodrigo Faselli 8d69fa3e5a Add 'SECURITY' label to PR label bot (#16024) 2026-09-30 11:28:07 -03:00
Ian BassiandRodrigo Faselli 97700c5ab5 Gyroid Optimization (#16002)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-30 10:21:24 -03:00
Kris Austin ff2f42016a Fix CLI crash on a 3mf without project settings (#16004)
When a 3mf is tagged as written by OrcaSlicer or BambuStudio, the CLI
treats it as a project and reads the printer settings stored in the
file. #14580 guarded four of those reads, but printable_height still
called opt_float() without a check, so a 3mf whose
project_settings.config is empty or missing crashed with a null
dereference. The handy models OrcaBadge.3mf and OrcaSliced.3mf are both
like this.

Skip the read when the option is absent, like the reads around it. The
bed-size logic further down already treats an old printable height of 0
as unknown and uses the current printer's.
2026-09-30 10:04:14 -03:00
SoftFever bd4306e8f8 Open the texture displacement tool in the Normal view 2026-09-30 21:01:45 +08:00
SoftFever 9abad9619d Add weathered bricks displacement texture 2026-09-30 20:54:25 +08:00
Eric McCannandCodex 2d1e6d5b96 Merge U1 profile updates and require explicit cooling variant values
Remove permissive singleton and short-array validation. Cover cooling array widths, preserve tuning with explicit variant entries, and bump the Snapmaker bundle version.

Co-authored-by: Codex <codex@openai.com>
2026-09-30 07:47:43 -04:00
Eric McCannandCodex 09184ac5a4 Fix U1 profile variant array validation
Expand shared filament and process values to their declared variant widths while preserving existing tuning. Bump the Snapmaker bundle version to 02.04.00.22.

Validation: Snapmaker profile check passed with zero errors and warnings; verified all expanded entries repeat their original values.

Co-authored-by: Codex <codex@openai.com>
2026-09-30 07:41:17 -04:00
Eric McCannandCodex 315df5750f Fix filament variant index references in GUI controls
Use the shared unsigned variant index for adaptive pressure advance and volumetric speed controls, resolving missing identifiers and the ambiguous string accessor.

Co-authored-by: Codex <codex@openai.com>
2026-09-30 07:36:25 -04:00
SoftFever 1e39a36a25 Bake a second painted region as finely as the first
Refinement now tapers off away from the paint, and the unpainted surface is kept out of
simplification, so what one bake leaves unpainted is still the model's own triangles when a later
bake paints there. A second bake used to refine the slivers and long triangles the first one left
around its stroke, and came out many times denser, with walls off the texture's lines.
2026-09-30 19:32:01 +08:00
SoftFever 36ebe0cde5 Let Cancel stop a texture bake while it simplifies
Once the budget is met the progress fraction stops moving, and a cancel was only checked when it
moved, so Cancel did nothing until the flat-face merging finished. It is now also checked every 16k
steps.
2026-09-30 19:31:54 +08:00
SoftFever 7c0a3ab916 Warn about the texture bake budget only when it cost detail
Meeting the triangle budget by merging flat faces alone no longer raises the warning, and the
warning now quotes the budget instead of the triangle count left after the flat faces were merged.
2026-09-30 19:31:48 +08:00
SoftFever 31e7dc0845 Fix texture bake stalling at 99% while simplifying
A vertex pinned on flat ground could collect a fan of thousands of slivers, and validating each
collapse next to it walked the whole fan, turning a second of simplification into minutes. Collapses
that would leave more than 64 faces around a vertex are now skipped.
2026-09-30 19:31:43 +08:00
Ian Chua b935a652b9 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-30 19:31:28 +08:00
Ian Chua a1fd66362b Merge branch 'main' into feat/printer-agent-infra 2026-09-30 19:29:07 +08:00
peachismomo a6cad8a6e5 fix: printer agent and camera shutdown races 2026-09-30 19:27:50 +08:00
HanifKoh 94266c2819 Fill Settings Missing From a CLI Project From Its System Presets (#15953)
* Fill Settings Missing From a CLI Project From Its System Presets

A project saved before a printer or process option existed has no value
for it. The GUI takes such keys from the project's system preset; the
CLI left them at the option default, so e.g. extruder_clearance_dist_to_rod
sliced as 40 instead of the P1S's 33.

The CLI now resolves the project's system printer and process presets by
name and copies the keys the project lacks, skipping preset bookkeeping,
print-host keys, the extruder variant layout and keys the legacy handler
drops. PresetBundle::resolve_system_preset finds the vendor through its
manifest or preset cache, so it also works in release builds, which ship
vendors as caches only.

* Load a CLI Project's Printer and Process Settings as the GUI Does

The CLI filled only the keys a project lacked from its system preset.
The GUI builds a project preset differently: the project's values go
over the default preset, without the print-host keys, and every key
the project does not list in different_settings_to_system is refreshed
to its base system preset's current value. After a profile update the
two sliced the same project differently.

That step now lives in Preset::load_external_config, which takes plain
configs and gets the base preset from a callback, so the collection
lookup stays in PresetCollection. PresetBundle::project_different_keys
builds the kept-key set from a project's escaped entry, adding the
preset bookkeeping keys, and is used by both the GUI and the CLI.
PresetCollection::load_external_preset calls the shared step with no
change in behaviour.

The CLI now builds the project's printer and process configs with the
same step, passing the system preset from resolve_system_preset. That
drops the hand-kept skip list for print-host and variant-layout keys
and the legacy-key check: the shared step already excludes print-host
keys and maps per-variant values onto the base preset's variant layout.
The --uptodate path and a printer or process given on the command line
are left as they were.

Because the GUI's kept-key set always holds the bookkeeping keys, the
refresh runs for every project that names a system preset, so the CLI
now loads that preset's vendor on every such run.
2026-09-30 18:14:28 +08:00
Ian Chua dc0e269186 test: bumping OFL version to test OTA workflow [To be reverted before 2.5.0 alpha] (#16017)
Merged by /bot merge on behalf of @peachismomo (id 52488812).
Grants: resources/profiles/OrcaFilamentLibrary/filament/Elegoo, resources/profiles/OrcaFilamentLibrary.json, resources/profiles/Elegoo, resources/profiles/Elegoo.json
Head: ff6a2f2056
2026-09-30 08:25:43 +00:00
SoftFever 1e4489eb16 Merge branch 'main' into feature/texture_displacement 2026-09-30 15:28:08 +08:00
ExPikaPaka 5d49423faa Use plain ASCII in the panel labels
The degree sign stays, as a unit, the way the other gizmos write it.
2026-09-30 09:02:33 +02:00
Error404JoyNotFound da0611a301 Fix : Add curr_bed_type to built-in placeholders in G-code editor (#15987) 2026-09-30 14:59:46 +08:00
ExPikaPaka 4da478c7ad Write the documentation in plain ASCII
Only the degree sign is left, as a unit, which the gizmos already use.
2026-09-30 08:55:52 +02:00
Hanif Koh 561737f407 Fix the CLI 3MF Export Crash After Rendering a Plate Thumbnail
Since the CLI can open an OpenGL context (#15745) it renders plate
thumbnails on export, and the viewport restore at the end of
render_thumbnail_internal (#15674) then reads the plater through the wx
application. The CLI has neither, so every --export-3mf on a machine
with a display died with a segmentation fault after the first
thumbnail. Skip the restore when there is no application or no plater;
the GUI path is unchanged.
2026-09-30 14:46:44 +08:00
Ian Chua 7d42ad17a4 fix: malformed jq filter in OFL publisher barrier (#16012) 2026-09-30 14:19:06 +08:00
Eric McCann e727caed18 Merge remote-tracking branch 'downstream/u1-hf' into u1-hf 2026-09-29 22:46:46 -04:00
Eric McCannandCodex d58c3b0d89 Keep downstream customization limited to printer profiles
Extract non-profile changes into a separate development line while preserving all profile content.

Co-authored-by: Codex <codex@openai.com>
2026-09-29 22:42:50 -04:00
Eric McCannandCodex 0eb6e6814e Preserve nozzle variant tuning and cooling controls
Include variant-aware validation, temperature and pressure controls, named nozzle selection, and filament sidebar refresh.

Co-authored-by: Codex <codex@openai.com>
2026-09-29 22:42:30 -04:00
Eric McCannandCodex 9590d71fd9 Merge upstream updates and reconcile nozzle variant controls
Co-authored-by: Codex <codex@openai.com>
2026-09-29 22:42:05 -04:00
SoftFever afaa94b3bf Merge branch 'main' into u1-hf 2026-09-29 23:34:35 +08:00
Eric McCann b5022dd454 Merge branch 'main' into u1-hf 2026-09-29 09:06:14 -04:00
Eric McCannandCodex 7cbea5f454 Consolidate U1 nozzle variants while preserving filament tuning
Support variant-specific cooling, pressure advance, purge, ramming and temperature ranges. Keep shared U1 values as singletons and verify element-zero fallback for missing variant entries. Preserve preset migration aliases and validate high-flow selections using their declared nozzle volumes.

Co-authored-by: Codex <codex@openai.com>
2026-09-29 08:24:37 -04:00
Eric McCann a46e29c21e Update breakaway support flow for HF 0.4 2026-09-29 07:31:37 -04:00
Eric McCannandCodex ea49b4851e Make U1 filament notes readable without commit references
Co-authored-by: Codex <codex@openai.com>
2026-09-29 07:26:38 -04:00
Eric McCann 6fcfe6a675 Merge remote-tracking branch 'orca/main' into u1-hf 2026-09-29 06:57:32 -04:00
ExPikaPaka 6f62fe374c Move the slicing optimizations to their own branch
They stand on their own: nothing in the texture displacement feature
calls them, and nothing in them knows about textures. Reviewing a
slicer-wide parallelization next to a new gizmo helped neither.

They now live in perf/slicing-optimizations, based on current main.
2026-09-29 10:38:48 +02:00
ExPikaPaka 75b6175988 Merge remote-tracking branch 'origin/main' into feature/texture_displacement 2026-09-29 08:36:11 +02:00
SoftFever f6514b798a Merge branch 'main' into u1-hf 2026-09-29 11:19:32 +08:00
Eric McCannandCodex b910857c18 Merge upstream updates and reconcile Snapmaker profiles
Preserve tuned filament defaults and nozzle metadata while adopting
per-extruder settings and complete filament variant arrays.

Validation: Snapmaker profile checks and staged whitespace checks passed.

Co-authored-by: Codex <codex@openai.com>
2026-09-28 16:06:39 -04:00
Ian Chua 9bac75dda5 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-28 21:15:32 +08:00
Ian Chua 50c5ec2862 fix: remove redundant earlier expires_never call 2026-09-28 21:14:22 +08:00
Ian Chua b36a72e16a fix: orca mqtt session timeout after connack 2026-09-28 21:14:21 +08:00
Ian Chua 64371c191e Merge branch 'main' into feat/printer-agent-infra 2026-09-28 20:50:03 +08:00
Ian Chua 94702a5f8d Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-28 20:48:51 +08:00
Ian Chua 68cdd31852 Merge branch 'main' into feat/printer-agent-infra 2026-09-28 20:48:37 +08:00
Eric McCannandCodex 8a254eb23f Merge upstream updates
Co-authored-by: Codex <codex@openai.com>
2026-09-28 07:08:54 -04:00
Eric McCannandCodex 2fcf2222b6 Refresh filament controls after loading printer presets
Synchronize the plater filament controls after preset loading, even when the internal filament list already matches the nozzle count.

Co-authored-by: Codex <codex@openai.com>
2026-09-27 14:14:28 -04:00
Eric McCannandCodex 7ee64d83bb Merge upstream updates
Co-authored-by: Codex <codex@openai.com>
2026-09-27 12:59:09 -04:00
Eric McCannandCodex d8ca9aa3b3 Merge upstream updates and preserve named nozzle variants
Keep hardened-steel and high-flow nozzle variants selected when refreshing the nozzle dropdown.

Co-authored-by: Codex <codex@openai.com>
2026-09-27 12:19:20 -04:00
Eric McCannandCodex d65f97a535 Merge upstream updates
Co-authored-by: Codex <codex@openai.com>
2026-09-26 09:28:24 -04:00
Eric McCann ae38570562 Merge remote-tracking branch 'orca/main' into u1-hf 2026-09-25 08:59:21 -04:00
ExPikaPaka 7766c2e003 Test that a baked texture still slices
The bake is covered in the libslic3r suite, but nothing carried its
result through the slicer - where a mesh it cannot use shows up as a
crash or as a print with no toolpaths, which is what was reported on
macOS and Linux.

Paints a cube, bakes it as the gizmo commits it (mesh replaced, hull
recomputed, paint cleared), slices it and checks the G-code has
toolpaths and layers. Once under its budget and once capped, since a
capped bake also goes through the simplification and the repair.
2026-09-25 10:31:04 +02:00
Eric McCannandCodex 517286c93d Apply consistent output filenames to all Snapmaker U1 presets
Extend the existing U1 filename format to the base process preset and let Benchy presets inherit it. Bump the Snapmaker catalog version.

Co-authored-by: Codex <codex@openai.com>
2026-09-24 08:50:54 -04:00
Ian Chua 87eb8de001 fix: serialize MQTT ws I/O and dewcouple keepalive 2026-09-24 20:28:13 +08:00
Ian Chua 220508534a fix: cancel inflight cloud signaling requests 2026-09-24 19:03:58 +08:00
Ian Chua 5a0810365b fix: clarify OrcaCloud signaling endpoint contract 2026-09-24 18:18:48 +08:00
Ian Chua fa98ec5afe feat: add ssl and ca_cert for OrcaPrinterAgent 2026-09-24 17:53:46 +08:00
Ian Chua 3c1d8ac8b4 fix: allow input ip address dialog to validate if is valid endpoint 2026-09-24 17:53:12 +08:00
Ian Chua 936b0bbc17 feat: add https compatibility for moonraker based printer agents 2026-09-24 14:53:32 +08:00
Ian Chua 5222a37eff fix: command_ams_refresh_rfid function signature in test_printer_agent.cpp 2026-09-24 12:35:03 +08:00
ExPikaPaka 14ee1e3d3a Fix the Windows crash on a truncated PNG
libpng reports a corrupt image by longjmp()ing to the jump buffer the
decoder sets. Both decoders set theirs in a frame holding a PNGDescr and
a vector, and with exceptions enabled MSVC unwinds the stack as part of
longjmp - so returning from that frame afterwards crashed. A truncated
texture segfaulted the Windows test run and was fine everywhere else.

The calls that can fail now sit in two helpers that own nothing but
pointers, and the C++ objects stay in the callers' frames.
2026-09-23 22:53:45 +02:00
peachismomo 6da938b660 fix: compilation after merge 2026-09-24 01:15:55 +08:00
peachismomo 2dcdf49185 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-24 01:02:59 +08:00
peachismomo 52e845a821 fix: add include for non BBL_RELEASE_TO_PUBLIC path in BBLPrinterAgent 2026-09-24 00:23:47 +08:00
ExPikaPaka c4a19bff1d Give an empty patch an upright frame
texture_displacement_patch_frame picks the world axis least aligned with
the patch's average normal. With no patch there is no normal either, and
the axis came out of the +Z fallback for it - so it was +X, while its
test asked for the upright frame the fallback is meant to give. An empty
patch now answers +Z outright.
2026-09-23 18:01:46 +02:00
ExPikaPaka 4504f315ee Include what the new code uses
The Windows build stopped on test_kdtree.cpp: it calls std::iota without
including <numeric>, which libstdc++ happens to pull in anyway. Added
there, and the same for <limits> and <algorithm>/<cmath> where the
recent changes rely on them being included by something else.
2026-09-23 16:16:46 +02:00
ExPikaPaka 56092f0367 Fix the Windows build: near and far are macros there
bridge_over_infill's helper for splitting polygons by proximity named
its locals near and far. The Windows headers define both as macros that
expand to nothing, so "Polygons near;" declared nothing and the uses of
it did not compile. Renamed; no behaviour change.
2026-09-23 14:51:07 +02:00
Ian Chua ffb4813510 fix: preserve moonraker device names 2026-09-23 20:17:06 +08:00
Ian Chua 7436f68e09 fix: make moonraker gcode commands asynchronous 2026-09-23 20:15:52 +08:00
Ian Chua ad956591ce fix: use correct device id for Moonraker connections 2026-09-23 20:14:21 +08:00
Ian Chua 8da8e9bee1 fix: persist input printer host and port 2026-09-23 20:12:18 +08:00
Ian Chua 0acc8608b1 refactor: make printer connection SSL agent-specific 2026-09-23 20:11:47 +08:00
ExPikaPaka 944f01c68a Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-09-23 14:08:47 +02:00
ExPikaPaka b3216b7f6a Wrap the budget warning, count triangles in thousands
Unwrapped, it ran past the edge of the panel and pulled the layout with
it. Counts under a million now read in thousands, so a 119 k budget is
no longer shown as 0.1 M.
2026-09-23 13:22:57 +02:00
ExPikaPaka 9a2eadf9cc Rewrite the texture displacement tooltips
They described the implementation, and a few no longer matched the code.
Each now says what the control does and in what units; five controls that
had no tooltip have one. The catalogs need regenerating.
2026-09-23 13:16:56 +02:00
Eric McCann 3f5faa75a7 Merge remote-tracking branch 'orca/main' into u1-hf 2026-09-23 06:58:04 -04:00
Ian Chua a2a9b2ce94 refactor: collapse command_ams_refresh_rfid and command_ams_refresh_rfid2 2026-09-23 18:30:05 +08:00
ExPikaPaka 1b029ff8fa Simplify flat faces even when a bake fits its budget
Nothing removed the even mesh over a texture's flat parts unless the
budget was lowered until simplification had to run. Collapses stay under
a thirty-second of the resolution: on the test map 387584 -> 369876
triangles, with the print unchanged (0.02% of the extrusion).

An earlier attempt was reverted for a 21% heavier result. That was the
colour despeckle switching itself off, not the collapsing: it keyed off
the face parent map, which any collapse empties.
2026-09-23 12:19:58 +02:00
ExPikaPaka 0a543644ef Give each bake its own budget, and warn when it is short
Relief preserved from an earlier bake is counted on top of the budget
instead of eating into it: baking a second area went from ~214 k
triangles to the ~1000 k it was given.

When the resolution needs more triangles than the budget allows, it is
said before the bake under Resolution and again afterwards.
2026-09-23 10:26:24 +02:00
ExPikaPaka 7fd12ed1ee Block undo and redo while a background job runs
A job is queued against the model as it stands, so undoing underneath it
left the bake landing on geometry it was never computed for.
2026-09-23 09:51:17 +02:00
ExPikaPaka 9327e7770b Rename the fast preview to the shaded preview
Its shader files, the name they are registered under, its members and
the panel text now match what it does: shading only, no geometry.
2026-09-23 09:51:08 +02:00
Ian Chua 07bee6c7a4 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-23 15:35:22 +08:00
Ian Chua 1615390182 Merge branch 'main' into feat/printer-agent-infra 2026-09-23 14:54:38 +08:00
Ian Chua cf20994b58 refactor: connect_printer api and dialog (#15827)
# 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?
-->

Changing the IP address input dialog and `connect_printer` API in
preparation for configurable Moonraker/Orca printer agent connections.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->
<img width="460" height="699" alt="image"
src="https://github.com/user-attachments/assets/35e3ce17-7594-4401-b140-100dea082221"
/>


## Tests

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

<!--
> 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-23 14:54:26 +08:00
Ian Chua 309bb3c6e2 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-23 14:53:49 +08:00
Ian Chua 79f20c3996 Merge branch 'feat/printer-agent-infra' into refactor/connect-printer-api 2026-09-23 14:48:33 +08:00
Ian Chua 33179c1902 fix: make integration tests headless 2026-09-23 14:29:57 +08:00
SoftFever c5bbb6e031 Merge branch 'main' into u1-hf 2026-09-23 13:13:32 +08:00
ExPikaPaka 6e88ad7f52 Visit seam candidates as the search finds them
Collecting every candidate within the radius into a vector cost more
than the search itself. Same order, so the seams are unchanged;
align_seam_points ~19.6 s at 0.1 mm / 2000k, was ~21.
2026-09-23 02:04:49 +02:00
ExPikaPaka 3023ca0d38 Run a layer's regions in parallel where they are independent
detect_surfaces_type, process_external_surfaces and the vertical shells
each waited on their own heaviest layer in turn. The LOTR map plate
slices in ~10.5 min at 0.1 mm / 2000k, was ~11.5; ~87 s at normal
settings, was ~97.
2026-09-22 22:14:39 +02:00
peachismomo ccaafb3003 tests: cover WebMediaController lifecycle with wxWebView stub 2026-09-23 03:50:35 +08:00
peachismomo 7d4d8276a2 tests: add device manager integration coverage 2026-09-23 03:25:08 +08:00
peachismomo c492436228 refactor: centralize printer compatibility checks 2026-09-23 03:06:15 +08:00
peachismomo 99a1cd1b74 chore: reduce diagnostic logging level to trace 2026-09-23 02:58:30 +08:00
peachismomo 4b41bf7f4f fix: clear webview document on stop 2026-09-23 02:57:56 +08:00
peachismomo d5962052ca fix: handle malformed printer progress values safely 2026-09-23 02:53:10 +08:00
peachismomo f1410c8cac feat: extend optional printer model warnings to calibration & ams workflows 2026-09-23 02:49:27 +08:00
peachismomo 7283a05040 fix: validate windows FFmpeg avformat library 2026-09-23 02:33:47 +08:00
peachismomo c32c87863b test: cover printer agent default command dispatch 2026-09-23 01:54:23 +08:00
peachismomo 06f7016d6a fix: preserve printer agent defaults in PrinterAgentPluginCapabilityTrampoline 2026-09-23 01:53:56 +08:00
SoftFever 61b503ca3a Merge branch 'main' into u1-hf 2026-09-23 01:28:11 +08:00
ExPikaPaka e46eecc716 Move polygons instead of copying them on move
MultiPoint had no rvalue constructor, so the derived move constructors
bound to the const reference and copied; append reserved exactly, so
collecting pieces one by one was quadratic. Colour segmentation ~3 s at
0.1 mm / 2000k, was ~40, and ordinary prints gain too.
2026-09-22 19:19:54 +02:00
ExPikaPaka d3485e8b63 Project painted faces onto the shell layers per tile
Only the slices within the deepest shell offset decide the result, so
the work is done per tile of the face. Top and bottom segmentation
~130 s at 0.1 mm / 2000k, was ~180.
2026-09-22 19:19:54 +02:00
peachismomo be9ae6ffbf fix(tests): make omitted printer agent operations answer like a missing agent 2026-09-23 00:35:33 +08:00
ExPikaPaka 5baefd8a4e Tile the booleans on layers of many pieces
ClipperLib slows down with the number of edges on a scan line, and a
layer cut through a fine relief has tens of thousands of pieces.
detect_surfaces_type ~50 s at 0.1 mm / 2000k, was ~145.
2026-09-22 18:06:54 +02:00
Eric McCannandCodex c9dbf6fdbd Merge upstream updates into reviewed U1 work
Integrate upstream commit 301f2ecba3 while retaining the U1 high-flow profiles and nozzle-selection behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 10:44:30 -04:00
ExPikaPaka b3963b2a49 Merge colour and top/bottom regions per island
The merge took anything from 3 to 38 minutes at 0.1 mm / 2000k, now
~2.5. Every region is grouped with the islands it overlaps, so the
result is the same.
2026-09-22 16:43:53 +02:00
Ian Chua cda25c3715 refactor: connect_printer api and dialog 2026-09-22 21:54:10 +08:00
Ian Chua 2dcdfe2d32 fix: refresh token before configuring printer MQTT 2026-09-22 21:46:09 +08:00
Eric McCannandCodex 2f05dbf577 Fix Snapmaker U1 profile defaults and extruder vectors
Register high-flow filament defaults for every U1 nozzle variant in the
wizard, and select compatible filaments for the standard and mixed-nozzle
printers. Size the shared U1 extruder vectors to its four physical tools.
Replace stale 3x flow claims with the source-specific filament guidance.
Bump the Snapmaker bundle version.

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 09:39:11 -04:00
Ian Chua 9e34ca536b fix: port BBL implementations from #15711 2026-09-22 21:11:03 +08:00
Ian Chua 201617a6c0 fix: guard DeviceManager command dispatch when no printer agent is bound 2026-09-22 21:09:58 +08:00
Ian Chua 2dcbee3d76 Merge branch 'main' into feat/printer-agent-infra 2026-09-22 20:55:29 +08:00
ExPikaPaka cfde702d09 Slice fine texture relief without stalling
A colour texture baked at 0.1 mm / 2000k made the top layers thousands
of islands and slicing never finished. Colour segmentation runs per
island, the merge subtracts piece by piece, the support check tests only
nearby islands, and the travel ordering finds crossings through a grid.
2026-09-22 13:06:44 +02:00
Ian Chua 6c353d9596 feat: add ssl compatibility with moonrakerprinteragent 2026-09-22 18:30:30 +08:00
Ian Chua ce0c759ded fix: snapmakerprinteragent use moonraker's lifecycle-owned command worker 2026-09-22 16:06:18 +08:00
Ian Chua 9a7e925790 fix: re-implement missing commands on BBLPrinterAgent 2026-09-22 15:49:22 +08:00
SoftFever 211dd7daaa Merge branch 'main' into feature/texture_displacement 2026-09-22 14:46:36 +08:00
Ian Chua f27b0a1f9d Merge branch main into feat/printer-agent-infra 2026-09-22 12:11:40 +08:00
ExPikaPaka 303efacec0 Generate walls and split solid infill in parallel
Same output, ~2.2 min for the LOTR map plate, was ~2.6.
2026-09-21 16:40:22 +02:00
ExPikaPaka ae7bf43fde Run colour segmentation and vertical shells in parallel
Same output, ~2.6 min for the LOTR map plate, was ~2.9.
2026-09-21 15:22:36 +02:00
ExPikaPaka 8f853c0e22 Faster slicing of colour-painted layers, texture panel tools row
A layer split into ~1000 colour fragments (a colour texture baked over a
large top face) made several per-fragment loops redo whole-layer ClipperLib
work, so slicing took ~33 min; it now takes ~3 min with the same output.

- make_fills: clip the layer's no-overlap area to each expolygon's box
  before intersecting
- discover_vertical_shells: small-piece filter compares only against the
  nearby part of the layer
- bridge_over_infill: whole-layer union/diff/intersections restricted to the
  candidate's neighbourhood; fill boundary expanded once per spacing; anchor
  tree built only from lines crossing the scan range; bbox pre-check in the
  collision test; limiting outline taken directly instead of through
  expand(..., 0.3 * flow.spacing()), which offsets by 0.135 scaled units
  (flow.spacing() is in mm) and only cost a whole-layer pass per candidate
- Bake job: include GUI.hpp for show_error()
- Texture panel: select/erase whole model next to the paint tools, brush
  size slider back on the same row
2026-09-21 14:55:39 +02:00
Ian Chua cf1c164546 feat: add printer-agent.md doc to HLSD 2026-09-21 18:40:24 +08:00
Ian Chua d9f1f8b207 fix: camera signaling API 2026-09-21 16:19:31 +08:00
Ian Chua a096e1ab8c Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-21 16:02:11 +08:00
Ian Chua bca8dd00dd Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-21 15:45:49 +08:00
Ian Chua 8b7f8cac82 fix: follow external-packages for flatpak libdatachannel deps & add flatpak path to use source_dir 2026-09-21 15:45:39 +08:00
ExPikaPaka 7aef3d1215 Merge remote-tracking branch 'origin/feature/texture_displacement' into feature/texture_displacement
# Conflicts:
#	src/libslic3r/TextureDisplacement.cpp
2026-09-21 09:00:18 +02:00
ExPikaPaka cef6527f9d Texture displacement: per-layer bake, UV pane redesign, unwrap and layer view fixes
- Bake: each layer is sampled only on its own painted area; analytic
  projections used to stack every layer over every painted region, so the
  top layer's texture showed on all of them (colour sampler too)
- Auto resolution follows the texture's texel size and sharpness again
- Unwrap: charts cut by each face's own normal (a cube gives 6 islands, not
  12 triangles); non-disk charts (tubes, closed shells) are split until they
  flatten; connected nets test real triangle overlap, grow from the largest
  chart and are packed side by side
- UV edits are stored per unwrapped copy, so dragging a seam vertex no
  longer moves its copies in neighbouring islands
- UV pane: tool strip with unwrap settings moved in from the panel, sharp
  HiDPI icons, clearer island/edge/selection drawing with hover, texture
  picker from the thumbnail, texture no longer lost on reopen (GL state
  from the 3D view, background upload retries)
- Panel: whole-model select/erase as icons in the tools row; inactive
  layers' paint shown muted; colour textures shown in colour in the picker
- Built-in displacement texture library
- Tests for unwrap segmentation, connected nets, UV edits and per-layer
  sampling
2026-09-21 08:59:26 +02:00
Ian Chua aae03257ed Merge branch 'main' into feat/printer-agent-infra 2026-09-21 11:54:23 +08:00
Ian Chua 3fba237c9a fix: shift camera signaling channel to network agent 2026-09-21 11:48:16 +08:00
Ian Chua cfb6349695 fix: update printer agent plugin API 2026-09-21 11:46:36 +08:00
Ian Chua f53ad6993a fix: update windows ffmpeg prebuild 2026-09-21 11:02:20 +08:00
Eric McCann 734dc1f9ac Disambiguate nozzles by the variant too 2026-09-19 07:23:55 -04:00
peachismomo 91c6d1d3d5 Merge branch 'feat/printer-agent-impl' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-impl 2026-09-18 22:31:37 +08:00
peachismomo b91f48141d fix: load windows roots for cloud MQTT and camera signaling 2026-09-18 22:31:25 +08:00
Ian Chua e168d900f2 fix: stop flatpak DataChannel build from re-cloning over the sandboxed network 2026-09-18 22:10:30 +08:00
Ian Chua 467e249b3d Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-18 20:46:18 +08:00
Ian Chua bc03e3be2f fix: bump libdatachannel ver & update flatpak to use tar instead 2026-09-18 20:45:29 +08:00
SoftFever 533b68ed9f Fix colour textures loading upside down and crashes on damaged PNGs
Colour height maps now displace the same way up as grayscale ones. A
truncated or corrupt PNG in the texture folder or a project file now
loads as an empty texture instead of aborting the app, and a 16-bit
colour PNG is converted on load rather than displacing as noise.
2026-09-18 20:37:35 +08:00
Eric McCannandCodex 30b3666c47 U1 HF: Resolve nozzle mismatch on-screen warning
Emit U1 nozzle flow metadata through profile end G-code

Append filament_volume_type footer comments through the standard and High Flow U1 machine profiles. Generate one comma-separated value per logical filament using existing placeholder expressions, preserving the existing end commands. Mixed-diameter standard profiles inherit the standard footer.

Bump the Snapmaker bundle to 02.04.00.17. No C++ or filament-schema changes. Validation intentionally not run at user request.

Co-authored-by: Codex <codex@openai.com>
2026-09-18 07:54:30 -04:00
Eric McCann 19277f44ad HLSD seems too official for this 2026-09-18 07:54:28 -04:00
Eric McCannandCodex e9b9a79815 Configure Snapmaker U1 purifier from filament softening temperature
Select purifier mode in U1 start G-code using the existing min_vitrification_temperature placeholder. Cover standard, high-flow and mixed-nozzle profiles without new slicer settings or C++ changes. Document the thresholds; retain the branch-wide Snapmaker version bump.

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 07:54:26 -04:00
Eric McCannandCodex 3baec9cf2b Add Snapmaker U1 high-flow nozzle profiles
Add separate HF presets for 0.2, 0.4, 0.6 and 0.8 mm nozzles, preserving
standard printer settings and machine G-code. Provide 141 HF filament
presets with volumetric ceilings 3x their non-HF baseline. Generic presets
inherit active Orca material definitions; remove unregistered Snorca-only
Generic profiles and their obsolete PLA Wood dependent.

Add 36 diameter-specific HF processes, all using Snorca's 0.4 mm HF speeds:
600 mm/s inner walls, 500 mm/s outer walls, and 600 mm/s solid/sparse infill.
Preserve other process settings and keep standard-nozzle speeds unchanged.
No extra-high-flow tier or C++ changes are included.

Native profile validation passed. HF material slicing checks passed, and
all 36 HF processes sliced with verified speed settings and tool changes.
Document calibration sources, flow ceilings and remaining physical tuning.

Generate canonical preset IDs and record the eight new Snapmaker generic
claims in the filament ID snapshot. Regenerate the vendor index.

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 07:54:21 -04:00
SoftFever b4aa57fe2c Merge branch 'main' into feature/texture_displacement 2026-09-18 18:58:44 +08:00
SoftFever 2159ab3f6d Merge branch 'main' into feature/texture_displacement 2026-09-18 17:25:04 +08:00
SoftFever 56561242a7 Merge branch 'main' into feature/texture_displacement 2026-09-18 16:33:18 +08:00
Ian Chua 5287fd0160 fix: swtich start print command from http to mqtt command 2026-09-18 15:59:57 +08:00
Ian Chua c441fd2fa4 Merge branch feat/printer-agent-infra into feat/printer-agent-impl 2026-09-18 15:59:25 +08:00
Ian Chua f224b8e4a6 refactor: media controller playback routing and ownership 2026-09-18 13:36:46 +08:00
Ian Chua 8b479c9af3 fix: default impl for vendor agnostic gcode commansd 2026-09-17 18:46:56 +08:00
Ian Chua d8299c388e fix: regression bug, connecting to bambu needs bblp username 2026-09-17 18:44:54 +08:00
Ian Chua 771d455d12 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-17 16:33:38 +08:00
Ian Chua a7ab01b815 fix: remove unimplemented pure virtual functions 2026-09-17 16:33:11 +08:00
Ian Chua 4d85b6627e fix: wrap command_* with small wrapper 2026-09-17 12:51:17 +08:00
Ian Chua 38e35dbff4 Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl 2026-09-16 22:59:22 +08:00
Ian Chua dcddb9283c fix: updated stale docs 2026-09-16 22:54:54 +08:00
Ian Chua ba9cf6e897 Revert "fix: latent ams in use bug"
This reverts commit 910dbcd2e6.
2026-09-16 22:54:54 +08:00
Ian Chua d1f2ecb903 feat: restore printer agent implementations 2026-09-16 22:54:46 +08:00
Ian Chua ee2b4ccf70 Merge branch 'main' into feat/printer-agent-infra 2026-09-16 22:32:32 +08:00
Ian Chua 753c793ea0 revert: filament sync work 2026-09-16 22:20:35 +08:00
Ian Chua 9c5494b19f fix: disable unused DataChannel media support 2026-09-16 22:08:53 +08:00
Ian Chua 93a119427a Merge branch main into feat/printer-agent-infra 2026-09-16 22:06:09 +08:00
Ian Chua d264fcaef4 fix: always build bundled DataChannel dep 2026-09-16 22:04:27 +08:00
Ian Chua b4ab6dedfd fix: move printer agent plugin tests into test_plugin_lifecycle.cpp 2026-09-16 21:33:04 +08:00
Ian Chua ed59f93456 fix: scope get_my_machine_list to printers listed under the current printer agent 2026-09-16 19:48:59 +08:00
Ian Chua cf52c81dcd fix: stop the correct media controller 2026-09-16 18:44:06 +08:00
Ian Chua 803a2a3239 fix: dedupe compatible printer type check 2026-09-16 17:18:59 +08:00
Ian Chua 985092bb46 fix: move non-mandatory printer agent function stubs to IPrinterAgent 2026-09-16 16:56:43 +08:00
Ian Chua cb16a2e547 feat: enable https camera stream mode 2026-09-16 16:24:29 +08:00
Ian Chua 2c2e91b629 fix: remove hardcoded ICE servers 2026-09-16 14:36:49 +08:00
Ian Chua c3faf903ca fix: use ORCA_CLOUD_PROVIDER instead of hardcoded string 2026-09-16 14:24:40 +08:00
Ian Chua 5f3c957597 fix: remove stale comment 2026-09-16 14:23:00 +08:00
Ian Chua 8557285389 fix: revert moonraker specific behavior 2026-09-16 14:21:09 +08:00
Ian Chua a9edab78a8 fix: inject provider, agent id and generation to get_user_print_info to ensure correct metadata 2026-09-16 14:15:24 +08:00
Ian Chua 79dede7d59 fix: change rtc log level 2026-09-16 14:14:21 +08:00
Ian Chua 2ac45b2f78 fix: invoke js clearInterval on WebMediaController::stop 2026-09-16 14:13:37 +08:00
Ian Chua b4f33e71e9 fix: add internal_developer_mode chekc back to MediaPlayCtrl::load() 2026-09-16 14:12:48 +08:00
Ian Chua 8c91cab971 fix(ci): add libdatachannel to flatpak manifest 2026-09-15 20:01:48 +08:00
Ian Chua 79077518e9 fix: re-include apply header guarded by ifdef __APPLE__ 2026-09-15 19:45:14 +08:00
Ian Chua 56aa348c89 fix(ci): set depends openssl 2026-09-15 18:16:50 +08:00
Lam Wei Lun ae1752fbf2 Resolve printer agent first before getting cloud printer agent 2026-09-15 18:02:02 +08:00
Ian Chua cf3f36a1c6 Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-15 17:37:57 +08:00
Ian Chua cca7d8adb1 fix(ci): deps build order for datachannel 2026-09-15 17:35:25 +08:00
ExPikaPaka b5ae632bd7 Merge remote-tracking branch 'origin/feature/texture_displacement' into feature/texture_displacement
The remote's 3aefae016f is the same tree as local e245f5d069, and every change
in 013ac898ba (continuous smoothing, world-space bake, mirrored/scaled
placements, plate clamp, UV editor framing/HiDPI, layer scrolling) was already
carried forward and reworked by the local commits. Resolved to the local side.
2026-09-15 11:12:02 +02:00
Ian Chua 396d9ff51a Merge branch 'main' into feat/printer-agent-infra 2026-09-15 16:47:15 +08:00
Ian Chua 81540c81e0 Merge branch 'feat/printer-agent-infra' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-infra 2026-09-15 16:46:38 +08:00
Ian Chua e7ace6cc99 fix: unit tests & unused variables 2026-09-15 16:46:28 +08:00
Lam Wei Lun 79c20a1fad Guard libdatachannel. Remove unused code 2026-09-15 16:33:12 +08:00
Ian Chua e48f9f47d2 Revert "fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests"
This reverts commit 2f566e3779.
2026-09-15 16:12:33 +08:00
Ian Chua 2f566e3779 fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests 2026-09-15 16:08:10 +08:00
Ian Chua 37d1edf69a fix: printer agent virutal optional functions 2026-09-15 15:54:55 +08:00
Lam Wei Lun f1cf9c69b1 Log first before std::move 2026-09-15 15:49:15 +08:00
Lam Wei Lun 1df362888e Fixes nullptr deref 2026-09-15 15:47:48 +08:00
ExPikaPaka a0430631b8 Texture displacement gizmo icons 2026-09-15 08:58:26 +02:00
ExPikaPaka f3d197c4ca Tests for the step cutter, edge flips, auto resolution and v2 colours 2026-09-15 08:58:26 +02:00
ExPikaPaka 78f873a27f Texture gizmo: one-run pipeline by default, auto resolution, colour fixes, debug view 2026-09-15 08:58:26 +02:00
ExPikaPaka bc28052245 Bump and uvcheck shaders: project in the bake's world frame 2026-09-15 08:58:26 +02:00
ExPikaPaka f802208d4c Texture displacement: step cutter, v2 colours, auto resolution, anchored bake frame 2026-09-15 08:58:26 +02:00
ExPikaPaka 45d10341c1 TextureBake: edge flips along the height field, stage recorder, faster displace 2026-09-15 08:58:26 +02:00
Ian Chua 4b2466b179 fix: uninitialized ams state blocking print 2026-09-15 14:40:04 +08:00
Ian Chua 3adcb3e953 fix: split infra from impl 2026-09-15 13:27:17 +08:00
Ian Chua 104dbb2140 fix: camera auto-play on startup 2026-09-14 16:17:17 +08:00
Ian Chua e482cbbdd7 fix: warnings 2026-09-14 14:56:34 +08:00
Ian Chua 2a5ddd9edb fix: warnings 2026-09-14 14:16:02 +08:00
Ian Chua c10f83cf61 Merge branch 'main' into feat/printer-agent-impl 2026-09-14 13:01:03 +08:00
Ian Chua 8eeec6935e Merge branch 'main' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/printer-agent-impl 2026-09-14 12:53:04 +08:00
Ian Chua 131a2abf99 Merge pull request #138 from OrcaSlicer/feat/orca-printer-agent
feat: orca printer agent
2026-09-14 12:14:53 +08:00
peachismomo 9daecc59b4 temp: doc for intended change 2026-09-12 02:32:01 +08:00
peachismomo 58e000d842 feat: cloud download via HTTP 2026-09-12 02:31:27 +08:00
Ian Chua af6be5858e fix: cloud printers were using the wrong MQTT endpoint 2026-09-11 17:38:56 +08:00
Ian Chua 3fc7fd99d6 fix: shim layer for any compatibiliity changes 2026-09-10 21:55:31 +08:00
ExPikaPaka 8bf80a2a46 Improve texture displacement smoothing and UV editor framing 2026-09-09 15:25:00 +02:00
Ian Chua 3fe043651b fix: moonraker printer agent hang on printer power cut 2026-09-09 18:40:01 +08:00
Ian Chua b0b78c296c feat: check printer storage status before sending 2026-09-09 18:35:38 +08:00
ExPikaPaka 60c03e706a add alternative baking algorithm 2026-09-09 08:42:37 +02:00
Ian Chua 62791fabfa fix: revert sdcard check 2026-09-09 14:10:12 +08:00
Ian Chua b0ada2dee5 fix: ffmpeg http camera stream jittering due to incomplete frames 2026-09-09 13:40:51 +08:00
Ian Chua 8f4df1aa8c fix: model_id resolution method for non bambu printers 2026-09-09 13:40:24 +08:00
Ian Chua 5fec9b8d19 Merge pull request #139 from OrcaSlicer/feat/webrtc-impl
feat: webrtc implementation
2026-09-08 19:37:31 +08:00
Ian Chua 75ed5afbac Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-08 19:31:14 +08:00
Ian Chua 01c553bad9 feat: LAN impl for Orca Printer Agent 2026-09-08 19:13:48 +08:00
Ian Chua a4376c77c3 Merge branch 'feat/orca-printer-agent' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/orca-printer-agent 2026-09-08 12:06:25 +08:00
peachismomo c459994290 fix: connect via ip dialog 2026-09-08 04:40:12 +08:00
Ian Chua 3a0fda7d18 fix: make model_id/dev_type optional instead of blocking 2026-09-07 17:28:40 +08:00
Ian Chua eb8733f0f4 Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-04 18:28:48 +08:00
Ian Chua 55acb940a8 Merge branch 'feat/printer-agent-impl' into feat/orca-printer-agent 2026-09-04 18:28:25 +08:00
Ian Chua 0d691403de Merge branch 'main' into feat/printer-agent-impl 2026-09-04 18:26:58 +08:00
Ian Chua 9b2b75a51d Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-04 18:23:26 +08:00
Ian Chua 25da273ea0 Merge pull request #142 from OrcaSlicer/fix/orca-printer-agent-refactor
fix: orcaprinteragent refactor
2026-09-04 18:05:27 +08:00
Ian Chua dbcb82075f feat: use ffmpeg to render http camera stream 2026-09-04 16:42:36 +08:00
Ian Chua 2f82cfe40f fix: LAN paths and camera stream 2026-09-04 16:18:43 +08:00
Ian Chua 972031cf06 fix: orcaprinteragent refactor 2026-09-03 19:42:25 +08:00
ExPikaPaka 08fa489335 Add new bake pipeline 2026-09-03 09:13:14 +02:00
ExPikaPaka 4a48fc770d Add alternative backe pipeline 2026-09-03 08:48:03 +02:00
Ian Chua 2a4792e762 feat: remove frame assembler and change config to set protocol 2026-09-02 15:59:13 +08:00
Ian Chua dd15ac6146 fix: cmake 2026-09-02 11:51:40 +08:00
Ian Chua b0469254bc Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-01 19:24:04 +08:00
Ian Chua 2228589e16 Merge branch 'feat/printer-agent-impl' into feat/orca-printer-agent 2026-09-01 19:23:47 +08:00
Ian Chua 4d8480e1a1 fix: build 2026-09-01 19:21:23 +08:00
Ian Chua 4320cc78d9 feat: camera via webrtc 2026-09-01 18:59:20 +08:00
Ian Chua d9d9678a7e Merge branch 'feat/orca-printer-agent' into feat/webrtc-impl 2026-09-01 18:38:01 +08:00
Ian Chua d55a0bfec6 fix: build errors 2026-09-01 18:37:04 +08:00
Ian Chua 3b1017df51 Merge branch 'feat/orca-printer-agent' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/orca-printer-agent 2026-09-01 18:17:23 +08:00
Ian Chua f5b81d2ffc Merge branch 'feat/webrtc-impl' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/webrtc-impl 2026-09-01 18:16:20 +08:00
Ian Chua eb9cfe0ecb feat: connect to cloud printer and monitor 2026-09-01 18:15:53 +08:00
Ian Chua 1e8805d43d feat: connect to cloud printer and monitor 2026-09-01 18:15:53 +08:00
Ian Chua e8f089dfa4 fix: build & access code UI 2026-09-01 18:13:11 +08:00
Ian Chua 84e929ea24 feat: generic camera stream support for http snapshot and rtsp 2026-09-01 18:13:11 +08:00
Ian Chua abec603e73 fix: build & access code UI 2026-09-01 18:04:37 +08:00
Ian Chua 0ed19d4826 feat: generic camera stream support for http snapshot and rtsp 2026-09-01 18:02:53 +08:00
Ian Chua b747c13ef4 Merge branch 'feat/orca-printer-agent' of https://github.com/OrcaSlicer/OrcaSlicer_priv into feat/orca-printer-agent 2026-09-01 17:40:07 +08:00
Ian Chua 6a12aca495 feat: connect to cloud printer and monitor 2026-09-01 17:38:52 +08:00
Ian Chua d486db6459 Merge branch 'main' into feat/printer-agent-impl 2026-09-01 16:51:34 +08:00
Ian Chua fe777b8801 feat: printer agent impl 2026-09-01 16:49:04 +08:00
ExPikaPaka 2ae1e09dc2 Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-08-31 08:45:38 +02:00
ExPikaPaka ae45d7b78a Add color suport for textures 2026-08-31 07:58:48 +02:00
SoftFever cb4a90402f Merge branch 'main' into feature/texture_displacement 2026-08-30 13:52:54 +08:00
Ian Chua 1b3e206f34 feat: connect to cloud printer and monitor 2026-08-28 18:10:31 +08:00
Ian Chua 4df1607ec4 fix: clear up some unrelated changes 2026-08-28 16:45:01 +08:00
ExPikaPaka e4c570a7b7 Move computation to background thread & resolve freeze 2026-08-27 09:20:02 +02:00
Ian Chua a0ec8bec8a fix: merge artifact 2026-08-27 15:08:06 +08:00
Ian Chua 36c362cbd2 Merge branch 'main' into feat/printer-agent-impl 2026-08-27 14:54:22 +08:00
Ian Chua 84fde32616 Merge branch 'main' into refactor/printer-agent-interface 2026-08-27 14:52:50 +08:00
Ian Chua 34e349e323 fix: snapmaker U1 SelectMachineDialog blocking print 2026-08-27 14:47:09 +08:00
Ian Chua 44941e571f fix: remove unused variable 2026-08-26 18:40:48 +08:00
Ian Chua 876d6e2499 fix: printer agent switching on preset change 2026-08-26 18:40:47 +08:00
ExPikaPaka ca52c08317 Improve adaptive subdivision at border & fix some visual bugs 2026-08-26 09:13:50 +02:00
Ian Chua 858b3024ff fix: tests 2026-08-26 12:04:02 +08:00
Ian Chua 6595557c34 fix: access codes regression 2026-08-25 18:32:02 +08:00
Ian Chua c729849843 cleanup moonraker and snapmaker printer agents 2026-08-25 16:39:08 +08:00
Ian Chua c0563be36e Merge branch 'feat/printer-agent-impl' of https://github.com/OrcaSlicer/OrcaSlicer into feat/printer-agent-impl 2026-08-25 14:47:12 +08:00
Ian Chua 462f8dce30 fix: remote do_fetch_filament_info from tests 2026-08-25 14:46:11 +08:00
Ian Chua b2a0485139 fix: defer filesystem and camera abstractions 2026-08-25 14:34:11 +08:00
Ian Chua f16071f083 fix: remove heavy includes from IPrinterAgent 2026-08-25 13:54:40 +08:00
Ian Chua a1505e9bed Merge branch 'main' into refactor/printer-agent-interface 2026-08-25 13:50:25 +08:00
Ian Chua 8e48312b1f Merge branch 'main' into feat/printer-agent-impl 2026-08-25 13:47:11 +08:00
Ian Chua ba7fdb8ceb Merge branch 'main' into feat/printer-agent-impl 2026-08-21 14:17:56 +08:00
Ian Chua 8be9800567 remove irrelevant docs 2026-08-21 14:16:49 +08:00
Ian Chua c4eabc3c52 fix: extend access code requirements t 0, 8 or more characters. 2026-08-21 14:14:23 +08:00
Ian Chua 0d0d281d0c feat: parse nozzle information for qidi and moonraker printer agents 2026-08-20 20:11:31 +08:00
Ian Chua c4bbcd322b fix: skip filament sync dialog if filamentSyncMode is none 2026-08-20 19:28:02 +08:00
Ian Chua 7aea1b1235 Merge branch 'main' into feat/printer-agent-impl 2026-08-20 16:39:54 +08:00
Ian Chua 41c16436bf Merge branch 'main' into refactor/printer-agent-interface 2026-08-20 16:09:10 +08:00
ExPikaPaka 3aa48abcb7 Measure post-process smoothing by edge energy instead of height spread 2026-08-20 07:14:21 +02:00
Ian Chua 2bbb229c91 Merge branch 'main' into refactor/printer-agent-interface 2026-08-19 23:30:22 +08:00
Ian Chua c2a43157d2 Merge branch 'main' into feat/printer-agent-impl 2026-08-19 18:29:36 +08:00
Ian Chua 738b30a743 fix: ams sync info and periodic ams sync via subscription workflow 2026-08-19 18:29:19 +08:00
Ian Chua 6e6ffe13f8 Merge branch 'refactor/printer-agent-interface' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-19 17:11:14 +08:00
Ian Chua eb96d127b0 fix: resolve stubgen byte header conflict 2026-08-19 17:11:10 +08:00
ExPikaPaka faa0d9a44a Merge branch 'main' into feature/texture_displacement 2026-08-19 09:13:53 +02:00
SoftFever 60421c33f4 Merge branch 'main' into refactor/printer-agent-interface 2026-08-19 14:32:42 +08:00
SoftFever 300c4b8afb Merge branch 'main' into refactor/printer-agent-interface 2026-08-18 20:44:56 +08:00
Ian Chua 96092ef2d3 feat: update qidi to use subscription based filament sync mode 2026-08-18 19:05:17 +08:00
Ian Chua d9002ad87d fix: ams filament mapping workflow 2026-08-18 18:07:22 +08:00
Ian Chua 4c1ea0a602 Merge branch 'main' into 'feat/printer-agent-impl' 2026-08-17 14:51:04 +08:00
Ian Chua 9415812d85 Merge branch 'main' into refactor/printer-agent-interface 2026-08-17 14:24:23 +08:00
Ian Chua d3c728557e Merge 'main' into 'refactor/printer-agent-interface' 2026-08-17 14:24:05 +08:00
Ian Chua 6558c52849 revert file transfer abstraction 2026-08-17 14:19:41 +08:00
Ian Chua a34d056de2 specify api for getting file transfer url 2026-08-14 15:31:42 +08:00
Ian Chua 7ba5718be6 fix: remove redundant cache 2026-08-12 18:41:51 +08:00
ExPikaPaka 8434223f96 Add Parallax preview & fix Undo\Redo history 2026-08-12 10:14:38 +02:00
Ian Chua c1163ce7e5 Merge branch 'refactor/printer-agent-interface' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-12 15:29:56 +08:00
Ian Chua da187eaaf9 fix callback error 2026-08-12 15:29:50 +08:00
Ian Chua de0268ce86 Merge branch 'main' into refactor/printer-agent-interface 2026-08-12 15:28:53 +08:00
Ian Chua 108923bdaa fix: default impl 2026-08-12 14:02:04 +08:00
Ian Chua 44793f7a21 remove unused 2026-08-12 13:51:54 +08:00
Ian Chua 7cb1805272 refactor: push bbl workflows to bbl printer agent 2026-08-12 13:50:47 +08:00
Ian Chua d5c528b7c7 Merge branch 'refactor/printer-agent-interface' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-11 15:00:42 +08:00
Ian Chua 742cb712d8 refactor: abstract bambu specific protocol to printer agent 2026-08-11 15:00:34 +08:00
Ian Chua fd1c5d826c Merge branch 'main' into refactor/printer-agent-interface 2026-08-07 18:37:56 +08:00
Ian Chua 58be7f4861 feat: abstract remaining gcode commands in devicemanager 2026-08-07 18:36:38 +08:00
Ian Chua 4031b00915 Merge branch 'main' into refactor/printer-agent-interface 2026-08-06 16:27:57 +08:00
Ian Chua 07c62112b8 Reconcile implementation split with PR tip 2026-08-04 18:12:51 +08:00
Ian Chua f23e4963bf fix: merge access codes into one 2026-08-04 18:12:43 +08:00
Andrew 95279f7084 docs: document the printer-agent subsystem 2026-08-04 18:12:43 +08:00
Andrew 1014558c91 Keep printer-agent progress in sync
Keep the shared task progress aligned with agent
reports that lack Bambu cloud task identity.

Release the lazily allocated task during reset to
avoid leaks when machine objects reconnect.
2026-08-04 18:12:42 +08:00
Andrew 6515062a3a fix: stop Qidi slot parse throwing on null 2026-08-04 18:12:42 +08:00
Andrew 8153e26b6d fix: start stream when camera URL changes 2026-08-04 18:12:42 +08:00
Andrew 79fd33d49a Show Snapmaker U1 camera in Device tab
The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.

Start the camera when the camera view is
shown and renew every 300 s: the printer
retires the capture task at ~362 s and
stop_monitor is accepted but ineffective,
so teardown is simply to stop renewing.

Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.
2026-08-04 18:12:41 +08:00
Andrew cdb662d4b2 Move Moonraker commands off the UI thread
Pause/resume/stop, g-code sends, temps, and
light ran synchronous HTTP on the UI thread,
freezing the app up to 10s per click on slow
or unreachable printers.

Run them on a single agent-owned FIFO worker
so g-code ordering is preserved, while command
translation stays synchronous so unsupported-
command dialogs still work.

Add a pending-disabled state to the pause,
resume, and abort buttons for Moonraker-family
printers: the icon only flips once the
WebSocket reports the real state, which also
rules out double-click races.
2026-08-04 18:12:41 +08:00
Andrew a27fa7b5df Fix multi-color filament logic
Reuse color decoding across functions to improve
code readability and maintain consistency in
multi-color filament handling.
2026-08-04 18:12:40 +08:00
Andrew cdeed107c5 Keep Bambu AMS dialect out of the agent waist
M620 is Bambu firmware dialect, not a
neutral command. Composing it in
MachineObject let non-Bambu agents
(Moonraker/Klipper) forward it and
report success on firmware that
cannot run it.

Agents now own the dialect: the
default refusal on IPrinterAgent
returns not-supported so the UI
can say so; BBLPrinterAgent keeps
the byte-identical composition.
2026-08-04 18:12:40 +08:00
Andrew be92f7e78c Surface Moonraker webcams and gate unrunnable controls 2026-08-04 18:12:39 +08:00
Andrew 76f9de4cfb fix: make Klipper macro lamp control reliable 2026-08-04 18:12:39 +08:00
Andrew cd6d2cbf5e Stop blocking print on unreported nozzle data 2026-08-04 18:12:39 +08:00
Andrew deaca0189f Bring Moonraker device panel to feature parity
The monitor panel showed wrong or missing
data for Moonraker printers, and its
controls did nothing.

Push payload now carries layer number and
total layers. Remaining time replaces the
wrong total_duration - print_duration
formula. The chamber light toggle maps to
Klipper SET_PIN / SET_LED, and pause,
resume and stop post to
/printer/print/{action}. Task thumbnails
resolve via /server/files/thumbnails onto
a new MachineObject thumbnail url.

Filament sync switches to pull mode so the
agent is queried on demand.

Not compiled or run.
2026-08-04 18:12:38 +08:00
Andrew 7d54ee286d fix: pin HTTP to prevent connection refusal
Set `use_ssl` to false to ensure Moonraker
connectivity, as the service uses HTTP rather
than HTTPS, preventing connection issues. Initialize
device info early for reliable name resolution.
2026-08-04 18:12:38 +08:00
Andrew dddfc7a8ad Add printer-agent and plugin status tests
Ports the agent lifecycle, duplicate
agent-id, built-in-id clash and
status-resolution tests. The loader
runs on a detached worker thread, so
the lifecycle tests live in their own
executable. Tests install the
production unload-side registry
wiring themselves (no GUI in the
test binary) and register agents
manually so concurrent loads stay
deterministic.
2026-08-04 18:12:37 +08:00
Andrew 673bfb0dee Track BBLPrinterAgentPlugin.py 2026-08-04 18:12:37 +08:00
Andrew b9e8c5a38c Gate agent mode behind use_printer_agents toggle
Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.
2026-08-04 18:12:36 +08:00
Andrew a2a4ca20e4 Reset device selection on agent swap or unload (#124)
set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.
2026-08-04 18:12:36 +08:00
Andrew 12075459d2 Replace fake-enum printer agent dropdown (#121)
A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.
2026-08-04 18:12:36 +08:00
Andrew f3770f3106 fix: checkbox should depend on plugin is_loaded status 2026-08-04 18:12:35 +08:00
Andrew 90fbf1770f Resolve duplicate agent ID conflicts
Reject a printer-agent capability
whose agent ID is already owned by
another capability or built-in:
flag the plugin error, disable the
capability, and warn the user
instead of silently ignoring it.
2026-08-04 18:12:35 +08:00
Andrew bde94ab37f Add support for runtime error status in plugins
Distinguish a loaded plugin whose
capability errored (RuntimeError,
warn-styled, stays checked) from a
load-time Error. Status now derives
via resolve_plugin_status(); enum
ordinal keeps dialog sort priority.
Unloading clears stale errors.
2026-08-04 18:12:34 +08:00
Ian Chua 6d25e1777e Working Moonraker and Qidi printer agent transport (#104)
Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.
2026-08-04 18:12:34 +08:00
Andrew 7ce26ca8e5 Harden send flow and separate upload failure recovery (#111)
* fix(send): harden FT send path + IP pre-flight UX

* Remove early returns
2026-08-04 18:12:33 +08:00
Andrew 367fcce634 Prevent loss of user access code on LAN reselect
Keep user access code intact to maintain access rights even if
device slot is unpopulated, ensuring continuous connection
and status message reception.
2026-08-04 18:12:33 +08:00
Andrew 9b3a44b339 Parse user print info on the UI thread to prevent heap corruption (#119)
get_user_print_info()'s HTTP fetch can run on a worker thread (e.g. BindJob),
but parse_user_print_info() mutates userMachineList (insert/erase/delete
MachineObject). on_machine_alive (SSDP) mutates the same maps on the UI thread
without locking, so parsing off-thread races the map and frees MachineObjects
out from under it -> heap corruption.

Keep all device-list mutation on the UI thread: parse inline when already on
the main thread, otherwise marshal via CallAfter so it stays serialized with
on_machine_alive.
2026-08-04 18:12:33 +08:00
Ian Chua 6384191102 Add developer flag for printer agents 2026-08-04 18:12:32 +08:00
ExPikaPaka b753cf2216 Displace the painted patch border and add post-process smoothing 2026-07-29 08:44:16 +02:00
ExPikaPaka 7b98433dd7 Rewrite adaptive subdivision to refine worst-first against a triangle budget 2026-07-28 11:42:40 +02:00
ExPikaPaka 0df1424101 Add adaptive subdivision 2026-07-24 09:03:06 +02:00
ExPikaPaka 23b6020344 Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-07-21 14:24:39 +02:00
ExPikaPaka d9f679d154 Rename reserved GLSL word on AMD GPU 2026-07-21 14:07:51 +02:00
SoftFever 5cae3337a7 Merge branch 'main' into feature/texture_displacement 2026-07-21 19:50:35 +08:00
ExPikaPaka 39bfceca6d Fix typo again 2026-07-21 12:53:00 +02:00
ExPikaPaka 219cddf40e Fix typo after cleanup 2026-07-21 11:28:51 +02:00
ExPikaPaka 129b612af5 Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-07-21 10:12:16 +02:00
ExPikaPaka 61d2d4355a Cleanup 2026-07-21 10:12:09 +02:00
ExPikaPaka ab023f3f6d Add texture projection frame overlay, fix remeshing and subdivision 2026-07-21 09:44:25 +02:00
Ian Bassi 9f1722ed3b Merge branch 'main' into feature/texture_displacement 2026-07-17 09:07:31 -03:00
ExPikaPaka 8247514ae2 Fix cmake config 2026-07-17 09:04:39 +02:00
ExPikaPaka 00f55639d3 Add new icons 2026-07-16 08:52:15 +02:00
ExPikaPaka 68d754d946 Add texture displacement documentation 2026-07-16 08:43:35 +02:00
ExPikaPaka dc5a48bdfd Add texture displacement toolbar icon and textures 2026-07-16 08:43:27 +02:00
ExPikaPaka 05083bb6ab Add texture displacement gizmo and UV editor 2026-07-16 08:43:21 +02:00
ExPikaPaka a393b21642 Add texture displacement bump and UV-check shaders 2026-07-16 08:43:08 +02:00
ExPikaPaka a7c8dcc58d Add texture displacement baking, LSCM unwrap and remesh core 2026-07-16 08:42:55 +02:00
ExPikaPaka 3514249197 Removed files that were accidently added 2026-07-09 08:40:37 +02:00
ExPikaPaka 7f2598d0d6 POC 2026-07-08 08:50:47 +02:00
1304 changed files with 44402 additions and 12537 deletions
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
"repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \ "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \
-f branch="$branch" -f event=workflow_dispatch -f per_page=100)" -f branch="$branch" -f event=workflow_dispatch -f per_page=100)"
run_id="$(jq -r --arg marker "[OFL cron $dispatch_id]" \ run_id="$(jq -r --arg marker "[OFL cron $dispatch_id]" \
'[.workflow_runs[] | select((.display_title // "") | contains($marker))] \ '[.workflow_runs[] | select((.display_title // "") | contains($marker))]
| sort_by(.created_at) | last | .id // empty' <<< "$runs_json")" | sort_by(.created_at) | last | .id // empty' <<< "$runs_json")"
[ -n "$run_id" ] && break [ -n "$run_id" ] && break
sleep 5 sleep 5
+2
View File
@@ -35,6 +35,7 @@ jobs:
// kind of change // kind of change
'crash', 'crash',
'bug-fix', 'bug-fix',
'SECURITY',
'enhancement', 'enhancement',
'QoL', 'QoL',
'optimization', 'optimization',
@@ -193,6 +194,7 @@ jobs:
// kind of change // kind of change
'crash', 'crash',
'bug-fix', 'bug-fix',
'SECURITY',
'enhancement', 'enhancement',
'QoL', 'QoL',
'optimization', 'optimization',
+42 -1
View File
@@ -834,6 +834,37 @@ find_package(OpenSSL REQUIRED)
find_package(CURL REQUIRED) find_package(CURL REQUIRED)
find_package(Freetype REQUIRED) find_package(Freetype REQUIRED)
if (SLIC3R_GUI)
# LibDataChannel's installed export references its bundled dependencies,
# but does not install their CMake targets. Recreate those targets from
# the same dependency prefix before loading the LibDataChannel config.
if (NOT TARGET Usrsctp::usrsctp)
find_library(_ORCA_USRSCTP_LIBRARY NAMES usrsctp
PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH)
if (_ORCA_USRSCTP_LIBRARY)
add_library(Usrsctp::usrsctp UNKNOWN IMPORTED GLOBAL)
set_target_properties(Usrsctp::usrsctp PROPERTIES
IMPORTED_LOCATION "${_ORCA_USRSCTP_LIBRARY}"
IMPORTED_LINK_INTERFACE_LANGUAGES C
INTERFACE_LINK_LIBRARIES "Threads::Threads")
endif()
endif()
if (NOT TARGET LibJuice::LibJuice)
find_library(_ORCA_LIBJUICE_LIBRARY NAMES juice
PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH)
if (_ORCA_LIBJUICE_LIBRARY)
add_library(LibJuice::LibJuice UNKNOWN IMPORTED GLOBAL)
set_target_properties(LibJuice::LibJuice PROPERTIES
IMPORTED_LOCATION "${_ORCA_LIBJUICE_LIBRARY}"
IMPORTED_LINK_INTERFACE_LANGUAGES C
INTERFACE_LINK_LIBRARIES "Threads::Threads")
endif()
endif()
find_package(LibDataChannel CONFIG REQUIRED)
endif()
add_library(libcurl INTERFACE) add_library(libcurl INTERFACE)
target_link_libraries(libcurl INTERFACE CURL::libcurl) target_link_libraries(libcurl INTERFACE CURL::libcurl)
@@ -1114,6 +1145,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls)
endif () endif ()
file(COPY ${_occt_dlls} file(COPY ${_occt_dlls}
${CMAKE_PREFIX_PATH}/bin/freetype.dll ${CMAKE_PREFIX_PATH}/bin/freetype.dll
${CMAKE_PREFIX_PATH}/bin/avformat-61.dll
${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll ${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll
${CMAKE_PREFIX_PATH}/bin/swresample-5.dll ${CMAKE_PREFIX_PATH}/bin/swresample-5.dll
${CMAKE_PREFIX_PATH}/bin/swscale-8.dll ${CMAKE_PREFIX_PATH}/bin/swscale-8.dll
@@ -1126,6 +1158,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls)
${_out_dir}/WebView2Loader.dll ${_out_dir}/WebView2Loader.dll
${_out_dir}/freetype.dll ${_out_dir}/freetype.dll
${_out_dir}/avformat-61.dll
${_out_dir}/avcodec-61.dll ${_out_dir}/avcodec-61.dll
${_out_dir}/swresample-5.dll ${_out_dir}/swresample-5.dll
${_out_dir}/swscale-8.dll ${_out_dir}/swscale-8.dll
@@ -1149,7 +1182,10 @@ function(orcaslicer_copy_sos target config postfix output_sos)
set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}")
endif () endif ()
file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so file(COPY ${CMAKE_PREFIX_PATH}/lib/libavformat.so
${CMAKE_PREFIX_PATH}/lib/libavformat.so.61
${CMAKE_PREFIX_PATH}/lib/libavformat.so.61.1.100
${CMAKE_PREFIX_PATH}/lib/libavcodec.so
${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61
${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100 ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100
${CMAKE_PREFIX_PATH}/lib/libavutil.so ${CMAKE_PREFIX_PATH}/lib/libavutil.so
@@ -1164,6 +1200,9 @@ function(orcaslicer_copy_sos target config postfix output_sos)
DESTINATION ${_out_dir}) DESTINATION ${_out_dir})
set(${output_sos} set(${output_sos}
${_out_dir}/libavformat.so
${_out_dir}/libavformat.so.61
${_out_dir}/libavformat.so.61.1.100
${_out_dir}/libavcodec.so ${_out_dir}/libavcodec.so
${_out_dir}/libavcodec.so.61 ${_out_dir}/libavcodec.so.61
${_out_dir}/libavcodec.so.61.3.100 ${_out_dir}/libavcodec.so.61.3.100
@@ -1293,6 +1332,8 @@ endif ()
if (CMAKE_SYSTEM_NAME STREQUAL "Linux") if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(LIBRARY_FILES set(LIBRARY_FILES
${LIBDIR_BIN}/libavformat.so.61
${LIBDIR_BIN}/libavformat.so.61.1.100
${LIBDIR_BIN}/libavcodec.so.61 ${LIBDIR_BIN}/libavcodec.so.61
${LIBDIR_BIN}/libavcodec.so.61.3.100 ${LIBDIR_BIN}/libavcodec.so.61.3.100
${LIBDIR_BIN}/libavutil.so.59 ${LIBDIR_BIN}/libavutil.so.59
+581
View File
@@ -0,0 +1,581 @@
# Texture Displacement - Technical Notes
Branch: `feature/texture_displacement`. Reference for the feature as it stands: what it does, how the
algorithms work, and where the code lives.
## What it does
A paint-style gizmo (`GLGizmoTextureDisplacement`) that lets you:
- Paint one or more "layers" onto a model's surface, each a height-map texture with its own
depth/tiling/rotation/offset/invert/tile-mode/projection-mode/blend-mode.
- Pick a texture from a shipped library (`resources/textures/displacement/`) or import your own
(saved into `<data_dir>/textures/displacement/`, kept separate so app updates can't clobber it).
- Combine overlapping layers with image-editor-style blend modes (Add/Subtract/Multiply/Divide).
- Preview the true displaced result live, before baking (background job, not on the UI thread).
- Preview via a fast GPU shader instead (no real geometry movement) for a lighter-weight alternative.
- Bake into real mesh geometry on demand, restricted to the painted area only.
- Remesh and subdivide so a low-poly model has enough vertices to show fine detail.
- Unwrap a painted patch with a real CGAL LSCM parameterization and view it in a dedicated,
dockable 2D "UV Editor" pane.
## Standard vs Pro mode
A two-position slider in the panel header, right of the Dock/Undock button.
**Pro** shows every mesh-preparation control; Remesh, Subdivide and Bake are run separately by the user,
in whatever order they like.
**Standard** hides all of it and folds one fixed recipe into the Bake button, because a height map only
ever *moves vertices that already exist* - painting onto an imported 12-triangle box and pressing Bake
would otherwise do nothing visible. Standard's Bake is:
1. `plan_remesh()` + `replace_mesh_keep_all_paint()` - isotropic remesh to 1 mm, sharp edges above 40
degrees protected. Gives the subdivider an even starting density whatever the input looked like.
2. `plan_adaptive_subdivision()` + `apply_adaptive_subdivision()` - feature-adaptive refinement, max
edge 20 mm, detail 0.02 mm, min edge 0.02 mm.
3. `bake()` - the ordinary background displacement job.
Both preparation stages are *planned* before the undo snapshot and *applied* after it, so a stage with
nothing to do is skipped without leaving an empty undo step. The standalone Pro buttons share the same
plan/apply split.
**All three stages sit under one undo step.** `Plater::take_snapshot()` records the state *before* the
change, so a single snapshot taken at the top of `bake_standard()` means one Undo returns the mesh to
exactly what was imported. `TextureDisplacementBakeInput::take_snapshot` lets the caller say who owns
the undo step - true for the Pro-mode button, false for the pipeline, whose background job commits long
after that snapshot's scope has closed.
The presets live in one place (`STD_*` constants) and `apply_standard_mode_presets()` pins the hidden
controls to them every frame while Standard is active, so the live preview cannot disagree with what
Bake will do. Switching to Standard also closes the subdivision preview, whose controls have just gone.
One control survives into Standard: **"Added triangles (k)"**, the subdivision budget. It is deliberately
*not* pinned - pinning would fight the user's own slider every frame - because unlike the rest of the
recipe its right value depends on the part rather than on the method (a big model, or a fine texture,
simply needs more triangles). Default 1500. The widget is one lambda shared by both layouts.
Standard remeshes *after* painting, so the remesh has to preserve paint: `ModelVolume::restore_painting()`
only remaps the four standard channels, so `replace_mesh_keep_all_paint()` additionally runs
`TriangleSelector::remap_painting()` over the eight texture-displacement masks. The Pro Remesh button
goes through the same helper. If the remap comes back empty the pipeline stops with a message rather
than baking a flat mesh.
## Architecture
### Data model (per `ModelVolume`)
Each of up to `TEXTURE_DISPLACEMENT_MAX_LAYERS` (8) layers gets its **own independent
`FacetsAnnotation`** paint mask - the same `TriangleSelector`/`FacetsAnnotation` machinery every other
paint gizmo (FdmSupports, Seam, MMU, FuzzySkin) already uses, just one full instance per layer slot
instead of one per volume. This is what makes layered/blended painting work for free: the same triangle
can be `ENFORCER` in layer 2's mask and layer 5's mask simultaneously, and at bake/preview time each
layer displaces the surface left by the previous one (image-editor-layer semantics).
Whole-stack settings (border handling, post-process smoothing) live beside the layers in
`texture_displacement_options` (`TextureDisplacementOptions`), since they belong to no single layer.
### Bake algorithm (`libslic3r/TextureDisplacement.cpp`)
`build_texture_displacement(base_mesh, layers, facets_data, options)` is **accumulate-then-displace,
and topology-preserving**: the returned mesh has exactly the input's vertices and triangles, in the
same order - only the positions of displaced vertices differ.
1. `its_compactify_vertices()` on a copy of the input. In practice a no-op (it only drops
*unreferenced* vertices, and preserves the order and indices of the rest). It is there to
guarantee the index alignment step 3 depends on.
2. Area-weighted vertex normals of the **undisplaced** mesh, computed once. Every layer both projects
and displaces along these, so a vertex covered by several layers moves along one single well-defined
direction. Where the paint does *not* cover every triangle around a vertex, the normal is recomputed
from the painted triangles alone (the union over all layers, so it stays one direction per vertex):
on the rim of a fully painted top face the whole-mesh normal is the 45-degree bisector it shares with
the side wall, and displacing along that flares the rim outwards instead of raising it. Interior
vertices are unaffected - all their triangles are painted, so the two normals coincide. Paint
coverage per original triangle comes straight off `TriangleSplittingData::triangles_to_split`.
3. For each layer in slot order: deserialize its stored paint mask into a `TriangleSelector` against
the **base mesh** (never against a previous layer's output), then
`selector.get_facets_strict(ENFORCER)` -> the painted patch. Two facts are exploited:
- `get_facets_strict()` returns the mesh's **entire** referenced vertex array regardless of which
state was asked for - only `.indices` is filtered by state. So `get_facets_strict(ENFORCER)`
and `get_facets_strict(NONE)` share identical vertex indexing, which is what lets boundary
detection be a plain index check instead of a position-hash lookup.
- The selector's vertex array *starts with* the mesh's own vertices (extra ones created where a
brush stroke split a triangle are appended after them), and `get_facets_strict()` emits the
referenced ones in order. Combined with step 1, **selector vertex index `i` is our vertex `i`**.
Split vertices live past the end of our array and are simply skipped - they sit on the paint
boundary anyway (splitting only happens at partial coverage).
4. A vertex used by at least one **unpainted** triangle is a border vertex. Whether it moves is
`TextureDisplacementOptions::displace_border`, and it does by default. Nothing can tear: the bake is
topology-preserving, so a border vertex is *one* vertex shared by both regions and moving it simply
tilts the unpainted triangles that use it. Pinning it instead clamps the outermost ring of relief to
zero, which on a fully painted face collapses the pattern into a ring of steep ramps at the edge; it
is kept as an option for when the relief must not spill past the paint at all. Either way the border
drives the `edge_smoothing` falloff.
5. Per interior vertex: sample the height texture (`sample_layer_height()`, see Projection methods)
and fold `height * depth_mm * (invert ? -1 : 1)` into that vertex's running total via the layer's
`TextureBlendMode` (see Blend modes). A `visited` set makes each layer fold in exactly **once**
per vertex, no matter how many of the patch's triangles share it - otherwise a Multiply/Subtract
layer would apply two or three times over depending on local triangle fan-out.
6. Move each touched vertex along its (step 2) normal by its accumulated total.
7. Optionally (`TextureDisplacementOptions::smooth_*`) relax the result - see Post-process smoothing.
### Post-process smoothing
`smooth_mesh_vertices(mesh, movable, strength, iterations)` - Laplacian relaxation, run after all layers
have been folded in, restricted to the vertices flagged in `movable`. Each pass moves a movable vertex a
`strength` fraction of the way to the average of its one-ring, read from a **snapshot** of the previous
pass so the result does not depend on vertex order (a Gauss-Seidel sweep would smooth several times as
hard at the end of the array as at the start). Neighbours come from a CSR-style adjacency built once per
call. Topology-preserving, like the bake.
Its job is to round off the hard steps a bitmap height map leaves behind - a different knob from
`TextureDisplacementLayer::smoothing`, which blurs the *height map* before it is ever sampled.
Two ways in, sharing one set of settings on the volume:
- The **"Smooth result"** checkbox + "Smoothing (%)" / "Passes" ride along with Preview and Bake.
`movable` is exactly the set of vertices the displacement moved, so the untouched part of the model
keeps its exact geometry and the ring just outside the displaced set anchors the relaxation (the
relief cannot creep outward).
- **"Smooth baked mesh now"** (`GLGizmoTextureDisplacement::smooth_model()`) applies the same settings to
the volume's *committed* geometry, for relief that is already baked in. `movable` there is the painted
triangles' vertices. Because smoothing never touches the triangle list, this is the one geometry
operation in the gizmo that keeps **every** paint channel verbatim - it saves and restores the eight
texture-displacement masks around `set_mesh()` rather than remapping or dropping them.
**"Ignore outer ring"** (`smooth_skip_border`, on by default) drops the patch's own outermost ring of
vertices from `movable`. That ring's neighbours *outside* the paint never move, so relaxing it drags the
rim of the relief down toward the flat surface and the pattern comes out half-melted where it meets the
edge. Held out, the border keeps the full depth the texture asked for and only the interior relaxes.
Turning it off softens the outer edge deliberately (a blunter version of the per-layer edge-smoothing
falloff). This is the *smoothing* rim, independent of whether that rim is displaced at all
(`displace_border`, step 4 above); both default to keeping the border sharp.
### Blend modes
`TextureBlendMode` {Add, Subtract, Multiply, Divide}, per layer, applied per vertex against the
total accumulated by the layers **below** it (lower slots). The quantity blended is a signed
displacement in **mm**, not a pixel value.
Add/Subtract are self-explanatory. Multiply/Divide are *scaling* operations and so need a unit
convention: they treat the layer's own value as a **factor relative to 1 mm**. That makes `depth_mm`
a gain, and - the property that makes a Multiply layer usable as a mask - a layer with depth 1 mm
sampling a white (1.0) texel multiplies by exactly 1, i.e. leaves the layers below unchanged.
Divide floors its divisor's magnitude at 0.05: a black texel samples to *exactly* zero, so the divisor
really does hit zero in ordinary use, and an unbounded `1/0` would fling vertices thousands of mm away
and poison the mesh's bounding box (and every plate/print-volume check downstream). The floor doubles as
a cap on how far Divide can amplify the relief beneath it: at most 20x.
The **lowest painted layer ignores its blend mode**: it has nothing beneath it, and Multiply/Divide
against an implicit zero base would annihilate (or blow up) it. Enforced in
`build_texture_displacement()` (the first layer to reach a given vertex always folds in additively) and
surfaced in the UI, which labels that layer "Base layer" instead of offering a control that does nothing.
### Projection methods
Five choices per layer (`TextureProjectionMethod`), all funneling through `apply_uv_transform()`
(scale by `1/tiling_scale`, rotate by `rotation_deg`, add `offset`). They are dispatched by
`sample_layer_height()`, which returns a **height**, not a UV - because Triplanar takes three
texture samples per vertex and so has no single UV that represents it.
- **Triplanar** (default) - samples the texture on all three world planes (`(y,z)`, `(x,z)`, `(x,y)`)
and blends the three by the vertex's own normal raised to `TRIPLANAR_BLEND_SHARPNESS` (4). Hard-picking
the single axis most aligned with the normal instead is discontinuous wherever that dominant axis
flips: on a +X face the planar coordinate is `(y, z)`, on a -Y face it is `(x, z)`, so at the shared
edge `u` jumps. A weighted blend is continuous across the transition by construction, since the weight
of the axis being left behind falls smoothly to zero. This removes the hard *seam*; some cross-fade
blurring in the band right at a 90° edge is inherent to triplanar mapping. A genuinely seam-free wrap
around a box needs a real unwrap - that is what the LSCM mode is for.
- **Cylindrical** - wraps around an axis through the patch centroid, axis auto-picked as the world
axis *least* aligned with the average normal (perpendicular to the outward radial normal, as a
cylinder's own axis would be). `u = angle * local_radius` (arc length in mm), `v = distance along
axis`. An approximation, not an exact fit for arbitrary geometry, and the axis/centre are not
user-overridable.
- **Spherical** - longitude/latitude around the centroid, scaled by local radius. Same caveat.
- **LSCM** - real UV unwrap via `MeshBoolean::cgal::parameterize_lscm()` (CGAL's
`Surface_mesh_parameterization` package, LSCM algorithm). Computed **once per patch** (not
per-vertex like the others - it's a single global least-squares solve), then each vertex looks up
its precomputed UV. Requires the patch to be a single topological disk (one connected component,
one boundary loop) - `compute_lscm_uvs()` returns empty and the layer falls back to Triplanar if not
(e.g. multiple disconnected painted islands, or a fully closed patch). CGAL's parameterizer needs a
mesh with no isolated/unreferenced vertices, but `get_facets_strict()` returns the *whole* mesh's
vertex array - so `compact_patch_with_map()` builds a clean sub-mesh plus an index map back to the
original vertex numbering, purely local to this file.
- **ViewProjected** ("From view") - a flat projection along a fixed direction captured from the 3D
camera, like a slide projector. `capture_view_projection()` takes the camera's right/up axes,
transforms them into the volume's *local* frame (so the projection rides along if the part is later
moved), and stores them as `TextureDisplacementLayer::view_project_right/up` (unit vectors, so the
projected coordinate stays in mm and `tiling_scale` keeps meaning mm). `sample_layer_height()`
projects `Vec2f(dot(pos, right), dot(pos, up))`. Single-valued per point, so - like LSCM but unlike
blended Triplanar - the fast preview and UV-check overlay precompute it per vertex
(`compute_layer_vertex_uvs()`) and drive the shader's `use_vertex_uv` path. Faces angled away from
the projector smear; that is inherent to view projection.
Two companions to this mode:
- **Projection frame overlay** (`TextureProjectorFrame`, see below) - a semi-transparent window
dragged over the 3D view whose border becomes the projection's edge. Applying it stores an exact
**projective** map in `view_project_matrix`, which supersedes the affine `right`/`up` axes above
for that layer (`view_project_projective`).
- **"Project only on visible"** (`select_visible_faces()`) - repaints the layer with exactly the
facets the camera can see, so the projected area matches the viewpoint the projector was captured
from. Two tests: a facing test (normal vs. view direction, per triangle - under perspective the
view direction varies across the model, so it is taken from the eye to each centroid), then
`MeshRaycaster::get_unobscured_idxs()` on the survivors to drop facets hidden behind other
geometry, so a concave part's far inner wall is correctly excluded. One ray query per front-facing
facet, hence click-driven (on the checkbox and on each "Capture current view"), never per frame.
It **replaces** the layer's paint rather than adding to it - "project onto what I can see" would
otherwise accumulate every angle the user had ever looked from.
### Manual seams and island cutting
`TextureDisplacementLayer::lscm_seam_edges` - undirected mesh-vertex-index edge pairs the unwrap is
forced to cut along, on top of the dihedral-angle seams. `segment_into_charts()` takes a set of these
(translated from mesh -> compacted-patch numbering inside `compute_patch_unwrap()`) and refuses to
union two triangles across a marked edge whatever their angle. Both the unwrap cache key and the
gizmo's `UVEditorState` include the seam list, so marking a seam (which leaves the paint mask
untouched) still forces a re-solve. Like the paint masks, seams are mesh-index-space and so dropped on
any topology change.
Two ways to write to it:
- **Mark seam (manual)** - a "Mark seams" click mode (`m_seam_edit_mode`) that suppresses painting. A
click raycasts the volume (`m_c->raycaster()->raycasters()[idx]->unproject_on_mesh()`, `idx` = the
volume's slot among model-part volumes), finds the facet's edge nearest the hit point, and toggles it.
Marked edges render as a red overlay (`render_seam_overlay()`), pulled toward the camera so they read
on top. This is the Blender mark-seam workflow.
- **Cut island (auto)** - `cut_island()` takes the selected chart's triangles (back-mapped from the
unwrap via `source_vertex`), finds their 3D bounding box, and marks every edge that straddles the
mid-plane perpendicular to the longest axis. The re-unwrap then splits the chart across its narrow
waist. Exposed as the UV pane's **Cut** button.
### UV-check overlays (checker / distortion)
`resources/shaders/{110,140}/texture_displacement_uvcheck.{vs,fs}`, one shader with a `mode` uniform,
drawn over the painted patch (`rebuild_uvcheck_mesh()`/`render_uvcheck_mesh()`, P3N3T2: `normal.x` =
distortion, `tex_coord` = uv), pulled forward with a polygon offset. **Checker** samples a procedural
checkerboard at the layer's uv (per-vertex for LSCM/ViewProjected, in-shader triplanar otherwise) -
squares that stay square mean low distortion. **Distortion** colours each triangle blue->green->red by
`log2(uv_area / surface_area)` centred on the patch's *median* stretch (so a globally-scaled unwrap
reads as uniformly ideal and only relative stretch shows), averaged to vertices. A separate **Show mesh
wireframe** toggle draws the whole volume's triangle edges, rebuilt only when the vertex count changes
(not per stroke).
### Tiling
`DecodedHeightTexture::sample(uv, tile_enabled, tile_method)`. Two tile methods when enabled
(Repeat, MirroredRepeat). **When `tile_enabled` is false, sampling outside `[0,1)` returns `0`
directly** rather than clamping the *coordinate* into range, which would smear the border row/column of
pixels outward to infinity in every direction (streaky lines radiating out from the painted patch).
### Subdivision - two modes
**Uniform (`subdivide_mesh_uniform()`)** - whole-mesh, 1-to-4 split. Recursive edge-midpoint split with
a shared per-pass midpoint cache (keyed by sorted vertex-index pair) so triangles sharing an edge get
the *same* new vertex - capped at `max_iterations` (default 6). Whole-mesh so it never leaves a
T-junction, at the cost of densifying everywhere. Wired as a "Subdivide steps" slider (**0-5**, 0 =
no subdivision), Apply snaps back to 0. Drops texture-displacement paint (no remap) via the standard
`save_painting()`/`set_mesh()`/`restore_painting()` dance; the other four channels are remapped.
**Adaptive (`subdivide_mesh_adaptive()`)** - refine **only the painted area**, by **Rivara longest-edge
bisection**, which is *conformal by construction*. Only **terminal** edges are ever bisected - an edge
that is the longest edge of *every* triangle sharing it - which splits both those triangles along one
shared midpoint at once, so a hanging node is never created. The edge to split for a triangle that wants
refining is found by **longest-edge propagation (LEPP)**: walk to the longest edge of ever-longer-edged
neighbours until a terminal one is reached, and bisect that. Edge length strictly increases along the
path (ties broken by mesh-vertex key, which both sides of an edge compute identically), so the walk
cannot cycle, and Rivara's result is that repeating it refines the original triangle in a bounded number
of bisections. The transition triangles it pulls in just outside the painted patch are the graded band
that makes the size change conformal.
The win: a small decal on a big model no longer quadruples the *whole* model's triangle count.
**Run to completion, worst-first, against a triangle budget.** The refinement loop is not a fixed number
of sweeps: it holds every triangle that is over its criteria in a max-heap keyed by *how many times over*
it is, pops the worst, walks its LEPP, bisects, and re-scores. Edge adjacency (`nb[e]`, the triangle
across each edge) is built **once** and maintained incrementally through each bisection, so the cost
scales with the refined region rather than with the whole model. `max_triangles` is the only bound;
stopping on it leaves a perfectly valid, still-conformal mesh that spent its budget on the largest
errors. A fixed sweep count instead spends itself grading the *coarse surroundings* - whose edges are
the longest, so they win every terminal-edge contest - and never reaches the painted patch.
**It carries the paint forward**, which is what makes it usable (uniform subdivide drops paint). Because
the refinement is *driven by* the paint, the remap is trivial: `subdivide_mesh_adaptive()` fills an
`out_source[new_tri] = input_tri` map (children inherit their parent), and the gizmo rebuilds each
layer's mask on the new mesh - a new triangle is painted iff its source was fully painted in that
layer. `collect_paint_region()` derives both:
- the union refine-region: **exactly** the original triangles the brush touched, read straight off
`TriangleSplittingData::triangles_to_split` (`serialize()` records an entry per original triangle that
is either split - i.e. partially painted, the patch boundary - or carries a non-default state). No
dilation: marking every triangle that shares a *vertex* with the patch drags in a whole fan of huge
unpainted neighbours and refines *those* down to the resolution floor, since the height field the
detail test samples is not restricted to the painted area. The conformal closure already grades the
size change outward on its own.
- the per-layer fully-painted-triangle sets (a `get_facets_strict(ENFORCER)` sub-triangle with all three
*original* vertex indices == a whole, fully-painted original triangle; a partial stroke's sub-triangles
always carry a split vertex).
The other four channels ride the normal `restore_painting()` remap.
Both modes share the gizmo's Preview/Apply/Done flow; the **"Only painted area (adaptive)"** checkbox
picks the mode, and the adaptive preview follows the paint live (`rebuild_preview()` refreshes the
wireframe while the subdivide preview is open in adaptive mode). The panel shows the previewed triangle
count.
**Feature-adaptive (follow texture detail).** A sub-mode of adaptive (the **"Follow texture detail"**
checkbox) that puts triangles where the *displaced surface actually bends*, not evenly. A flat region or
a linear **ramp** needs no extra vertices (linear interpolation is exact for a ramp); what needs them is
**curvature** - the *second* derivative, not the gradient. So the extra predicate is a **chord-error**
test: sample the combined displacement at the triangle's three edge midpoints *and its centroid*
(sampling the interior is what catches a hill sitting inside a triangle, the blind spot of an edge-only
test) and take the largest departure from the flat triangle's barycentric interpolation. Refine while
that exceeds `chord_tolerance_mm` ("Detail (mm)"). Zero chord error on a ramp => untouched; high on a
hill/ridge/noise => refined until captured. Same conformal machinery, so still crack-free. The
per-triangle error is cached and recomputed only for the children of a split.
Four knobs bracket it, and all four matter:
- **"Max edge (mm)"** (`target_edge_length_mm`) is a **baseline that applies in feature mode too**.
Without it the chord test aliases: a big triangle over a fine pattern can sample four points that all
land at similar heights, report no error, and stall before refinement ever starts. The baseline
guarantees a sampling density fine enough for the curvature test to see the texture at all.
- **"Detail (mm)"** is the chord tolerance above.
- **"Min edge (mm)"** is a hard floor under both, and is what guarantees termination across a sharp
texture *step*, where the error never falls however fine the mesh gets.
- **"Added triangles (k)"** is the budget, passed as `max_triangles` (the model's own triangle count plus
the slider, so the control still means something on an already-dense model).
The height field is `make_combined_displacement_sampler()` - it mirrors `build_texture_displacement()`'s
per-layer setup (decode, patch centroid, cylinder axis, blend order, "lowest layer folds additively")
but evaluated per point. Two deliberate simplifications, both erring toward *more* detail (safe -
over-refinement is never a crack): every sampleable layer is sampled at every point (no per-point paint
test), and edge-smoothing falloff is ignored. The first is *why* the refine region must not be dilated -
outside the paint the sampler still reports full relief. **LSCM layers are skipped** (no per-point UV); a
purely LSCM stack yields a null sampler and the code falls back to the length baseline alone. Per-vertex
heights are sampled lazily, so a small patch on a huge model never pays for the rest of it.
### Fast preview (GPU-only, no CPU meshing)
`resources/shaders/{110,140}/texture_displacement_shaded.{vs,fs}`, registered as
`"texture_displacement_shaded"`. Shades the *displaced* surface without moving geometry - active-layer
only, selected from the View row, and the default when the gizmo opens (`m_use_shaded_preview = true`).
Vertex format is `GLModel::Geometry::EVertexLayout::P3N3T2`: `normal.x` carries the per-vertex paint
weight (0/1), `normal.y` flags the UV island currently being dragged, and `tex_coord` carries a
precomputed texture UV, so it can use `GLModel` normally instead of a hand-rolled VBO/VAO manager.
The mesh is **flat** (vertices not shared between triangles): every corner of a painted triangle gets
weight 1, every corner of an unpainted one weight 0. A coarse mesh needs that - one painted face of a raw
cube has no strictly-interior vertex, so per-vertex weighting would either bleed onto the neighbours or
vanish outright. Duplicating vertices costs no shading quality here because the shader takes its surface
normal from screen-space derivatives of position, not from a per-vertex normal.
**Both preview meshes work in the patch's vertex space, not the mesh's.** Those agree only until a
*brush* stroke splits a triangle: `get_facets_strict()` then appends the split vertices, so the patch
array is longer. `rebuild_shaded_preview_mesh()` and `rebuild_uvcheck_mesh()` therefore index
`patch.vertices` throughout. The weight buffer is rebuilt at the same cadence as the true-displacement
preview (stroke-end/slider-release) but from the **live** `TriangleSelector` state, not the flushed model
facets, so it does not lag by a full model round-trip.
The perturbed normal is the analytic one for a height field `H = +/-depth_mm - h(uv)` displaced along
`N` over any orthonormal surface tangent pair `T`/`B`:
N' = normalize(N - (dH/da)-T - (dH/db)-B), a = dot(p,T), b = dot(p,B)
The two slopes have to be genuine **mm-per-mm** derivatives for the preview's apparent depth to match
the bake's.
**Two projection paths (`use_vertex_uv` uniform):**
- **Triplanar (`use_vertex_uv = 0`)** - `uv` and the `T`/`B` axes are both derived in-shader from
the dominant normal component, mirroring `project_planar()`/`apply_uv_transform()`, and the slope is
formed analytically. `T`/`B` are the projection's axis-aligned pair, exact only when the face is
axis-aligned; the shader drops the along-normal component to keep the gradient in the surface. Here
one `uv` unit is exactly `tiling_scale` mm, so the `1/tiling_scale` gradient factor is right.
- **Precomputed UV (`use_vertex_uv = 1`, used for LSCM and ViewProjected)** - `uv` comes per-vertex from
the CPU (`compute_layer_vertex_uvs()`, so island placement + tiling/rotation/offset are already folded
in), and the perturbed normal is built with **Mikkelsen's method** ("Bump Mapping Unparametrized
Surfaces on the GPU"): the surface gradient taken directly from the screen-space derivatives of the
*sampled height* and position. **This makes no uv->mm scale assumption**, which is essential, because an
LSCM map is **conformal, not isometric**: it is globally area-scaled but the *local* mm-per-uv varies
across the chart, so a single global `1/tiling_scale` factor gets the apparent depth wrong. `dFdx(h)`
captures the true on-screen rate of change however the chart is stretched. This path is also what makes
the fast preview follow the UV editor: move an island and its uv - hence its shading - moves with it
(the mesh rebuilds on drag-end, `on_island_edited(finished)` -> `rebuild_preview()` ->
`rebuild_shaded_preview_mesh()`). The branch is uniform and the paint weight gates by multiply, so the
texture derivatives stay well defined. A triangle straddling a seam has a discontinuous uv -> the
`det~0` guard skips it (a localised preview-only artifact, never in the bake).
**Parallax (triplanar path).** Perturbing the shading normal alone welds the pattern to the base surface:
it does not slide as the camera orbits, and does not get deeper as `depth_mm` grows. The triplanar path
therefore shades at the point the *displaced* surface would show at this pixel, found by **ray marching**
(parallax occlusion mapping). A point at ray parameter `s`, i.e. `P + V-s` (`P` the base point, `V` the
unit direction to the eye), sits at height `s-dot(V,n)` above the undisplaced surface. The displaced
surface lives in a shell between the extreme values of `amp-(h - midlevel)` - taken from both ends of
`h in [0,1]`, so it holds for an inverted layer and a raised midlevel too, where the surface sits *below*
the undisplaced one. The march starts at the top of that shell, where the ray is outside the surface by
construction, and steps inward until the ray height drops below the sampled height. That crossing *is*
the visible point.
Solving `Q = P + V-(H(Q)/dot(V,n))` by fixed-point iteration instead is geometrically exact but the
divisor goes to zero edge-on; the sample then lands a large fraction of a tile away and the iteration
oscillates, which reads as a second, flat copy of the pattern ghosted over the real one. Clamping the
step to one tile does not help - a tile-sized shift lands on the neighbouring tile, the same pattern
again. Offset limiting (stepping along the tangential part of `V`) is stable but understates parallax
enough that the relief still flattens as soon as the camera tilts. Marching has neither problem.
The hit is interpolated between the last two samples, which keeps `PARALLAX_STEPS` (24) affordable, and
the whole march is skipped when sweeping the shell would move the sample point less than half a texel -
the head-on case, so the common view pays almost nothing. The 140 variant samples with
`textureLod(..., 0.0)` inside the loop, since implicit derivatives are undefined in non-uniform control
flow. Two uniforms exist for this: `midlevel` (parallax needs the real height, not just its derivative)
and `eye_model_pos` (the camera in the volume's local frame).
Parallax cannot change the model's silhouette or cast shadows; the View row's Normal mode is one click
away for that. The LSCM path stays plain Mikkelsen normal perturbation - it has no closed-form uv, so there is no cheap
way to re-project a marched position. One further approximation: the GPU sampler's wrap mode stands in
for `tile_enabled`/`tile_method`, so with tiling *off* the GPU repeats where the CPU returns 0 outside
`[0,1)`.
### On-canvas "Adjust Texture" gizmo
A per-active-layer toggle ("Adjust placement") that disables painting and shows a flat pan panel (free
2D drag on both axes) plus two arrows along the patch's own U/V axes (constrained single-axis drag).
Anchored to the painted patch's centroid/average-normal (`compute_layer_paint_anchor()`). Hit-testing is
screen-space distance/point-to-segment, not real 3D ray intersection against the handle geometry - simple
and good enough at this handle size.
### Projection frame overlay (ViewProjected)
`src/slic3r/GUI/TextureProjectorFrame.hpp/.cpp` - a semi-transparent, resizable `wxFrame` the user
drags **over the 3D view**, like a slide projector's gate. Whatever the model shows through it is what
the texture is projected onto, and the window's border becomes the hard edge of the displacement.
Press **Apply projection frame** and the gizmo reads the window's rectangle and commits it.
The window is deliberately **dumb**: it owns no placement state and reports nothing continuously. Its
position and size *are* the placement, read on demand at Apply - which is also when the expensive
visible-facet raycast runs. So dragging it is free and nothing recomputes until asked.
Plain 2D (`wxPaintDC`), not a `wxGLCanvas`: a second GL canvas would have to share the app's one real
`wxGLContext`. It only ever draws a bitmap and a border.
**The projective mapping (`apply_projection_frame()`)**. The frame defines a **screen-space** rectangle,
but the bake samples from a **local-space** position, so the two have to be reconciled.
`view_project_right/up` can only express an *affine* projection - exact under an orthographic camera, but
wrong under perspective, where the near end of a part projects larger than the far end and no pair of
axes reproduces that. So the layer instead stores a full projective map (`view_project_matrix`, row-major
3x4, `uv = (row0-p/row2-p, row1-p/row2-p)`), built like this:
- `K = projection - view - (instance - volume)`, i.e. local -> clip, the same product the renderer uses.
Note `Camera::get_projection_matrix()` is typed `Transform3d` (nominally affine) but its perspective
form explicitly writes a `(0, 0, -1, 0)` bottom row into the underlying 4x4, so `clip.w = -z_eye` is
genuinely carried. The build therefore multiplies **`.matrix()` products** (plain `Matrix4d`), never
`Transform3d` products, which would not compose that row correctly.
- Window coordinates follow `igl::project`'s convention (as `CameraUtils::project` does), with y
measured downward. Writing `uv = (win - rect_origin) / rect_size` makes u and v affine in
`ndc = clip.xyz / clip.w`; multiplying through by `clip.w` leaves a plain linear combination of `K`'s
rows, which is exactly the 3x4 matrix - the perspective divide survives intact.
- `w > 0` is checked rather than divided blindly. A point behind the projector has `w < 0` and divides
to a plausible-looking but **mirrored** uv - the classic way a projected decal reappears on the back
of a model. `project_uv_projective()` returns false there and the caller treats it as no height.
The map already includes placement, so `apply_uv_transform()` is **not** applied on top of it - the
window's own position and size are the placement, and the tiling/rotation/offset sliders would shove
the result off the frame the user just aligned. A "Clear" button drops back to the affine path where
those controls mean something again.
Apply also sets `tile_enabled = false`, so `DecodedHeightTexture::sample()` returns 0 outside `[0,1)`
and the border is a hard edge rather than the first seam of an endless repeat, and repaints the layer
via `select_visible_faces(&matrix)` - the frame's uv square clips the selection, which both matches the
paint to the border and keeps the ray queries proportional to the framed area instead of the model.
Owned by the gizmo and **destroyed** (not just hidden) in `on_shutdown()`. Closing it only hides it, so
reopening keeps it where it was left.
### UV Editor pane
`UVEditorCanvas` (`src/slic3r/GUI/UVEditorCanvas.hpp/.cpp`) - a standalone `wxGLCanvas` rendering the
flattened LSCM islands (per-island wireframe + outline + fill) over the height texture (background
quad tiled across the whole unwrap), with mouse pan/zoom. It is wrapped in a **`UVEditorPanel`**
(same file) that adds a button row (Frame / Snap / Avg scale / Cut / Join / Unjoin) and a status line
along the bottom naming the current gesture and the shortcuts in play. The *panel* is what is
registered as a `wxAuiPaneInfo` pane on `Plater`'s `m_aui_mgr`; `Plater::show_uv_editor(bool)`
shows/hides it (deferred via `CallAfter`, since the gizmo calls it mid-3D-frame), and
`get_uv_editor_canvas()` returns the inner canvas the gizmo talks to.
Deliberately **shares the app's one real `wxGLContext`** (`wxGetApp().init_glcontext(*this)`, the
same call `View3D`/`Preview`/`AssembleView` make) rather than creating an independent context like
`SkipPartCanvas` does elsewhere in this codebase - this is what lets it reuse the already-registered
`"flat"`/`"flat_texture"` shaders and `GLModel` as-is, instead of needing its own shader
compilation/VBO management.
**Geometry is uploaded once, in the unwrap's own (raw, mm) coordinates**, one `GLModel` set per island;
each island is then drawn through its own 2x3 affine (`island_transform_matrix()` composed with the
layer's tiling/rotation/offset) passed as the `flat` shader's `view_model_matrix`. A drag updates one
matrix per island and touches no vertex buffer - `on_island_edited(!finished)` calls only
`set_island_transforms()`, and the full `set_islands()` rebuild happens solely when the unwrap itself
changes (`unwrap_changed` in `update_uv_editor()`).
**Gestures** (canvas-owned, reported to the gizmo as incremental deltas via `IslandEditFn`): left-drag
= move, right-drag or **R** = rotate (hold **Shift** to snap to 15° steps - quantised on the
*cumulative* rotation, not each delta, so it doesn't judder, and accumulated incrementally so it
survives crossing +/-180°), **S** = scale (R/S modal, click/Enter to confirm, Esc to cancel), wheel =
zoom about the cursor, middle-drag = pan, **Home**/**F** = frame all. Scale writes
`TextureIsland::scale`; "Avg scale" (`average_island_scales()`) sets every island to the mean, so
one island scaled by hand can be matched back to its neighbours' texel density. **Snap** (canvas-owned
`m_snap_enabled`, toggled from the toolbar) sticks a dragged island's nearest boundary vertex onto a
neighbouring island's at drag-*end* only - a magnet that re-applies mid-drag is very hard to pull out
of. Toolbar commands the canvas can't service itself (Avg scale, Cut, Join, Unjoin) are forwarded to the
gizmo via `CommandFn`; view-only ones (Frame, Snap) it handles directly.
## File map
**libslic3r (core, no GUI dependency):**
- `src/libslic3r/TextureDisplacement.hpp/.cpp` - data model, bake algorithm, projection methods,
tiling, subdivision (uniform + adaptive longest-edge bisection), post-process smoothing
(`smooth_mesh_vertices()`), and `TextureDisplacementOptions` (the whole-stack settings). See doc
comments throughout, they're kept accurate and up to date.
- `src/libslic3r/MeshBoolean.hpp/.cpp` - `parameterize_lscm()` and `remesh_isotropic()` in the `cgal`
sub-namespace, reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already
there for mesh boolean ops. CGAL includes: `Polygon_mesh_processing/border.h`,
`Polygon_mesh_processing/connected_components.h`, `Surface_mesh_parameterization/{Error_code,
LSCM_parameterizer_3, parameterize}.h`. No new dependency - CGAL 5.6.3 is already vendored and the
`Surface_mesh_parameterization` package headers were already present.
- `src/libslic3r/Model.hpp/.cpp` - the 8 named `FacetsAnnotation` fields + accessor,
`texture_displacement_layers`, `texture_displacement_options`, and all the mirrored touch points
(see Data model above).
**GUI:**
- `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp/.cpp` - the gizmo and its whole panel.
- `src/slic3r/GUI/TextureLibrary.hpp/.cpp` - scans the shipped + user texture folders, imports an
arbitrary image into the user folder (converting it to the 8-bit grayscale PNG libslic3r decodes),
and loads a library file's bytes for a layer. The image->grayscale-PNG conversion lives here, on the
GUI side, because libslic3r has no image toolkit; both the import path and the "pick a shipped
texture" path go through the same one function.
- `resources/textures/displacement/*.png` - the 10 shipped height maps (Bricks, Grid, Hexagons,
Knurl, Noise, Quilt, Studs, Waves, Weave, Wood Grain). All 512x512 8-bit grayscale and **seamless**
(each is periodic over the full image in both axes, so tiling shows no seam). Generated
procedurally; the whole `resources/` tree is installed recursively by CMake, so a new folder under
it ships with no build-system change.
- `src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp/.cpp` - background bake commit.
- `src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp/.cpp` - background preview compute
(mirrors the bake job's shape but commits nothing to the Model).
- `src/slic3r/GUI/TextureProjectorFrame.hpp/.cpp` - the semi-transparent projection-frame overlay for
ViewProjected layers (plain 2D `wxPaintDC`, no GL context - see its section above).
- `src/slic3r/GUI/UVEditorCanvas.hpp/.cpp` - the 2D UV unwrap viewer widget.
- `src/slic3r/GUI/Plater.hpp/.cpp` - `uv_editor_canvas` member, AUI pane registration,
`get_uv_editor_canvas()`/`show_uv_editor()`.
- `src/slic3r/GUI/GLShadersManager.cpp` - registers `"texture_displacement_shaded"`.
- `resources/shaders/{110,140}/texture_displacement_shaded.{vs,fs}` - the fast-preview shader.
- `src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp` - `PainterGizmoType::TEXTURE_DISPLACEMENT`.
- `src/slic3r/GUI/Gizmos/GLGizmosManager.hpp/.cpp` - `EType::TextureDisplacement` registration.
## Tests
`tests/libslic3r/test_texture_displacement.cpp`. Covers `decode_height_texture` round-trip, empty-layer
no-op, full-cube uniform displacement, a second layer over the same area contributing, all four blend
modes (table-driven), the lowest layer ignoring its blend mode, border displace/pin, post-process
smoothing and its mask guarantees, and adaptive subdivision: conformality (`every_edge_used_twice` on a
partially-refined cube - an exact crack detector for a closed mesh), the target edge length actually
being reached, the triangle budget capping the result without opening a crack, curvature-driven
refinement (a Gaussian hill refines at its centre, a linear ramp adds nothing), and the max-edge
baseline.
`BUILD_TESTS` is `OFF` in the checked-in build cache; flip it on to run them:
cmake -S . -B build -DBUILD_TESTS=ON
cmake --build build --config Release --target libslic3r_tests -- -m
./build/tests/libslic3r/Release/libslic3r_tests.exe "[TextureDisplacement]" --order rand
+326
View File
@@ -0,0 +1,326 @@
# Texture Displacement - Feature & Controls Guide
Texture Displacement is a paint-style gizmo that stamps height-map textures onto a model's surface
and turns them into real relief - engraved or embossed detail - either as a live preview or baked
into actual mesh geometry. You paint where the texture applies, stack multiple textures as blended
layers, choose how each is projected onto the surface, and (for the unwrap projection) lay the result
out by hand in a dedicated 2D **UV Editor** pane.
This document describes every feature and control. For the internal architecture and algorithms, see
`TEXTURE_DISPLACEMENT.md`.
---
## Table of contents
1. [Quick start](#quick-start)
2. [Entering the tool](#entering-the-tool)
3. [Selection modes](#selection-modes)
4. [View modes](#view-modes)
5. [Auto update](#auto-update)
6. [Texture layers](#texture-layers)
7. [Per-layer settings](#per-layer-settings)
8. [Projection methods](#projection-methods)
9. [The UV Editor](#the-uv-editor)
10. [Seams](#seams)
11. [Adjust placement (on-model)](#adjust-placement-on-model)
12. [Preparing the mesh: Subdivide & Remesh](#preparing-the-mesh-subdivide--remesh)
13. [Baking & resetting](#baking--resetting)
14. [Controls reference](#controls-reference)
15. [Tips & limitations](#tips--limitations)
---
## Quick start
1. Select an object and open the **Texture displacement** gizmo from the left toolbar.
2. A texture layer is added automatically. Pick a texture from the layer's picker, or import your own.
3. **Paint** the area you want the texture to affect (or press **Select whole model**).
4. The relief appears live on the model. Tune **Depth**, **Tile size**, **Rotation**, etc.
5. If the model is low-poly, use **Subdivide** or **Remesh** so there are enough vertices for detail.
6. Press **Bake** to convert the preview into real geometry, or leave it as a live preview.
> The tool only ever affects the **painted** area. Everything you don't paint keeps its original
> surface, and bake blends the relief seamlessly into it.
---
## Entering the tool
The gizmo lives on the left gizmo toolbar (icon: `toolbar_texture_displacement.svg`). Its settings
panel opens beside the toolbar. You can **Dock panel / Undock panel** (top of the panel) to pin it or
float it freely over the 3D view, and **Close** at the bottom exits the gizmo.
When you first open the tool on a never-textured object it starts with **one texture layer already
added**, so you can paint straight away.
---
## Selection modes
Choose *how* you paint. All three write into the **active layer's** mask.
| Mode | What it does |
|------|--------------|
| **Brush** | Free-hand painting with a round brush. Shows a **Brush size** slider and a **Circle / Sphere** choice (circle = surface disc, sphere = 3D ball that also paints around curves). |
| **Face** | Click a single triangle to paint it. |
| **Connected area** | Click to flood-fill a region; the **Angle threshold** slider limits how far the fill spreads across changes in surface angle. |
- **Select whole model** - marks the entire model as painted for the active layer, instead of
brushing it by hand.
---
## View modes
A row of icon buttons labelled **View** controls how the painted area is shown. The first four are a
radio group; **Wireframe** is an independent toggle. Hover any icon for its tooltip.
| View | Meaning |
|------|---------|
| **Normal** | The true displaced geometry - exactly what **Bake** produces. Rebuilt in the background. |
| **Fast** | A GPU shaded approximation of the *active layer only*. No real geometry movement - quick to update, not exact. Best while tuning or dragging islands. |
| **Checker** | A test grid painted over the unwrap so you can see stretching (squares stay square where the map isn't distorted). |
| **Distortion** | A blue->green->red heatmap of how much each area is compressed or stretched in UV space. Needs the **Unwrap (LSCM)** projection. |
| **Wireframe** | Overlays the mesh edges (white). Independent of the view above; in **Normal** view it sits on the displaced surface. |
---
## Auto update
**Auto update** (on by default) rebuilds the true displaced geometry as soon as *anything* changes -
painting, swapping textures, moving sliders. Turn it off on very heavy models to only rebuild when you
release a slider (painting still updates on stroke end).
---
## Texture layers
You can stack up to **8** texture layers. Each has its own independent paint mask, its own texture,
and its own parameters, and they combine in slot order like layers in an image editor.
- **Add a layer** - the **+ icon** to the right of the *Texture layers* heading (reuses the tool icon
for now).
- **Remove** - the button on each layer's header row.
- **Active layer** - click a layer's header (or anywhere in its block) to make it active. The active
layer is the one you paint into and the one whose block is tinted. Only one layer is active at a time.
- **Erase all** - clears the active layer's paint.
Each layer shows a texture **picker** (large preview + name). Open it to choose from the shipped
library or import your own image (any png/jpg/bmp; it's converted to an 8-bit grayscale height map and
copied into your user texture folder so app updates can't overwrite it).
---
## Per-layer settings
| Control | Range / options | What it does |
|---------|-----------------|--------------|
| **Depth (mm)** | 0.01-10 (log) | Maximum displacement along the surface normal. |
| **Tile size (mm)** | 0.2-200 (log) | Physical size of one texture tile on the surface. |
| **Rotation** | 0-360° | Rotates the texture on the surface. |
| **Midlevel** | 0-10 | The grey level that means "don't move". At 0 the texture only pushes outward; raise it and darker texels cut *inward* (one map both embosses and engraves). 0.5 makes mid-grey neutral. |
| **Smoothing** | 0-1 | Blurs the height texture before it displaces - rounds hard edges and removes speckle without needing a softer source image. |
| **Edge smoothing** | checkbox + **Edge amount** 0-1 | Fades the relief to flat toward the *edge of the painted area*, so it blends into the surrounding surface. A small amount softens only a thin band at the very edge; the maximum flattens the whole painted face. |
| **Invert** | checkbox | Flips the height map (peaks become valleys). |
| **Blend** | Add / Subtract / Multiply / Divide | How this layer combines with the layers **below** it where they overlap. Add/Subtract pile relief on or carve it away; Multiply/Divide scale the relief underneath (a mask). The lowest painted layer is the **Base** and always behaves additively. |
| **Tile** | checkbox + **Repeat / Mirrored repeat** | When off, the texture is placed once (a decal) instead of repeating. Mirrored repeat flips every other tile to hide seams. |
| **Projection** | see below | How the texture is mapped onto the painted surface. |
> **Midlevel warning:** cutting inward can fold the surface through itself in sharp concave corners or
> thin walls. Keep Depth small relative to the feature you're cutting into; the panel warns when a deep
> inward setting is risky.
---
## Projection methods
How the 2D texture is wrapped onto the 3D painted area.
| Method | Best for | Notes |
|--------|----------|-------|
| **Triplanar (blended)** | Patches wrapping around edges | Projects from all three axes at once and blends, so there's no seam across a sharp edge. |
| **Cylindrical** | Round, tube-like selections | Wraps the texture around the patch's own centre/axis. |
| **Spherical** | Ball-like selections | Longitude/latitude wrap around the patch centre. |
| **Unwrap (LSCM)** | Flat, controlled layout | A real conformal unwrap. Cuts the area into pieces at sharp edges (see **Seam angle**), flattens each, and lets you lay them out by hand in the **UV Editor**. Unlocks Checker/Distortion, seams, and island editing. |
| **From view** | Decals / slide-projector look | Projects straight onto the surface from the current camera direction. Use **Capture current view** to re-lay it from wherever you're looking. |
### LSCM-only controls
These appear when a layer uses **Unwrap (LSCM)**:
- **Seam angle** (5-90°) - edges sharper than this are cut so each piece lies flat. Lower cuts more
(less stretching, more seams); raise to keep more in one piece. A box's 90° corners are cut by
default. *Ignored once you've marked any seam by hand* (your seams then define the pieces).
- **Connect islands** (on by default) - lays the unwrap out as a **connected net**: pieces that share
an edge are unfolded next to each other (a cube becomes a joined net instead of six loose squares).
They stay separate islands, so you can still move any of them by hand. Turn off for the classic
packed-grid layout.
- **Open UV editor** - shows the flattened unwrap in a side pane (see below). Opens *only* when you
turn this on - it never pops up on its own.
- **Mark seams** / **Path** / **Clear seams** - see [Seams](#seams).
- An **Unwrap: N islands, F faces, V verts** read-out tells you what the unwrap actually produced.
---
## The UV Editor
A dockable 2D pane (enable **Open UV editor** on an LSCM layer) showing the flattened unwrap over the
height texture. Islands are the flattened pieces; you can rearrange them freely - nothing re-packs them
behind your back. Moving an island updates the model **live** (in Fast view it tracks the cursor
smoothly, via a shader uniform - no rebuild until you release).
### Navigation
| Action | Control |
|--------|---------|
| Pan | Middle-drag |
| Zoom | Mouse wheel (zooms about the cursor) |
| Frame everything | **Home** or **F**, or the **Frame** toolbar button |
### Editing an island
| Action | Control |
|--------|---------|
| Select | Left-click an island |
| Move | Left-drag |
| Rotate | Right-drag, or press **R** then move the mouse (click/Enter to confirm, Esc to cancel) |
| Rotate snapped | Hold **Shift** while rotating - snaps to **global** 15° marks (0/15/30...). A protractor dial with tick marks and the current angle is shown. |
| Scale | Press **S** then move the mouse (click/Enter to confirm, Esc to cancel) |
| Undo / Redo | **Ctrl+Z** / **Ctrl+Shift+Z** or **Ctrl+Y** |
The **selected** island gets a bold light-green outline and a brighter wireframe; unselected islands
are a translucent light-green wash. The texture underneath repeats exactly as it will when baked.
A **status line** along the bottom always names the current gesture and the shortcuts in play.
### Toolbar
| Button | Action |
|--------|--------|
| **Frame** | Frame all islands (same as Home). |
| **Snap** | Toggle magnetic snapping - a dragged island sticks its boundary to a neighbour's when they come close. |
| **Avg scale** | Give every island the same texel density (Blender's "Average Islands Scale"). |
| **Cut** | Split the selected island across its long axis (useful for very long islands). |
| **Join** | Unfold the selected island onto its nearest neighbour along their shared edge - keeps both as separate islands with their own borders. |
| **Unjoin** | Send the selected island back to its own packed position. |
> **Checker / Distortion in the UV editor:** selecting those View modes also colours the UV pane - a
> checker background, or a per-island distortion heatmap - so you can judge stretch in 2D as well as
> on the model.
---
## Seams
Seams are edges the unwrap is forced to cut along, on top of whatever the Seam angle cuts - the
Blender "mark seam" workflow. They let you control exactly where the unwrap splits.
Enable **Mark seams** on an active LSCM layer, then:
- **Click an edge** on the model to mark it (it turns **red**); click a red edge again to unmark it.
The edge under the cursor is highlighted **yellow** so you can see what a click will toggle.
- **Path mode** (the **Path** checkbox) - for dense meshes where clicking each edge is tedious: click a
start point, then an end point, and the whole **shortest path** between them is seamed at once. It
chains (each click extends from the last point); the start vertex is shown in **green**.
- **Ctrl+drag** rotates/pans the camera while in seam mode.
- **Clear seams** removes them all.
Once any seam is marked, the automatic Seam-angle cutting is disabled so *your* seams define the
islands - pieces you leave un-seamed merge together.
---
## Adjust placement (on-model)
**Adjust placement** (on an active layer) lets you position the texture by dragging a handle on the
model instead of nudging the Rotation/offset numbers. The handle is a flat panel in the patch's
tangent plane (drag anywhere on it to move freely) plus U/V arrows for single-axis nudges. It's
anchored to the painted patch, so paint something first.
---
## Preparing the mesh: Subdivide & Remesh
Displacement can only move vertices that exist, so a coarse model needs more of them first.
### Subdivide
Splits every triangle into four, **1-5 times** (each step roughly quadruples the triangle count).
- **Subdivide steps** (1-5) - how many times to split.
- **Preview subdivision** - shows the result as a **cyan wireframe** without changing the model.
- **Apply** - commits the subdivision to the geometry.
- **Done** - ends the preview and leaves the model as it is.
### Remesh
Rebuilds the whole model with triangles close to a target edge length - evens out a mesh with wildly
varying triangle sizes (CGAL isotropic remeshing).
- **Target edge (mm)** - desired triangle edge length (seeded to the model's current average).
- **Remesh** - splits the big triangles and merges the small ones to that size.
> Both Subdivide-Apply and Remesh **replace the geometry** and clear any *not-yet-baked* paint on it
> (already-baked relief is kept). If you had the mesh **Wireframe** on before, it stays on afterward.
---
## Baking & resetting
- **Bake** - converts the current preview into real, permanent mesh geometry, restricted to the
painted area. Runs in the background; the button shows *Baking...* while it works.
- **Erase all** - clears the active layer's paint.
Baking is the exact same algorithm as the **Normal** preview, so what you see is what you get.
---
## Controls reference
### Mouse - 3D view (while painting)
| Input | Action |
|-------|--------|
| Left-drag | Paint the active layer |
| Ctrl + drag | Rotate / pan the camera (works in seam mode too) |
| Wheel | Zoom |
### Mouse & keys - UV Editor
| Input | Action |
|-------|--------|
| Left-click | Select island |
| Left-drag | Move island |
| Right-drag | Rotate island |
| **R** / **S** | Modal rotate / scale (mouse drives it, click or Enter confirms, Esc cancels) |
| **Shift** (while rotating) | Snap to global 15° marks |
| Middle-drag | Pan |
| Wheel | Zoom about cursor |
| **Home** / **F** | Frame all islands |
| **Ctrl+Z** / **Ctrl+Shift+Z** / **Ctrl+Y** | Undo / redo |
### Seam mode
| Input | Action |
|-------|--------|
| Click edge | Mark / unmark a seam (yellow = hover, red = marked) |
| Click (Path mode) | Set start, then seam the shortest path to the next click |
| Ctrl + drag | Rotate / pan camera |
---
## Tips & limitations
- **Paint first, then bake.** The preview is free to explore; only Bake changes the real mesh.
- **Not enough detail?** Subdivide or Remesh before painting fine textures.
- **Inward cuts** (high Midlevel + big Depth) can self-intersect on thin walls or sharp concave
corners - keep Depth modest there.
- **Fast vs Normal:** Fast preview only shades the relief and shows only the active layer; use it for quick
tuning and smooth UV dragging, but trust **Normal**/**Bake** for the exact result.
- **Topology changes drop unbaked paint.** Subdivide-Apply, Remesh, and Simplify replace the mesh, and
texture-displacement paint isn't remapped across that change (already-baked relief is unaffected).
- **Island placements** are tied to the current unwrap. Re-painting or changing the Seam angle can
re-segment the charts and renumber them, so a re-unwrap re-lays the connected net and discards
hand placements made before it.
- **Connect islands** is on by default; turn it off (per layer) for the classic packed-grid layout, or
if an unfold looks wrong on an unusual mesh.
+18 -6
View File
@@ -164,7 +164,7 @@ if (NOT _is_multi AND NOT CMAKE_BUILD_TYPE)
endif () endif ()
function(orcaslicer_add_cmake_project projectname) function(orcaslicer_add_cmake_project projectname)
cmake_parse_arguments(P_ARGS "FORWARD_CONFIG" "INSTALL_DIR;BUILD_COMMAND;INSTALL_COMMAND" "CMAKE_ARGS" ${ARGN}) cmake_parse_arguments(P_ARGS "FORWARD_CONFIG" "INSTALL_DIR;BUILD_COMMAND;INSTALL_COMMAND;SOURCE_DIR" "CMAKE_ARGS" ${ARGN})
# MSVC is true for clang-cl as well, so the sub-build toolchain has to key on the # MSVC is true for clang-cl as well, so the sub-build toolchain has to key on the
# generator. A non-Visual-Studio superbuild passes its own generator down, and with # generator. A non-Visual-Studio superbuild passes its own generator down, and with
@@ -210,12 +210,18 @@ function(orcaslicer_add_cmake_project projectname)
set(_build_j "-j${NPROC}") set(_build_j "-j${NPROC}")
endif () endif ()
set(_source_dir_arg "")
if (P_ARGS_SOURCE_DIR)
set(_source_dir_arg SOURCE_DIR ${P_ARGS_SOURCE_DIR})
endif ()
if (NOT IS_CROSS_COMPILE OR NOT APPLE) if (NOT IS_CROSS_COMPILE OR NOT APPLE)
ExternalProject_Add( ExternalProject_Add(
dep_${projectname} dep_${projectname}
EXCLUDE_FROM_ALL ON EXCLUDE_FROM_ALL ON
INSTALL_DIR ${DESTDIR} INSTALL_DIR ${DESTDIR}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname} DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
${_source_dir_arg}
${_gen} ${_gen}
CMAKE_ARGS CMAKE_ARGS
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DCMAKE_POLICY_VERSION_MINIMUM=3.5
@@ -249,12 +255,14 @@ if (NOT IS_CROSS_COMPILE OR NOT APPLE)
# note for future devs: shared libs may actually create a size reduction # note for future devs: shared libs may actually create a size reduction
# but orcaslicer_deps tends to get really funny regarding linking after that (notably boost) # but orcaslicer_deps tends to get really funny regarding linking after that (notably boost)
# so, as much as I would like to use that, it's not happening # so, as much as I would like to use that, it's not happening
if (NOT P_ARGS_SOURCE_DIR)
ExternalProject_Add_Step(dep_${projectname} free_download_space ExternalProject_Add_Step(dep_${projectname} free_download_space
DEPENDEES download # do after download DEPENDEES download # do after download
COMMENT "Freeing Space: Removing source archive" COMMENT "Freeing Space: Removing source archive"
WORKING_DIRECTORY ${DEP_DOWNLOAD_DIR} WORKING_DIRECTORY ${DEP_DOWNLOAD_DIR}
COMMAND ${CMAKE_COMMAND} -E rm -r ${projectname} COMMAND ${CMAKE_COMMAND} -E rm -rf ${projectname}
) )
endif ()
ExternalProject_Add_Step(dep_${projectname} free_build_space ExternalProject_Add_Step(dep_${projectname} free_build_space
DEPENDEES install # do after install DEPENDEES install # do after install
COMMENT "Freeing Space: Removing source and build files" COMMENT "Freeing Space: Removing source and build files"
@@ -268,6 +276,7 @@ else()
EXCLUDE_FROM_ALL ON EXCLUDE_FROM_ALL ON
INSTALL_DIR ${DESTDIR} INSTALL_DIR ${DESTDIR}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname} DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
${_source_dir_arg}
${_gen} ${_gen}
CMAKE_ARGS CMAKE_ARGS
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DCMAKE_POLICY_VERSION_MINIMUM=3.5
@@ -396,10 +405,6 @@ include(libnoise/libnoise.cmake)
include(Draco/Draco.cmake) include(Draco/Draco.cmake)
include(FFMPEG/FFMPEG.cmake)
include(Assimp/Assimp.cmake)
# I *think* 1.1 is used for *just* md5 hashing? # I *think* 1.1 is used for *just* md5 hashing?
# 3.1 has everything in the right place, but the md5 funcs used are deprecated # 3.1 has everything in the right place, but the md5 funcs used are deprecated
# a grep across the repo shows it is used for other things # a grep across the repo shows it is used for other things
@@ -410,6 +415,12 @@ if(NOT OPENSSL_FOUND)
set(OPENSSL_PKG dep_OpenSSL) set(OPENSSL_PKG dep_OpenSSL)
endif() endif()
include(FFMPEG/FFMPEG.cmake)
include(Assimp/Assimp.cmake)
include(DataChannel/DataChannel.cmake)
set(DATACHANNEL_PKG dep_DataChannel)
# we don't want to load a "wrong" openssl when loading curl # we don't want to load a "wrong" openssl when loading curl
# so, just don't even bother # so, just don't even bother
# ...i think this is how it works? change if wrong # ...i think this is how it works? change if wrong
@@ -483,6 +494,7 @@ set(_dep_list
dep_wxInspector dep_wxInspector
dep_FFMPEG dep_FFMPEG
dep_Assimp dep_Assimp
${DATACHANNEL_PKG}
) )
if (MSVC) if (MSVC)
+37
View File
@@ -0,0 +1,37 @@
# libdatachannel is the native ICE/DTLS/SCTP implementation used by the
# GUI WebRTC camera controller. Keep the source revision fixed: the signaling
# protocol is evolving independently of this transport dependency.
#
# It vendors plog, usrsctp and libjuice as git submodules, which a plain
# GitHub tag tarball does not include. The flatpak sandbox has no network
# access during the build, so there the manifest itself clones the repo
# (submodules and all) into the dependency download directory before the
# sandbox closes. ExternalProject_Add is pointed at that existing checkout
# instead of being given its own network-dependent download method.
if (FLATPAK)
set(_datachannel_source
SOURCE_DIR ${DEP_DOWNLOAD_DIR}/DataChannel
)
else()
set(_datachannel_source
GIT_REPOSITORY https://github.com/paullouisageneau/libdatachannel.git
GIT_TAG v0.24.5
GIT_SHALLOW ON
GIT_SUBMODULES_RECURSE ON
)
endif()
orcaslicer_add_cmake_project(DataChannel
DEPENDS ${OPENSSL_PKG}
CMAKE_ARGS
-DNO_EXAMPLES=ON
-DNO_TESTS=ON
-DNO_WEBSOCKET=ON
-DNO_MEDIA=ON
-DUSE_NICE=OFF
-DUSE_SYSTEM_JUICE=OFF
-DUSE_SYSTEM_USRSCTP=OFF
-DOPENSSL_ROOT_DIR:PATH=${DESTDIR}
-DOPENSSL_USE_STATIC_LIBS=ON
${_datachannel_source}
)
+23 -7
View File
@@ -1,14 +1,26 @@
set(_conf_cmd ./configure) set(_conf_cmd ./configure)
set(_ffmpeg_depends)
set(_ffmpeg_configure_command ${_conf_cmd})
if (TARGET dep_OpenSSL)
set(_ffmpeg_depends DEPENDS dep_OpenSSL)
set(_ffmpeg_configure_command
${CMAKE_COMMAND} -E env
"PKG_CONFIG_PATH=${DESTDIR}/lib/pkgconfig:$ENV{PKG_CONFIG_PATH}"
${_conf_cmd}
)
endif()
if (MSVC) if (MSVC)
set(_source_dir "${CMAKE_BINARY_DIR}/dep_FFMPEG-prefix/src/dep_FFMPEG") set(_source_dir "${CMAKE_BINARY_DIR}/dep_FFMPEG-prefix/src/dep_FFMPEG")
set(PREBUILD_URL_arm64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-winarm64-orca-shared-7.0.zip") set(PREBUILD_URL_arm64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-09-18-16-50/ffmpeg-n7.0.3-33-g887d4b4919-winarm64-orca-shared-7.0.zip")
set(PREBUILD_HASH_arm64 "12f4140279f2f8469885e1b5b2e8be9d788882914c21523cacd56989f3548054") set(PREBUILD_HASH_arm64 "da480cbb39680056de824c57ec4dc3bd577b479ebbc310ff1f9dc55cf014b4c1")
set(PREBUILD_URL_x64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-win64-orca-shared-7.0.zip") set(PREBUILD_URL_x64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-09-18-16-50/ffmpeg-n7.0.3-33-g887d4b4919-win64-orca-shared-7.0.zip")
set(PREBUILD_HASH_x64 "e65916020ddb9ef84b2666dfbcbfc9b1d67f69d15b4a66db53754637bf2d498c") set(PREBUILD_HASH_x64 "85da19daf198f5548259d8aabb349db84997a3f6e6886d8d7764114add9c6dae")
ExternalProject_Add(dep_FFMPEG ExternalProject_Add(dep_FFMPEG
${_ffmpeg_depends}
URL ${PREBUILD_URL_${DEPS_ARCH}} URL ${PREBUILD_URL_${DEPS_ARCH}}
URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}} URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
@@ -21,6 +33,8 @@ if (MSVC)
) )
else () else ()
set(_openssl_cmd --enable-openssl)
if (APPLE) if (APPLE)
set(_minos_cmd set(_minos_cmd
"--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}" "--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}"
@@ -52,10 +66,11 @@ else ()
endif() endif()
ExternalProject_Add(dep_FFMPEG ExternalProject_Add(dep_FFMPEG
${_ffmpeg_depends}
URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz
URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
CONFIGURE_COMMAND ${_conf_cmd} CONFIGURE_COMMAND ${_ffmpeg_configure_command}
${_cross_cmd} ${_cross_cmd}
${_pic_cmd} ${_pic_cmd}
${_arch_cmd} ${_arch_cmd}
@@ -63,20 +78,21 @@ else ()
"--prefix=${DESTDIR}" "--prefix=${DESTDIR}"
${_link_cmd} ${_link_cmd}
${_minos_cmd} ${_minos_cmd}
${_openssl_cmd}
--disable-doc --disable-doc
--enable-small --enable-small
--disable-outdevs --disable-outdevs
--disable-filters --disable-filters
--enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test* --enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test*
--disable-protocols --disable-protocols
--enable-protocol=file,fd,pipe,rtp,udp --enable-protocol=file,fd,pipe,http,https,rtp,tcp,udp
--disable-muxers --disable-muxers
--enable-muxer=rtp --enable-muxer=rtp
--disable-encoders --disable-encoders
--disable-decoders --disable-decoders
--enable-decoder=*aac*,h264*,mp3*,mjpeg,rv* --enable-decoder=*aac*,h264*,mp3*,mjpeg,rv*
--disable-demuxers --disable-demuxers
--enable-demuxer=h264,mp3,mov --enable-demuxer=h264,mp3,mov,mpjpeg,rtsp,sdp
--disable-zlib --disable-zlib
--disable-avdevice --disable-avdevice
BUILD_IN_SOURCE ON BUILD_IN_SOURCE ON
+1 -1
View File
@@ -24,7 +24,7 @@ endif()
# On macOS/Linux OCCT links statically, so an unreferenced toolkit costs build time and no # 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 # 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 # 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. # not a number to quote. See docs/HLSD/design-tab.md.
if (IN_GIT_REPO) if (IN_GIT_REPO)
set(OCCT_DIRECTORY_FLAG --directory ${BINARY_DIR_REL}/dep_OCCT-prefix/src/dep_OCCT) set(OCCT_DIRECTORY_FLAG --directory ${BINARY_DIR_REL}/dep_OCCT-prefix/src/dep_OCCT)
+11 -1
View File
@@ -151,6 +151,12 @@ elseif(APPLE)
# the post-install -add_rpath below. # the post-install -add_rpath below.
set(_python_ldflags "${_python_arch_flags} -Wl,-headerpad_max_install_names") set(_python_ldflags "${_python_arch_flags} -Wl,-headerpad_max_install_names")
# The macOS 27 SDK declares pipe2() and dup3() as available from macOS 27, so
# configure finds them and CPython 3.12 calls them without a runtime check.
# Below a macOS 27 deployment target they are weak-linked and resolve to NULL
# on older systems, where os.pipe() then segfaults -- in `make install`
# (compileall, ensurepip) and in the shipped app alike. Every configure below
# keeps the pipe()/dup2() fallbacks (python/cpython#153711).
if(IS_CROSS_COMPILE) if(IS_CROSS_COMPILE)
set(_python_build_tgt --build=${_python_build_arch}-apple-darwin --host=${_python_host_arch}-apple-darwin) set(_python_build_tgt --build=${_python_build_arch}-apple-darwin --host=${_python_host_arch}-apple-darwin)
set(_python_build_arch_flags "-arch ${_python_build_arch_flag} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}") set(_python_build_arch_flags "-arch ${_python_build_arch_flag} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}")
@@ -174,7 +180,8 @@ elseif(APPLE)
--enable-shared \ --enable-shared \
--without-static-libpython \ --without-static-libpython \
--disable-test-modules \ --disable-test-modules \
--build=${_python_build_arch}-apple-darwin && \ --build=${_python_build_arch}-apple-darwin \
ac_cv_func_pipe2=no ac_cv_func_dup3=no && \
make -j${NPROC} python && \ make -j${NPROC} python && \
cd '<SOURCE_DIR>' && \ cd '<SOURCE_DIR>' && \
env \ env \
@@ -191,6 +198,7 @@ elseif(APPLE)
--without-static-libpython \ --without-static-libpython \
--with-openssl='${DESTDIR}' \ --with-openssl='${DESTDIR}' \
--disable-test-modules \ --disable-test-modules \
ac_cv_func_pipe2=no ac_cv_func_dup3=no \
${_python_build_tgt} \ ${_python_build_tgt} \
--with-build-python='${_python_build_python}' \ --with-build-python='${_python_build_python}' \
py_cv_module__tkinter=n/a" py_cv_module__tkinter=n/a"
@@ -213,6 +221,8 @@ elseif(APPLE)
--with-openssl=${DESTDIR} --with-openssl=${DESTDIR}
--disable-test-modules --disable-test-modules
${_python_build_tgt} ${_python_build_tgt}
ac_cv_func_pipe2=no
ac_cv_func_dup3=no
# Tcl/Tk 9.0 (e.g. from Homebrew) is incompatible with CPython 3.12's # Tcl/Tk 9.0 (e.g. from Homebrew) is incompatible with CPython 3.12's
# _tkinter; OrcaSlicer's embedded Python does not need tkinter anyway. # _tkinter; OrcaSlicer's embedded Python does not need tkinter anyway.
py_cv_module__tkinter=n/a py_cv_module__tkinter=n/a
-160
View File
@@ -1,160 +0,0 @@
# 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
@@ -1,136 +0,0 @@
# 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
@@ -1,704 +0,0 @@
# Orca-CAD — UX guidelines and design charter
Status: proposed, v1. Owner: design working group. Applies to the Design tab —
the parametric CAD environment inside OrcaSlicer.
This document is a **review instrument**, not an essay. Sections 3–9 are written
so that a reviewer can hold a pull request against them and get a yes or a no.
If a rule here cannot be failed, it is badly written and should be rewritten.
---
## 1. Why this exists
A CAD tool acquires its interface by accretion. Every feature arrives needing
"just one more field", the side panel is the cheapest place to put it, and after
forty features the product is FreeCAD: complete, respected, and abandoned by
almost everyone who opens it once. That end state is not a failure of any single
decision. It is the sum of forty locally reasonable ones taken without a written
rule to violate.
So we write the rule down first, and we make additions argue against it.
## 2. Product thesis
**Orca-CAD is a modelling space for people who want a part, inside the tool that
prints it.**
Three audiences, one interface:
- **The fourteen-year-old on a school laptop.** Free software, on the machine
they already have, with no account, no subscription, no licence and no
tutorial. They open the tab because they want a bracket for a bike light, and
an hour later it is printing. This is not the charity case at the bottom of
the list — it is the reason the project is worth doing. A CAD tool that only
the equipped can run is a tool for people who were already going to design
something; this one has to be a creative instrument in the hands of someone
who did not yet know they could make things. Everything in §6.1 exists to
keep that door open, and nothing gets to close it for the convenience of the
other two audiences.
- **The maker** who has an idea and a printer, and who has bounced off FreeCAD.
They should be modelling something real within ten minutes of first opening
the tab, without a tutorial, without knowing the word "constraint".
- **The mechanical designer** who needs assemblies, mates, exploded views,
variables, and a feature history they can edit six months later. They should
not have to leave for SolidWorks the moment the work gets serious.
The order matters. When a decision helps one audience and hurts another, the
earlier one wins unless there is a written argument for why not.
The reference for *how it feels* is Shapr3D: direct, gestural, quiet, almost no
chrome, depth revealed by what you touch rather than by what is on screen. The
anti-references are Blender (a modal keyboard language you must learn before the
first success) and FreeCAD (a workbench-and-dialog architecture where the
geometry is a preview of a form you fill in elsewhere).
We are not cloning Shapr3D's feature set. We are adopting its *interaction
economy*: the smallest number of visible controls that still makes an expert
fast.
**And one thing neither reference has:** Orca-CAD lives inside a slicer. The
plate, the nozzle, the material and the print constraints are known to the
application at design time. Designing for print is not a plugin here, it is the
home advantage. Where a rule below trades generality for print-awareness, it
trades in favour of print-awareness.
## 3. The laws
Non-negotiable. A change that breaks one of these does not get merged on the
grounds that it was easier, that the alternative is more work, or that another
CAD does it that way. Each law carries a test — the question a reviewer asks.
### L1 — Geometry first: you point, then you act
Controls live **on the geometry**: handles, arrows, points, small circles and
boxes, with an inline label tab for typed values. Not in a side panel of combos
and spin fields.
The canonical gesture: **select a face or plane in the viewport, then click the
sketch tool.** Never: click the sketch tool, then choose a plane from a list.
The tool consumes what you pointed at — and, better still, the thing you pointed
at offers the tool itself (§4).
> **Test.** Can the operation be performed start to finish without the pointer
> leaving the viewport, except to press the tool itself? If a control had to be
> added to a panel to make it work, the design is not finished.
This is the law the others serve. It was stated after two proposals in a row
reached for a dropdown, and the failure mode it names is real and recurrent: a
fix that "adds a row to the plane combo" is the side-panel pattern wearing a
different hat.
### L2 — Everything draggable is typable, and everything typable is draggable
Any value produced by direct manipulation (a fillet radius, an extrude depth, a
pattern spacing, a plane offset) shows a live label on the geometry, and that
label is an editable field. Any value entered numerically has a corresponding
handle in the viewport.
Dragging is for finding the answer. Typing is for committing to it. A tool that
offers only one of the two is half a tool.
> **Test.** Point at the number the tool produces. Can you drag it? Can you
> click it and type? Both must be yes.
### L3 — Noun then verb, always the same way round
Selection precedes action, without exception, across sketch tools, features,
dress-up, booleans and mates. There is no tool in the product that is armed
first and asks for its input afterwards.
> **Test.** Does this tool work if the user has already selected the thing they
> want it applied to? Does it work *only* that way?
### L4 — No modal dialog in the modelling loop
Dialogs belong to document-level actions: open, save, import, export, preferences.
Modelling never opens one. A feature that needs three values gets three labels on
the geometry, not a form; a feature that needs confirming gets a ghost preview and
a confirm/cancel puck in the scene beside it (§4.2) — an object, not a window: the
camera still orbits, the values are still editable, nothing is blocked.
> **Test.** Between starting an operation and seeing its result, does a window
> appear that must be dismissed? If yes, redesign.
### L5 — One click, one visible change
Every click either changes what is on screen or tells the user why it did not.
A click that opens something invisible, arms an invisible state, or requires a
second identical click to have any effect is a defect, not a design.
This law exists because we shipped its violation twice. Sketch-tool family
buttons were flyouts whose first click only rendered a pressed state — three
separate sessions filed bugs against tools that were working. Solid picking used
a click *cycle* (first click selects the body, second refines to the face), so
sketching on a face appeared broken to anyone who clicked a face once, the way
every human does.
> **Test.** Perform the gesture exactly once, as a first-time user would. Take a
> screenshot. Is the state visibly different, and is the difference the one the
> user intended?
### L6 — The default is the answer four times out of five
Every option that has a default must have the *common* answer as its default,
measured against real parts, not against generality. "New body" as the default
result of an extrude is wrong: most extrudes join. Radius as the input for a
circle is wrong: drawings give diameter.
> **Test.** Take ten real parts. In how many is the default correct? Below eight,
> change the default or infer it from context.
### L7 — Errors are caught before the commit, in the user's words
A self-intersecting profile, a cut that removes no material, a wall thinner than
the nozzle: these are reported at the moment they become knowable, on the
geometry that is wrong, phrased as what happened and what to do — not as a kernel
exception after the fact, and never silently.
> **Test.** Is the failure detectable before the user commits? Then it must be
> reported before the user commits. Read the message aloud: does it name a thing
> the user can see and an action they can take?
### L8 — The camera is the application's job
Selecting a sketch plane orients the view to it. Committing a feature does not
throw the camera away. Zoom-to-fit exists and is one keystroke. The user is never
required to fight the view in order to reach the geometry, and orbit is bound to
the gesture people actually try.
> **Test.** Count camera manipulations in a representative modelling session.
> Any camera action the application could have performed for the user is a bug.
### L9 — Accessible by construction, not by retrofit
The floor, applied to every new interaction (details in §6.2): full keyboard
reach, no meaning carried by colour alone, hit targets that survive a shaky hand
and a HiDPI screen, legible labels over an arbitrary 3D background, no gesture
that depends on timing.
> **Test.** Drive the whole interaction from the keyboard. Then drive it in
> greyscale. Both must work.
### L10 — Vocabulary from the drawing office
Names come from the language of people who make parts: fillet, chamfer, boss,
rib, counterbore, mate, exploded view. Not from the kernel (no "boolean
subtract", no "B-rep"), not from invented product-speak. Where the drawing-office
word and the beginner's word differ, use the drawing-office word and make the
tooltip teach it — an approachable tool that leaves the user unable to talk to a
machinist has failed them.
> **Test.** Would a shop-floor engineer recognise this word? Would a first-time
> user be able to look it up and find a real definition?
### L11 — The floor is a school laptop, and nothing is behind a door
The product runs, completely, on a low-end laptop with integrated graphics and a
small screen, offline, with no account, no subscription and no feature withheld.
No capability in this document is reserved for a paid tier, a cloud service, a
plugin, or a machine with a discrete GPU — there is one product and everybody
gets all of it.
> **Test.** On the reference low-end machine (§6.1), at 1366×768, with the
> network cable pulled and no account ever created: does this feature work, and
> is it usable at an honest frame rate? Any "no" is a defect, not a limitation.
## 4. Interaction grammar — object-driven
The rules above compose into one sentence the whole product obeys:
> **Point at geometry → the geometry offers what can be done to it → choose the
> tool → manipulate handles and type exact values → confirm or cancel.**
The selection does not merely feed the tool. **The selection determines which
tools exist.** Pick a planar face and the product shows you the small set of
things a planar face can become — sketch on it, extrude it, hole it, shell it,
put a datum on it. Pick an edge and that set is fillet, chamfer, and the sketch
tools that can use it as a reference. Nothing else is offered, because nothing
else is possible.
This is the single largest thing we can do for a first-time user, and it is
worth stating as the reason: a beginner's difficulty is not operating a tool,
it is **not knowing which tools apply to what they are looking at**. A palette
of sixty icons answers a question they cannot yet ask. A face that offers its
own five verbs teaches the model of the product by using it. It also removes an
entire class of failure — a tool that silently does nothing because the
selection was wrong can no longer be reached.
### 4.1 The offer, and the one thing that makes it work
The flow, in full:
> **left-click the geometry to select it → right-click to open the offer → a
> vertical list, always in the same order, each row an icon, a name and its
> keyboard shortcut → click.**
- **Selecting and acting are separate gestures.** Left-click only ever selects,
so pointing at things is quiet — nothing pops up while you look around.
Right-click on the selection opens the offer, at the pointer, over the
geometry it acts on.
- **Order is fixed and it is the whole point.** A verb occupies one permanent
row, and that row is the same in every selection where the verb appears.
Dress-up is the fourth row on an edge, on a face, on a body, on the day the
product ships and two years later. The hand learns the position; the eye stops
being needed.
- **What does not apply is DISABLED IN PLACE, never removed.** This is the
single strongest thing the list does, and it is why it beat the radial we
drew first: a greyed row still carries its name *and the reason it is grey* —
"Create a sketch, or pick a solid face, first", "Create a solid body to
pattern first" — in the words the product already ships. On a first-run
document the offer is therefore not a mostly-empty control but a map of what
the product does and what you have to do first.
- **It is an accelerator, not a toll gate.** The toolbar and the single-letter
shortcuts keep working exactly as they do now, and pressing a tool directly
consumes the same selection (L3). An expert never has to open the offer; a
beginner never has to know the toolbar exists. Both routes land in the same
place — this is the only way one interface serves §2's three audiences.
- **Every row shows its keyboard shortcut**, right-aligned so the keys stack
into a column the eye learns without trying, beside the icon and the
drawing-office word (L10). This is deliberate: the offer is the path by which
a user stops needing the offer. You reach for fillet in its row, the row says
"F", and one day your hand types F before the menu has finished opening. A
menu that teaches its own shortcut is how a beginner becomes the power user
who never opens it — the same interface at two speeds, with no "advanced mode"
between them (§7).
- **A family with more than one applicable verb opens a submenu** to the side,
in its own fixed order. A family with exactly one shows that verb directly, so
the common path is never one click longer than it needs to be.
- **It never blocks the view of what it acts on**: it opens beside the pick,
never over it, with a thin leader back to the point it belongs to, and it
dismisses the moment the selection changes.
- **The header names what is selected** ("Top face · Body 1"), because a user
who mis-picked should find that out before choosing a verb, not after.
#### Opening the offer on every machine
Right-click is the primary gesture and every platform must have a first-class
equivalent — this is a reach requirement (L11), not a nicety:
| Input | Gesture |
|---|---|
| Two-button mouse | right-click |
| Trackpad | two-finger tap (the OS-standard secondary click) |
| macOS, one-button mouse | **long-press**, and Ctrl-click, which is the platform convention |
| Keyboard | the Menu key, or Shift+F10, on the current selection |
| Touch / pen | long-press |
The long-press is an **additional** route, never the only one — §6.2 forbids
press-and-hold as a sole path to a function, and it stays forbidden. Every
opening gesture is reachable at least two ways on every platform, and the
keyboard route exists everywhere. A long-press must show that it is charging
(a growing ring under the finger) so a user who holds too briefly learns why
nothing happened rather than concluding the product is broken (L5).
#### The row-constancy invariant
This is the rule that has to survive every future feature, so it is written as
an invariant rather than as advice:
> **Every verb has exactly one row index in the offer. That index is identical
> for every selection type in which the verb appears. Verbs that do not apply to
> the current selection are DISABLED IN PLACE, with their reason — the offer is
> never compacted, re-sorted or re-ordered. Adding a verb never changes the
> index of an existing one.**
Two consequences the group must accept together with the invariant:
- **No adaptive ordering. Ever.** Not most-used-first, not recently-used-first,
not per-selection frequency. An offer that rearranges itself to be helpful
destroys the only thing that made it fast, and it does so precisely for the
user who has just started to learn it. (Office 2000's adaptive menus are the
textbook case; they were removed.)
- **Greyed rows are the price, and they are cheap.** A compacted menu is shorter
and unlearnable. A constant one is a few rows longer, teaches while it waits,
and is memorised in a week.
#### The map — RATIFIED 2026-07-31
The invariant is not negotiable, and as of 2026-07-31 neither is the assignment:
the row order below is **ratified**. It was argued once; it is not argued again.
Changing an index from here on is a breaking change to every user's muscle
memory and needs the group, not a pull request (§9 q12).
Eight families, ordered so the sequence itself has a logic: material is created,
grows, is taken away, is refined, is repeated, is moved, is referred to, is
edited.
| Row | Family | On a face | On an edge | On a body | On text/art |
|---|---|---|---|---|---|
| **1** | Create | Sketch on it | — | — | Edit text |
| **2** | Add material | Extrude, thicken | — | Combine, thicken | Extrude |
| **3** | Remove | Hole, shell | Thread | Shell, cut, split | — |
| **4** | Dress-up | Draft | Fillet, chamfer | Fillet, chamfer | — |
| **5** | Repeat | Pattern | Pattern along it | Pattern, mirror | Pattern |
| **6** | Transform | Align to, mate | — | Move, mate | Move, size |
| **7** | Reference | Plane, axis, measure | Axis, measure | Project, measure, mass | — |
| **8** | Modify | Delete face, edit | — | Edit, colour, delete | Replace art |
A dash means the row is drawn greyed for that selection, with its reason.
The authoritative version of this table is **`docs/ux/tool_atlas.json`**, which
carries all 52 verbs with their preconditions and their refusal strings, taken
from the code rather than from memory. Every state it produces — 20 selection
kinds × 2 document states, 40 primary menus and 73 submenus — is rendered by
`docs/ux/mockups/gen_offer_mockups.py` into `docs/ux/offer_atlas.html`. Read the
atlas before proposing a change to the map; the generator refuses to render an
address collision, so the map cannot silently rot.
#### Rejected: the radial ring
The first design put the eight families at eight compass points around the pick.
It is recorded here because it is a good idea that loses on evidence, and
someone will propose it again:
- an inapplicable slot could only be drawn empty, and **an empty slot says
nothing** — the reason text above has nowhere to live;
- the measured fill was **3.45 of 8 slots**, so most of the control was blank
most of the time, and on a fresh document only two of eight were live;
- sketch-mode *Create* needs **nine** addresses; eight forced two primitives
behind a "More" slot, and a ninth position costs the 45° spacing that made the
ring worth having;
- long translated names do not fit around a circle, and screen readers and arrow
keys need bespoke handling a list gets for free;
- a 380 px disc over the model costs more on a 1366×768 screen than a 324 px
list beside it (§6.1).
What it kept — equidistant targets and a future flick gesture — buys little in a
product whose experts live on the keyboard by design.
### 4.2 Confirm and cancel are objects, not gestures
The old rule — click empty space to commit — is withdrawn. It was an invisible
gesture with a destructive meaning: nothing on screen said it, and a stray click
committed a feature the user was still adjusting. That is exactly what L5
forbids, and it is hostile to the audience §6.1 exists for.
- **A pending feature carries a confirm/cancel puck**, attached to the geometry
it is editing, next to its handles: ✓ commits, ✗ discards. Enter and Escape
mirror them for the keyboard (L9). It is drawn where the user's attention
already is, and it is the only thing in the viewport that commits.
- **Empty space now means "clear the selection"** — the safe meaning, and the
same meaning everywhere.
- **This is not a dialog** (L4). It is two objects in the scene, on the
geometry, non-modal: the camera still orbits, the tree is still there, the
values are still editable while it waits.
- **Continuous tools do not ask.** Drawing a line, a rectangle, a circle commits
each entity as its own gesture completes — a ✓ per line would destroy the
inner loop. The puck belongs to *features* (extrude, fillet, hole, pattern,
mate) and to sketch edits that hold a pending state. Enter/Escape end a
continuous tool rather than confirming an entity.
- **Ambiguity resolves toward keeping work, never toward losing it.** Starting
another operation while a valid feature is pending commits it rather than
discarding it; if it is not valid, the product says why (L7) and keeps it
pending. Since undo reaches everything (§6.1), the recoverable direction is
always the right default.
### 4.3 The rest of the grammar
- **The status line is one imperative sentence** naming what the tool wants
next, and it names the target when the target came from a selection
("Circle — click centre, then radius · on the picked face"). It is the
authoritative feedback surface for the armed tool; the toolbar is not.
- **Hover previews, click commits.** A hover shows the ghost of what a click
would do wherever this is cheap to compute.
- **Selection is persistent and visible** until consumed or cleared. A tool that
consumes a selection clears it, so the next feature cannot silently inherit it.
- **Every gesture is undoable**, and the feature tree is editable history, not a
log. Re-editing a feature re-enters the same on-geometry interaction that
created it — including its offer and its puck.
## 5. Layout and screen budget
The viewport is the application. Chrome is a tax on it.
- **One toolbar**, contextual to the mode (model / sketch). Tools are grouped by
what they make, not by which subsystem implements them.
- **A left rail for the document, not for parameters**: feature tree, bodies,
variables. It answers "what exists", never "what value should this be".
- **No parameter panel.** Where one exists today it is technical debt with a
scheduled removal (§10).
- **Print context is ambient**, not a panel: the plate is visible in the design
space, and print-domain warnings appear on the geometry that will fail.
- **Nothing is added to permanent chrome without removing something**, or
demonstrating that the addition is used in the majority of sessions.
- **The budget is set by the smallest screen we serve**, 1366×768 (§6.1) — not
by the reviewer's monitor. Chrome that fits a 27-inch display and swallows a
laptop's has not fitted, it has just failed somewhere the author cannot see.
## 6. Accessibility — reach first, then the assistive floor
"Accessible" means two different things and the product owes both. §6.1 is about
**who can get in at all**; §6.2 is about **who can operate it once inside**.
Neither is a phase. Both are merge requirements.
### 6.1 Reach — the door has to be open
The premise of the whole project: someone with no money, no licence, no account,
no fast machine and no teacher can open this and make a real thing. Free
software on a school laptop is the only path to a CAD tool that reaches people
who were never going to be handed one. If a design decision quietly raises the
cost of entry, it has broken the premise, however elegant it is.
- **The reference machine.** A 5-year-old laptop: dual/quad-core CPU,
**integrated graphics**, 8 GB RAM, **1366×768** screen, no discrete GPU. The
Design tab must be usable there, and any interaction that needs more is a
design failure to be solved, not a requirement to be documented. The GPU path
degrades gracefully to software rendering rather than refusing to start; the
viewport stays interactive while the kernel thinks.
- **1366×768 is the layout target, not the stretch case.** A form-heavy side
panel is not merely inelegant on that screen — it takes the model off it.
This is the second, independent argument for the whole of L1 and §5.
- **No account, no cloud, no connection.** The product works forever with the
network unplugged. Nothing is uploaded, no sign-in gates any feature, no
telemetry is required to use it. A school network that blocks everything must
not be able to block this.
- **No tier, no plugin wall, no "pro".** Every feature named in this document is
in the product everyone downloads. Assemblies and exploded views are not the
paid half.
- **Files belong to the user**, on their disk, in a format that outlives the
project: the design travels inside the ordinary project file, and the geometry
exports to STEP and mesh formats anyone can open.
- **Learnable without instruction.** The first solid comes with no
documentation, no video and no tutorial mode — from noticing that a face can
be clicked. Tooltips teach the vocabulary (L10) at the moment it is needed;
nothing is explained in a manual the user will never open.
- **Plain language at the entry tier.** The Make tier speaks in words a
thirteen-year-old reads without stopping. Precision comes with the tier that
needs it, and everything is translated, because "accessible" in English only
is not accessible.
- **Exploration must be free.** Undo reaches everything, work is never lost to a
wrong click, and no dialog ever asks the user to be sure. A tool that punishes
experiments teaches people to stop experimenting, which is the one thing this
audience cannot afford to learn.
- **The product never blames the user.** Failures are stated as what happened
and what to do (L7). "Invalid input" is not an acceptable sentence anywhere.
### 6.2 Assistive floor
- **Keyboard**: every operation reachable and completable without a pointer.
Single-letter shortcuts for sketch tools, shown in the offer itself (§4.1) as
well as in the tooltip. The offer opens from the keyboard (Menu key or
Shift+F10) and walks by arrow key and by type-ahead, so the row map works for
someone who never touches the pointer. A visible focus state on every
focusable element. No shortcut that only works while the pointer happens to be
over the canvas.
- **Colour**: never the sole carrier of meaning. Selection is colour *and*
outline; an error is colour *and* an icon *and* text. Verify in greyscale.
- **Contrast**: labels over the 3D viewport get a scrim or halo so 4.5:1 holds
against any background the model can produce, including a white body under a
white plate.
- **Targets**: handles and grips no smaller than 32 px at 100 % scale, scaling
with the OS factor; the grab tolerance is larger than the drawn glyph.
- **Timing**: no double-click-to-mean-something-else, no press-and-hold as the
only route to a function, no cycle that depends on repeated clicks
(see L5). The long-press that opens the offer on a one-button Mac and on touch
(§4.1) is explicitly an *additional* route — Ctrl-click, two-finger tap and
the keyboard all reach the same place — and it shows its own progress while
charging, so it never fails silently.
- **Motion**: animation is functional (showing where a thing went), never
decorative, and it respects the reduced-motion preference.
- **Text**: no fixed-width assumptions; the UI holds together in German and in
Chinese, at 125 % and 200 % scale. Every string routed through the normal
translation path.
## 7. Depth without clutter — the three tiers
Power for experts is delivered by **progressive disclosure of tools, never by
relocation of tools**. A tool that appears in a later tier is in the same place
it will always be; it is simply not shown yet.
| Tier | Who | What appears |
|---|---|---|
| **Make** | first hour | Sketch, extrude, revolve, hole, fillet/chamfer, move, commit to plate |
| **Model** | competent user | Patterns, shell, draft, sweep/loft, booleans, reference geometry, variables, import/export |
| **Mechanism** | mechanical designer | Assemblies and mates, exploded views, interference detection, surfaces, feature-level editing of imported solids |
Rules that keep this honest:
1. **Tiers are non-modal.** No mode switch, no workbench selector, no "advanced
mode" toggle that changes the meaning of anything. The tier only governs what
is *offered*.
2. **A tier reveals itself by use.** Using a body reveals boolean tools; adding
a second body reveals assembly tools. The product notices what you are doing.
3. **Nothing moves when a tier appears.** A user who learned where fillet lives
finds it in the same place forever.
4. **An expert tool obeys the same grammar** as a beginner tool. Mates are
picked in 3D like everything else, not configured in a table.
5. **Exploded views are a view state**, not a document mode — reversible,
draggable along mate axes, and never a separate file.
## 8. Designing for print — the home advantage
Design-time knowledge the application already has, and must use:
- **The plate is present** in the design space, at the real size, with the real
origin. Committing a body to the plate is one action and preserves placement.
- **Print-domain checks run on the model, on the geometry, before slicing**:
walls thinner than the nozzle, unsupported overhangs beyond the material's
angle, features smaller than the layer height, a part that does not fit the
build volume.
- **These are warnings on the geometry, never a report.** The thin wall glows;
the tooltip says how thin and what the nozzle is.
- **Material and machine context is inherited** from the active slicer profile,
not re-entered in the Design tab.
- **The round trip is preserved**: editing a design after slicing returns to the
feature history, not to a mesh.
## 9. The review gate
Every pull request that touches the Design tab UI answers these, in the PR body.
A "no" that is not accompanied by an argument is a request for changes.
1. Which law (L1–L11) does the change most directly serve?
2. Can the whole operation be completed without the pointer leaving the
viewport? If not, why is this the exception?
And: does the relevant selection *offer* this tool (§4.1), or must the user
already know it exists?
3. Are the values draggable *and* typable?
4. Screenshot of the state after **exactly one** click of the new gesture,
performed as a first-time user.
5. Keyboard-only walkthrough: does it complete?
6. Greyscale screenshot: is every state still distinguishable?
7. What was **removed**? (Net additions to permanent chrome require an argument.)
8. Which tier does it belong to, and does it appear without moving anything else?
9. What does it do when the geometry is invalid, and is that reported before the
commit?
10. Interaction cost: actions required for the canonical task it addresses,
before and after.
11. Reach (L11): screenshot at 1366×768 with the panel open — is the model still
on screen? Does it run on integrated graphics? Does it need the network, an
account, or a file the user cannot keep?
12. If the change adds or moves a verb in the offer: which row, and is it that
verb's row in **every** selection where it appears? Did any existing verb's
index change? (If yes, this is not a UI change, it is a breaking change to
every user's muscle memory, and it needs the group — see §4.1.) Was
`docs/ux/tool_atlas.json` updated and the atlas regenerated?
13. If the change adds a pointer gesture: what is its keyboard equivalent, and
what does a one-button Mac, a trackpad and a touch screen do (§4.1)?
## 10. Where we stand today — honest inventory
Complying with the laws already:
- Sketch inline editors — draw an entity and its dimension tab opens on the
geometry; Tab walks Length → Width → Angle.
- Fillet/chamfer draggable radius arrow with an editable value label.
- Extrude depth arrow; move-body three-axis arrows.
- Datum-plane resize handles and offset arrow; ghost reference planes picked in
3D.
- Imported-art place/size gizmo.
- Sketch plane taken from the picked face, with the target named in the status
line, and the sketch-plane dropdown deleted outright.
Violating them, with removal scheduled:
- **Every tool card is a two-column form** of combos and spin fields in the left
panel. This is the single largest debt in the product and the reason this
document exists. Tracked as an epic; each card is replaced by its on-geometry
equivalent, not improved in place. It fails L1 and it fails L11 twice over —
on a 1366×768 screen the cards leave the model a strip.
- Seven remaining plane pickers still populate a combo instead of consuming a
viewport selection.
- Pattern has no on-geometry spacing arrow or count badge.
- Hole is positioned by X/Y fields rather than by a point on a face.
- Booleans and cuts pick their operands from lists rather than in 3D.
- Fillet/chamfer edge selection still requires the click cycle L5 forbids.
- **Selecting geometry offers nothing.** There is no contextual offer (§4.1):
the user faces the full toolbar whatever they have picked, and finds out that
a tool did not apply by it doing nothing. This is the largest single item of
new work the charter asks for. The map and every state of it are already
drawn (`docs/ux/offer_atlas.html`); what the group owes itself before the code
is ratifying the row order, since every verb built before that lands has to be
addressed afterwards anyway.
- **Committing is an invisible click in empty space** rather than the
confirm/cancel puck of §4.2 — the exact gesture that rule withdraws.
Nothing on the violating list is defended. The only open question for each is
what its on-geometry replacement should be.
## 11. How the group works
**Roles.** Product/UX lead (owns this document and casts the tie-break vote on
interaction questions); kernel maintainer; GUI maintainer; a print-domain
reviewer; a mechanical-design reviewer who uses the product on real work; an
accessibility reviewer covering both senses of §6 — reach and assistive — who
owns the reference machine and actually runs on it. One person may hold more
than one role; the UX lead and the mechanical-design reviewer should not be the
same person, and nobody reviews reach from a workstation.
**The absent audience needs a seat.** The fourteen-year-old is not in the room
and cannot file an issue. Someone in the group is accountable for B5 and B6, and
the group watches real first-timers use the product on the reference machine at
least once a quarter — school, makerspace, or a friend's kid. Everything else in
this document can be argued from principle; approachability can only be
observed.
**Cadence.** A short weekly review of open interaction proposals. A monthly pass
over the violating inventory in §10 — anything that has not moved in two months
is either scheduled or explicitly accepted as permanent, with a reason written
into this document.
**How a change moves.**
1. *Problem* — a described user difficulty, ideally with an interaction-cost
measurement, never a solution in disguise.
2. *Sketch* — one or two on-geometry interaction proposals, drawn or described
as a gesture sequence. Reviewed against §3 before any code.
3. *Prototype* — built behind whatever the smallest safe path is, driven end to
end on a real display, and screenshotted at each state.
4. *Gate* — §9 answered in the PR.
5. *Merge*, then update §10.
**Decisions are written down.** Any resolution that constrains future work is
appended to this document as a numbered law or as an accepted exception with its
reasoning. A decision that lives only in a call is not a decision.
**How disagreements resolve.** Against the laws first. If the laws do not decide
it, the tie-break is the interaction cost measured on the canonical tasks in
§12; if that does not decide it, the UX lead chooses and records why.
## 12. Canonical tasks — the benchmark
The measure of every UX change is the cost of these five tasks. Each is timed and
counted (clicks, keystrokes, camera actions, mode switches) on the headless rig
and, periodically, with real users who have not seen the product.
| # | Task | What it exercises |
|---|---|---|
| **B1** | Bracket: sketch an L, extrude, two holes, fillet the inside corner, send to plate | The inner loop |
| **B2** | Change a hole diameter and the plate thickness, six features deep, and rebuild | Parametric editability |
| **B3** | Take an imported STEP, delete a boss, close the face, thicken a wall to nozzle width | Direct editing + print awareness |
| **B4** | Two parts, one revolute mate, check interference, produce an exploded view | The Mechanism tier |
| **B5** | First-run: from opening the Design tab to a print-ready solid, no documentation | Approachability |
| **B6** | B1 again, on the reference machine at 1366×768, offline, on a fresh account-less install | Reach (L11) |
Every task is run on the reference machine of §6.1, not on a workstation — a
number measured on a fast desktop describes an experience most of our users will
never have. B6 repeats the inner loop under the full entry conditions so that
reach is a measured quantity and not an intention.
Targets are set once each task has been measured on the current build. B5's
target is expressed in minutes-to-first-solid **by someone who has never seen a
CAD program**, and it is the number this project is ultimately judged by.
---
### Appendix — anti-patterns we have already paid for
Kept because each cost real time and each is easy to reintroduce.
- **The dropdown that grew a row.** Fixing "cannot sketch on a face" by adding a
"Face of Body 1" entry to a plane combo. It reads as a small fix and it is the
side-panel architecture reproducing itself.
- **The invisible first click.** Flyout buttons and pick cycles whose first click
changes nothing meaningful. Filed as bugs three separate times against working
code, and made a real bug look fixed when it was not.
- **The fix verified through a path the user will never take.** A face-sketch fix
confirmed by double-clicking to reach face level. Users click once. A fix
reachable only by an undiscoverable gesture is indistinguishable from no fix.
- **The wrong feedback surface.** Measuring an armed tool by the toolbar, which
never renders keyboard-armed state. The status line is the surface that
answers.
- **The silent success.** A cut that removed no material, reported as done. Now
an error naming the likely cause.
@@ -1,169 +0,0 @@
# 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.
@@ -1,998 +0,0 @@
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
@@ -1,922 +0,0 @@
# Mate connectors: aligning with the mainstream CAD systems
Research date: 2026-08-05. Written against `orca_cad` / `Snapmaker` at the M8 state
(`CadDocument.{hpp,cpp}`, `apply_mate`, `datum_frame`, the `Mate` card in `DesignPanel.cpp`).
**Brief:** align with the mate-connector concept as the main CAD programs actually implement it,
and be simple, unequivocal, unconfusing. Alignment is the organising principle of this document:
every recommendation is labelled either **[INDUSTRY]** — do what they all do — or **[DEVIATION]** —
we would be departing, here is why and what it costs.
---
## 0. The answer in ten lines
1. Seven systems surveyed. **Five of the seven use the same model**; two are the old world.
2. The model: a joint is defined between **two local coordinate frames**, one rigidly attached to
each part, plus **one type** naming which DOF stay free.
3. The frame is called a mate connector (Onshape), a **joint origin** (Fusion, Inventor), a joint
connector (FreeCAD 1.0). Same object, three names.
4. **Every one of them expresses every DOF about the frame's Z axis.** One axis, one convention.
5. **Five types appear in every frame-based system with identical names and identical DOF**:
Fastened/Rigid, Revolute, Slider, Cylindrical, Planar. Ball is in four of five.
6. That is not fashion — those are the classical **lower kinematic pairs**. The vocabulary converged
because the mechanics converged.
7. Our kernel is already on the right side of the line: frame-based, five types, Z-relative,
superimpose-then-relax. **The architecture needs no revisiting.**
8. Where we are out of step: connectors that are not attached to a body; an origin that can only be
a face centroid; no live preview of the two Z arrows; a mate card of abstract dropdowns.
9. Where we would knowingly deviate: refusing a second mate per body (no vendor does this — it is
forced on us by having no solver) and possibly inverting the default mate direction.
10. Biggest single win for the stated goal, and it costs no kernel work: **draw both frames and
ghost the result before Confirm.** The convention stops needing to be remembered.
---
## 1. The two families
**Constraint-based ("old CAD").** The user states pairwise *geometric relations* between raw
topology — this face coincident with that face, this axis concentric with that axis, this plane
parallel at 12 mm. Each relation removes some DOF; a numerical solver satisfies all of them at once.
Fully positioning one part typically takes **three or more mates**, and the set can be
over-constrained, under-constrained, or satisfiable in several configurations.
**Frame-based ("mate connectors").** The user places a *local coordinate system* on each part and
states **one** relation between the two frames. The relation is not "these surfaces touch" but
"these frames coincide, except for the following DOF, which stay free."
Onshape's help page opens by drawing exactly this line:
> *"Mates in Onshape are different than mates in old CAD systems. Many assemblies require only one
> Onshape Mate between any two instances, as the movement (degrees of freedom) between those two
> instances is embedded in the Mate."*
The frame-based model won for three reasons, all of which matter here:
- **One mate per pair.** No mental arithmetic about which three constraints add up to a hinge.
- **The DOF are declared, not deduced.** A revolute mate *is* one rotation. You do not discover the
remaining freedom by dragging.
- **It needs no simultaneous solver for the common case.** Frame-to-frame alignment is a matrix
composition — precisely what `apply_mate` already does.
> **Caveat — several vendors ship both, and "align with X" is therefore ambiguous.** **Inventor**
> kept its legacy constraints *and* added frame-based Joints in 2012; many Inventor users still build
> assemblies entirely with the old constraint stack. **Creo** has placement constraints *and*
> Mechanism connections. **FreeCAD** had constraint-based Assembly2/3 add-ons before the frame-based
> Assembly workbench shipped in 1.0. So copying "what Inventor does" means copying **one of two
> coexisting workflows**. **Onshape and Fusion 360 are the only pure frame-based examples**, and they
> are the ones to weight most heavily when the evidence conflicts.
---
## 2. Field survey — seven systems
| | Onshape | Fusion 360 | Inventor | FreeCAD 1.0 | Creo | Siemens NX | SOLIDWORKS |
|---|---|---|---|---|---|---|---|
| **Family** | Frame | Frame | Frame (+ legacy constraints) | Frame (+ legacy add-ons) | Both | Constraint | Constraint |
| **Frame object** | Mate connector | Joint origin | Joint origin | Joint connector (`Placement1/2`) | CSYS on `Weld`/`6DOF` | — | — (nearest: **mate reference**) |
| **Where it lives** | Part Studio **and** Assembly; in the feature list | Component, inside the joint | Component / inside the joint | Inside the Joint object | Part | — | Part (up to 3 named entities) |
| **Origin placement** | Inferred family on hover; `Shift` locks | Discrete **snap points**; `Ctrl` cycles | Snap points + explicit origins | Inferred, previewed on hover | Picked CSYS | Picked entities | Picked entities |
| **Orientation control** | Primary axis (Z) + secondary axis; flip + 90° reorient | Flip, angle, offsets | Flip, angle, offsets | `Placement1/2` + `Offset1/2` | CSYS + offset | — | — |
| **Type inference** | No — explicit | No — explicit | **Yes — "Automatic"** from picked geometry | No | No | No | Partial (mate reference type) |
| **Solver** | Yes, simultaneous — *"order won't affect a Mate"* | Yes | Yes | Yes (Ondsel) | Yes | Yes | Yes |
| **Reuse across instances** | **Yes** — a Part Studio connector exists on every instance | Weak | Partial | Per-joint | Interfaces | Product Interface | Mate references auto-mate on insert |
Three observations that shape everything below.
- **Every frame-based system reduced the type list by an order of magnitude** relative to SOLIDWORKS
(7–13 vs ~25) and lost nothing. That is not simplification-by-omission; it is what happens when the
DOF live in the mate instead of being assembled from constraints.
- **Every one of them defines its types relative to a single axis.** Slider translates along Z,
Revolute rotates about Z, Cylindrical does both, Planar translates in X/Y and rotates about Z.
One axis carries the whole vocabulary.
- **Onshape alone treats the connector as a first-class, reusable, named object** — and that is also
where its worst usability complaints come from (§4).
---
## 3. The type vocabulary — cross-system table
DOF = degrees of freedom left **free**, stated about/along the connector Z.
| DOF | Onshape | Fusion 360 | Inventor | FreeCAD 1.0 | Creo | **Ours today** |
|---|---|---|---|---|---|---|
| 0 | Fastened | Rigid | Rigid | Fixed | Rigid / Weld | **Fastened** ✅ |
| 1 — rot Z | Revolute | Revolute | Rotational | Revolute | Pin | **Revolute** ✅ |
| 1 — trans Z | Slider | Slider | Slider | Slider | Slider | **Slider** ✅ |
| 2 — rot + trans Z | Cylindrical | Cylindrical | Cylindrical | Cylindrical | Cylinder | **Cylindrical** ✅ |
| 3 — trans XY + rot Z | Planar | Planar | Planar | *(Parallel+Distance)* | Planar | **Planar** ✅ |
| 3 — rot XYZ | Ball | Ball | Ball | Ball | Ball | — |
| 2 — different axes | Pin slot | Pin-Slot | — | — | Slot / Bearing | — |
| 1 — coupled | Screw | — | — | Screw | — | — |
| 4 | Parallel | — | — | Parallel | — | — |
| other | Tangent, Width, Group | As-built | Automatic | Perpendicular, Angle, Distance, Gears, Belt, RackPinion | General, 6DOF | — |
**Five types appear in every frame-based system, with the same name and the same DOF.** Those five
are the industry's common denominator, and they are exactly `mate_kind` 0–4 as already implemented.
Ball is in four of five. Everything past that is a long tail no two vendors agree on.
### Why the convergence is a fact, not a fashion
A rigid-body placement is an element of SE(3). A mate leaves some set of relative motions free. For
the mate to behave the same throughout its range — for a hinge to be a hinge at every angle — that
free set must be **closed under composition**: two allowed motions must compose to an allowed motion.
A closed set of motions is a **subgroup** of SE(3).
The subgroups corresponding to physical surface-on-surface contact are the classical **six lower
pairs** (Reuleaux):
| Pair | Free motion relative to Z | DOF |
|---|---|---|
| Revolute (R) | rotation about Z | 1 |
| Prismatic / slider (P) | translation along Z | 1 |
| Helical / screw (H) | coupled rotation + translation | 1 |
| Cylindrical (C) | rotation about **and** translation along Z | 2 |
| Planar (E/G) | translation in X,Y + rotation about Z | 3 |
| Spherical / ball (S) | rotation about X, Y, Z | 3 |
Plus the two trivial ends: identity (0 DOF — **fastened**) and all of SE(3) (6 DOF — floating, i.e.
no mate). Hervé's Lie-subgroup analysis of the displacement group is the standard reference for
treating these as the algebraic building blocks of mechanism synthesis.
**Consequence.** Anything outside this table is either (a) a *composition* needing a solver, or
(b) not a joint at all but a *measurement*:
- Onshape's **Parallel** (4 DOF), **Tangent**, **Width**, **Pin slot**, and FreeCAD's **Distance /
Angle / Perpendicular** are constraints, not pairs — their free set is not a subgroup, so they only
make sense alongside a simultaneous solver.
- **Gear, Belt, Rack-and-pinion** are *relations between two mates*, a different object entirely.
- **Screw (H)** is a legitimate lower pair but needs a pitch parameter and is rare in printed parts.
So the vendors' shared five, the lower pairs, and our `mate_kind` 0–4 are the same list arrived at
three ways. **[INDUSTRY] Stop looking for missing types and spend the budget on the connector.**
---
## 4. What they all agree on — adopt verbatim
Deviating from any of these makes an experienced user's intuition *wrong*, which is the operational
definition of "confusing".
**A1 [INDUSTRY] — The connector is a full right-handed frame.**
Origin + Z (primary) + X (secondary). Onshape and Fusion expose exactly these two axis controls and
nothing else. A point cannot express spin; an axis cannot express clocking.
*Status: we comply* — `DatumCoordSys` carries origin/x/y and derives Z.
**A2 [INDUSTRY] — Z is the joint axis; every DOF is about or along Z.**
Revolute rotates about Z. Slider translates along Z. Planar's free plane is normal to Z. Offsets run
along Z. This single rule is what makes the system learnable: **one axis to look at, and its meaning
never changes.**
*Status: we comply* — `mate_offset` along A's z, `mate_angle` about A's z.
**A3 [INDUSTRY] — Mating superimposes the two frames; the type then relaxes specific DOF.**
FreeCAD states it most plainly: *"the second connector is superimposed on the first connector by
default and may change its position according to the joint type."* Fastened is not a special case —
it is the base case with nothing relaxed.
*Status: we comply* — `T = M_A · Rz · Tz · F · M_B⁻¹`, looser kinds relaxing from there.
**A4 [INDUSTRY] — The connector belongs to a part and moves with it.**
Onshape: a connector defined in a Part Studio *"is available for reuse on every instance of that part
in every assembly in which it is instanced."* It is part geometry, not assembly geometry.
*Status: **violated**.* `CoordSysType::PointWorld` is a bare world XYZ with `X = world X` and no
`coordsys_body`. Such a connector does not follow its part. See §6 G1.
**A5 [INDUSTRY] — Selection order is meaningful and must be visible.**
One connector is the reference; the other is driven onto it. Onshape spells out that offsets are
measured *"from the second Mate connector selected to the first"*, and that reversing the order
flips the sign.
*Status: complied with in the data model* (`mate_cs_a` fixed, `mate_cs_b` moves) *but not in the UI* —
two dropdowns labelled A and B do not tell the user which part is about to jump.
**A6 [INDUSTRY] — Flip and re-clock live in the mate dialog, always.**
Onshape: *"Click the arrow icon to flip the direction of the primary axis. Click the Reorient
secondary axis icon to rotate the secondary axis in 90-degree increments."*
*Status: partial.* We have `mate_flip` (Z reversal). We have `mate_angle` as a free number — strictly
more powerful than 90° steps, and much worse to *use*: the common case is "it came in a quarter turn
out", and typing 90 is a worse gesture than pressing a button.
**A7 [INDUSTRY] — DOF are shown, not inferred by the user.**
Onshape animates each mate's remaining DOF on demand; Fusion and Inventor name the DOF in the type
list. Our dropdown text already does this in words ("free spin + axial slide"). Keep it.
**A8 [INDUSTRY] — Free DOF are preserved from the current placement, not zeroed.**
Onshape: a Planar mate aligns the frames *"but they are not restricted to this location with respect
to their degrees of freedom."*
*Status: we comply* — and it must be *said*, because a Planar mate that leaves the part where it was
looks like a mate that did nothing.
---
## 5. Where they diverge — who to copy, and why
### D1 — Where the connector's origin comes from
| | Behaviour |
|---|---|
| **Fusion 360** | Discrete **snap points** only: vertex, edge midpoint, face centre, arc centre. `Ctrl` cycles the candidates under the cursor. A circle icon denotes a vertex, a triangle a midpoint. "Between two faces" is a separate explicit option. |
| **Onshape** | Infers a *family* on hover — centroid, every vertex, every edge midpoint, every arc centre, the centroids of interior regions (holes, slots), and the virtual sharps of conical faces. `Shift` locks the current candidate. |
| **Inventor** | Snap points, plus explicit joint origins for awkward cases. |
| **FreeCAD 1.0** | Hovering previews where the connector will land before you commit. |
| **Ours** | Always the **face centroid**. No alternative exists. |
Onshape's richness has a cost its own documentation admits: *"The suggested locations are based on
the underlying geometry of the part and changing the geometry will change the location of the Mate.
This can be undesirable in certain situations."* On the forum this shows up as connectors that move
or break on edit — the classic topological-naming failure. Fusion's discrete set is poorer and far
more predictable.
> **[INDUSTRY] Copy Fusion's candidate *set*.** A small, closed, enumerable set — **face centroid,
> vertex, edge midpoint, arc/circle centre** — each drawn before commit, with the card naming which is
> in use ("Origin: edge midpoint"). This is our largest expressiveness gap: a face centroid alone
> cannot place a hinge pin on a corner boss. It is also the one place where copying the *simpler*
> vendor is clearly right.
>
> **Open sub-choice — how the candidate is chosen.** Three options, in increasing order of magic:
> (1) **explicit dropdown** in the card after picking the face — no hover behaviour at all;
> (2) **Fusion's `Ctrl` cycling** through candidates under the cursor; (3) **Onshape's hover
> inference**. Kimi's independent review argued for (1) on the grounds that hover is exactly where
> both vendors' instability complaints originate, and that a dropdown gets ~90% of the expressiveness
> with none of the hover-guess debugging. That is a fair reading and (1) is the cheapest to build and
> the easiest to make unequivocal. **Recommendation: build (1) first; if hover is added later, let it
> *pre-fill the dropdown* rather than silently create an implicit connector** — which also keeps R2
> (one kind of connector) intact.
### D2 — Explicit type, or inferred from the geometry?
Inventor is the only surveyed system that infers: *"Rotational is selected if the two selected
origins are circular. Cylindrical if the two selected origins are points on a cylinder. Ball if
points on a sphere. Rigid for all other origin selections."* Onshape and Fusion require an explicit
choice.
> **[INDUSTRY, Inventor] Do both, in Inventor's order.** Infer a *default* type from what was picked,
> then show it in an editable control. Inference is what makes the tool feel like it understands the
> geometry; the visible, editable result is what keeps it unequivocal. Pure inference with no visible
> type is the confusing option; a pure dropdown with no default is the tedious one. This also fits
> the Design tab's geometry-first charter exactly: point at a bore, get Revolute offered.
### D3 — How the Z-direction ambiguity is resolved
This is the specific failure the brief is aimed at. A former IT trainer stated it precisely on the
Onshape forum:
> *"There is always the risk that users will build their own conceptual models of how software works
> which may not match the designer's concept. The result is usually a poor user experience and many
> mistakes… for a good (say) Fixed mate to occur do the Z axes of the two mates have to be pointing
> in the same direction… Alternatively, should they be facing each other?"*
He is asking the right question and **no vendor's documentation answers it.** Onshape's own advice —
*"if the behavior is not what you expected, try flipping the primary and/or secondary axis"* — is
trial and error. This is a gap in the industry, not a convention to copy.
> **[INDUSTRY, method] Resolve it with live preview, not documentation.** FreeCAD previews the
> connector on hover; Onshape and Fusion both draw the frames. Draw **both** Z arrows the moment the
> second connector is picked, and ghost the resulting placement *before* Confirm. The convention then
> never has to be remembered because it is on screen.
>
> **[DEVIATION, optional] Name the two cases in the user's words** rather than in axis-speak:
> "the two faces come together" vs "the axes run the same way". No surveyed vendor does this — they
> all ship a flip arrow. It is a small, low-risk improvement on the state of the art, and it is
> separable from the default-direction question in §8 D1.
### D4 — Named, reusable connectors on the part
Onshape: connectors created in the Part Studio are reused on every instance in every assembly.
SOLIDWORKS' **mate reference** reaches the same end by another route: up to three named entities
(primary/secondary/tertiary) baked into the part so it auto-mates on drag-and-drop — and a *named*
mate reference seeks out a matching name on insertion. That naming trick is how a library of
fasteners assembles itself.
> **[INDUSTRY] Out of scope now, but do not preclude it.** Give connectors a stable, user-visible
> name at creation. One string today; expensive to add once documents exist in the wild.
---
## 6. Confusion catalogue
Documented ways real implementations confuse people. Each is a requirement in disguise.
**C1 — Which way does Z point?** See D3. If a user has to ask once, they will mis-predict a hundred
times.
**C2 — The roll is unspecified.** Aligning Z leaves one rotation about Z undetermined. Something must
pin it, and if that something is world-derived, the frame does not rotate with its part. **This
codebase shipped exactly this bug** (`en4`): a face-only connector took Z from the face
normal but X from `coordsys_x_hint`, a world constant, so Fastened and Slider claimed to lock an
orientation the frame could not see. Fixed 2026-07-26 by deriving X from the face's own first usable
edge — but note the fix's own caveat: *"replaying an older document whose face-only connector fed a
mate can now place that body differently."* Roll conventions are load-bearing, and changing one is a
document-format change.
**C3 — The origin drifts.** See D1.
**C4 — Implicit and explicit connectors are not the same thing.** On the Onshape forum, implicit
connectors are reported to change their query structure when a feature is edited and re-accepted, and
are unusable in places explicit ones work. Two things called by one name that behave differently is a
permanent tax.
**C5 — Which part moves?** A frame alignment is asymmetric. If the UI does not say which frame is
driven, the user finds out by watching the wrong part jump.
**C6 — Which direction is a positive offset?** Onshape measures *"from the second Mate connector
selected to the first"* — the sign depends on pick order, and swapping the picks flips it. Documented
behaviour, documented surprise.
**C7 — One intent, several mates.** The SOLIDWORKS failure: expressing "this shaft is in this hole,
resting on this shoulder" as three constraints, then discovering the solver picked the mirror
configuration. Frame-based systems fix this by construction; the requirement is not to reintroduce it.
**C8 — Degenerate frames.** A circular face has no usable in-plane edge direction; a cylinder seam
projects to nothing; a picked edge parallel to Z gives a zero cross product. `datum_frame` handles all
three with fallbacks — the requirement is that a fallback be *visible*, because a silent fallback is
C2 wearing a different hat.
**C9 — Order dependence without a solver.** Onshape can say *"Onshape solves Mates simultaneously so
order won't affect a Mate."* A system that composes transforms in tree order cannot say that. Two
mates driving one body means the second wins and the first is a lie on screen.
**C10 — Mirrors and patterns.** A mirrored instance has a left-handed frame. Blindly mirroring a
connector gives a frame whose Z still points "out" but whose handedness flipped, so every rotation
runs backwards. Cheap to handle now, miserable to retrofit.
---
## 7. Requirements
Labelled **[INDUSTRY]** (what the frame-based systems do) or **[DEVIATION]** (we would depart).
### Definition
**R1 [INDUSTRY] — A mate connector is a frame attached to exactly one body.** No body, no connector.
*Test:* creating a connector without a body is rejected at creation, not at mate time.
→ **`CoordSysType::PointWorld` violates this.** It is a datum wearing a connector's name.
**R2 [INDUSTRY] — One kind of connector, not two.** No "implicit" connector that behaves differently
from an explicit one. If hover inference is offered, hovering *creates* an ordinary connector.
*Why:* C4. *Test:* everything that accepts a connector accepts any connector.
**R3 [INDUSTRY] — A mate names exactly one subgroup of free motion.** Fastened (0), Revolute (1),
Slider (1), Cylindrical (2), Planar (3), optionally Ball (3). *Why:* §3. *Test:* every type's free
set is closed; no type is "A and also B".
### Orientation
**R4 [INDUSTRY] — Everything is about Z. Say so once, in the UI.** *Test:* no mate parameter refers
to any other axis.
**R5 [DEVIATION] — Z is the outward material direction, and mates default to FACING.**
A mate would drive B's Z onto **−A's Z** by default, so picking two faces that should touch makes
them touch with no options changed. *Why:* it is the whole of C1.
**Cost and caveat:** this inverts today's default (`mate_flip=false` currently *aligns*), and I could
not establish from any vendor's documentation what their default actually is — the forum question in
D3 went unanswered precisely because it is undocumented. So this is marked a deviation on the honest
grounds that **I cannot prove the industry agrees with it.** If D3's live preview lands first, the
default matters much less, because the user sees the outcome before committing. See §9 D1.
**R6 [DEVIATION] — Name the two directions; do not ship a boolean called "flip".**
`Direction: Facing | Aligned`. Every surveyed vendor ships a flip arrow instead. A boolean requires
remembering what unticked means; two named values do not. Low risk, small improvement on the state of
the art.
**R7 [INDUSTRY] — Roll is picked, or a stored quarter turn. Never world-derived.**
X from a referenced edge or in-plane direction; failing that, a deterministic body-attached seed, with
**Rotate 90°** offered as a stored integer 0–3 on top (this is Onshape's "reorient secondary axis",
A6). *Why:* C2 and the world-constant bug this project already shipped. *Test:* rotate the parent
body by any angle; the connector's X rotates with it — *this test already exists* ("a face-only frame
rotates with its body").
**R8 [INDUSTRY] — A degenerate roll is reported, not absorbed.** *Test:* a connector on a full
cylindrical face reports "roll undefined — pick a direction" rather than silently taking a fallback.
### Placement
**R9 [INDUSTRY, Fusion] — Origin comes from a small closed set of named candidates.**
**Face centroid, arc/circle centre, edge midpoint, vertex.** Four. Each stored as
`(kind, topological reference)` and resolved at rebuild. *Why:* D1. *Test:* the stored kind is visible
in the card; a rebuild either resolves it or raises an error.
**R10 [INDUSTRY] — An unresolvable reference is an error, never a silent relocation.**
*Test:* delete the referenced face; the mate reports "connector A: face not found" and the body stays
where it was.
### Semantics without a solver
**R11 [DEVIATION] — A body is driven by at most one mate. The second is refused.**
**No surveyed system does this** — they all have solvers and all accept many mates per body. It is
forced on us by tree-order composition: a second mate on the same body silently overrides the first
and the screen shows a configuration satisfying only one stated intent (C9). *Test:* creating a
second mate whose moving body already has one is rejected, naming the existing mate.
This is the single largest departure in this document. See §9 D4.
> **A tempting misreading, checked and rejected.** It is easy to find the claim that Onshape mandates
> *"exactly one Mate between any two instances"*, which would make R11 an industry agreement rather
> than a deviation. **The Onshape page does not say that.** It says *"**Many assemblies require only**
> one Onshape Mate between any two instances"* and then lists, as an explicit remedy, *"**Use more
> than one Mate if necessary.**"* One mate per pair is Onshape's *typical case*, not its rule. R11
> remains a deviation and must be justified on our own architecture, not on theirs.
**R11a [DEVIATION] — The refusal list.** With no solver, these are unsupportable and must be refused
rather than half-done: a second mate on an already-driven body; cycles (A→B, B→A); closed loops
(A→B, A→C, B→C); relations *between* mates (gear, belt, rack-and-pinion, screw coupling); **joint
limits**, which nothing can enforce without a solver; and **dragging a body to exercise a free DOF**,
which requires keeping the body on the allowed manifold. Motion analysis and animation follow from the
same lack. *Requirement:* none of these may appear in the UI as something that half-works.
**R12 [DEVIATION] — The mate graph is an acyclic forest rooted at fixed bodies.** A body reached by
no mate is fixed; cycles are refused. Same root cause as R11. *Test:* A→B, B→A rejected at creation.
**R13 [INDUSTRY] — Free DOF are preserved from the current placement, and the user is told.**
Behaviour already matches Onshape (A8); the telling does not. *Test:* the card for any type with
DOF > 0 says which motions remain and that dragging exercises them.
**R14 [INDUSTRY] — State what mirroring does to a connector.**
*Checked in the code:* `datum_frame` ends with a Gram-Schmidt forcing a right-handed frame
(`ds.x = Y.cross(Z)`), so a connector resolved on a mirrored body comes out **right-handed, not
mirror-imaged**. Z follows the mirrored face's outward normal, X follows a mirrored edge, handedness
is re-imposed. Defensible — a mate on the mirrored part still turns the way its type says — but it
means a mirrored sub-assembly is *not* the mirror image of the original in its rotation sense.
*Requirement:* document it and pin it with a test. *Why:* C10.
### Feedback — the part that actually removes confusion
**R15 [INDUSTRY] — Before Confirm, the card answers four questions in words.** Which body moves;
which way Z points on each connector; how many DOF remain; what the offset is measured from.
**R16 [INDUSTRY] — Draw both frames live, with Z distinguishable, and ghost the result.**
Two triads with Z rendered differently from X/Y (length, arrowhead, colour). *Why:* D3 — the fastest
way to make a convention unequivocal is to show it. *Test:* both Z directions are readable in a
screenshot.
**R17 [INDUSTRY] — Show the DOF budget per body.** "Body 2: 1 of 6 DOF free (rotation about Z)."
The most educational readout in any assembly system, and free to compute here — the type *is* the DOF
count. *Test:* the number changes when the type changes.
**R18 [DEVIATION] — Refuse loudly and name the alternative.** Where something is out of scope (a
second mate, a tangency, a gear ratio), say what is unsupported and what to do instead. Vendors do not
need this because their solvers accept the input. *Test:* no refusal message ends without a suggested
next action.
---
## 8. Minimal specification, and gap analysis
### The connector
```
MateConnector
body int required, ≥ 0 (R1)
origin_kind enum FaceCentroid | ArcCentre | EdgeMidpoint | Vertex (R9)
origin_ref topo ref face / edge / vertex index on that body
z_source implied by origin_kind: face normal, arc axis, edge tangent
roll_ref topo ref optional in-plane edge; else deterministic seed (R7)
roll_quarters int 0..3 stored quarter turns on top of the seed (R7, A6)
flip_z bool reverse Z at the connector
name string stable, user-visible (D4)
```
`flip_z` is a property of the **connector**, chosen once when it is made — not a per-mate
afterthought. Keeping connector-flip and mate-direction separate is what stops the "which flip do I
tick?" question.
### The mate
```
Mate
kind enum Fastened | Revolute | Slider | Cylindrical | Planar [| Ball] (R3)
fixed connector A — its body does not move
moving connector B — its body is driven (A5, C5)
direction enum Facing | Aligned (R5, R6)
offset mm along A's Z, measured A → B — state this in the label (C6)
angle deg about A's Z (R4)
```
Within one field of what exists.
### Gaps against today
Source of record: `CadDocument.hpp:26,247-252,298-310`; `CadDocument.cpp:1669` (`datum_frame`),
`:2961` (`apply_mate`), `:1302` (`add_mate`); `DesignPanel.cpp:2671-2709` (the Mate card).
| # | Gap | Severity | Ref |
|---|---|---|---|
| G1 | `PointWorld` connectors are not attached to a body and their X is a world constant | **High — data model** | A4/R1 |
| G2 | Origin is always the face centroid; no vertex / edge-midpoint / arc-centre snap | **High — expressiveness** | D1/R9 |
| G3 | No live preview of the two Z arrows or of the resulting placement | **High — this is the brief** | D3/R16 |
| G4 | Mate card is two abstract dropdowns; nothing says which body moves | High — charter + A5 | R15 |
| G5 | No joint-type inference from the picked geometry | Medium — feel | D2 |
| G6 | `add_mate` validates nothing — no one-mate-per-body, no cycle check | Medium | R11/R12 |
| G7 | No `Ball` type | Low | §3 |
| G8 | Re-clocking needs a typed angle; no 90° step control | Low, cheap | A6/R7 |
| G9 | Degenerate roll falls back silently | Low | C8/R8 |
| G10 | Connectors have no stable user-facing name | Low now, expensive later | D4 |
**Already aligned — do not "fix" these:** the five types and their DOF; the frame definition (A1);
Z as the joint axis (A2); superimpose-then-relax (A3); the fixed/moving asymmetry in the data model
(A5); DOF wording in the type list (A7); free-DOF preservation (A8); right-handed frames under mirror
(R14); and `en4`'s fix, which put roll derivation on the body where it belongs (C2).
**The pattern worth naming: the kernel is in good shape and the concept is under-explained.** Half the
requirements here are wording and drawing, not geometry. The two real engineering items are R9 (origin
candidates) and R11/R12 (the mate-graph rules).
### Expensive-to-retrofit decisions — get these right in the data model now
Changing any of these after documents exist in the wild costs a migration, not an edit.
1. **Topological reference stability.** Storing raw face/edge indices is brittle — editing a body
renumbers faces. Either persistent topology IDs, or store the named origin *kind* plus a
deterministic search that re-finds the same geometric intent on rebuild. The latter is cheaper and
probably sufficient here; it is also what makes R10's "error, never silent relocation" enforceable.
2. **Connector ownership** (R1). Remove `PointWorld` or bind it to a body. Do this first.
3. **Mate direction semantics** (R5/D1). Inverting the default rewrites the meaning of every saved
mate.
4. **Roll representation** (R7). "First usable edge" is better than world-X but still fragile. Store
an explicit roll reference plus quarter turns.
5. **Coordinate convention** — Z = joint axis, X = roll reference. Changing this after release
invalidates every mate.
6. **Units** — offset in mm, angle in degrees. Never change.
7. **Mirror handedness** (R14) — document the decision, do not let it stay an accident.
8. **Flat body index vs. a component tree.** Mates currently reference bodies in a flat vector. If
**sub-assemblies** are ever in scope, mates must reference nodes in a tree instead. Retrofitting
this is painful and it is the one item on this list not already implied elsewhere in the document —
**decide now whether nested assemblies are in scope.**
9. **Serialization field semantics.** Adding fields is easy; redefining `mate_flip` or
`coordsys_x_hint` is not.
10. **The one-mate-per-body rule** (R11). Enforce at creation. Relaxing it later by adding a solver is
straightforward; allowing many mates now and discovering later that they silently conflict is not.
---
## 8b. The visual shape of the connector — polarity and verse
Researched separately (2026-08-05) by downloading and **looking at** the vendors' own figures, not
by reading their prose. Files kept alongside this document in `doc/design/mate-connectors/`.
### What the systems actually draw
**Onshape** — verified from `planarfacemateconnectors.png`, `cylindricalmateconnectors.png`,
`linearedgemateconnectors.png`, `mateconnector-planarpoints.png`, `matepointiconLG.png`:
> **A small circle with one quadrant filled, plus three short coloured axis arms (X red, Y green,
> Z blue).**
Three parts, each doing one job:
| Element | What it says |
|---|---|
| The **circle** | "I am a frame, and this is my XY plane." |
| The **filled quadrant** | **The roll.** The shaded sector is the +X/+Y quadrant. |
| The **coloured arms** | The three axis directions, Z distinguished by colour. |
The quadrant is the cleverest part of the whole design and it is easy to miss. The figure
`matepointreorientsecondaryaxis.png` shows three connectors side by side with the quadrant in three
different rotations — **it is the live readout of "reorient secondary axis in 90° increments" (A6).**
One glyph element makes the otherwise-invisible clocking visible, and makes the 90° button's effect
legible before you commit. The toolbar icon `matepointiconLG.png` is that same circle-with-a-quadrant,
so the symbol is consistent from toolbar to viewport.
Candidate snap points, before you choose one, are drawn as **plain small white dots** on the model
(clear in `mateconnector-planarpoints.png`: dots at every corner and edge midpoint). Candidate and
committed are deliberately different weights — dots propose, the circle-and-triad commits.
**FreeCAD 1.0** — verbatim from the wiki: *"Connectors are local coordinate systems and are marked by
a symbol with three axes (X, Y, Z) and a circle representing the XY-plane."* Same core as Onshape —
circle plus triad — **without** the quadrant.
**Fusion 360** — the joint origin glyph, plus a documented icon language for *candidates*: *"A circle
denotes a vertex, and a triangle denotes a midpoint."* Shape encodes what kind of point it is.
**Convergent core:** *circle for the XY plane + coloured triad*. Onshape alone adds the roll quadrant.
### What none of them draw — and it is exactly what was asked for
**Nothing in any vendor's glyph says which connector is the reference and which one is about to
move.** Both ends of a mate are drawn identically. That is confusion C5 ("which part moves?") left
unsolved in the visual language, and it is why the honest recommendation earlier was a live ghost —
the ghost compensates for a glyph that does not carry the information.
So the two things asked for split cleanly, and only one of them is solved upstream:
- **Verse** (*verso* — which way it points): **solved**. Z has a colour and a direction.
- **Polarity** (which end receives, which end inserts; who is anchored, who travels): **unsolved
everywhere.** This is open ground, and getting it right is a genuine improvement rather than a
deviation to justify.
### Our starting point
**We draw nothing.** `resolve_datum_coordsys()` (`CadDocument.cpp:1749`) has exactly one consumer in
the entire tree — `McpControl.cpp:1310`, the agent socket. A mate connector is today visible only to
a program. The glyph is unbuilt, so there is no migration cost to designing it properly now.
### Proposed glyph: the magnet
Adopt Onshape's proven core, then add the missing polarity with a metaphor that carries its own
instructions.
```
▲ solid cone on +Z ONLY ← verse
|
────●──── ← the disc = XY plane, ● = exact origin
▨ quadrant filled ← roll / clocking, steps 90°
```
**Rule 1 — verse: draw +Z and never −Z.** A single stem with a cone head, on the positive side only.
No stem below the disc. A double-headed axis is the one thing that guarantees the question gets asked;
an arrow that exists on one side only cannot be misread. Length is asymmetric on purpose.
**Rule 2 — roll: keep Onshape's quadrant.** Filled sector = the +X/+Y quadrant. It rotates in 90°
steps with the reorient control (A6/R7). This is aligned *and* it is the only in-glyph answer to
"where is X?", which matters because Fastened and Slider lock the clocking.
**Rule 3 — polarity: solid cone travels, open collar receives.**
- The **driven** connector (B, on the body that will move) draws a **solid filled cone** — the plug.
- The **fixed** connector (A) draws an **open ring / hollow cone outline** — the socket.
Same silhouette, so they read as a matched pair; opposite fill, so which one is about to jump is
answerable at a glance and without a legend. Plug-into-socket is the one mechanical metaphor every
user of this tool already has in their hands.
**Rule 4 — the pair reads as a magnet.** Draw a dashed line joining the two origins the moment both
are picked. Two poles, one field line. And because a magnet's north seeks a south, **"facing" becomes
the self-evident default** — which quietly settles open decision D1 (§9) on visual grounds rather than
on a convention nobody can look up. If the glyph looks like a magnet, nobody has to be told that two
faces which touch have opposed normals.
**Rule 5 — three states, three weights.**
| State | Drawing |
|---|---|
| **Candidate** (hover) | small dot only — Onshape's white dots; shape may encode kind, Fusion-style |
| **Picked** | full glyph: disc + quadrant + cone |
| **Degenerate roll** (C8/R8) | the quadrant is drawn **hollow/hatched** — "roll undefined, pick a direction" |
That last row is worth the trouble: it turns R8 from a message nobody reads into a mark you cannot
miss, and it costs one branch in the renderer.
**Rule 6 — do not reuse the existing triad.** The bed-centre world triad
(`DesignCanvas.cpp:65`, `set_axes_at_bed_center`) and the move gizmo are already three-coloured arrows.
The connector must not be a fourth set of RGB arrows or the viewport becomes unreadable. The disc and
the quadrant are what distinguish it; keep the arms short, and consider drawing only Z on the
committed glyph, with X/Y implied by the quadrant.
### Built and judged in the viewport, not in a mock
The browser mock that first accompanied this section was the wrong instrument and its proportions
were meaningless: **every gizmo in this codebase is sized in SCREEN PIXELS** via `upp = 1/zoom`
(`render_shell_gizmo` uses `15.0 * upp`, `render_hole_gizmo` `9.0 * upp` for its cube). A connector
is a symbol, not a part — it must not shrink with the model. Nothing about that is visible in SVG.
The glyph was therefore implemented and driven on the rig. Screenshots: `g-0*.png`, left in the workspace `artifacts/shots/` and not moved into the repo.
Five findings, none of which a mock could have produced:
**F1 — Three axis arms lose to one.** Rendered side by side (`ORCA_CAD_GLYPH=A` vs default), the
Onshape-style RGB trio crowds a 22 px disc: the arrowheads are as large as the disc, they bury the
gold quadrant, and at an oblique angle the three heads pile into a coloured smudge. Worse, **it is
indistinguishable from the move gizmo and the bed triad**, which are already RGB arrow trios in this
viewport. One-sided Z wins on evidence, not taste. (`g-01-zoom.png` vs `g-02-zoom.png`.)
**F2 — Polarity works, and colour does more of the work than fill.** A filled blue head against an
open grey outline head is readable instantly at 22 px (`g-03-zoom.png`). But the fill difference is
the *second* cue; the colour split carries it. Keep both — fill survives greyscale and colour-blind
palettes, colour survives small size.
**F3 — Depth off floats, depth on tears.** With `GL_DEPTH_TEST` off, connectors on faces pointing
*away* from the camera still drew their discs over the solid, so the part looked covered in frames
that were really on its back. Turning depth on fixed that and immediately caused **z-fighting**: the
disc is exactly coplanar with its face, and came out as a broken dotted arc. The fix is depth **on**
plus a sub-pixel lift along Z (`0.7 * upp`), scaled by `upp` so it never becomes a visible gap on
zoom-in. Both failure modes are in the images (`g-03` torn, `g-04` clean).
**F4 — The quadrant is the first thing to die at a grazing angle.** On a face seen nearly edge-on the
disc foreshortens to a sliver and the fan collapses into a blob (`g-01-zoom.png`, lower-right glyph).
The roll is exactly the information that is hardest to read when you most need it. Not yet solved —
see the open item below.
**F5 — Roll-undefined in red is too loud.** It works, but it makes the *least* important connector
the most eye-catching thing on screen. Amber, or the same grey with a hatched quadrant, is enough.
Also surfaced while testing, and unrelated to the glyph: `add_mate` accepted a mate between two
connectors **on the same body**, which is meaningless, and duly transformed the body relative to
itself. Concrete instance of gap G6.
**Still untested:** a true grazing view (the view-cube click missed), a connector on a curved face,
and behaviour when a connector overlaps the move gizmo. F4 is the open design question — the disc may
need to billboard its *quadrant* while keeping the disc in-plane, which is a compromise no surveyed
vendor makes and which should be tried before being adopted.
### What this costs
A renderer for `resolve_datum_coordsys()` — which does not exist and has to be written whatever glyph
is chosen — plus one dashed line and three fill states. No kernel work. It is the same piece of work
as G3 (live preview), and doing them together is what makes the mate card honest.
---
## 8c. The "faceted ridge dome" proposal — built, rendered, judged
A colleague proposed replacing the flat disc with an **asymmetric low-poly solid**: a faceted
prismatic wedge with a dominant longitudinal ridge that **slopes** from a tall steep back to a long
shallow front, plus a male protrusion / female pocket pair with a 0.2 mm clearance.
It was built rather than discussed. `faceted_ridge_key.scad` (this folder) (6 vertices, 7 faces),
verified as a closed manifold, exported through OpenSCAD, and flat-shaded from five directions with
`render_key.py` / `render_stl.py`. Sheets: `rk-sheet.png`, `cmp-sheet.png`.
### The verdict: the shape is right, the male/female polarity cue is not
**It solves F4, decisively.** The grazing view — where the flat disc dies, its quadrant collapsing to
a blob — is the view where this shape is *most* legible: the tall back and long shallow front are
unmistakable in silhouette. At a grazing angle the silhouette IS the information, and this solid's
silhouette is maximally informative there. That is a real, evidence-backed win over what is currently
in the code.
**Down the mating axis (+Z) it also reads well**, which matters because that is the natural viewing
direction when you are looking at a face you intend to mate.
**One degenerate view, and it is not the one I predicted.** I expected the ±X views (along the ridge)
to be silhouette-ambiguous, resolved only by shading. Wrong: front and back are clearly *different* —
the front shows several facets, the back is a **single flat featureless triangle**. So they are not
confusable, but the view from directly behind the tall end tells you nothing about roll or slope.
A second blind spot remains untested: from below the base, where the protrusion is hidden behind its
own face.
**The female half fails, and much harder than expected.** Rendered with flat shading and no outlines —
the honest test, since a viewport draws no black edges — a recessed pocket is *invisible*: iso and
grazing show a plain block with a hairline; straight down the axis shows a **completely blank
rectangle**. The interior faces are lit almost identically to the top face and are occluded by the rim
from most angles. As a polarity cue, male/female therefore works in exactly one direction and returns
nothing in the other.
> **Conclusion: do not overload shape with all three jobs.** Let the solid carry **verse and roll**,
> where it is excellent, and carry **polarity on a second channel** — colour plus the filled/open head
> that already tested well at 22 px (F2). Drawing the fixed connector as an outline/wireframe of the
> same solid is the variant worth trying; drawing it as a pocket is not.
### Two premises in the brief are wrong
**"Avoid curved surfaces to optimise rendering computations / rapid mesh processing."** Not a reason
for a viewport glyph. There are 2–20 connectors on screen, the renderer pushes `GLModel` triangles
directly, and it performs no CSG or mesh processing at all. **The real argument for flat facets is
legibility**: hard normals give distinct value steps between adjacent facets, and the renders confirm
that is exactly what makes the shape readable from an arbitrary angle. Keep the constraint, fix the
justification. (For a *printed* part the original justification is sound for a different reason: flat
facets slice without the stair-stepping a tessellated curve produces.)
**"0.2 mm clearance for smooth mechanical mating."** Meaningless for a glyph. A symbol mates with
nothing, and every gizmo here is sized in screen pixels via `upp`, so a millimetre tolerance has no
referent. This is the strongest signal that **the brief was written for a physical printed part**,
not for a viewport symbol — as are "scannable" and "mechanical mating". See the open question below.
### Two defects the build caught that discussion would not have
1. **The flank quads are not planar.** Written as `[0,3,5,4]` and `[1,4,5,2]` the base edge and the
ridge edge are skew, so the four corners do not share a plane — my own first draft asserted the
opposite in a comment. Left as quads, the tessellator picks the fold direction, the "flat facet"
promise is broken by an unspecified crease, and two exporters can disagree about the shape. Fixed
by triangulating explicitly (7 faces, Euler 6 − 11 + 7 = 2).
2. **The pocket punched through its own plate.** A 4.5 mm key against a 3 mm demo plate gives a
through-hole, not a pocket. Minimum stock = height + clearance + pocket depth + a wall.
Also worth recording: the first female render was misleading because the debug renderer outlined
*every* triangle, so a flat top face triangulated by CGAL looked like a faceted dome. The instrument
lied before the geometry did. Conclusions were only drawn after outlines were removed.
### Second opinion, and the one disagreement worth resolving
Kimi reviewed the proposal independently and **rejected it for the viewport**. It agreed on the two
wrong premises, agreed the female pocket is unreadable, and added the useful framing that a
screen-constant symbol and a model-constant part feature are two different design spaces that cannot
be served by one geometry. It also noted correctly that there is **no single scalar** that removes
ambiguity from every view: you need one asymmetry in the base plane (for top-down roll) and one out
of plane (the ridge slope, for front/back). Our base is scalene, so it has both.
Its central objection was numeric and testable: *"at 22 px with 6–8 facets each facet is 3–7 px wide,
that is at the aliasing limit … minimum useful size is roughly 32–48 px, which is not compatible with
a 22 px screen-constant symbol."* My own renders were ~300 px, so the claim was unaddressed by my
evidence and would have killed the concept if true.
**Rendered at 22, 32 and 48 px (`size-test.png`), it is false for this shape.** At 22 px all three
views still read: the grazing view shows the tall back and shallow front unmistakably, and the
down-axis view keeps a strong dark/light split. The reason Kimi's arithmetic does not apply is that
this solid presents only **four or five large facets with high value contrast**, not eight small ones —
the silhouette does most of the work, and silhouettes survive downsampling far better than facet
detail does.
*Honest limit on that result:* the test renderer has no anti-aliasing, no perspective, one directional
light, and no background. Readable at 22 px against white is not the same as readable at 22 px on top
of a shaded gold part next to the move gizmo. That case still needs the rig.
**Where I do not follow Kimi:** its recommendation is to **billboard** the existing flat glyph so it
never turns edge-on. That kills F4 by construction, but a billboarded frame cannot show the frame's
orientation *in place* — which is the entire reason the disc is a disc and not a dot — and it is what
no surveyed CAD system does; Onshape, Fusion and FreeCAD all draw the frame in the geometry. Worth
prototyping as an option, not worth adopting on argument.
### Open question for Tommaso
**Is this a viewport glyph or a printable alignment feature?** The vertex logic is identical either
way; only the units and the clearance change, and the `.scad` file states both readings. But the
answer decides whether `clr`/`depth` are real millimetres or meaningless, and whether the geometry
scales with the model or stays screen-constant. The brief's own language points at "physical", the
conversation it arrived in points at "glyph".
---
## 9. Decisions for you
**D1 — Invert the default direction to Facing?** [DEVIATION, R5]
It changes the meaning of every stored document containing a mate. Options: (a) invert and migrate,
writing `direction=Aligned` where `mate_flip` was false; (b) invert only for new mates and store
`direction` explicitly from now on. (b) is safer and costs one field. Note this project has taken one
such semantic hit knowingly before — the `en4` fix — and the golden fixture survived, so the
migration path is a known quantity. **If G3 (live preview) lands first, this matters much less.**
**D2 — How far to take origin candidates?** [R9]
Four kinds is the Fusion-aligned recommendation. Two (face centroid + arc centre) would cover "sit on
a face" and "go down a hole" — most printed-part assembly — at a third of the work. Where do you want
to stop?
**D3 — Ball mate: in or out?**
In four of five frame-based systems, so including it is the aligned choice. Out is defensible for
printable mechanical parts. Cheap either way — align origins, leave orientation free. Kimi's review
argued **out**: a true ball joint is hard to print and hard to use without a roll reference, and a
Fastened connector at the ball centre approximates it.
**D3a — Should Planar be dropped?** [dissent worth recording]
Kimi's independent review recommended **removing Planar** and shipping four types, on the grounds that
"slide on a flat surface" is rarely how printed mechanisms work — you usually want a rail or a hinge —
and that Planar is the type most likely to confuse a user who expected "put this flat on that" and got
a part free to slide. It further ranked the honest minimum as **three**: Fastened, Revolute, Slider,
with Cylindrical useful and decomposable.
**I do not agree, and the reason is alignment.** Planar appears in every frame-based system surveyed,
it is a genuine lower pair, it is already implemented and tested, and removing it is a document-format
change made in exchange for nothing. The confusion Kimi names is real but it is a *feedback* problem —
it is exactly what R17 (show the DOF budget) and R13 (say that free DOF are preserved) exist to fix.
Recorded here because it is a legitimate reading of the same evidence and the call is yours.
**D4 — Is refusing a second mate per body acceptable?** [DEVIATION, R11 — the big one]
It is the honest consequence of having no solver, and it is what makes the tool predictable. But **no
mainstream system behaves this way**, so it is the point where an experienced user's intuition will
break. It means a part cannot be constrained by two independent relationships — "in this hole *and*
resting on this shoulder" must be expressed by placing one connector correctly rather than by two
mates. If that trade is unacceptable, the answer is a solver, and the scope of this document changes
entirely.
There is a strong argument that the trade is not merely acceptable but *correct for this product*:
the Design tab lives inside a slicer, and most of its users are positioning parts for printing rather
than building working mechanisms. For layout-and-export, tree-order composition is genuinely enough,
and adding a solver to look like Onshape would buy complexity nobody asked for. The rule to publish is
then simple and defensible: **one mate per moving body, acyclic, no relations between mates** — with
R18's loud refusals carrying the honesty.
---
## Sources
**Onshape** — [Mate Connector](https://cad.onshape.com/help/Content/PartStudio/mate_connector.htm) ·
[Mates](https://cad.onshape.com/help/Content/Assembly/mates.htm) ·
[Fastened](https://cad.onshape.com/help/Content/Assembly/fastened_mate.htm) ·
[Revolute](https://cad.onshape.com/help/Content/Assembly/revolute_mate.htm) ·
[Slider](https://cad.onshape.com/help/Content/Assembly/slider_mate.htm) ·
[Cylindrical](https://cad.onshape.com/help/Content/Assembly/cylindrical_mate.htm) ·
[Planar](https://cad.onshape.com/help/Content/Assembly/planar_mate.htm) ·
[Ball](https://cad.onshape.com/help/Content/Assembly/ball_mate.htm) ·
[Parallel](https://cad.onshape.com/help/Content/Assembly/parallel_mate.htm) ·
[Tangent](https://cad.onshape.com/help/Content/Assembly/tangent_mate.htm) ·
[Pin Slot](https://cad.onshape.com/help/Content/Assembly/pin_slot_mate.htm) ·
[5 things you can do with mate connectors in Part Studios](https://www.onshape.com/en/resource-center/tech-tips/tech-tip-5-things-you-can-do-with-mate-connectors-in-onshape-part-studios)
**Onshape forum** — [The concept behind Mates Z Axes](https://forum.onshape.com/discussion/22828/the-concept-behind-mates-z-axes) (C1/D3) ·
[Implicit mate connectors act differently than explicit ones](https://forum.onshape.com/discussion/15736/implicit-mate-connectors-act-differently-than-explicit-ones) (C4) ·
[Efficiently set mate connectors](https://forum.onshape.com/discussion/13133/efficiently-set-mate-connectors)
**Fusion 360** — [Joint types](https://help.autodesk.com/cloudhelp/ENU/Fusion-Assemble/files/GUID-8818AE31-958A-4A59-989B-9875A174C67A.htm) ·
[Joint origins](https://help.autodesk.com/view/fusion360/ENU/?guid=ASM-JOINT-ORIGIN) ·
[Joints vs. Mates in Fusion](https://www.autodesk.com/products/fusion-360/blog/joints-mates-moving-fusion/) ·
[Joint tips — snap points and Ctrl cycling](https://mgfx.co.za/blog/engineering-manufacturing-design/fusion-360-joint-tips/)
**Inventor** — [Create Joints Reference](https://help.autodesk.com/cloudhelp/2026/ENU/Inventor-Help/files/GUID-6AA68E8F-7C97-4806-8483-3941DE915E70.htm) ·
[Use Joint to define and manage relationships](https://knowledge.autodesk.com/support/inventor-products/learn-explore/caas/CloudHelp/cloudhelp/2014/ENU/Inventor/files/GUID-21DC3336-5C51-42C1-90FB-4299CD66E0C6-htm.html) (type inference, D2)
**FreeCAD 1.0** — [Assembly Workbench](https://wiki.freecad.org/Assembly_Workbench) ·
[Fixed Joint properties](https://wiki.freecad.org/Assembly_CreateJointFixed)
**Creo** — [About Predefined Constraint Sets](https://support.ptc.com/help/creo/creo_pma/r12/usascii/assembly/asm/About_Predefined_Constraint_Sets.html)
**Siemens NX** — [Assembly constraints](https://learnnx.com/lesson/siemens-nx-assemblies-assembly-constraints/)
**SOLIDWORKS** — [Mate References](https://help.solidworks.com/2025/English/SolidWorks/sldworks/c_Mate_References_Overview_SWassy.htm) ·
[Creating and using mate references](https://blogs.solidworks.com/tech/2019/07/creating-and-using-mate-references.html)
**Theory** — [Hervé, The Lie group of rigid body displacements, a fundamental tool for mechanism design](https://www.sciencedirect.com/science/article/abs/pii/S0094114X98000512) ·
[Joint kinematics — the six lower pairs and their DOF](https://erc-bpgc.github.io/handbook/mechanical/Joint%20Kinematics/) ·
[ISO 10303-105 — Kinematics (STEP integrated resource)](https://www.iso.org/standard/78589.html)
**Internal** — `en4` (closed 2026-07-26, fixes C2 here) · `CadDocument.cpp:1669`
`datum_frame` · `CadDocument.cpp:2961` `apply_mate` · `CadDocument.cpp:1302` `add_mate`
**Second opinion** — an independent review by Kimi Code (2026-08-05) contributed the
vendors-ship-both caveat (§1), the explicit-dropdown option for origin choice (D1), the expanded
refusal list (R11a), the retrofit list (§8), and the dissents recorded at D3/D3a. One of its claims —
that Onshape mandates *"exactly one Mate between any two instances"* — **was checked against the
source and is wrong**; the correction is recorded at R11 because it is a misreading that would
otherwise turn our largest deviation into a false agreement.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,30 +0,0 @@
// 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

@@ -1,299 +0,0 @@
<!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>
@@ -1,68 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,140 +0,0 @@
// 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.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-226
View File
@@ -1,226 +0,0 @@
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
@@ -1,12 +0,0 @@
// 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();
}
@@ -1,20 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -1,99 +0,0 @@
"""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")
@@ -1,143 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 35 KiB

@@ -1,112 +0,0 @@
"""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.

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -1,135 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1,71 +0,0 @@
"""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.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -1,99 +0,0 @@
"""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")
@@ -1,9 +0,0 @@
# 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)}")
@@ -1,85 +0,0 @@
#!/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))
@@ -1,76 +0,0 @@
#!/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.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

@@ -1,142 +0,0 @@
"""Reduce the bear face to the fewest marks that still read at glyph size — wi3z.
Geometry comes from bear_outline.json, which extract_outline.py pulled off the supplied male
B-rep's back plate: the outer wire IS the silhouette, the inner wires are the two eyes and the
muzzle opening. Nothing here is traced by eye.
The glyph is drawn IN the connector's plane, so a grazing view foreshortens it along one axis by
sin(elevation) — exactly what collapsed the disc's roll quadrant to 3 pixels at 10 deg. Every
candidate is therefore rendered at three elevations as well as three pixel sizes.
"""
import json, math, os
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
D = json.load(open(os.path.join(HERE, "bear_outline.json")))
def norm(pts):
"""Part frame (X right, Z down-negative) -> glyph frame (x right, y up), centred, unit height."""
p = [(x, -z) for x, z in pts]
return p
outer = norm(D["outer"])
holes = [norm(h["pts"]) for h in D["holes"]]
# the two Ø9.8 wires are the eyes; the wide one is the muzzle
eyes = [h for h, meta in zip(holes, D["holes"]) if meta["d"] < 20]
muzzle = [h for h, meta in zip(holes, D["holes"]) if meta["d"] >= 20]
ALL = outer + [p for h in holes for p in h]
xs = [p[0] for p in ALL]; ys = [p[1] for p in ALL]
CX, CY = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2
SPAN = max(max(xs)-min(xs), max(ys)-min(ys))
def to_unit(pts): return [((x-CX)/SPAN, (y-CY)/SPAN) for x, y in pts]
def rdp(pts, eps):
"""Douglas-Peucker. Vertex count is the honest measure of 'how simplified'."""
if len(pts) < 3: return pts
ax, ay = pts[0]; bx, by = pts[-1]
dx, dy = bx-ax, by-ay
n = math.hypot(dx, dy)
best, bi = -1.0, 0
for i in range(1, len(pts)-1):
px, py = pts[i]
d = abs(dx*(ay-py) - (ax-px)*dy)/n if n > 1e-12 else math.hypot(px-ax, py-ay)
if d > best: best, bi = d, i
if best <= eps:
return [pts[0], pts[-1]]
return rdp(pts[:bi+1], eps)[:-1] + rdp(pts[bi:], eps)
def simp_closed(pts, eps):
r = rdp(pts + [pts[0]], eps)
return r[:-1]
def centroid(pts):
return (sum(p[0] for p in pts)/len(pts), sum(p[1] for p in pts)/len(pts))
U_OUT = to_unit(outer)
U_EYE = [to_unit(e) for e in eyes]
U_MUZ = [to_unit(m) for m in muzzle]
def eye_dots(scale=1.0):
out = []
for e in U_EYE:
cx, cy = centroid(e)
r = max(max(p[0] for p in e)-min(p[0] for p in e),
max(p[1] for p in e)-min(p[1] for p in e))/2*scale
out.append((cx, cy, r))
return out
def muzzle_tri():
"""The muzzle reduced to one filled triangle: its two lower corners and its apex."""
m = U_MUZ[0]
lo = min(p[1] for p in m); hi = max(p[1] for p in m)
bottom = [p for p in m if p[1] < lo + 0.06*(hi-lo)]
apex = max(m, key=lambda p: p[1])
return [min(bottom), max(bottom), apex]
CANDIDATES = {
"C0 full": dict(out=U_OUT, eyes=eye_dots(), muz=U_MUZ[0]),
"C1 eps .004": dict(out=simp_closed(U_OUT, .004), eyes=eye_dots(), muz=simp_closed(U_MUZ[0], .004)),
"C2 eps .012": dict(out=simp_closed(U_OUT, .012), eyes=eye_dots(), muz=muzzle_tri()),
"C3 eps .030": dict(out=simp_closed(U_OUT, .030), eyes=eye_dots(1.15), muz=muzzle_tri()),
"C4 no eyes": dict(out=simp_closed(U_OUT, .012), eyes=[], muz=muzzle_tri()),
}
def sym_report(pts, tol=0.02):
"""Trivial symmetry group is the property doing the work. If a simplification restores a
mirror or a 180 deg rotation, that simplification is wrong."""
def match(tf):
t = [tf(p) for p in pts]
hit = 0
for q in t:
if min(math.hypot(q[0]-p[0], q[1]-p[1]) for p in pts) <= tol: hit += 1
return hit, len(pts)
return {
"mirror-x": match(lambda p: (-p[0], p[1])),
"mirror-y": match(lambda p: ( p[0], -p[1])),
"rot-180": match(lambda p: (-p[0], -p[1])),
}
def render(c, px, elev_deg, supersample=8):
S = px*supersample
img = Image.new("L", (S, S), 0)
d = ImageDraw.Draw(img)
k = math.sin(math.radians(elev_deg))
def m(p):
return (S/2 + p[0]*S*0.92, S/2 - p[1]*S*0.92*k)
d.polygon([m(p) for p in c["out"]], fill=255)
if c["muz"]: d.polygon([m(p) for p in c["muz"]], fill=0)
for cx, cy, r in c["eyes"]:
a = m((cx-r, cy+r)); b = m((cx+r, cy-r))
d.ellipse([a[0], a[1], b[0], b[1]], fill=0)
return img.resize((px, px), Image.LANCZOS)
print(f"{'candidate':14} {'verts':>6} {'marks':>6} symmetry (matched/total, lower is better)")
print("-"*78)
for name, c in CANDIDATES.items():
s = sym_report(c["out"])
marks = 1 + (1 if c["muz"] else 0) + len(c["eyes"])
sym = " ".join(f"{k} {v[0]}/{v[1]}" for k, v in s.items())
print(f"{name:14} {len(c['out']):6} {marks:6} {sym}")
SIZES = [22, 32, 48]
ELEVS = [(90, "flat on"), (47, "47 deg"), (16, "16 deg"), (6, "6 deg")]
pad, cell = 8, 56
W = pad + len(SIZES)*len(ELEVS)*cell + pad
H = pad + len(CANDIDATES)*cell + pad
sheet = Image.new("RGB", (W, H), (24, 27, 32))
for r, (name, c) in enumerate(CANDIDATES.items()):
for ci, (elev, _) in enumerate(ELEVS):
for si, px in enumerate(SIZES):
g = render(c, px, elev)
tile = Image.new("RGB", (px, px), (24, 27, 32))
gold = Image.new("RGB", (px, px), (237, 168, 23))
tile.paste(gold, (0, 0), g)
x = pad + (ci*len(SIZES)+si)*cell + (cell-px)//2
y = pad + r*cell + (cell-px)//2
sheet.paste(tile, (x, y))
sheet = sheet.resize((W*2, H*2), Image.NEAREST)
sheet.save(os.path.join(HERE, "simplify-sheet.png"))
print("\ncolumns: " + " | ".join(f"{e[1]} @ 22/32/48px" for e in ELEVS))
print("rows: " + ", ".join(CANDIDATES))
print("WROTE simplify-sheet.png")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

@@ -1,74 +0,0 @@
# Trim the boxy frame off the female so its outer shape is the bear face itself.
#
# Method: take the male's flat back face (the plane Y=0 -- that face IS the bear silhouette),
# offset its OUTER wire outward in 2D, extrude the result along the insertion axis, and keep only
# the part of the female inside it. Everything outside is the block frame and goes away.
#
# NOTE ON THE NUMBER. The pocket's side walls stand at +0.20 mm from the male outline, because that
# is the clearance. A trim boundary at +0.10 mm therefore falls INSIDE them by 0.10 mm and removes
# the side wall entirely rather than leaving a thin one. The script runs the requested value and
# then measures what is actually left, so the outcome is a number rather than an opinion; it also
# emits a second variant at an offset that leaves a printable wall, for comparison.
#
# Run: /snap/bin/freecad.cmd trim_female.py
import os, sys
import FreeCAD as App
import Part
from FreeCAD import Vector
HERE = os.path.dirname(os.path.abspath(__file__))
MALE = os.path.join(HERE, "bear.step")
FEMALE = os.path.join(HERE, "BearConnector_Female.step")
REQUESTED = 0.10 # as asked
CLEARANCE = 0.20 # what the pocket was built with
SAFE_WALL = 1.60 # a wall that survives an FDM nozzle: clearance + ~1.4 mm
male = Part.Shape(); male.read(MALE); male = male.Solids[0]
fem = Part.Shape(); fem.read(FEMALE); fem = fem.Solids[0]
print(f"female in : {fem.Volume/1000:.2f} cm3, {len(fem.Faces)} faces")
# --- the bear silhouette: the male's flat back face at Y = 0
back = None
for f in male.Faces:
n = f.normalAt(0, 0)
if abs(f.CenterOfMass.y) < 1e-6 and abs(abs(n.y) - 1.0) < 1e-6:
if back is None or f.Area > back.Area:
back = f
if back is None:
print("FAIL: could not find the flat back face at Y=0"); sys.exit(1)
print(f"silhouette: back face area {back.Area:.1f} mm2, {len(back.Wires)} wires "
f"(outer + {len(back.Wires)-1} holes: eyes and mouth)")
fb = fem.BoundBox
y0, y1 = fb.YMin - 5.0, fb.YMax + 5.0
def trimmed(offset):
"""keep only the part of the female inside the silhouette grown by `offset`"""
wire = back.OuterWire
grown = wire.makeOffset2D(offset, join=2, fill=False, openResult=False, intersection=True)
face = Part.Face(Part.Wire(grown.Edges))
prism = face.extrude(Vector(0, y1 - y0, 0))
prism.translate(Vector(0, y0 - face.CenterOfMass.y, 0))
return fem.common(prism)
for tag, off, out in (("requested", REQUESTED, "BearConnector_Female_Trimmed.step"),
("safe wall", SAFE_WALL, "BearConnector_Female_Trimmed_wall.step")):
r = trimmed(off)
if not r.Solids:
print(f"\n{tag} (+{off:.2f} mm): NOTHING LEFT"); continue
wall = off - CLEARANCE
# is there any material left at the level of the pocket's side wall?
sec = r.section(Part.makePlane(400, 400, Vector(-200, 1.5, -200), Vector(0, 1, 0)))
perim = sum(e.Length for e in sec.Edges)
print(f"\n{tag} (+{off:.2f} mm) wall = {wall:+.2f} mm")
print(f" volume {r.Volume/1000:.2f} cm3, {len(r.Solids)} solid(s), {len(r.Faces)} faces")
print(f" section through the pocket wall at Y=1.5: {perim:.1f} mm of edge")
if wall <= 0:
print(f" -> the trim cuts {abs(wall):.2f} mm INSIDE the pocket wall: no side wall remains")
doc = App.newDocument(tag.replace(" ", "_"))
o = doc.addObject("Part::Feature", "Female")
o.Shape = r; doc.recompute()
Part.export([o], os.path.join(HERE, out))
print(f" wrote {out}")
@@ -1,25 +0,0 @@
# Check both trimmed females still fit the male, and export STLs for a visual comparison.
# Run: /snap/bin/freecad.cmd verify_trimmed.py
import os
import Mesh, Part
HERE = os.path.dirname(os.path.abspath(__file__))
male = Part.Shape(); male.read(os.path.join(HERE, "bear.step")); male = male.Solids[0]
for name in ("BearConnector_Female_Trimmed", "BearConnector_Female_Trimmed_wall"):
p = os.path.join(HERE, name + ".step")
s = Part.Shape(); s.read(p); s = s.Solids[0]
d = male.distToShape(s)[0]
c = male.common(s)
cv = c.Volume if c.Solids else 0.0
bb = s.BoundBox
print(f"{name}")
print(f" {bb.XLength:.2f} x {bb.YLength:.2f} x {bb.ZLength:.2f} mm, {s.Volume/1000:.2f} cm3, "
f"{len(s.Faces)} faces, valid={s.isValid()}")
print(f" gap to male {d:.4f} mm, interference {cv:.6f} mm3")
m = Mesh.Mesh(); m.addFacets([tuple(t) for t in s.tessellate(0.12)[1]] and
[(s.tessellate(0.12)[0][a], s.tessellate(0.12)[0][b],
s.tessellate(0.12)[0][c2])
for a, b, c2 in s.tessellate(0.12)[1]])
m.write(os.path.join(HERE, name + ".stl"))
print(f" wrote {name}.stl ({m.CountFacets} facets)")
-333
View File
@@ -1,333 +0,0 @@
# The Design tab
Object-driven parametric CAD inside the slicer. Point at geometry; the geometry offers the
verbs that apply to it. Selection comes first and the tool consumes it. Draw a sketch,
constrain it, turn it into a solid, refine it, and send it straight to Prepare — without
leaving for another application and coming back through an STL.
The model is a **recipe**, not a mesh. Every action becomes a feature in a tree that is
replayed from the start whenever anything changes, so editing a dimension you set twenty
steps ago rebuilds everything downstream. The geometry kernel is OCCT, which the slicer
already ships for STEP import.
---
## Getting started
1. Open the **Design** tab.
2. Click a face or a reference plane in the viewport, then press `Shift+S` (Sketch). The offer
opens with the sketch tools on it.
3. Draw a closed profile, then press **✓ Confirm** in the floating action bar.
4. With the sketch selected, press `Shift+E` (Extrude).
5. Press **Commit to Plate** to hand the solid to Prepare.
The status line under the toolbar is the thing to watch: it says what the current tool is
waiting for. When no plane is picked it reads *"Click a face or a reference plane in the
viewport, then a sketch tool"*; once one is picked it reads *"Sketching on <face> — pick a
tool"*. It is also where a refusal explains itself.
---
## Selecting
- One left-click selects what is under the cursor. There is no click-cycling through
face → edge → body.
- A click near a corner takes the corner, not the face behind it.
- Left-drag sweeps a rubber band, and a rubber band takes the whole body.
- An open sketch line can be clicked, even where it bounds a region.
- Double-click a sketch stroke to edit it — the gesture belongs on the geometry.
- Editing a dimension's value **updates** that dimension instead of adding a second one next
to it.
- The floating chrome that belongs to a sketch leaves with the sketch when it ends.
- Sketching happens on the face you clicked, first click.
- A sketch whose entities form no wire **fails** instead of extruding a default box. A
subtraction that removes nothing is reported as an error instead of a silent success.
---
## The offer
Right-click on the geometry, released without moving the mouse (an 8 px budget — a
right-drag that orbits the camera does not open it). Left-click still only selects, so
pointing at things stays quiet.
The offer also opens by itself the moment you press Sketch on a face or plane, showing the
sketch tools — the app hands you the tools directly.
**Eight families, always in this fixed order:** Create, Add material, Remove, Dress-up,
Repeat, Transform, Reference, Modify.
- A family with at least one applicable verb shows it. Several applicable verbs collapse
into a submenu under the family name.
- A family with nothing applicable is **shown greyed in place, with the reason** — e.g.
*"Create — Click a face or a reference plane in the viewport, then a sketch tool"*. It is
not hidden. A control that cannot be used still says what it is and what you would have to
do first.
- Inside a sketch the offer shows the sketch verbs; outside it shows the feature verbs.
**Document-level actions never enter the offer**, because they act on the document and not
on a selection: Import STEP, Import mesh, Text, SVG, Export STEP, Commit to Plate, Undo,
Redo, Variables, Section view, Origin planes, World axes. They live in the toolbar.
---
## Keyboard
Single letters drive sketch tools **while a sketch is open**; Shift+letter drives feature
tools and single letters drive view toggles **when no sketch is open**. The two maps are
selected by the mode, not by whether a sketch session is running.
### Sketch (while a sketch is open)
| Key | Tool |
|---|---|
| `L` | Line — click start, then end |
| `R` | Rectangle — click two opposite corners |
| `C` | Circle — click centre, then radius |
| `A` | Arc — click start, end, then a point |
| `S` | Slot — two centreline ends, then width |
| `E` | Ellipse — centre, major end, minor point |
| `B` | Spline — click control points |
| `P` | Point — click to place |
| `G` | Polygon — click centre, then a vertex |
| `D` | Dimension — click 2 points or an entity |
| `T` | Trim — click a segment to trim it |
| `X` | Extend — click a line/arc to extend it |
| `O` | Offset — pick an entity, drag the distance |
| `M` | Mirror — pick axis, then entities |
| `F` | Fillet — pick two lines, set the radius |
| `H` | Chamfer — pick two lines, set the distance |
| `K` | Constrain — finish the live sketch and enter constrain |
| `Q` | Construction toggle — draw the next entity as construction geometry |
| `Del` | Delete the selected sketch entity |
| `Esc` | Cancel the live tool |
### Feature (when no sketch is open)
| Key | Tool |
|---|---|
| `Shift+S` | Sketch |
| `Shift+E` | Extrude — extrude a profile, or push/pull a picked face |
| `Shift+R` | Revolve |
| `Shift+W` | Sweep |
| `Shift+L` | Loft |
| `Shift+N` | Pattern |
| `Shift+G` | Surface Extrude |
| `Shift+J` | Surface Revolve |
| `Shift+O` | Surface Loft |
| `Shift+Q` | Surface Fill |
| `Shift+U` | Surface Offset |
| `Shift+V` | Thicken Surface |
| `Shift+P` | Plane |
| `Shift+A` | Axis |
| `Shift+C` | Coord Sys |
| `Shift+Y` | Transform |
| `Shift+Z` | Mirror |
| `Shift+B` | Boolean |
| `Shift+X` | Cut |
| `Shift+F` | Fillet / Chamfer |
| `Shift+D` | Draft |
| `Shift+K` | Shell |
| `Shift+H` | Hole |
| `Shift+T` | Thread |
| `Shift+I` | Import STEP |
| `Shift+M` | Import mesh |
### View toggles (single letters, when no sketch is open)
| Key | Action |
|---|---|
| `Home` | Axonometric view, fitted to the model |
| `P` | Origin planes on/off |
| `A` | World axes on/off |
| `X` | Section view on/off |
While the section is on: `PageUp` / `PageDown` move the cut plane, `F` flips which half is
kept. With no section on, `F` is Place on Face — lay the picked face flat on the bed.
---
## Sketching
A sketch is a closed (or open) 2D profile on a plane or on a flat face of an existing body.
Press `Shift+S`, click the face or plane you want to sketch on, and draw. The toolbar and
the offer both carry the sketch tools.
**Entities:** line, polyline, rectangle (corner / centre / oblique / rounded), circle
(centre-radius / 2-point / 3-point), arc (centre-point / 3-point / tangent), ellipse and
elliptical arc, polygon (inscribed / circumscribed), slot (straight / arc), spline, point,
and text.
**Editing:** move, rotate, scale, trim, extend, offset, mirror, and linear or polar arrays.
**Constraints:** coincident, horizontal, vertical, parallel, perpendicular, tangent, equal,
concentric, midpoint, symmetric, fix, plus dimensional radius, diameter, distance and angle.
The solver reports the remaining degrees of freedom and tells you when a sketch is fully
constrained — or when a constraint conflicts with one already there.
Sketches stay editable. Selecting one in the feature tree reopens it with its dimensions
live.
---
## Building solids
Grouped in the toolbar by what they do, one concept per drawer.
### Add material
| Tool | Shortcut | What it does |
|---|---|---|
| Extrude | `Shift+E` | Extrude a profile, or push/pull a face already on a body |
| Revolve | `Shift+R` | Revolve a profile about an axis |
| Sweep | `Shift+W` | Sweep a profile along a path — including a helix, for springs and augers |
| Loft | `Shift+L` | Skin between two or more profiles |
| Thicken | — | Offset a solid face into a thin plate as a new body |
| Rib | — | Grow a stiffening wall from an open sketch line, fused to a body |
Extrude offers blind, symmetric, two-sided, through-all and up-to-face end conditions, plus
a draft angle on the side wall, and can add, subtract, intersect or start a new body.
### Surface
Sheet bodies — surfaces with no thickness — for shapes that are easier to build as skins and
solidify afterwards.
| Tool | Shortcut |
|---|---|
| Surface Extrude | `Shift+G` |
| Surface Revolve | `Shift+J` |
| Surface Loft | `Shift+O` |
| Surface Fill | `Shift+Q` |
| Surface Offset | `Shift+U` |
| Thicken Surface | `Shift+V` |
Thicken Surface is how a sheet becomes a printable solid.
### Dress-up
| Tool | Shortcut |
|---|---|
| Fillet / Chamfer | `Shift+F` |
| Draft (taper a face) | `Shift+D` |
| Shell | `Shift+K` |
| Delete Face | — |
Delete Face removes faces and heals the solid — useful for stripping a feature off an
imported part.
### Holes
**Hole** (`Shift+H`) drills simple, counterbored or countersunk holes, with an ISO/ANSI
standards table so you can ask for an M6 clearance hole instead of computing a diameter.
**Thread** (`Shift+T`) cuts a real helical thread into a bore or onto a shaft.
### Placement
Operations that move a body without changing its shape: **Transform** (`Shift+Y`),
**Mirror** (`Shift+Z`), and **Mate** for assemblies.
### Combining
**Boolean** (`Shift+B`) unions, subtracts or intersects two bodies. **Cut** (`Shift+X`)
splits a body with a plane. **Pattern** (`Shift+N`) repeats a body linearly, in a circle, or
along a curve.
---
## Reference geometry
Datum features carry no material; they exist to give later features something to attach to.
- **Plane** (`Shift+P`) — offset, tilted, midplane, tangent, through two edges, or coincident
- **Axis** (`Shift+A`) — two points, a face normal, a cylinder centreline, the intersection of
two planes, or along an edge
- **Coord Sys** (`Shift+C`) — a full frame, from a world point or from a face plus a
direction edge
- **Helix** — a helical curve to sweep along
- **Project** — project a body's edges onto a plane as sketch geometry
On the Coord Sys tool, picking a direction **edge** is worth the extra click: without one the
frame takes its X from the face's first edge, which is deterministic but not necessarily the
direction you meant.
---
## Assemblies
**Mate** aligns two coordinate systems and moves one body onto the other. Five kinds:
| Kind | Leaves free |
|---|---|
| Fastened | nothing — 6 DOF locked |
| Planar | sliding in the plane |
| Revolute | rotation about the axis |
| Slider | sliding along the axis |
| Cylindrical | rotation *and* sliding |
**Check interference** reports every overlapping pair of solids with the overlapping volume,
so a clash is a number rather than an impression. Bodies that merely touch enclose no volume
and are not reported.
---
## Variables and expressions
Define named variables and drive dimensions from them. Any numeric field accepts an
expression — `width/2`, `wall*3` — and everything re-evaluates on recompute. Change one
variable and the whole model follows.
---
## Import and export
**Import STEP** brings in a real B-rep solid, not a mesh: its faces and edges can be filleted,
shelled and cut like anything modelled here.
**Import mesh** (STL/OBJ) converts triangles to a B-rep body and tells you honestly what it
got — whether the result is a closed solid or an open shell, with the boundary and
non-manifold edge counts. A large mesh becomes a large number of faces, which is slow to
edit; the importer warns before you commit to it.
**Export STEP** writes the model out for another CAD tool.
**Commit to Plate** sends the solid to Prepare for slicing. The whole feature recipe is saved
inside the 3MF, so reopening the project restores the editable model rather than a frozen
mesh.
---
## View controls
**Section view** (`X`) hides half the model so you can see inside — `PageUp`/`PageDown` move
the plane, `F` flips which half is kept. **Place on Face** (`F`, when section is off) lays a
picked face flat on the bed. Origin planes (`P`) and world axes (`A`) can be toggled on while
you orient yourself.
---
## Known limitations
Being straight about the edges, so nobody discovers them the hard way:
- **Rib** needs a sketch containing an explicit open line. A parametric rectangle sketch
carries no individual entities, so Rib cannot use one.
- **Surface Loft** and **Surface Fill** have kernel tests but have not been exercised by hand.
- Card wiring for 9 of the 16 late-wired tools has never been click-tested.
- Mate resolves by composing transforms directly. There is no 3D assembly solver, so mates
are applied in order rather than solved simultaneously, and mate limits are not implemented.
- Move-face and replace-face are not implemented — OCCT offers no clean primitive for them.
- There is no automated GUI test in CI. Every behaviour above is traced to code and to a
hand pass, not to a synthetic click.
---
## Where the code lives
| Path | Role |
|---|---|
| `src/libslic3r/CAD/CadDocument.*` | the feature recipe and its replay |
| `src/libslic3r/CAD/GeometryEngine.*` | OCCT wrapper — faces, edges, booleans, healing |
| `src/libslic3r/CAD/SketchEngine.*` | profile → wire → solid |
| `src/libslic3r/CAD/SketchSolver.*` | constraint solving, over the vendored solver |
| `src/libslic3r/slvs/` | vendored 2D constraint solver (GPLv3) |
| `src/slic3r/GUI/CAD/DesignPanel.*` | the tab: toolbar, cards, feature tree |
| `src/slic3r/GUI/CAD/DesignCanvas.*` | viewport integration |
| `src/slic3r/GUI/CAD/DesignSketchTool.*` | in-canvas sketching |
Build with `-DSLIC3R_CAD=ON` (the default). With it OFF the tab is not compiled and the deps
prefix matches upstream exactly — see [cad_dependency_weight.md](cad_dependency_weight.md).
-73
View File
@@ -1,73 +0,0 @@
# Design (CAD) tab — upstream pull request
## What this adds
A sketch-first parametric CAD tab inside the slicer. The workflow is direct:
sketch → constrain → solid features → commit to plate. The whole feature recipe is
persisted inside the 3MF, so reopening restores an editable model rather than a frozen mesh.
- Kernel: OCCT, which upstream already links for STEP import — see
[cad_dependency_weight.md](docs/cad_dependency_weight.md)
- Constraint solver: vendored SolveSpace `libslvs` subset
- Interaction model: object-driven — point at geometry, the geometry offers the verbs that
apply to it; see [cad_ux_guidelines.md](docs/cad_ux_guidelines.md)
- Full user-facing documentation: [design_tab.md](docs/design_tab.md)
## Why it belongs in the slicer
Every round trip through an external CAD tool costs a file export, a re-import, and the
design intent that both steps discard. A part modified after slicing should return to its
feature history, not to a mesh. Keeping the CAD model inside the slicer preserves that
loop — the nozzle diameter, the build volume and the material are known at design time.
For the integration case in full: [design_tab_upstream_portability.md](docs/design_tab_upstream_portability.md).
## How it is built
The `SLIC3R_CAD` CMake flag (default ON) gates the entire tab. With it OFF the tab is not
compiled and the deps prefix matches upstream exactly — the dependency diff is one line in
OCCT's CMake: `BUILD_MODULE_ModelingAlgorithms=OFF → ON`.
Measured cost table: [cad_dependency_weight.md](docs/cad_dependency_weight.md).
## Diff shape
<!-- fork-specific: measured against this fork's upstream base; re-run the commands above after mirroring -->
Against merge-base `d6cb667b894f`:
306 files changed, 83032 insertions(+), 777 deletions(-)
350 commits, of which 284 are new files and 37 modify upstream files. 99.3 % of the diff
is new code. The negotiable surface is the 37 modified files.
## Tests
205 `TEST_CASE` blocks across 6 new test source files. This counts assertions written, not
assertions passed — a run needs a build.
`scripts/CAD/run-kernel-tests.sh` is the headless verification contract: it builds only
`libslic3r_tests` (not the GUI app), needs no display, and exit 0 means the CAD suite
passed. It now runs with **no exclusions** — both cases that used to be quarantined (the
circle-line tangency solver abort and the internal-thread reference) are fixed.
## Licensing
The vendored solver in `src/libslic3r/slvs/` is **GPL-3.0** (see `src/libslic3r/slvs/LICENSE`),
not LGPL. The combined work is distributable under AGPL-3.0. See the Licensing section of
[design_tab_upstream_portability.md](docs/design_tab_upstream_portability.md) for the
AGPLv3/GPLv3 compatibility argument; this point should be confirmed with upstream explicitly.
## Not verified
- Card wiring for 9 of the 16 late-wired tools was never click-tested.
- There is no automated GUI test in CI. A green kernel run says nothing about the GUI —
synthetic clicks never drift, so the test suite and the viewport are two separate realities.
- The click-test defect rate has **not converged**: a second pass found no new defects, but
four further days of work found five more. The earlier pass is not evidence of stability.
## Reviewer's map
See the [Where the code lives](docs/design_tab.md#where-the-code-lives) table in the user
doc for the file-to-role mapping, and [docs/ux/tool_atlas.json](docs/ux/tool_atlas.json) as
the generated-from source of `src/slic3r/GUI/CAD/DesignOffer.hpp`.
-162
View File
@@ -1,162 +0,0 @@
# Design (CAD) tab — upstream integration brief
**Question:** can the Design tab (sketch-first parametric CAD: sketch → constrain →
extrude/revolve/fillet/hole/thread/shell, multi-body, undo, 3MF persistence) land in
mainline OrcaSlicer?
**Answer: yes, and the ask is far smaller than previously believed.** OCCT is *already*
an OrcaSlicer dependency. We are not asking upstream to adopt a new library; we are
asking it to widen one it already builds, at a measured cost of **3.77 MiB on Windows**.
> ### Corrections to the 2026-06-21 assessment
> That revision was written before the persistence work landed and got two load-bearing
> facts wrong. Both are corrected here from direct measurement of the branch:
>
> 1. **"The real blocker: OCCT … a dependency mainline OrcaSlicer has never carried."**
> **False.** `deps/OCCT/` exists at the merge-base and upstream links it from
> `Format/STEP.cpp`, `Format/svg.cpp`, and `Shape/TextShape.cpp`. Our entire
> dependency diff is **one line**: `BUILD_MODULE_ModelingAlgorithms=OFF → ON`.
> 2. **"vendored SolveSpace solver … LGPL."** **False.** `src/libslic3r/slvs/LICENSE` is
> **GPL-3.0**, not LGPL. This is fine (see Licensing) but must not be misstated.
>
> It also claimed "no changes to Model" — no longer true; 3MF recipe persistence adds one
> `std::string` to `Model`.
## Measured shape of the change
Against merge-base `449a4cf9fc` (34 commits ahead):
| | files | lines |
|---|---:|---:|
| **New files** | 138 | +61,720 |
| **Modified upstream files** | 23 | +457 / −75 |
| **Deleted upstream files** | 0 | — |
The 62 kLOC headline is inflated by localization. The feature itself:
| area | LOC | files |
|---|---:|---:|
| kernel (`src/libslic3r/`) | 15,828 | 37 |
| GUI (`src/slic3r/`) | 19,544 | 14 |
| tests (Catch2) | 2,567 | 6 |
| i18n (unrelated; strip from the CAD PR) | 23,438 | 77 |
**99.3 % of the diff is new files.** The negotiable surface is 457 added lines across 23
files, and nothing upstream is deleted. The largest single hook is `GLCanvas3D.cpp`
(+110/−2): an `m_design_sketch_tool` member plus render/mouse/key hooks, **every one
already null-guarded** — which is why the compile-time gate below is cheap.
No changes to the slicing pipeline (Print/PrintObject/Layer/GCode), Tab, or the
printer-profile/config system.
## The dependency ask, precisely
Not "adopt OCCT" — **widen the existing OCCT build**:
```diff
- -DBUILD_MODULE_ModelingAlgorithms=OFF
+ -DBUILD_MODULE_ModelingAlgorithms=ON
```
Cost, measured from the shipped Windows artifact (42 OCCT DLLs, 45.43 MiB total):
| toolkit | size | note |
|---|---:|---|
| `TKFillet.dll` | 2.02 MiB | only exists with the flag ON |
| `TKOffset.dll` | 1.75 MiB | only exists with the flag ON |
| **delta** | **3.77 MiB** | Windows only (OCCT is Shared on Win, Static elsewhere) |
`TKBool` is *not* part of the delta — upstream's `DataExchange` already pulls it in
transitively. On macOS/Linux OCCT links statically, so the cost is only the code actually
referenced, not a 3.77 MiB floor.
**Unmeasured, and we should measure before the call:** clean-deps build-time delta with
the flag ON vs OFF, and the resulting CI runner-minute cost. Do not guess these at him.
## Licensing
- Vendored solver `src/libslic3r/slvs/` — **GPL-3.0**, 9,339 LOC, © Jonathan Westhues,
a self-contained subset of SolveSpace (`libslvs`). No external dependencies.
- OrcaSlicer — **AGPL-3.0** (`LICENSE.txt`).
GPLv3 §13 expressly permits combining a GPLv3 work with an AGPLv3 work; AGPLv3 §13 grants
the converse. The combined work is distributable under AGPL-3.0 with the solver's GPLv3
terms preserved. This is a favourable direction (GPLv3 → into an AGPLv3 project), but it
is a point to **confirm explicitly with upstream**, not to assert unilaterally.
Open question for SoftFever: keep the solver **vendored** (current: pinned, no submodule,
no external build) or move it to `deps/` as a fetched external? Vendoring costs us
upstream-sync burden; `deps/` costs build complexity.
## The one irreversible decision: the 3MF format
Persistence adds an **optional** archive entry and one field:
```cpp
// Model.hpp
std::string cad_recipe; // empty for non-CAD projects
```
```
Metadata/orca_cad.bin // written only when cad_recipe is non-empty
```
Readers that do not know the entry ignore it; writers skip it entirely when empty. So
existing projects are bit-identical and old readers are unaffected. Good.
**But the moment upstream ships this, it owns forward-compatibility forever.** Three
things should be settled *before* the first release, because none can be changed after:
1. **Name.** Renamed to `Metadata/orca_cad.bin`.
2. **Encoding.** The recipe is an opaque **cereal `PortableBinaryArchive`** blob whose
layout is the field order of `CadFeature::serialize`. Portable across endianness and
word size — *not* across a field reorder. Append-only is currently a convention held by
discipline, not by any check.
3. **Embedded BRep.** `Import` features embed OCCT's ASCII BRep for the imported solid,
which couples saved project files to an OCCT BRep revision. Alternative: re-import from
the source STEP and store only a reference. Worth deciding deliberately.
**Concrete gap we should close before the call.** `test_caddocument.cpp` covers the
in-memory round-trip and correctly refuses a version-999 blob — but there is **no
checked-in v1 fixture on disk**. A reordered field in `CadFeature::serialize` would pass
the entire suite while silently breaking every previously-saved project. Ship a golden
`.bin` fixture generated today plus a test that loads it; that is the only thing that will
hold the format still once real users have files.
## Proposed PR decomposition
35 kLOC in one PR is not reviewable. Behind the flag, slices 1–4 are behaviour-neutral for
existing users:
1. **Build gate + OCCT flag + Windows packaging guard.** `-DSLIC3R_CAD=ON/OFF`, default
**OFF**. Flips `ModelingAlgorithms=ON`. Includes the guard that asserts every linked
OCCT toolkit has a shipped DLL (already on both forks: `546cef5f42`). ← *this is what
makes SoftFever's "parallel build" a one-line CI matrix entry.*
2. **Vendored `slvs` solver** + its Catch2 tests. No GUI, no OCCT.
3. **CAD kernel** (`CadDocument`, `SketchEngine`, `GeometryEngine`, `Sketch*`) + kernel
tests. Headless, no GUI.
4. **3MF recipe persistence** + golden-fixture regression test.
5. **GUI Design tab** (`DesignPanel`, `DesignCanvas`, `DesignSketchTool`, `GLGizmoSketch`)
+ the 23 upstream hooks.
## Agenda for the call
Questions only SoftFever can answer:
- Does OrcaSlicer *want* to be a CAD-integrated slicer? (Strategic; everything else is mechanical.)
- Default of `SLIC3R_CAD` at merge time, and when it flips ON.
- Vendored solver vs `deps/` external; and confirmation of the GPLv3/AGPLv3 combination.
- Project-file format: neutral name, encoding, embedded-BRep policy, and who owns v1 forward-compat.
- Undo/redo: the Design tab has its own stack; integrate with Orca's snapshot system or keep separate?
- Does he want the i18n work (Romanian, +23 kLOC) as a wholly separate PR? (Yes, almost certainly.)
## Verdict
Portability **high**. The prior "does upstream want OCCT" framing was wrong — OCCT is
already there. What remains is a 3.77 MiB dependency widening, a compile-time gate that
the existing null-guards make cheap, and one file-format decision that must be made before
the first release rather than after.
---
*Revised 2026-07-10 from direct measurement of `cad-mainline` @ `546cef5f42` vs upstream
merge-base `449a4cf9fc`. Supersedes the 2026-06-21 read-only assessment.*
-144
View File
@@ -1,144 +0,0 @@
# Design tab — interaction model
The contract for Esc, the right mouse button, and the states between them. Code that changes any
of the three changes this file in the same commit.
## 1. The state machine
`src/slic3r/GUI/CAD/DesignInteraction.hpp` — a four-level LIFO stack. The enum value *is* the
depth, so "which level does this press belong to" is a comparison rather than a chain of
special cases spread over three files.
```cpp
enum class CadLevel : int {
Idle = 0, // nothing transient is up: Esc clears the selection
Tool = 1, // a feature card / armed sketch tool / constrain session: Esc exits it
Gesture = 2, // an uncommitted delta (entity being drawn, body being dragged): Esc reverts it
Transient = 3, // a value field or a popup menu: Esc closes just that
};
struct CadInteractionState { // the four bits routing actually needs
bool value_field_open{false};
bool gesture_active{false};
bool tool_armed{false};
bool has_selection{false};
};
constexpr CadLevel cad_escape_level(const CadInteractionState& s)
{
if (s.value_field_open) return CadLevel::Transient;
if (s.gesture_active) return CadLevel::Gesture;
if (s.tool_armed) return CadLevel::Tool;
return CadLevel::Idle;
}
```
The rule is a `constexpr` free function over a POD, not a method on the panel, so the ordering
that is the entire contract is checkable without a window, a GL context or an event loop. Five
`static_assert`s in the header do exactly that, at compile time.
**Strict invariant.** No level of Esc deletes a feature, discards a sketch that holds geometry,
or rolls history back. Destroying work needs a gesture that says so:
| To destroy | Gesture |
|---|---|
| a feature | Delete / Backspace on an explicit selection |
| a drawn sketch | the ribbon's ✗ Cancel, which asks first |
| the last committed change | Ctrl+Z |
## 2. Event routing
**`OnKeyDown(WXK_ESCAPE)`** — `DesignPanel`'s `wxEVT_CHAR_HOOK`, one line:
```cpp
if (key == WXK_ESCAPE) { escape(); return; }
```
Every Esc in the tab goes through it, whatever holds focus. `DesignPanel::escape_level()` answers
the four questions of `CadInteractionState` about this panel; `DesignPanel::escape()` acts on the
one level that answer names, and on no other:
| Level | What one press does | What it must not touch |
|---|---|---|
| `Transient` | close the value field (`cancel_value` / `inline_cancel`) | the tool, which stays armed |
| `Gesture` | drop the clicks of the entity being drawn, or put a moved body back at the pose it had when the gizmo appeared | everything already committed |
| `Tool` | discard a feature card's *candidate*; drop an armed sketch tool to Select; end Constrain | committed features; entities already drawn |
| `Idle` | clear the selection (model and sketch); leave a sketch session **only if it is empty** | a sketch holding geometry — it is left through Finish or Cancel |
A sketch *session* is deliberately not a `Tool`. It is the environment the Idle level lives in,
which is what makes the destructive path unrepresentable rather than merely unlikely.
**`OnRightDown` / `OnRightUp`** — `DesignCanvas::set_on_context_menu`, bound after `GLCanvas3D`'s
own handlers so it can consume the event before them:
```cpp
RIGHT_DOWN: remember the press position and the clock, then Skip() // the canvas still seeds the orbit
RIGHT_UP: terminated = sketch_tool.take_right_consumed(); // read-and-clear, always
is_click = drift <= 3 px && dt <= 200 ms; // both budgets, or it was navigation
if (callback && !terminated && !inline_busy && is_click) {
select_at_screen(press.x, press.y); // raycast at the PRESS, not the release
on_context_menu(ClientToScreen(press));
return; // consumed
}
Skip(); // orbit / pan / the handlers underneath
```
Two independent budgets because the two failure modes are independent: drift alone still popped a
menu at the end of a slow, careful orbit. `take_right_consumed()` is how a right-click that
already meant something to the armed sketch tool (terminate a chain, drop an edit-op) declines to
also mean "open a menu".
## 3. Transition table
`sel` = something is picked. Blank = the input does nothing at that state.
| State | Left-click | Right-click | Esc | Enter |
|---|---|---|---|---|
| **Idle — model view** | pick / escalate the pick | offer menu for what is under the cursor | clear the selection | — |
| **Idle — sketch, empty** | pick | sketch offer menu | leave the session (nothing to lose) | Finish sketch |
| **Idle — sketch, drawn** | pick | sketch offer menu | clear the selection; status says the sketch is kept | Finish sketch |
| **Tool — feature card** | pick the card's next reference | offer menu | discard the candidate, close the card | commit the feature |
| **Tool — sketch tool armed** | place the first point | drop the tool to Select | drop the tool to Select | — |
| **Tool — constrain** | pick an entity | offer menu | end the session | apply |
| **Gesture — drawing** | place the next point | terminate the chain (keep what is drawn) | drop the in-progress entity, tool stays armed | commit the entity as drawn |
| **Gesture — moving a body** | drop the body here | end the move | revert to the pose at move-start | keep the placement |
| **Transient — value field** | — | — | close the field, tool stays armed | commit the value, advance the chain |
| **Transient — popup menu** | run the entry | — | close the menu | run the highlighted entry |
| **any** | — | — | *never* deletes, discards or rolls back | — |
Right-hold-and-drag is not in the table on purpose: past 3 px or 200 ms it is navigation, and
navigation does not transition the state machine.
## 4. Visual scaffolding
Entering a sketch changes three things at once, so the state is legible from across the room:
- **Banner.** A teal strip across the top of the viewport: `Editing: Sketch N · N = look normal to
the plane · Finish or Cancel in the toolbar`. Indicator only — Confirm and Cancel stay on the one
ribbon action bar, per the Design UX contract. It is a sibling above the canvas, not a floating
child over it: a child window over a `wxGLCanvas` is a native window on GTK and does not reliably
stack over GL, and this banner's job is to be unmissable rather than clever.
- **The printer bed is muted.** A plate grid and a sketch grid are the same visual language, and
reading one as the other is how a sketch gets drawn against the wrong reference. The view
checkbox remains the stored preference and is restored on the way out; ticking it mid-sketch
still shows the bed, because that is a deliberate act and this is only a default.
- **`N` looks normal to the plane**, keeping the current zoom, with the plane's own y axis as up.
Sketch key map only — in Feature mode the navigator orb owns orientation.
## 5. Context menu content
The offer is generated from `docs/CAD/ux/tool_atlas.json`; its 8-row shape and permanent row
indices are ratified and are not changed here. Checked against the per-context vocabularies asked
for in the 2026-09-05 interaction brief, the atlas already carries all of them except two, both on
a planar face:
| Asked for | Status |
|---|---|
| Revolve on a planar face | **not offered, and should not be**: `revolve` accepts `sk_loop` only, because the kernel takes a sketch profile — a face is not one |
| Offset Face | offered as **Thicken** (`thicken`, accepts `face_planar`); `surf_offset` is the sheet-body verb and accepts `body_sheet` |
View and document actions — Zoom to Fit, View Isometric, Clear Selection, Finish Sketch, Normal to
Sketch — stay in chrome by the atlas's own rule: the offer describes verbs that consume a
*selection*, and these act on the document or the camera. Esc covers Clear Selection, `N` covers
Normal to Sketch, and the ribbon covers Finish.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

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