Compare commits

...
Author SHA1 Message Date
Ian Bassi ffbbd62355 Clean design Docs and move context (#15803) 2026-09-30 14:33:35 -03: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
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
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
Error404JoyNotFound da0611a301 Fix : Add curr_bed_type to built-in placeholders in G-code editor (#15987) 2026-09-30 14:59:46 +08: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
789f848694 Write the estimated printing time comment after the config block, not before (#15897)
Co-authored-by: Fernando Marino <f.marino@rheagroup.com>
Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-29 19:18:15 -03:00
Rodrigo FaselliandIan Bassi 3a0694dce6 Remember last print action (#15774)
Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
2026-09-29 19:17:45 -03:00
Kris AustinandRodrigo Faselli f5679ad343 perf: load presets in parallel, cutting preset load time by over 60% (#15943)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-29 18:13:44 -03:00
HanifKoh 2769b12ce7 Give OBJ Quad and Flipped Faces the Texture Coordinates of Their Own Corners (#15977)
load_obj emits the second triangle of a quad from corners 0, 2 and 3,
but read its texture coordinates from corners 0, 1 and 2, so half of
every textured quad sampled the wrong part of the texture. The corner
indices are now passed down to where the coordinates are read.

A mesh with inward-facing triangles is flipped after loading, which
swaps corners 1 and 2 of every face. The texture coordinates were left
as they were. They are now swapped along with the corners.
2026-09-30 03:13:29 +08:00
HanifKoh 203bc63f35 Escape Project Metadata in the Project Page and Restrict Accessory Opening (#15956)
* Escape Project Metadata in the Project Page and Restrict Accessory Opening

The Project page rendered the model and profile name, author, description
and accessory file names from the 3MF as live HTML. Names, authors and file
names are now set as text, and the file list is built from DOM nodes with
bound click handlers instead of concatenated markup. Descriptions can
legitimately carry rich-text HTML, so they are rebuilt from an inert
DOMParser document, keeping only plain formatting tags, http(s) links and
http(s) images, with every other attribute dropped.

Opening an accessory from the page now only launches regular files that
lie inside the project's extracted auxiliary directory. The containment
check is a new libslic3r helper, is_absolute_path_within_root, built on
is_path_within_root so symlinks leading out of the root are rejected too.

* Tighten Project Page Description Rendering and Keep More Formatting

Link and image URLs in descriptions must now start with an http or https
scheme as written and parse as such with the URL parser. Preview images are
built as DOM nodes like the file list, and accessory names show their full
text as a tooltip.

Descriptions keep more plain formatting: del, ins, figure, figcaption, dl,
dt, dd, caption, q, abbr, kbd and wbr, plus alt, title, width and height on
images, colspan and rowspan on table cells and start on ordered lists.
Numeric attributes must be plain integers. Embedded YouTube players become
a link to the video.

* Confirm Before Opening Program Attachments and Load Only HTTPS Images

Opening a project attachment whose type runs as a program or script
(executables, installers, shortcuts, shell and PowerShell scripts, macOS
command files and apps, Linux desktop entries) now asks for confirmation
first. The check lives in libslic3r as is_executable_file_name and ignores
the trailing dots and spaces Windows strips from file names.

Images in project descriptions are kept only when they load over https,
so opening the Project tab no longer issues plain-http requests.

* Open Project Attachments Through One Guarded Helper

The Edit Project Info view launched attachments directly, without the
checks the project page has. Both now call
desktop_open_project_attachment, which checks that the file is inside
the auxiliary directory, asks for confirmation where needed and then
opens it.

The auxiliary root was built through encode_path, which returns code
page bytes on Windows, while boost::filesystem reads a narrow string as
UTF-8. With a non-ASCII temporary directory the root never matched and
no attachment opened. It is now built from the UTF-8 path directly.

The list of program extensions could not be kept complete and let
unknown types open without a prompt. It is replaced by
is_safe_to_open_file_name, a list of plain document, image, model and
video types that open directly. Everything else asks first.
2026-09-30 00:39:30 +08:00
HanifKoh e40030cf81 Stop Malformed Network Responses from Crashing the App (#15947)
* Stop Malformed Network Responses from Crashing the App

Duet, MKS and UltiMaker parsed print host replies with boost read_json
inside the HTTP completion callback with no try, so an HTML or truncated
reply threw out of the Physical Printer Test button and terminated the app,
or killed the upload queue thread. The five identical copies of the parser
(ESP3D's and Flashforge's were unused) are replaced by one shared
PrintHost::get_err_code_from_body that reports a non-JSON reply as an error.
The upload queue now catches a failing job per job, so one bad upload no
longer leaves later jobs queued forever.

Flashforge read material station slots with nlohmann value(), which throws
on off-type fields or non-object entries. The parsing moves into
Flashforge::parse_material_slots, which reads fields leniently with the
existing try_parse_json_int and skips bad entries.

UserManager::parse_json parsed the payload before its try block; the parse
now happens inside it.

* Keep UploadFinished Paired with UploadStarted When an Upload Throws

The exception from a throwing upload was caught around perform_job, so
the UploadFinished lifecycle event was skipped and plugins saw an
upload start that never finished.

The catch now sits around the upload call. The error is reported
through the job's error callback and UploadFinished is fired with an
error code, as for any other failed upload. The worker keeps running
for the next job. The started, upload and finished sequence moved to
PrintHostJobQueue::upload_job so it can be tested without the dialog.
2026-09-30 00:39:01 +08:00
HanifKoh ba468c842d Confine Updater and Plugin Archive Extraction to the Target Directory (#15957)
* Confine Updater Archive Extraction to the Target Directory

The preset updater extracted downloaded archives by appending each entry
name to the cache directory, and the network plugin installer did the same
for the plugin folder, without checking that the result stays inside it.

Move the updater's extraction into libslic3r as extract_archive_confined,
which validates every entry with is_path_within_root before writing
anything and fails the whole archive if one entry resolves outside the
target. The plugin installer now rejects such an entry the same way. Well
formed archives extract exactly as before.

* Harden Archive Extraction Against Symlinks

The plugin installer now creates a symlink entry only when its target is
relative and, joined to the link's own directory, passes
is_path_within_root, via the new is_symlink_target_within_root helper.
Before writing any entry it checks the destination with symlink_status, so
an existing symlink, dangling or not, is replaced rather than followed, and
it creates parent directories inside the existing error handling.
extract_archive_confined replaces a symlink at a destination file the same
way.

is_path_within_root now ignores a trailing separator on the root, which
previously made every path fail the check.

* Validate Plugin Symlink Targets Before Replacing Existing Files

A symlink entry's target is now read and checked before anything already
at its destination is removed or renamed aside, so an archive rejected
for its link target leaves the installed plugin files in place.

* Reject Paths with an Embedded NUL When Confining Extraction

is_path_within_root compared each component with "..", so a name such
as "..\0" passed the check. The filesystem calls stop at the NUL and
act on a shorter path than the one that was checked: a symlink target
read from a plugin archive as raw bytes was created as "..", pointing
out of the plugin directory.

A path containing a NUL is now rejected before anything touches the
filesystem, which covers every caller, including entry names taken from
the Unicode Path extra field.
2026-09-29 23:40:12 +08:00
SoftFever dd9b5dc0d4 Tune pressure advance separately for each extruder variant (#15986)
* Tune pressure advance separately for each extruder variant

Pressure advance, adaptive pressure advance and its model can now take a
different value for each extruder variant of a filament, such as Standard and
High Flow nozzles, like the other per-variant filament settings. Projects
saved with one value per filament apply it to every variant of that filament,
and the addnorth BBL filaments in the Orca Filament Library are updated to the
per-variant layout.

* Move Nozzle type to each extruder's settings page

Editing other settings on an Extruder page of a single-extruder
multi-material printer no longer triggers the nozzle diameter prompt.

* Fix command-line slicing when a filament leaves out a per-variant setting
2026-09-29 23:23:08 +08:00
HanifKoh e68694dbaf Percent-Encode Local File URLs for Embedded Web Pages (#15961)
* Percent-Encode Local File URLs for Embedded Web Pages

The Home tab, setup wizard, Project tab and other embedded pages were
loaded from file:// URLs built by pasting the resources path into a
string. A '#', '%' or '?' in the install path was then read as a URL
fragment, escape or query, so the pages failed to load, for example a
portable install under D:\#OneDrive showed a directory listing instead
of the setup wizard.

Add file_url_from_path(), built on wxFileSystem::FileNameToURL, and use
it wherever a local page or image URL is built from a path. Queries such
as ?lang= are appended after the path is encoded. The wizard's printer
cover images are passed to the page as file URLs too.

* Encode the Login Error Page URL and Cover More Windows Path Forms

The login dialog's error page was still loaded from a raw resources path;
it now uses file_url_from_path like the other local pages.

The Windows file URL tests now also cover a resources path joined with a
forward-slash relative path, as the callers build them, and a UNC path.

* Build the Flush Dialog Page URLs with the Shared Helper

WipingDialog and NozzleListTable still called
wxFileSystem::FileNameToURL directly. They now go through
file_url_from_path like every other local page, so the URLs are built
in one place.

Adds a test for a resources directory with a '#' in its name, which the
plugin page check did not recognise before.
2026-09-29 22:30:56 +08:00
SoftFever 1504bd7153 Fix command-line slicing when a filament leaves out a per-variant setting 2026-09-29 21:09:20 +08:00
SoftFever d87fe1b290 Merge branch 'main' into feature/pa_per_extruder_variant 2026-09-29 19:15:17 +08:00
Kris Austin 72cfe71b81 fix: link webkit2gtk and X11 on every Linux build, not only Flatpak (#15972)
libslic3r_gui calls webkit_* directly, and OrcaSlicer.cpp and libspnav
call Xlib, but both libraries were only linked when FLATPAK was set.
The default build links because the bundled static wxWidgets lists
them in wx-config. A shared wxWidgets does not, so any build against
one, like the Flatpak build or a distro package, fails with undefined
webkit_* and X* symbols.
2026-09-29 08:04:48 -03:00
SoftFever 50eea48408 Move Nozzle type to each extruder's settings page
Editing other settings on an Extruder page of a single-extruder
multi-material printer no longer triggers the nozzle diameter prompt.
2026-09-29 17:52:19 +08:00
SoftFever ef0c656932 Tune pressure advance separately for each extruder variant
Pressure advance, adaptive pressure advance and its model can now take a
different value for each extruder variant of a filament, such as Standard and
High Flow nozzles, like the other per-variant filament settings. Projects
saved with one value per filament apply it to every variant of that filament,
and the addnorth BBL filaments in the Orca Filament Library are updated to the
per-variant layout.
2026-09-29 17:52:13 +08:00
SoftFever 46fb512690 enable python unit test (#15593)
* enable python unit test

* fix Windows

* Require numpy for the plugin tests in CI
2026-09-29 12:49:54 +08:00
HanifKoh 8ffd3e514e Harden OBJ and DRC Import Against Malformed Files (#15948)
* Validate OBJ Texture-Coordinate Indices

load_obj read the texture coordinates of a face without checking the
vt index, so a face referencing a vt past the end of the list read out
of bounds and crashed, and a face vertex with no vt read index -1.
Out-of-range or missing indices now fall back to a zero UV. The face
keeps its entry in the per-face UV list, so the following faces stay
aligned, and the geometry loads as before.

Negative (relative) vt indices were also rebased by dividing the float
count by 3, but each vt stores two floats.

* Reject DRC Meshes Without Positions or with Invalid Face Indices

load_drc dereferenced the POSITION attribute without checking that the
mesh has one, and trusted the decoded face indices, which the Draco
decoder does not check against the point count. Both now fail the load
cleanly. A failed vertex conversion is treated the same way.

The libslic3r tests link Draco so they can encode the malformed meshes
in-test.

* Keep OBJ Texture Coordinates That Carry a W Component

The vt parser stopped reading the optional third component when texture
coordinates were cut down to u and v, but the check that nothing is left
on the line stayed. A legal "vt u v w" line was therefore rejected and
silently dropped, shifting every later texture index. The w component is
parsed again and discarded.

The texture coordinate stride is now a named constant, OBJ_TEXCOORD_LENGTH,
used by the parser and the importer, so the relative-index rebase cannot
drift from the storage layout again.
2026-09-29 12:25:16 +08:00
Paulcake 7d8318f275 profiles: initialize Ender-3 V3 SE extrusion mode before purge (#15489)
# Description

The Ender-3 V3 SE machine start G-code performs its purge using
absolute-style extrusion positions:

```gcode
G1 ... E15
G1 ... E30
```

However, the machine start G-code does not explicitly initialize the
positioning or extrusion mode before these commands.

OrcaSlicer emits the printer's custom `machine_start_gcode` before its
own generated `G90` / `M82` or `M83` preamble. This means the purge can
inherit the extrusion mode left active by the printer.

For example, if `M83` relative extrusion is still active, such as after
a cancelled print where normal end G-code was not executed:

- `E15` extrudes 15 mm
- `E30` extrudes another 30 mm

Instead of the intended 15 mm followed by another 15 mm.

This change explicitly adds:

```gcode
G90 ;Absolute positioning
M82 ;Absolute extrusion mode
```

before the purge sequence so startup behaviour is deterministic and does
not depend on inherited printer state.

The change is applied consistently to all Ender-3 V3 SE nozzle variants:

- 0.2 mm
- 0.4 mm
- 0.6 mm
- 0.8 mm

No print speeds, temperatures, retraction values, machine limits, or
other profile settings are changed.

# Screenshots/Recordings/Graphs

Not applicable. This is a machine start G-code profile fix.

## Tests

- Confirmed all four Ender-3 V3 SE profiles use the same `E15` / `E30`
purge sequence.
- Confirmed the current profiles do not explicitly issue `G90`, `M82`,
or `M83` before that purge.
- Confirmed OrcaSlicer emits `machine_start_gcode` before its generated
positioning/extrusion-mode preamble.
- Verified the modified JSON for all four machine profiles parses
successfully.
- Verified the added commands make the purge explicitly use absolute XYZ
and absolute extrusion state.
2026-09-29 11:15:28 +08:00
SoftFever 9688f0ae62 Slice-validate every custom G-code and filename_format in system profiles (#15966)
# Description

Follow-up to #15950, where a `filename_format` using
`initial_no_support_extruder` shipped broken because the profile
validator's slice sweep never expands `filename_format`. The sweep now
expands every custom G-code and `filename_format` text shipped in any
system profile. Each printer's slice also fires the pause, template
custom G-code and clumping-detection hooks and names the output file,
and the first printer shipping a `printing_by_object_gcode` also slices
by object. Beyond each printer's default process and filament, every
compatible system process and filament carrying a template text no
earlier slice has expanded is sliced once, which takes the sweep from
1,110 to 1,248 slices (about 49 s locally, up from 43 s). While the
sweep runs, the placeholder parser also resolves variable names inside
`{if}` branches a slice does not take, so one expansion checks every
branch.

The stricter sweep found two profile bugs, fixed here: the Anycubic
Kobra X filament change G-code carried an unreachable block reading
variables only Anycubic's own slicer defines, and the Wanhao France D12
template custom G-code had an unterminated `{if}`, so adding a template
custom G-code on those printers failed the slice. It also fixes a parser
bug where a declaration such as `{local a = layer_height + 1}` failed to
parse inside a branch that is not taken.

No change to slicing output for templates that already worked: the
untaken-branch check is enabled only by the validator's slice mode, and
the parser fix only lets previously rejected templates parse.

# Screenshots/Recordings/Graphs

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

## Tests

New placeholder-parser cases cover the parse fix and the untaken-branch
check: off by default it changes nothing; on, it rejects undefined names
in branches not taken, accepts names declared there, and leaves boolean
expressions (compatibility conditions) alone. The full sweep passes on
all system profiles, and planting an undefined variable in an untaken
branch of a printer's start G-code, of a non-default filament's start
G-code or of a non-default process's `filename_format`, or reverting
#15950, each fails it and names the preset. `scripts/check_profile.sh`,
`libslic3r_tests` and `fff_print_tests` pass.

<!--
> 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-29 10:39:17 +08:00
Santiago Postorivo 79afb020db fix(device): resolve current-print thumbnail placeholder in Device panel (#15911) 2026-09-28 17:15:29 -03:00
45bc39a47a Fix cli opengl context version (#15745)
Co-authored-by: chris <basque-estate.0b@icloud.com>
Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
2026-09-28 15:33:21 -03:00
HanifKoh d35ea27ea5 Validate Zip Entry Sizes Before Parsing 3MF XML (#15958)
The 3MF importers read XML entries into a single expat buffer whose size is
an int, while the archive extraction used the entry's 64-bit declared size.
The two could disagree for entries declaring more than INT_MAX bytes.

Reject such entries before allocating, and use one size for the buffer, the
extraction and the parse. This applies to the BBS importer, the PrusaSlicer
importer and the PrusaSlicer fingerprint probe. The load now fails with an
error instead.
2026-09-29 02:32:50 +08:00
HanifKoh 490d134507 Harden 3MF Loading Against Malformed Plate IDs and Paint Data (#15959)
* Reject 3MF Plate IDs Below 1 Instead of Indexing Before the Plate List

The plate importer copied each plater_id from model_settings.config into the
1-based plate list after checking only the upper bound, so plater_id="0"
wrote to plate_data_list[-1] and crashed on load. Both copy sites now reject
ids below 1 with the same "invalid plate index" error already used for ids
past the end.

* Drop Malformed 3MF Paint Data Instead of Reading Past the Bitstream

Painted facets are decoded from a bitstream a nibble at a time with no bound
check, so a truncated or corrupt paint string in a 3MF (for example split
codes with no children behind them) read past the end and crashed on load and
slice. A one- or two-side split naming side 3 also indexed past the triangle's
vertices.

Every nibble read now goes through a bounds-checked reader. Loading validates
each triangle's tree and drops a malformed one with a warning, so the stored
data, used extruder states and later decoding all agree. deserialize() also
unwinds and clears any triangle whose tree is incomplete or malformed, and
has_facets() stops at a truncated triangle. Valid streams decode unchanged.
2026-09-29 02:31:26 +08:00
HanifKoh 41eeaf3883 Sanitize Server-Supplied Download File Names (#15955)
* Sanitize Server-Supplied Download File Names

The URL downloader used the file name from the Content-Disposition header
as given, without the cleaning and unused-name search applied to the
URL-derived name.

Reduce the header name to a sanitized base name with the new
sanitize_file_basename helper, which splits on both path separators and
rejects names made only of dots and spaces. Run the result through the
same unused-name search as the URL-derived name, now shared in
find_unused_filename, and fall back to the URL-derived name when nothing
usable remains.

* Sanitize Download Names Before Choosing an Unused One

The unused-name search probed the name as given and sanitized the
result afterwards, so a name whose special characters are replaced
could be mapped onto a file that already exists.

Move the search into libslic3r as find_unused_filename, sanitize first
and probe the name that is actually written. The download marker path
is shared through download_marker_path. Restore the last tried name in
the error reported when no free name is found, and cover the search
with unit tests.

* Keep Downloads on an Unused Name Until They Complete

When the server supplied the name, the download marker stayed under the
URL-derived name, so the adopted name was not reserved against other
downloads. The final rename also replaced any file that took the name
while the download ran.

Move the marker to the adopted name before any data is written, and
check the name again right before the final rename, picking the next
free name if it is taken by then.

* Sanitize the File Name of Model Import Links

The model import took the file name from the link as given and only
avoided an existing file with a substring match on the folder listing.

Reduce the name to a sanitized base name, falling back to untitled.3mf,
choose the name with the shared unused-name search, and check it again
before the final rename.

* Handle Filesystem Errors When Finishing a Model Import Download

Choosing the final name and moving the downloaded project into place
could throw from inside the download callback. Any such error now removes
the temporary file and reports the existing import failure message.
2026-09-29 02:19:24 +08:00
Rodrigo FaselliandIan Bassi 11e9e07f20 Non-crossing infill optimization (#15931)
* Non-crossing infill optimization

* test triangles

* test grid

* cleaning

* Align and clip rectilinear infill paths

Generate infill coverage in the pattern's local frame, rotate triangular patterns by layer, and clip centerlines to the surface vicinity. Start closed outlines outside the surface so clipping splits them cleanly.

* Update test_fill.cpp

* Update multiline-infill.md

---------

Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
2026-09-28 15:13:57 -03:00
SoftFever 9f34f37c27 Merge branch 'main' into feature/slice-sweep-full-placeholder-coverage 2026-09-29 01:57:28 +08:00
SoftFever 9d8257d2eb Support extruder variants on every printer (#15964)
# Description

Extruder variants (Standard, High Flow, extra high flow) now work on any
printer. Any vendor profile can declare them, and a multi-variant
filament picks up the right variant on every printer. In the sidebar,
users can switch the printer variant and set the nozzle volume type of
each extruder on multi-extruder printers. This also fixes a later
filament printing at the first filament's temperature on a P1S or X1C
with a High Flow nozzle. The profile checks now reject variant arrays of
the wrong size and outdated variant strings. Every shipped vendor
profile passes them, and the orca-profiles skill documents the rules.

Slicing output changes only where the wrong variant was used before. 
# Screenshots/Recordings/Graphs
<img width="393" height="218" alt="Screenshot 2026-09-28 at 11 28 07 PM"
src="https://github.com/user-attachments/assets/8bee4b0e-c38c-4039-8c11-096ace6fbad0"
/>
<img width="448" height="267" alt="Screenshot 2026-09-28 at 11 28 37 PM"
src="https://github.com/user-attachments/assets/e767cd97-a5d0-4728-b010-c8ea2bc94ca7"
/>
<img width="746" height="603" alt="Screenshot 2026-09-28 at 11 28 54 PM"
src="https://github.com/user-attachments/assets/23c8af3b-c679-46e5-9fd0-2e43df0449b3"
/>




https://github.com/user-attachments/assets/10d75d72-86a3-42e8-8a95-b1627bd58e91



## 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-29 01:57:01 +08:00
SoftFever 185cfe4323 Slice-validate every custom G-code and filename_format in system profiles 2026-09-29 01:53:30 +08:00
SoftFever 78fb4f767a Fix the Wanhao France D12 template custom G-code failing to parse 2026-09-29 01:53:30 +08:00
SoftFever 05da6bbcc8 Remove unreachable ACE block from the Kobra X filament change G-code
The block ran only when flush_length_4 is -1392 and read ace_t_box_vector / ace_t_slot_vector, which only Anycubic's own slicer defines. It emitted comments only, so the printed G-code is unchanged.
2026-09-29 01:53:30 +08:00
Ian Chua 0a55e6ef83 fix: expose initial_no_support_extruder to the filename format template (#15950) 2026-09-29 01:52:25 +08:00
1846407e93 Add Precise Seam placement feature (#12974)
Co-authored-by: Ioannis Giannakas <59056762+igiannakas@users.noreply.github.com>
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
2026-09-28 14:40:55 -03:00
SoftFever 52679ea1bc Bump the version of every vendor bundle this branch changes 2026-09-28 23:48:22 +08:00
SoftFever d48e63b5e3 Merge branch 'main' into feature/add-multi-variant 2026-09-28 23:30:14 +08:00
SoftFever 7a2daaa351 Make all vendor profiles pass the updated profile checks 2026-09-28 23:02:04 +08:00
Ian Chua 08f086daf3 fix: tolerate unsupported OFL publisher branches (pre 2.5.x) (#15962) 2026-09-28 20:40:24 +08:00
Ian Chua 293aa3e0ed fix: OFL workflow checkpoint and clear ordering (#15960)
# 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?
-->
This PR re-orders the OFL OTA auto-publish workflow by using successful
cron runs as checkpoints, marking and waiting for every dispatched
profile publisher to finish, and clearing the pending queue once
centrally only after all publishers succeed.

# Screenshots/Recordings/Graphs

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

## Tests

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

<!--
> 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-28 20:00:04 +08:00
Ian Chua faeb84da72 fix: change checkpointing to use last cronjob instead 2026-09-28 19:59:01 +08:00
anjisandyw4z c30c9beb09 Fix clipped tall models in G-code preview for gcode files (#15360)
Fix clipped tall models in G-code preview

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-28 14:58:24 +03:00
Ian Chua 77f8c64d37 fix: OFL workflow checkpoint and clear ordering 2026-09-28 19:53:23 +08:00
SoftFever 00a2c5a087 update profile checks 2026-09-28 19:08:35 +08:00
SoftFever 71bb400e46 Add multi-variant support to the app 2026-09-28 19:08:35 +08:00
SoftFever 2ccabb5695 add multi variant to orca-profiles skill
add symbol link .agents
2026-09-28 19:08:35 +08:00
Ian Chua 576cce2f72 test: bump elegoo profile to test OFL OTA E2E [TO BE REVERTED] (#15954)
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: d4a18c633e
2026-09-28 10:47:41 +00:00
Kenneth Rapleeandyw4z f3a8f711fd Catch Standard_Failure before std::exception (OCCT >= 8) (#15826)
Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-28 13:44:32 +03:00
Kenneth Rapleeandyw4z 00bde9265b Add missing OCCT (8.x) header includes (#15825)
* Add missing TopTools includes to GeometryEngine.cpp

* Add missing TDF_LabelSequence include in STEP.cpp

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-28 13:42:25 +03:00
Ioannis Giannakas 7707487252 Fix overhang slowdown and fan applied to whole walls ahead of an overhang (#15945) 2026-09-28 10:59:39 +01:00
Hugo Costa 3df9c215f8 Blocks: branded filaments, naming/loading fixes, RF50 bed type and other fixes. (#15804)
# 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?
-->

This PR fixes some problems with the current profiles for Blocks
printers

- **Fix**: start gcode for the Blocks 0.6mm RF50 printer where the start
gcode command had no newline separation on two commands so they were
concatenated and would make printing fail.
- **Added**: multi material plates selection for the RF50 printers,
right now only textured PEI and smooth high temp plates are available,
but in the future more to come.
- **Naming consistancy** for Blocks Pro S100 processes and filaments,
target labels used bare `@Blocks` while RD50 and RF50 used
`@Blocks_RD50` and `@Blocks_RF50`. Renamed Pro S100 to
`@Blocks_Pro_S100` to match the structure.
- **Filament restructure**: replaced Blocks tuned-generic filaments
(e.g. `Generic PLA @Blocks`) with real Blocks-branded filaments. One for
each printer model. Every material keeps the same coverage we already
had.
- **Fix**: `sparse_infill_pattern` was misspelled `sparse_infill_patter`
on 13 `@Blocks_RF50` process presets. Harmless due to inheritances but
fixed non the less.
- **Fix**: `Blocks RD50 V2 0.4 nozzle.json` was missing
`printer_variant`.
- **Normalize** all Blocks profiles to standard formatting (tab
indentation, key order) and bumped `Blocks.json` version.
- **RF50 retraction length** increased on all RF50 machines.


## Tests

- [x] `orca_profile_tool.py check --vendor Blocks` - clean
- [x] `orca_profile_tool.py check` - clean
- [x] `normalize` / `generate-id` / `update-index --dry-run` — fully
settled, no pending changes
- [x] `check_profile.sh --vendor Blocks` → `profile_tool` check — passes
- [ ] `validate_system` / `validate_slice` /
`validate_filament_subtypes` / `validate_custom` —
could not run locally (dev host's glibc 2.36 is older than the nightly
validator binary's
      required 2.38); needs CI or a compatible host
- [x] In-app confirmation that the Blocks bundle loads
 
<!--
> 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-28 17:16:55 +08:00
HanifKoh cda1588578 Draw the Toolpaths Top-Down When the Camera Looks Down on the Print (#15883)
The segments come in print order, bottom layer first, which seen from above is back to
front: every hidden fragment is shaded before the one that covers it, and on an integrated
GPU that overdraw is most of the frame. Drawing the instances last to first whenever the
camera looks down lets the depth test reject the hidden fragments instead. Side views and
views from below keep the print order, and the shadow-caster pass is unchanged.
2026-09-28 15:36:08 +08:00
3338 changed files with 42379 additions and 27251 deletions
Symlink
+1
View File
@@ -0,0 +1 @@
.claude
+146 -84
View File
@@ -1,78 +1,127 @@
--- ---
name: orca-profiles name: orca-profiles
description: Use when creating, modifying, reviewing or debugging OrcaSlicer FFF system profiles under resources/profiles, including printer/vendor/nozzle/material additions, bundle indexes and versions, preset renames, setting_id and filament_id. Also use for missing presets or vendors, ignored profile settings, ambiguous AMS filament matches, and failures from orca_profile_tool.py, check_profile.sh/.bat, OrcaSlicer_profile_validator or the Check profiles CI job. description: Use when creating, modifying, reviewing or debugging OrcaSlicer FFF system profiles under resources/profiles, including printer/vendor/nozzle/material additions, bundle indexes and versions, preset renames, setting_id and filament_id, and moving settings that sibling presets repeat onto shared bases after fix-variant or while drafting. Also use for missing presets or vendors, ignored profile settings, ambiguous AMS filament matches, and failures from orca_profile_tool.py, check_profile.sh/.bat, OrcaSlicer_profile_validator or the Check profiles CI job.
--- ---
# OrcaSlicer system profiles # OrcaSlicer system profiles
A bundle is `resources/profiles/<Vendor>.json` plus `<Vendor>/`. The vendor id is the This skill describes how OrcaSlicer system profiles are drafted and shaped: the rules, equations and
filename stem, not the index's display `name`. The index is the loader's only entry point: patterns a profile follows. Use it to draft new profiles, modify existing ones, fix profile issues and
unindexed presets never load. `OrcaFilamentLibrary` is the shared filament bundle; review profile changes.
`blacklist.json` is data, not a bundle.
## Choose the reference for the task A bundle is the index `resources/profiles/<Vendor>.json` plus the folder `<Vendor>/`. The vendor id is
the filename stem (`BBL`), not the index's display `name` (`Bambulab`). The index is the loader's only
entry point: an unindexed preset never loads. `OrcaFilamentLibrary` is the shared filament bundle,
loaded first; `blacklist.json` is data, not a bundle.
Read the relevant reference before editing; load others only when the task crosses those areas. ## References
Paths below are relative to this skill. Commands run from the repository root.
Read the reference for the task before editing; load others only when the task crosses into them.
Paths below are relative to this skill. Commands run from the repository root; on Windows use `py -3`
for `python3`.
| Task | Read | | Task | Read |
| --- | --- | | --- | --- |
| Add or tune a filament, brand or material; fix compatibility / alias shadowing | [filament-profiles.md](references/filament-profiles.md) | | Add or tune a filament, brand or material; fix compatibility, alias shadowing or overlapping coverage | [filament-profiles.md](references/filament-profiles.md) |
| Add a printer or nozzle; change models, variants, assets or extruder vectors | [machine-profiles.md](references/machine-profiles.md) | | Add a printer or nozzle; change models, variants, assets or per-extruder vectors | [machine-profiles.md](references/machine-profiles.md) |
| Add or tune extruder variants (`extruder_type` Direct Drive / Bowden × nozzle volume type Standard / High Flow / TPU High Flow / E3D High Flow / Extra High Flow variants) on a printer, process or filament | [extruder-variants.md](references/extruder-variants.md) |
| Add a quality tier or tune a process | [process-profiles.md](references/process-profiles.md) | | Add a quality tier or tune a process | [process-profiles.md](references/process-profiles.md) |
| Create a vendor bundle; diagnose loading or inheritance; migrate preset names | [vendor-bundle.md](references/vendor-bundle.md) | | Draft several presets, or clean up after `fix-variant`: which base each shared value belongs on, when a new base pays off, proving nothing loads differently | [shared-bases.md](references/shared-bases.md) |
| Name a preset; check what a name must equal | [naming.md](references/naming.md) | | Name a preset; check what a name must equal; base names, uniqueness, filenames | [naming.md](references/naming.md) |
| Create a vendor bundle; index, `version`, `inherits`, `include`; migrate preset names; diagnose why a bundle fails to load | [vendor-bundle.md](references/vendor-bundle.md) |
| Change ids; diagnose AMS identity | [ids.md](references/ids.md), then `docs/HLSD/filament_id.md` for identity changes | | Change ids; diagnose AMS identity | [ids.md](references/ids.md), then `docs/HLSD/filament_id.md` for identity changes |
| Review a profile diff | [review-checklist.md](references/review-checklist.md) |
| Run checks, interpret failures, test another tree or verify in the app | [validation.md](references/validation.md) | | Run checks, interpret failures, test another tree or verify in the app | [validation.md](references/validation.md) |
| Review a profile diff | [review-checklist.md](references/review-checklist.md) |
## Golden rules ## Rules
1. **Bump every changed bundle's `version`**, including `OrcaFilamentLibrary.json` when affected. 1. **Bump the `version` of every bundle you change**, `OrcaFilamentLibrary.json` included when affected.
Increment the last component; carry `.99` into the third component (`02.04.00.99` → Increment the last component and carry `.99` into the third (`02.04.00.99` → `02.04.01.00`). The
`02.04.01.00`). The updater requires a strictly newer version. CI does not check this. updater installs only a strictly newer version, and CI does not check the bump.
2. **Register every preset, bases included, parents before children.** `update-index` generates 2. **Register every preset, bases included, parents before children.** `update-index` writes the four
the four `*_list` arrays; `check` requires its output. Index names must equal file `name` fields. `*_list` arrays from the files on disk; `check` fails unless the index equals its output. Each index
3. **Generate ids; never invent or copy them.** Keep existing ids during ordinary tuning. New entry's `name` must equal the file's `name`.
presets normally omit them until `generate-id`; bases must have no `setting_id`. 3. **Generate ids; never invent or copy them.** Keep existing ids during ordinary tuning. New presets
BBL's authoritative `setting_id` and a wrongly inherited `filament_id` need the explicit normally omit them until `generate-id`; bases carry no `setting_id`. BBL's own `setting_id`s and a wrongly
handling in [ids.md](references/ids.md). inherited `filament_id` need the explicit handling in [ids.md](references/ids.md).
4. **Load failures can discard a whole vendor bundle.** Broken `inherits`, missing indexed files, 4. **A name is an identity; preserve shipped selectable names.** Every reference (`inherits`,
duplicate names, invalid model/variant references and unresolved filament ids affect more than `compatible_printers`, `default_*`, `printer_model`) is the exact, case-sensitive `name`. Renaming or
the edited preset. Inheritance stays within a bundle, except filaments may inherit the library. deleting a shipped selectable preset, or flipping its `instantiation` from `"true"` to `"false"`,
5. **Preserve shipped selectable names.** Renaming, deleting or changing `instantiation` from needs `renamed_from` (a `;`-separated string) on a selectable successor
`"true"` to `"false"` needs `renamed_from` on a selectable successor. It is a `;`-separated string; ([migration rules](references/vendor-bundle.md#renamed_from)); update in-tree references too.
update in-tree references too. See [migration rules](references/vendor-bundle.md#renamed_from). 5. **Values are strings or arrays of strings.** `"instantiation": "false"`, never `false`. A
6. **Compatibility uses exact printer variant names.** Every instantiated non-library filament `machine_model`'s `nozzle_diameter` is a `;`-separated string; a `machine`'s is an array. Custom
needs a non-empty `compatible_printers` in its own file. Library fallbacks may omit it; G-code is one string. Wrong types can abort loading of the bundle or of every vendor
library printer-specific tunes use a non-empty list. One variant may be claimed by only one ([failure scopes](references/vendor-bundle.md#failure-scopes)).
profile per filament product (`filament_id`); an overlap is resolved by moving the variant to the 6. **Unknown keys are dropped silently.** Confirm every new key exists in
most specific preset, which is preferred over deleting a profile. See `src/libslic3r/PrintConfig.cpp`; a key a neighbouring file writes is no evidence it exists. `check` rejects,
[one variant, one profile](references/filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product). and `normalize` removes, known obsolete keys, but neither detects an arbitrary misspelling. A key
7. **Preset values are strings or arrays of strings.** Use `"instantiation": "false"`, not `false`. missing from the definitions may be a legacy name the loader still renames
Model `nozzle_diameter` is a `;`-separated string; machine `nozzle_diameter` is an array. (`tool_change_gcode` → `change_filament_gcode`) or whose value it rewrites (`DirectDrive` →
Wrong types can abort loading; see [failure scopes](references/vendor-bundle.md#failure-modes-ranked-by-blast-radius). `Direct Drive`); check `PrintConfigDef::handle_legacy` before removing one, and write the current name
8. **Verify setting keys against the code.** Unknown keys are silently discarded. Check in new edits.
`PrintConfig.cpp` definitions and `PrintConfigDef::handle_legacy`; neighbours can contain dead 7. **Write overrides only.** Inherit the bundle's bases and restate just what differs; follow the
keys. `normalize` removes known obsolete keys, but does not detect arbitrary misspellings. bundle's existing layering and style, except that a new filament prefers the library's bases. A
9. **Run the full profile checks before reporting completion.** A vendor-scoped pass is only a value that every preset of a group shares goes on the group's base
development loop. Review also covers version bumps, assets, non-default processes and hardware ([shared bases](references/shared-bases.md)).
tuning that CI cannot establish. 8. **One load error can discard a whole vendor bundle**: an unresolved `inherits`, a missing indexed
10. **One all-printer preset per product; color is a runtime property, never a preset.** Never ship file, two selectable presets with one name, an unknown `printer_model` or `printer_variant`, a
presets that differ only by color — CI accepts them, so this is a review call. See filament with no resolvable `filament_id`, `nil` in a non-nullable key. `inherits` and `include`
[color is a runtime property](references/filament-profiles.md#color-is-a-runtime-property). resolve only inside the bundle, except that filaments may inherit from OrcaFilamentLibrary.
9. **Filament compatibility names exact printer variants.** Every instantiated filament outside the
library writes a non-empty `compatible_printers` in its own file. Library fallbacks may omit it;
library printer-specific tunes use a non-empty list. One variant may be claimed by only one preset
per filament product (`filament_id`); an overlap is resolved by moving the variant to the most
specific preset, which is preferred over deleting a preset
([one variant, one profile](references/filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)).
10. **One all-printer preset per product; colour is a runtime property, never a preset.** Never ship
presets that differ only by colour. CI accepts them, so this is a review call
([colour](references/filament-profiles.md#colour-is-a-runtime-property)).
11. **Write a variant key at full width or not at all.** A key in the four variant sets holds exactly
`N` values in the selectable preset that writes it, `N = S × k` in the
[sizing equation](references/extruder-variants.md#sizing-equation): one per variant, a (normal,
silent) pair per variant for the `machine_max_*` limits; one value is not "the same for every
variant". Profiles must be correct as written: `check` judges each selectable preset by the
equation, never by what the loader pads or cuts, and `check --strict` also holds what a preset
inherits to its own width, as BBL writes it. A base is never judged on its own: its array widths
count, under `--strict`, where they reach a preset, while the id and layout rules judge the
composed preset, inherited values included, without it. Declare the variant layout on a
multi-extruder printer whose extruders need different values
([widths](references/extruder-variants.md#widths)).
12. **Run the full checks before reporting completion.** A `--vendor` run is only a development loop.
Review also covers version bumps, assets, non-default processes and hardware tuning, which CI cannot
establish.
## Names
| Type | Shape | What the loader uses |
| --- | --- | --- |
| `machine_model` | `<Model>` (`Bambu Lab X1 Carbon`) | the exact string, named by each variant's `printer_model`; also the `<Model>_cover.png` stem |
| `machine` | `<Model> <nozzle> nozzle` | the exact string, named by `compatible_printers`; `printer_variant` holds the nozzle token (`0.4`, [rules](references/machine-profiles.md#printer_model-and-printer_variant)) |
| `process` | `<lh>mm <Quality> @<target>` | the exact string when referenced or selected; `@<target>` is a label, compatibility comes from the preset's list or condition |
| `filament` | `<Product> @<target>` | text before the first `@` is the alias (shadowing, `filament_id`); the rest is a label, with reserved targets `@base` and `@System` |
The shapes are convention; `check` enforces only uniqueness. Per-type conventions, base names and
filename rules are in [naming.md](references/naming.md).
## Creating or modifying a profile ## Creating or modifying a profile
1. **Inspect the diff and neighbouring presets.** Read their `name`, parent chain and children; 1. **Inspect the diff and the neighbouring presets.** Read their `name`, parent chain and children:
edits to a base or a leaf with descendants propagate. Match the bundle's structure and write edits to a base, or to a leaf that others inherit, propagate. Match the bundle's structure and write
only overrides. New files use tab indentation, LF and a trailing newline; preserve unrelated only overrides. New files use tab indentation, LF and a trailing newline; preserve unrelated
formatting in existing files. Match filename case exactly and use cross-platform names. formatting in existing files. Match filename case exactly and use
2. **Author explicit metadata.** Set `type` yourself, especially for `machine` vs `machine_model`. [cross-platform names](references/naming.md#filenames-and-paths).
Use `"from": "system"` and string `instantiation` on config presets. Omit ids on new presets 2. **Author explicit metadata.** Set `type` yourself (`machine` vs `machine_model` especially), and use
unless [ids.md](references/ids.md) requires special handling; retain them on existing ones. `"from": "system"` and a string `instantiation` on config presets. Omit ids on new presets unless
Complete compatibility, defaults, assets and any rename migration using the task reference. [ids.md](references/ids.md) requires special handling; retain them on existing ones. Complete
3. **Bump the version**, then run the authoring commands in order for each affected bundle: compatibility, defaults, assets and any rename migration using the task reference.
3. **Put shared values on shared bases** when drafting several presets, and after `fix-variant`, which
widens an array in every preset that writes it and moves nothing. Each value goes on the base of the
level that determines it, a new base only where it pays for itself, and a restructure must leave
every selectable preset loading what it loaded: `snapshot` before the edit, `compare` after it
([shared-bases.md](references/shared-bases.md)).
4. **Bump the version**, then run the authoring commands in order for each affected bundle, reading
every diff and resolving every error before moving on:
```bash ```bash
python3 scripts/orca_profile_tool.py normalize --vendor "<Vendor>" python3 scripts/orca_profile_tool.py normalize --vendor "<Vendor>"
@@ -81,46 +130,59 @@ Paths below are relative to this skill. Commands run from the repository root.
python3 scripts/orca_profile_tool.py check python3 scripts/orca_profile_tool.py check
``` ```
Writing commands support `--dry-run`. Inspect their diffs: `normalize` changes content and can Writing commands accept `--dry-run`. `normalize` changes content and can reformat entire files;
reformat entire files. Stop and resolve command errors before proceeding. rerun `update-index` after any change to `inherits` or `include`, since it orders by them.
**Do not use `trim` in this workflow:** it deletes unindexed files, including one you just added. Do
**Do not use `trim` in this workflow:** it can delete newly authored, unindexed profiles. not use `normalize --force` for routine edits. An error in a bundle you did not touch predates your
Do not use `normalize --force` for routine edits. change: confirm it on a clean checkout and report it rather than fixing it in the same change.
4. **Validate:** 5. **Validate:**
```bash ```bash
./scripts/check_profile.sh --vendor "<Vendor>" # development loop ./scripts/check_profile.sh --vendor "<Vendor>" # development loop
./scripts/check_profile.sh # full tree before the PR ./scripts/check_profile.sh # full tree before the PR
``` ```
On Windows use `py -3` instead of `python3`, and `scripts\check_profile.bat -Vendor "<Vendor>"` On Windows use `scripts\check_profile.bat -Vendor "<Vendor>"` / `scripts\check_profile.bat`. Logs
/ `scripts\check_profile.bat`. Logs land in a per-user cache dir (see land in a per-user cache dir ([validation.md](references/validation.md)). Id checks stay tree-wide
[validation.md](references/validation.md)). under `--vendor`, and filament-only bundles skip the default slice check. Under `--vendor` read only
Id checks remain tree-wide under `--vendor`; filament-only bundles skip the default slice check. `profile_tool` and `validate_slice`: the other three checks fail on library presets that name other
See [validation.md](references/validation.md) for flags, coverage and error remedies. vendors' printers ([why](references/validation.md#the-five-checks)).
5. **Verify the changed behavior.** Slice newly added non-default processes explicitly, and 6. **Verify the changed behaviour.** Slice newly added non-default processes and filaments
[test in the app](references/validation.md#testing-in-the-app) for selection or UI behavior. [explicitly](references/validation.md#checking-a-copy-of-the-tree), and
Report checks actually run, failures/skips and any hardware tuning still unverified. [test in the app](references/validation.md#testing-in-the-app) for selection or UI behaviour. Report
the checks actually run, their failures and skips, and any hardware tuning still unverified.
## Symptom → first reference ## Symptom → first look
| Symptom | Start here | | Symptom | Start here |
| --- | --- | | --- | --- |
| A vendor disappears | Loader log / `validate_system`; [bundle failure scopes](references/vendor-bundle.md#failure-modes-ranked-by-blast-radius) | | A vendor disappears | the app's log or the `validate_system` log; [failure scopes](references/vendor-bundle.md#failure-scopes) |
| A setting has no effect | Key spelling/type, `handle_legacy`, or a config key placed on a `machine_model` | | A setting has no effect | key spelling or a legacy name (`PrintConfigDef::handle_legacy`), value type, or a config key placed on a `machine_model` |
| A preset exists but is not selectable | Index registration, `instantiation`, installation and compatibility | | A preset exists but is not selectable | index registration, `instantiation`, whether it is installed (chosen in the setup wizard, or listed in the model's `default_materials`), compatibility |
| A filament is missing, duplicated, or matches the wrong spool | [Compatibility and alias shadowing](references/filament-profiles.md#compatible_printers); [ids](references/ids.md) | | A filament is missing, duplicated, or matches the wrong spool | [compatibility and alias shadowing](references/filament-profiles.md#compatible_printers), [ids](references/ids.md) |
| Presets differ only by color, or an all-printer library preset lacks `@System` | [Color is a runtime property](references/filament-profiles.md#color-is-a-runtime-property) | | Presets differ only by colour, or an all-printer library preset lacks `@System` | [colour is a runtime property](references/filament-profiles.md#colour-is-a-runtime-property) |
| A bed temperature is ignored | [Plate-specific temperature keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) | | High Flow (or a second extruder) slices with Standard (or extruder 1) values; a variant switch is missing; a variant's tuned values never arrive | [extruder variants](references/extruder-variants.md#variant-strings), [widths](references/extruder-variants.md#widths), [variant names](references/validation.md#variant-names) |
| A change is absent from the running app | Version bump and [installed profile location](references/validation.md#testing-in-the-app) | | Values land on the wrong extruder or mode after a variant was added | [inserting a variant](references/extruder-variants.md#adding-a-variant-inserts-its-values-at-its-variant-index), [padding and composition](references/extruder-variants.md#padding-truncation-and-composition) |
| A check fails | [Error → remedy](references/validation.md#error--remedy) | | A resolved value matches neither the file nor its `inherits` parent; an array is not the width the file wrote, or a child got only a base's first value | [composition order and `include`](references/vendor-bundle.md#inherits-and-include) |
| A bed temperature is ignored | [the twelve plate keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) |
| A change is absent from the running app | version bump and [installed profile location](references/validation.md#testing-in-the-app) |
| A check fails | [error → remedy](references/validation.md#error--remedy) |
## Source of truth ## Source of truth
When guidance and behavior disagree, inspect the current checkout: When this skill and the checkout disagree, the checkout wins: `scripts/orca_profile_tool.py` for the
`scripts/orca_profile_tool.py` for tooling and flags; `src/libslic3r/Preset*.cpp` for loading and tool and its flags; `src/libslic3r/Preset*.cpp` for loading and compatibility;
compatibility; `src/libslic3r/PrintConfig.cpp` for setting types and legacy handling; `src/libslic3r/PrintConfig.cpp` for keys, types, nullable options, the variant key sets and legacy
`src/dev-utils/OrcaSlicer_profile_validator.cpp` and `.github/workflows/check_profiles.yml` for handling; `src/dev-utils/OrcaSlicer_profile_validator.cpp` and `.github/workflows/check_profiles.yml`
validation coverage. `docs/HLSD/filament_id.md` defines filament identity. The for validation coverage; `docs/HLSD/filament_id.md` for filament identity. The wiki's
[profile development guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/how_to_create_profiles.md) [profile guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/how_to_create_profiles.md)
is a tutorial; confirm loader and CLI details against these sources. is a tutorial; confirm loader and CLI details against these sources.
## Editing this skill
The skill describes what a profile must be, not what the shipped tree currently is. State rules,
equations, patterns and profile shapes; never inventories of shipped defects, counts, dated
measurements or lists of which vendors do what. Test each sentence: if editing the profiles alone,
with the engine and tool unchanged, could make it false, state the rule behind it or give a generic
example instead. Example files named as models to copy, and commit hashes cited as the reason for a
rule, are fine.
@@ -0,0 +1,547 @@
# Extruder variants
Use this when a printer's hotend or extruder can be in more than one hardware configuration that
needs different settings: a Standard and a High Flow nozzle, a TPU High Flow, E3D High Flow or Extra High Flow nozzle, or
a Direct Drive and a Bowden extruder on one machine. Variants let one printer, process and filament preset carry a
separate value per configuration; the user picks the configuration in the sidebar, and slicing uses
the matching values. They are not for nozzle **diameter**: that stays one `machine` preset per
`printer_variant`.
## Variant strings
- A **variant string** is `"<extruder_type> <nozzle volume type>"`: the extruder's `extruder_type`
value, a space, and its nozzle volume type (the project's `nozzle_volume_type`, seeded by the
printer preset's `default_nozzle_volume_type`), e.g. `"Direct Drive High Flow"`.
- A **variant** is one entry of a preset's variant list, named by its variant string (plus an extruder
id for printer and process lists). A variant-aware key holds **one value per variant** (a
(normal, silent) pair for the `machine_max_*` limits), and each preset declares its variants in
that list. Slicing picks, for each extruder, the variant whose
variant string equals the current `extruder_type` + nozzle volume type.
- Matching is an **exact string compare** of the whole string (plus the extruder id for printer and
process lists) against the preset's own resolved list, in any order. With no match the **first
variant** is used, silently: variant index 0 for printer and process keys (extruder 1's first
variant, whichever extruder asks), the filament's own first variant for filament keys. Printer and
process keys are matched only on a printer with several extruders or whose
`extruder_variant_list` offers several variant strings (`support_different_extruders`); on any
other printer their variant index 0 is read whatever it names. Nothing rejects a profile for a
variant mismatch; mistakes surface only as wrong values in the G-code.
- Variant index and array length follow [Widths](#widths).
The complete enum is `s_keys_map_ExtruderType` and `s_keys_map_NozzleVolumeType` in
`src/libslic3r/PrintConfig.cpp` (`grep -A5 s_keys_map_NozzleVolumeType` there to confirm):
| Part | Values |
| --- | --- |
| Extruder type | `Direct Drive`, `Bowden` |
| Nozzle volume type | `Standard`, `High Flow`, `TPU High Flow`, `E3D High Flow`, `Extra High Flow` (`Hybrid` exists but is runtime-only) |
So the ten legal variant strings are the two extruder types × the five writable nozzle volume types,
and this table is the whole test of legality: a string's presence in a shipped profile is no evidence
for it. `Hybrid` (an extruder with several sub-nozzles) is never a variant: a filament on it reads the
variant of its own nozzle volume type from the project's `filament_volume_map`, printer and process
keys get one variant per nozzle volume type the extruder holds (`extruder_nozzle_stats`), and any
other lookup reads `Standard`. Never write it in a variant string. The same per-filament and
per-type reading applies on any extruder once `extruder_nozzle_stats` lists more nozzle volume
types than there are extruders.
Every other string (a nozzle volume type name from another slicer, a typo, a variant copied from a
shipped profile) is a **dead variant**: nothing selects it, and since lookup is by string it does not
shift the variants beside it; it still counts toward the variant length when arrays are sized.
`orca_profile_tool.py check` reports it as an error in every bundle, BBL included
([variant names](validation.md#variant-names)). Legacy names are errors too: in the four variant
lists, `default_nozzle_volume_type` and `nozzle_volume_type`, the loader still rewrites `Normal` →
`Standard` and `Big Traffic` → `High Flow`, so a ported `Direct Drive Normal` variant would load, but
`check` rejects the spelling and names the enum name to write. `DirectDrive` is rewritten only in
`extruder_type`, so `DirectDrive Standard` in a variant list is dead. Write the enum names;
`normalize` does not convert them.
### A nozzle the enum does not name
The nozzle volume types are fixed by the engine, and a profile cannot add one. A nozzle the table does
not name needs the type added in code first, which is outside profile work; until then any string
for it is a dead variant. Once the engine has the type, its variant string is the enum name after
the extruder type, e.g. `"Direct Drive <name>"`, and `check` accepts it with no tool change, since it
reads the enum from `PrintConfig.cpp`. Existing arrays are unaffected
([slice time](#slice-time-and-existing-users)).
## When to use variants
| Hardware | Do |
| --- | --- |
| One extruder, one nozzle type | Nothing. Without a variant list the printer has one variant ([variant length](#widths)) and no printer-key lookup takes place; a filament with several variants still gets the one for the printer's variant string. |
| Bowden-only printer | Nothing either. A list-less printer matches no string, so every printer-key lookup reads variant index 0 whether that variant is called `"Bowden Standard"` or `"Direct Drive Standard"`; naming it is cosmetic while it is the printer's only variant. A multi-extruder Bowden printer that declares the layout writes `"Bowden Standard"` variants: `"Direct Drive Standard"` entries match none of its extruders, so every extruder reads variant index 0 (and `check` reports them under [Printer rule 3](#printer-machine)). A filament with a `"Bowden Standard"` variant does get that variant there. |
| Nozzle types the user swaps (Standard / High Flow / TPU High Flow / E3D High Flow / Extra High Flow) | The printer lists them as variants; tune the keys that really differ per nozzle volume type. |
| Extruders of different types on one machine | One `extruder_type` per extruder, each extruder listing its own variants. |
| Several extruders that need different values in a variant key (retraction, z-hop, `nozzle_volume`, the `machine_max_*` limits) | Declare the layout even with a single nozzle volume type: `extruder_variant_list` with one `"<type> Standard"` per extruder, and the flattened pair. Without it the arrays still hold one value per extruder ([variant length](#widths)), but the loader keeps only extruder 1's value of a list-less printer's arrays, so every extruder prints with it. |
A preset without variant keys keeps working on a variant printer: its single variant is applied to
every extruder. So adding variants to a printer does not break existing processes or library
filaments; it only makes per-variant tuning possible.
## Printer (`machine`)
```json
"extruder_type": ["Direct Drive", "Direct Drive"],
"extruder_variant_list": ["Direct Drive Standard,Direct Drive High Flow",
"Direct Drive Standard,Direct Drive High Flow,Direct Drive TPU High Flow"],
"printer_extruder_id": ["1", "1", "2", "2", "2"],
"printer_extruder_variant": ["Direct Drive Standard", "Direct Drive High Flow",
"Direct Drive Standard", "Direct Drive High Flow", "Direct Drive TPU High Flow"],
"default_nozzle_volume_type": ["Standard", "Standard"]
```
Rules:
1. `extruder_variant_list` has **one entry per extruder**; each entry is the `,`-joined variants that
extruder supports. It is the per-extruder menu the sidebar offers. It is in no
[variant set](#the-four-key-sets), so the variant-length resize leaves it alone. Extruder 1's
first variant, variant index 0 of the flattened pair, is the fallback of every extruder whose
variant is missing when the arrays are collapsed for slicing
([slice time](#slice-time-and-existing-users)); it is not necessarily the configuration the user
sees, which is `default_nozzle_volume_type` (rule 4).
2. `printer_extruder_variant` is that list **flattened** extruder-major, one entry per variant, and
`printer_extruder_id` gives each entry its 1-based extruder. These two size and address every
variant. Write all three keys and keep them in agreement. At load with
`single_extruder_multi_material` off, and in the app when the printer tab loads a printer with a
different number of extruders, the pair is rebuilt from `extruder_variant_list` (one
`Direct Drive Standard` per extruder when the list is absent) and the variant arrays are resized to
the rebuilt pair, padded with their first value or cut. The resize skips the `machine_max_*` limits
and `hotend_heating_rate` / `hotend_cooling_rate`: they keep their width, and an extruder beyond it
reads their first value, so extruder 2 and up of a list-less printer take extruder 1's normal limit
as their silent one too. With the three in agreement that changes nothing; a pair written without
the list is replaced. A listed variant the pair lacks is a menu choice that reads variant index 0.
- The pair without `extruder_variant_list` slices, but the sidebar offers no variant switch and
the app cannot add variants to a list-less process: nothing is lost while every extruder
has exactly one variant, and every further variant is unreachable.
- A missing or one-value `printer_extruder_id` is extruder 1 at every index
([the id trap](#padding-truncation-and-composition)) unless the load-time rebuild above
replaces the pair (`single_extruder_multi_material` off).
- `check` reports against this rule ([variant arrays](validation.md#variant-arrays)). Errors: a
pair that is not the flattening, an id array that does not give each entry its extruder, a list
of more than one variant without the pair, and a pair without the list that puts several variants
on one extruder. A pair without the list and one variant per extruder is a warning where the
load-time rebuild would replace it (`single_extruder_multi_material` off, and a pair other than
one `Direct Drive Standard` per extruder), and passes otherwise.
3. Every variant string in an entry must start with that extruder's `extruder_type`.
4. `default_nozzle_volume_type` has one value per extruder and must name a nozzle volume type that
extruder's variants list; it seeds the sidebar. The live choice is `nozzle_volume_type` in the
project config, never a preset key.
5. Size every array by the set its key belongs to (the three sets are listed in full in
[The four key sets](#the-four-key-sets); the lengths are worked through in the
[sizing equation](#sizing-equation)): exactly the length below in each selectable preset that
writes it, or leave the key out and the preset takes what reaches it, the default or its base's
array. One value is no
shorthand for "the same for every variant"; write the value for every variant:
| Set | Length | Keys |
| --- | --- | --- |
| `printer_extruder_options` | extruders (`E`) | the 8 per-extruder keys outside the variant scheme (`extruder_type`, `nozzle_diameter`, `default_nozzle_volume_type`, …) |
| `printer_options_with_variant_1` | variant length (`S`) | the full list below; not guessable from names |
| `printer_options_with_variant_2` | 2 × variant length | the 16 `machine_max_*` limits, at [stride 2](#widths): a (normal, silent) pair per variant |
Put the variant layout on the shared base of all printers that share the hardware, and let the
nozzle-diameter siblings inherit it, restating only the variant arrays whose values change. `check`
does not judge a base on its own; its arrays count where they reach a selectable preset, under
`check --strict`. The loader stores a base at its **own** `printer_extruder_variant`, one variant
when it writes none whatever its extruder count, and cuts a wider array to its first values before
any child inherits it; so a multi-extruder base whose extruders need different values declares the
layout itself ([composition](#padding-truncation-and-composition)). When a variant array can move from
the presets to a base is in [shared-bases.md](shared-bases.md#variant-arrays-on-a-base).
## Process
1. `print_extruder_variant` + `print_extruder_id` list the (extruder id, variant string) pairs of the
process's variants. A variant is found by that pair, never by position: what is **required** is
that every pair a compatible printer (by list or condition) can select is present, in any order. A
missing pair reads the process's variant index 0 for that extruder, an extra pair is a variant
nothing selects, and neither is reported. The exception is a single-extruder printer whose
`extruder_variant_list` offers one variant string: no pair is matched there and variant index 0
is read, so a process shared with such a printer lists that printer's pair first. **Mirroring** the printer's `printer_extruder_variant` +
`printer_extruder_id` entry for entry is the convention; follow it, so the arrays compare by eye,
but a different order with every pair present is a nit, not a defect. A process shared by printers
whose pairs differ falls back to variant index 0 on the pairs it lacks; give each layout its own base.
2. Every key in `print_options_with_variant` ([the full list](#the-four-key-sets), which is not
"every speed") has exactly one value per variant, or is left out. `print_extruder_id` needs its
value per variant too: one value pads to extruder 1 everywhere. `check` holds a
`print_extruder_id` that reaches the preset, written or inherited, to one entry per variant on any
printer, without `--strict`, and warns when it is absent and a variant repeats.
3. Only add process variants if speeds or accelerations really differ per nozzle volume type or
extruder. Otherwise omit the variant keys: a one-variant process needs no list, because match and
no-match both read variant index 0, and that variant is copied to every extruder at slice time.
4. Put the lists on the process base for that printer layout, so leaves stay small.
## Filament
```json
"filament_extruder_variant": ["Direct Drive Standard", "Direct Drive High Flow"],
"filament_max_volumetric_speed": ["21", "29"],
"filament_flow_ratio": ["0.98", "0.98"],
"filament_retraction_length": ["nil", "0.4"]
```
1. `filament_extruder_variant` lists variants **without extruder ids**: a filament's High Flow variant
is used on whichever extruder is in High Flow. Its entries must be distinct, since the lookup
returns the first equal string and a repeated entry is a variant nothing selects. There is no
filament id key: `filament_extruder_id` exists only as a G-code placeholder, not as a filament
option (its option and its set entry are commented out in `PrintConfig.cpp`), so a filament file that writes it loses the key as unknown.
`filament_extruder_compatibility` is unrelated to variants (it says which extruders the filament
may be loaded into).
2. Every key in `filament_options_with_variant` ([the full list](#the-four-key-sets)) that reaches the
preset, whether written, included or inherited, is resized to its variant count: write it at exactly
that width, or leave it out. Keys outside the set
(`filament_type`, plate temperatures, `fan_max_speed`, `slow_down_min_speed`, …) are never addressed by variant index; the preset contributes their first value however wide a
file writes them.
3. Cover every variant the material is meant to print on across its `compatible_printers`. Leave out
a variant deliberately when the material should not be tuned for it (e.g. a TPU High Flow variant
only on TPU filaments); an extruder reporting that variant string then reads the first variant.
4. Order the variants like the printer's, Standard first, so the first-variant fallback is the
conservative one.
5. Tune what really differs: `filament_max_volumetric_speed` is the usual difference between nozzle
volume types, then flow ratio, temperature and retraction. Use measured values; never copy the
Standard value into the High Flow variant and call it tuned.
6. `nil` is legal per variant in the nullable override keys, occupies one entry like any value, and
keeps the printer's value for that variant only.
7. Declare a multi-variant list with the arrays it sizes: on the filament, in its own file or in a
template it pulls in with [`include`](vendor-bundle.md#inherits-and-include). The filament may be
compatible with printers that list fewer variants, or none: each printer takes the filament's variant
for its own variant string, else the filament's first variant. A one-variant list on a shared base
is harmless, because one variant is the width a list-less preset has anyway; and a list-less base is
stored at that width, so a wider array on it reaches its children as its first value only.
A key such a base writes reaches a multi-variant child as one value, which the loader spreads over
the child's variants; `check --strict` reports it at the child, which restates it at its own width.
## Widths
Every variant key is a flat array addressed by **variant index**: index `n` belongs to entry `n` of
the preset's own variant list (`printer_extruder_variant`, `print_extruder_variant` or
`filament_extruder_variant`). The index is found by exact compare of the string
`"<extruder_type> <nozzle volume type>"` (plus the 1-based extruder id for printer and process lists),
and the value is read at `index × stride`:
| Stride | Keys | Layout |
| --- | --- | --- |
| 1 | `printer_options_with_variant_1`, `print_options_with_variant`, `filament_options_with_variant` | `[variant 0, variant 1, …]` |
| 2 | `printer_options_with_variant_2` (the `machine_max_*` limits) | `[variant 0 normal, variant 0 silent, variant 1 normal, variant 1 silent, …]` |
So a variant array is `variant length × stride` long. The **variant length** is the number of
entries in the preset's variant list, counted on the preset's config **after** `include` and
`inherits` are applied, dead variants included. An inherited array arrives already resized to the
base's own variant length, an included one at the width its file wrote
([composition](#padding-truncation-and-composition)):
| Preset | Variant length |
| --- | --- |
| `machine` | `len(printer_extruder_variant)`; without it, the variants `extruder_variant_list` offers; without both, **one per extruder** (`len(nozzle_diameter)`), since the list's default is one `Direct Drive Standard` per extruder. `extruders_count` is a printer-tab field, not a preset key. The loader honours that default only halfway for a system preset: it sizes the composed preset by the one-entry default `printer_extruder_variant`, cutting every variant array to its first value, and only then (with `single_extruder_multi_material` off) rebuilds the pair to one variant per extruder ([Printer rule 2](#printer-machine)) and pads the arrays with that value. The rule's width is still one per extruder; to give extruders different values, declare the layout. |
| `process` | `len(print_extruder_variant)`; without one, 1 |
| `filament` | `len(filament_extruder_variant)`; without one, 1 |
The per-extruder keys in `printer_extruder_options` ([listed with the sets](#the-four-key-sets)) are
outside this scheme and stay one value per extruder. Keys outside [the four sets](#the-four-key-sets)
are never variant-resized or addressed by variant index, however wide a shipped file writes them; a
per-extruder vector holds `len(nozzle_diameter)` values, and a shorter one acts as padded with its
first value ([machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers)).
### Sizing equation
For a key in one of the four variant sets:
```
E = extruders = len(nozzle_diameter) = len(extruder_type)
= len(default_nozzle_volume_type) = len(extruder_variant_list)
V_i = variants listed for extruder i = ","-separated entries of extruder_variant_list[i],
each "<extruder_type[i]> <nozzle volume type>"
S = variant length = V_1 + V_2 + … + V_E
= len(printer_extruder_variant) = len(printer_extruder_id)
k = stride = 2 for printer_options_with_variant_2, else 1
N = values the key holds, one per variant:
machine (printer_options_with_variant_1, _2) = S × k
process (print_options_with_variant) = len(print_extruder_variant) = len(print_extruder_id)
filament (filament_options_with_variant) = len(filament_extruder_variant)
values[s × k + m] = variant index s, mode m (m = 0 normal, m = 1 silent; only m = 0 at stride 1)
variant index s = (printer_extruder_id[s], printer_extruder_variant[s])
```
`printer_extruder_variant` is not sized by the equation; it defines `S`: it is `extruder_variant_list`
flattened extruder by extruder, and `printer_extruder_id[s]` is the extruder that index `s` came from.
A process that mirrors the printer's pairs, as [Process rule 1](#process) asks, has `N = S`; a
filament lists each variant string it is tuned for once, with no extruder id, so its `N` is its own
and independent of any one printer: it serves every printer in its `compatible_printers`, and a
variant string no extruder of a printer reports is simply never read there (a two-extruder printer
with `S = 7` serves a filament whose `N` is 3, and the filament keeps its 3 on a printer that offers
two variant strings). The
pair is what the loader reads, so the equality with the sum holds when the three keys agree, as
[Printer rule 2](#printer-machine) requires.
A selectable preset that writes a variant key writes it at its own `N`; any other width, one value
included, is an error. A preset that leaves a key out takes what reaches it, the default or an array
it inherits or includes, which the loader resizes to the preset's `N`; `check --strict` holds that
array to the preset's `N` too. A base is not judged on its own: its arrays count only where they reach
a preset that does not override them. BBL is the model for strict: every printer-specific machine,
process and filament declares its layout and restates every variant key at its own `N`, even where all
the values are the same, keys Orca added to the sets included.
Without the lists, `extruder_variant_list` defaults to one
`Direct Drive Standard` per extruder, so a machine has `V_i = 1` and `S = E`, and a process or filament
has `N = 1`; the loader cuts a list-less machine to its first variant and, with
`single_extruder_multi_material` off, widens it again with that value ([variant length](#widths)). The per-extruder keys outside the sets
(`printer_extruder_options` plus `extruder_offset` and `extruder_colour`) and `extruder_variant_list`
itself hold `E` values.
### Sizing examples
**One extruder, two nozzle volume types**: `E = 1`, `V_1 = 2`, so `S = 2`; stride-1 keys hold 2
values, `machine_max_*` hold 4:
```json
"nozzle_diameter": ["0.4"],
"extruder_type": ["Direct Drive"],
"extruder_variant_list": ["Direct Drive Standard,Direct Drive High Flow"],
"printer_extruder_id": ["1", "1"],
"printer_extruder_variant": ["Direct Drive Standard", "Direct Drive High Flow"],
"default_nozzle_volume_type": ["Standard"],
"retraction_length": ["0.8", "1.0"],
"machine_max_speed_x": ["500", "200", "600", "250"]
```
The matching process lists the same two pairs (`print_extruder_id` `["1", "1"]`,
`print_extruder_variant` as above) and holds 2 values per key, e.g. `outer_wall_speed`
`["200", "260"]`; a filament for it lists `["Direct Drive Standard", "Direct Drive High Flow"]` and
holds 2 values per key, e.g. `filament_max_volumetric_speed` `["16", "24"]`.
**Four extruders, one nozzle volume type each** (a tool changer): `E = 4`, every `V_i = 1`, so
`S = 4`; stride-1 keys hold 4 values, `machine_max_*` hold 8:
```json
"nozzle_diameter": ["0.4", "0.4", "0.6", "0.4"],
"extruder_type": ["Direct Drive", "Direct Drive", "Direct Drive", "Direct Drive"],
"extruder_variant_list": ["Direct Drive Standard", "Direct Drive Standard",
"Direct Drive Standard", "Direct Drive Standard"],
"printer_extruder_id": ["1", "2", "3", "4"],
"printer_extruder_variant": ["Direct Drive Standard", "Direct Drive Standard",
"Direct Drive Standard", "Direct Drive Standard"],
"default_nozzle_volume_type": ["Standard", "Standard", "Standard", "Standard"],
"retraction_length": ["0.8", "0.8", "1.2", "0.8"],
"machine_max_speed_x": ["500", "200", "500", "200", "500", "200", "500", "200"]
```
The (normal, silent) pair is repeated per extruder: `["500", "200"]` would be width 2 against
`S × k = 8` (the loader pads it to `500, 200, 500, 500, …`) and `["500"]` width 1; both are errors. The
process mirrors the four pairs (`print_extruder_id` `["1", "2", "3", "4"]`) with 4 values per key, or
omits the variant keys altogether when nothing differs per extruder (then one value per key). A
filament for it lists only `["Direct Drive Standard"]`: one entry, so one value per key. Drop the
layout from this printer and the widths stay the same, since the default list gives `S = E = 4`; but
the loader then keeps only the first variant ([variant length](#widths)): `retraction_length`
becomes `0.8` on every extruder and extruder 3 loses its `1.2`.
### Adding a variant inserts its values at its variant index
Indexes run extruder-major: extruder 1's variants in `extruder_variant_list` order, then extruder
2's. A new variant's values go in at its index, not at the end. Giving extruder 1 of a two-extruder
printer a High Flow option, when only extruder 2 had one:
| Key | Before | After |
| --- | --- | --- |
| `extruder_variant_list` | `["Direct Drive Standard", "Direct Drive Standard,Direct Drive High Flow"]` | `["Direct Drive Standard,Direct Drive High Flow", "Direct Drive Standard,Direct Drive High Flow"]` |
| `printer_extruder_id` | `["1", "2", "2"]` | `["1", "1", "2", "2"]` |
| `printer_extruder_variant` | `[Standard, Standard, High Flow]` | `[Standard, High Flow, Standard, High Flow]` (full strings in the file) |
| `retraction_length` (stride 1) | `["0.8", "1.0", "1.2"]` | `["0.8", "?", "1.0", "1.2"]`: one value at index 1 |
| `machine_max_speed_x` (stride 2) | `["500", "200", "600", "250", "700", "300"]` | `["500", "200", "?", "?", "600", "250", "700", "300"]`: a (normal, silent) pair at position 2 |
| `print_extruder_id` / `print_extruder_variant` / `outer_wall_speed` | mirror the printer | the same insertion at index 1 |
| `filament_extruder_variant` `[Standard, High Flow]` | — | unchanged: filament variants carry no extruder id, so the existing High Flow variant now serves both extruders |
Every `?` is a measured value for that nozzle, on the machine limits as much as on retraction.
Removing or renaming a variant shifts the later values the same way in reverse; a variant string that
no longer matches the enum is simply a variant nothing selects.
### Padding, truncation and composition
**Every key in the set widens, whether or not a file restates it.** At load each variant key of the
composed config is resized to the length of the preset's own `*_extruder_variant` (the one-entry
default when it writes none) × stride: a short array is **padded by repeating its first value**, a
long one is truncated to its first values, without a word from the loader or the validator. The rule
does not follow from this padding: a file writes a variant key at **exactly `variant length × stride`**
or not at all. Any other length, one value included (which the loader spreads over every variant, at
stride 2 over normal *and* silent alike), is a mistake the loader hides and
`orca_profile_tool.py check` reports as an error in the selectable preset that writes it, even when
every value is the same; `check --strict` also reports an array that reaches a selectable preset at
another width
([variant arrays](validation.md#variant-arrays)).
The loader sizes a list-less preset of any type to one variant at this step, a list-less machine
included: a two-extruder machine without a layout that writes `retraction_length` `["0.8", "0.9"]`, the
width the equation asks for, stores `["0.8"]`, which the pair rebuild and the slice-time collapse hand
to both extruders. A 3-value array on a preset of variant length 2 keeps its first two; at length 4 it
becomes `[a, b, c, a]`.
Composition hands down widths in two ways:
- **`inherits` hands down the parent's resized arrays.** A base is stored after its own resize, at
the length of its own `*_extruder_variant` (one variant for a base of any type that writes none,
whatever its extruder count), so an array wider than the base's list is cut to its first values
before any child sees it, and a child that adds variants gets those first values padded. Widen an
array only on a preset whose own resolved list already has the entries. `check` judges selectable
presets only, at the width each file wrote: an array a base's own resize cuts is not seen (a review
item), and an inherited array of another width than a child's list is left to the loader's resize
unless `check --strict`, which asks the child to restate it at its own width.
- **`include` hands down the template's diff at its pre-resize width.** The template contributes every
key where its composed config differs from the built-in defaults, taken before its own resize, so the
arrays it writes arrive at the width its file wrote, and the includer's own list sizes them. A key the
template sets to the built-in default is not passed on
([`include`](vendor-bundle.md#inherits-and-include)).
The id keys are the trap in this padding: `printer_extruder_id` and `print_extruder_id` are members
with default `[1]`, so a missing or one-value id array beside a longer variant list is padded to
extruder 1 at every index. That is right on a single-extruder printer and wrong on a multi-extruder
one, where every variant is then addressed as extruder 1's. A machine escapes it only where the
load-time pair rebuild of [Printer rule 2](#printer-machine) runs (`single_extruder_multi_material`
off); a process's `print_extruder_id` is never rebuilt at load. `check`
holds an id array that reaches a selectable preset, written or inherited, to one entry per variant on
any printer; `fix-variant` never pads an id array or a variant list, since those address the
variants rather than fill them.
Consequences:
- A base that gains a variant silently pads every descendant that restates a variant array at the old
width, and a short `machine_max_*` array copies variant 0's *normal* limit into the silent entries
too; a descendant that restates nothing inherits the widened array and needs no edit. Extend, in one
change: the printer base and each nozzle-diameter sibling that restates a variant array, the process
bases that mirror the printer's variants, and the filaments that should cover the variant.
- A one-value override is reported, and the loader spreads it over **all** variants, overwriting the
ones that should differ.
- A process or filament that omits the new variant is not padded; the variant resolves to its index 0.
### Slice time and existing users
**Slice time collapses variants to extruders.** When printer, process and filaments are combined on a
printer with several extruders or several variant strings, each printer and process variant key is
re-gathered to one entry per extruder (× stride) in extruder order, using each extruder's live nozzle
volume type, or to one entry per nozzle volume type for an extruder that holds several (Hybrid, or
`extruder_nozzle_stats` listing more types than there are extruders); on any other printer they are
not re-gathered and variant index 0 is read. Filament keys are re-gathered to one entry per filament,
on a printer with a single variant too once a filament has several variants, and under a dynamic
nozzle map to one entry per variant each filament prints through. Custom G-code and the
`machine_max_*` limits therefore index by extruder or filament, never by variant index; variant order
matters only inside the preset. An extruder with no matching variant reads variant index 0, extruder
1's first variant, whichever extruder it is; under layered nozzle grouping, `get_config_index_base`
reads the collapsed arrays and falls back to the same extruder's first entry instead.
**Existing user presets and projects follow the variant string, not the position.** A user preset
stores every variant array it changed (nullable keys as per-variant diffs, `nil` where equal to the
parent). On load each parent variant takes the child's value for the variant with the same extruder id
and variant string; variants the parent gained keep the parent's value, and a child whose arrays the
parent cannot map keeps its own. Per-object process overrides are remapped when a printer change
alters the extruder count or the length of `printer_extruder_variant`, by variant string alone (no
extruder id; of several matching values the smallest wins), and a single-value override applies to
every variant. Adding or reordering variants in a shipped preset is therefore safe for existing
users; renaming a variant, or moving it to another extruder id, loses their values for it.
**A new nozzle volume type changes no array.** Adding one to the code widens nothing until a profile
lists the new variant string; until then every existing array keeps its length and meaning.
## The four key sets
Membership is literal (four `std::set<std::string>` initializers in `src/libslic3r/PrintConfig.cpp`)
and **not guessable from names**: `ironing_speed`, `skirt_speed`, `wipe_speed`, `scarf_joint_speed`,
`small_support_perimeter_speed` and `wipe_tower_max_purge_speed` are process speeds outside the set,
while every process `*_acceleration` and `*_jerk` key is inside, and so are
`small_perimeter_threshold`, `top_solid_infill_flow_ratio` and `slowdown_for_curled_perimeters`;
`filament_flush_temp` is in and `filament_flush_temp_fast` out; `use_firmware_retraction` is out and
`travel_slope` and `retract_lift_enforce` in. The three variant-list keys and the two id
keys are members of their own set (the list sizes itself, a no-op; the id keys are padded like any
other member, [the id trap](#padding-truncation-and-composition)), while `extruder_variant_list` is in
no set: the variant-length resize leaves it alone, and only the pair rebuild of
[Printer rule 2](#printer-machine) pads it. The per-extruder `printer_extruder_options` is listed
last for contrast; it is not a variant set.
`check` and `fix-variant` read the four sets from `PrintConfig.cpp` on every run, so they follow the
engine. The lists below are from the 2026-09-29 checkout; regenerate them from the repository root
before relying on them (the recipe strips comments, since an initializer can carry a commented-out entry):
```bash
python3 - <<'EOF'
import re
src = open('src/libslic3r/PrintConfig.cpp', encoding='utf-8', errors='replace').read()
for name in ['printer_options_with_variant_1', 'printer_options_with_variant_2',
'print_options_with_variant', 'filament_options_with_variant',
'printer_extruder_options']:
body = re.search(r'std::set<std::string>\s+' + name + r'\s*=\s*\{(.*?)\};', src, re.S).group(1)
body = re.sub(r'/\*.*?\*/', '', body, flags=re.S)
body = re.sub(r'//[^\n]*', '', body)
print(name, sorted(set(re.findall(r'"([^"]+)"', body))))
EOF
```
**`printer_options_with_variant_1`**, machine, stride 1 (27): `deretraction_speed`, `hotend_cooling_rate`, `hotend_heating_rate`, `long_retractions_when_cut`, `nozzle_flush_dataset`, `nozzle_type`, `nozzle_volume`, `printer_extruder_id`, `printer_extruder_variant`, `retract_after_wipe`, `retract_before_wipe`, `retract_length_toolchange`, `retract_lift_above`, `retract_lift_below`, `retract_lift_enforce`, `retract_restart_extra`, `retract_restart_extra_toolchange`, `retract_when_changing_layer`, `retraction_distances_when_cut`, `retraction_length`, `retraction_minimum_travel`, `retraction_speed`, `travel_slope`, `wipe`, `wipe_distance`, `z_hop`, `z_hop_types`
**`printer_options_with_variant_2`**, machine, stride 2 (16): `machine_max_acceleration_e`, `machine_max_acceleration_extruding`, `machine_max_acceleration_retracting`, `machine_max_acceleration_travel`, `machine_max_acceleration_x`, `machine_max_acceleration_y`, `machine_max_acceleration_z`, `machine_max_jerk_e`, `machine_max_jerk_x`, `machine_max_jerk_y`, `machine_max_jerk_z`, `machine_max_junction_deviation`, `machine_max_speed_e`, `machine_max_speed_x`, `machine_max_speed_y`, `machine_max_speed_z`
**`print_options_with_variant`**, process, stride 1 (45): `bridge_acceleration`, `bridge_speed`, `default_acceleration`, `default_jerk`, `default_junction_deviation`, `enable_overhang_speed`, `gap_infill_speed`, `infill_jerk`, `initial_layer_acceleration`, `initial_layer_infill_speed`, `initial_layer_jerk`, `initial_layer_speed`, `initial_layer_travel_acceleration`, `initial_layer_travel_jerk`, `initial_layer_travel_speed`, `inner_wall_acceleration`, `inner_wall_jerk`, `inner_wall_speed`, `internal_bridge_speed`, `internal_solid_infill_acceleration`, `internal_solid_infill_speed`, `outer_wall_acceleration`, `outer_wall_jerk`, `outer_wall_speed`, `overhang_1_4_speed`, `overhang_2_4_speed`, `overhang_3_4_speed`, `overhang_4_4_speed`, `print_extruder_id`, `print_extruder_variant`, `slowdown_for_curled_perimeters`, `small_perimeter_speed`, `small_perimeter_threshold`, `sparse_infill_acceleration`, `sparse_infill_speed`, `support_interface_speed`, `support_speed`, `top_solid_infill_flow_ratio`, `top_surface_acceleration`, `top_surface_jerk`, `top_surface_speed`, `travel_acceleration`, `travel_jerk`, `travel_speed`, `travel_speed_z`
**`filament_options_with_variant`**, filament, stride 1 (54): `activate_air_filtration`, `activate_air_filtration_during_print`, `activate_air_filtration_on_completion`, `adaptive_pressure_advance`, `adaptive_pressure_advance_bridges`, `adaptive_pressure_advance_model`, `adaptive_pressure_advance_overhangs`, `complete_print_exhaust_fan_speed`, `during_print_exhaust_fan_speed`, `enable_pressure_advance`, `filament_adaptive_volumetric_speed`, `filament_cooling_before_tower`, `filament_deretraction_speed`, `filament_extruder_variant`, `filament_flow_ratio`, `filament_flush_temp`, `filament_flush_volumetric_speed`, `filament_ironing_flow`, `filament_ironing_inset`, `filament_ironing_spacing`, `filament_ironing_speed`, `filament_long_retractions_when_cut`, `filament_max_volumetric_speed`, `filament_pre_cooling_temperature`, `filament_pre_cooling_temperature_nc`, `filament_preheat_temperature_delta`, `filament_ramming_travel_time`, `filament_ramming_travel_time_nc`, `filament_ramming_volumetric_speed`, `filament_ramming_volumetric_speed_nc`, `filament_retract_after_wipe`, `filament_retract_before_wipe`, `filament_retract_length_nc`, `filament_retract_length_toolchange`, `filament_retract_lift_above`, `filament_retract_lift_below`, `filament_retract_lift_enforce`, `filament_retract_restart_extra`, `filament_retract_restart_extra_toolchange`, `filament_retract_when_changing_layer`, `filament_retraction_distances_when_cut`, `filament_retraction_length`, `filament_retraction_minimum_travel`, `filament_retraction_speed`, `filament_wipe`, `filament_wipe_distance`, `filament_z_hop`, `filament_z_hop_types`, `long_retractions_when_ec`, `nozzle_temperature`, `nozzle_temperature_initial_layer`, `pressure_advance`, `retraction_distances_when_ec`, `volumetric_speed_coefficients`
**`printer_extruder_options`**, machine, one value per extruder, not a variant set (8):
`default_nozzle_volume_type`, `extruder_max_nozzle_count`, `extruder_printable_area`,
`extruder_printable_height`, `extruder_type`, `max_layer_height`, `min_layer_height`,
`nozzle_diameter`. These are never addressed by variant index; `extruder_offset`, `extruder_colour` and
`extruder_variant_list` are per extruder too. A shorter array acts as padded with its first value, and
entries beyond the extruder count are never read
([per-extruder vectors](machine-profiles.md#multi-extruder-idex-and-tool-changers)).
## Checking and testing
Checklist for a new variant profile set:
1. Decide the variants per extruder from the real hardware; pick legal strings only.
2. Printer base: `extruder_type`, `extruder_variant_list`, flattened `printer_extruder_variant` +
`printer_extruder_id`, `default_nozzle_volume_type`; every variant array at variant length, every
`machine_max_*` at 2 × variant length as (normal, silent) pairs, even where the values are the
same, values in variant order.
3. A process base per variant layout, or no variant keys at all.
4. Filaments for the printer: a variant list covering the intended variants, every variant key at that
width, measured values per nozzle volume type.
5. Run the usual authoring commands and full checks. `check` holds each array of steps 2–4 to the
width of the selectable preset that writes it, `check --strict` also to every selectable preset it
reaches, and
the printer's layout keys through every selectable preset
([variant arrays](validation.md#variant-arrays)); `check_variant_names` holds every variant string,
`extruder_type`, `nozzle_volume_type` and `default_nozzle_volume_type` to the enums
([variant names](validation.md#variant-names)). The choice of variants, the variant order and
the measured values are not checked.
6. In the app, for each nozzle volume type in the sidebar combo: slice and confirm the G-code uses that
variant's values (e.g. volumetric speed limit, retraction). `validate_slice` only slices the default
nozzle volume type.
To review rather than author, run the same list against the diff. `check` catches a wrong array length,
a layout key out of step, and a variant string the enums cannot build. The failures no check catches: a
new variant appended instead of inserted at its index, an array widened on a base whose list is
shorter (cut before any child inherits it), a process lacking a pair its printer can select, and a High
Flow variant copied from Standard.
### UI facts to design around
- The single-extruder sidebar shows a **nozzle volume type combo** (tooltip `Flow`, in place of the
nozzle-diameter selector) only when `extruder_variant_list` offers more than one distinct variant
string (`support_different_extruders`); four extruders listing `Direct Drive Standard` each show
none.
- The two-extruder sidebar's per-extruder nozzle volume type combos are shown for BBL printers only.
Another vendor's multi-extruder printer falls back to the single-extruder layout: one combo (for the
first extruder) when the variants differ, otherwise the nozzle-diameter selector, so the other
extruders' nozzle volume type stays at `default_nozzle_volume_type`.
- High Flow is hidden from the combo when `printer_variant` is `0.2` or the printer model is
`Bambu Lab X1E`, and E3D High Flow unless `printer_variant` is `0.4` or `0.6`. A variant listed on
another nozzle diameter is never selectable there.
- The filament tab shows a variant switch built from the filament's own `filament_extruder_variant`;
a multi-variant filament whose tab shows none did not resolve its variant list through `include` or
`inherits`. The printer and process tabs show one entry per extruder instead, labelled with that
extruder's live nozzle volume type (two for Hybrid), and only on two-extruder printers whose
`extruder_variant_list` offers more than one variant string; elsewhere they edit the variant of the
nozzle volume type selected in the sidebar (an X1C shows no switch).
### Worked examples in the tree
`BBL/machine/fdm_bbl_3dp_001_common.json` (one extruder), `fdm_bbl_3dp_002_common.json` (two
extruders), `Bambu Lab X1 Carbon 0.4 nozzle.json` (Standard + High Flow), `Bambu Lab H2D 0.4
nozzle.json` (extruders with different variant sets), `Bambu Lab X2D 0.4 nozzle.json` (Direct Drive +
Bowden), with their `@BBL` processes and filaments. The H2D and X2D examples also carry
`E3D High Flow` variants. BBL's multi-variant filament lists come from `fdm_filament_template_*`
presets that the printer-specific filaments pull in with `include`, not from their `inherits` chain.
@@ -1,8 +1,8 @@
# Filament profiles and OrcaFilamentLibrary # Filament profiles and OrcaFilamentLibrary
`OrcaFilamentLibrary` is the filament-only bundle the loader reads **first**; its config map `OrcaFilamentLibrary` is the filament-only bundle the loader reads **first**, so any vendor's filament may
becomes the base bundle, so any vendor may inherit a library preset by name. It is the only cross-bundle inherit a library preset by name. It is the only cross-bundle parent: vendor-to-vendor inheritance
parent — vendor-to-vendor inheritance always fails. always fails.
## Where a filament goes ## Where a filament goes
@@ -10,63 +10,65 @@ parent — vendor-to-vendor inheritance always fails.
| --- | --- | | --- | --- |
| Generic material for all printers | `OrcaFilamentLibrary/filament/Generic <mat> @System.json` | | Generic material for all printers | `OrcaFilamentLibrary/filament/Generic <mat> @System.json` |
| A brand's product, all printers | `OrcaFilamentLibrary/filament/<Brand>/` | | A brand's product, all printers | `OrcaFilamentLibrary/filament/<Brand>/` |
| A brand's tune for one printer | `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/` — recommended; `<PrinterVendor>/filament/<Brand>/` also works | | A brand's tune for one printer vendor | `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/` (recommended); `<PrinterVendor>/filament/<Brand>/` also works |
| A printer vendor's tune of a generic or its own product | `<Vendor>/filament/` | | A printer vendor's tune of a generic, or its own product | `<PrinterVendor>/filament/` |
Both locations for the last-but-one row are supported: `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/<Name>.json` Both locations in the third row are supported: `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/<Name>.json`
(the shape the wiki shows) and `<PrinterVendor>/filament/<Brand>/`. The library path is the one a (the shape the wiki shows) and `<PrinterVendor>/filament/<Brand>/`. The library path is the one a
filament vendor should contribute to — `OrcaFilamentLibrary/filament/<Brand>/` is the brand's own filament brand should contribute to: `OrcaFilamentLibrary/filament/<Brand>/` is the brand's own folder,
folder, while a printer vendor's folder belongs to that printer vendor. Brand tunes do ship under while a printer vendor's folder belongs to that printer vendor.
printer vendors' folders today (Polymaker and SUNLU among others).
Library layout: `filament/base/fdm_filament_*.json` type roots, root-level `Generic <mat> @System.json` Library layout: `filament/base/fdm_filament_*.json` material roots, root-level
generics, and one subfolder per brand, which may nest printer-specific tunes one level deeper. Adding `Generic <mat> @System.json` generics, and one subfolder per brand, which may nest printer-specific
a brand means adding a folder here; the folder name is a directory label only — `filament_vendor` inside the JSON is the real vendor string. tunes one level deeper. Adding a brand means adding a folder here; the folder name is a directory label
only, and `filament_vendor` inside the JSON is the real vendor string.
## The three-part shape ## The three-part shape
```jsonc ```jsonc
// Fiberon PA6-CF @base.json — the product root, holds identity + material values // OrcaFilamentLibrary/filament/Polymaker/Fiberon PA6-CF @base.json — the product root: identity + material values
{ "type": "filament", "name": "Fiberon PA6-CF @base", "from": "system", { "type": "filament", "name": "Fiberon PA6-CF @base", "from": "system",
"instantiation": "false", "inherits": "fdm_filament_pa", "instantiation": "false", "inherits": "fdm_filament_pa",
"filament_id": "OFkOviHk", // generated here; variants inherit it "filament_id": "OFkOviHk", // minted here by generate-id; every child inherits it
"filament_vendor": ["Polymaker"], "filament_type": ["PA6-CF"], /* … */ } "filament_vendor": ["Polymaker"], "filament_type": ["PA6-CF"], /* … */ }
// Fiberon PA6-CF @System.json — the selectable shim, 7 keys // OrcaFilamentLibrary/filament/Polymaker/Fiberon PA6-CF @System.json — the selectable all-printer shim, 7 keys
{ "type": "filament", "name": "Fiberon PA6-CF @System", "from": "system", { "type": "filament", "name": "Fiberon PA6-CF @System", "from": "system",
"instantiation": "true", "inherits": "Fiberon PA6-CF @base", "instantiation": "true", "inherits": "Fiberon PA6-CF @base",
"setting_id": "…", "compatible_printers": [] } "setting_id": "…", "compatible_printers": [] }
// <PrinterVendor>/filament/Polymaker/Fiberon PA6-CF @BBL X1C.json — a printer tune // BBL/filament/Polymaker/Fiberon PA6-CF @BBL X1C.json — a printer tune (BBL keeps its own copy of the @base)
{ … "inherits": "Fiberon PA6-CF @base", "filament_max_volumetric_speed": ["14"], { …, "inherits": "Fiberon PA6-CF @base", "filament_max_volumetric_speed": ["14"],
"compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", …] } "compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", …] }
``` ```
- `@base` is the convention for a root. A base carries **no** `setting_id`, no `compatible_printers`, no - `@base` is the convention for a root; a root is really `instantiation: "false"`. A base carries
`filament_settings_id`. Only the `setting_id` half is enforced, and nothing violates it; the other two **no** `setting_id`, no `compatible_printers` and no `filament_settings_id`. Only the `setting_id`
are unchecked and plenty of bases still carry them. Do not copy that from a neighbouring file. half is enforced; the other two are unchecked, so a neighbouring base that carries them is no model.
- Every `@System` must be `"instantiation": "true"`. DREMC ships `@System` presets set to `"false"`, - Every `@System` shim must be `"instantiation": "true"`; one set to `"false"` would ship but could
which therefore ship but can never be selected; no check catches it. never be selected, and no check catches it. The shim exists only for products in the library.
- A duplicated brand `@base` across bundles is normal and intentional (`Fiberon PA6-CF @base` exists in - `filament_cost`, `filament_density`, `filament_type` and `filament_vendor` belong on the root and
both the library and BBL with the same id, differing only in MVS) — bases never enter the preset should not appear in a printer tune.
collection, so there is no duplicate-name error. - A brand `@base` duplicated across bundles is legal (a vendor bundle may keep its own copy of a
- You may inherit from an instantiated preset as well as from a base; it is common. library product root, with the same id): bases never become selectable presets and the
duplicate-name error covers only those, so there is none.
- You may inherit from an instantiated preset as well as from a base.
## Color is a runtime property ## Colour is a runtime property
`filament_id` identifies a product, not a color; filament sync/AMS reads the color from the spool at `filament_id` identifies a product, not a colour; filament sync and AMS read the colour from the spool
runtime. A product ships one all-printer preset and the color is chosen at runtime — never a sibling at runtime. A product ships one all-printer preset and the colour is chosen at runtime, never a sibling
preset that differs only by color. A material family (PLA vs PLA Matte vs PLA Silk) is a new product; a preset that differs only by colour. A material family (PLA vs PLA Matte vs PLA Silk) is a new product;
color is not. A printer tune keeps the product alias and does not multiply per color either. a colour is not. A printer tune keeps the product alias and does not multiply per colour either.
CI does not catch this — per-color presets pass `check` — so it is a review call. CI does not catch this (per-colour presets pass `check`), so it is a review call.
## The two most common contributions ## The two most common contributions
**A printer vendor tuning a generic.** Keep the `Generic X` base name so the alias shadows the library **A printer vendor tuning a generic.** Keep the `Generic X` alias so it shadows the library preset on
preset on your printers, inherit `Generic X @System`, declare **no** `filament_id` (inheriting the your printers, inherit `Generic X @System`, declare **no** `filament_id` (inheriting the library's is
library's is correct — the product really is the library's generic), and give it a non-empty correct: the product really is the library's generic), and give it a non-empty `compatible_printers` in
`compatible_printers` in its own body: its own file:
```jsonc ```jsonc
// <Vendor>/filament/Generic PETG @Acme One 0.4 nozzle.json // <Vendor>/filament/Generic PETG @Acme One 0.4 nozzle.json
@@ -76,10 +78,11 @@ library's is correct — the product really is the library's generic), and give
"compatible_printers": ["Acme One 0.4 nozzle"] } "compatible_printers": ["Acme One 0.4 nozzle"] }
``` ```
**A printer vendor's own branded product.** Give it a `@base` root so `generate-id` can mint the id (see **A printer vendor's own branded product.** Give it a `@base` root on a material base so `generate-id`
[ids.md](ids.md) — inheriting `Generic X @System` directly makes the id unfixable by the tool), then one can mint the id, then one instantiated leaf per printer in the same bundle. Inheriting
instantiated leaf per printer in the same bundle. No `@System` shim: that is only for a product entering `Generic X @System` directly gives the product the generic's id, which the tool cannot fix
OrcaFilamentLibrary. ([ids.md](ids.md#what-generate-id-does-and-does-not-fix)). No `@System` shim: that is only for a product
entering OrcaFilamentLibrary.
```jsonc ```jsonc
// <Vendor>/filament/Acme Aura PETG @base.json — instantiation false, no setting_id // <Vendor>/filament/Acme Aura PETG @base.json — instantiation false, no setting_id
@@ -94,174 +97,190 @@ OrcaFilamentLibrary.
"compatible_printers": ["Acme One 0.4 nozzle"] } "compatible_printers": ["Acme One 0.4 nozzle"] }
``` ```
Omit `filament_settings_id` from new presets — it is runtime bookkeeping the app rewrites to the preset Omit `filament_settings_id` from new presets: it is runtime bookkeeping the app rewrites to the preset
name. name.
To offer either kind by default, add its name to each model's `default_materials`; put it first in the
machine's `default_filament_profile` only if it should be the preselected filament
([machine keys](machine-profiles.md#other-keys)).
## `compatible_printers` ## `compatible_printers`
- **Library fallbacks:** empty `[]` or absent, so they are offered on all printers except where - **Library fallbacks** (`@System`): empty `[]` or absent, so they are offered on all printers except
[alias shadowing](#alias-shadowing) supplies a printer-specific tune. where [alias shadowing](#alias-shadowing) supplies a printer-specific tune.
- **Library printer-specific tunes:** non-empty, listing exact printer **variant** names. These can - **Library printer-specific tunes**: non-empty, listing exact printer **variant** names. These
supersede a same-alias fallback just like a tune in a printer vendor's bundle. supersede a same-alias fallback just like a tune in a printer vendor's bundle.
- **Instantiated filaments in every other vendor:** non-empty, listing exact printer **variant** names. - **Instantiated filaments in every other vendor**: non-empty, listing exact printer **variant** names.
Enforced twice but not identically: the C++ `has_errors` reads the *flattened* config, so an inherited list satisfies it, Enforced twice, but not identically: `validate_system` reads the resolved config, so an inherited list
while the Python check reads the file's **own** key. Write the list in the file itself. This is the satisfies it, while `check` reads the file's **own** key. Write the list in the file itself. This is
most common filament CI failure. the most common filament CI failure.
- Emptying it to "make it apply everywhere" fails that check *and* creates a duplicate-`filament_id` - Emptying it to "make it apply everywhere" fails that check *and* collides with the library generic's
collision against the library generic on every printer. `filament_id` on every printer.
- Copying a base's full printer list onto a nozzle-specific variant produces duplicate combobox entries — - Copying a base's full printer list onto a nozzle-specific tune produces duplicate combobox entries: a
a real shipped bug twice over. real shipped bug twice over.
## Overlapping coverage: one variant, one profile per product ## Overlapping coverage: one variant, one profile per product
`filament_id` is the **product** key, not the preset key — every variant of one product shares it `filament_id` is the **product** key, not the preset key: every preset of one product shares it
(`<filament_vendor>/<filament_type>/<name-before-@>`). So if one printer variant appears in the (`<filament_vendor>/<filament_type>/<alias>`). So if one printer variant appears in the
`compatible_printers` of two presets of that product, the slicer cannot tell them apart at AMS match time. `compatible_printers` of two presets of that product, the slicer cannot tell them apart at AMS match
The C++ validator reports `Ambiguous AMS filament match: N presets share filament_id "X" … printer "Y"`. time. `validate_system` reports `Ambiguous AMS filament match: N filament presets share filament_id "X"
`orca_profile_tool.py check` does **not** see it and passes. Resolve the overlap by **specificity**: keep and are all compatible with printer "Y"`; `orca_profile_tool.py check` does **not** see it and passes.
the variant on the most specific profile and remove it from every more general one. Deleting a profile is Resolve the overlap by **specificity**: keep the variant on the most specific profile and remove it from
the least preferred fix — moving coverage keeps the tune that users rely on. every more general one. Deleting a profile is the least preferred fix: moving coverage keeps the tune
that users rely on.
Judge specificity from the profile's `compatible_printers` — how many variants it actually covers — and Judge specificity from the profile's `compatible_printers` (how many variants it actually covers) and
use the name only as a secondary, easily-vague hint; decide by the lists, with a best judgement call on use the name only as a secondary, often vague hint; decide by the lists, with a judgement call on the
the name. Naming conventions differ by vendor: BBL's is the reference (`@<Vendor> <Model>` for a whole name. Naming conventions differ by vendor: BBL's is the reference (`@<Vendor> <Model>` for a whole
model, `@<Vendor> <Model> <nozzle> nozzle` for one variant, `@<Vendor>` for a vendor-wide generic), but model, `@<Vendor> <Model> <nozzle> nozzle` for one variant, `@<Vendor>` for a vendor-wide generic), but
others vary (`@<printer model>`, a printer serial, or Creality's `@<Model>-all`). A name never overrides others vary (`@<printer model>`, a printer serial, or Creality's `@<Model>-all`). A name never overrides
the list — see [preset naming](naming.md) for the shapes. the list; see [preset naming](naming.md#filament) for the shapes.
Specificity, most to least: Specificity, most to least:
1. **Variant-specialized** — lists a single printer variant (BBL-style 1. **Variant-specialized**: lists a single printer variant (BBL-style
`... @<Vendor> <Model> <nozzle> nozzle`). `… @<Vendor> <Model> <nozzle> nozzle`).
2. **Model-specialized** — lists the variants of one printer model (BBL-style `... @<Vendor> <Model>`). 2. **Model-specialized**: lists the variants of one printer model (BBL-style `… @<Vendor> <Model>`). It
It should cover every variant of its model, not only the nozzle it was authored for. should cover every variant of its model, not only the nozzle it was authored for.
3. **Family / series** — lists variants spanning a printer family or series. 3. **Family / series**: lists variants spanning a printer family or series.
4. **Generic / catch-all** — vendor-wide, covering many unrelated models (often the bare 4. **Generic / catch-all**: vendor-wide, covering many unrelated models (often the bare
`Generic <mat> @<Vendor>`). `Generic <mat> @<Vendor>`).
Rules: Rules:
- A model-specialized profile is extended to **all** variants of its model, and each variant it thereby - A model-specialized profile is extended to **all** variants of its model, and each variant it thereby
starts covering is removed from the family and generic profiles that also listed it — including variants starts covering is removed from the family and generic profiles that also listed it, including
that had no overlap before. Apply it per nozzle, not just 0.4. variants that had no overlap before. Apply it per nozzle, not just 0.4.
- Apply it **per product**: trim only the material that has a specialized profile from the generic; a - Apply it **per product**: trim only the material that has a specialized profile from the generic; a
material whose product has no specialized profile keeps the variant in the generic. material whose product has no specialized profile keeps the variant in the generic.
- Never strip coverage a variant has nowhere else to get. If a variant has no variant-level specialized - Never strip coverage a variant has nowhere else to get. If a variant has no variant-level specialized
profile, the next level down keeps it; when the model has specialized profiles, the model-level one wins profile, the next level down keeps it; when the model has specialized profiles, the model-level one
over the family/generic. wins over the family and generic ones.
- Moving coverage is preferred over deleting. If a profile must be deleted, remove the more general - Moving coverage is preferred over deleting. If a profile must be deleted, remove the more general one,
one, not the specialized profile that carries the tune. not the specialized profile that carries the tune.
- After moving coverage, repoint the affected `default_filament_profile` (machine) and clean the model's - After moving coverage, repoint the affected machine's `default_filament_profile` and clean the model's
`default_materials`: they should name the most specific profile that covers the variant, and should not `default_materials`: they should name the most specific profile that covers the variant, and should
keep generic entries that no longer cover the model. This rule applies equally when adding or fixing not keep generic entries that no longer cover the model. This rule applies equally when adding or
defaults. fixing defaults.
Multiple profiles of one product with **disjoint** `compatible_printers` is the intended end state. Several profiles of one product with **disjoint** `compatible_printers` is the intended end state.
Adding coverage to the specialized profile and removing it from the generic is the preferred direction. Adding coverage to the specialized profile and removing it from the generic is the preferred direction.
**Detection caveat:** `orca_profile_tool.py check` is blind to this; only the C++ validator behind the full **Detection caveat:** `orca_profile_tool.py check` is blind to this; only the validator behind the full
`./scripts/check_profile.sh` reports it (`validate_system`). Always confirm with that, not the vendor-scoped `./scripts/check_profile.sh` reports it (`validate_system`). Always confirm with that, not the
loop. vendor-scoped loop.
## Alias shadowing ## Alias shadowing
A printer-specific filament in either the library or a vendor bundle supersedes the library fallback A printer-specific filament in either the library or a vendor bundle supersedes the library fallback on
on the printers it lists. The matching key is the **alias**: the preset name up to the **first** `@`, the printers it lists. The matching key is the **alias**: the preset name up to the **first** `@`,
right-trimmed (no `@` → the whole name). So right-trimmed (no `@` → the whole name). So `QIDI ABS-GF@Q2-Series` aliases to `QIDI ABS-GF`.
`QIDI ABS-GF@Q2-Series` aliases to `QIDI ABS-GF`.
A library preset with an empty `compatible_printers` collects, into `m_excluded_from`, every printer named A library preset with an empty `compatible_printers` is hidden on every printer that a same-alias preset
by any same-alias preset that *has* a non-empty list, and is then hidden on those printers. lists in a non-empty `compatible_printers`, whether that preset is in the library or in a vendor bundle
(a printer matches by its own name or its parent's).
Two consequences: Two consequences:
- **Only an unrestricted library fallback can be shadowed.** Two printer-specific presets sharing - **Only an unrestricted library fallback can be shadowed.** Two printer-specific presets sharing an
an alias do not exclude each other — overlapping lists for the same product trip the alias do not hide each other; overlapping lists for the same product trip the ambiguous-match error
duplicate-`filament_id` check instead. above instead.
- This is why adding `Generic PLA @<printer>` to a vendor silently removes the library `Generic PLA` - This is why adding `Generic PLA @<printer>` to a vendor silently removes the library
from that printer. Intended — and the reason a vendor tuning a generic must **keep the `Generic X` `Generic PLA @System` from that printer. That is intended, and the reason a vendor tuning a generic
base name**. must **keep the `Generic X` alias**.
The literal spelling `Generic <mat> @System` is load-bearing beyond shadowing: `find_preset2` rewrites an The literal spelling `Generic <mat> @System` is load-bearing beyond shadowing: when a user preset, an
unresolved name containing "Generic" into that form and retries against the library, which is how 3MF imported preset or a 3MF project names a parent that no longer resolves and contains `Generic`, the
and project recovery works. loader rewrites the name into `Generic <mat> @System` and retries. Only the library ships those names,
so keep them.
## `filament_id`, `filament_vendor`, `filament_type` ## `filament_id`, `filament_vendor`, `filament_type`
`filament_id` is minted from the triple `(filament_vendor, filament_type, name-before-first-@)`. `filament_id` is minted from the triple `(filament_vendor, filament_type, alias)`. `filament_vendor` and
`filament_vendor` and `filament_type` are therefore **identity, not decoration** — editing either `filament_type` are therefore **identity, not decoration**: editing either, or the alias, re-mints the
re-mints the id. Read `docs/HLSD/filament_id.md` before changing any of them, and see id. Read `docs/HLSD/filament_id.md` before changing any of them, and see [ids.md](ids.md) for the
[ids.md](ids.md) for the tooling. tooling.
A filament with no resolvable `filament_id` anywhere in its `inherits` chain is a **hard load error** that A filament with no resolvable `filament_id` anywhere in its `inherits` chain is a **hard load error**
discards the vendor bundle. The id inherits across bundles, so a vendor's `Generic ABS @X` inheriting that discards the vendor bundle. The id inherits across bundles, so a vendor's `Generic ABS @X`
`Generic ABS @System` gets the library's id for free; a vendor's own product must resolve its own. inheriting `Generic ABS @System` gets the library's id for free; a vendor's own product must resolve its
own.
- `filament_type` **must be a JSON array** — the one vector key the Python check enforces. A scalar - `filament_type` **must be a JSON array**: the one vector key `check` rejects as a scalar outright. A
`"PP"` once hung the filament/printer selection UI. scalar `"PP"` once hung the filament and printer selection UI.
- It is an **open** enum: an unlisted value is accepted silently and falls back to 190–300 °C defaults - It is an **open** enum: an unlisted value is accepted silently and falls back to 190–300 °C defaults
and adhesion 1.0. Off-list values do ship. Prefer a value from `MaterialType::all()` in and adhesion 1.0. Prefer a value from `MaterialType::all()` in
`src/libslic3r/MaterialType.cpp`, or add a row there. `src/libslic3r/MaterialType.cpp`, or add a row there.
- Generics use `filament_vendor: ["Generic"]`, which `fdm_filament_common` already defaults to. - Generics use `filament_vendor: ["Generic"]`, which `fdm_filament_common` already defaults to.
## `"nil"` ## `"nil"`
Legal in any key whose `ConfigOptionDef` is `nullable`. In a filament preset that is most of the `"nil"` is legal only in an option defined as nullable (`add_nullable`, or `nullable = true`, in
`filament_*` family, plus `long_retractions_when_ec` and `retraction_distances_when_ec`. About half are `src/libslic3r/PrintConfig.cpp`). Anywhere else it fails the file, and with it the **whole bundle**
the extruder overrides (`filament_retraction_length`, `filament_z_hop`, `filament_wipe`, (`Failed loading configuration file`, after `Deserializing nil into a non-nullable object` or
`filament_retract_*`, `filament_retraction_speed`, `filament_deretraction_speed`, `Invalid value provided for parameter <key>: nil`). To leave a non-nullable key unset, omit it; do not
`filament_retraction_minimum_travel`, `filament_wipe_distance`, `filament_long_retractions_when_cut`, write `nil`.
`filament_retraction_distances_when_cut`, …), where `nil` means *keep the printer/extruder's own value*.
The rest are ordinary nullable options (`filament_flow_ratio`, `filament_flush_temp`,
`filament_adaptive_volumetric_speed`, …) where it means *unset*.
Anywhere else it throws `Deserializing nil into a non-nullable object`. To not set a non-nullable key, In filament presets a minority of the `filament_*` keys are nullable, plus `long_retractions_when_ec`
omit it — do not write `nil`. and `retraction_distances_when_ec`. About half of them are the extruder overrides (`filament_retraction_length`,
`filament_z_hop`, `filament_wipe`, `filament_retract_*`, `filament_retraction_speed`,
`filament_deretraction_speed`, `filament_retraction_minimum_travel`, `filament_wipe_distance`,
`filament_long_retractions_when_cut`, `filament_retraction_distances_when_cut`, …), where `nil` means
*keep the printer's or extruder's own value*. The rest are ordinary nullable options
(`filament_flow_ratio`, `filament_flush_temp`, `filament_adaptive_volumetric_speed`, …), where it means
*unset*. Check the option's definition before writing `nil` anywhere else.
## What to review per nozzle ## Tuning per nozzle and per variant
Across `@X` / `@X 0.N nozzle` sibling pairs the keys that differ, most often first, are Between a product's `@X` and `@X 0.N nozzle` tunes the keys that usually differ, most often first, are
`filament_max_volumetric_speed`, `filament_retraction_length`, `slow_down_min_speed`, `filament_max_volumetric_speed`, `filament_retraction_length`, `slow_down_min_speed`,
`filament_flow_ratio`, `slow_down_layer_time`, `nozzle_temperature` and `pressure_advance`. `filament_flow_ratio`, `slow_down_layer_time`, `nozzle_temperature` and `pressure_advance` (switched on
`filament_cost`, `filament_density`, `filament_type` and `filament_vendor` belong on the `@base` and by `enable_pressure_advance`).
should not appear in a printer tune.
Use measured values for the material, hotend, extruder and nozzle combination. Neither maximum Use measured values for the material, hotend, extruder and nozzle combination. Neither maximum
volumetric speed nor pressure advance has a universal nozzle-only lookup table. When cloning a volumetric speed nor pressure advance has a universal nozzle-only lookup table. When cloning a 0.4
0.4 preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure-advance value preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure-advance value, or a
or a required direction of change from diameter alone. required direction of change, from diameter alone.
## Style On a printer with extruder variants, a filament tunes these per variant too:
`filament_max_volumetric_speed`, `filament_flow_ratio`, `nozzle_temperature`, pressure advance and the
Overrides, not full copies: a typical instantiated filament preset carries around a dozen non-meta keys, retraction overrides carry one value per variant of `filament_extruder_variant` (Standard, High Flow, …). The
and a library leaf two or three. Presets that restate fifty-plus keys from their parent do still ship — exact key set is [`filament_options_with_variant`](extruder-variants.md#the-four-key-sets);
Phrozen's single filament preset is that style — but they are the pattern to move away from, not to `slow_down_min_speed` and `fan_max_speed` are not in it. Keep every such array at exactly that width, even where the
copy. Commit `6943b6ddc3` is the stated model (flip true bases to `instantiation: "false"`, strip setting does not differ per variant, and measure the High Flow variant rather than copying Standard
`compatible_printers`/`setting_id`/`filament_settings_id`, add `renamed_from` on the survivor). ([extruder-variants.md](extruder-variants.md#filament)).
Prefer the library's `fdm_filament_*` bases over a vendor-local copy. Phrozen's local
`fdm_filament_common` has drifted from the library's.
Canonical key order, written by `orca_profile_tool.py normalize` when it rewrites a file: `type`, `name`,
`renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`, `instantiation`, then everything else in
the order you wrote it. Not enforced — a file that leads with `compatible_printers` passes `check`.
**Every vector-typed (`co*s`) key must be a JSON array.** Only `filament_type` is an outright error, but
`normalize` silently arrayifies five more (`filament_cost`, `filament_density`,
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`) and `check` fails when
it would. Every other vector key is on you — including `filament_start_gcode`, `filament_end_gcode`,
`filament_extruder_variant`, `compatible_printers` and the plate temperatures.
## Bed temperature is twelve keys, not one ## Bed temperature is twelve keys, not one
There is no single "bed temperature". Which plate key applies depends on `curr_bed_type`, whose six There is no single "bed temperature". The plate type selected for the printer (`Cool Plate`,
selectable values (`btPC`, `btEP`, `btPEI`, `btPTE`, `btPCT`, `btSuperTack`; `btDefault` maps to no key) `Engineering Plate`, `High Temp Plate`, `Textured PEI Plate`, `Textured Cool Plate`, `Supertack Plate`)
`get_bed_temp_key()` turns into `cool_plate_temp`, `eng_plate_temp`, `hot_plate_temp`, picks one of six keys, each with an `_initial_layer` twin: `cool_plate_temp`, `eng_plate_temp`,
`textured_plate_temp`, `textured_cool_plate_temp` and `supertack_plate_temp` — each with an `hot_plate_temp`, `textured_plate_temp`, `textured_cool_plate_temp` and `supertack_plate_temp`.
`*_initial_layer` twin.
`textured_cool_plate_temp` is the one most often forgotten. A printer with `support_multi_bed_types` off `textured_cool_plate_temp` is the one most often forgotten. A non-BBL printer with
hides the selector, and the printer preset's `support_multi_bed_types` off hides the plate selector and uses the printer preset's `default_bed_type`
`default_bed_type` decides which plate is selected for it, but `curr_bed_type` can still hold a stale (High Temp Plate, `hot_plate_temp`, when unset or invalid), but a loaded project or a CLI config can
value carried over from another printer — so set every plate the printer plausibly has, as the sibling still carry another plate. So set every plate the printer plausibly has, as the sibling presets in the
presets in the bundle do. bundle do.
## Style
- Overrides, not full copies: an instantiated filament preset carries around a dozen non-meta keys,
and a library `@System` shim two or three. A preset that restates fifty-plus keys from its parent is
the pattern to move away from, not to copy. Commit `6943b6ddc3` is the stated model for converting such presets: flip true
bases to `instantiation: "false"`, strip their `compatible_printers`, `setting_id` and
`filament_settings_id`, and add `renamed_from` on the surviving selectable preset. A value every
printer tune of a product shares goes on the product's `@base`
([shared bases](shared-bases.md#levels)).
- Prefer the library's `fdm_filament_*` bases over a vendor-local copy; for a new preset, even in a
bundle whose older presets use one: a local copy drifts from the library's.
- Canonical key order, written by `orca_profile_tool.py normalize` when it rewrites a file: `type`,
`name`, `renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`, `instantiation`, then
everything else in the order you wrote it. Not enforced on its own: a file that leads with
`compatible_printers` passes `check`.
- **Every vector-typed (`co…s`) key must be a JSON array.** Only a scalar `filament_type` is an outright
error; `normalize` silently arrayifies five more (`filament_cost`, `filament_density`,
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`), and `check` fails
when it would. Every other vector key is on you, including `filament_start_gcode`,
`filament_end_gcode`, `filament_extruder_variant`, `compatible_printers` and the plate temperatures.
+76 -69
View File
@@ -1,76 +1,78 @@
# `setting_id` and `filament_id` # `setting_id` and `filament_id`
Orca-generated ids are deterministic hashes of identity. **Never invent an id or copy a sibling's Orca-generated ids are deterministic hashes of identity. **Never invent an id or copy a sibling's
`setting_id`.** Use `scripts/orca_profile_tool.py`; the two special cases are `setting_id`.**
Use `scripts/orca_profile_tool.py`; the two special cases are
[a wrongly inherited filament id](#what-generate-id-does-and-does-not-fix) and [a wrongly inherited filament id](#what-generate-id-does-and-does-not-fix) and
[BBL's authoritative setting ids](#bbls-exception-precisely). [BBL's authoritative setting ids](#bbls-exception-precisely).
`docs/HLSD/filament_id.md` is the authoritative design document for `filament_id` — the id landscape, the `docs/HLSD/filament_id.md` is the authoritative design document for `filament_id`: the id landscape,
checks CI runs, and the Bambu catalog map. This page is the tooling half. the checks CI runs, and the Bambu catalog map. This page is the tooling half.
| | `setting_id` | `filament_id` | | | `setting_id` | `filament_id` |
| --- | --- | --- | | --- | --- | --- |
| Identifies | one selectable preset | one filament **product** | | Identifies | one selectable preset | one filament **product** |
| Key hashed | `<vendor folder>/<type>/<name>` | `filament_product/<filament_vendor>/<filament_type>/<name-before-@>` | | Key hashed | `<vendor folder>/<type>/<name>` | `filament_product/<filament_vendor>/<filament_type>/<alias>`, using the resolved (inherited) first values and the name up to the first `@`, right-trimmed |
| Shape | 16 base62 chars | `OF` + 6 base62 chars | | Shape | 16 base62 characters | `OF` + 6 base62 characters |
| Required on | every `instantiation: "true"` preset | every **instantiated** filament, own or inherited | | Required on | every `instantiation: "true"` preset | every **instantiated** filament, own or inherited |
| Forbidden on | bases (`instantiation != "true"`) | — (a base is exactly where it belongs) | | Forbidden on | bases (`instantiation` not `"true"`) | — (a product's root base is exactly where it belongs) |
| Scope | globally unique across the tree | shared by every variant of the product, in every bundle | | Scope | unique across the whole tree | shared by every preset of the product, in every bundle |
`<type>` is `machine` / `process` / `filament` — the vendor is the **folder** name (`BBL`), not the `<type>` is `machine`, `process` or `filament`, and the vendor is the **folder** name (`BBL`), not the
display name (`Bambulab`). Renaming a preset changes its `setting_id`; renaming a filament, or editing display name (`Bambulab`). Renaming a preset changes its `setting_id`; renaming a filament's alias, or
its `filament_vendor` or `filament_type`, also changes its `filament_id`. editing its `filament_vendor` or `filament_type`, also changes its `filament_id`, and the old id is not
forwarded.
## The tool ## The tool
Use `scripts/orca_profile_tool.py` with a subcommand: `scripts/orca_profile_tool.py` takes a subcommand:
| Command | Does | | Command | Does |
| --- | --- | | --- | --- |
| `check` | everything CI's `profile_tool` step runs — see [validation.md](validation.md) | | `check` | everything CI's `profile_tool` step runs; see [validation.md](validation.md#orca_profile_toolpy-check) |
| `generate-id` | writes `setting_id` and `filament_id` | | `generate-id` | writes `setting_id` and `filament_id` |
| `normalize` | rewrites profile files into their canonical shape | | `normalize` | rewrites profile files into their canonical shape |
| `trim` | deletes profile files no `<vendor>.json` list references | | `trim` | deletes profile files no `<Vendor>.json` list references |
| `update-index` | rebuilds the `*_list` sections from the files on disk | | `update-index` | rebuilds the `*_list` sections from the files on disk |
The order after adding, renaming or deleting files — each step feeds the next, so it is not The order after adding, renaming or deleting files is `normalize` → `update-index` → `generate-id` →
interchangeable — is `normalize` → `update-index` → `generate-id` → `check`. `check`. Each step feeds the next, so it is not interchangeable. The
The [authoring workflow](../SKILL.md#creating-or-modifying-a-profile) has the commands. [authoring workflow](../SKILL.md#creating-or-modifying-a-profile) has the commands.
> **`trim` deletes.** It removes every profile file the index does not list — including the one you just > **`trim` deletes.** It removes every profile file the index does not list, including the one you just
> added and have not registered yet. Register first, or skip `trim` entirely; it is a cleanup sweep, not > added and have not registered yet. Register first, or skip `trim` entirely: it is a cleanup sweep, not
> part of landing a profile. Preview with `--dry-run`. > part of landing a profile. Preview with `--dry-run`.
**Register, then mint.** The `filament_id` pass reads `<Vendor>.json`'s `filament_list`, not the **Register, then mint.** The `filament_id` pass reads `<Vendor>.json`'s `filament_list`, not the
filesystem (the `setting_id` pass walks the filesystem, so a bundle whose index has not landed yet is filesystem (the `setting_id` pass walks the filesystem, so a bundle whose index has not landed yet is
still assignable). A new filament file is therefore invisible to `generate-id`'s filament_id pass until still assignable). A new filament file is therefore invisible to `generate-id`'s `filament_id` pass
it is registered — its `setting_id` is written regardless. until it is registered; its `setting_id` is written regardless.
- `--dry-run` works on every writing command (`generate-id`, `normalize`, `trim`, `update-index`) - `--dry-run` works on every writing command (`generate-id`, `normalize`, `trim`, `update-index`) and
and writes nothing. writes nothing.
- `--filament-id` / `--setting-id` narrow `generate-id`; they exclude each other, and passing neither - `--filament-id` / `--setting-id` narrow `generate-id` to one pass; they exclude each other, and
writes both. passing neither writes both.
- `--vendor` is repeatable and narrows **only what is written** — the id is a function of the triple - `--vendor` is repeatable and narrows **only what is written**: an id is a function of its own key
alone, so a narrowed run writes exactly what a full run would. An unknown vendor exits 1 before any alone, so a narrowed run writes exactly what a full run would. An unknown vendor exits 1 before any
write. `--vendor` on `check` narrows the per-vendor checks only; the `setting_id` and `filament_id` write. `--vendor` on `check` narrows the per-vendor checks only; the `setting_id` and `filament_id`
passes stay tree-wide. passes stay tree-wide.
- `--profiles DIR` points any command at another tree — see - `--profiles DIR` points any command at another tree; see
[Checking a copy of the tree](validation.md#checking-a-copy-of-the-tree). [Checking a copy of the tree](validation.md#checking-a-copy-of-the-tree).
- `--profile-type` narrows `normalize`, `trim` and `update-index` to `machine_model`, `process`, - `--profile-type` narrows `normalize`, `trim` and `update-index` to `machine_model`, `process`,
`filament` or `machine`. `filament` or `machine`.
- Exit codes: 0 clean, 1 errors found (`generate-id` still writes what it could), 2 argparse misuse. - Exit codes: 0 clean, 1 errors found (`generate-id` still writes what it could), 2 argparse misuse.
- Output is ANSI-coloured; searching for the literal `[ERROR]` still works. - Output is ANSI-coloured; searching for the literal `[ERROR]` still works.
`generate-id` is **idempotent and byte-preserving** — BOM and CRLF kept, one key line touched per pass. `generate-id` is **idempotent and byte-preserving**: BOM and CRLF are kept, and each pass touches only
A legitimate `generate-id` diff is one or two changed lines per file: a new instantiated filament gets its one key line. A legitimate `generate-id` diff is one or two changed lines per file: a new instantiated
both a `filament_id` and a `setting_id`, and a BBL file with a misspelled `settings_id` has that line filament gets both a `filament_id` and a `setting_id`. `normalize` is the opposite by design (it
dropped and its value restored under the right key. `normalize` is the opposite by design — it rewrites rewrites whole files into canonical shape), which is why `check` demands it already be a no-op. A file
whole files into canonical shape — which is why `check` demands it already be a no-op. Some bundles have committed with CRLF line endings changes on every line under `normalize`; read the diff before
CRLF committed (OrcaFilamentLibrary, Anycubic and RH3D among them), so a `normalize` pass there rewrites committing it.
every line — read the diff before committing it.
On a clean tree `check` and `generate-id --dry-run` both exit 0 with zero findings. That is the Exit 1 from `generate-id` does not mean nothing was written: it writes every id it can and reports the
baseline to restore before opening a PR. rest, so read the diff before rerunning. On a clean tree `check` and `generate-id --dry-run` both exit 0
with zero findings; that is the baseline to restore before opening a PR.
## What `generate-id` does and does not fix ## What `generate-id` does and does not fix
@@ -78,35 +80,37 @@ Writes:
- a `setting_id` into any instantiated preset that lacks one, or whose value does not match the formula; - a `setting_id` into any instantiated preset that lacks one, or whose value does not match the formula;
- strips a `setting_id` from a base; - strips a `setting_id` from a base;
- deletes the misspelled `settings_id` key; - deletes the misspelled key `settings_id`, moving its value to `setting_id` only on an instantiated BBL
preset that lacks one (everywhere else the old value is discarded and a fresh id minted);
- a `filament_id` into the id-less **root(s)** of an instantiated filament that resolves none; - a `filament_id` into the id-less **root(s)** of an instantiated filament that resolves none;
- rewrites a **declared** `filament_id` that is not the mint of its own triple. - rewrites a **declared** `filament_id` that is not the mint of its own triple.
Refuses to write (reports only): a base62 collision between two products, an empty `filament_vendor` or Refuses to write (reports only): a base62 collision between two products, an empty `filament_vendor` or
`filament_type`, a broken `inherits` chain, roots of one filament resolving divergent `(vendor, type)` `filament_type`, a broken `inherits` chain, and roots of one filament resolving different
pairs. `(filament_vendor, filament_type)` pairs.
**Does not fix: a preset that *inherits* a wrong `filament_id`.** This is check 2b, and it is the trap **Does not fix: a preset that *inherits* a wrong `filament_id`.** This is check 2b (the label `docs/HLSD/filament_id.md` and the tool use), and it is the trap most likely to bite.
most likely to bite. It happens when a branded filament inherits a generic for its settings: It happens when a branded filament inherits a generic for its settings:
```jsonc ```jsonc
{ "name": "Phrozen Aura PETG @Phrozen Arco 0.4 nozzle", { "name": "Phrozen Aura PETG @Phrozen Arco 0.4 nozzle",
"inherits": "Generic PETG @System" } // resolves the OFL generic's id — wrong product "inherits": "Generic PETG @System" } // resolves the library generic's id: wrong product
``` ```
The preset resolves *an* id, so `generate-id` neither inserts nor rewrites, and `check` fails with The preset resolves *an* id, so `generate-id` neither inserts nor rewrites one, and `check` fails with
`inherits filament_id "X" but its own triple "V/T/N" mints "Y"`. `inherits filament_id "X" but its own triple "V/T/N" mints "Y"`.
Two fixes, in order of preference: Two fixes, in order of preference:
1. **Give the product a `@base` root** inheriting a material base (`fdm_filament_pet`, 1. **Give the product a `@base` root** inheriting a material base (`fdm_filament_pet`,
`fdm_filament_pla`, …). No `fdm_filament_*` base carries a `filament_id`, so the filament now resolves `fdm_filament_pla`, …) with its own `filament_vendor` and `filament_type`. No `fdm_filament_*` base
none and `generate-id` mints it for you. This is also the shape the rest of the tree uses. carries a `filament_id`, so the filament now resolves none and `generate-id` mints it on the root.
2. **Declare the tool-computed key on the preset itself.** Use the expected value reported by `check` This is the product-root shape ([the three-part shape](filament-profiles.md#the-three-part-shape)).
or compute it with the function below; this is not a manually chosen id. Make sure the preset 2. **Declare the tool-computed id on the preset itself.** Use the expected value `check` reports, or
resolves the right `filament_vendor` and `filament_type` first — with compute it with the function below; this is not a manually chosen id. First make sure the preset
neither set, the triple resolves through the generic parent and the branded product is minted resolves the right `filament_vendor` and `filament_type`: with neither set, the triple resolves
under vendor `Generic`. If you need the id before the file exists: through the generic parent and the branded product is minted under vendor `Generic`. If you need the
id before the file exists:
```bash ```bash
python3 -c "import sys; sys.path.insert(0,'scripts'); from orca_profile_tool import generate_filament_id as g; print(g('Polymaker','PLA','PolyLite PLA'))" python3 -c "import sys; sys.path.insert(0,'scripts'); from orca_profile_tool import generate_filament_id as g; print(g('Polymaker','PLA','PolyLite PLA'))"
@@ -114,36 +118,39 @@ Two fixes, in order of preference:
``` ```
The quoting works unchanged in cmd and PowerShell; only swap `python3` for `py -3`. The quoting works unchanged in cmd and PowerShell; only swap `python3` for `py -3`.
`generate_preset_setting_id('<vendor folder>', '<type>', '<name>')` is the `setting_id` equivalent.
The `setting_id` equivalent is `generate_preset_setting_id('<vendor folder>', '<type>', '<name>')`. A vendor's tune of a generic that keeps the `Generic X` alias is not this case: inheriting the
generic's id is correct there, because the product really is the library generic
([filament-profiles.md](filament-profiles.md#the-two-most-common-contributions)).
## BBL's exception, precisely ## BBL's exception, precisely
`RESERVED_VENDORS = {"BBL"}` covers **`setting_id` assignment only**, keyed on the *folder* name: The exception covers **`setting_id` assignment only**, keyed on the *folder* name `BBL`:
- The tool never mints or replaces a BBL `setting_id`. A new instantiated BBL preset with no - The tool never mints or replaces a `setting_id` in `BBL/`: those are Bambu's own ids. A new
`setting_id` therefore **cannot be fixed by the tool**, yet the presence rule still applies to it — instantiated BBL preset with no `setting_id` therefore **cannot be fixed by the tool**, yet the
carry over Bambu's authoritative id by hand. presence rule still applies to it: carry over Bambu's authoritative id by hand.
- BBL is not exempt from anything else: bases still get their `setting_id` stripped, ids must still be - BBL is not exempt from anything else: bases still get their `setting_id` stripped, ids must still be
globally unique, and BBL `filament_id`s are minted like everyone else's — every one of them is an unique across the tree, and BBL `filament_id`s are minted like everyone else's, as `OF…` ids.
`OF*`.
## Ids other systems compose ## Ids other systems compose
No id from another system is the mint of a triple, so `check` rejects it like any other bad id — same No id from another system is the mint of a triple, so `check` rejects one used as a `filament_id` like
error, same remedy, whoever wrote it. Three such spaces exist near the tree; recognise them so you do any other bad id: same error, same remedy, whoever wrote it. Three such spaces exist near the tree;
not copy one into a profile: recognise them so you do not copy one into a profile:
- **Bambu's `GF*` catalog** — external and opaque, correlated to Orca's ids by the generated - **Bambu's `GF…` catalog**: external and opaque, correlated to Orca's ids by the generated
`resources/printers/bambu_filament_ids.json`. `GF` is a *prefix*, not a spelling the tree avoids: most `resources/printers/bambu_filament_ids.json`. `blacklist.json` and
BBL `setting_id`s start with `G`, and `blacklist.json` and `BBL/filament/filaments_color_codes.json` reference Bambu catalog ids by design. The rule is about
`BBL/filament/filaments_color_codes.json` both reference Bambu catalog ids by design. The rule is `filament_id` and nothing else: every BBL `setting_id` starts with `G`, and that is Bambu's own
about `filament_id` and nothing else. preset id, not a leaked catalog id.
- **Qidi's `QD_*`** — composed at runtime by the box (`QD_<series>_<vendor>_<typeidx>`), not a preset id. - **Qidi's `QD_…`**: composed at runtime by the printer's filament box
- **`P` + 7 hex, and `"null"`** — what `CreatePresetsDialog.cpp` gives a *user*-created filament. (`QD_<series>_<vendor>_<typeidx>`), not a preset id.
- **`P` + 7 hex digits, and `"null"`**: what the app gives a *user*-created filament.
## Tests ## Tests
`python3 -m unittest discover -s scripts/tests -t scripts` (`py -3 -m …` on Windows). Note the `python3 -m unittest discover -s scripts/tests -t scripts` (`py -3 -m …` on Windows) runs the tool's
`-t scripts` argument; without it the imports fail. CI runs them as the first, non-`continue-on-error` unit tests. Note the `-t scripts` argument; without it the imports fail. CI runs them as the first,
step of the profile job — see [validation.md](validation.md#ci). non-`continue-on-error` step of the profile job; see [validation.md](validation.md#ci).
@@ -1,23 +1,23 @@
# Printer models and variants # Printer models and variants
Both live in `resources/profiles/<Vendor>/machine/*.json`; models go in `machine_model_list`, variants Both live in `resources/profiles/<Vendor>/machine/`; models go in `machine_model_list`, variants and
and shared bases in `machine_list`. Every one of them is registered. Some vendors (Elegoo, Eryone, shared bases in `machine_list`. Every one of them is registered. A bundle may nest further subfolders
InfiMech, FlyingBear) nest a further subfolder under `machine/`, so recurse rather than globbing under `machine/`, so recurse rather than globbing `machine/*.json`.
`machine/*.json`.
## A `machine_model` is not a config preset ## `machine_model`: a record, not a config preset
It is parsed by a hand-written key switch, and only these keys are stored (`version` and `url` are The loader reads a fixed set of keys from a `machine_model` and stores only these (`version` and `url`
matched and discarded): are recognised and discarded):
`name`, `model_id`, `nozzle_diameter`, `machine_tech`, `family`, `bed_model`, `bed_texture`, `name`, `model_id`, `nozzle_diameter`, `machine_tech`, `family`, `bed_model`, `bed_texture`,
`hotend_model`, `default_materials`, `not_support_bed_type`, `image_bed_type`, `hotend_model`, `default_materials`, `not_support_bed_type`, `image_bed_type`,
`bottom_texture_end_name`, `bottom_texture_rect`, `bottom_texture_rect_longer`, `middle_texture_rect`, `bottom_texture_end_name`, `bottom_texture_rect`, `bottom_texture_rect_longer`, `middle_texture_rect`,
`use_double_extruder_default_texture`. `use_double_extruder_default_texture`.
**Everything else is silently dropped.** Only `name` and `nozzle_diameter` are required. Dead keys ship **Everything else is silently dropped**, a printer config key such as `default_bed_type` or a
on real models today — `url`, `default_bed_type`, even a `desciption` typo — so a neighbour carrying a misspelling included, so a neighbour carrying a key is no evidence it does anything. Printer config options belong on the `machine` preset, never here. The loader drops a model
key is no evidence it does anything. Printer config options belong on the `machine` preset, never here. silently if its index entry has no name or its `nozzle_diameter` yields no sizes; `check` also requires
the file's own `name`.
```json ```json
{ {
@@ -36,28 +36,28 @@ key is no evidence it does anything. Printer config options belong on the `machi
| Field | Notes | | Field | Notes |
| --- | --- | | --- | --- |
| identity | **the `name` of the `machine_model_list` entry**, which is what a variant's `printer_model` must equal. `check_name_consistency` forces it to equal the file's `name`, so they coincide. | | identity | **the `name` of the `machine_model_list` entry**, which is what a variant's `printer_model` must equal. `check` forces it to equal the file's `name`, so they coincide. |
| `model_id` | a *separate* cloud/device printer type. Optional, and not required to be unique. Not the model's identity. Changing it changes device matching. | | `model_id` | a *separate* cloud/device printer type. Optional, and not required to be unique. Not the model's identity; changing it changes device matching. |
| `machine_tech` | only `starts_with("SL")` means SLA; everything else is FFF. Write `FFF`; a few models write `FGF`, which is a label with no effect. | | `machine_tech` | only a value starting with `SL` means SLA; everything else is FFF. Write `FFF`; `FGF` behaves as FFF. |
| `nozzle_diameter` | `;`-separated string, one token per available size. Order is free (Qidi writes `0.4;0.2;0.6;0.8` to put the default first). This list is the authoritative set of legal `printer_variant` values. | | `nozzle_diameter` | `;`-separated string, one token per available size. Order is free (`0.4;0.2;0.6;0.8` puts the default first). This list is the authoritative set of legal `printer_variant` values. |
| `default_materials` | `;`-separated filament **preset names**. Used to preselect in the wizard *and* by `PresetBundle::load_installed_filaments` to auto-install a printer's filaments on first run, so a dangling entry costs a real user a filament. Not `,`; case-sensitive (`@System`). `check` fails on a dangling name here or in `default_filament_profile`. | | `default_materials` | `;`-separated filament **preset names**, not `,`; case-sensitive (`@System`); order is ignored. Used to preselect filaments in the setup wizard *and* to install a printer's filaments on first run, so a dangling entry costs a real user a filament. Every name must exist (`check` fails on a name matching no filament file, here or in `default_filament_profile`), and every variant of the model needs at least one entry compatible with it (`validate_system`). |
| `family` | a wizard grouping label only; give every model one. | | `family` | a wizard grouping label only; give every model one. |
### Assets ### Assets
`bed_model`, `bed_texture` and `hotend_model` are paths relative to the **vendor folder** (by id). `bed_model`, `bed_texture` and `hotend_model` are paths relative to the **vendor folder** (named by the
Majority convention: `<Model>_buildplate_model.stl` and `<Model>_buildplate_texture.svg`. An empty string vendor id). Convention: `<Model>_buildplate_model.stl` and `<Model>_buildplate_texture.svg`. An
is the legal "none", and is the norm for `hotend_model`. empty string is the legal "none", and is the norm for `hotend_model`.
**Nothing checks that the file exists.** A missing `hotend_model` falls back to **Nothing checks that the files exist.** A missing `hotend_model` falls back to
`resources/profiles/hotend.stl`; a missing `bed_model`/`bed_texture` just renders nothing. Broken `resources/profiles/hotend.stl`; a missing `bed_model` makes the bed render as a generic custom bed, and a missing
references already ship. Verify by hand. `bed_texture` renders no texture.
Verify by hand, in exact case.
Every model also has a `<Model>_cover.png` in the vendor folder — treat it as required, not optional. Every model also has a `<Model>_cover.png` in the vendor folder; treat it as required, not optional.
240×240 is the cap `scripts/optimize_cover_images.py` enforces and the size most covers already use. 240×240 is the cap `scripts/optimize_cover_images.py` enforces. A missing cover degrades to a placeholder in both the wizard and the sidebar.
A missing cover degrades to a placeholder in both the wizard and the sidebar.
## The `machine` variant ## `machine`: the variant
```json ```json
{ {
@@ -79,116 +79,154 @@ A missing cover degrades to a placeholder in both the wizard and the sidebar.
Minimum viable key set: `type`, `name`, `from`, `instantiation`, `setting_id`, `inherits`, Minimum viable key set: `type`, `name`, `from`, `instantiation`, `setting_id`, `inherits`,
`printer_model`, `printer_variant`, `nozzle_diameter`, `printable_area`, `printable_height`, `printer_model`, `printer_variant`, `nozzle_diameter`, `printable_area`, `printable_height`,
`default_print_profile`. The four keys without which the preset will not load at all are `name`, `default_print_profile`. `default_filament_profile` is optional; when written it
`instantiation`, `printer_model` and `printer_variant`; `default_filament_profile` is an array is an array (`["Generic PLA @System"]`), while the model's `default_materials` is a `;`-separated
(`["Generic PLA @System"]`) and the model's `default_materials` a `;`-separated string. Unlike a string. Unlike a `machine_model`, a `machine` **is** a config preset, so a key belonging to another
`machine_model`, a `machine` **is** config-loaded, so a key belonging to another preset type is a preset type is a reported error and is removed; a misspelled key is still dropped silently.
reported error (a misspelled key is still silent).
### `printer_variant` — three hard rules ### `printer_model` and `printer_variant`
1. Non-empty, and an exact member of the model's `;`-separated `nozzle_diameter` list. 1. `printer_model` is non-empty and names a model of this bundle exactly.
2. `printer_model` non-empty and naming a model of this vendor. 2. `printer_variant` is non-empty and an exact token of that model's `;`-separated `nozzle_diameter`
3. In validation mode, for instantiated presets only: split `printer_variant` on `+`, each token must list.
start with a number (a trailing non-numeric suffix such as `HF` is ignored), and the resulting **set** 3. For instantiated presets, when validating: split `printer_variant` on `+`; each token must start
must equal `set(nozzle_diameter)`. with a number (a trailing non-numeric suffix such as `HF` is ignored), and the resulting **set** must
equal the set of `nozzle_diameter` values.
Rules 1 and 2 are loader-enforced — failing either drops the preset *and* the whole bundle. Rule 3 only Rules 1 and 2 are loader-enforced: failing either discards the whole bundle. Rule 3 only raises a
raises a validation error: the preset still loads, but the validator exits non-zero. validation error: the preset still loads, but the validator exits non-zero.
`nozzle_diameter` lists one entry **per physical nozzle**; `printer_variant` lists the **distinct** `nozzle_diameter` lists one entry **per extruder**; `printer_variant` lists the **distinct** diameters
diameters joined with `+`. Snapmaker U1 is the worked case: `["0.4","0.4","0.6","0.6"]` against joined with `+`: `["0.4","0.4","0.6","0.6"]` against `"0.4+0.6"` passes because the comparison is on
`"0.4+0.6"` — it passes because the comparison is on sets. sets.
The conventional values are `0.2`, `0.25`, `0.4`, `0.5`, `0.6`, `0.8` and `1.0`. Suffixed forms Write `printer_variant` as a bare diameter matching the model's list, with no unit. The conventional
(`0.4HF`, `0.6HF`, `0.8HF`, `0.4HS`) are Flashforge-only and the `+` form is rare. A variant is **not** values are `0.2`, `0.25`, `0.4`, `0.5`, `0.6`, `0.8` and `1.0`; a suffixed form (`0.4HF`, `0.4HS`) or
required to be unique within a model — Volumic ships `EXO42 IDRE`, `… COPY MODE` and `… MIRROR MODE` all the `+` form is legal under rule 3. A `printer_variant` is **not** required to be unique within a
at `0.4` under the one model `EXO42 IDRE`. model: an IDEX model's normal, `COPY MODE` and `MIRROR MODE` presets can all be `0.4`.
The converse is **unchecked**: a nozzle size in the model's list with no matching variant is offered in The converse is **unchecked**: a nozzle size in the model's list with no matching variant is offered in
the wizard and resolves to nothing. `Wanhao France`'s `D12 500 PRO M2 DIRECT` ships that bug today. the wizard and resolves to nothing. A variant whose `printer_model` names a sibling model by mistake
leaves its own model's size in exactly that state.
### Other fields worth knowing ### Other keys
- `default_print_profile` is a **scalar**, matched by exact preset name. Not a `;` list. The named - `default_print_profile` is a **scalar**, matched by exact preset name; not a `;` list. The named
process must be compatible with this printer through its resolved list or condition. process must be compatible with this printer through its resolved list or condition.
`validate_slice` attempts to select it and rejects generic Default fallbacks, but compatibility `validate_slice` attempts to select it and rejects generic Default fallbacks, but compatibility
updates can choose another compatible preset. Check the exact default reference yourself. updates can choose another compatible preset, and `check` does not resolve the name. Check the exact
- `default_filament_profile` is an **array**, one name per element. default reference yourself.
- `printable_area` is an array of `"XxY"` strings — four points for a rectangle, one per segment for a - `default_filament_profile` is an **array**, one name per element. Entry 0 is the filament preselected
delta or circular bed. when the printer is chosen; entry *i* is the preferred replacement when filament *i* is incompatible,
- `gcode_flavor` is usually set once in the base; `klipper`, `marlin`, `marlin2` and `reprapfirmware` and any listed name outranks an unlisted one. The validator checks every entry. The list of a
cover nearly every shipped printer. printer's filaments is the model's `default_materials`: a new filament goes into `default_materials`;
- `printer_settings_id` is junk — most files carrying it disagree with their own name. Do not copy it put it first in `default_filament_profile` only if it should become the preselected one.
when cloning a bundle. - `printable_area` is an array of `"XxY"` strings: four points for a rectangle; a delta or other circular bed
- `min_layer_height` / `max_layer_height` are **machine** keys (per extruder), never process keys. is a polygon with one point per segment.
- A non-BBL printer shows the plate selector only with `support_multi_bed_types` `"1"`; otherwise it
uses its `default_bed_type` (a plate name such as `"Textured PEI Plate"`; High Temp Plate when unset).
Filaments still set every plate
([twelve keys](filament-profiles.md#bed-temperature-is-twelve-keys-not-one)).
- `gcode_flavor` is usually set once in the base; the common values are `klipper`, `marlin`, `marlin2`
and `reprapfirmware`.
- `printer_settings_id` does nothing in a preset file: the app replaces it with the selected preset's
name before slicing. Omit it, and do not copy it when cloning a bundle.
- `min_layer_height` / `max_layer_height` are **machine** keys (one per extruder), never process keys.
## Bases ## Bases
Nearly every machine-bearing vendor registers a base literally named `fdm_machine_common`, and Klipper The conventional machine root is a base named `fdm_machine_common`, with `fdm_klipper_common` on top
vendors add `fdm_klipper_common` on top of it. Two levels is the usual depth. of it for Klipper printers. Which values a hardware
family or model base holds, and when adding one pays off, is in [shared-bases.md](shared-bases.md).
**There is no leading-underscore convention for bases.** **There is no leading-underscore convention for bases.**
## Adding a printer to an existing bundle ## Adding a printer to an existing bundle
1. Choose the names first — model, variant(s), process(es); everything else references them. 1. Choose the names first: model, variant(s), process(es); everything else references them
([naming.md](naming.md)).
2. Add the model (`machine_model_list`) and one `machine` variant per nozzle; the minimum key sets are 2. Add the model (`machine_model_list`) and one `machine` variant per nozzle; the minimum key sets are
above. Bed assets and `<Model>_cover.png` go directly in `<Vendor>/`. above. Bed assets and `<Model>_cover.png` go directly in `<Vendor>/`.
3. Add at least one process per variant naming it in `compatible_printers` 3. Add at least one process per variant naming it in `compatible_printers`
([process-profiles.md](process-profiles.md#adding-a-quality-tier-or-a-nozzles-processes)). ([process-profiles.md](process-profiles.md#adding-a-quality-tier-or-a-nozzles-processes)).
4. Register everything (or run `update-index`), bump the version, run the id tool, validate. 4. Add the variants to the filaments they should offer, and set the model's `default_materials` so every
variant has a compatible entry.
5. Register everything (`update-index`), bump the version, run the id tool, validate: the
[authoring workflow](../SKILL.md#creating-or-modifying-a-profile).
## Adding a nozzle variant ## Adding a nozzle variant
1. Extend the model's `nozzle_diameter` (`"0.4"` → `"0.4;0.6"`). 1. Extend the model's `nozzle_diameter` (`"0.4"` → `"0.4;0.6"`).
2. Add the variant preset. Either inherit the shared base (the usual choice) or the 0.4 sibling (Elegoo, 2. Add the variant preset. Either inherit the shared base (the usual choice), or the 0.4 sibling
BBL, Prusa and Qidi do this — smaller diff, but the sibling's edits now reach this file too). (a smaller diff, but the sibling's edits now reach this file too). Follow the bundle.
3. Override what actually changes with nozzle: `nozzle_diameter`, `printer_variant`, 3. Override what actually changes with the nozzle: `nozzle_diameter`, `printer_variant`,
`default_print_profile`, `default_filament_profile`, `min_layer_height` / `max_layer_height`, and `default_print_profile`, `default_filament_profile`, `min_layer_height` / `max_layer_height`, and
retraction if the vendor tunes it. retraction if the vendor tunes it.
4. Add at least one process for the new nozzle — see [process-profiles.md](process-profiles.md). 4. Add at least one process for the new nozzle (see [process-profiles.md](process-profiles.md)), and
extend the filaments' `compatible_printers` so at least one `default_materials` entry covers the new
variant.
5. Register both, bump the version, run the id tool, validate. 5. Register both, bump the version, run the id tool, validate.
## Multi-extruder, IDEX and tool-changers ## Multi-extruder, IDEX and tool changers
Per-extruder vectors are **silently resized** to the nozzle count, with no error. Padding repeats the Per-extruder vectors hold one value per extruder (`len(nozzle_diameter)`), and a wrong length raises no
**first** value, not the last — `["0.4","0.6"]` on a 4-nozzle machine becomes `0.4, 0.6, 0.4, 0.4`. error: a short vector acts as padded with its **first** value, not the last (`extruder_offset`
Longer vectors are truncated. `["0x0","50x0"]` on a 4-extruder machine reads as `0x0, 50x0, 0x0, 0x0`), and entries beyond the
extruder count are never read.
Note the two sizing families: the plain per-extruder keys (`extruder_offset`, `extruder_colour`, Note the two sizing families. The plain per-extruder keys (`printer_extruder_options`: `extruder_type`,
`extruder_printable_height`, `min_layer_height`, `max_layer_height`, `nozzle_diameter`) are sized to the `nozzle_diameter`, `default_nozzle_volume_type`, `extruder_printable_height`, `min_layer_height`,
extruder count, while `printer_options_with_variant_1` (`retraction_length`, `z_hop`, `wipe`, `max_layer_height`, …, given in full with the [key sets](extruder-variants.md#the-four-key-sets), plus
`nozzle_type`, the rest of the retraction family) is sized to `printer_extruder_variant` instead. `extruder_offset` and `extruder_colour`) hold one value per extruder. The variant sets
(`retraction_length`, `z_hop`, `wipe`, `nozzle_type`, the `machine_max_*` limits at stride 2;
[the full lists](extruder-variants.md#the-four-key-sets)) are sized to the variant length:
`len(printer_extruder_variant)`, or one variant per extruder when the resolved preset writes no layout,
since `extruder_variant_list` defaults to one `Direct Drive Standard` per extruder
([widths](extruder-variants.md#widths)). `extruders_count` is a printer-tab field, not a preset key; the
loader drops it.
- Give **one entry per extruder** for ordinary per-extruder vectors such as `extruder_offset`, - Give **one entry per extruder** for ordinary per-extruder vectors such as `extruder_offset`,
`extruder_colour`, `min_layer_height` and `max_layer_height`; size the variant-dependent family `extruder_colour`, `min_layer_height` and `max_layer_height`. Size the variant sets to the variant
to `printer_extruder_variant` instead. length × stride, one value per variant (per extruder when there is no layout; a (normal, silent)
A single `["0x0"]` `extruder_offset` on a dual or multi-tool machine — which already ships — pads every pair for the `machine_max_*` limits), even where the values are the same; the loader keeps only the first value of a list-less printer's variant arrays, so declare
toolhead to the same offset, so the offset never applies. the layout when the extruders differ. A single `["0x0"]` `extruder_offset` on a dual or
- Overriding `nozzle_diameter` to a different count without re-stating every per-extruder vector is the multi-extruder machine pads every extruder to the same offset, so the offset never applies.
other half of the trap — `Snapmaker U1 (0.4+0.6 nozzle)` inherits 5-entry vectors against 4 nozzles. - Overriding `nozzle_diameter` to a different count without restating every per-extruder vector is the
other half of the trap: a 4-extruder preset on a 5-extruder base inherits 5-entry vectors against 4
extruders.
The reverse is silent too: a base is stored resized to its own `printer_extruder_variant` (one variant
when it writes none, whatever its extruder count), so a wider variant array on it reaches the children
as its first value padded ([composition](extruder-variants.md#padding-truncation-and-composition)).
`check` does not judge the base on its own; where its extruders need different values, declare the
layout on the base.
Copy targets: `Custom/machine/fdm_toolchanger_common.json` + `Custom/machine/MyToolChanger 0.4 Structure to copy: `Custom/machine/fdm_toolchanger_common.json` + `Custom/machine/MyToolChanger 0.4
nozzle.json` (a clean minimal variant on a base that gives every vector five entries), and nozzle.json` (a minimal variant on a base that gives the per-extruder vectors five entries), and
`Ratrig/machine/RatRig V-Core 4 IDEX 300 0.4 nozzle.json` for IDEX. The BBL extruder-variant machinery `Ratrig/machine/RatRig V-Core 4 IDEX 300 0.4 nozzle.json` for IDEX. Take the structure from them and
(`extruder_variant_list`, `printer_extruder_id`, `default_nozzle_volume_type`) is used by a handful of the widths from the [sizing equation](extruder-variants.md#sizing-equation). Both are list-less, so
vendors — do not copy it into a new bundle (`nozzle_volume_type` itself is not a machine-preset key). every variant array holds one value per extruder and the loader keeps only the first: fine while every
extruder shares the same retraction and limits. Add the extruder-variant layout
(`extruder_variant_list`, `printer_extruder_variant` / `printer_extruder_id`,
`default_nozzle_volume_type`) when the hardware has swappable nozzle volume types or mixed extruder
types, or as soon as one extruder needs its own value in a variant key; how to author it, and the
matching process and filament variants, is in [extruder-variants.md](extruder-variants.md).
(`nozzle_volume_type` itself is not a machine-preset key.)
## Custom G-code ## Custom G-code
The keys are `machine_start_gcode`, `machine_end_gcode`, `change_filament_gcode`, The keys are `machine_start_gcode`, `machine_end_gcode`, `change_filament_gcode`,
`machine_pause_gcode`, `before_layer_change_gcode` and `layer_change_gcode`. Both a single string with `machine_pause_gcode`, `before_layer_change_gcode` and `layer_change_gcode`. Each is one string with
embedded `\n` and a JSON array of lines are legal and both are in use — do not convert one into the embedded `\n`. Never split G-code into a JSON array of lines: the loader joins array elements with `,`
other. Conditionals are `{if …}` / `{elsif …}` / `{else}` / `{endif}`; `{elsif}` is rare but real (Qidi's into a single line (a one-element array is equivalent to the string): a two-element
`layer_change_gcode` uses it). `machine_start_gcode` becomes one line, `PRINT_START …,SET_PRESSURE_ADVANCE ADVANCE=0.046`.
Conditionals are `{if …}` / `{elsif …}` / `{else}` / `{endif}`.
Placeholder errors only surface when the config is actually expanded, which means `validate_slice`: Placeholder errors only surface when the G-code is actually expanded, which means `validate_slice`:
```bash ```bash
./scripts/check_profile.sh --vendor "<Vendor>" validate_slice ./scripts/check_profile.sh --vendor "<Vendor>" validate_slice
# Windows: scripts\check_profile.bat -Vendor "<Vendor>" validate_slice # Windows: scripts\check_profile.bat -Vendor "<Vendor>" validate_slice
``` ```
What the sweep covers is in [validation.md](validation.md#validate_slice); no `CP TOOLCHANGE START` in What the sweep covers is in [validation.md](validation.md#validate_slice); a printer whose output has no
the output means `change_filament_gcode` never expanded. `CP TOOLCHANGE START` fails it, because its `change_filament_gcode` never expanded.
@@ -1,90 +1,94 @@
# Preset naming # Preset naming
A preset's `name` is the loader's key, not decoration. The index registers it; `inherits`, A preset's `name` is its identity, not decoration. The index registers it; `inherits`,
`compatible_printers` and the `default_*` keys reference it by the exact string; `renamed_from` depends `compatible_printers`, `printer_model` and the `default_*` keys reference it by the exact,
on it; and `setting_id` / `filament_id` hash it (see [ids.md](ids.md)). Two presets of one type in a case-sensitive string; `renamed_from` migrates it; `setting_id` and `filament_id` hash it
bundle may not share a name (`check_preset_name_uniqueness`). Treat a name change as an identity change, ([ids.md](ids.md)). Treat a name change as an identity change that needs
not a relabel. [migration](vendor-bundle.md#renamed_from), not a relabel. Which part of each name the loader acts on
is the table in [SKILL.md](../SKILL.md#names); this page holds the conventions.
Naming is convention only where the loader does not parse it. What the loader actually acts on:
| Type | Shape | Acted on |
| --- | --- | --- |
| `machine_model` | `<Model>` | the exact string, named by a variant's `printer_model` |
| `machine` | `<Model> <nozzle> nozzle` | the exact string, named by `compatible_printers`; `printer_variant` must equal a nozzle diameter |
| `process` | `<lh>mm <Quality> @<target>` | the exact string when referenced or selected; the `@<target>` half is a label |
| `filament` | `<Product> @<target>` | text before the first `@` is the **alias**, used for shadowing; the rest is a label |
## `machine_model` ## `machine_model`
`<Model>` — the vendor-prefixed model name (`Bambu Lab X1 Carbon`, `Creality K1`, `Prusa CORE One`). A `<Model>`, vendor-prefixed: `Bambu Lab X1 Carbon`, `Creality K1`, `Prusa CORE One`. Each variant's
variant names it verbatim in `printer_model`; a mismatch makes the variant invalid. `check_name_consistency` `printer_model` names it verbatim (a mismatch discards the bundle), and its index entry equals the
forces the index entry to equal the file's `name`, and the variant's `printer_model` targets this string file's `name`. It is also the stem of `<Model>_cover.png` and, by convention, of the bed assets
([A `machine_model` is not a config preset](machine-profiles.md#a-machine_model-is-not-a-config-preset)). (`<Model>_buildplate_model.stl`).
It is also the `<Model>_cover.png` and bed-asset stem.
## `machine` (variant) ## `machine` (variant)
`<Model> <nozzle> nozzle` is near-universal (`Bambu Lab X1 Carbon 0.4 nozzle`). `printer_variant` holds `<Model> <nozzle> nozzle` is near-universal (`Bambu Lab X1 Carbon 0.4 nozzle`). Casing varies
the bare nozzle (`0.4`) and must be an exact member of the model's `nozzle_diameter` list — the hard (`nozzle` / `Nozzle`): match the bundle, not this page. A variant that is not nozzle-specific (a
rules are in [The `machine` variant](machine-profiles.md#the-machine-variant). Casing varies special toolhead, a multi-material build, IDEX copy and mirror modes such as
(`nozzle` / `Nozzle`): match the bundle, not this page. A variant that is not nozzle-specific (a special `<Model> COPY MODE (0.4 nozzle)`) may drop or reshape the suffix; it is still an exact reference. `printer_variant`
toolhead, a multi-material build) may drop the suffix — still an exact reference. Bases are named holds the nozzle token: `0.4`, a suffixed `0.4HF`, or `0.4+0.6` for mixed nozzles
`fdm_machine_common` / `fdm_<vendor>_common`. ([rules](machine-profiles.md#printer_model-and-printer_variant)).
## `process` ## `process`
`<layer height>mm <quality> @<target>` — [process-profiles.md](process-profiles.md#naming) has the `<layer height>mm <quality> @<target>`. The quality word stays before `@` and the printer target after
quality ladder and the `fdm_process_*` base names. The quality label stays before `@` and the printer it: a printer model in the quality position leaves the tier undescribed. The `@<target>` is a label,
target after it: a printer model in the quality slot leaves the tier undescribed. The `@<target>` is a and need not equal any variant name; compatibility comes from `compatible_printers` or the
human label, not a reference: it usually does not equal a real variant, and compatibility comes from the condition. The quality ladder and per-nozzle labels are in
resolved `compatible_printers` list or condition. [process-profiles.md](process-profiles.md#naming).
## `filament` ## `filament`
`<Product> @<target>`. The product half is what `filament_id` hashes and what survives as the **alias** up `<Product> @<target>`. The product half, up to the first `@` and right-trimmed, is the **alias**:
to the first `@`; the target half is a label except for reserved forms: shadowing matches on it and `filament_id` hashes it. The target half is a label, except for the
reserved forms:
- `@base` — a non-instantiated product root. `@base` is convention; a base is really identified by - `@base`: a non-instantiated product root. Convention only; a base is really
`instantiation: "false"` and no `setting_id` ([the three-part shape](filament-profiles.md#the-three-part-shape)). `instantiation: "false"` without `setting_id`
- `@System` — the OrcaFilamentLibrary selectable shim, and the convention for an all-printer product ([the three-part shape](filament-profiles.md#the-three-part-shape)).
(`<Product> @System`, empty `compatible_printers`); not enforced, so a deviation is worth a review - `@System`: the OrcaFilamentLibrary selectable shim, and the convention for an all-printer product
comment. The literal `Generic <mat> @System` is load-bearing for 3MF/project recovery, beyond the (`<Product> @System`, empty `compatible_printers`). Not enforced, so a deviation is worth a review
alias rule ([alias shadowing](filament-profiles.md#alias-shadowing)). comment. The literal `Generic <mat> @System` is also load-bearing for project recovery
- `@<Vendor>`, `@<Vendor> <Model>`, `@<Vendor> <Model> <nozzle> nozzle` — printer tunes, BBL's shape. ([alias shadowing](filament-profiles.md#alias-shadowing)).
Other vendors differ (a bare model, a printer serial, Creality's `@<Model>-all`). Specificity is judged - Printer tunes. BBL's shape is the reference: `@<Vendor>` (vendor-wide), `@<Vendor> <Model>` (one
from `compatible_printers`, not the name model), `@<Vendor> <Model> <nozzle> nozzle` (one variant). Other vendors differ: a bare model
(`QIDI ABS-GF@Q2-Series`), a printer serial, Creality's `@<Model>-all`. Judge specificity from
`compatible_printers`, never from the name
([one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)). ([one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)).
- Color is not part of the product name: `<Product> <Color>` presets are not authored; the color is - No colour in the product name: `<Product> <Colour>` presets are not authored; colour is chosen at
chosen at runtime runtime ([colour](filament-profiles.md#colour-is-a-runtime-property)).
([color is a runtime property](filament-profiles.md#color-is-a-runtime-property)).
## Checking names ## Bases
Check every newly added profile's `name` against its type and role: model, selectable preset or base.
Apply the same checks to an intentional name change. The human-readable naming shapes are not enforced
by `check`: inspect the added or renamed profiles in the diff, using neighbouring names as context and
following the bundle's established style where the type-specific conventions allow variation.
For bases (`instantiation: "false"`), use the type-specific conventions:
| Type | Base names | | Type | Base names |
| --- | --- | | --- | --- |
| `machine` | `fdm_machine_common`, `fdm_<vendor>_common`, or an established machine-family base name | | `machine` | `fdm_machine_common`, `fdm_<vendor>_common`, `fdm_klipper_common`, or an established machine-family base |
| `process` | `fdm_process_*`, including shared roots and per-layer-height / per-nozzle bases such as `fdm_process_single_0.20` | | `process` | `fdm_process_*`: shared roots and per-layer-height or per-nozzle bases such as `fdm_process_single_0.20` or `fdm_process_<vendor>_<lh>_nozzle_<n>` |
| `filament` | `fdm_filament_*` material roots or `<Product> @base` product roots | | `filament` | `fdm_filament_*` material roots, `<Product> @base` product roots |
Shared base names across bundles are intentional, including product roots such as `Fiberon PA6-CF @base`. There is no leading-underscore convention. Base names repeat across bundles by design:
Investigate a newly authored base that retains an unrelated selectable preset's name from a copy. every bundle may have its own `fdm_process_common`, and a product root such as `Fiberon PA6-CF @base`
can exist in both the library and a vendor. Investigate a newly authored base that kept an unrelated
selectable preset's name from a copy.
**Name uniqueness is checked by CI.** `check_preset_name_uniqueness` checks type + name within each ## Uniqueness
bundle. `check_machine_model_name_uniqueness` checks model names across the entire tree, even with
`--vendor`: `Preset::get_printer_type` matches `printer_model` against all vendors' models and returns
the first match, so a duplicate makes lookup depend on vendor order. Both run as part of
`python3 scripts/orca_profile_tool.py check`.
## Not the same as the filename - Type + name is unique within a bundle, indexed or not; `check` enforces it.
- `machine_model` names are unique across the whole tree; `check` enforces it even with `--vendor`.
Printer-type lookup matches `printer_model` against every vendor's models and takes the first, so a
duplicate makes it depend on vendor order.
- At load, two selectable presets with one name discard the bundle, and a duplicate across vendors
is a validator error. Two bases with one name, or a base and a selectable preset, load silently and
the first in the index wins; an unindexed twin is therefore one `sub_path` edit away from becoming
the parent every child resolves to.
The loader keys off `name`, and a filename that disagrees usually still loads. Index `name` must equal the ## Filenames and paths
file's `name`, and the filename should match `sub_path`; a mismatch that differs only in case breaks
another platform ([cross-platform paths](validation.md#cross-platform-paths)). The loader keys off `name`, and a filename that disagrees usually still loads, but keep the filename
equal to the `name` and to the index `sub_path`. Match the exact case of every `sub_path` and asset
filename: Linux filesystems distinguish case even when a macOS or Windows checkout does not, and
preset-name references are case-sensitive on every platform. Avoid Windows-invalid characters
(`< > : " | ? *`), reserved device names such as `CON` and `NUL` (with any extension), and trailing
spaces or dots in a path component; a space right before `.json` is not a trailing space.
## Checking names
Check the `name` of every newly added profile, and every intentional rename, against its type and role
(model, selectable preset or base). `check` does not enforce the shapes on this page: inspect the
added or renamed presets in the diff, use neighbouring names as context, and follow the bundle's
established style where the conventions allow variation. Preserve shipped names during ordinary
tuning; renaming a shipped selectable preset needs `renamed_from`.
@@ -1,14 +1,15 @@
# Process profiles # Process profiles
Processes live in `resources/profiles/<Vendor>/process/` — selectable leaves and shared bases alike, and Processes live in `resources/profiles/<Vendor>/process/`, selectable leaves and shared bases alike, and
every one of them is registered in `process_list`. There are no global processes shared across vendors. every one of them is registered in `process_list`. There are no global processes shared across vendors.
## Naming ## Naming
`"<layer height>mm <quality> @<target>"` — near-universal, so match it. `"<layer height>mm <quality> @<target>"` is near-universal, so match it: the quality word before `@`,
the printer label after it ([naming.md](naming.md#process)).
Follow the bundle's existing quality vocabulary. BBL's common ladder relates the quality word to Follow the bundle's existing quality vocabulary. BBL's common ladder relates the quality word to the
the layer-height / nozzle ratio; it is a naming convention, not a loader constraint: layer height / nozzle ratio; it is a naming convention, not a loader constraint:
| Quality | Ratio | 0.2 nozzle | 0.4 | 0.6 | 0.8 | | Quality | Ratio | 0.2 nozzle | 0.4 | 0.6 | 0.8 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
@@ -20,49 +21,50 @@ the layer-height / nozzle ratio; it is a naming convention, not a loader constra
| Extra Draft | 0.7× | 0.14 | 0.28 | 0.42 | 0.56 | | Extra Draft | 0.7× | 0.14 | 0.28 | 0.42 | 0.56 |
This is the `fdm_process_single_<lh>_nozzle_<n>` ladder; 0.4 is commonly the unsuffixed nozzle default. This is the `fdm_process_single_<lh>_nozzle_<n>` ladder; 0.4 is commonly the unsuffixed nozzle default.
Match neighbouring names rather than renaming shipped tiers to fit the table. Newer BBL printers add High Quality, Balanced Quality and Strength tiers. Match neighbouring names rather
than renaming shipped tiers to fit the table. On a model with several nozzles, processes for the other
nozzles usually carry the nozzle in the label (`0.30mm Standard @BBL X1C 0.6 nozzle`); follow the bundle.
The `@target` is a human label, not a reference: most do not equal any real printer variant name. The `@target` is a human label, not a reference: it need not equal any printer variant name.
Compatibility comes from the resolved list or condition, not this label. Compatibility comes from the resolved list or condition, not this label.
## Shape ## Shape
A selectable leaf's only truly universal keys are `type`, `setting_id`, `name` and `instantiation`; A selectable leaf has `type`, `setting_id`, `name` and `instantiation`, normally `inherits` and
`inherits` and `from` are near-universal — plus compatibility. No slicing key is universal; even `from`, plus compatibility; its slicing keys, `layer_height` included, normally come from its bases. A
`layer_height` is more often inherited than restated. A base has `type`, `name`, `instantiation`, almost base has `type`, `name`, `instantiation`, `from`, and **no** `setting_id`.
always `from`, and **no** `setting_id`.
**Target shape: a 7-key leaf.** `OrcaArena` is the cleanest model — **Target shape: a 7-key leaf.** `OrcaArena` is the cleanest model:
`fdm_process_common` → `fdm_process_arena_common` → `fdm_process_arena_<lh>_nozzle_<n>` → leaf, where the `fdm_process_common` → `fdm_process_arena_common` → `fdm_process_arena_<lh>_nozzle_<n>` → leaf, where the
leaf carries only `type`, `name`, `inherits`, `from`, `setting_id`, `instantiation`, leaf carries only `type`, `name`, `inherits`, `from`, `setting_id`, `instantiation` and
`compatible_printers`, and the per-nozzle base holds the layer height and all eight line widths. `compatible_printers`, and the per-nozzle base holds the layer height and all eight line widths.
[shared-bases.md](shared-bases.md#levels) says which level each process setting belongs to.
BBL, WonderMaker and Z-Bolt are uniform in *layering* — every leaf inherits a base, names its printers BBL's *layering* is a model too (every leaf inherits a base, names its printers directly and holds no
directly and holds no layer height of its own — but not in key count. Imitate BBL's layering, not its layer height of its own), but not its content: its leaves carry multi-variant `print_extruder_variant`
content: its leaves carry doubled `print_extruder_variant` arrays that no single-variant vendor needs. arrays that no single-variant vendor needs ([extruder-variants.md](extruder-variants.md#process)).
Nearly every vendor ships its own `fdm_process_common` as the inherits-less root. Those files are not A bundle has its own `fdm_process_common` as the inherits-less root, since a process inherits only
identical; copying another vendor's version into a new bundle is normal. inside its bundle; starting a new bundle's from another vendor's copy is fine.
Beware leaf-inherits-leaf: Prusa chains several levels deep through sibling leaves, and Elegoo and Beware leaf-inherits-leaf: a bundle may chain selectable processes several levels deep, so editing one
Flashforge do it too, so editing one selectable process silently changes others. Check a leaf's children silently changes others. Check a leaf's children before editing it.
before editing it.
## Compatibility ## Compatibility
Most leaves set `compatible_printers` directly; some inherit it from a base, and Prusa's fall through to A leaf sets `compatible_printers` directly, inherits it from a base, or falls through to
`compatible_printers_condition`. After resolving `inherits`, **every selectable process has one or the `compatible_printers_condition`. After resolving `inherits`, **every selectable process has one or the
other** — that is the invariant to review against. Unlike filaments, inheriting `compatible_printers` is other**: that is the invariant to review against. Unlike filaments, inheriting `compatible_printers` is
legitimate for a process, and no check enforces its presence. legitimate for a process, and no check enforces its presence.
- A non-empty `compatible_printers` makes `compatible_printers_condition` **dead code**. Use one or - A non-empty `compatible_printers` makes `compatible_printers_condition` **dead**. Use one or the
the other. other.
- A condition that fails to parse means *compatible with everything* — a warning, not an error. A typo - A condition that fails to parse means *compatible with everything*: a warning, not an error. A typo
widens compatibility instead of narrowing it. widens compatibility instead of narrowing it.
- Matching is `boost::regex` **`regex_match`** — a full-string match, which is why every shipped - A regex in a condition must match the **whole** string, so wrap the keyword in `.*`; `.` also spans
condition wraps its keyword in `.*`. Because it is boost rather than `std`, `.` also spans the newlines the newlines inside `printer_notes`.
inside `printer_notes`. - A `printer_notes` keyword that prefixes another model's keyword matches both. Guard it with a
- A `printer_notes` keyword that prefixes another model's keyword matches both. Prusa guards it: character class after the keyword, and combine terms with `and`:
``` ```
printer_notes=~/.*PRINTER_MODEL_COREONE[^_a-zA-Z0-9].*/ and nozzle_diameter[0]==0.4 and printer_notes=~/.*HF_NOZZLE.*/ printer_notes=~/.*PRINTER_MODEL_COREONE[^_a-zA-Z0-9].*/ and nozzle_diameter[0]==0.4 and printer_notes=~/.*HF_NOZZLE.*/
@@ -70,76 +72,84 @@ legitimate for a process, and no check enforces its presence.
The `[^_a-zA-Z0-9]` exists because `PRINTER_MODEL_COREONE_L` also contains `PRINTER_MODEL_COREONE`. The `[^_a-zA-Z0-9]` exists because `PRINTER_MODEL_COREONE_L` also contains `PRINTER_MODEL_COREONE`.
`compatible_printers` is almost always one element. A leaf listing a whole model family is where a newly A leaf listing a whole model family is where a newly added printer is usually forgotten.
added printer is usually forgotten.
## What to review per nozzle ## Values to review per nozzle
| Key group | Review | | Key group | Review |
| --- | --- | | --- | --- |
| `line_width` and per-region widths | resolved widths suit the nozzle and layer height | | `line_width` and per-region widths | resolved widths suit the nozzle and layer height |
| `layer_height`, `initial_layer_print_height` | within the printer's limits | | `layer_height`, `initial_layer_print_height` | within the printer's `min_layer_height` / `max_layer_height` |
| print speeds | consistent with flow limits and hardware tuning | | print speeds | consistent with flow limits and hardware tuning |
| shell layers, wall loops, accelerations, support Z distances | preserve the intended thickness, motion and support behavior | | shell layers, wall loops, accelerations, support Z distances | preserve the intended thickness, motion and support behaviour |
**A common starting pattern is nozzle + 0.02 mm**: 0.22 / 0.42 / 0.62 / 0.82 / 1.02. In that pattern, at 0.4, **A common starting pattern is line width = nozzle + 0.02 mm**: 0.22 / 0.42 / 0.62 / 0.82 / 1.02. In
`inner_wall_line_width`, `sparse_infill_line_width`, `skin_infill_line_width` and that pattern, at 0.4, `inner_wall_line_width`, `sparse_infill_line_width`, `skin_infill_line_width` and
`skeleton_infill_line_width` widen to 0.45 and `initial_layer_line_width` to 0.5; at 0.2, `skeleton_infill_line_width` widen to 0.45 and `initial_layer_line_width` to 0.5; at 0.2,
`initial_layer_line_width` widens to 0.25. Also derived, and easily missed: `initial_layer_line_width` widens to 0.25. Also derived, and easily missed:
`ironing_inset = line_width / 2` (0.11 / 0.21 / 0.31 / 0.41). `ironing_inset = line_width / 2` (0.11 / 0.21 / 0.31 / 0.41). These are examples, not required values;
These are examples, not required values; preserve intentional vendor tuning and percentage/automatic preserve intentional vendor tuning and percentage or automatic widths, and validate their resolved
widths, and validate their resolved values. values.
`min_layer_height` and `max_layer_height` are machine keys — no process file sets them. `min_layer_height` and `max_layer_height` are machine keys; no process file sets them.
## Slice-time content checks ### Slicing limits
`Print::validate()` enforces four rules at slice time: Slicing rejects a process that breaks one of these (the message in italics):
1. `initial_layer_print_height` ≤ min `nozzle_diameter` 1. `initial_layer_print_height` ≤ the smallest `nozzle_diameter` (with a raft, the nozzle of the raft's
2. `layer_height` ≤ min `nozzle_diameter` — *"Layer height cannot exceed nozzle diameter."* first-layer extruder).
3. `line_width` and the seven per-region widths (inner/outer wall, sparse infill, internal solid infill, 2. `layer_height` ≤ the smallest `nozzle_diameter`: *"Layer height cannot exceed nozzle diameter."*
top surface, skin, skeleton) > `layer_height` — *"Line width too small"*. `support_line_width` only 3. `line_width` and the seven per-region widths (inner and outer wall, sparse infill, internal solid
when the object has support or a raft; `initial_layer_line_width` is never checked. infill, top surface, skin, skeleton) > `layer_height`: *"Line width too small"*.
4. every width ≤ 5 × max `nozzle_diameter` — *"Line width too large"* `support_line_width` is checked only when the object has support or a raft;
`initial_layer_line_width` is never checked. A width that resolves to 0 (automatic) is skipped.
4. Every width ≤ 5 × the largest `nozzle_diameter`: *"Line width too large"*.
Two further rules cover `bridge_line_width` (≤ nozzle diameter; > `layer_height` unless `thick_bridges` Two further rules cover `bridge_line_width`: it must not exceed the nozzle diameter, and must exceed
and `thick_internal_bridges` are both on). The sweep starts from printer defaults rather than `layer_height` unless `thick_bridges` and `thick_internal_bridges` are both on. The slice sweep starts
enumerating every process. **A new non-default process gets no dedicated slice coverage in CI.** from printer defaults rather than enumerating every process: **a new non-default process gets no
dedicated slice coverage in CI.**
## What CI checks on a process ## What CI checks on a process
Structure, not content: `process_list` name consistency **and** index coverage the other way, two files Structure, not content: `process_list` name consistency **and** index coverage the other way, two files
claiming one process name, the `extruder_clearance_radius` / `extruder_clearance_max_radius` conflict claiming one process name, the `extruder_clearance_radius` / `extruder_clearance_max_radius` conflict
pair, duplicate JSON keys, a file `normalize` would rewrite, and the five `setting_id` rules (the fifth pair, duplicate JSON keys, a file `normalize` would rewrite, the five `setting_id` rules (present on
rejects the misspelled key `settings_id`). `compatible_printers` presence is checked for **filaments selectable presets, absent from bases, equal to the formula outside `BBL/`, unique across the tree, and
only**. no misspelled key `settings_id`), and the variant arrays: every array of `print_options_with_variant`
exactly `variant length × stride` wide in each selectable process that writes it, and a written
`print_extruder_id` one entry per variant
([variant arrays](validation.md#variant-arrays)). `compatible_printers` presence is checked for
**filaments only**.
Note the C++ loader derives a missing `setting_id` on the fly, so the validator will not fail a process The loader derives a missing `setting_id` on the fly, so the validator accepts a process without one;
without one — only `orca_profile_tool.py check` catches it. Running the validator alone gives a false only `orca_profile_tool.py check` catches it.
all-clear. Running the validator alone gives a false all-clear.
## Silent failures specific to processes ## Silent failures specific to processes
- **Unknown or misspelled keys are discarded with no error and no warning.** They ship all over the - **Unknown or misspelled keys are discarded with no error and no warning**, both plain typos
process tree, both plain typos (`inital_layer_height`, `tree_support_bramch_diameter_angle`, (`inital_layer_height`, `tree_support_bramch_diameter_angle`, `sparse_infill_patter`) and keys
`sparse_infill_patter`) and keys copied from other slicers that Orca never defined. copied from other slicers that Orca never defined.
- Keys on the tool's `OBSOLETE_KEYS` list (`adaptive_layer_height`, `overhang_totally_speed`, …) are - Keys on the tool's obsolete list (`adaptive_layer_height`, `overhang_totally_speed`, …) are rejected
rejected by `check`'s normalization pass across preset types; `normalize` removes them. by `check`'s normalization pass across preset types; `normalize` removes them. The additional per-key
The additional per-key obsolete warnings read `filament/` only. obsolete warnings read `filament/` only.
- A dangling `compatible_printers` inside an `instantiation: "false"` base is invisible to - A dangling `compatible_printers` inside an `instantiation: "false"` base is reported only through a
`check_preset_references`: a base never becomes a `Preset` at all (its config goes into `config_maps` selectable child that inherits it unchanged; it goes unreported when every child overrides the list,
and the loader returns early), so it is in no collection for the check to walk. or when the base has no instantiated children.
- Orphan bases that nothing inherits are scattered through the tree — usually the leftover of a - Nothing flags an orphan base that nothing inherits, usually the leftover of a half-finished nozzle
half-finished nozzle addition. addition.
## Adding a quality tier or a nozzle's processes ## Adding a quality tier or a nozzle's processes
1. Choose the layer height and quality label using the vendor's existing ladder. 1. Choose the layer height and quality label using the bundle's existing ladder.
2. If the vendor has per-nozzle bases, add one (`fdm_process_<vendor>_<lh>_nozzle_<n>`) with the layer 2. If the bundle has per-nozzle bases, add one (`fdm_process_<vendor>_<lh>_nozzle_<n>`) with the layer
height, nozzle-appropriate line widths, `initial_layer_print_height` and `ironing_inset`. height, nozzle-appropriate line widths, `initial_layer_print_height` and `ironing_inset`.
3. Add the leaf: 7 keys, `compatible_printers` naming the exact printer variant(s). 3. Add the leaf: 7 keys, `compatible_printers` naming the exact printer variant(s).
4. Register both in `process_list`, parent first. Bump the version, run the id tool, validate. 4. Register both in `process_list`, parent first, bump the version, run the id tool and validate: the
5. Slice this process explicitly with its intended printer; the sweep gives non-default tiers no [authoring workflow](../SKILL.md#creating-or-modifying-a-profile).
dedicated coverage. If it is a printer's `default_print_profile`, verify the exact name and 5. Slice this process explicitly with its intended printer
resolved compatibility too — the sweep may fall back or select another compatible process. ([on a copy of the tree](validation.md#checking-a-copy-of-the-tree)); the sweep gives non-default
tiers no dedicated coverage. If it is a printer's `default_print_profile`, verify the exact name and
resolved compatibility too: the sweep may fall back or select another compatible process.
@@ -1,37 +1,43 @@
# Reviewing a profile change # Reviewing a profile change
Start with delivery, identity and backward compatibility, then check the affected preset types. Run `./scripts/check_profile.sh` on the applied diff first ([validation.md](validation.md) says what CI
The table highlights gaps that need human review. What CI *does* run: runs), then work through the items below: delivery, identity and backward compatibility first, then the
[validation.md](validation.md). affected preset types. The table lists the gaps CI cannot see, so only a reviewer catches them.
| Not checked by CI | Consequence | | Not checked by CI | Consequence |
| --- | --- | | --- | --- |
| The `version` bump | The change never reaches an upgrading user | | The `version` bump | The change never reaches an upgrading user; an absent `version` hides the vendor from the setup wizard |
| A misspelled setting key | Setting silently has no effect | | A misspelled setting key | The setting silently has no effect |
| A filename Windows cannot check out, or one that differs from its `sub_path` only in case | Works on the author's machine, breaks the bundle on another platform | | A filename Windows cannot check out, or one that differs from its `sub_path` only in case | Works on the author's machine, breaks the bundle on another platform |
| `bed_model` / `bed_texture` / `hotend_model` pointing at a missing asset | Bed renders as Custom, hotend falls back to the generic model | | `bed_model` / `bed_texture` / `hotend_model` / cover pointing at a missing asset | A missing bed model renders a generic custom bed and a missing texture renders none, the hotend falls back to the generic model, the cover shows a placeholder |
| A nozzle size in a model's list with no matching variant | The size is offered and resolves to nothing | | A nozzle size in a model's list with no matching variant | The size is offered and resolves to nothing |
| A non-default process | `validate_slice` gives non-default quality tiers no dedicated coverage | | A non-default process | `validate_slice` gives non-default quality tiers no dedicated coverage |
| Whether the intended default survived compatibility selection | The sweep can select a different compatible preset | | Whether the intended default survived compatibility selection | The sweep can select a different compatible preset |
| A dangling `compatible_printers` inside an `instantiation: "false"` base | A base never becomes a `Preset`, so the reference check never sees it (a bad `inherits` in a base *is* caught) | | A dangling `compatible_printers` inside a base whose children all override it (or that has no instantiated children) | The reference check walks resolved selectable presets, so it reports a base's list only through a child that inherits it unchanged (a bad `inherits` in a base *is* caught) |
| A base nothing inherits | Dead weight, usually the leftover of an unfinished nozzle addition |
| A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name | | A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name |
| A preset differentiated only by color, or an all-printer library preset without `@System` | Per-color presets split one product across ids and the selector fills with near-duplicates; CI stays green | | A preset differentiated only by colour, or an all-printer library preset without `@System` | Per-colour presets split one product across several ids and the selector fills with near-duplicates; CI stays green |
| Per-extruder vector length on a multi-nozzle printer | Silently padded (with the **first** value) or truncated | | Plate temperatures for plates the printer has | The user's plate reads an unset or inherited temperature |
| A name that ignores its type's convention — a model in a `process` quality slot, or an unrelated target label left in a copied preset | The selector misrepresents the preset's quality or intended printer | | Per-extruder vector length on a multi-extruder printer | Silently padded (with the **first** value) or truncated |
| A new variant appended instead of inserted at its variant index, a variant array widened on a base with a shorter list, or a variant a filament/process lacks | Values shift onto the wrong extruder, are cut before any child inherits them, or resolve to the first variant: High Flow silently gets Standard values |
| A name that ignores its type's convention: a printer model in a `process` quality position, or an unrelated target label or base name left in a copied preset | The selector misrepresents the preset's quality or intended printer |
| Values: temperatures, speeds, widths, pressure advance | A wrong value prints wrong while CI stays green |
## 1. Was the vendor `version` bumped? ## 1. Was the vendor `version` bumped?
For **every** bundle whose folder the diff touches, `resources/profiles/<Vendor>.json` must have its For **every** bundle whose folder the diff touches, `resources/profiles/<Vendor>.json` must have its
`version` incremented — last component, carrying `.99` into the third component. A library change `version` incremented: last component, carrying `.99` into the third component. A library change means
means bumping `OrcaFilamentLibrary.json`. bumping `OrcaFilamentLibrary.json`.
*Why:* nothing in CI checks it, and `PresetUpdater` reinstalls only when `vendor_ver < resource_ver` — *Why:* nothing in CI checks it, and the app reinstalls a bundled profile set only when its version is
without a bump the change reaches neither an upgrading user nor the author's own running app. newer than the installed one: without a bump the change reaches neither an upgrading user nor the
author's own running app. Without any `version` the vendor vanishes from the setup wizard, and neither
`check` nor the validator reports it.
## 2. Was the index rebuilt, and does the diff contain only this change? ## 2. Was the index rebuilt, and does the diff contain only this change?
`check` now fails on an unregistered file, on an index `update-index` would reorder, and on a file `check` fails on an unregistered file, on an index `update-index` would reorder, and on a file
`normalize` would rewrite — so a PR that skipped them arrives red, and you do not have to spot the `normalize` would rewrite, so a PR that skipped them arrives red and you do not have to spot the
omission yourself. Three things are still yours: omission yourself. Three things are still yours:
- **The index diff belongs to this change.** `update-index` rewrites whole `*_list` sections. If the - **The index diff belongs to this change.** `update-index` rewrites whole `*_list` sections. If the
@@ -41,11 +47,15 @@ omission yourself. Three things are still yours:
registration; `validate_custom` detects the break only for names covered by released fixtures. registration; `validate_custom` detects the break only for names covered by released fixtures.
- **`normalize` edits content, not just layout.** It drops `version` and `is_custom_defined` from preset - **`normalize` edits content, not just layout.** It drops `version` and `is_custom_defined` from preset
files, removes obsolete keys, deletes six print-speed keys from filament profiles, and resolves files, removes obsolete keys, deletes six print-speed keys from filament profiles, and resolves
`extruder_clearance_radius` against `extruder_clearance_max_radius` by keeping the larger. `extruder_clearance_radius` against `extruder_clearance_max_radius` by keeping the larger
Check that the keys it removed were meant to go. ([what normalize changes](validation.md#normalize-and-update-index-are-part-of-the-check)). Check that
the keys it removed were meant to go.
Obsolete keys fail `check`'s normalization pass and should be removed with `normalize`. Index order is dependency order, not alphabetical: parents and include templates before the presets
`check` also reports per-key obsolete warnings for filament profiles in the selected vendors. that use them, then, in name order, the entries that neither depend on nor are depended on by another
entry of their own list (such as a leaf filament whose only parent is in the library), and any entry on
a dependency cycle. Judge a hand-placed entry only by
`update-index --dry-run`: if it reports nothing to rebuild, the position is not a finding.
*Why:* the index is the loader's only entry point. Out-of-order entries fail with `can not find inherits` *Why:* the index is the loader's only entry point. Out-of-order entries fail with `can not find inherits`
and take the whole vendor bundle down; an unindexed file gets reviewed, merged and never loads. and take the whole vendor bundle down; an unindexed file gets reviewed, merged and never loads.
@@ -55,8 +65,8 @@ and take the whole vendor bundle down; an unindexed file gets reviewed, merged a
No hand-typed or copied `setting_id` / `filament_id`. Instantiated presets have a `setting_id`; bases do No hand-typed or copied `setting_id` / `filament_id`. Instantiated presets have a `setting_id`; bases do
not. `check` enforces all of that; what it cannot tell you is whether the identity *should* have moved. not. `check` enforces all of that; what it cannot tell you is whether the identity *should* have moved.
A rewritten or removed `filament_id` means a product's identity moved — a rename, or an edited A rewritten or removed `filament_id` means a product's identity moved (a renamed alias, or an edited
`filament_vendor` / `filament_type` — and the old id is not forwarded anywhere. Confirm that was `filament_vendor` / `filament_type`), and the old id is not forwarded anywhere. Confirm that was
intended, and that a new id is not a rename in disguise. intended, and that a new id is not a rename in disguise.
*Why:* a duplicate `filament_id` on one printer makes AMS spool matching a coin toss; a copied *Why:* a duplicate `filament_id` on one printer makes AMS spool matching a coin toss; a copied
@@ -64,136 +74,204 @@ intended, and that a new id is not a rename in disguise.
## 4. Does anything disappear for existing users? ## 4. Does anything disappear for existing users?
A rename, a deletion, or a flip of `"instantiation": "true"` → `"false"` on a shipped preset removes the A rename, a deletion, or a flip of `"instantiation": "true"` → `"false"` on a shipped selectable preset
name from the preset collection. It needs `renamed_from` on a successor — and only one preset may claim a removes the name from the preset collection. It needs `renamed_from` on a selectable successor
given old name. The claimed old name must **not** still be a live preset; the redirect is inert if it is. ([renamed_from](vendor-bundle.md#renamed_from)), and only one preset may claim a given old name. The
claimed old name must **not** still be a live preset; the redirect is inert if it is.
*Why:* user presets inheriting it die with `can not find parent <name> for config <file>!`; 3MF-embedded *Why:* user presets inheriting it die with `can not find parent <name> for config <file>!`; 3MF-embedded
presets are dropped with no error at all. Commit `33923464ae` reverted exactly this for Cubicon; presets are dropped with no error at all. Commit `33923464ae` reverted exactly this for Cubicon;
`6943b6ddc3` redid it correctly. CI's `validate_custom` catches the shipped-name case — but not an inert `6943b6ddc3` redid it correctly with `renamed_from`. CI's `validate_custom` catches the shipped-name
`renamed_from`. case, but not an inert `renamed_from`.
## 5. Is `compatible_printers` right? ## 5. Is `compatible_printers` right?
Exact printer **variant** names, non-empty on every instantiated filament outside OrcaFilamentLibrary Exact printer **variant** names, non-empty on every instantiated filament outside OrcaFilamentLibrary,
and written in the preset's own file — golden rule 6, with the flattened-vs-own-key trap in and written in the preset's own file ([SKILL.md rule 9](../SKILL.md#rules); the resolved-vs-own-key trap
[filament-profiles.md](filament-profiles.md#compatible_printers). Watch for a nozzle-specific variant that is in [filament-profiles.md](filament-profiles.md#compatible_printers)). A `machine_model` name instead
inherited or copied the base's full printer list, and for two presets of one product with overlapping of a variant name is the usual mistake: `check` passes it, the validator reports
lists — duplicate combobox entries and an ambiguous AMS match. `references unknown compatible_printers`. Watch for a nozzle-specific tune that inherited or copied the
base's full printer list, and for two presets of one product with overlapping lists: duplicate combobox
entries and an ambiguous AMS match.
Two presets of one product (`filament_id`) must not share a variant. Resolve it by specificity: move the Two presets of one product (`filament_id`) must not share a variant. Resolve it by specificity: move the
variant to the most specific preset and remove it from the more general ones — preferred over deleting a variant to the most specific preset and remove it from the more general ones, which is preferred over
profile. Then repoint the machine's `default_filament_profile` and the model's `default_materials` at the deleting a profile. Then repoint the machine's `default_filament_profile` and the model's
profile that now covers it. See `default_materials` at the profile that now covers it. See
[one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product). [one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product).
*Why:* real shipped bugs twice (`b7b3418baf` "showing up everywhere", `ff83aa41ef` duplicate Flashforge *Why:* real shipped bugs twice (`b7b3418baf` filaments "showing up everywhere", `ff83aa41ef` duplicate
entries). The Python `check` passes on an overlap; only the full `check_profile.sh` (`validate_system`) Flashforge entries). The Python `check` passes on an overlap; only the full `check_profile.sh`
reports `Ambiguous AMS filament match`. (`validate_system`) reports `Ambiguous AMS filament match`.
## 6. Model ↔ variant ↔ process consistency ## 6. One product, one all-printer preset; colour is not a preset
- New nozzle size → the model's `nozzle_diameter` list extended, a variant with a matching No presets that differ only by colour: `filament_id` identifies a product, and the colour comes from the
`printer_variant`, and at least one process listing that variant. spool at runtime. An all-printer library product is a `<Product> @System` shim with an empty
- `default_print_profile` is one exact name (not a `;` list), and that process's resolved `compatible_printers` ([colour](filament-profiles.md#colour-is-a-runtime-property)).
compatibility list or condition includes this printer.
*Why:* per-colour presets pass every check, so this is a review call.
## 7. Model ↔ variant ↔ process consistency
- A new nozzle size → the model's `nozzle_diameter` list extended, a variant with a matching
`printer_variant`, and at least one process listing that variant. Every size in the model's list has a
variant (unchecked).
- `default_print_profile` is one exact name (not a `;` list), and that process's resolved compatibility
list or condition includes this printer.
- `default_filament_profile` is an array of names that exist. - `default_filament_profile` is an array of names that exist.
- Each variant of the model has at least one compatible entry in the model's `default_materials`.
*Why:* an unlisted `printer_variant` is a hard bundle-load failure. Default process selection is *Why:* an unlisted `printer_variant` is a hard bundle-load failure. Default process selection is weaker:
weaker: the sweep attempts the named default, then updates compatibility and rejects generic Default the sweep attempts the named default, then updates compatibility and rejects generic Default fallbacks,
fallbacks. Another compatible process can conceal a bad reference, so inspect it even after a pass. so another compatible process can conceal a bad reference. Inspect it even after a pass.
## 7. Types and spellings ## 8. Types and spellings
Every value a string or an array of strings; `filament_type` an array; `instantiation` the string Every value a string or an array of strings; `filament_type` an array; `instantiation` the string
`"true"`/`"false"` — golden rule 7. Check index metadata and model `nozzle_diameter` especially; `"true"` / `"false"`; custom G-code one string, never an array of lines ([SKILL.md rule 5](../SKILL.md#rules)).
wrong types there can abort loading for **every** vendor. Check index metadata and model `nozzle_diameter` especially: wrong types there can abort loading for
**every** vendor.
The part only a reviewer can do: check new setting keys against `src/libslic3r/PrintConfig.cpp`. A The part only a reviewer can do: check new setting keys against `src/libslic3r/PrintConfig.cpp`. A
misspelled key is silently discarded (rule 8), the single most common way a profile edit does nothing misspelled key is silently discarded ([rule 6](../SKILL.md#rules)), the single most common way a profile
while CI stays green. edit does nothing while CI stays green.
## 8. Blast radius of a base edit ## 9. Blast radius of a base edit
A change to `fdm_*_common.json` reaches every child at once. Ask which presets it touches — several A change to `fdm_*_common.json` or any other base reaches every child at once. Ask which presets it
reverts in this repo are exactly this (`41d1b0d3c8`, `dc491166a8`). Also check whether the edited leaf has touches: several reverts in this repo are exactly this (`41d1b0d3c8`, `dc491166a8`). Also check whether
children of its own: Prusa, Flashforge and Elegoo all chain leaf-inherits-leaf several levels deep. the edited leaf has children of its own: a bundle may chain leaf-inherits-leaf several levels deep. A
newly added base that nothing inherits is dead weight, and a dangling
`compatible_printers` inside a base is reported only through a child that inherits it unchanged. A diff
that only moves values between presets and bases must leave every selectable preset loading what it
loaded: ask for the `compare` result ([shared bases](shared-bases.md#nothing-loads-differently)), and
check each new base against the [balance rules](shared-bases.md#balance).
## 9. Do the numbers make sense for the nozzle? ## 10. Do the numbers make sense for the nozzle and material?
Check resolved widths and layer heights against the nozzle, and flow limits / pressure advance Check resolved widths and layer heights against the nozzle, temperatures against the material (PLA
against the actual hardware and material. The patterns in [process-profiles.md](process-profiles.md) values under an ASA name print wrong), and flow limits / pressure advance against the actual hardware
are examples, not mandatory values; [filament-profiles.md](filament-profiles.md) explains what to and material. The patterns in [process-profiles.md](process-profiles.md#values-to-review-per-nozzle)
revisit for a nozzle change. A cloned preset's unchanged MVS needs particular scrutiny. are examples, not mandatory values; [filament-profiles.md](filament-profiles.md#tuning-per-nozzle-and-per-variant)
explains what to revisit for a nozzle change. A cloned preset's unchanged volumetric speed needs
particular scrutiny.
Settings tuned for real hardware cannot be verified by reading the diff. Say so rather than approving Settings tuned for real hardware cannot be verified by reading the diff. Say so rather than approving
numbers nobody measured. numbers nobody measured.
## 10. Asset references (not checked anywhere) ## 11. Plate temperatures
A filament sets the plate temperature for every plate the printer plausibly has, as its siblings do;
`textured_cool_plate_temp` is the one most often forgotten
([twelve keys](filament-profiles.md#bed-temperature-is-twelve-keys-not-one)).
## 12. Asset references (not checked anywhere)
`bed_model`, `bed_texture`, `hotend_model` and `<Model>_cover.png` exist under `bed_model`, `bed_texture`, `hotend_model` and `<Model>_cover.png` exist under
`resources/profiles/<vendor folder>/`. Broken references already ship; nothing checks them. `resources/profiles/<vendor folder>/`, in exact case. Nothing checks them.
## 11. `default_materials` (checked by CI) ## 13. `default_materials` and `default_filament_profile` (checked by CI)
`check` fails on a `default_materials` / `default_filament_profile` name that resolves to no system `check` fails on a `default_materials` / `default_filament_profile` name that matches no filament file,
filament, so a dangling entry no longer reaches review. When compatibility moves between profiles of a and `validate_system` on a variant with no compatible system filament in `default_materials`, so a
product, the machine's `default_filament_profile` and the model's `default_materials` must be repointed at dangling entry no longer reaches review. When compatibility moves between profiles of a product, the
the most specific profile that still covers the variant, dropping generic entries that no longer apply — machine's `default_filament_profile` and the model's `default_materials` must be repointed at the most
the same [specificity rule](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product) specific profile that still covers the variant, dropping generic entries that no longer apply; the same
applies when adding or fixing defaults. Scope the run while working on one vendor: [specificity rule](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product) applies
when adding or fixing defaults. Scope the run while working on one vendor:
```bash ```bash
python3 scripts/orca_profile_tool.py check --vendor "<Vendor>" # py -3 on Windows python3 scripts/orca_profile_tool.py check --vendor "<Vendor>" # py -3 on Windows
``` ```
## 12. Per-extruder vector lengths (not checked) ## 14. Per-extruder vectors (not checked) and variant arrays (widths and layout checked)
One entry per extruder for the plain per-extruder vectors; the `printer_options_with_variant_1` keys are One entry per extruder for the plain per-extruder vectors; the variant sets are sized to the variant
sized to `printer_extruder_variant` instead. A wrong length is silently padded — repeating the **first** length, `len(printer_extruder_variant)` or one per extruder when the resolved preset has no layout (a
value, not the last — or truncated. The two sizing families and the worked cases are in per-extruder difference in those keys still needs the layout, since the loader keeps only the
first value of a list-less printer's arrays). A wrong length is silently padded, repeating the **first**
value, not the last, or truncated. The two sizing families and the worked cases are in
[machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers). [machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers).
## 13. Non-default processes get no slice coverage `check` reports a variant array of any type that is not exactly the `variant length × stride` width
of the selectable preset that writes it, one value included, as an error whatever the values;
`check --strict` also reports one that reaches a selectable preset at another width. A base is not
judged on its own. On a machine it
also reports layout keys that disagree: `printer_extruder_variant` / `printer_extruder_id` not the
flattening of `extruder_variant_list`, a variant without its extruder's `extruder_type` prefix, a
`default_nozzle_volume_type` the extruder does not list, a list of several variants without the pair, a
pair without the list that puts several variants on one extruder. On a machine or a process, an id
array, written or inherited, whose length differs from its variant list is an error; the id and
layout rules judge the composed preset without `--strict`. Two layout findings are warnings: a
pair without the list that `single_extruder_multi_material` off would replace at load, and a process
without `print_extruder_id` whose variants repeat. The full rules are in
[variant arrays](validation.md#variant-arrays). `check_variant_names` separately holds every variant
string, `extruder_type`, `nozzle_volume_type` and `default_nozzle_volume_type` to the engine's enums
in every bundle, BBL included, failing on a dead variant, a legacy spelling and a variant list that
names one variant twice ([variant names](validation.md#variant-names)).
On a multi-variant printer, still check by hand:
- that a new variant's values sit at its variant index (machine limits as a (normal, silent) pair at
`2 × index`) in every file of the chain that restates the key, `include` templates included;
- that each process lists every (extruder id, variant) pair its printers can select;
- that no array was widened on a base with a shorter variant list: the loader cuts it before the
children inherit it ([composition](extruder-variants.md#padding-truncation-and-composition)), and
`check`, even `--strict`, composes without that cut, so it passes;
- that only keys in [the four sets](extruder-variants.md#the-four-key-sets) carry per-variant values;
- that the High Flow variants carry measured values rather than copies
([checking and testing](extruder-variants.md#checking-and-testing)).
## 15. Non-default processes get no slice coverage
`validate_slice` starts from printer defaults; it does not enumerate every process. Slice a new or `validate_slice` starts from printer defaults; it does not enumerate every process. Slice a new or
changed non-default tier explicitly with its intended printer. changed non-default tier explicitly with its intended printer
([on a copy of the tree](validation.md#checking-a-copy-of-the-tree)).
## 14. Housekeeping worth a nit, not a block
`"from"` other than `"system"` (the preset-bundle loader ignores it, though the CLI's config-file loader
rejects anything but `system`/`user`/`User`), `printer_settings_id` copied from another
vendor, and a filename that disagrees with the preset's `name` (common; the loader keys off `name`).
## 15. Cross-platform filenames and paths (not checked)
Check for Windows-invalid characters, reserved device names, trailing path-component spaces/dots,
and case mismatches in `sub_path` or asset paths. See [cross-platform paths](validation.md#cross-platform-paths).
## 16. Do the preset names follow the conventions? ## 16. Do the preset names follow the conventions?
Check **every newly added profile and intentional name change**, including models and bases, Check **every newly added profile and intentional name change**, including models and bases, against
against [the naming conventions](naming.md#checking-names). Preserve shipped names during [the naming conventions](naming.md#checking-names): no printer model in a process quality position, no
ordinary tuning; renaming a shipped selectable preset requires the migration in item 4. target label or base name left over from a copied preset, the bundle's established style. Preserve
shipped names during ordinary tuning; renaming a shipped selectable preset requires the migration in
item 4.
*Why:* CI checks name uniqueness, but does not enforce the naming conventions. Catch naming *Why:* CI checks name uniqueness, but does not enforce the naming conventions. Catch naming mistakes
mistakes before the names ship and existing projects depend on them. before the names ship and existing projects depend on them.
## 17. Cross-platform filenames and paths (not checked)
Check for Windows-invalid characters, reserved device names, trailing path-component spaces or dots,
and case mismatches between `sub_path` or asset references and the files on disk. See
[filenames and paths](naming.md#filenames-and-paths).
## 18. Housekeeping worth a nit, not a block
`"from"` other than `"system"` (the bundle loader ignores it, though loading the file as a CLI config
rejects anything but `system` / `user` / `User`), `printer_settings_id` copied from another vendor,
redundant overrides that restate the parent's value, siblings that each repeat a value their base could
hold ([shared bases](shared-bases.md)), and a filename that disagrees with the preset's
`name` (the loader keys off `name`).
--- ---
## Reporting the review ## Reporting the review
A finding is: **one defect**, its file, what breaks at runtime or in CI, and the fix. Split independent A finding is **one defect**: its file (or quoted lines), what breaks at runtime or in CI, and the fix.
defects into separate findings even when they live in one file — five id problems in one bullet get one Split independent defects into separate findings even when they live in one file: five id problems in
fix and four survivors. one bullet get one fix and four survivors. Say which findings `check` or the validator reports and which
only a reader catches: a missing version bump, a misspelled key and wrong temperatures pass CI, so a
contributor who only reruns the tools fixes what CI flags and resubmits the rest.
Severity discriminates only if it is earned: Severity discriminates only if it is earned:
| Severity | Means | | Severity | Means |
| --- | --- | | --- | --- |
| blocker | the bundle fails to load, or a preset is unreachable at runtime | | blocker | the bundle fails to load, or a preset is unreachable at runtime |
| major | CI fails, or existing users lose a preset | | major | CI fails, existing users lose a preset, or a value prints wrong while CI stays green (PLA temperatures under an ASA name; a misspelled key whose intended value differs from the inherited one) |
| minor | wrong-but-working: dead keys, `from`, naming, redundant overrides | | minor | wrong but working: redundant or dead keys that change nothing, `from`, naming |
Compute every number and id (`orca_profile_tool.py`, a scripted count) or omit it — one invented count Compute every number and id (`orca_profile_tool.py`, a scripted count) or omit it: one invented count
makes a reader stop trusting the right ones. Report a command's result only if you ran it. makes a reader stop trusting the right ones. Report a command's result only if you ran it. End with a
verdict: can it merge as it stands?
@@ -0,0 +1,254 @@
# Shared bases
Use this when drafting several presets at once, after `fix-variant`, or when sibling presets repeat the
same values. Each value is written once, on the base of the group it is true for, and a selectable
preset holds its identity and what makes it different. The work has two halves: choosing the groups,
which is judgment, and moving the values, which must leave every preset loading exactly what it loaded
before.
## When
- **Drafting** a printer family, a quality ladder or a product line: place each setting at its
[level](#levels) before writing any preset, then write the presets as overrides
([Rule 7](../SKILL.md#rules)).
- **After `fix-variant`.** It resizes each array in the selectable preset that writes it and never
moves or deletes a value ([variant arrays](validation.md#variant-arrays)). A family whose presets all
wrote one value now repeats the widened array in every preset, and a preset that restated what it
inherits now restates it wider.
- **Converting full copies**: a machine that inherits nothing, or a filament that restates fifty-plus
keys, is the style to move away from ([filament style](filament-profiles.md#style)).
Out of scope:
- **BBL.** Its profiles are synced from BambuStudio: a restructure is overwritten by the next sync and
makes every later sync diff unreadable. Fix BBL values in the file that holds them.
- **OrcaFilamentLibrary bases for one vendor's values.** A library base reaches every bundle's
filaments. Put a vendor's shared values on a base in its own bundle (a product `@base`, or a vendor
base that inherits the library's), and change a library base only for a value true of every filament
below it in every bundle.
- **Families the change does not touch.** Restructure the presets you are already changing; a
bundle-wide pass is a change of its own. Commit a restructure without any value change, so `compare`
alone verifies it.
## Nothing loads differently
A restructure changes where values are written, never what a selectable preset loads. Presets keep
their `name` and `setting_id`, and user presets and projects refer to a system preset by name and store
their own changes against what it loads, so an unchanged load changes nothing for users. Prove it with
the bundled helper, from the repository root (every subcommand takes `--profiles <dir>` to work on a
copy of the tree):
```bash
python3 .claude/skills/orca-profiles/scripts/shared_settings.py snapshot <before.json> # before any edit
python3 .claude/skills/orca-profiles/scripts/shared_settings.py compare <before.json> # after: "0 difference(s)", exit 0
```
`snapshot` records every selectable preset of every bundle as the loader stores it: the parent's stored
config, each `include` at the width its file wrote, the preset's own keys, then every variant array
resized to the preset's own variant list ([composition](vendor-bundle.md#inherits-and-include)). It
models the step `check` does not see: a base is stored after its own resize, so an array wider than a
base's list reaches its children cut. It also leaves out what the loader reads from each file and never
hands down (`name`, `type`, `from`, `instantiation`, `inherits`, `include`, `setting_id`,
`renamed_from`, `description`, `version`, `url`, `is_custom_defined`), so those keys never move to a
base. Nor does it record `print_settings_id`, `printer_settings_id` or `filament_settings_id`: the app
replaces them with the selected presets' names before slicing, so a file's value never counts. Delete
them from the presets you restructure rather than moving them to a base.
It does not know the built-in defaults, so `compare` lists a key written on one side only (no file of
the preset's chain writes it on the other) apart from the differences, and exits 1 for either. A
difference is a value the edit changed: undo it, or make it a separate, deliberate change. A one-sided
key is no change only when its written value is the option's default in `PrintConfig.cpp`: confirm
each, as when a preset that loaded the default by leaving a key out must now write it
([Balance 5](#balance)). A value equal to the default needs writing nowhere when no base above writes
another: delete it from the presets rather than moving it, and confirm the one-sided keys.
## Levels
A base stands for a level of the vendor's catalogue, and a setting lives at the level that determines
it. The test for a shared value: if it had to change for one preset of the group, should it change for
all of them? If yes, it is the group's and goes on the group's base. A value that is only equal today
(two unrelated printers with the same acceleration) stays in each preset: a base built on coincidence
is later edited for one preset and silently changes the others. For a default with exceptions
([Balance 5](#balance)), ask the question of the presets that inherit the default.
The tables give each setting's usual level; the test decides for a given bundle: where each toolhead
(Bowden or Direct Drive) has its own default filament, `default_filament_profile` follows the
toolhead, not the nozzle. Levels run
coarse to fine, and a chain need not visit every level: each preset or base inherits the next coarser
level that has a base.
| Machine level | Base | Settings it determines |
| --- | --- | --- |
| Vendor | `fdm_machine_common`, `fdm_<vendor>_common` | the vendor's defaults for every printer |
| Firmware | `fdm_klipper_common`, `fdm_marlin_common` | `gcode_flavor`, G-code that calls the firmware's macros (layer change, pause, filament change), `host_type`, `print_host`, the thumbnail format |
| Hardware family: models that share a frame, motion system, toolhead or extruder layout | `fdm_<vendor>_<family>_common` (`fdm_qidi_x3_common`, `fdm_machine_eryone_ER20_common`) | the `machine_max_*` limits, `extruder_clearance_*`, the toolhead's retraction where every nozzle shares it, `z_hop` and wipe, fitted hardware such as `auxiliary_fan`, the extruder count and per-extruder vectors, the variant layout ([Printer rule 2](extruder-variants.md#printer-machine)) |
| Model: one `machine_model`, which for an IDEX printer includes its mode | the default-nozzle preset where the bundle hangs its other nozzle presets off it; otherwise `fdm_<vendor>_<model>_common` | `printable_area`, `printable_height`, `bed_exclude_area`, the model's start G-code, a COPY or MIRROR mode's settings |
| Nozzle: the selectable preset | none | `printer_model` and `printer_variant`, which name the preset's model and nozzle and stay in every preset as the tree writes them; `nozzle_diameter`, `min_layer_height`, `max_layer_height`, `default_print_profile`, `default_filament_profile`, retraction the vendor tunes per nozzle |
A family may split once more, into toolhead or revision groups that exist only within it:
`fdm_<vendor>_<family>_common` → `fdm_<vendor>_<family>_mk1_common`. A split that crosses another axis is not a
level: when every controller comes with every toolhead, a toolhead base under each controller base
repeats the same values in each ([Balance 6](#balance)).
| Process level | Base | Settings it determines |
| --- | --- | --- |
| Vendor | `fdm_process_common`, `fdm_process_<vendor>_common` | strategy: seam, wall order, infill and support patterns |
| Printer family or variant layout | `fdm_process_<vendor>_<family>_common` (`fdm_process_arena_common`, BBL's `fdm_process_dual_common`) | speeds, accelerations and jerk of that motion system; the variant layout ([Process rule 4](extruder-variants.md#process)) |
| Layer height × nozzle | `fdm_process_<vendor>_<lh>_nozzle_<n>` (BBL's `fdm_process_single_0.20`) | `layer_height`, line widths, shell layers, speeds scaled to the layer |
| Quality × printer: the selectable preset | none | `compatible_printers`, and what is unique to that combination |
| Filament level | Base | Settings it determines |
| --- | --- | --- |
| Material | `fdm_filament_<material>` in OrcaFilamentLibrary, shared by every bundle | material defaults |
| Product | `<Product> @base` | `filament_id` (minted here, [ids](ids.md)), `filament_vendor`, `filament_type`, density, cost, the product's temperatures and cooling |
| Printer or nozzle tune: the selectable preset | none | `compatible_printers` (always in its own file, [Rule 9](../SKILL.md#rules)), volumetric speed, flow ratio, pressure advance and retraction measured on that printer |
**Equal where they must differ is a copy.** A key that follows a finer level, such as the layer-height
limits and line widths that follow the nozzle diameter or `printable_area` that follows the bed, never
moves above that level. When presets that differ in it carry the same value, the value was copied: leave
it in the presets and report it (the [worked example](#worked-example-an-idex-family) has one).
## Balance
1. **One group, one base; use the existing one first.** A base is the home of the presets below it,
whatever its name. In a bundle whose presets are all one family, the vendor base is the family base,
so the family's values go there. In a bundle that hangs the other nozzles off the default-nozzle preset, that preset is the model's home.
Never create a base whose presets are exactly its parent's. Where two existing bases already serve
the same presets (a copied `fdm_machine_common` above the vendor's own base), the finer one is the
home, and merging the pair is a change of its own. Moving a key into an existing home adds no file.
A base value that no preset below it loads is dead: replace it with the group's value when there is
one; otherwise leave it and report it, since a future preset would inherit it.
2. **A new base must stand for a level and pay for itself.** Its file costs five metadata keys and an
index entry, so create it only when it takes `k` keys off `n` selectable presets with
`(n − 1) × k > 6`, where a custom G-code value counts as one key per G-code line: what it removes
must outnumber what it adds. A model with two nozzles that share three short keys keeps them in both.
3. **No ad-hoc bases:** never a base for presets that merely agree (the ones with 0.8 mm retraction),
and never a base with one preset below it.
4. **Keep every selectable preset within four ancestors**, selectable parents included. When a level would push a preset past that, fold it into the level above or leave
its keys in the presets.
5. **A default with exceptions.** A value that only some presets below a shared base load moves to that
base, whichever axis it follows, when two conditions hold. More presets load it than any other value
(on a tie the key stays in the presets), and `w − a > 1`, where `w` presets drop their copy and `a`
presets that take the key from the base and load another value, the built-in default included, must
now write theirs. Presets that write another value keep it and are unaffected. The base then holds
the group's default, and the exceptions stay visible in their own files.
6. **One chain; the other axes stay in the presets.** `inherits` follows one axis. When presets vary
along several (bed size × controller × toolhead), first fill the existing homes by
rules 1 and 5. Then give new bases to the axis whose bases pay most: sum rule 2's count over its
bases, less the keys a re-parented preset must now write because it no longer inherits them from its
old parent; on a tie, follow the layering the bundle already has. Leave the other axes' keys in the
presets, and never repeat one axis's bases under each group of another. Outside BBL, whose synced
presets need theirs, add no `include` template for a second axis: the loader reads `include` only
since #15869 (2026-09-25), and an app that predates it ignores the key, so the template's settings
never reach the preset.
Name a new base after its level ([base names](naming.md#bases)). The name must be unique in its bundle
and must not equal a selectable preset's: two such presets load silently and the first in the index wins
([uniqueness](naming.md#uniqueness)).
## Restated values
`candidates` lists every key a file writes that it would load unchanged without writing it. Delete it
when the value is what the presets below the base that supplies it share: more of them load it,
written or inherited, than any other value. A family that restates machine limits, clearances and
G-code every other printer of the bundle loads from `fdm_klipper_common` drops its copies. When most
presets below that base load another value, the match is a coincidence: keep the key, and move it to
the level of the presets that share it. Keys of the nozzle level stay in the preset in
either case.
After a restructure the report still lists keys that are right where they are: the nozzle-level keys
each preset keeps, and arrays a list-less multi-extruder base writes at its presets' width for
`check --strict`.
## Variant arrays on a base
- The loader stores a base with its variant arrays resized to the base's own variant list, one variant
when it writes none, so an array on a narrower base reaches its presets as its first value, padded.
Move a variant array to a base only when the base declares the presets' variant list (and, for a
machine or process, their ids), moving the layout keys with it as
[Printer rule 2](extruder-variants.md#printer-machine) and [Process rule 4](extruder-variants.md#process)
ask; or when the presets load its first value anyway: every value is equal, or the presets are
list-less machines, which the loader cuts to one variant. `compare` catches a cut.
- Write the array on the base at the width of the presets it serves, their `N`
([sizing equation](extruder-variants.md#sizing-equation)), so `check --strict` judges the right width
where it reaches them. When the presets below a base need different widths (single- and
dual-extruder models on one base), leave the array in the presets, or on bases that each serve one
width.
- On a list-less multi-extruder family base, declare the extruder count: `nozzle_diameter` and the other
per-extruder vectors at one entry per extruder. The base then has its presets' width, and
`fix-variant --strict` writes an array that reaches the presets at another width into the base once,
instead of into every preset.
- Deleting a restated variant array leaves the preset on the inherited array. Plain `check` accepts
that; `check --strict` reports it when the inherited width differs, which is why a bundle held to
`--strict` restates the array at each preset's width.
## Procedure
1. **Snapshot** the tree before any edit, `fix-variant` included. After `fix-variant`, run `compare`: a
difference is a value its padding changed (it repeats the last value, the loader the first). Set that
value deliberately, then snapshot again as the baseline for the restructure.
2. **List the candidates:**
```bash
python3 .claude/skills/orca-profiles/scripts/shared_settings.py candidates --vendor "<Vendor>" --type machine
python3 .claude/skills/orca-profiles/scripts/shared_settings.py candidates --vendor "<Vendor>" --type machine --group-by printer_model
```
It prints the [restated values](#restated-values), then, per base, the keys every selectable preset
below it loads with one value and how many of those presets write it themselves, and under
`default with exceptions` the values that pass [Balance 5](#balance), with `w` and `a`. With
`--group-by <key>` it groups the selectable presets by that key's value instead and names each
group's nearest common base, where a new base would go: `printer_model` for models, `gcode_flavor`
for firmware, `extruder_type` or `default_filament_profile` for toolheads, `filament_id` for
filament products, `layer_height` for processes. The report is evidence, not a plan: it cannot tell
a shared value from a coincidence or a copy.
3. **Decide each key** by [Levels](#levels), [Balance](#balance) and
[Restated values](#restated-values): delete the restatements of shared values, move group values up
to the group's home, and create only the bases that pay.
4. **Edit.** Each new base gets `"type"`, `"name"`, `"from": "system"`, `"instantiation": "false"`, no
`setting_id`, and `inherits` set to the presets' old parent. Point the presets' `inherits` at it and
delete the moved keys from them. Bump the version, then run `normalize`, `update-index` (it orders
parents first) and `generate-id --dry-run`, which must write nothing: bases take no id and presets
keep theirs.
5. **In a bundle held to `--strict`, run `fix-variant --strict` now**, so it writes into the new bases.
6. **Verify.** `compare` prints `0 difference(s)`, and every one-sided key it lists is a default.
`check` reports no error it did not report before, and neither does `check --strict` where the bundle
passes it. Then run the [authoring checks](../SKILL.md#creating-or-modifying-a-profile).
7. **Report** each base added (name, level, presets below it, keys it holds), the keys left in presets
and why, the copies found, and the `compare` result.
## Worked example: an IDEX family
A Klipper bundle's IDEX family has 36 selectable machines (bed size 300, 400 or 500 × normal, COPY or
MIRROR mode × 0.4, 0.5, 0.6 or 0.8 nozzle) that all inherit `fdm_klipper_common` directly and write 49
keys each. `fix-variant` has widened their retraction arrays to two values and their machine limits
to four.
- **Restated.** All 36 restate 10 values that every other printer of the bundle loads from
`fdm_klipper_common`: three machine limits, the three clearances, wipe, `retract_before_wipe`, and the
layer-change and pause G-code. Delete them.
- **Family.** All 36 load one value for 24 more keys: the other machine limits, `extruder_offset`,
retraction, `z_hop`, `single_extruder_multi_material`, `manual_filament_change`, the remaining G-code
except the start G-code, and thumbnails. A new family base, `fdm_<vendor>_<family>_idex_common`,
holds them, plus `nozzle_diameter` `["0.4", "0.4"]` for the extruder count: at least
`(36 − 1) × 24 = 840`.
- **Model.** Each `printer_model` (a bed size in one mode) has four nozzle presets that share
`printable_area`, `printable_height` and a three-line `machine_start_gcode`: `(4 − 1) × 5 = 15`, so
one base per model, nine in all. The family does not hang its other nozzles off a default-nozzle
preset, so the model level here is a base.
- **A coincidence.** The twelve 500 presets' `printable_height` 500 equals `fdm_klipper_common`'s, but
every preset of the bundle writes its own height and 300 is the most common. The 500 is the base's
leftover, not a shared value, so it stays on the model bases.
- **A copy.** In COPY and MIRROR mode, every nozzle of a model carries the 0.4 nozzle's
`min_layer_height`, `max_layer_height` and `retract_lift_below`: 0.06, 0.3 and 0.2 on the 0.8 nozzle,
where normal mode has 0.12, 0.5 and 0.3. `candidates --group-by printer_model` lists the first and
last as shared by the model, and `max_layer_height` as restated from `fdm_klipper_common`, whose value
is also 0.3. They follow the nozzle, so they stay in the presets and are reported for tuning.
- **Nozzle.** Each preset keeps `printer_model`, `printer_variant`, `nozzle_diameter`,
`min_layer_height`, `max_layer_height` and `retract_lift_below` beside its metadata.
- **Result.** 36 full presets become 36 short ones on 10 new bases. `compare` reports 0 differences,
`check --vendor "<Vendor>"` passes as before, and `generate-id --dry-run` writes nothing.
`check --strict` reports more errors than before, because the deleted variant arrays now reach the
presets at `fdm_klipper_common`'s one value. `fix-variant --strict` writes those arrays into the
family base alone, after which `check --strict` passes for the family and `compare` still reports 0
differences.
@@ -2,7 +2,7 @@
```bash ```bash
./scripts/check_profile.sh # everything CI runs ./scripts/check_profile.sh # everything CI runs
./scripts/check_profile.sh --vendor "<Vendor>" # fast loop ./scripts/check_profile.sh --vendor "<Vendor>" # development loop
./scripts/check_profile.sh profile_tool validate_slice # named checks only ./scripts/check_profile.sh profile_tool validate_slice # named checks only
``` ```
@@ -12,153 +12,316 @@ scripts\check_profile.bat -Vendor "<Vendor>"
scripts\check_profile.bat profile_tool validate_slice scripts\check_profile.bat profile_tool validate_slice
``` ```
`check_profile.bat` is a shim around `check_profile.ps1` — same checks, same order, same logs; `check_profile.bat` is a shim around `check_profile.ps1`: same checks, same order, same logs. Its flags
the flags take PowerShell spellings (`-Vendor`, `-ProfilesDir`, `-Validator`, `-Download`, `-Refresh`, take PowerShell spellings (`-Vendor`, `-ProfilesDir`, `-Validator`, `-Download`, `-Refresh`,
`-WorkDir`, `-LogLevel`) and positional check names are unchanged. `-p`, `-v` and `-l` are aliases, so `-WorkDir`, `-LogLevel`), and positional check names are unchanged. `-p`, `-v` and `-l` are aliases,
`-v Elegoo -l 2` reads the same on both platforms. It passes `-ExecutionPolicy Bypass` because a so `-v Elegoo -l 2` reads the same on both platforms. It passes `-ExecutionPolicy Bypass` because a
default Windows client refuses to run a checked-out `.ps1` at all. The `.ps1` finds Python itself, default Windows client refuses to run a checked-out `.ps1` at all. The `.ps1` finds Python itself,
probing `py -3`, then `python`, then `python3`; run the tool by hand with `py -3` for the same reason. probing `py -3`, then `python`, then `python3`; run the tool by hand with `py -3` for the same reason.
Every check in the run happens even after an earlier one fails; the script exits non-zero if any did, and writes Every check runs even after an earlier one fails; the script exits non-zero if any failed, and writes
`logs/<check>.log` plus, on failure, `pr_comment.md` under a per-user cache dir — the same report CI posts on the PR. `logs/<check>.log` plus, on failure, `pr_comment.md` (the same report CI posts on the PR) under a
That dir is `~/Library/Caches/orca-profile-check` on macOS, `${XDG_CACHE_HOME:-~/.cache}/orca-profile-check` on Linux per-user cache dir:
and `%LOCALAPPDATA%\orca-profile-check` on Windows; it is named apart from OrcaSlicer's own per-user dirs and sits
outside the checkout, so every worktree shares one copy. `--work-dir` / `-WorkDir` overrides it. A stale `.lock` | Platform | Cache dir |
there after a crash must be removed by hand. | --- | --- |
| macOS | `~/Library/Caches/orca-profile-check` |
| Linux | `${XDG_CACHE_HOME:-~/.cache}/orca-profile-check` |
| Windows | `%LOCALAPPDATA%\orca-profile-check` |
It is named apart from OrcaSlicer's own per-user dirs and sits outside the checkout, so every worktree
shares one copy and each run overwrites its `logs/`. `--work-dir` / `-WorkDir` overrides it.
When other worktrees or agents may run checks too, pass `--work-dir <a dir of your own>` from the start
and capture the console output yourself: the shared `logs/` can belong to another run by the time you
read them. With `--work-dir`, also pass `--validator` pointing at the cached nightly, so the new dir
does not download it again:
| Platform | Cached validator |
| --- | --- |
| Linux | `<cache dir>/validator/OrcaSlicer_profile_validator` |
| macOS | `<cache dir>/validator/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator` |
| Windows | `<cache dir>\validator\OrcaSlicer_profile_validator.exe` |
Copying the cached `profile-fixtures/` into the new dir reuses the fixture archives; the fixture
`manifest.json` is still downloaded on every run, so `validate_custom` needs the network either way.
`another run is using <dir>` means a live run holds `<dir>/.lock`: leave it and use your own
`--work-dir`. Only a `.lock` with no `check_profile` process alive is a crash leftover; delete it by
hand.
## The five checks ## The five checks
| Check | Command it runs | Catches | | Check | Command it runs | Catches |
| --- | --- | --- | | --- | --- | --- |
| `profile_tool` | `python3 scripts/orca_profile_tool.py check` | index coverage **both ways**, preset-name collisions, files `normalize`/`update-index` would still rewrite, duplicate JSON keys, filament `compatible_printers`, `filament_type` array, conflict keys, id length, **all `setting_id` and `filament_id` rules** | | `profile_tool` | `python3 scripts/orca_profile_tool.py check` | index coverage **both ways**, preset-name collisions, files `normalize` / `update-index` would still rewrite, duplicate JSON keys, filament `compatible_printers`, `filament_type` array, conflict keys, variant strings and array widths and layout keys, dangling `default_materials`, id length, **all `setting_id` and `filament_id` rules** ([below](#orca_profile_toolpy-check)) |
| `validate_system` | `validator -p resources/profiles -l 2` | load errors, missing filament `compatible_printers`, dangling `inherits`/`compatible_*`, duplicate `filament_id` per printer | | `validate_system` | `validator -p resources/profiles -l 2` | load errors, missing filament `compatible_printers`, dangling `inherits` / `compatible_*`, duplicate `filament_id` per printer (`Ambiguous AMS filament match`), printer defaults that name no compatible system filament |
| `validate_slice` | `validator -p … -s -l 2` | custom G-code expansion, unresolvable printer defaults | | `validate_slice` | `validator -p … -s -l 2` | custom G-code expansion and unresolvable printer defaults, by slicing |
| `validate_filament_subtypes` | `validator -p … -l 2 -f` | nothing extra — see below | | `validate_filament_subtypes` | `validator -p … -l 2 -f` | nothing extra; see below |
| `validate_custom` | `validator -p <tree + fixture> -l 2` | a shipped preset name that a past release offered no longer resolving | | `validate_custom` | `validator -p <tree + fixture> -l 2` | a shipped preset name that a past release offered no longer resolving |
**`-f` is a no-op.** It is declared `po::bool_switch()->default_value(true)`, so the duplicate-`filament_id` **`-f` is a no-op.** It defaults to on, so the duplicate-`filament_id` check runs whether or not you
check runs whether or not you pass it — `validate_system` already fails on duplicates. The binary's own pass it, and `validate_system` already fails on duplicates. The binary's own `--help` ("Off unless this
`--help` ("Off unless this flag is present") does not reflect that default. flag is present") does not reflect that default.
### `validate_custom` — the backward-compatibility gate **A `--vendor` run reads differently from CI.** The validator's `-v` loads that vendor plus
OrcaFilamentLibrary and nothing else, so every library tune whose `compatible_printers` names another
vendor's printers fails `validate_system`, `validate_filament_subtypes` and each `validate_custom`
fixture with thousands of `references unknown compatible_printers "Bambu Lab …"` lines. Under
`--vendor`, `profile_tool` and `validate_slice` are the meaningful results; for the other three, filter
the log for your vendor's files and treat only those lines as findings. The unscoped run is the CI
result; run it before the PR.
Downloads one fixture archive per past release (v1.9.0 onwards) of *generated mock* user presets — ### `validate_custom`: the backward-compatibility gate
a `<vendor>_<preset>_orca_test` copy of every system preset that
release shipped, cut with the validator's own `-g 1` mode — unpacks each over a copy of the current tree It downloads one fixture archive per past release (v1.9.0 onwards) of *generated mock* user presets: a
and loads it. Each entry holds only `inherits` plus a canned diff, so the one failure it adds over `<vendor>_<preset>_orca_test` copy of every system preset that release shipped, cut with the
`validate_system` is a shipped preset name disappearing. (The whole current tree sits under each fixture, validator's own `-g 1` mode. It unpacks each over a copy of the current tree and loads it. Each entry
so every `validate_system` error fails it too.) This is what makes a rename or an holds only `inherits` plus a canned diff, so the one failure it adds over `validate_system` is a shipped
`instantiation` flip a CI failure rather than just a user complaint, and the reason `renamed_from` is preset name disappearing. This is what makes a rename, a deletion or an `instantiation` flip a CI
mandatory. failure rather than just a user complaint, and the reason `renamed_from` is mandatory.
The whole current tree sits under each fixture, so every `validate_system` error fails
`validate_custom` too: fix `validate_system` first. Under `--vendor` it copies only the top-level index
files, `<Vendor>/` and `OrcaFilamentLibrary/`, and picks fixtures by the index's display `name`
(`Bambulab` for `BBL`), not the file stem; fixture presets without that prefix are covered only by an
unscoped run, and it warns `validate_custom checked nothing` when none match.
### `validate_slice` ### `validate_slice`
Slices a two-colour cube on every instantiable printer in the tree, sequentially, forcing the prime tower. It slices a two-colour cube on every instantiable printer in the tree, sequentially, forcing the prime
It selects `default_print_profile` and the first `default_filament_profile`, then updates compatibility; tower. It selects `default_print_profile` and the first `default_filament_profile`, then updates
that update can select a different compatible preset. Confirm the intended defaults yourself rather compatibility; that update can select a different compatible preset. Confirm the intended defaults
than treating a passing sweep as proof that those exact presets were sliced. yourself rather than treating a passing sweep as proof that those exact presets were sliced.
A printer fails if it cannot be selected, falls back to a Default preset, throws, produces no g-code, or
emits no `CP TOOLCHANGE START`. It cannot be scoped to a filament-only vendor A printer fails if it cannot be selected, falls back to a Default preset, throws, produces no G-code,
(`No instantiable printer presets found for vendor OrcaFilamentLibrary`); `check_profile.sh` records it or emits no `CP TOOLCHANGE START` (`change_filament_gcode` never expanded). Non-default processes and
as SKIP for a vendor with no `machine/` folder. filaments get no dedicated coverage; [slice them on a copy](#checking-a-copy-of-the-tree). In the
default set, a bundle without a `machine/` folder is recorded as SKIP; naming `validate_slice`
explicitly for it fails (`No instantiable printer presets found for vendor OrcaFilamentLibrary`). The
validator logs `[error]` lines that do not fail a check (such as `could not found extruder_type`); only
each check's PASS or FAIL counts.
## `orca_profile_tool.py check` ## `orca_profile_tool.py check`
`check` is one subcommand of the tool that also owns `check` is one subcommand of the tool that also owns `fix-variant`, `generate-id`, `normalize`,
`generate-id`, `normalize`, `trim` and `update-index`; see [ids.md](ids.md) for the writing half. `trim` and `update-index`; [ids.md](ids.md#the-tool) has the writing half.
| Per vendor | Catches | | Catches | Scope | Function in the tool |
| --- | --- | | --- | --- | --- |
| `check_preset_name_uniqueness` | two files in one bundle claiming one type + name — indexed or not | | two files in one bundle claiming one type + name, indexed or not | per vendor | `check_preset_name_uniqueness` |
| `check_index_coverage` | a file on disk that no `*_list` references (**an error, not a warning**) | | a file on disk that no `*_list` references (**an error, not a warning**) | per vendor | `check_index_coverage` |
| `check_name_consistency` | an index entry whose `name` disagrees with the file, or whose `sub_path` is missing | | an index entry whose `name` disagrees with the file, or whose `sub_path` is missing | per vendor | `check_name_consistency` |
| `check_normalized` | a file `normalize` would rewrite, and an index `update-index` would rebuild | | a file `normalize` would rewrite, an index `update-index` would rebuild | per vendor | `check_normalized` |
| `check_filament_compatible_printers` | an instantiated non-library filament with no `compatible_printers` of its own | | duplicate JSON keys in a file | every file read | the JSON loader |
| `check_conflict_keys` | `extruder_clearance_radius` alongside `extruder_clearance_max_radius` | | an instantiated non-library filament with no non-empty `compatible_printers` of its own | per vendor | `check_filament_compatible_printers` |
| `check_vector_type_keys` | a vector option written as a scalar (`"filament_type": "PLA"`) | | `extruder_clearance_radius` alongside `extruder_clearance_max_radius` | per vendor | `check_conflict_keys` |
| `check_filament_id_length` | a declared `filament_id` longer than 8 characters | | a scalar `filament_type` (`"filament_type": "PLA"`); the five other filament vectors `normalize` arrayifies surface as `normalize would convert <field> to an array` | per vendor | `check_vector_type_keys`, `check_normalized` |
| `check_machine_default_materials` | every `default_materials` / `default_filament_profile` name resolves | | a variant string the two enums cannot build (a dead variant, a legacy spelling included), a variant list naming one variant twice, an `extruder_type`, `nozzle_volume_type` or `default_nozzle_volume_type` that is not an enum name ([variant names](#variant-names)) | per vendor | `check_variant_names` |
| `check_obsolete_keys` | per-key warnings for ignored options; **filament files only** | | a variant array not exactly `variant length × stride` wide in a selectable preset that writes it (with `--strict`, also in one it reaches); machine variant layout keys that disagree; a process id array that does not pair each variant ([variant arrays](#variant-arrays)) | per vendor | `check_variant_arrays` |
| a declared `filament_id` longer than 8 characters | per vendor | `check_filament_id_length` |
| a `default_materials` name, or a `default_filament_profile` name, matching no filament file ([below](#default-material-references)) | per vendor | `check_machine_default_materials` |
| per-key warnings for ignored options, **filament files only** | per vendor | `check_obsolete_keys` |
| `setting_id` uniqueness, every `filament_id` rule, and `machine_model` names duplicated across bundles | **tree-wide, ignoring `--vendor` entirely** | `check_setting_id_uniqueness`, `check_filament_ids`, `check_machine_model_name_uniqueness` |
Tree-wide, **ignoring `--vendor` entirely**: `check_setting_id_uniqueness` and `check_filament_ids`. So a Because the id and model-name checks stay tree-wide, a vendor-scoped run can and does fail on another
vendor-scoped run can and does fail on another vendor's files — and it saves seconds, not minutes. vendor's files, and it saves seconds, not minutes.
Unscoped, the per-vendor pass covers every bundle. The only exclusion is the stray `user/` directory Unscoped, the per-vendor pass covers every bundle. The only exclusion is the stray `user/` directory
(see below); `OrcaFilamentLibrary` is held to the same rules as any vendor, its sole exemption being (below); OrcaFilamentLibrary is held to the same rules as any vendor, its sole exemption being that a
that a library filament may leave `compatible_printers` empty — exactly what library filament may leave `compatible_printers` empty. `check_normalized` covers every bundle with an
`check_filament_compatible_printers` allows. `check_normalized` covers every bundle with an index. index.
Notes that matter: Notes that matter:
- Exit codes: **0** clean, **1** errors found, **2** argparse misuse. Warnings never change the exit code. - Exit codes: **0** clean, **1** errors found, **2** argparse misuse. Warnings never change the exit
- A nonexistent `--vendor` is a hard error — `[ERROR] unknown vendor "<V>" in <dir>`, exit 1. code.
- `--vendor ""` means all vendors; `check_profile.sh` relies on that. `--vendor` is repeatable. - A nonexistent `--vendor` is a hard error: `[ERROR] unknown vendor "<V>" in <dir>`, exit 1.
- A **stray directory** under `resources/profiles/` still gets counted as a vendor by the per-vendor pass - `--vendor ""` means all vendors; `check_profile.sh` relies on that. `--vendor` is repeatable
and warned about (`No profiles found for vendor: <dir> at …/<dir>.json`, and the "Checked vendors" count (`check --vendor A --vendor OrcaFilamentLibrary`); `check_profile.sh` takes one.
goes up by one). The one exception is `user/`, the validator's data dir, which an unscoped `check` - A **stray directory** under `resources/profiles/` still gets counted as a vendor by the per-vendor
skips by name; `--vendor user` still checks and warns about it. Warnings never change the exit code. pass and warned about (`No profiles found for vendor: <dir> at …/<dir>.json`, and the "Checked
`normalize`, `trim` and `update-index` ignore strays too — they define a bundle as *a directory with a vendors" count goes up by one): usually an emptied folder, or a `user/` left by a direct validator
run. An unscoped `check` skips `user/` by name; `--vendor user` still checks and warns about it.
`normalize`, `trim` and `update-index` ignore strays too: they define a bundle as *a directory with a
matching index file*. matching index file*.
- Each remedy is printed once for the whole run, not once per file, as a `[WARNING]` under the errors - Each remedy is printed once for the whole run, not once per file, as a `[WARNING]` under the errors
("2 unreferenced file(s) above: delete them, or run … update-index"). Read those lines: they name the (`2 unreferenced file(s) above: delete them, or run … update-index`). Read those lines: they name the
command that fixes the batch. command that fixes the batch.
- The trailing summary always suggests `normalize`. That is right for the shape errors and misleading for - When there are errors or warnings, the trailing summary suggests `normalize`. That is right for the
everything else — an id error needs `generate-id`, a dangling `default_materials` needs a human. shape errors and misleading for everything else: an id error needs `generate-id`, a dangling
`default_materials` needs a human.
- Other options: `--dry-run` on every writing command, `--profiles DIR` to point any command at another
tree, `--profile-type` to narrow `normalize`, `trim` and `update-index` to one type
([ids.md](ids.md#the-tool)).
- `resources/profiles/check_unused_setting_id.py` is a legacy BBL-only diagnostic, not part of - `resources/profiles/check_unused_setting_id.py` is a legacy BBL-only diagnostic, not part of
profile CI. Use `orca_profile_tool.py check` for current id validation. profile CI. Use `orca_profile_tool.py check` for current id validation.
### Obsolete-key diagnostics ### Obsolete keys
`check` always reports per-key warnings for obsolete options in filament profiles. `check` always reports per-key warnings for obsolete options in filament profiles. Its normalization
The normalization check also rejects obsolete keys across preset types; `normalize` removes them. check also rejects obsolete keys across all preset types (`normalize would remove <key>`); `normalize`
removes them.
### Default-material references ### Default-material references
The materials check finds `default_materials` / `default_filament_profile` entries naming a preset The materials check finds `default_materials` / `default_filament_profile` entries naming a preset that
that does not exist. The three authoring errors it surfaces are `,` instead of `;`, wrong case does not exist. It reads each `machine/` file's own key (a model's `default_materials`, a variant's
(`@system`), and a whole `;`-joined string stuffed into one array element. `default_filament_profile`; a file that writes both is checked on `default_materials` only) and accepts
any `name` found in the vendor's or OrcaFilamentLibrary's `filament/` files, bases and unindexed files
included. The three authoring errors it surfaces are `,` instead of `;`, wrong case (`@system`), and a
whole `;`-joined string stuffed into one array element. Only the validator (`validate_system`) requires
an instantiated system filament that is compatible with each variant.
### Variant arrays
`check_variant_arrays` composes every selectable preset the loader's way (the parent, then each
`include` in order, then the file's own keys; a filament's `inherits` may fall through to
OrcaFilamentLibrary) and holds each key of [the four variant sets](extruder-variants.md#the-four-key-sets)
that the preset writes itself to exactly its `variant length × stride`
([widths](extruder-variants.md#widths)). The variant length is the length of the composed preset's
own `*_extruder_variant` list; without one, a machine's is the number of variants its
`extruder_variant_list` offers, else its extruder count (the list's default is one
`Direct Drive Standard` per extruder), and a process's or filament's is 1. Any other width is an
error, one value included and even when every value is the same. A key the preset does not write
takes what reaches it, the default or an array it inherits or includes, which the loader resizes; it
is not checked. A base is not judged on its own: what it writes counts only where it reaches a preset
that does not override it.
`check --strict` also holds every selectable preset to its own width for each key that reaches it,
so a preset whose variants differ from those of the file its array comes from restates the array
(the error names that file). That is BBL's practice and the target for new printer-specific presets;
CI runs `check` without `--strict`.
An id array that reaches a selectable preset, `printer_extruder_id` or `print_extruder_id`, written or
inherited, must have one entry per entry of its variant list on any printer. This rule and the
machine layout rules below judge the composed preset without `--strict`, whichever file writes the
keys. Beside a written variant list this rule reports it instead of the width
rule, so it is reported once. A process that
lists variants without `print_extruder_id` gets a **warning** when a variant repeats (every entry then
reads as extruder 1, so the repeated variant is unreachable), nothing otherwise.
On a machine the layout keys are held to [Printer rules 1–4](extruder-variants.md#printer-machine),
every failure an error unless marked:
- With `extruder_variant_list` written: the list has one entry per extruder, as many as
`nozzle_diameter`; every variant starts with its extruder's `extruder_type`;
`default_nozzle_volume_type` names a nozzle volume type that extruder lists;
`printer_extruder_variant` is the list flattened extruder-major and `printer_extruder_id` gives each
entry its 1-based extruder (an id array left out reads as extruder 1 everywhere, which passes when
those are the flattening's ids). Without the pair, the list may offer one variant in total.
- With the pair written and no `extruder_variant_list`: one variant per extruder at most. A pair that
`single_extruder_multi_material` off would replace with the default at load is a **warning**; a pair
the rebuild would leave as it is passes.
It does **not** see a base's own resize: it composes at the width each file wrote, so an array wider
than a base's list, which the loader cuts before any child inherits it, passes even with `--strict`
([composition](extruder-variants.md#padding-truncation-and-composition)). Nor does it see per-extruder
vectors outside the sets (`extruder_offset`, `printer_extruder_options`, …), which no variant list
sizes. What a variant holds (a High Flow variant copied from Standard, a variant inserted at the wrong
index) is review work
([item 14](review-checklist.md#14-per-extruder-vectors-not-checked-and-variant-arrays-widths-and-layout-checked));
whether it is a name the engine can select at all is `check_variant_names`'.
`python3 scripts/orca_profile_tool.py fix-variant` resizes every array the width rule rejects in the
selectable preset that writes it, leaves bases alone and adds no key: extra values are dropped, missing ones
repeat the last value (the last normal/silent pair at stride 2; a lone value fills normal and silent
alike). It leaves the variant lists and id arrays to you, since they address the variants rather than
fill them. `fix-variant --strict` then also writes each key that reaches a selectable preset at
another width: into the most general file on the way down to the preset whose own width is the
preset's and whose selectable presets taking it all need that width, else into the preset itself.
Presets of every bundle count towards that agreement; `--vendor` limits the files written and
`--dry-run` previews. The loader pads with the first value where `fix-variant` repeats the last, so a
padded array need not load as before, and
trimming deletes values: when the extra values were meant as per-extruder or per-variant values,
declare the variant layout instead ([Printer rule 2](extruder-variants.md#printer-machine)) and keep
them. `fix-variant` moves no value, so a family whose presets all wrote one value repeats the widened
array in each; put it on the family's base afterwards ([shared bases](shared-bases.md)).
### Variant names
`check_variant_names` reads the four list keys plus `extruder_type`, `nozzle_volume_type` and
`default_nozzle_volume_type` of every preset the bundle's index references, bases included, and holds
each entry to the names the engine's two enum maps define (`s_keys_map_ExtruderType`,
`s_keys_map_NozzleVolumeType`, read from `PrintConfig.cpp` on every run). The bundle's own files are
judged, not the composed config: a bad string is the writing file's error, once. Every finding is an
error, and no bundle is exempt: BBL, whose bundle is imported from BambuStudio, is held to OrcaSlicer's
enums like any other.
- A variant string outside `<extruder type> <nozzle volume type>` is a **dead variant**: it still
counts toward the variant length the arrays are sized by, so the values written for it silently never
reach the G-code. `Hybrid` too, which is runtime-only, and an empty entry. A name BambuStudio's enum
has and OrcaSlicer's lacks is dead here as well, and passes with no tool change once the engine gains
that nozzle volume type.
- A legacy name the loader still rewrites in these keys (`Normal` → `Standard`, `Big Traffic` →
`High Flow`) is an error that names the enum name to write; the profile has to spell the enum
name. `DirectDrive` is only rewritten in `extruder_type`, so a variant string carrying it is dead.
- A variant list naming one variant twice is an error: the lookup returns the first equal string, so
the repeat is unreachable and its value sits at an index no extruder reads. A filament list takes
strings, `extruder_variant_list` takes them per extruder, and a process takes `(extruder id, variant)`
pairs — one string on two extruders is two pairs, not a repeat.
- An `extruder_type`, `nozzle_volume_type` or `default_nozzle_volume_type` value that is not an enum
name is an error: they are enum options, so an unknown value fails the validator's load of the
whole bundle, while the app silently loads the option's default instead. A legacy spelling
(`DirectDrive`, `Normal`, `Big Traffic`) and `Hybrid`, an enum value no profile writes, are errors
too.
The variant *order*, the choice of variants, and the values themselves are not checked.
### `normalize` and `update-index` are part of the check ### `normalize` and `update-index` are part of the check
`check` fails when either command would still change something, so they are not optional polish — the `check` fails when either command would still change something, so they are not optional polish: the
file that gets reviewed has to be the file that ships. What `normalize` changes is narrow and fixed: file that gets reviewed has to be the file that ships. What `normalize` changes is narrow and fixed:
adds a missing `type`, deletes a `version` or `is_custom_defined` key from a *preset* file, deletes six
print-speed keys from filament profiles (`initial_layer_print_speed`, `outer_wall_speed`, - adds a missing `type`;
`inner_wall_speed`, `infill_speed`, `top_surface_speed`, `travel_speed`), deletes the - deletes a `version` or `is_custom_defined` key from a *preset* file;
obsolete keys in `PrintConfigDef::handle_legacy`'s `ignore` set across preset types, resolves the - deletes six print-speed keys from filament profiles (`initial_layer_print_speed`, `outer_wall_speed`,
`extruder_clearance_*` conflict pair by keeping the larger, arrayifies five filament options besides `inner_wall_speed`, `infill_speed`, `top_surface_speed`, `travel_speed`);
`filament_type`, and hoists `type`, `name`, `renamed_from`, `inherits`, `from`, `setting_id`, - deletes the obsolete keys the loader ignores (the `ignore` set in `PrintConfigDef::handle_legacy`),
`filament_id`, `instantiation` to the front. A file it changes is then rewritten whole — tab-indented, across preset types;
LF, one trailing newline, keys reordered. - resolves the `extruder_clearance_*` conflict pair by keeping the larger;
- arrayifies six filament options (`filament_type`, `filament_cost`, `filament_density`,
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`);
- hoists `type`, `name`, `renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`,
`instantiation` to the front.
A file it changes is then rewritten whole: tab-indented, LF, one trailing newline, keys reordered. A
file committed with CRLF line endings therefore changes on every line; read the diff before committing
it.
**Set `type` explicitly when authoring.** For a file in `machine/` without it, normalization guesses **Set `type` explicitly when authoring.** For a file in `machine/` without it, normalization guesses
`machine` only if its name contains `nozzle`, otherwise `machine_model`. That heuristic cannot `machine` only if its name contains `nozzle` (case-insensitive), otherwise `machine_model`. That
reliably classify shared machine bases or unusually named variants. heuristic cannot reliably classify shared machine bases or unusually named variants.
The Python obsolete-key set is checked against the C++ source by a unit test. Active options The tool's obsolete-key set is checked against the loader's ignore list by a unit test. Active options
and legacy aliases that the loader migrates (such as `extruder_type` and are preserved, including live keys whose *values* the loader rewrites (`extruder_type`: `DirectDrive` →
`extruder_clearance_max_radius`) are preserved. `Direct Drive`; the variant-string keys: `Normal` / `Big Traffic` → `Standard` / `High Flow`), and so are
legacy key names the loader migrates (such as `extruder_clearance_max_radius`).
Two things it therefore does **not** enforce: Two things it therefore does **not** enforce:
- **Formatting and key order on their own.** A file with none of those problems is skipped entirely, so - **Formatting and key order on their own.** A file with none of those problems is skipped entirely, so
4-space indent, a missing trailing newline, and a file that leads with `compatible_printers` all pass 4-space indent, a missing trailing newline, and a file that leads with `compatible_printers` all pass
`check`. They stay latent until something else trips `normalize` and the whole file reformats inside an `check`. They stay latent until something else trips `normalize` and the whole file reformats inside
unrelated diff. (`normalize --force` rewrites every file, which is not something to run on a shipped an unrelated diff. (`normalize --force` rewrites every file; do not run it on a shipped bundle.)
bundle.)
- **A misspelled setting key.** `inital_layer_height` and `sparse_infill_densiti` pass `check` cleanly. - **A misspelled setting key.** `inital_layer_height` and `sparse_infill_densiti` pass `check` cleanly.
Verify new keys against `PrintConfig.cpp` and `PrintConfigDef::handle_legacy`. Verify new keys against `src/libslic3r/PrintConfig.cpp` and the loader's legacy handling
(`PrintConfigDef::handle_legacy`: renamed keys, rewritten values and ignored keys).
`check` does not catch a dangling `default_print_profile` either; check that name by hand.
## The validator binary ## The validator binary
Built from `src/dev-utils/OrcaSlicer_profile_validator.cpp` (`-DORCA_TOOLS=ON`). Built from `src/dev-utils/OrcaSlicer_profile_validator.cpp` with `-DORCA_TOOLS=ON`. Both scripts use a
Both scripts find a local build under `build*/` — `check_profile.sh` tries Release, RelWithDebInfo, then local build under `build*/` when one exists, else they download the nightly into the `validator`
Debug, and `check_profile.ps1` adds MinSizeRel — else they download the nightly into the subdirectory of the cache dir. `check_profile.sh` searches Release, then RelWithDebInfo, then Debug
`validator` subdirectory of the per-user cache dir (see above). Pass `--download` / `-Download` to (each under `build*/src/<config>` and `build*/*/src/<config>`), then single-config `build*/src`, and
match CI exactly, since a stale local build is used silently. Windows looks for prefers a host-architecture build tree; `check_profile.ps1` tries Release, RelWithDebInfo, MinSizeRel,
then Debug. A stale local build is used silently; `--download` / `-Download` skips local builds and uses the
nightly. The download and the fixtures stay cached until `--refresh` / `-Refresh`, so
`--download --refresh` (`-Download -Refresh`) matches CI exactly. Windows looks for
`OrcaSlicer_profile_validator.exe`. `OrcaSlicer_profile_validator.exe`.
If your build lives somewhere else entirely, point at it with `--validator` / `-Validator`, or set If your build lives somewhere else, point at it with `--validator` / `-Validator`, or set
`ORCA_PROFILE_VALIDATOR` (`$env:ORCA_PROFILE_VALIDATOR` in PowerShell). `ORCA_PROFILE_VALIDATOR` (`$env:ORCA_PROFILE_VALIDATOR` in PowerShell).
| Flag | Meaning | | Flag | Meaning |
@@ -167,11 +330,12 @@ If your build lives somewhere else entirely, point at it with `--validator` / `-
| `-l <n>` | log level; CI uses 2 | | `-l <n>` | log level; CI uses 2 |
| `-v <Vendor>` | load only that vendor **plus** OrcaFilamentLibrary | | `-v <Vendor>` | load only that vendor **plus** OrcaFilamentLibrary |
| `-s` | slice sweep | | `-s` | slice sweep |
| `-o <dir>` | with `-s`, save each printer's G-code there |
| `-f` | no-op (see above) | | `-f` | no-op (see above) |
| `-g 1` | regenerate user-preset fixtures; takes a value, and wipes the user preset dir first | | `-g 1` | regenerate user-preset fixtures; takes a value, and wipes the user preset dir first |
On ARM64 Linux the nightly is x86-64 only — the script warns and downloads anyway, producing a binary On ARM64 Linux the nightly is x86-64 only: the script warns and downloads anyway, producing a binary
that will not run. Build it locally instead. that will not run. Build it locally with `-DORCA_TOOLS=ON` and pass `--validator`.
Running the validator directly uses the profile tree as its data directory and can create `user/` Running the validator directly uses the profile tree as its data directory and can create `user/`
there. Prefer the wrappers, which stash existing user presets and restore them afterward. After a there. Prefer the wrappers, which stash existing user presets and restore them afterward. After a
@@ -185,16 +349,27 @@ Use `--profiles DIR` on the Python tool and `-p DIR` on the validator. The wrapp
```bash ```bash
./scripts/check_profile.sh --profiles "<tree>" ./scripts/check_profile.sh --profiles "<tree>"
# Windows: scripts\check_profile.bat -ProfilesDir "<tree>"
``` ```
On Windows use `scripts\check_profile.bat -ProfilesDir "<tree>"`. To slice a process or filament that is not a printer's default, or to read the G-code, work on a copy:
copy `resources/profiles` and `resources/info` into one scratch dir (the validator reads `info/` next to
the tree), point the printer's `default_print_profile` or first `default_filament_profile` at the preset
in the copy, and run the validator with `-o`:
```bash
<validator> -p <scratch>/profiles -v "<Vendor>" -s -o <gcode dir>
```
Each printer's G-code is saved as `<vendor name>__<printer>.gcode`; its embedded config
(`; print_settings_id = …`, `; filament_settings_id = …`) shows what was actually sliced.
## Testing in the app ## Testing in the app
Editing this checkout's `resources/profiles` does not update a separately installed application. Editing this checkout's `resources/profiles` does not update a separately installed application. Test
Test with a build using the edited resources and a bumped bundle version; the updater installs newer with a build using the edited resources and a bumped bundle version: the updater installs newer bundles
bundles under `<data_dir>/system/`, and the preset cache also depends on the bundle version. under `<data_dir>/system/`, and the preset cache also depends on the bundle version. Use Help ▸ Show
Use Help ▸ Show Configuration Folder to locate the active data directory: Configuration Folder to locate the active data directory:
| Platform | Default data directory | | Platform | Default data directory |
| --- | --- | | --- | --- |
@@ -202,58 +377,71 @@ Use Help ▸ Show Configuration Folder to locate the active data directory:
| Linux | `$XDG_CONFIG_HOME/OrcaSlicer`, or `~/.config/OrcaSlicer` when unset | | Linux | `$XDG_CONFIG_HOME/OrcaSlicer`, or `~/.config/OrcaSlicer` when unset |
| Windows | `%APPDATA%\OrcaSlicer` | | Windows | `%APPDATA%\OrcaSlicer` |
A portable `data_dir` next to the executable takes precedence. Use a separate test configuration A portable `data_dir` next to the executable takes precedence. Use a separate test configuration for a
for a clean-install check; preserve the normal configuration and user presets. clean-install check; preserve the normal configuration and user presets.
## Cross-platform paths
Match the exact case of each `sub_path` and asset filename; Linux filesystems commonly distinguish
case even when a macOS or Windows checkout does not. Preset-name references are case-sensitive
on every platform. Avoid Windows-invalid characters (`< > : " | ? *`), reserved device names
such as `CON` / `NUL` (including with extensions), and trailing spaces or dots in path components.
Keep stems tidy too, but a space immediately before `.json` is not a trailing path-component space.
## Error → remedy ## Error → remedy
| Message | Fix | | Message | Fix |
| --- | --- | | --- | --- |
| `can not find inherits <parent> for <preset>` | parent missing, unregistered, or listed **after** the child | | `can not find inherits <parent> for <preset>` | parent missing, unregistered, misspelled, or listed **after** the child |
| `can not find filament_id for <name>` | nothing in the chain declares one — run `generate-id` | | `can not find include` | the template is misspelled, registered after the includer, or selectable |
| `can not find parent <name> for config <user preset>!` | a shipped name disappeared — add `renamed_from` | | `can not find filament_id for <name>` | nothing in the chain declares one: run `generate-id` |
| `can not find parent <name> for config <user preset>!` | a shipped name disappeared: add `renamed_from` |
| `Failed loading configuration file <file>` | that file could not be loaded and the whole bundle was discarded: a JSON error, or a value its option cannot take, such as `nil` in a non-nullable key (`Invalid value provided for parameter <key>: nil`, `Deserializing nil into a non-nullable object`); the lines above it name the cause |
| `Missing instantiation attribute for <name>` | key absent **or** not the string `"true"` / `"false"` | | `Missing instantiation attribute for <name>` | key absent **or** not the string `"true"` / `"false"` |
| `contains incorrect keys: <keys>, which were removed` | a key valid for a different preset type | | `contains incorrect keys: <keys>, which were removed` | a key valid for a different preset type |
| `defines invalid printer variant "<v>"` | not in the model's `nozzle_diameter` list | | `defines invalid printer variant "<v>"` | not a token of the model's `nozzle_diameter` list |
| `has printer_variant "<v>" that does not match its nozzle_diameter` | the set comparison in [machine-profiles.md](machine-profiles.md) | | `has printer_variant "<v>" that does not match its nozzle_diameter` | [the set comparison](machine-profiles.md#printer_model-and-printer_variant) |
| `references unknown compatible_printers "<p>"` | the printer was renamed or deleted; fix the reference | | `references unknown compatible_printers "<p>"` | the printer was renamed or deleted, or a `machine_model` name was used instead of a variant name: fix the reference. Under `--vendor`, usually another vendor's printer ([why](#the-five-checks)) |
| `references renamed compatible_printers "<old>" (now "<new>")` | in-tree references must name the current preset; `renamed_from` does not excuse them | | `references renamed compatible_printers "<old>" (now "<new>")` | in-tree references must name the current preset; `renamed_from` does not excuse them |
| `Filament preset "<f>" is missing compatible_printers setting` | non-library filaments need a non-empty list in their **own** file — the flattened-vs-own-key trap is in [filament-profiles.md](filament-profiles.md#compatible_printers) | | `Filament preset "<f>" is missing compatible_printers setting` | non-library filaments need a non-empty list in their **own** file; the resolved-vs-own-key trap is in [filament-profiles.md](filament-profiles.md#compatible_printers) |
| `Ambiguous AMS filament match: N presets share filament_id "X" … printer "Y"` | make the lists disjoint by [specificity](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product) — the specialized profile keeps the variant, the general ones drop it; prefer this over deleting a profile — or fix an `inherits` pointing at another material's `@base`. `orca_profile_tool.py check` does not catch this; only `validate_system` here does | | `Ambiguous AMS filament match: N filament presets share filament_id "X" and are all compatible with printer "Y"` | make the lists disjoint by [specificity](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product): the specialized profile keeps the variant, the general ones drop it; prefer this over deleting a profile. Or fix an `inherits` pointing at another product's `@base`. `orca_profile_tool.py check` does not catch this; only `validate_system` does |
| `Layer height cannot exceed nozzle diameter.` / `Line width too small` | `Print::validate()` flow rules | | `Layer height cannot exceed nozzle diameter.` / `Line width too small` / `Line width too large` | the [slicing limits](process-profiles.md#values-to-review-per-nozzle) |
| `[ERROR] … no <V>.json list references it, so it never loads` | `update-index`, or delete the file | | `[ERROR] … no <V>.json list references it, so it never loads` | `update-index`, or delete the file |
| `[ERROR] … references it and it declares no profile type` | set the correct `type` explicitly, then `normalize` and `update-index` | | `[ERROR] … no <V>.json list references it and it declares no profile type` | set the correct `type` explicitly, then `normalize` and `update-index` |
| `[ERROR] … normalize would <change>` / `<V>.json: update-index would rebuild <lists>` | run that command and commit the result | | `[ERROR] … normalize would <change>` / `<V>.json: update-index would rebuild <lists>` | run that command and commit the result |
| `[ERROR] <V> has N <type> profiles named "<name>"` | identify the intended preset and remove or rename the duplicate; use `trim --dry-run` only for deliberate unindexed-file cleanup | | `[ERROR] <V> has N <type> profiles named "<name>"` | identify the intended preset and remove or rename the duplicate; use `trim --dry-run` only for deliberate unindexed-file cleanup |
| `[ERROR] … must not have a setting_id` / `is missing a setting_id` | `generate-id --setting-id` | | `Duplicate key error in <file>: Duplicate key detected: <key>` | a key written twice in one file; keep the intended one |
| `inherits filament_id "X" but its own triple … mints "Y"` | `generate-id` will **not** fix this — see [ids.md](ids.md) | | `… must not have a setting_id` / `… is missing a setting_id` / `setting_id "X" in <file> does not match the expected "Y" …` | `generate-id --setting-id` (by hand in `BBL/`, see [ids.md](ids.md#bbls-exception-precisely)) |
| `vendor <V>'s config version: <s> invalid` | the `version` string is not Semver-parseable | | `filament_id "X" declared by … does not match the mint of its triple …` | `generate-id --filament-id`; if the id should not be declared here at all, remove it so the preset inherits its root's |
| `[json.exception.type_error.302] type must be string` | locate the non-string value in the index or model; see [failure scopes](vendor-bundle.md#failure-modes-ranked-by-blast-radius) | | `inherits filament_id "X" but its own triple "V/T/N" mints "Y"` | `generate-id` will **not** fix this; see [ids.md](ids.md#what-generate-id-does-and-does-not-fix) |
| `Printer "<p>" fell back to a default preset` | final process or filament selection is a generic Default preset; check named defaults, visibility and available compatible presets. An incompatible default may instead be replaced without this error | | `"<key>" has N values for variant length S at stride k, which takes M` (with ` (no <list key>, so …)` after `S`, naming where `S` came from, when the preset writes no list, and ` (it comes from <file>)` at the end under `--strict` for an array the preset does not write) | exactly `S × k` values in variant order, or leave the key out ([widths](extruder-variants.md#widths)); on a list-less multi-extruder printer whose extruders differ, declare the layout; `fix-variant` cuts or pads to that width ([variant arrays](#variant-arrays)); an `S` you did not expect means the variant list did not resolve through `include` or `inherits` |
| `Printer "<p>" sliced but the filament change never fired` | `change_filament_gcode` never expanded | | `printer_extruder_variant […] is not extruder_variant_list flattened extruder-major […]` / `printer_extruder_id […] does not give each entry of printer_extruder_variant its 1-based extruder […]` / `printer_extruder_id has N entries for the M entries of printer_extruder_variant` | write the pair as the flattening of the list, ids in step ([Printer rule 2](extruder-variants.md#printer-machine)) |
| `extruder_variant_list has N entries for M extruder(s)` / `extruder_variant_list entry i "…" holds a variant that does not start with extruder i's extruder_type` / `default_nozzle_volume_type "…" is not a nozzle volume type extruder i lists` | [Printer rules 1, 3 and 4](extruder-variants.md#printer-machine) |
| `extruder_variant_list offers N variants but the preset writes no printer_extruder_variant/printer_extruder_id` / `printer_extruder_variant lists several variants for extruder N but the preset writes no extruder_variant_list` / `[WARNING] … with single_extruder_multi_material off the loader replaces printer_extruder_variant …` | write all three layout keys ([Printer rule 2](extruder-variants.md#printer-machine)) |
| `print_extruder_id has N entries for the M entries of print_extruder_variant` / `[WARNING] … print_extruder_variant repeats a variant but print_extruder_id is absent` | one id per variant entry, mirroring the printer's pairs ([Process rule 1](extruder-variants.md#process)) |
| `<key> <where> holds "…", which no extruder can select: "…" is not a nozzle volume type the enum has (…)` / `… it does not start with an extruder type the enum has (…)` / `… is empty` / `… Hybrid names the sub-nozzles of one hybrid extruder at runtime` | write a legal variant string, `<extruder type> <nozzle volume type>` from the two enums ([variant names](#variant-names), [variant strings](extruder-variants.md#variant-strings)); in every bundle, BBL included |
| `… holds the legacy variant "…"` / `extruder_type spells the legacy name "…"` / `nozzle_volume_type spells the legacy name "…"` / `default_nozzle_volume_type spells the legacy name "…"` | write the enum name the message gives: the loader still rewrites the legacy one, but `check` rejects it |
| `<key> lists "…" N times` / `print_extruder_variant lists the pair (extruder N, "…") N times` | drop the repeat and its value from every variant array: the lookup returns the first equal string |
| `extruder_type "…" is not one of (…)` / `nozzle_volume_type "…" is not one of (…)` / `default_nozzle_volume_type "…" is not one of (…)` / `… names "Hybrid", which the engine computes for a hybrid extruder at runtime` | they are enum options, so an unknown value fails the validator's load of the whole bundle and silently becomes the default in the app; use `s_keys_map_ExtruderType` / `s_keys_map_NozzleVolumeType`, `Hybrid` excepted ([variant strings](extruder-variants.md#variant-strings)) |
| `[WARNING] No profiles found for vendor: <dir>` | a directory with no matching index (an emptied folder, or a `user/` left by a direct validator run); remove it |
| `… has no compatible system filament in its model's "default_materials"` | add a system filament preset compatible with that variant to the model's `default_materials` |
| `… names the unknown system filament "<n>" in its "default_materials"` / `… "default_filament_profile"` | name an existing system (not user, not base) filament exactly; `;` separators, exact case |
| `Missing filament profile: '<n>' referenced in <file>` | the same, caught by `check`; usual causes are `,` instead of `;`, wrong case (`@system`), or a `;`-joined list packed into one array element |
| `machine_model name "<n>" is declared by N bundles` | model names are unique across the tree; rename the new model |
| `vendor <V>'s config version: <s> invalid` | the `version` string does not parse; write `MM.mm.pp.bb` |
| `[json.exception.type_error.302] type must be string` | locate the non-string value in the index or a model; see [failure scopes](vendor-bundle.md#failure-scopes) |
| `Printer "<p>" fell back to a default preset` | the final process or filament selection is a generic Default preset: check the named defaults, their visibility and that compatible presets exist. An incompatible default may instead be replaced without this error |
| `Printer "<p>" sliced but the filament change never fired (no CP TOOLCHANGE START)` | `change_filament_gcode` never expanded |
## CI ## CI
`.github/workflows/check_profiles.yml`, job **"Check profiles"**, on `pull_request` into `main` or `.github/workflows/check_profiles.yml`, job **"Check profiles"**, runs on `pull_request` into `main` or
`release/*`, paths `resources/profiles/**`, `resources/printers/**`, `scripts/**` and the workflow itself. `release/*` touching `resources/profiles/**`, `resources/printers/**`, `scripts/**`,
There is no push trigger — a direct push to main runs no profile validation. `src/libslic3r/PrintConfig.cpp` (where the tool reads the variant key sets) or the workflow itself.
There is no push trigger: a direct push to main runs no profile validation.
The job opens with `python3 -m unittest discover -s scripts/tests -t scripts`, the tool's own unit The job opens with `python3 -m unittest discover -s scripts/tests -t scripts`, the tool's own unit tests
tests. That step is deliberately **not** `continue-on-error`: a broken tool makes everything it then says (run them locally after changing `scripts/`, with `py -3` on Windows, and keep `-t scripts` or the
about the profiles worthless. Every check after it is `continue-on-error` with a final gate, so one run imports fail). That step is deliberately **not** `continue-on-error`: a broken tool makes everything it
reports all five results. On failure a second workflow posts or replaces a single PR comment marked then says about the profiles worthless. Every check after it is `continue-on-error` with a final gate,
`<!-- profile-validation-comment -->`, with each failing log truncated to 30 KB; it deletes the comment so one run reports all five results. On failure a second workflow posts or replaces a single PR comment
once the run is green. marked `<!-- profile-validation-comment -->`, holding the start of each failing log (30 KB per check,
12 KB per `validate_custom` fixture; reproduce locally for the full list); it deletes the comment once
the run is green.
The job name is also the required check for the delegated-merge bot, which lets a vendor maintainer The job name is also the required check for the delegated-merge bot, which lets a vendor maintainer
self-merge a `resources/profiles/<Their vendor>/` PR with no human review — so whatever CI does not check self-merge a PR limited to their own `resources/profiles/<Vendor>/` folder with no human review, so
is what ships unreviewed. Its denied patterns refuse `^scripts/` and any `.py`, so a PR that touches the whatever CI does not check is what ships unreviewed. Its denied patterns refuse `^scripts/` and any
tooling always needs a maintainer. `.py`, so a PR that touches the tooling always needs a maintainer.
@@ -1,7 +1,7 @@
# The vendor bundle and the loader # Vendor bundles
A bundle is `resources/profiles/<Vendor>.json` (the index) plus `resources/profiles/<Vendor>/`. A bundle is `resources/profiles/<Vendor>.json` (the index) plus `resources/profiles/<Vendor>/`. The
The **vendor id is the filename stem**, not the `name` inside — several differ (`BBL.json` is named **vendor id is the filename stem**, not the `name` inside; the two may differ (`BBL.json` is named
"Bambulab"). Asset paths and the `setting_id` formula use the id; the `validate_custom` fixture prefix "Bambulab"). Asset paths and the `setting_id` formula use the id; the `validate_custom` fixture prefix
uses the `name`. uses the `name`.
@@ -13,16 +13,16 @@ uses the `name`.
"version": "02.04.00.03", "version": "02.04.00.03",
"force_update": "0", "force_update": "0",
"description": "Phrozen configurations", "description": "Phrozen configurations",
"machine_model_list": [ { "name": "...", "sub_path": "machine/....json" } ], "machine_model_list": [ { "name": "Phrozen Arco", "sub_path": "machine/Phrozen Arco.json" } ],
"machine_list": [ ... ], "machine_list": [ … ],
"process_list": [ ... ], "process_list": [ … ],
"filament_list": [ ... ] "filament_list": [ … ]
} }
``` ```
The loader reads `name`, `version`, `url` and the four `*_list` arrays. The loader reads `name`, `version`, `url` and the four `*_list` arrays. `description` is only logged;
`description` is only logged. `force_update` is read by `PresetUpdater`, never by the loader. `force_update` is read by the profile updater, never by the loader. `sub_path` is relative to the
`sub_path` is relative to the **vendor folder**. **vendor folder**.
| List | Holds | | List | Holds |
| --- | --- | | --- | --- |
@@ -35,141 +35,180 @@ The loader reads `name`, `version`, `url` and the four `*_list` arrays.
1. **Everything is registered, bases included.** Every preset file on disk has exactly one entry in the 1. **Everything is registered, bases included.** Every preset file on disk has exactly one entry in the
matching list, and no unindexed preset file is left in the tree. matching list, and no unindexed preset file is left in the tree.
2. **Parents before children.** `inherits` resolves against a per-kind map filled as the list is walked 2. **Parents before children, includes before includers.** The lists load processes first, then
(`configs.clear()` then process, filaments, printers). A parent listed after its child produces filaments, then printers, each in index order, and `inherits` and `include` resolve only against
`can not find inherits <parent> for <child>` and the bundle is discarded. presets of that type already loaded from it. A parent
3. **The index entry's `name` must equal the `name` inside the sub_path file.** `check_name_consistency` listed after its child produces `can not find inherits <parent> for <child>` and the bundle is
walks the index looking for the files; `check_index_coverage` walks the files looking for them in the discarded; an include listed after its includer is `can not find include`, a counted error that
index. The `renamed_from` escape hatch `check_name_consistency`'s docstring promises is commented out. leaves the includer without those keys.
3. **The index entry's `name` equals the `name` inside the `sub_path` file.** `renamed_from` does not
excuse a mismatch.
All three are `check` errors now, and `update-index` writes an index that satisfies all three from the All three are `check` errors, and `update-index` writes an index that satisfies all three from the
files on disk — including the parents-first ordering, by topological sort. Hand-editing the index is files on disk, including the dependency ordering (parents and templates before the presets that use
them, then, in name order, entries that neither depend on nor are depended on by another entry of their own
list, and any entry on a dependency cycle). Hand-editing the index is
fine for a one-line addition, but the committed result must equal what `update-index` writes, because fine for a one-line addition, but the committed result must equal what `update-index` writes, because
`check` compares them. `check` compares them.
The loader itself reports none of this: an unregistered file, or an entry with a typo'd key The loader itself reports none of this: an unregistered file, or an entry with a misspelled key
(`"subpath"`), is silently dropped. (A typo'd `sub_path` is a `check` error naming the entry.) (`"subpath"`), is silently dropped. (A misspelled `sub_path` value is a `check` error naming the entry.)
`BBL/cli_config.json` and `BBL/filament/filaments_color_codes.json` are auxiliary data loaded by path, `BBL/cli_config.json` and, in `BBL/filament/`, `filaments_color_codes.json`, `filament_id_map.json`,
not presets. The tool's `NON_PROFILE_FILES` excludes these basenames from preset maintenance. `filament_name_map.json` and `support_recommended_params.json` are auxiliary data files read by path,
not presets. The last three carry a `type` key and look like presets; the tool excludes all five from
preset maintenance.
## `version` ## `version`
Parsed by a four-component Semver where the 4th is folded in as `patch = patch*100 + value`. Write it Four components, `MM.mm.pp.bb`, compared as a version number in which the fourth is folded into the
zero-padded, `MM.mm.pp.bb`; a couple of bundles drop a component or the padding, but do not imitate them. third (`patch × 100 + build`). Write all four components, zero-padded.
- **Bump the version for every bundle the PR touches.** `PresetUpdater` installs bundled resources - **Bump the version for every bundle the change touches.** The app installs bundled profiles only
only when their version is newer than the installed version; the `.opc` preset cache is also when their version is newer than the installed one, and the `.opc` preset cache is also keyed on
versioned. Nothing in profile CI checks the bump. the version. Nothing in profile CI checks the bump.
- **Keep the last component ≤ 99.** `02.04.00.100` and `02.04.01.00` both parse to `2.4.100`. A bundle - **Keep the last component ≤ 99.** `02.04.00.100` and `02.04.01.00` both read as
that reaches `.99` carries into the third component (`02.03.02.99` → `02.03.03.00`). `2.4.100`. A bundle that
- An **absent** version is worse than a stale one: the validator still passes, but `Semver::valid()` reaches `.99` carries into the third component (`02.03.02.99` → `02.03.03.00`).
excludes `0.0.0`, so the vendor is dropped from the configuration wizard entirely and the preset cache - An **absent** version is worse than a stale one: it reads as `0.0.0`, which is not a valid version.
is disabled for it. An *unparseable* version is not silent — it throws and discards the whole bundle `check` and the validator still pass, but the vendor is dropped from the setup wizard entirely and gets no preset cache. Confirm the key exists. An
(see the failure table below). *unparseable* version is not silent: it discards the whole bundle (`vendor <V>'s config version: <s>
invalid`).
## Common preset keys ## Common preset keys
| Key | Value | | Key | Value |
| --- | --- | | --- | --- |
| `type` | `machine_model` / `machine` / `process` / `filament` | | `type` | `machine_model`, `machine`, `process` or `filament` |
| `name` | the preset name; the filename is *not* authoritative | | `name` | the preset name, the identity every reference uses; the filename is *not* authoritative |
| `inherits` | the parent's exact `name` — no path, no `.json` | | `inherits` | the parent's exact `name`: no path, no `.json` |
| `include` | a template's exact `name`, or an array of them, layered under this preset's own keys ([below](#inherits-and-include)) |
| `instantiation` | the **string** `"true"` (selectable) or `"false"` (base) | | `instantiation` | the **string** `"true"` (selectable) or `"false"` (base) |
| `from` | `"system"` by convention; the vendor loader never reads it | | `from` | `"system"` for shipped presets |
| `setting_id` | required on instantiated presets, forbidden on bases — generated | | `setting_id` | generated; required on instantiated presets, forbidden on bases |
| `renamed_from` | `;`-separated list of old names this preset supersedes | | `renamed_from` | `;`-separated old names this preset supersedes ([below](#renamed_from)) |
These are config-preset keys; `machine_model` records have their own These are config-preset keys; `machine_model` records have their own
[schema](machine-profiles.md#a-machine_model-is-not-a-config-preset). Keep `from` as `"system"` [key set](machine-profiles.md#machine_model-a-record-not-a-config-preset). Keep `from` as `"system"`:
for shipped presets. The vendor loader ignores it, but the CLI config-file loader accepts only the bundle loader ignores it, but loading the file as a CLI config accepts only `system`, `user` or
`system`, `user` or `User` and handles their inheritance differently. `User` and handles their inheritance differently.
`instantiation` is the one metadata key that is hard-gated: a missing key or any value other than the `instantiation` is the one metadata key the validator gates: a missing key or any value other than the
strings `"true"`/`"false"` is an error (`Missing instantiation attribute for <name>`). A JSON boolean strings `"true"` / `"false"` is a counted error (`Missing instantiation attribute for <name>`) that fails
`true` fails harder — it throws inside `load_from_json` and takes the **whole vendor bundle** down. the validator, though the preset still loads and is treated as selectable. A file with no
`instantiation` whose name contains `gcode`, or that has no `name`, silently becomes an include-only
template. A JSON boolean `true` fails harder: it takes the **whole vendor bundle** down.
### `inherits` ## `inherits` and `include`
Resolution is an exact-name lookup **within the same bundle**, plus one exception: filaments may inherit `inherits` resolves by exact name **within the same bundle**, plus one exception: filaments may inherit
from `OrcaFilamentLibrary`, which is loaded first and becomes the base bundle. Vendor-to-vendor from OrcaFilamentLibrary, which is loaded first. Vendor-to-vendor inheritance always fails, and an
inheritance always fails. You can inherit from an instantiated preset as well as from a base; it is unresolved `inherits` discards the bundle. You can inherit from an instantiated preset as well as from a
common. base.
### `renamed_from` `"include": ["<name>", …]` (or one bare name) pulls in `instantiation: "false"` presets of the same type
from the same bundle (never the library), registered before the includer. It shares a block of keys
between presets that do not share a parent: a variant layout, a G-code template. Only `"false"` presets
can be included, so a name that resolves to nothing (misspelled, registered after the includer, or a
selectable preset) is a counted error (`can not find include`) and the preset loads without it.
**How a preset's config is composed:** start from the parent's stored config (a root starts from the
built-in defaults), apply each preset named in `include` in the order listed, then the preset's own
keys. Later layers win, so precedence is own keys > later includes > earlier includes > the `inherits`
chain. Only then is every variant key of the composed config resized to its variant length
([widths](extruder-variants.md#widths)), and keys of another preset type removed. The two routes hand
down different widths:
- **`inherits` hands down the resized config.** A base is stored *after* its own resize, at the length
of its own `*_extruder_variant` (one variant for a base of any type that writes none, whatever its
extruder count). A child therefore inherits the base's arrays at the base's width: an array wider than
that is cut to its first values before any child sees it, and a child that adds variants gets those
first values padded. So widen an array only on a preset whose own variant list already has the
entries; a wide array on a narrow base is silently lost at load, and `check`, which composes at the
width each file wrote and judges selectable presets only, misses that cut.
- **`include` hands down the template's diff, at its pre-resize width.** An included preset contributes
every key where its own composed config (its parent, its own includes and its own keys) differs from
the built-in defaults, taken *before* its resize. So keys the template inherits are passed on too,
arrays it writes arrive at the width its file wrote, and a key it sets to the built-in default value
is not passed on at all, so it cannot override what the includer inherited. Resizing happens on the
includer, not on the template.
## `renamed_from`
One JSON string, `;`-separated for several old names. One JSON string, `;`-separated for several old names.
- Write `"A;B"`, never `"A ; B"` — an unquoted item keeps its trailing space and can never match. - Write `"A;B"`, never `"A ; B"`: a space after a `;` is skipped, but a space before it stays part of
the name (`"A "`), which can never match.
- When `renamed_from` is **absent** and the name contains `@`, the loader auto-adds the `@`-removed form - When `renamed_from` is **absent** and the name contains `@`, the loader auto-adds the `@`-removed form
(`X @Y` → `X Y`) as a rename alias. Declaring an explicit `renamed_from` **suppresses** that, so a (`X @Y` → `X Y`) as a rename alias. Declaring an explicit `renamed_from` **suppresses** that, so a
preset that needs both the `@`-removed form and a real old name must list both. No shipped profile preset that needs both the `@`-removed form and a real old name must list both; a preset that gains
currently does, which means any preset that gained a `renamed_from` quietly lost its `X Y` alias. a `renamed_from` without it quietly loses its `X Y` alias.
- It rescues names stored **outside** the tree: user presets and 3MF projects. It does **not** rescue - It rescues names stored **outside** the tree: user presets and 3MF projects. It does **not** rescue
in-tree `inherits` (exact lookup), it does **not** satisfy `check_name_consistency`, the validator in-tree `inherits` (exact lookup), it does **not** satisfy the index-name rule, the validator reports
reports an in-tree reference that only resolves through it (`references renamed compatible_printers an in-tree reference that only resolves through it (`references renamed compatible_printers "OLD"
"OLD" (now "NEW")`), and `machine_model` records never read it at all. (now "NEW")`), and `machine_model` records never read it at all.
- Only one preset may claim a given old name — two that do is a counted error - Only one preset may claim a given old name; two that do is a counted error
(`… was marked as renamed from "Y" … as well`). But the redirect is **inert while a live preset still (`… was marked as renamed from "Y" … as well`). But the redirect is **inert while a live preset still
carries that name**, and nothing checks *that*; Z-Bolt ships a folder of such dead entries. carries that name**, and nothing checks *that*, so a neighbour's `renamed_from` is no model.
## Failure modes, ranked by blast radius ## Failure scopes
| Scope | Cause | | Scope | Cause |
| --- | --- | | --- | --- |
| **All vendors, zero system profiles** | a non-string `version`, `name` or `url` at the top level of a vendor index (`"version": 2`), or non-string `nozzle_diameter` on a model — `nlohmann::type_error` escapes the per-vendor `std::runtime_error` catch | | **Every vendor except OrcaFilamentLibrary, and all user presets** | a non-string where the index or a `machine_model` expects a string (`"version": 2` at the top level of an index, a numeric `name` or `url`, a non-string `nozzle_diameter` or other model key): `[json.exception.type_error.302] type must be string`, and the validator reports `Validation failed` |
| **The whole vendor bundle** | unparseable `version`; index JSON parse error; a `sub_path` file missing or unparseable; unresolvable `inherits`; duplicate preset name within the vendor; empty/unknown `printer_model` or `printer_variant`; a filament resolving no `filament_id` | | **The whole vendor bundle** | index JSON parse error; unparseable `version`; a listed file missing or unparseable; a value its option cannot take, such as `nil` in a non-nullable key (`Failed loading configuration file`); unresolved `inherits`; two selectable presets with one name; empty or unknown `printer_model` / `printer_variant`; a filament resolving no `filament_id`; a JSON boolean `instantiation` |
| **One preset** | `instantiation` missing or a wrong string; keys belonging to another preset type (`contains incorrect keys: …, which were removed`); a non-string inside a `*_list` entry (`invalid value type for <key>`) | | **A counted error; the preset still loads** | `instantiation` missing or not `"true"` / `"false"`; keys belonging to another preset type (`contains incorrect keys: …, which were removed`); a non-string inside a `*_list` entry (`invalid value type for <key>`); an `include` naming nothing usable (`can not find include`, loads without it) |
| **Logged, not counted** | a raw JSON number in a preset — `invalid json type for <key>`, the value is dropped and the exit code stays 0 | | **The rest of the file, logged only** | an array with a non-string element (`[0.4]`, `invalid json array`): that key and every key after it in the file are dropped, and no error is counted |
| **Nothing reported by the loader** | unregistered file; misspelled setting key; missing bed/hotend asset. Only the first of those is a `check` error; the other two reach users | | **One value, logged only** | a raw JSON number in a preset (`invalid json type for <key>`): the value is dropped and the exit code stays 0 |
| **Nothing reported by the loader** | unregistered file; two bases with one name, or a base and a selectable preset with one name (the first in the index wins); misspelled setting key; missing bed, hotend or cover asset. `check` catches the first two; the others reach users |
Deleting a file the index still lists surfaces as a *parse error* on line 1, not "file not found" — the Deleting a file the index still lists surfaces as a *parse error* on line 1 (`unexpected end of input`), not "file
loader `ifstream`s the missing path and nlohmann reports `unexpected end of input`. not found".
Preset names are a **single global namespace across every vendor**: a duplicate within one vendor is a Selectable preset names are a **single namespace across every vendor**: a duplicate within one vendor
hard bundle failure, a duplicate across vendors is reported as `Found duplicated preset: <name> in is a hard bundle failure, and a duplicate across vendors is reported as `Found duplicated preset: <name>
vendor: <vendor>` and still counts as an error. `check_preset_name_uniqueness` catches the within-bundle in vendor: <vendor>` and still counts as an error. `check` catches the within-bundle case earlier and
case earlier and more precisely — including an *unindexed* twin, which is one `sub_path` edit away from more precisely, bases included, and including an *unindexed* twin, which is one `sub_path` edit away
silently becoming the parent every child resolves to (`std::map::emplace` keeps the first insertion, so from silently becoming the parent every child resolves to (the first registered preset of a name wins,
index order decides). Base names, by contrast, repeat across bundles by design: `fdm_process_common` so index order decides). Base names, by contrast, repeat across bundles by design:
exists in nearly all of them. every bundle may have its own `fdm_process_common` ([uniqueness](naming.md#uniqueness)).
## Starting a whole new vendor bundle ## Starting a whole new vendor bundle
Nothing generates one; copy the smallest bundle that resembles the hardware. **`Voxelab` or `M3D`** are Nothing generates one; copy the smallest bundle that resembles the hardware. **`Voxelab`** is the
the minimal shape — a shared machine base, the model, one variant, a shared process base, two minimal shape: a shared machine base, the model, one variant, a shared process base, two processes, and
processes, and an empty `filament_list` that takes the library generics. Do *not* start from `Phrozen`: an empty `filament_list`, so the printer takes the library generics. Do *not* start from a bundle that
it carries local `fdm_filament_*` copies that have drifted from the library, and a filament preset that carries local `fdm_filament_*` copies, which drift from the library, or filament presets that restate
restates most of its parent — the style this skill advises against. most of their parent, the style this skill advises against.
Write the machine files **last**, so you only visit them once: Write the machine files **last**, so you only visit them once:
1. **Choose the names first** — model, variant(s), process(es). Everything else references them. 1. **Choose the names first**: model, variant(s), process(es). Everything else references them
([naming.md](naming.md)).
2. `resources/profiles/<Vendor>.json`: `name`, `version` (`01.00.00.00`), `force_update: "0"`, 2. `resources/profiles/<Vendor>.json`: `name`, `version` (`01.00.00.00`), `force_update: "0"`,
`description`, and all four `*_list` arrays (an empty `filament_list` is fine). `description`, and all four `*_list` arrays (empty is fine: `update-index` fills them once the files
3. The shared bases — `<Vendor>/machine/fdm_machine_common.json` and exist, so this step only needs the bundle metadata to be right).
`<Vendor>/process/fdm_process_common.json`, both `"instantiation": "false"` with no `setting_id`. 3. The shared bases: `<Vendor>/machine/fdm_machine_common.json` and
For a Klipper printer add your own `<Vendor>/machine/fdm_klipper_common.json` inheriting the machine `<Vendor>/process/fdm_process_common.json`, both `"instantiation": "false"` with no `setting_id`. For
a Klipper printer add your own `<Vendor>/machine/fdm_klipper_common.json` inheriting the machine
base; there is no shared one, because a `machine` preset can only inherit inside its own bundle. base; there is no shared one, because a `machine` preset can only inherit inside its own bundle.
4. One selectable process per variant, each naming its variant in `compatible_printers`. 4. One selectable process per variant, each naming its variant in `compatible_printers`.
5. Bed assets and `<Model>_cover.png`, all directly in `<Vendor>/`. None of them is needed for the 5. Bed assets and `<Model>_cover.png`, all directly in `<Vendor>/`. None of them is needed for the
bundle to load, and nothing in CI checks them — but the bed files are inert unless the `machine_model` bundle to load, and nothing in CI checks them; but the bed files are inert unless the
names them in `bed_model` / `bed_texture`, and the cover is found by convention as `machine_model` names them in `bed_model` / `bed_texture`, and the cover is found by convention as
`<the name you gave the model in machine_model_list>_cover.png`. `<the name you gave the model in machine_model_list>_cover.png`.
6. The `machine_model` record and the `machine` variants, now that every value they reference exists — 6. The `machine_model` record and the `machine` variants, now that every value they reference exists;
the minimum key sets and the `default_*` shapes are in the minimum key sets and the `default_*` shapes are in
[machine-profiles.md](machine-profiles.md#the-machine-variant). [machine-profiles.md](machine-profiles.md#machine-the-variant).
7. Run the tool and validate — follow 7. Run the tool and validate: follow
[Creating or modifying a profile](../SKILL.md#creating-or-modifying-a-profile). `generate-id` is not [Creating or modifying a profile](../SKILL.md#creating-or-modifying-a-profile). `generate-id` is not
optional for a new bundle: the validator loads presets that have no `setting_id`, but `check` fails optional for a new bundle: the validator loads presets that have no `setting_id`, but `check` fails
every one of them. `update-index` will fill the four `*_list` arrays for you once the files exist, so every one of them.
step 2 only needs the bundle metadata to be right.
## `resources/profiles_template/` ## `resources/profiles_template/`
A separate tree (`Template.json` + `Template/`) holding filament and process templates. It is **not** a A separate tree (`Template.json` + `Template/`) holding filament and process templates. It is **not** a
scaffold for shipped profiles — `CreatePresetsDialog.cpp` reads it for the in-app "create a custom scaffold for shipped profiles: the app's "create a custom printer / filament" dialog reads it, so
printer/filament" wizard, so editing it changes what users get when they create a custom preset. editing it changes what users get when they create a custom preset. `check_profile.sh`'s validator
`check_profile.sh`'s validator checks default to `resources/profiles` (redirectable with `-p`), and so checks default to `resources/profiles` (redirectable with `-p`), and so does `orca_profile_tool.py`
does `orca_profile_tool.py` (redirectable with `--profiles`); (redirectable with `--profiles`); neither covers this tree.
neither covers this tree.
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Find settings to move onto shared bases, and prove a move changed nothing.
snapshot OUT.json write every selectable preset's config as the loader stores it
compare BEFORE.json report every value that differs from the snapshot; exit 1 if any
candidates --vendor V restated values; per base, the settings its presets all share and the
defaults with exceptions that would pay
Every subcommand takes --profiles DIR (default resources/profiles).
Configs are composed the loader's way: the parent's stored config, then each include at
the width its file wrote, then the preset's own keys, and every variant key resized to the
preset's own variant list (one variant without one), padded with its first value or cut.
A base is stored after that resize, so a variant array wider than a base's list reaches its
children cut. Not modelled: the built-in defaults. A key no file in a preset's chain writes
loads its default, so compare reports a key written on one side only separately: it is no
change when the written value is the option's default in PrintConfig.cpp. Nor is it modelled
that an include template does not pass on a key equal to the default. Reads
scripts/orca_profile_tool.py.
"""
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
# The profile tool lives in <repo>/scripts; this file in <repo>/.claude/skills/orca-profiles/scripts.
sys.path[:0] = [os.path.join(os.getcwd(), "scripts"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), *[os.pardir] * 4, "scripts")]
import orca_profile_tool as tool # noqa: E402
TYPES = ("machine", "process", "filament")
# Keys the loader reads from each file as metadata; neither inherits nor include passes them on.
PER_FILE = {"type", "name", "from", "instantiation", "setting_id", "renamed_from", "description",
"inherits", "include", "version", "url", "is_custom_defined"}
# Keys the app replaces with the selected presets' names before slicing: a file's value never counts.
REPLACED = {"print_settings_id", "printer_settings_id", "filament_settings_id"}
# What a restructure changes by design, or what never reaches a slice.
MOVED_BY_DESIGN = {"inherits", "include"} | REPLACED
# Keys that stay in their own file: metadata, identity, and each preset's compatibility.
NEVER_SHARED = PER_FILE | REPLACED | {"filament_id", "compatible_printers", "compatible_prints",
"printer_variant", "printer_model"}
class Tree:
def __init__(self, profiles_dir):
self.dir = profiles_dir
self.scheme = tool._variant_scheme()
self.vendors = tool.list_vendor_names(profiles_dir)
self.bundles = {v: tool.load_vendor_configs(profiles_dir, v) for v in self.vendors}
self.cache = {}
def lookup(self, vendor, ptype, name, in_ofl=False):
"""(vendor the name resolves in, (rel, data)); filaments fall back to the library."""
if not in_ofl and name in self.bundles[vendor][ptype]:
return vendor, self.bundles[vendor][ptype][name]
if ptype == "filament" and tool.OFL in self.bundles and name in self.bundles[tool.OFL][ptype]:
return tool.OFL, self.bundles[tool.OFL][ptype][name]
return None, None
def composed(self, vendor, ptype, name, drop=None, seen=frozenset()):
"""Config before the preset's own resize: parent stored, includes, own keys."""
owner, found = self.lookup(vendor, ptype, name, vendor == tool.OFL)
if found is None or (owner, name) in seen:
return {}
seen = seen | {(owner, name)}
data = found[1]
config = {}
if data.get("inherits"):
config.update(self.stored(owner, ptype, data["inherits"], seen))
include = data.get("include") or []
for included in [include] if isinstance(include, str) else include:
if included in self.bundles[owner][ptype]:
config.update(self.composed(owner, ptype, included, seen=seen))
config = {k: v for k, v in config.items() if k not in PER_FILE}
config.update((k, v) for k, v in data.items() if k != drop)
return config
def stored(self, vendor, ptype, name, seen=frozenset()):
key = (vendor, ptype, name)
if key not in self.cache:
self.cache[key] = self.resize(ptype, self.composed(vendor, ptype, name, seen=seen))
return self.cache[key]
def resize(self, ptype, config):
list_key, strides = self.scheme[ptype]
length = len(tool._as_list(config[list_key])) if list_key in config else 1
out = dict(config)
for key, stride in strides.items():
if key in out:
values = tool._as_list(out[key])
need = length * stride
out[key] = values[:need] + values[:1] * (need - len(values))
return out
def presets(self, vendors=None, ptypes=TYPES):
for vendor in vendors or self.vendors:
for ptype in ptypes:
for name, (rel, data) in sorted(self.bundles[vendor][ptype].items()):
yield vendor, ptype, name, rel, data
def snapshot(tree):
return {f"{vendor}/{ptype}/{name}": {k: v for k, v in tree.stored(vendor, ptype, name).items()
if k not in MOVED_BY_DESIGN}
for vendor, ptype, name, _rel, data in tree.presets()
if data.get("instantiation") == "true"}
def compare(before, after):
changed, one_sided = 0, []
for preset in sorted(before.keys() | after.keys()):
old, new = before.get(preset), after.get(preset)
if old is None or new is None:
print(f"{preset}: {'added' if old is None else 'removed'}")
changed += 1
continue
for key in sorted(old.keys() | new.keys()):
if key not in old or key not in new:
one_sided.append(f"{preset}: {key} "
f"{json.dumps(old[key]) if key in old else '(built-in default)'} -> "
f"{json.dumps(new[key]) if key in new else '(built-in default)'}")
elif old[key] != new[key]:
print(f"{preset}: {key} {json.dumps(old[key])} -> {json.dumps(new[key])}")
changed += 1
for line in one_sided:
print(line)
print(f"{changed} difference(s)")
if one_sided:
print(f"{len(one_sided)} key(s) written on one side only: each is a difference unless the "
f"written value is the option's default in src/libslic3r/PrintConfig.cpp")
return changed + len(one_sided)
def candidates(tree, vendor, ptypes, group_by):
for ptype in ptypes:
entries = {name: data for _v, _t, name, _rel, data in tree.presets([vendor], (ptype,))}
selectable = [n for n, d in entries.items() if d.get("instantiation") == "true"]
stored = {n: tree.stored(vendor, ptype, n) for n in entries}
list_key = tree.scheme[ptype][0]
# Restated: a key a file writes that it would inherit unchanged without writing it.
restated = {}
for name, data in entries.items():
restated[name] = sorted(
k for k in data if k not in NEVER_SHARED and k != list_key
and (data.get("inherits") or data.get("include"))
and tree.resize(ptype, tree.composed(vendor, ptype, name, drop=k)).get(k) == stored[name][k])
if restated[name]:
print(f"{vendor}/{ptype} {name}: restates what it inherits: {', '.join(restated[name])}")
# Groups: every preset with selectable presets below it, or the --group-by values.
chain = {n: [] for n in selectable}
for name in selectable:
node = entries[name].get("inherits")
while node in entries and node not in chain[name]:
chain[name].append(node)
node = entries[node].get("inherits")
groups = defaultdict(list)
for name in selectable:
if group_by:
groups[json.dumps(stored[name].get(group_by))].append(name)
else:
for base in chain[name]:
groups[base].append(name)
printed = {}
for label, members in sorted(groups.items(), key=lambda g: -len(g[1])):
if len(members) < 2 or label == "null":
continue
if frozenset(members) in printed:
print(f"\n{vendor}/{ptype} {label}: the same presets as {printed[frozenset(members)]}")
continue
common = [b for b in chain[members[0]] if all(b in chain[m] for m in members[1:])]
home = common[0] if group_by and common else None if group_by else label
def below(m):
"""m and the files between it and the group's home."""
return [m] + chain[m][:chain[m].index(home)] if home in chain[m] else [m]
shared, defaults = [], []
for key in sorted(set().union(*(entries[m].keys() for m in members)) - NEVER_SHARED):
loaded = [json.dumps(stored[m].get(key)) for m in members]
counts = Counter(loaded).most_common(2)
if len(counts) == 1:
writers = sum(key in entries[m] and key not in restated[m] for m in members)
if writers >= 2:
shared.append(f"{key} ({writers} write it)")
continue
(top, held), (_, runner_up) = counts
if held == runner_up or top == "null" or home is None:
continue
# Balance 5: w presets drop their copy; a presets that take the key from the home
# (no file on their way to it writes it) and load another value must write theirs.
w = sum(key in entries[m] and v == top for m, v in zip(members, loaded))
a = sum(v != top and not any(key in entries[f] for f in below(m))
for m, v in zip(members, loaded))
if w - a > 1:
shown = top if len(top) <= 40 else top[:37] + "..."
defaults.append(f"{key} = {shown}: {held} load it, {w} write it, "
f"{a} would have to write their own")
if shared or defaults:
where = f"; nearest common base {common[0]}" if group_by and common else ""
title = f"{group_by} = {label}" if group_by else label
print(f"\n{vendor}/{ptype} {title}: {len(members)} presets{where}")
printed[frozenset(members)] = title
for line in shared:
print(f" {line}")
if defaults:
print(" default with exceptions:")
for line in defaults:
print(f" {line}")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
profiles = argparse.ArgumentParser(add_help=False)
profiles.add_argument("--profiles", default=os.path.join("resources", "profiles"),
help="profiles directory (default: resources/profiles)")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("snapshot", parents=[profiles]).add_argument("out")
sub.add_parser("compare", parents=[profiles]).add_argument("before")
cand = sub.add_parser("candidates", parents=[profiles])
cand.add_argument("--vendor", required=True)
cand.add_argument("--type", choices=TYPES, action="append")
cand.add_argument("--group-by", help="group selectable presets by this key's value instead of by "
"base: printer_model, gcode_flavor, extruder_type, filament_id, layer_height, ...")
args = parser.parse_args()
tree = Tree(args.profiles)
if args.command == "snapshot":
presets = snapshot(tree)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(presets, f, sort_keys=True)
print(f"{len(presets)} selectable presets written to {args.out}")
elif args.command == "compare":
with open(args.before, encoding="utf-8") as f:
sys.exit(1 if compare(json.load(f), snapshot(tree)) else 0)
else:
candidates(tree, args.vendor, args.type or TYPES, args.group_by)
if __name__ == "__main__":
main()
+5 -3
View File
@@ -183,9 +183,11 @@ jobs:
os: ${{ vars.SELF_HOSTED && 'orca-macos-arm64' || 'macos-14' }} os: ${{ vars.SELF_HOSTED && 'orca-macos-arm64' || 'macos-14' }}
artifact: ${{ github.sha }}-tests-macos-arm64 artifact: ${{ github.sha }}-tests-macos-arm64
test-dir: build/arm64/tests test-dir: build/arm64/tests
# Slice a two-colour cube through every shipped printer so all custom g-code # Slice a two-colour cube through every shipped printer, and through every
# (change_filament_gcode, machine start/end, etc.) is expanded - catches # system process/filament whose templates no printer's own slice reaches, so
# slicing regressions the static profile checks and unit tests can't see. # every custom g-code and filename_format shipped is expanded (names in {if}
# branches not taken included) - catches slicing regressions the static
# profile checks and unit tests can't see.
# Profile-only PRs are covered by check_profiles.yml's nightly binary; this # Profile-only PRs are covered by check_profiles.yml's nightly binary; this
# covers src/engine PRs with the PR-built binary. # covers src/engine PRs with the PR-built binary.
slice_check_linux: slice_check_linux:
+7 -2
View File
@@ -14,6 +14,9 @@ on:
# this workflow. # this workflow.
- 'resources/printers/**' - 'resources/printers/**'
- 'scripts/**' - 'scripts/**'
# orca_profile_tool.py reads the variant key sets from PrintConfig.cpp, and its
# tests the obsolete keys, so a PR changing either must be checked against the profiles.
- 'src/libslic3r/PrintConfig.cpp'
- ".github/workflows/check_profiles.yml" - ".github/workflows/check_profiles.yml"
workflow_dispatch: workflow_dispatch:
@@ -71,8 +74,10 @@ jobs:
set +e set +e
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log ./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log
exit ${PIPESTATUS[0]} exit ${PIPESTATUS[0]}
# Slice a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode) # Slice a two-colour cube through every printer, and through every system process/filament whose
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks above cannot see. # templates no printer's own slice reaches, so every custom g-code and filename_format shipped is
# expanded (names in {if} branches not taken included) - catches undefined-placeholder /
# invalid-flow bugs the static checks above cannot see.
- name: validate slice (expand custom g-code) - name: validate slice (expand custom g-code)
id: validate_slice id: validate_slice
continue-on-error: true continue-on-error: true
+105 -45
View File
@@ -1,5 +1,7 @@
name: Daily OFL OTA Update name: Daily OFL OTA Update
run-name: Daily OFL OTA Update [OFL barrier]
# This workflow is intended for creating and publishing the OrcaFilamentLibrary (OFL) OPC package to # This workflow is intended for creating and publishing the OrcaFilamentLibrary (OFL) OPC package to
# https://github.com/OrcaSlicer/orcaslicer-profiles, which generates an OTA update. # https://github.com/OrcaSlicer/orcaslicer-profiles, which generates an OTA update.
# This cronjob runs daily at 00:00 UTC every day and scans main plus every release/vX.Y.Z branch for # This cronjob runs daily at 00:00 UTC every day and scans main plus every release/vX.Y.Z branch for
@@ -12,9 +14,9 @@ name: Daily OFL OTA Update
# vendor-dispatch path is also what makes post_merge_profiles.yml call the OTA auto-publish API after # vendor-dispatch path is also what makes post_merge_profiles.yml call the OTA auto-publish API after
# uploading - see post_merge_profiles.yml for both sides of that contract. # uploading - see post_merge_profiles.yml for both sides of that contract.
# #
# At the start of each run, the pending-publish table is cleared up to a captured # Each run captures a timestamp, dispatches the needed OFL publishers, waits for
# timestamp (POST /api/v1/ota/ofl/pending/clear?timestamp=...). Changes merged after # all of them to finish, then clears the pending-publish table once. Changes merged
# that timestamp remain pending for the next run. # after that timestamp remain pending for the next run.
on: on:
schedule: schedule:
@@ -34,32 +36,14 @@ jobs:
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }} if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Capture start timestamp and clear OFL pending queue - name: Capture start timestamp
id: start id: start
shell: bash shell: bash
env:
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
run: | run: |
set -euo pipefail set -euo pipefail
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
timestamp="$(date -u +%s)" timestamp="$(date -u +%s)"
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT" echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
resp_file="$RUNNER_TEMP/ota-pending-clear-response.json"
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear?timestamp=$timestamp" \
-H "Authorization: Bearer $OTA_API_KEY")"
body="$(cat "$resp_file")"
echo "$body"
if [ "$status" != "200" ]; then
echo "::error::OTA pending-clear call failed with HTTP $status"
exit 1
fi
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
@@ -84,27 +68,21 @@ jobs:
| grep -E '^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$' | sort -u | grep -E '^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$' | sort -u
) )
# The cron run is the checkpoint: a successful run means every
# dispatched branch publisher completed and the pending queue was
# cleared. Manual or push-triggered post_merge_profiles runs are not
# checkpoints for this scan.
successful_cron_runs="$(gh api --method GET \
"repos/${{ github.repository }}/actions/workflows/ofl-ota-cronjob.yml/runs" \
-f status=success -f branch=main -f per_page=100 --paginate \
--jq '.workflow_runs[] | select((.display_title // "") | contains("[OFL barrier]"))')"
since="$(jq -rs 'sort_by(.run_started_at) | last.run_started_at // empty' <<< "$successful_cron_runs")"
for branch in "${branches[@]}"; do for branch in "${branches[@]}"; do
echo "::group::$branch" echo "::group::$branch"
# post_merge_profiles.yml's own run history, not this workflow's: this
# workflow only ever runs against main (schedule, or workflow_dispatch
# --ref main), so its head branch never varies - filtering ITS history
# by $branch would never match anything except main. post_merge_profiles.yml
# genuinely runs per-branch (this dispatch below sets --ref "$branch"),
# so its history is the real per-branch checkpoint. It also means a
# failed publish naturally gets retried tomorrow: the checkpoint only
# advances on a run that actually succeeded.
# --method GET is required, not cosmetic: gh api defaults to POST
# whenever -f fields are present unless a method is given
# explicitly, and POST on this list-runs endpoint 404s - confirmed
# on real Actions infrastructure, not just reasoned about.
since="$(gh api --method GET "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \
-f status=success -f branch="$branch" -f per_page=1 \
--jq '.workflow_runs[0].run_started_at // empty')"
if [ -z "$since" ]; then if [ -z "$since" ]; then
echo "No prior successful run for $branch; checking OFL changes up to $SCAN_UNTIL." echo "No prior successful OFL cron run; checking $branch through $SCAN_UNTIL."
changed_files="$(git log --until="$SCAN_UNTIL" --name-only --pretty=format: "origin/$branch" -- \ changed_files="$(git log --until="$SCAN_UNTIL" --name-only --pretty=format: "origin/$branch" -- \
resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \ resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \
| sed '/^$/d')" | sed '/^$/d')"
@@ -124,16 +102,98 @@ jobs:
fi fi
if [ "$changed" = true ]; then if [ "$changed" = true ]; then
# Tolerate a per-branch failure (e.g. a pre-existing release branch dispatch_id="${GITHUB_RUN_ID}-${branch//\//-}"
# whose post_merge_profiles.yml predates the vendor/auto_publish # Record successful dispatches for the barrier step below.
# inputs) rather than aborting the whole scan under set -e. # Branches whose workflow predates workflow_dispatch are skipped
if ! gh workflow run post_merge_profiles.yml \ # with a warning, as they were before the barrier was added.
if gh workflow run post_merge_profiles.yml \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--ref "$branch" \ --ref "$branch" \
-f vendor="$VENDOR" -f auto_publish=true; then -f vendor="$VENDOR" -f auto_publish=true \
echo "::warning::failed to dispatch post_merge_profiles.yml for $branch - its post_merge_profiles.yml at this ref may predate the vendor/auto_publish inputs" -f ofl_cron_dispatch_id="$dispatch_id"; then
printf '%s\t%s\n' "$branch" "$dispatch_id" >> "$RUNNER_TEMP/ofl-dispatches.tsv"
else
echo "::warning::skipping $branch because post_merge_profiles.yml could not be dispatched at that ref"
fi fi
fi fi
echo "::endgroup::" echo "::endgroup::"
done done
- name: Wait for OFL publishers
id: wait
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISPATCHES_FILE: ${{ runner.temp }}/ofl-dispatches.tsv
run: |
set -euo pipefail
if [ ! -s "$DISPATCHES_FILE" ]; then
echo "No OFL publisher workflows were dispatched; pending queue will not be cleared."
echo "publishers_dispatched=false" >> "$GITHUB_OUTPUT"
exit 0
fi
: > "$RUNNER_TEMP/ofl-run-ids.tsv"
while IFS=$'\t' read -r branch dispatch_id; do
[ -n "$branch" ] || continue
echo "Waiting for OFL publisher on $branch ($dispatch_id)"
run_id=""
for _ in {1..120}; do
runs_json="$(gh api --method GET \
"repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \
-f branch="$branch" -f event=workflow_dispatch -f per_page=100)"
run_id="$(jq -r --arg marker "[OFL cron $dispatch_id]" \
'[.workflow_runs[] | select((.display_title // "") | contains($marker))]
| sort_by(.created_at) | last | .id // empty' <<< "$runs_json")"
[ -n "$run_id" ] && break
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::could not find dispatched post_merge_profiles run for $branch ($dispatch_id)"
exit 1
fi
printf '%s\t%s\n' "$branch" "$run_id" >> "$RUNNER_TEMP/ofl-run-ids.tsv"
done < "$DISPATCHES_FILE"
all_success=true
while IFS=$'\t' read -r branch run_id; do
[ -n "$run_id" ] || continue
echo "Watching OFL publisher run $run_id for $branch"
if ! gh run watch "$run_id" --repo "${{ github.repository }}" --exit-status; then
all_success=false
fi
done < "$RUNNER_TEMP/ofl-run-ids.tsv"
if [ "$all_success" != true ]; then
echo "::error::one or more OFL publisher workflows failed; pending queue will not be cleared"
exit 1
fi
echo "publishers_dispatched=true" >> "$GITHUB_OUTPUT"
- name: Clear OFL pending queue
if: steps.wait.outputs.publishers_dispatched == 'true'
shell: bash
env:
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
TIMESTAMP: ${{ steps.start.outputs.timestamp }}
run: |
set -euo pipefail
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
resp_file="$RUNNER_TEMP/ota-pending-clear-response.json"
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear?timestamp=$TIMESTAMP" \
-H "Authorization: Bearer $OTA_API_KEY")"
body="$(cat "$resp_file")"
echo "$body"
if [ "$status" != "200" ]; then
echo "::error::OTA pending-clear call failed with HTTP $status"
exit 1
fi
+15
View File
@@ -1,5 +1,14 @@
name: Post-merge profiles name: Post-merge profiles
run-name: >-
Post-merge profiles${{
inputs.ofl_cron_dispatch_id != '' &&
inputs.vendor == 'OrcaFilamentLibrary' &&
(inputs.auto_publish == true || inputs.auto_publish == 'true') &&
format(' [OFL cron {0}]', inputs.ofl_cron_dispatch_id) ||
''
}}
# Push-triggered counterpart to check_profiles.yml (which only gates PRs). When a # Push-triggered counterpart to check_profiles.yml (which only gates PRs). When a
# profile change lands on main or a release branch, rebuild the affected vendors' # profile change lands on main or a release branch, rebuild the affected vendors'
# binary preset caches (<vendor>.opc) and publish each as a versioned ZIP asset on # binary preset caches (<vendor>.opc) and publish each as a versioned ZIP asset on
@@ -59,6 +68,12 @@ on:
required: false required: false
type: boolean type: boolean
default: false default: false
ofl_cron_dispatch_id:
description: >-
Unique marker supplied by the trusted OFL daily cron so it can find
and wait for this dispatched workflow run.
required: false
type: string
permissions: permissions:
contents: read contents: read
+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',
+8
View File
@@ -44,6 +44,14 @@ jobs:
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: ${{ inputs.artifact }} name: ${{ inputs.artifact }}
# run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages
# beside them; the Windows arm64 build bundles none, so put one on PATH there.
- name: Install uv
if: runner.os == 'Windows' && runner.arch == 'ARM64'
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt
enable-cache: false
- uses: lukka/get-cmake@latest - uses: lukka/get-cmake@latest
with: with:
cmakeVersion: "~4.3.0" # use most recent 4.3.x version cmakeVersion: "~4.3.0" # use most recent 4.3.x version
+2
View File
@@ -52,4 +52,6 @@ internal_docs/
__pycache__/ __pycache__/
*.pyc *.pyc
*.opc *.opc
/.test/
docs/superpowers/ docs/superpowers/
ctest_results.xml
+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
+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)
-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

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