Compare commits

...
Author SHA1 Message Date
Hanif Koh 8535c40466 Keep User Preset Values on Extruder Variants They Don't List
A user preset stores the variant list its parent had when it was saved.
When the parent later gains variants, update_diff_values_to_child_config
matched variants by name only and left the new ones at the parent's
value, so the user's settings were silently replaced there, and a
re-save wrote the system values into the user's file.

An unmatched parent variant now takes the child's first variant of the
same extruder, the rule slicing already uses in get_config_index_base.
A child without a variant list covers the parent's first extruder. The
name match also no longer indexes the child's extruder ids when it has
none.
2026-10-01 22:12:18 +08:00
SoftFever b102ae3aaa delete unwanted docs 2026-10-01 21:56:56 +08:00
SoftFever 1046851b50 add orca-wxwidgets skill 2026-10-01 21:56:56 +08:00
Lam Wei Lun 03377a0242 Missing cassert include (#16039) 2026-10-01 10:34:43 -03:00
Kris Austin e7c0e2cd82 ci: let main builds finish instead of cancelling them on every merge (#16044) 2026-10-01 10:34:13 -03:00
Kris Austin d1d14329d9 fix: exporting a sliced print again gives different G-code (#16025)
extrude_infill() and extrude_support() reversed the layer's extrusion
entities in place while chaining them, so each export started from the
previous one's reversed toolpaths, and each copy of an object from the
copy before it. The export-time region lists now hold const pointers,
and chaining reverses a clone instead.
2026-10-01 08:08:03 -03:00
HanifKoh 236a8786ef Keep the Orca Cloud Agent Tests Out of the System Keychain (#15980)
The display-name tests call set_user_session(), which persists the session.
The agent they built was in keychain mode, so every run saved a fake
OrcaSlicer/Auth session into the system keychain of whoever ran the tests,
replacing their real Orca Cloud login on any desktop with a working keychain.

Move them next to the other agent tests and build the agent the same way:
encrypted-file mode with a throwaway config directory.
2026-10-01 16:58:52 +08:00
HanifKoh 92d30fbc55 Clamp Ironing Line Spacing to a Usable Minimum (#15949)
An ironing line spacing of 0 reached the fillers from a 3MF, the CLI or
the per-filament override, which has no GUI guard. Concentric ironing
then never finished slicing, because a zero inset never shrinks the
region, and rectilinear ironing was silently dropped. Tiny positive
values produced an unprintable number of lines.

Top surface and support ironing now clamp the spacing to the 0.05 mm
floor the process GUI guard already enforces, so these configurations
iron at that spacing. Spacings at or above the floor, including every
shipped profile, are unchanged. The concentric filler also returns early
on a non-positive step so no other caller can hang it, and the filament
settings page now resets a too-small override the same way the process
page does.
2026-10-01 16:53:16 +08:00
SoftFever 3384daa6bc Fix bundled Python crashing on macOS 26 and older when built with Xcode 27 (#16035)
# Description

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

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

# Screenshots/Recordings/Graphs

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

## Tests

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

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

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-01 14:57:30 +08:00
HanifKoh 6842d9c778 Keep the Plugin Tests' Python Packages Out of the Working Directory (#15981)
The plugin test fixtures start the interpreter before the test points
data_dir at its temporary directory, so PythonInterpreter creates
{data_dir}/python/packages and {data_dir}/log with an empty data_dir: a
python/ and log/ folder in whatever directory the tests run from. When that
is the test binary's folder, the next run's embedded-interpreter tests took
the stray python/ as their home and failed to start Python.

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

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

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

## Support nozzle-specific filament cooling and tuning

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

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

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

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

### Additional changes

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

Profile changes remain separate in `u1-hf`.

### Validation

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

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

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

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

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

---

## User-facing features

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

---

## How it works (implementation)

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


---


## Backward compatibility & constraints

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

---

## Testing

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

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

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

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

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

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

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

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

* Prevent loss of user access code on LAN reselect

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

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

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

* Remove early returns

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

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

* Add support for runtime error status in plugins

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

* Resolve duplicate agent ID conflicts

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

* fix: checkbox should depend on plugin is_loaded status

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

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

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

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

* Gate agent mode behind use_printer_agents toggle

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

* Track BBLPrinterAgentPlugin.py

* Add printer-agent and plugin status tests

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

* fix: pin HTTP to prevent connection refusal

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

* Bring Moonraker device panel to feature parity

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

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

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

Not compiled or run.

* Stop blocking print on unreported nozzle data

* fix: make Klipper macro lamp control reliable

* Surface Moonraker webcams and gate unrunnable controls

* Keep Bambu AMS dialect out of the agent waist

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

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

* Fix multi-color filament logic

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

* Move Moonraker commands off the UI thread

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

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

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

* Show Snapmaker U1 camera in Device tab

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

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

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

* fix: start stream when camera URL changes

* fix: stop Qidi slot parse throwing on null

* Keep printer-agent progress in sync

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

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

* docs: document the printer-agent subsystem

* fix: merge access codes into one

* Reconcile implementation split with PR tip

* feat: abstract remaining gcode commands in devicemanager

* refactor: abstract bambu specific protocol to printer agent

* refactor: push bbl workflows to bbl printer agent

* remove unused

* fix: default impl

* fix callback error

* fix: remove redundant cache

* specify api for getting file transfer url

* revert file transfer abstraction

* fix: ams filament mapping workflow

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

* fix: resolve stubgen byte header conflict

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

* fix: skip filament sync dialog if filamentSyncMode is none

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

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

* remove irrelevant docs

* fix: remove heavy includes from IPrinterAgent

* fix: defer filesystem and camera abstractions

* fix: remote do_fetch_filament_info from tests

* cleanup moonraker and snapmaker printer agents

* fix: access codes regression

* fix: tests

* fix: printer agent switching on preset change

* fix: remove unused variable

* fix: snapmaker U1 SelectMachineDialog blocking print

* fix: merge artifact

* fix: clear up some unrelated changes

* feat: connect to cloud printer and monitor

* feat: connect to cloud printer and monitor

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

* fix: build & access code UI

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

* fix: build & access code UI

* feat: connect to cloud printer and monitor

* feat: connect to cloud printer and monitor

* fix: build errors

* feat: camera via webrtc

* fix: build

* fix: cmake

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

* fix: orcaprinteragent refactor

* fix: LAN paths and camera stream

* feat: use ffmpeg to render http camera stream

* fix: make model_id/dev_type optional instead of blocking

* fix: connect via ip dialog

* feat: LAN impl for Orca Printer Agent

* fix: model_id resolution method for non bambu printers

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

* fix: revert sdcard check

* feat: check printer storage status before sending

* fix: moonraker printer agent hang on printer power cut

* fix: shim layer for any compatibiliity changes

* fix: cloud printers were using the wrong MQTT endpoint

* feat: cloud download via HTTP

* temp: doc for intended change

* fix: warnings

* fix: warnings

* fix: camera auto-play on startup

* fix: split infra from impl

* fix: uninitialized ams state blocking print

* Fixes nullptr deref

* Log first before std::move

* fix: printer agent virutal optional functions

* fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests

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

This reverts commit 2f566e3779.

* Guard libdatachannel. Remove unused code

* fix: unit tests & unused variables

* fix(ci): deps build order for datachannel

* Resolve printer agent first before getting cloud printer agent

* fix(ci): set depends openssl

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

* fix(ci): add libdatachannel to flatpak manifest

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

* fix: invoke js clearInterval on WebMediaController::stop

* fix: change rtc log level

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

* fix: revert moonraker specific behavior

* fix: remove stale comment

* fix: use ORCA_CLOUD_PROVIDER instead of hardcoded string

* fix: remove hardcoded ICE servers

* feat: enable https camera stream mode

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

* fix: dedupe compatible printer type check

* fix: stop the correct media controller

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

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

* fix: always build bundled DataChannel dep

* fix: disable unused DataChannel media support

* revert: filament sync work

* fix: wrap command_* with small wrapper

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

* fix: default impl for vendor agnostic gcode commansd

* refactor: media controller playback routing and ownership

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

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

* fix: update windows ffmpeg prebuild

* fix: update printer agent plugin API

* fix: shift camera signaling channel to network agent

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

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

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

* fix: port BBL implementations from #15711

* refactor: connect_printer api and dialog

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

* fix: preserve printer agent defaults in PrinterAgentPluginCapabilityTrampoline

* test: cover printer agent default command dispatch

* fix: validate windows FFmpeg avformat library

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

* fix: handle malformed printer progress values safely

* fix: clear webview document on stop

* chore: reduce diagnostic logging level to trace

* refactor: centralize printer compatibility checks

* tests: add device manager integration coverage

* tests: cover WebMediaController lifecycle with wxWebView stub

* fix: make integration tests headless

* refactor: collapse command_ams_refresh_rfid and command_ams_refresh_rfid2

* refactor: make printer connection SSL agent-specific

* fix: persist input printer host and port

* fix: use correct device id for Moonraker connections

* fix: make moonraker gcode commands asynchronous

* fix: preserve moonraker device names

* fix: add include for non BBL_RELEASE_TO_PUBLIC path in BBLPrinterAgent

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Validation: Snapmaker profile checks and staged whitespace checks passed.

Co-authored-by: Codex <codex@openai.com>
2026-09-28 16:06:39 -04:00
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
Eric McCannandCodex 8a254eb23f Merge upstream updates
Co-authored-by: Codex <codex@openai.com>
2026-09-28 07:08:54 -04: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
Ian Chua ec0d8c225f fix: slicing lifecycle event naming on cancellation (#15889)
# 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?
-->

Avoid calling Print::output_filename() when a slice is canceled or
fails, since unresolved filename placeholders can throw before G-code
export completes.
Instead, we should use the model name in ctx.name and the stable model
ID in ctx.id, consistently across slice and G-code export events.

# 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)
Fix #15885
2026-09-28 12:20:08 +08:00
Ian Chua 3040ebaac1 Merge branch 'main' into fix/slicing-evt-name 2026-09-28 12:19:59 +08:00
Kris Austin 5298e49dd2 fix: Klipper/Moonraker upload errors show a raw Python traceback (#14841) 2026-09-27 19:08:27 -03:00
Eric McCannandCodex 2fcf2222b6 Refresh filament controls after loading printer presets
Synchronize the plater filament controls after preset loading, even when the internal filament list already matches the nozzle count.

Co-authored-by: Codex <codex@openai.com>
2026-09-27 14:14:28 -04:00
Eric McCannandCodex 7ee64d83bb Merge upstream updates
Co-authored-by: Codex <codex@openai.com>
2026-09-27 12:59:09 -04:00
Kenneth Rapleeandyw4z 4964f49765 Fix size_t/%d format mismatch in MsgDialog::add_button (#15852)
Fix size_t/%d mismatch in MsgDialog button keys

`m_buttons.size()` is a `size_t`, which does not match the `%d` conversion in
printf-style variadics. Build the key with `std::to_string` instead; exact
for any size_t, no behavior change for realistic counts.

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-27 19:21:03 +03:00
Eric McCannandCodex d8ca9aa3b3 Merge upstream updates and preserve named nozzle variants
Keep hardened-steel and high-flow nozzle variants selected when refreshing the nozzle dropdown.

Co-authored-by: Codex <codex@openai.com>
2026-09-27 12:19:20 -04:00
Chris Bennight ea471dc82d Fix model initialization without a wx application (#15866)
Guard the smooth-normals preference read when CLI thumbnail generation initializes geometry without a wx application. Use the existing flat-normal path in that case and preserve GUI preferences.
2026-09-27 18:53:11 +03:00
Kiss Lorand bd65cce3f4 Unify GUI scroll rates (#15902)
Use DPI-aware 20 DIP vertical scrolling for general-purpose GUI
scroll areas.

Keep list-based views aligned to their item height so one wheel increment follows the visible row rhythm.

This makes Preferences and other dialogs scroll consistently across
Windows DPI settings.
2026-09-27 16:58:10 +03:00
d976d9eb0c Release the DC after GetDC in get_dpi_for_window and font enumeration (#15919)
get_dpi_for_window's pre-8.1 fallback and get_font_list_by_enumeration
both called GetDC without a matching ReleaseDC, leaking a GDI handle
each call. get_dpi_for_window runs on every mouse-move over the 3D
viewport, so the leak exhausts the per-process GDI handle limit and
hangs the app within minutes on Windows 7/8.

Co-authored-by: Fernando Marino <f.marino@rheagroup.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:19:27 +03:00
Ian Chua d347a80ef8 Merge branch 'main' into fix/slicing-evt-name 2026-09-27 17:57:40 +08:00
peachismomo e340a13c18 fix: use current print for slicing lifecycle identity 2026-09-27 17:53:41 +08:00
peachismomo 9e16cdb23b fix: comment above reset_export 2026-09-27 17:29:25 +08:00
peachismomo 58842bab05 test: test for SliceStarted ensuring stable model ID is presetn when model name is absent 2026-09-27 17:29:01 +08:00
peachismomo 04204ec0d3 test: cover slicing lifecycle event context 2026-09-27 17:25:56 +08:00
Ian Chua ae27a795b6 fix: timestamp format for OFL OTA update endpoint (#15936)
# 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?
-->

The OFL OTA workflow sent an ISO-8601 timestamp to the pending-clear
endpoint, which only accepts Unix epoch seconds. This caused the
scheduled workflow to fail with HTTP 400.

Use `data -u +%s` so that request matches the endpoint contract. 

# 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-27 16:40:04 +08:00
peachismomo ecd0be353c fix: timestamp format for OFL OTA update endpoint 2026-09-27 16:36:15 +08:00
Kiss Lorand 6a07853933 Fix crash with placeholders in file header G-code (#15915) 2026-09-26 17:09:56 -03:00
Noisyfox 5ae9015c82 Fix rare crash due to invalid pointer when vector::resize reallocate underlying memory (#15877) 2026-09-26 16:52:29 -03:00
0ddc730854 No fuzzy skin on bridge like overhangs perimeters (#13891)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
2026-09-26 16:42:44 -03:00
Ian Bassi ea280ba6f6 Wipe tower sparse layers combination (#15841) 2026-09-26 16:27:15 -03:00
Kris Austin 237cd10eb5 fix: Home start shows Prepare or a blank window, and Prepare opens slowly (#15878) 2026-09-26 14:23:52 -03:00
Ioannis Giannakas e77d179bbe Fix inner-outer-inner wall ordering falling back to outer-inner on narrow walls with Arachne (#15924)
* Fix wall ordering edge case
* IOI performance tuning - greedy stop when a first touch is identified.
2026-09-26 17:44:52 +01:00
Ioannis Giannakas d5aaa463c8 Fix MacOS 27 Xcode and Command Line build failures (#15923) 2026-09-26 13:20:17 -03:00
Ian BassiandRodrigo Faselli 8c03985818 Add Cubic Non-crossing multiline strategy (#15887)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-26 11:40:49 -03:00
Eric McCannandCodex d65f97a535 Merge upstream updates
Co-authored-by: Codex <codex@openai.com>
2026-09-26 09:28:24 -04:00
Kris Austin 93b58a2034 fix(gui): keyboard shortcuts cleanup after #15706 (#15862) 2026-09-25 21:51:19 -03:00
Eric McCann ae38570562 Merge remote-tracking branch 'orca/main' into u1-hf 2026-09-25 08:59:21 -04:00
weng haishi 6be6fdd7c7 feat: add timestamp to ofl update workflow so that concurrent post_merge_profiles are not silently dropped (#15898)
# 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?
-->

If a vendor runs `/bot merge` while the cronjob is running, it might be
dropped because the table might be cleared before `post_merge_profiles`
completes. Instead we can add a timestamp so that we don't accidentally
drop any PR merges that occur while the OFL cronjob is running.

# 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-25 17:53:22 +08:00
Ian Chua 521a30a45c feat: add timestamp to ofl update workflow so that concurrent post_merge_profiles are not silently dropped 2026-09-25 16:53:45 +08:00
ExPikaPaka 7766c2e003 Test that a baked texture still slices
The bake is covered in the libslic3r suite, but nothing carried its
result through the slicer - where a mesh it cannot use shows up as a
crash or as a print with no toolpaths, which is what was reported on
macOS and Linux.

Paints a cube, bakes it as the gizmo commits it (mesh replaced, hull
recomputed, paint cleared), slices it and checks the G-code has
toolpaths and layers. Once under its budget and once capped, since a
capped bake also goes through the simplification and the repair.
2026-09-25 10:31:04 +02:00
Ian Chua 35d5ff705b fix: cronjob checks against last ofl-ota-cronjob instead of post_merge_profiles (#15894)
# 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?
-->

The previous implementation checks against the last successful
`post_merge_profiles` which can trigger when a normal OTA update for
non-OFL profiles are made. This causes the check to fail when OFL
changes are made before other regular profile changes are made in
`resources/profiles/<vendor>`

# 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-25 15:09:41 +08:00
Ian Chua 482fc1e719 fix: cronjob checks against last ofl-ota-cronjob instead of post_merge_profiles 2026-09-25 15:04:32 +08:00
Ian Chua d087941289 test: update profiles for ota update (WILL BE REVERTED) (#15891)
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: b73e9df4f4
2026-09-25 06:38:09 +00:00
Ian Chua 9d320954a0 feat: daily OTA publish for OFL (OrcaFilamentLibrary) (#15870)
# 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?
-->
- `OrcaFilamentLibrary` (OFL) has no `FOLDER_MERGERS` delegation and
changes far more often than other vendors, so it can't go through the
existing merge-triggered, human-published OTA flow.
`post_merge_profiles.yml` gains an explicit `vendor` + `auto_publish`
`workflow_dispatch` path that bypasses the `FOLDER_MERGERS` check and
calls the OTA auto-publish API directly, making the update live with no
human step.
- `ofl-ota-cronjob.yml` drives that path daily across `main` and every
`release/vX.Y.Z` branch, checkpointed against
`post_merge_profiles.yml`'s own per-branch run history so a failed
publish is retried rather than silently dropped.
- Fixes a pre-existing bug in `post_merge_profiles.yml`: a single vendor
with no `FOLDER_MERGERS` grant in a push used to zero out the *entire*
vendor batch, silently dropping other, properly-authorized vendors'
publishes too. It now drops only the ungranted vendor, with a warning.

## Notes
Once v2.5.0 stable is released, we need to remove branch checking
against main in both these workflows.

# 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-25 14:17:03 +08:00
Ian Chua ca093d9bf4 Merge branch 'main' into fix/ota-updates-workflow 2026-09-25 14:16:56 +08:00
Ian Chua 9ed459e132 fix: slicing event name and ID 2026-09-25 13:25:45 +08:00
SoftFever da951ad7f3 Load profile includes so synced Bambu Lab presets get their G-code back (#15869)
# Description

This brings back the Bambu Lab profile sync with BambuStudio, reverted
from main, along with its bed-model offset fix. The synced printers and
filaments share their start and end G-code and dual-nozzle settings
through template files that Orca did not read, so those printers had no
machine G-code. Orca now loads these templates the same way BambuStudio
does, so every synced preset comes in complete. `profile_include_dump`,
a new developer tool, compares the result with BambuStudio's.

Other vendors' profiles are unaffected. Bambu Lab profiles move to
version 02.08.00.10, so installs that took the earlier sync update too.

<!--
> 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?
-->

# Screenshots/Recordings/Graphs

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

## Tests

Unit tests cover how template settings combine with inherited and preset
settings, loaded from JSON and from the preset cache. The profile
validator passes on every shipped vendor, and every Bambu Lab preset
gets the same template values in Orca as in BambuStudio.

<!--
> 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-25 13:17:43 +08:00
HanifKoh db10c719f6 Keep the Scarf Seam Free of Sub-Millimetre Segments (#15832)
A scarf joint split the loop at exactly the scarf length, so the remainder
of the segment the split landed in became the first flat segment, often a
fraction of a millimetre. The seam insertion also leaves segments of a few
micrometres at both ends of the loop, which a scarf extrudes through where a
plain loop would start or stop. With junction deviation the planner treats
such short moves as tight corners, limited by the Z axis acceleration at the
end of the ramp, and slows to a third of the wall speed or nearly halts at
the seam.

Extend the ramp to the next vertex when the remainder would be shorter than
half a scarf step, capped at a millimetre, beyond which planners treat a move
as ordinary; the scarf only grows, never shrinks. Drop the vertex next to the
seam point at either end when that segment is shorter than an eighth of a
common line width, so the loop still starts and ends at the seam; a trimmed
path loses its arc fitting and prints as line segments. Clamp the scarf
length to the trimmed loop so a scarf covering a whole loop still ends at
full flow. The descending pass reuses the same path, so both wedges stay
consistent, and a flat part that would collapse to a single point is dropped.
2026-09-25 12:32:05 +08:00
Lam Wei Lun 7cb7465ed8 Speed Dial Fixes (#15867)
# Description

- Force repaint on resize in an attempt to fix random occurrences of it
now working on macOS.
- Updated some text in the speed dial
2026-09-25 11:18:20 +08:00
HanifKoh ddf9b85169 Reserve the Prime Tower When the CLI Arranges a Project (#15837)
The global arrange branch, taken by --arrange with all plates selected, only
reserved the prime tower when filament ids had been given on the command
line for STL input. A project carries its filament use per plate and its own
tower positions, but that set was empty for it, so the tower was never an
obstacle: the arranged pile was centred over it and the slice then failed on
a G-code path conflict.

When no filament ids were given, count the filaments each plate uses and
reserve a tower on every plate that needs one, keeping the project's tower
position instead of resetting it to the default. Only a tower the slicer will
print is reserved: the prime tower must be enabled, and a by-object print
gets none unless a smooth timelapse needs it, as the per-plate arrange
decides. Overflow beds are sized for the busiest plate. The STL route is
unchanged.
2026-09-25 08:39:52 +08:00
HanifKoh af52da061f Report Per-Object Slicing Errors in the CLI (#15834)
G-code generation collects errors raised per object, such as an empty
first layer, into one SlicingErrors exception whose own message is just
"Errors". The CLI's generic handler printed that word and recorded the
generic slicing error text, so a headless caller had nothing to act on.

Let Print render the per-object messages with each object's name, and have
the CLI catch SlicingErrors ahead of the generic handler, print that text
and record it as the result's error string. The exit code is unchanged. A
unit test lifts a cube off the bed and checks the message names the object.
2026-09-25 08:37:28 +08:00
Kris Austin 87a5d20d4c fix(gtk): crash at startup when opening a project with Home as the start page (#15876)
Since #15811 the settings page is built when its tab is shown, so on a
Home start that opens a project the Quality page is built on screen. Its
flow-compensation-model field is a multiline text view that GTK maps as
it is created and that is hidden, because compensation is off, before
GTK first allocates it. The loading dialog's wxWindowDisabler then
desensitizes the frame, and GTK crashes in
gtk_text_layout_cursors_changed() on that text view.

On GTK the page view is now hidden while a page is built, so a control
that starts hidden is never realized and GTK realizes it when it is
shown. The deferred build is unchanged.
2026-09-24 17:37:46 -03:00
Kris Austin 0db1dc6480 build: keep header dependencies through a compiler cache hit on clang-cl (#15875)
For clang-cl, CMake sets the depfile flags to the gcc-style -MD, -MT and -MF,
passed through as -clang: arguments. ccache does not parse those, so a cache
hit writes only the object. Ninja has no depfile to read, so it records zero
header dependencies for that object and does not rebuild it after a header
edit. The stale object is still linked into the library and the DLL. sccache
0.15.0 reproduces the depfile on a hit and is unaffected.

Use /showIncludes instead. CMake already does that for MSVC, and ccache
reproduces it on a hit. A make-rules override sets the flags, because CMake
includes the override after Platform/Windows-Clang.cmake. It is forwarded to
each dependency because every one configures as its own CMake project, and it
is only set when the file exists, because scripts/flatpak/make_deps_tar.sh
packs deps/ alone.

In a build with a warm cache, 678 of 790 objects had no recorded headers.
Object code does not change. One GUI translation unit compiled both ways is
byte-identical apart from the COFF timestamp.
2026-09-24 14:06:24 -03:00
Kris Austin 42009cf385 fix: Linux Flatpak crash rendering plate thumbnails while slicing or saving (#15873) 2026-09-24 13:41:56 -03:00
SoftFever 0e2fbf781d restore transparent cover image 2026-09-25 00:17:09 +08:00
SoftFever b535a81064 Fix: support per-nozzle top_solid_infill_flow_ratio 2026-09-24 23:52:55 +08:00
Eric McCannandCodex 517286c93d Apply consistent output filenames to all Snapmaker U1 presets
Extend the existing U1 filename format to the base process preset and let Benchy presets inherit it. Bump the Snapmaker catalog version.

Co-authored-by: Codex <codex@openai.com>
2026-09-24 08:50:54 -04:00
Kris Austin 9859d788d4 fix: installed vendor profiles only update when OTA is enabled (#15831)
* preset updater: refresh installed vendors from resources regardless of enable_ota

Since c4fea8ad24 the resources check in check_installed_vendor_profiles()
sat behind enabled_config_update, which now follows enable_ota, a hidden
flag that defaults to off. The hotfix a93c6ea67b then made that gate skip
installed vendors entirely, so a new build's newer vendor profiles were
never installed over an existing vendor unless OTA had been turned on.
Before the gating the update URL always had a default, so the comparison
effectively always ran.

The resources shipped with a build are not an over-the-air update. Judge
installed vendors against them, and drop the ones no longer enabled,
regardless of the flag; enable_ota keeps gating the online sync.

* preset updater: stop reinstalling the filament library on every launch

check_installed_vendor_profiles() put OrcaFilamentLibrary on the install
list unconditionally, so every launch recopied the whole vendor from
resources and, in a build that ships the profile JSONs rather than a
preset cache, then re-parsed and re-cached it: about 0.3 s of a dev
build's startup, and a 3 MB copy in a release build, for a vendor that
had not changed.

The library was special-cased because it is never in the enabled-vendor
list. Treat it like the default bundle instead: always wanted, and
reinstalled only when the resources carry a newer version.
2026-09-24 09:05:41 -03:00
Ian Chua 879f6b67e9 fix: use --method GET 2026-09-24 19:56:51 +08:00
Ian Chua 4ccb5648e6 fix: OFL pending-record step, missing permission and event-type gap 2026-09-24 19:37:16 +08:00
Ian Chua aed0164ea1 fix: update pending db on merge 2026-09-24 16:15:19 +08:00
SoftFever 434ff3011f Fix: Update tree support wall count default value and improve G-code loading for Bambu Lab profiles 2026-09-24 15:45:35 +08:00
Lam Wei Lun 0ee529e283 Fixes missing Plugins category for installed plugins 2026-09-24 15:25:11 +08:00
Ian Chua 6e055e5d8b fix: invoke endpoint to clear pending queue 2026-09-24 14:59:53 +08:00
Ian Chua 15b64522a4 fix: single vendor with no grant in a push will zero out the entire vendor batch 2026-09-24 13:36:37 +08:00
SoftFever 2b602943a5 Load profile includes so synced Bambu Lab presets get their G-code back 2026-09-24 13:22:37 +08:00
SoftFever d51ad889fd Reapply "Restore the Bambu Lab bed-model offset so stock BambuStudio meshes line up"
This reverts commit bf024ed34e.
2026-09-24 13:15:14 +08:00
SoftFever edc2f8bf90 Reapply "Sync Bambu Lab profiles with BambuStudio"
This reverts commit b46916a8be.
2026-09-24 13:15:04 +08:00
Lam Wei Lun 7ae76e44b1 Merge branch 'main' into feat/speed-dial-fixes 2026-09-24 10:42:19 +08:00
Lam Wei Lun 54c32a54b4 Change some search actions and results text. Force repaint for when resizing and show happens 2026-09-24 10:41:55 +08:00
Valerii Bokhan 037b859447 Fix: Correct camera panning for the perspective view (#15212) 2026-09-23 20:38:26 -03:00
Ian BassiandRodrigo Faselli 02746cd0f5 Disable SSAO for grid (#15865)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-23 20:38:04 -03:00
Kris Austin 66b300987b feat: faster startup by lazy-loading main window panels on idle or first use (#15811) 2026-09-23 19:11:10 -03:00
Kris Austin 9bae19fcb3 fix: the 3D scene is drawn in the wrong view for the first half second of startup (#15830) 2026-09-23 18:21:16 -03:00
ExPikaPaka 14ee1e3d3a Fix the Windows crash on a truncated PNG
libpng reports a corrupt image by longjmp()ing to the jump buffer the
decoder sets. Both decoders set theirs in a frame holding a PNGDescr and
a vector, and with exceptions enabled MSVC unwinds the stack as part of
longjmp - so returning from that frame afterwards crashed. A truncated
texture segfaulted the Windows test run and was fine everywhere else.

The calls that can fail now sit in two helpers that own nothing but
pointers, and the C++ objects stay in the callers' frames.
2026-09-23 22:53:45 +02:00
ExPikaPaka c4a19bff1d Give an empty patch an upright frame
texture_displacement_patch_frame picks the world axis least aligned with
the patch's average normal. With no patch there is no normal either, and
the axis came out of the +Z fallback for it - so it was +X, while its
test asked for the upright frame the fallback is meant to give. An empty
patch now answers +Z outright.
2026-09-23 18:01:46 +02:00
ExPikaPaka 4504f315ee Include what the new code uses
The Windows build stopped on test_kdtree.cpp: it calls std::iota without
including <numeric>, which libstdc++ happens to pull in anyway. Added
there, and the same for <limits> and <algorithm>/<cmath> where the
recent changes rely on them being included by something else.
2026-09-23 16:16:46 +02:00
ExPikaPaka 56092f0367 Fix the Windows build: near and far are macros there
bridge_over_infill's helper for splitting polygons by proximity named
its locals near and far. The Windows headers define both as macros that
expand to nothing, so "Polygons near;" declared nothing and the uses of
it did not compile. Renamed; no behaviour change.
2026-09-23 14:51:07 +02:00
Ian Chua 0030bed519 fix: ota updates workflow 2026-09-23 20:37:25 +08:00
Lam Wei Lun fcd8131998 WebGuide's Filament Dialog Fix to ensure filaments inheritance chain is checked properly (#15855)
* fix: resolve filament vendor/type across split base presets

WebGuide's filament list dropped presets whose vendor and type came from different ancestors, and looped forever on inherits cycles. Walk the full inherits chain with a path-scoped guard, fill only missing values, and skip presets that still lack vendor or type. Add tests for split-base resolution and validate the real profile tree.

* Merge branch 'main' into feat/filament_dialog_fix

* Fixes unit test

* Merge branch 'main' into feat/filament_dialog_fix

* Merge branch 'main' into feat/filament_dialog_fix

* revert tests/fff_print/test_gcodewriter.cpp changes
2026-09-23 20:23:52 +08:00
ExPikaPaka 944f01c68a Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-09-23 14:08:47 +02:00
a8f1061a02 Add Sovol Zero nozzle profiles and filament profiles (#15756)
* Add Sovol Zero nozzle profiles and filament updates

Preserve inherited tuning with Zero-only overrides so shared
Sovol defaults and other printers remain unchanged. Retain migration
aliases while using canonical names in compatibility references.

Co-authored-by: Codex <noreply@openai.com>

* Fix Sovol Zero bed model reference and ignored settings

Point the Zero model at the shipped bed mesh. Remove unsupported filament
and process keys that the loader silently discards, preserving effective
tuning and the existing preset names and IDs. Bump the Sovol bundle version.

Co-authored-by: Codex <noreply@openai.com>

* Fix Sovol Zero filament identity and vector formatting

Add hardened-steel nozzle variants with disjoint filament compatibility, retain generic product IDs and preserve renamed preset aliases. Convert 43 scalar vector fields to arrays without changing tuning values and bump the Sovol bundle version.

Co-authored-by: Codex <codex@openai.com>

* Keep only shipped names in the Sovol Zero profile renames

The Zero machines, processes, machine model and SPEEDBENCHY filament carried renamed_from entries for names that only ever existed inside this branch (Sovol ZERO ..., " - Brass", " - Hardened Steel"). Drop those, and keep only the redirects that matter to existing users: the historical Sovol Zero material names and the three deleted hardened-steel presets. The Sovol Zero machine model also sheds the setting_id, instantiation and from keys the model loader ignores.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-23 20:05:01 +08:00
ExPikaPaka b3216b7f6a Wrap the budget warning, count triangles in thousands
Unwrapped, it ran past the edge of the panel and pulled the layout with
it. Counts under a million now read in thousands, so a 119 k budget is
no longer shown as 0.1 M.
2026-09-23 13:22:57 +02:00
ExPikaPaka 9a2eadf9cc Rewrite the texture displacement tooltips
They described the implementation, and a few no longer matched the code.
Each now says what the control does and in what units; five controls that
had no tooltip have one. The catalogs need regenerating.
2026-09-23 13:16:56 +02:00
Eric McCann 3f5faa75a7 Merge remote-tracking branch 'orca/main' into u1-hf 2026-09-23 06:58:04 -04:00
ExPikaPaka 1b029ff8fa Simplify flat faces even when a bake fits its budget
Nothing removed the even mesh over a texture's flat parts unless the
budget was lowered until simplification had to run. Collapses stay under
a thirty-second of the resolution: on the test map 387584 -> 369876
triangles, with the print unchanged (0.02% of the extrusion).

An earlier attempt was reverted for a 21% heavier result. That was the
colour despeckle switching itself off, not the collapsing: it keyed off
the face parent map, which any collapse empties.
2026-09-23 12:19:58 +02:00
SoftFever b46916a8be Revert "Sync Bambu Lab profiles with BambuStudio"
This reverts commit cc883e458d.
2026-09-23 18:19:23 +08:00
SoftFever bf024ed34e Revert "Restore the Bambu Lab bed-model offset so stock BambuStudio meshes line up"
This reverts commit 7758332406.
2026-09-23 18:19:03 +08:00
Sylvain 0211277976 Add Wanhao Duplicator 9 profiles (D9/300, D9/400, D9/500) (#15773) 2026-09-23 17:24:17 +08:00
ExPikaPaka 0a543644ef Give each bake its own budget, and warn when it is short
Relief preserved from an earlier bake is counted on top of the budget
instead of eating into it: baking a second area went from ~214 k
triangles to the ~1000 k it was given.

When the resolution needs more triangles than the budget allows, it is
said before the bake under Resolution and again afterwards.
2026-09-23 10:26:24 +02:00
ExPikaPaka 7fd12ed1ee Block undo and redo while a background job runs
A job is queued against the model as it stands, so undoing underneath it
left the bake landing on geometry it was never computed for.
2026-09-23 09:51:17 +02:00
ExPikaPaka 9327e7770b Rename the fast preview to the shaded preview
Its shader files, the name they are registered under, its members and
the panel text now match what it does: shading only, no geometry.
2026-09-23 09:51:08 +02:00
SoftFever 7758332406 Restore the Bambu Lab bed-model offset so stock BambuStudio meshes line up 2026-09-23 14:58:41 +08:00
SoftFever cc883e458d Sync Bambu Lab profiles with BambuStudio
Polymaker, Overture and eSUN presets move from brand subfolders to
BBL/filament/, matching BambuStudio's layout; their names and ids are
unchanged. Default filament lists keep pointing at presets that exist,
0.2 mm nozzles keep a default PLA, and user presets based on removed or
renamed presets still load. Generic SBS is no longer in the Bambu bundle;
new selections on those printers fall back to OrcaFilamentLibrary's
Generic SBS @System. The profile tool now recognises BambuStudio's new
filament id/name maps and support_recommended_params.json as data files.
2026-09-23 14:58:41 +08:00
SoftFever 24f380963b Move Orca-only third-party BBL filaments to OrcaFilamentLibrary
The Polymaker, BETA, COEX, Overture, addnorth, Numakers, FusRock and
AliZ presets that BambuStudio does not ship now live under
OrcaFilamentLibrary/filament/<Brand>/BBL. The BBL bundle now holds
only the filament presets BambuStudio ships, plus a few Bambu and
generic ones.

New "<product> @BBL base" presets carry the values these presets used
to get from BBL's own bases, so their settings on Bambu Lab printers
are unchanged.
2026-09-23 13:49:23 +08:00
SoftFever c5bbb6e031 Merge branch 'main' into u1-hf 2026-09-23 13:13:32 +08:00
HanifKoh ef956b995a Pick the Parity Build From the Unfiltered Run List and Allow Pinning One (#15823)
The nightly found its build with a filtered run listing (branch=main,
status=success) and trusted the first result. GitHub serves filtered
listings from a run search index that has intermittently returned
weeks-old results, so some nights tested a build from weeks earlier and
reported its differences as regressions. The same filter also matched
fork PR builds whose branch is named main.

The build is now picked from the unfiltered listing, which stays
current, and filtered here: a successful build_all run of this
repository on the requested branch. Fork PR builds are excluded by
repository. A feature branch is normally built only for its PR, so this
repository's own PR builds stay eligible, but a PR build compiles the PR
merged into its base rather than the head commit the later jobs check
out, so a push or dispatch build of the branch is preferred when the same
page of the listing has one. A scheduled run fails instead of testing a
build more than 48 hours old, and every run names the build it tested
in the job summary.

Manual runs scan further back, so a branch that last built weeks ago
can still be tested, and a new build_run_id input pins one build_all
run, read directly rather than through a search.
2026-09-23 12:57:15 +08:00
Ian Chua 82b860480f feat: initial draft of lifecycle events API for plugins (#15293)
# Description

This PR introduces lifecycle events to the plugin API. 
For all plugin capabilities, you can define a `on_lifecycle_event`
function in the plugin that takes in a event enum and a small payload
for some generic information on the lifecycle event.
The idea is to keep the payload generic and small, and if you want to
get more information, you should invoke other more targeted APIs to get
more information.
For example, lets say you are keeping track of the the `ObjectAdded*`
event hook for model transformation, addition or deletion. The payload
would tell you the name of the model, and you should use a targeted API
such as `orca.host.plater().model()` to get more information on the
model. This is the overall design principle of the API.

Currently the lifecycle events are the following:
```cpp
    enum class LifecycleEvent {
        // Project (3mf)
        NewProject,
        ProjectOpened,
        ProjectBeforeSave,
        ProjectAfterSave,
        ProjectClosed,
        ProjectDirtyChanged,

        // Slicing pipeline
        SliceStarted,
        SliceGeometryFinished,
        GCodeExportStarted,
        GCodeExportFinished,
        SlicingJobComplete,

        // Plate/model editing
        ObjectAdded,
        ObjectDeleted,
        ObjectTransformed,
        ObjectChanged,
        ObjectRenamed,
        PlateCreated,
        PlateDeleted,
        PlateSelected,
        PlateRenamed,

        // Preset
        PresetSelected,
        PresetSaved,

        // Printer/device
        PrintStateChanged,
        DeviceOnlineChanged,
        DeviceDiscovered,
        DeviceSelected,
        DeviceConnected,
        DeviceDisconnected,
        UploadStarted,
        UploadFinished,

        // Print/send jobs
        PrintJobStarted,
        PrintJobFinished,
        SendJobStarted,
        SendJobFinished,
    };
```

This is an initial draft and lifecycle events can be included later on.

[orca_telegram_notifier_plugin_any.py](https://github.com/user-attachments/files/31220973/orca_telegram_notifier_plugin_any.py)

If you're familiar with telegram bots, after you install the telegram
bot, in the config of this plugin, you can enter the Bot ID and the Chat
ID with said bot.

# 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.
-->

For this plugin, I am testing it with a telegram bot that sends me a
message on lifecycle event.


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

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-23 12:52:08 +08:00
HanifKoh 89dc4e1b99 Add an Align to Y Axis Option to the CLI Arrange (#15836)
The CLI turned "align to Y axis" on for every i3 printer with no way to opt
out. With rotations forbidden the pre-rotation is the result, so every object
ends up turned 90 degrees from how it was loaded. The GUI defaults the
checkbox the same way for i3 printers, but lets the user untick it.

Add --align-to-y-axis. When it is not given the printer-structure rule still
applies, so existing calls are unchanged; the CLI's own options are filled
with defaults after parsing, so the keys the user typed are remembered to
tell the two apart.
2026-09-23 12:43:08 +08:00
HanifKoh 12670a6e40 Answer the Preview's Per-Frame Lookups From Cached Sums and Draw Segments From an Index Buffer (#15833)
* Answer the Preview's Per-Frame Time Query From a Cached Sum

The G-code preview's cost is linear in the number of toolpath vertices, and on a
tall multi-filament print the wipe tower dominates that count: it emits a roughly
constant 160-180 moves on every layer whatever the object is, measured at 57-61%
of all moves on a three-filament print.

Four places scanned or allocated across the whole vertex array. None of them
needed to.

get_estimated_time_at re-accumulated the estimated time from vertex 0 on every
call, and its caller is the tool marker tooltip, which ImGui re-renders every
frame while the properties panel is unfolded. It now starts from a running sum
kept at each layer's first vertex, built at load in vertex order, and adds only
that layer's vertices: the same additions in the same order, so the float result
is unchanged, at a cost of one float per layer and time mode rather than per
vertex. At the 351k vertices of a 636-layer test print the call scanned the whole
print (238us); it now scans one layer.

update_view_full_range walked from vertex 0 to find where the layer range starts,
on every slider tick. It now starts at the first vertex of that layer. The index
is derived from the vertices rather than from Layers::Item::range, because
Layers::update folds a vertex whose layer_id arrives out of order into whichever
bucket is open, which makes that range the wrong answer in general; the index
costs four bytes per layer, not per vertex.

update_colors_texture allocated one float per vertex of the whole print on every
slider tick. It now reuses a buffer.

render_legend fetched the layer Zs and the per-layer times from inside loops over
the custom G-code items, and built whole vectors only to test them for emptiness.
The times are hoisted, the Zs are built lazily so a print with no colour change
does not pay for them at all, and the emptiness tests use the existing counters.

No rendering behaviour changes.

* Draw the Preview's Toolpath Segments From an Index Buffer

The preview's frame cost is dominated by one call: a single instanced draw of
every visible toolpath segment. On a tall multi-filament print the wipe tower
supplies most of those segments, which is why the preview of a large tower is
slow and why shrinking the layer range speeds it up again.

That draw is not fill bound. Shrinking the model to about a fortieth of its
screen area moved the frame from 419 ms to 401 ms, so the cost is per segment,
not per pixel, and it is paid in the vertex shader: five texelFetch calls plus
several cross/normalize per invocation.

Each segment is a box of eight corners, but it was submitted with
glDrawArraysInstanced over a 24 entry array, so every corner was transformed
once per triangle that touches it and the shader ran 24 times per segment. The
same 24 entries are now an element buffer over the eight distinct corners, which
lets the post-transform cache reuse them and drops the shader to 8 runs per
segment. The triangles, their winding and the vertex_id each corner receives are
unchanged.

Measured over 100 frames on the 636-layer, 351k-vertex three-filament fixture,
the segment draw goes from 381 ms to 322 ms per frame. That is a software
rasterizer, where triangle setup dominates and understates the win; the drop in
shader invocations is the transferable part.

Verified by loading the same project in this build and in a build of the parent
commit and comparing the canvas across three states - the default view, a
rotated camera, and a reduced layer range: pixel identical in all three. The
rotated case matters because the shader picks its corner offsets from the camera
direction. The only pixels that differ anywhere on screen are in the G-code text
panel, which prints a per-process object id that varies between any two runs.
2026-09-23 12:06:02 +08:00
9454ca7454 Add Prusa CORE One MMU3 profiles (#15757)
* Add Prusa CORE One MMU3 profiles

Dedicated MMU3 CoreOne profiles (like those used on prusaslicer 3).

Tested with latest coreone and mmu3 firmware, and works as well as prusaslicer.

Correct model default materials to reference compatible MMU3 filaments.

Co-authored-by: Codex <noreply@openai.com>

* Consolidate the CORE One MMU3 generic filaments

The bundle shipped two families covering the same four MMU3 variants for
each generic material: a standalone `Generic X @MMU3` and a
`Prusa Generic X @CORE One MMU3`. The `Prusa Generic` spelling was renamed
away from the rest of the tree, so re-name the CORE One-tuned family to
`Generic X @Prusa CORE One MMU3` (they inherit the CORE One tune and now the
shared generic product id) and drop the standalone files, which only carried
raw material-base values. Repoint the model default_materials and re-register
the index.

* Fix the CORE One MMU3 0.4 default process

default_print_profile named `0.20mm Speed @COREONE0.4 + MMU3`, but the
preset is `0.20mm SPEED @COREONE0.4 + MMU3`. Preset lookup is case-sensitive,
so the intended default never resolved and compatibility selection silently
picked another tier.

* Normalise the CORE One MMU3 process names

Match the bundle's all-caps quality ladder: `Fast Detail` -> `FAST DETAIL`,
`Speed` -> `SPEED`, `Structural` -> `STRUCTURAL`, `Balanced` -> `BALANCED`.
Filenames now equal their preset name, as every pre-existing Prusa process
file does, and the index is rebuilt for the renamed entries.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-23 11:50:17 +08:00
ExPikaPaka 6e88ad7f52 Visit seam candidates as the search finds them
Collecting every candidate within the radius into a vector cost more
than the search itself. Same order, so the seams are unchanged;
align_seam_points ~19.6 s at 0.1 mm / 2000k, was ~21.
2026-09-23 02:04:49 +02:00
Ian Bassi e71497738f Configurable default G-code preview view type (#15769) 2026-09-22 20:50:59 -03:00
Ian BassiandRodrigo Faselli d820303a3f Improve preview colors (#15809)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-22 20:50:18 -03:00
ExPikaPaka 3023ca0d38 Run a layer's regions in parallel where they are independent
detect_surfaces_type, process_external_surfaces and the vertical shells
each waited on their own heaviest layer in turn. The LOTR map plate
slices in ~10.5 min at 0.1 mm / 2000k, was ~11.5; ~87 s at normal
settings, was ~97.
2026-09-22 22:14:39 +02:00
Ian Chua 01fcd71aa3 Merge branch 'main' into feat/plugin-lifecycle-evts 2026-09-23 03:16:07 +08:00
peachismomo 9851874742 fix: fire PrintJobStarted only after preflight validation 2026-09-23 03:12:14 +08:00
peachismomo ff96cce7c3 fix: update lifecycle contract for printer connection 2026-09-23 03:09:03 +08:00
peachismomo 4a4c649dbb fix: dispatch task lifecycle events on one worktre thread 2026-09-23 02:51:35 +08:00
peachismomo fb825acbd5 fix: use original project name 2026-09-23 02:46:44 +08:00
Kris Austin f83bfa17ff ci: keep older compiler cache entries when the save wrote nothing (#15828)
#15668 saves the compiler cache on cancelled and failed builds and then
drops the older entries for the leg on the ref. actions/cache/save only
warns when its tar fails, so a cancelled build whose ccache directory
was still being written saved nothing, the drop ran anyway and deleted
the leg's last good entry. The next run on main restored nothing and
compiled cold, and so did every PR that restored in the gap. Run
35405244634 (Flatpak x86_64, 2026-09-18) did this to
ccache-Flatpak-x86_64-35397824860-1; between 13 and 18 September 9 of
87 cancelled main build jobs did the same.

Look the new entry up before deleting anything, and keep the older ones
when it is not there.
2026-09-22 15:41:30 -03:00
peachismomo 24ddd81d7d feat: stop lifecycle dispatch after slicing cancellation 2026-09-23 02:31:46 +08:00
peachismomo 0fb8f3d487 fix: synchronize lifecycle hook shutdown with active dispatches 2026-09-23 02:14:56 +08:00
7ca2b9ad9c update FlyingBear Ghost7 0.4 nozzle.json, InfiMech EX 0.4 nozzle.json… (#15707)
update FlyingBear Ghost7 0.4 nozzle.json, InfiMech EX 0.4 nozzle.json, InfiMech EX+APS 0.4 nozzle.json

Co-authored-by: Flyingbear <adam@3dflyingbear.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-09-23 01:46:05 +08:00
SoftFever 61b503ca3a Merge branch 'main' into u1-hf 2026-09-23 01:28:11 +08:00
ExPikaPaka e46eecc716 Move polygons instead of copying them on move
MultiPoint had no rvalue constructor, so the derived move constructors
bound to the const reference and copied; append reserved exactly, so
collecting pieces one by one was quadratic. Colour segmentation ~3 s at
0.1 mm / 2000k, was ~40, and ordinary prints gain too.
2026-09-22 19:19:54 +02:00
ExPikaPaka d3485e8b63 Project painted faces onto the shell layers per tile
Only the slices within the deepest shell offset decide the result, so
the work is done per tile of the face. Top and bottom segmentation
~130 s at 0.1 mm / 2000k, was ~180.
2026-09-22 19:19:54 +02:00
ExPikaPaka 5baefd8a4e Tile the booleans on layers of many pieces
ClipperLib slows down with the number of edges on a scan line, and a
layer cut through a fine relief has tens of thousands of pieces.
detect_surfaces_type ~50 s at 0.1 mm / 2000k, was ~145.
2026-09-22 18:06:54 +02:00
226e95f734 feat(profiles): add Lulzbot Mini 1 (#15476)
Adds the LulzBot Mini 1 (single extruder, 0.5 mm nozzle, 2.85 mm filament)
to the existing Lulzbot vendor, which previously shipped only TAZ models.

Values are taken from LulzBot's own current slicer configuration
(github.com/lulzbot3d/CuraLE) rather than estimated:

  - geometry and custom g-code from resources/definitions/single_mini_mini_1.def.json
    and resources/gcodes/mini_1/{mini_1_start,mini_1_end}.gcode
  - filament temperatures and cooling from resources/materials/*.xml.fdm_material,
    overridden by resources/quality/single_mini/<material>/*.inst.cfg

The start g-code reproduces the Mini's nozzle wipe and four-washer G29 probe,
with the filament-type temperature conditionals used by the sibling TAZ profiles.

One deliberate departure from current CuraLE: the pre-wipe retract is 30 mm,
the value used by Cura LE 4.13.x, rather than the 4 mm current CuraLE uses.
The Mini probes by electrical contact between nozzle and washer, so the nozzle
must stay clean through the wipe and all four touches. At 4 mm the melt zone
stays full and can ooze onto a washer, which caused auto-levelling failures on
hardware; 30 mm empties it. The cost is a ~24 s purge at print start.

The three filament presets follow docs/HLSD/filament_id.md rule 4: a vendor
tuning a generic material inherits Generic X @System, keeps the Generic X base
name and declares no filament_id, so identity stays with OrcaFilamentLibrary.
Their compatible_printers is the Mini alone, disjoint from Generic X @Lulzbot
(TAZ only) as rule 3 requires. They exist because temperature is a filament-scope
setting, so LulzBot's values need printer-scoped presets. All six plate types
carry the same temperature, because the Mini has a single bed and curr_bed_type
can hold a stale value carried over from another printer.

Strictly additive: no existing profile is modified, so TAZ behaviour is
unchanged and no migration is required. Lulzbot.json is bumped to 02.04.00.05.

Verified with scripts/orca_profile_tool.py check and scripts/check_profile.sh
on the full tree (profile tool, system validation, slice, filament subtypes and
custom-preset fixtures all pass), by diffing sliced output against LulzBot's own
Cura LE g-code for the same model and filament (temps, retraction, speeds and
the full wipe/probe sequence match), and by printing a 3DBenchy on a Mini 1 over
OctoPrint.


Claude-Session: https://claude.ai/code/session_012aLyqsXB7FKqwQdE2QF7Et

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-09-22 23:53:15 +08:00
SoftFever 37b3f9b0b5 Check preset names against the conventions during profile review 2026-09-22 23:21:04 +08:00
Eric McCannandCodex c9dbf6fdbd Merge upstream updates into reviewed U1 work
Integrate upstream commit 301f2ecba3 while retaining the U1 high-flow profiles and nozzle-selection behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 10:44:30 -04:00
ExPikaPaka b3963b2a49 Merge colour and top/bottom regions per island
The merge took anything from 3 to 38 minutes at 0.1 mm / 2000k, now
~2.5. Every region is grouped with the islands it overlaps, so the
result is the same.
2026-09-22 16:43:53 +02:00
SoftFever 301f2ecba3 Reject a machine model name declared by two bundles
Preset::get_printer_type matches a preset's printer_model against every vendor's model names and returns the first hit, so two bundles declaring one name make the lookup order-dependent, and the Add Printer list shows the printer twice. The existing name check is per bundle, because base profiles share names across vendors by design; this one covers the global machine_model namespace and runs over the whole tree, like the setting_id and filament_id checks.
2026-09-22 22:15:36 +08:00
Eric McCannandCodex 2f05dbf577 Fix Snapmaker U1 profile defaults and extruder vectors
Register high-flow filament defaults for every U1 nozzle variant in the
wizard, and select compatible filaments for the standard and mixed-nozzle
printers. Size the shared U1 extruder vectors to its four physical tools.
Replace stale 3x flow claims with the source-specific filament guidance.
Bump the Snapmaker bundle version.

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 09:39:11 -04:00
592a5ba777 Fix and rework Ultimaker profiles (#15383)
* Rework Ultimaker profiles

The Ultimaker profiles where not usable by default

* Fixes

* couple small fixes

* Rework Ultimaker profiles

The Ultimaker profiles where not usable by default

* Fixes

* couple small fixes

* Fixes

* fix errors

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 20:50:12 +08:00
jkuhl-devandSoftFever 346b44b24a Port over Prusament filament profiles for MK3/S and MK4/S from PrusaSlicer (#15336)
* Port over Prusament filament profiles for MK3 and MK4/S from PrusaSlicer

* fix errors

---------

Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 20:14:24 +08:00
WegerichandSoftFever f31e610b72 Ensure Snapmaker U1 filename respects filament selection (#15349)
Fixed bug to ensure filename format respects multi-extruder filament selection

Improved filename format to ensure that the selected filament is included in the filename and not the [0] filament

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-09-22 20:13:25 +08:00
Robert BakerandSoftFever bad4a4e66d Add file_start_gcode to Anker fdm_marlin_common.json (#15339)
This is needed for the AnkerMake M5 and M5c to have correct print time estimates. Fixes a random  super-long time on the M5 touchscreen.

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-09-22 20:08:26 +08:00
d5662b3e3c Fix buildplate asset references and refresh printer covers for some vendors (#15267)
* Anycubic Kobra 3 Max

* Update Anycubic Kobra S1 Max_cover.png

* Update Anycubic Kobra X_cover.png

* Update bbl-3dp-H2C.stl

* bbl

* creality

* Update creality_SPARKX_buildplate_model.stl

* creality sparkx

* ender3 v4

* Update Elegoo Centauri 2_cover.png

* Flashforge creator 5

* lh stinger

* Update Qidi.json

* Update Creality.json

* Update CoLiDo SR1_cover.png

* Update Cubicon xCeler-Mini_cover.png

* qidi xplus 5

* revert

* bump versions

---------

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 20:00:13 +08:00
a84c323b28 Fix Raise3D Pro3/Pro3 Plus printable_area (single vs dual extruder) (#15243)
Fix incorrect printable_area for Raise3D Pro3 and Pro3 Plus profiles

All Raise3D Pro3/Pro3 Plus machine profiles (Left, Right, Dual) shared
the same 340x300 printable_area regardless of single vs dual extruder
use. This overstated single-extruder X travel by 40mm and failed to
shrink the Dual profile to the real nozzle-overlap zone.

Corrected to Raise3D's published build volume specs:
- Single extruder (Left/Right): 300 x 300 mm
- Dual extruder: 255 x 300 mm

printable_height (300 for Pro3, 605 for Pro3 Plus) and origin (0,0)
are unchanged; both were already correct.

Source: https://www.raise3d.com/pro3-series/

Co-authored-by: Jon Ashton <ashtonj@zentechman.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-09-22 19:43:28 +08:00
d632843fca Creality K2 Plus Filament Profile Updates (#15237)
* Increase max volumetric speed for CR-ABS filament

* Update filament settings for CR-PETG profile

* Increase max volumetric speed from 16 to 18

* Increase max volumetric speed from 16 to 18

* Increase max volumetric speed for CR-PLA Matte

* Adjust filament temperature and speed settings

* Update filament settings for ENDER FAST PLA profile

* Modify pressure advance and filament load settings

* Adjust filament cost, speeds, and pressure advance

Updated filament settings for Hyper PETG @K2 Plus.

* Update filament cost and pressure advance values

* Increase max volumetric speed from 10 to 14

* Enable pressure advance in filament profile

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 19:36:25 +08:00
396a12064a profiles: add Anycubic Kobra 3 V2 support (#15229)
* profiles: add Anycubic Kobra 3 V2 support

* fix errors

---------

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 19:30:49 +08:00
ExPikaPaka cfde702d09 Slice fine texture relief without stalling
A colour texture baked at 0.1 mm / 2000k made the top layers thousands
of islands and slicing never finished. Colour segmentation runs per
island, the merge subtracts piece by piece, the support check tests only
nearby islands, and the travel ordering finds crossings through a grid.
2026-09-22 13:06:44 +02:00
6183007c28 profile: Wondermaker - reorganize the ZR Ultra family and fix the Ultra S tool count (#15179)
* profile: reorganize the ZR Ultra family and fix the Ultra S tool count

- Adds a new fdm_ultra_common profile to hold all common ZR Ultra toolchanger attributes.
- The S variants are the base machines plus an enclosure heater and filtration, so each now inherits its matching ZR Ultra profile instead of duplicating the per-nozzle values
- Also fixes Ultra S 0.6 and 0.8 - original were declared a single nozzle_diameter entry, so OrcaSlicer treated four-tool machines as single-extruder.
- ZR Ultra S 0.8's retraction_minimum_travel now matches the base Ultra.
- nozzle_diameter stays declared on each S variant rather than inherited, even
  though the value is identical to its parent's. Several profile consumers read
  these files without resolving `inherits` -- the config wizard's loader and the
  web Profiles page among them -- and 1012 of the 1013 instantiated machine
  profiles in the tree declare it, so this is the format's expectation rather
  than redundancy. The same rule is enforced for filaments' compatible_printers
  by scripts/orca_extra_profile_check.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix errors

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 19:03:25 +08:00
Kiss Lorand b23953d7cf Fix missing runtime DLLs in Windows Install target (#15791)
fix: install all Windows runtime DLLs

Keep the runtime DLL list local while it is assembled.

The previous PARENT_SCOPE assignment exported the initial list before the OCCT DLLs were appended. CMake then created a local list containing only the appended OCCT entries, causing the generated Install manifest to omit GMP, MPFR, WebView2, FreeType, and FFmpeg DLLs.

Export the completed list only after all runtime DLLs have been added.
2026-09-22 07:37:35 -03:00
cfc6be5001 Eryone (#14797)
* add eryone config

* add eryone config

* add eryone config

* Add 13 Eryone filament presets for the Thinker X400 0.4 nozzle

Rework the stale author branch onto the current Eryone bundle. Main already ships
the Thinker X400, so the duplicate "Eryone Thinker X400" machine and process family
from the branch is dropped and the new filaments are pinned to the existing
"Thinker X400 0.4 nozzle" variant. Hand-typed setting_id/filament_id values are
replaced with generated ones, the bundle version is bumped, the stray .info sidecars
are removed, and Eryone PETG-CF's filament_settings_id is corrected to match its name.

* fix errors

---------

Co-authored-by: Eryone <technical@eryone.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-09-22 18:36:08 +08:00
HanifKoh 19e094a1d1 Move the Cube with the Wipe Tower in the Profile Validator (#15799)
The slice check centres its cube on the bed, puts the prime tower beside
it, then pulls the tower alone inside the printable outline. On a bed too
narrow for the estimated footprint that pull drags the tower back over
the cube: Volumic EXO42 IDRE MIRROR MODE (189 mm wide, 87.6 mm estimate)
logged "gcode path conflicts found between WipeTower and cube" in every
run, and three ~105 mm beds were left with 0.15 to 3.3 mm of clearance.

The cube and the tower's footprint are now pulled inside as one rigid
pair, so the clearance between them is fixed by construction. A bed too
small for the pair keeps the old placement, and presets that were never
clamped keep their exact layout.
2026-09-22 18:22:42 +08:00
Lam Wei Lun a2c631753b Capitalize Open Speed Dial menu title (#15822) 2026-09-22 17:37:34 +08:00
Lam Wei Lun 29b9076440 Capitalize Open Speed Dial 2026-09-22 17:07:13 +08:00
Ian Chua 323cd3afe1 Keep Python Printer Agent Exceptions Out of the Host (#15819)
# 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?
-->

A Python printer agent plugin could take the host down, and one of its
operations could never report its result. This PR fixes both in
`PrinterAgentPluginCapabilityTrampoline.hpp`.

## Changes

### A faulty printer agent no longer throws into the GUI

`IPrinterAgent` reports failure through return values, and none of its
callers catch. A Python `raise`, a missing override or a wrongly typed
return from a printer agent plugin therefore escaped the trampoline as a
C++ exception.

Every trampoline operation now catches, logs `Printer agent plugin
'<key>': <operation> failed: <error>`, and answers with what
`NetworkAgent` returns when no printer agent is set. `BBLPrinterAgent`
returns the same values when the Bambu plug-in is unavailable:

- `-1` for every `int` status code
- `false` for `start_discovery` and `fetch_filament_info`
- `""` for `get_user_selected_machine`
- an empty `AgentInfo` for `get_agent_info` (registration already
rejects an empty agent ID)
- `FilamentSyncMode::none` for `get_filament_sync_mode`

`ORCA_PY_AGENT_OVERRIDE(ret, name, ...)` derives the fallback from the
return type through `printer_agent_failure<ret>()`, so the call sites
carry no fallback values of their own.

An exception is the safety net for plugin bugs, not an error channel. A
plugin reports an expected failure by returning a code, as the Bambu
plug-in does. A raise is logged as a failure and collapses to the
generic `-1`, so the GUI shows the generic message instead of the
specific one (`-18` cancelled, `-4020` FTP upload failed, …).

### `bind_detect` results now reach the host

`detect` is an out-parameter (`detectResult&`). pybind11 casts a
reference argument to an override with a copy, so a plugin that filled
in `detect` wrote to a throwaway object and the host always saw an empty
`detectResult`. It is now passed so that Python edits the caller's
struct. Plugins see the same `DetectResult` argument as before.

## TODO

- Expose the `BAMBU_NETWORK_*` return codes to Python (the
`orca.printer_agent` binding and the generated stub from
`scripts/generate_orca_python_stubs.py`). Plugins can already return
them, but only as hard-coded numbers.

# 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.
-->

- New `tests/slic3rutils/test_plugin_printer_agent.cpp`: an agent whose
operations raise, one that omits them, and one that returns the wrong
type all answer like a missing agent, and the interpreter stays usable.
A working agent's answers reach the host unchanged, including
`request_bind_ticket`'s out-param and the fields a plugin writes into
`bind_detect`'s `detect`.
- The `bind_detect` check failed before the fix (`"" == "192.168.0.2"`)
and passes after.
- `slic3rutils` passes under `ctest` (144/144); full Release build clean
on Linux.
- End to end on Linux with a test plugin whose chosen operations raise
(`start_discovery`, `get_filament_sync_mode`, `disconnect_printer`):
selecting the plugin's agent in the printer preset and switching back
logged each raise as a `Printer agent plugin '…': <operation> failed`
line, and the app kept running and closed cleanly (exit 0). Without the
guard, the first raise (`start_discovery`, on selecting the agent) ended
the app with `Uncaught exception` and SIGABRT (exit 134); that run used
a build whose printer-agent files are identical to `main`.

<!--
> 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-22 16:57:55 +08:00
Hanif Koh 57b3a040e2 Pass bind_detect's Result to Python by Reference
pybind11 copies a reference argument to an override, so a Python
printer agent that filled in detect wrote to a throwaway object and the
host always saw an empty detectResult. Pass it as a pointer so Python
edits the caller's struct.
2026-09-22 14:55:18 +08:00
SoftFever 811b587eb0 Document filament color as a runtime property, not a preset
Add the one-all-printer-preset-per-product rule to the orca-profiles skill:
color is chosen at runtime, a material family is a new product and a color is
not, and CI does not catch per-color presets so it stays a review call. Note
that @System is the all-printer convention rather than an enforced check.
2026-09-22 14:55:08 +08:00
HanifKoh 2876374b45 Add a Dockable HTML Panel API for Plugins (#15736)
orca.host.ui.create_dock_panel(html, title, width, height, on_message,
on_close, dock) hosts plugin HTML in a pane of the Plater's dock manager,
next to the sidebar, and returns a UiDockPanel handle
(post/show/hide/close/is_open). The arguments follow create_window(). The
panel uses the window.orca bridge of plugin windows, restores its position
and size from the saved window layout, hides with the Plater off the Prepare
and Preview tabs when floating, and is closed with its plugin; plugin panes
are removed in MainFrame::shutdown().

The web view hosting moves out of PluginPage into a shared WebPanel base:
bootstrap page and swap to the plugin HTML, theme, element-default and
bridge scripts, window.orca message parsing, delivery to the page, and live
re-theming, also re-applied on every load after the swap. Pages tabs and
docked panels both derive from it. Pages tabs now re-theme in place on a
theme change instead of being reloaded, and a window.orca call a host does
not support is logged.

What the hosts share no longer lives in one of them: the bootstrap page, the
base URL and the plugin-window bridge move to Widgets/WebHosting, used by
WebDialog and WebPanel alike. The Plater restores plugin panes with a new
saved-layout parser, GUI/AuiPaneLayout, kept in its own small header so
slic3rutils can test it without pulling in the Plater.

The web hosting classes carry no plugin name, so other hosts can reuse them:
PluginWebDialog becomes WebDialog (its bootstrap page moves to
resources/web/dialog/WebDialog), and destroy_for_plugin(),
load_plugin_content() and plugin_defaults_user_script() become
destroy_silently(), load_page_html() and element_defaults_user_script().

Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and
binding and layout-helper tests in slic3rutils.
2026-09-22 14:52:25 +08:00
SoftFever 211dd7daaa Merge branch 'main' into feature/texture_displacement 2026-09-22 14:46:36 +08:00
Lam Wei Lun f769a39b7f Remove duplicated Open speed dial option in menubar in macOS (#15817) 2026-09-22 14:46:25 +08:00
Hanif Koh fd9c1218a4 Keep Python Printer Agent Exceptions Out of the Host
IPrinterAgent callers do not catch, so a Python raise, a missing
override or a wrongly typed return from a printer agent plugin escaped
into the GUI. Each trampoline operation now logs the failure and
answers with NetworkAgent's no-agent value: -1 for status codes, the
empty value otherwise.
2026-09-22 14:35:14 +08:00
Ian Chua 3124943292 Merge branch 'main' into feat/plugin-lifecycle-evts 2026-09-22 14:22:16 +08:00
Ian Chua 8240984ca8 Merge branch 'main' into feat/plugin-lifecycle-evts 2026-09-22 14:21:36 +08:00
Lam Wei Lun adbeefd424 Merge branch 'main' into feat/speed_dial_macos_fix 2026-09-22 14:17:50 +08:00
Lam Wei Lun 423e7b1dbd Remove duplicated speed dial menu on macOS 2026-09-22 14:16:48 +08:00
b0ee2cbbef Add PlastAR (PLA) and Printalot (ABS) filament vendors (#14825)
* Add PlastAR and Printalot filament vendors

Two Argentine brands from Printalot: PlastAR (budget PLA) and Printalot
(ABS). Each has a tuned @base plus per-color presets covering the
manufacturer's color lineup.

PlastAR PLA: flow 0.98, 1.75 mm, nozzle 220 C (190-230), bed 60 C; 14 colors.
Printalot ABS: flow 0.94, density 1.05, retraction 0.2 mm, max volumetric
18 mm3/s, nozzle 250 C (240-270), bed 100 C; 14 colors.

Per-color presets inherit their @base (no per-color parameter changes).
Passes scripts/orca_extra_profile_check.py.

* Ship PlastAR and Printalot as a single Printalot filament vendor

Place both Argentine lines in one OrcaFilamentLibrary/Printalot bundle and follow the library's brand shape: a non-selectable @base plus one all-printer @System per product, which is where a library preset meant for every printer belongs. The former per-color presets are dropped, leaving PlastAR PLA and Printalot ABS with their PLA and 250 C ABS material values. setting_ids are minted by the tooling; filament_ids resolve through the @base roots.

---------

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 14:15:34 +08:00
55a0b51ae2 Add Polymaker PLA Pro filament profile (#15046)
* Add Polymaker PLA Pro filament profile

Polymaker PLA Pro is present in the tree only as printer-scoped variants
under Snapmaker U1 and Anycubic Kobra S1, so it is invisible to every other
printer. This adds it to the Orca Filament Library as OGFPM020 so it is
selectable generally.

Values are taken from the manufacturer's published print settings and
cross-checked against the two existing vendor profiles:

  density 1.23 g/cm3       both existing profiles agree
  softening 55 C           both existing profiles agree
  nozzle 220 C (210-230)   manufacturer's stated range
  max volumetric speed 15  Snapmaker 15, Anycubic 16

Flow ratio is set to 0.96, matching the Snapmaker profile. Worth noting for
review: the two existing profiles disagree here -- Snapmaker 0.96, Anycubic
0.85 -- because flow ratio depends on the extruder as much as the filament.
Any single value in a vendor-neutral preset is a starting point users should
calibrate; 0.96 is closer to the generic PLA baseline than 0.85 is.

setting_id assigned by scripts/assign_vendor_setting_ids.py.
OrcaFilamentLibrary version bumped 02.04.00.03 -> 02.04.00.04.

* fix errors

---------

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-22 13:42:01 +08:00
Lam Wei Lun 9e42f59022 Remove ellipses and update translations (#15814)
# Description
- Remove ellipses from "Open speed dial" menu item
- Update translation files
2026-09-22 13:19:18 +08:00
Lam Wei Lun 5caa3372bc Remove ellipses and update translations 2026-09-22 12:39:53 +08:00
Kris Austin 824b216f18 feat(gui): assignable keyboard shortcuts (#15706) 2026-09-21 17:36:04 -03:00
Ian Bassi bfe458507b Add X-Ray view mode to 3D viewport (#15770) 2026-09-21 14:23:32 -03:00
Tim Schneiderandyw4z b6bb1b92c9 Add Meltingplot vendor profiles (CHX 350 + MBL series) (#14756)
* Add Meltingplot vendor profiles (CHX 350 + MBL series)

Adds the Meltingplot vendor bundle with 5 printer models:
- CHX 350 (880x422x943, RepRapFirmware/Duet)
- MBL 133/136/308/480 (RepRapFirmware/Duet)

Each model ships 0.4/0.6/0.8/1.0/1.2 nozzle variants. Layer height
presets span 40-60% of the nozzle diameter and deliberately avoid
heights that divide the Z screw lead evenly (CHX: 1204 ball screw,
4 mm lead; MBL: TR4x2 trapezoidal screw, 8 mm lead) so periodic
screw error does not repeat as banding.

All printers use 2.85 mm filament, so the bundle carries its own
filament library (PLA, StoneFil, ABS, TitanX, ASA, PETG, PA6 CF HT,
STYX 12, PP 9-2, PET-CF15, Extrudr PLA NX2 Matt, Igus iglidur J260)
with the 2.85 mm diameter pinned once in the vendor-local
fdm_filament_common base.

Machine limits mirror the printers' firmware configuration; machine
limit emission is disabled so the values only feed the time estimator.

* Add per-nozzle volumetric flow limits and nozzle size in filename

Cap filament volumetric speed per nozzle (0.4: 14, 0.6: 20, 0.8: 26,
1.0: 30, 1.2: 40 mm3/s). Filaments whose limit exceeds a nozzle's
capability get a nozzle-scoped child profile that only lowers
filament_max_volumetric_speed; filament_id is inherited so all nozzle
variants remain the same material.

Also include layer height and nozzle diameter in the g-code filename.

* Set CHX 350 machine time cost to 5 EUR/h

* Inherit Meltingplot filaments from OrcaFilamentLibrary generics

Per the profile creation guide, vendor filament profiles now inherit
from Generic * @System instead of vendor-local fdm_filament_* copies.
Each profile carries only Meltingplot-specific deviations plus the
2.85 mm filament diameter; the seven local fdm_filament_* base files
are removed.

Deliberate pins where the library default would change tuned behavior:
- PLA/ABS/TitanX keep filament_max_volumetric_speed 20 (0.6 nozzle cap)
- ABS/PET-CF15 keep close_fan_the_first_x_layers 1
- STYX 12 sets required_nozzle_HRC 0 (unfilled PA12, density 1.02)

* Remove Meltingplot StoneFil filament profile

* Meltingplot ABS: use library fan settings

* Meltingplot TitanX/ABS: use library fan and nozzle temperature settings

* Remove STYX 12, PP 9-2 and Igus iglidur J260 filament profiles

* Meltingplot ASA: use library fan_max_speed

* Derive per-nozzle volumetric speed caps from physical limit formula

Caps now follow V = 34*d * m / S: the 34*d limit line calibrated on
Micro Swiss Volcano plated copper at 2.85 mm, material factor m from
the OrcaFilamentLibrary generic ratios (PLA/ABS/ASA 1.0, PETG 10/12,
PETG-CF 11.5/12, PA-CF 8/12, PLA Matte 11/12), safety factor S = 1.25
covering batch/moisture/wear/diameter tolerances.

Each material base now serves the 1.2 nozzle at its maximum cap;
children @0.4-@1.0 lower the cap per nozzle. Machine variant
default_filament_profile entries point at the matching preset.

* Scale slow_down_layer_time per nozzle from bead cross-section

Minimum layer time follows the derived cooling rule: heat per layer
scales with bead cross-section (layer height x line width), so the
0.8-nozzle-tuned values are scaled by q(d)/q(0.8) using each nozzle's
standard preset geometry. Bases carry the 1.2 value, children @0.4-@1.0
their nozzle-specific value.

* Meltingplot: lift Z above the print before the end macro

Add a G90 / G1 Z{min(max_layer_z + 5, printable_height)} F600 move at the
start of machine_end_gcode, before M98 print_end shuts the printer down and
parks it. Besides clearing the part, the explicit Z coordinate lets the Duet
file-info parser pick up the object height.

* Meltingplot: cap default layer height at 40% of outer wall width

A 45 deg slope shifts each bead outward by exactly one layer height. With
the Slic3r bead model (rectangle (w-h) x h plus half-circles of diameter h)
the flat contact band to the layer below is w - 2h, so the maximum
self-supporting angle is atan((w-h)/h). Support cannot compensate: the
stair tread at 45 deg is only h wide, far below one support line width, so
no support line ever fits underneath a single step.

All ten machine variants defaulted to h/w = 0.455..0.524, i.e. at or past
the h/w = 0.5 point where the flat contact vanishes. The 0.8 nozzle default
(0.44 mm at w = 0.84) reached atan(0.4/0.44) = 42.3 deg and failed on 45 deg
slopes even with support.

Default presets now satisfy h <= 0.40 * outer_wall_line_width, which leaves
one third of the bead's flat underside in contact at 45 deg and puts the
geometric limit at 56.3 deg. To keep the defaults on sensible ladder rungs,
outer wall width is widened where it was the binding constraint:
chx_small 110 -> 115%, chx_large 105 -> 110%, mbl_04 105 -> 115%.

Ladders are unchanged; the thicker rungs stay available for parts without
steep overhangs.

* Meltingplot PLA: raise slow_down_layer_time by calibration factor 1.5

Print feedback on CHX 350 with a 0.8 nozzle at 0.44 mm layer height showed
curling at the retraction points with the derived value of 25 s. There is no
cooling headroom left to trade: fdm_filament_pla pins fan_min_speed =
fan_max_speed = 100, and additional_cooling_fan_speed is inert because no
Meltingplot machine enables auxiliary_fan. Minimum layer time is the only
remaining lever; 35 s prints cleanly at that geometry.

The cross-section rule only fixes the ratios between nozzle sizes, so this
is a calibration of the PLA anchor itself: both PLA ladders are scaled by
1.5 (Extrudr 9/20/38/56/83 s, Meltingplot PLA 8/17/30/45/66 s for
0.4/0.6/0.8/1.0/1.2). The Extrudr base fan_cooling_layer_time goes 60 -> 100
so the 1.2-nozzle value stays below it.

Other materials are left unchanged - only the PLA anchor has a real
counter-example so far.

* Meltingplot: drop layer heights that cannot print 45 degree walls

A preset whose layer height exceeds ~45% of the outer wall line width has
less than ~6 degrees of reserve on a 45 degree wall (max self-supporting
angle atan((w-h)/h)), and support cannot compensate because the 45 degree
stair tread is only h wide - far below one support line. Those presets are
not usable in practice, so shipping them only invites failed prints.

Removes the 22 rungs above h/w = 0.45 and prunes them from the vendor
index. What remains per nozzle (default first):

  CHX 350   0.4: 0.18 | 0.6: 0.24 0.28 | 0.8: 0.32 0.36
            1.0: 0.44 0.48 | 1.2: 0.48 0.56
  MBL       0.4: 0.18 | 0.6: 0.24 0.28 | 0.8: 0.36 0.34
            1.0: 0.44 0.48 | 1.2: 0.48 0.56

The 0.4 nozzle keeps a single rung: 0.20 mm would qualify (h/w = 0.435) but
divides both screw leads evenly (4/0.20, 8/0.20) and stays excluded by the
Z-artifact rule, and 0.22 is already at 0.478.

Also corrects the MBL 0.8 default to 0.36 mm, which at w = 0.92 sits at
h/w = 0.391 and therefore still satisfies the stricter 40% default rule.

* Meltingplot: require 5 degrees of reserve on 45 degree walls

Replaces the two separate thresholds (h <= 0.40 * outer wall width for
defaults, 0.45 for ladder rungs) with a single requirement stated on the
design constraint itself: every shipped preset must reach

  a_max = atan((w - h) / h) >= 50 deg

i.e. 5 degrees of reserve on a 45 degree wall, equivalent to
h <= 0.456 * outer_wall_line_width. The default is simply the thickest rung
that passes.

The 11 degrees the 40% rule produced were more margin than the failure mode
warrants, and cost throughput for no return. For calibration: across 940
machine defaults from 60 vendor bundles in this repo, the median reserve is
+1.8 deg, 71% ship less than 5 deg, and 8% cannot geometrically produce a
45 deg wall at all.

Adds the rungs the relaxed threshold makes available - 0.30 (0.6 nozzle,
both series) and 0.38 (0.8 nozzle, both series) - and restores the 0.30 mm
MBL preset removed in 5b4862b3 with its original setting_id. 0.20 mm on the
0.4 nozzle and 0.50 mm on the 1.0 nozzle would also qualify but stay
excluded because they divide both screw leads evenly.

Defaults are now (0.4/0.6/0.8/1.0/1.2) 0.18/0.30/0.38/0.48/0.56 for both
series, at 50.2 to 57.3 deg. The 1.2 nozzle deliberately keeps 0.56 rather
than the 0.60 the rule would allow: solidification time scales with h^2 and
bead weight with h*w, so the largest nozzle is the one that should not sit
on the minimum.

* Meltingplot: disable precise wall by default

Precise wall was inheriting OrcaSlicer's global default of true. Disable it
bundle-wide in the vendor process root so it applies to CHX 350 and all MBL
models. Note it was already ignored on the CHX small-nozzle branch, which
uses the inner-outer-inner wall sequence.

* Meltingplot CHX: raise max extruding acceleration to 6000

The 4000 limit silently clamped the profiles' 6000 values for default and
inner wall acceleration, since GCodeWriter caps print acceleration against
machine_max_acceleration_extruding regardless of emit_machine_limits_to_gcode.
Raising it to 6000 matches the X/Y limits so the configured values are emitted
as-is. The firmware's own M201 still governs what is actually driven.

* Meltingplot PA6 CF HT: raise part cooling floor to 40%

Sharp 90 degree corners were washing out layer by layer while straight walls
and overhangs printed cleanly. Overhangs are fine because overhang_fan_speed
forces 100% regardless of layer time; everything else fell back to
fan_min_speed, since at the layer times seen in practice neither branch of the
layer-time fan interpolation applies.

Raise fan_min_speed to 40% and set reduce_fan_stop_start_freq so that floor is
actually applied instead of dropping to zero. Also lower the auxiliary fan to
40% and raise the overhang cooling threshold to 95%, matching the values
validated on the print.

* Meltingplot: re-mint filament ids with orca_id_tool

Upstream now requires every filament_id to be a minted "OF" id derived
from the (filament_vendor, filament_type, name) triple and recorded in
scripts/filament_id_snapshot.json. The hand-made ids this bundle shipped
with (MPFPLA0, EXTNX2M, ...) were rejected by orca_extra_profile_check.

Regenerate them with "orca_id_tool.py --generate --vendor Meltingplot"
and record the result with --update-snapshot. No setting_id changed, and
the ids were never released, so no existing project file refers to them.

* Meltingplot: normalize bundle for updated profile checks

Drop obsolete keys (silent_mode, adaptive_layer_height, tree_support_with_infill) and rebuild the vendor index in canonical order, as orca_profile_tool.py normalize / update-index write them.

* Meltingplot: list per-nozzle filaments in default_materials

The unsuffixed filament presets only cover the 1.2 mm variants, so the 0.4-1.0 mm printer presets had no compatible entry in their model's default_materials. The validator's check_printer_default_materials rejects that, and load_installed_filaments would auto-install no filament for those variants on first run. Name the @0.4/0.6/0.8/1.0 nozzle presets alongside the base ones for PLA, PETG, ABS and ASA.

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-22 00:14:33 +08:00
61dbe5572f Add Flashforge Adventurer A5 printer profiles (#14733)
* Add Flashforge Adventurer A5 printer profiles

Adds system profiles for the Flashforge Adventurer A5 to the existing
Flashforge vendor: the machine model with 0.25/0.4/0.6/0.8 mm nozzle variants,
14 process presets, and 43 filament presets, plus the printer cover image.

All presets inherit from existing Flashforge bases already in-tree
(Adventurer 5M Pro nozzle profiles, AD5M Pro process/filament presets,
Flashforge generics); no new base assets are introduced (the A5 reuses the
existing 5M-series bed/hotend models and buildplate texture). Every inherits
reference and sub_path was validated to resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Assign setting_id to Adventurer A5 presets (fix profile check)

The new A5 filament and process presets were missing the deterministic
setting_id required by OrcaSlicer's profile validator. Generated via
scripts/assign_vendor_setting_ids.py (61 instantiated presets). Verified with
scripts/orca_extra_profile_check.py: 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix Flashforge Adventurer A5 profiles so the bundle loads and validates

The new A5 presets were not in canonical form, several filaments resolved
no or the wrong filament_id, and the model and machines pointed at
non-existent or AD5M Pro defaults. Run the standard profile tooling
(normalize, update-index, generate-id), give the branded filaments the ids
of their existing Flashforge products, and repoint the defaults at
A5-compatible filaments and processes. Bump the Flashforge bundle version.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: yw4z <ywsyildiz@gmail.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-21 23:19:41 +08:00
ExPikaPaka 303efacec0 Generate walls and split solid infill in parallel
Same output, ~2.2 min for the LOTR map plate, was ~2.6.
2026-09-21 16:40:22 +02:00
2adb631d6f RH3D new printer and print profiles update (#14707)
* Update 0.25mm Standard @E3NG v1.2S.json

* Create 0.20mm Fast @Virtu E3.json

* Update 0.20mm Fast Virtu E3.json

* Create 0.20mm SLOW Virtu E3.json

* Update and rename 0.20mm Fast @Virtu E3.json to 0.20mm FAST @Virtu E3.json

* Update and rename 0.20mm Fast @E3NG v1.2S.json to 0.20mm FAST @E3NG v1.2S.json

* Update and rename 0.20mm Slow @E3NG v1.2S.json to 0.20mm SLOW @E3NG v1.2S.json

* Update and rename 0.20mm Standard @E3NG v1.2S.json to 0.20mm STANDARD @E3NG v1.2S.json

* Create 0.20mm STANDARD @VIRTU E3

* Update and rename 0.20mm FAST Virtu E3.json to 0.20mm FAST VIRTU E3.json

* Rename 0.20mm SLOW Virtu E3.json to 0.20mm SLOW VIRTU E3.json

* Update 0.20mm STANDARD @VIRTU E3

* Update 0.20mm STANDARD VIRTU E3

* Update and rename 0.25mm Fast @E3NG v1.2S.json to 0.25mm FAST @E3NG v1.2S.json

* Create 0.25mm FAST VIRTU E3

* Update and rename 0.15mm Standard @E3NG v1.2S.json to 0.15mm STANDARD @E3NG v1.2S.json

* Update and rename 0.10mm Standard @E3NG v1.2S.json to 0.10mm STANDARD @E3NG v1.2S.json

* Create 0.15mm STANDARD VIRTU E3

* Create 0.10mm STANDARD VIRTU E3.json

* Rename 0.15mm STANDARD @VIRTU E3 to 0.15mm STANDARD @VIRTU E3.json

* Update 0.15mm STANDARD VIRTU E3.json

* Update 0.20mm STANDARD VIRTU E3

* Update and rename 0.25mm FAST VIRTU E3 to 0.25mm FAST VIRTU E3.json

* Rename 0.20mm STANDARD VIRTU E3 to 0.20mm STANDARD VIRTU E3.json

* Update 0.15mm STANDARD VIRTU E3.json

* Update and rename 0.25mm Standard @E3NG v1.2S.json to 0.25mm STANDARD @E3NG v1.2S.json

* Create 0.25mm STANDARD VIRTU E3.json

* Create process_common_VIRTU E3.json

* Create process_FAST_VIRTU E3.json

* Update 0.20mm FAST VIRTU E3.json

* Update 0.25mm FAST VIRTU E3.json

* Create process_SLOW_VIRTU E3.json

* Update 0.20mm SLOW @VIRTU E3.json

* Update 0.20mm SLOW VIRTU E3.json

* Update and rename process_common_VIRTU E3.json to process_STANDARD_VIRTU E3.json

* Update 0.10mm STANDARD VIRTU E3.json

* Update 0.15mm STANDARD VIRTU E3.json

* Update 0.20mm STANDARD VIRTU E3.json

* Update 0.25mm STANDARD @VIRTU E3.json

* Update process_STANDARD_VIRTU E3.json

* Update 0.25mm STANDARD VIRTU E3.json

* Update 0.20mm STANDARD VIRTU E3.json

* Update 0.15mm STANDARD @VIRTU E3.json

* Update 0.10mm STANDARD VIRTU E3.json

* Update 0.15mm STANDARD VIRTU E3.json

* Update 0.20mm SLOW VIRTU E3.json

* Update RH3D.json

* Update RH3D.json

* Create VIRTU E3 - 0.2 nozzle.json

* Create VIRTU E3 - 0.3 nozzle.json

* Create VIRTU E3 - 0.4 nozzle.json

* Create VIRTU E3 - 0.5 nozzle.json

* Create VIRTU E3 - 0.6 nozzle.json

* Create VIRTU E3,json

* Update fdm_common_E3NG v1.2S.json

* Update fdm_machine_common.json

* Create fdm_common_VIRTU E3.json

* Create Generic ABS VIRTU E3.json

* Create Generic ASA @VIRTU E3.json

* Update Generic ASA VIRTU E3.json

* Create Generic PCCF VIRTU E3.json

* Create Generic PETG @VIRTU E3.json

* Create Generic PLA VIRTU E3.json

* Create Generic TPU VIRTU E3.json

* Update Generic ASA VIRTU E3.json

* Update Generic PETG VIRTU E3.json

* Update 0.20mm SLOW VIRTU E3.json

* Update 0.15mm STANDARD VIRTU E3.json

* Rename VIRTU E3,json to VIRTU E3.json

* Add files via upload

* Update VIRTU E3 - 0.2 nozzle.json

* Update E3NG v1.2S - 0.2 nozzle.json

* Update E3NG v1.2S - 0.3 nozzle.json

* Update E3NG v1.2S - 0.4 nozzle.json

* Update E3NG v1.2S - 0.5 nozzle.json

* Update E3NG v1.2S - 0.6 nozzle.json

* Update 0.10mm STANDARD @E3NG v1.2S.json

* Update 0.15mm STANDARD @E3NG v1.2S.json

* Update 0.20mm STANDARD @E3NG v1.2S.json

* Update 0.25mm STANDARD @E3NG v1.2S.json

* Delete resources/profiles/RH3D/VIRTU-bed.stl

* Update fdm_common_VIRTU E3.json

* Add files via upload

* Delete resources/profiles/RH3D/E3NG-bed-texture.svg

* Add files via upload

* Delete resources/profiles/RH3D/VIRTU E3_cover.png

* Add files via upload

* Add files via upload

* Create process_STANDARD_E3NG.json

* Create process_SLOW_E3NG.json

* Update and rename process_STANDARD_E3NG.json to process_STANDARD_E3NG v1.2S.json

* Update and rename process_SLOW_E3NG.json to process_SLOW_E3NG v1.2S.json

* Create process_FAST_E3NG v1.2S.json

* Update 0.25mm STANDARD @E3NG v1.2S.json

* Update 0.20mm STANDARD @E3NG v1.2S.json

* Update 0.15mm STANDARD @E3NG v1.2S.json

* Update 0.10mm STANDARD @E3NG v1.2S.json

* Update 0.20mm SLOW @E3NG v1.2S.json

* Update 0.20mm FAST @E3NG v1.2S.json

* Update 0.25mm FAST @E3NG v1.2S.json

* Update 0.20mm STANDARD @E3NG v1.2S.json

* Update 0.20mm STANDARD VIRTU E3.json

* Delete resources/profiles/RH3D/process/process_common_E3NG v1.2S.json

* Update RH3D.json

* Update fdm_process_common.json

* Update fdm_filament_common.json

* Update fdm_filament_abs.json

* Update fdm_filament_abs.json

* Update fdm_filament_asa.json

* Update fdm_filament_pccf.json

* Update fdm_filament_petg.json

* Update fdm_filament_pla.json

* Update fdm_process_common.json

* Update fdm_common_VIRTU E3.json

update speeds

* Update fdm_common_E3NG v1.2S.json

update speeds

* Update fdm_process_common.json

fix accel_to_decel

* Update fdm_machine_common.json

fix speed profile output

* Create 0.10mm SLOW @E3NG v1.2S.json

* Create 0.10mm FAST @E3NG v1.2S.json

* Create 0.15mm FAST @E3NG v1.2S.json

* Create 0.15mm SLOW @E3NG v1.2S.json

* Create 0.25mm SLOW @E3NG v1.2S.json

* Create 0.10mm SLOW VIRTU E3.json

* Create 0.10mm FAST VIRTU E3.json

* Create 0.15mm SLOW VIRTU E3.json

* Create 0.15mm FAST VIRTU E3.json

* Create 0.25mm SLOW VIRTU E3.json

* Update RH3D.json

add more slow and fast profiles

* Update 0.20mm FAST @E3NG v1.2S.json

* Update 0.20mm SLOW @E3NG v1.2S.json

* Update 0.20mm FAST @VIRTU E3.json

* Update 0.20mm SLOW @VIRTU E3.json

* Update fdm_process_common.json

* Update fdm_process_common.json

* Update Generic ABS @VIRTU E3.json

* Update Generic ABS VIRTU E3.json

* Update Generic PCCF VIRTU E3.json

* Update Generic PETG VIRTU E3.json

* Update Generic PLA VIRTU E3.json

* Update Generic ASA VIRTU E3.json

* Update Generic TPU VIRTU E3.json

* Update 0.20mm SLOW @E3NG v1.2S.json

* Update 0.25mm SLOW @E3NG v1.2S.json

* Update 0.15mm FAST @VIRTU E3.json

* Update 0.20mm FAST VIRTU E3.json

* Update 0.20mm STANDARD @E3NG v1.2S.json

* Update 0.15mm SLOW @E3NG v1.2S.json

* Update 0.25mm STANDARD @VIRTU E3.json

* Update 0.20mm STANDARD VIRTU E3.json

* Update 0.20mm FAST @E3NG v1.2S.json

* Update 0.25mm FAST VIRTU E3.json

* Update 0.10mm FAST VIRTU E3.json

* Update 0.10mm SLOW VIRTU E3.json

* Update 0.15mm SLOW VIRTU E3.json

* Update 0.20mm SLOW VIRTU E3.json

* Update 0.25mm FAST @E3NG v1.2S.json

* Update 0.10mm SLOW @E3NG v1.2S.json

* Update 0.10mm STANDARD @E3NG v1.2S.json

* Update 0.15mm STANDARD @VIRTU E3.json

* Update 0.25mm STANDARD @E3NG v1.2S.json

* Update 0.15mm STANDARD @E3NG v1.2S.json

* Update 0.25mm SLOW @VIRTU E3.json

* Update 0.10mm STANDARD VIRTU E3.json

* Update 0.15mm FAST @E3NG v1.2S.json

* Update 0.10mm FAST @E3NG v1.2S.json

* Update VIRTU E3 - 0.4 nozzle.json

* Update VIRTU E3 - 0.5 nozzle.json

* Update VIRTU E3 - 0.6 nozzle.json

* Update VIRTU E3 - 0.3 nozzle.json

* Update VIRTU E3 - 0.2 nozzle.json

* Replace cover PNGs for edge to edge images

* Update 0.10mm STANDARD @E3NG v1.2S.json

added alias with previous name

* Update 0.10mm STANDARD @E3NG v1.2S.json

update aliases

* Update and rename 0.10mm STANDARD @E3NG v1.2S.json to 0.10mm Standard @E3NG v1.2S.json

* Update RH3D.json

update naming

* Update and rename 0.10mm FAST @E3NG v1.2S.json to 0.10mm Fast @E3NG v1.2S.json

* Update and rename 0.10mm FAST VIRTU E3.json to 0.10mm Fast VIRTU E3.json

* Update and rename 0.15mm FAST @E3NG v1.2S.json to 0.15mm Fast @E3NG v1.2S.json

* Update and rename 0.15mm FAST VIRTU E3.json to 0.15mm Fast VIRTU E3.json

* Update and rename 0.20mm FAST @E3NG v1.2S.json to 0.20mm Fast @E3NG v1.2S.json

* Update and rename 0.20mm FAST VIRTU E3.json to 0.20mm Fast VIRTU E3.json

* Update and rename 0.25mm FAST @E3NG v1.2S.json to 0.25mm Fast @E3NG v1.2S.json

* Update and rename 0.25mm FAST @VIRTU E3.json to 0.25mm Fast @VIRTU E3.json

* Update and rename process_FAST_E3NG v1.2S.json to process_Fast_E3NG v1.2S.json

* Update and rename process_FAST_VIRTU E3.json to process_Fast_VIRTU E3.json

* Update and rename 0.10mm SLOW @E3NG v1.2S.json to 0.10mm Slow @E3NG v1.2S.json

* Update and rename 0.10mm SLOW @VIRTU E3.json to 0.10mm Slow @VIRTU E3.json

* Update and rename 0.15mm SLOW @E3NG v1.2S.json to 0.15mm Slow @E3NG v1.2S.json

* Update and rename 0.20mm SLOW @E3NG v1.2S.json to 0.20mm Slow @E3NG v1.2S.json

* Update and rename 0.15mm SLOW VIRTU E3.json to 0.15mm Slow VIRTU E3.json

* Update and rename 0.20mm SLOW @VIRTU E3.json to 0.20mm Slow @VIRTU E3.json

* Update and rename 0.25mm SLOW @E3NG v1.2S.json to 0.25mm Slow @E3NG v1.2S.json

* Update and rename 0.25mm SLOW @VIRTU E3.json to 0.25mm Slow @VIRTU E3.json

* Update and rename process_SLOW_E3NG v1.2S.json to process_Slow_E3NG v1.2S.json

* Update and rename process_SLOW_VIRTU E3.json to process_Slow_VIRTU E3.json

* Update and rename 0.10mm STANDARD @VIRTU E3.json to 0.10mm Standard @VIRTU E3.json

* Update and rename 0.15mm STANDARD @E3NG v1.2S.json to 0.15mm Standard @E3NG v1.2S.json

* Update and rename 0.20mm STANDARD @E3NG v1.2S.json to 0.20mm Standard @E3NG v1.2S.json

* Update and rename 0.15mm STANDARD @VIRTU E3.json to 0.15mm Standard @VIRTU E3.json

* Update and rename 0.20mm STANDARD @VIRTU E3.json to 0.20mm Standard @VIRTU E3.json

* Update and rename 0.25mm STANDARD @E3NG v1.2S.json to 0.25mm Standard @E3NG v1.2S.json

* Update and rename 0.25mm STANDARD VIRTU E3.json to 0.25mm Standard VIRTU E3.json

* Update and rename process_STANDARD_E3NG v1.2S.json to process_Standard_E3NG v1.2S.json

* Update and rename process_STANDARD_VIRTU E3.json to process_Standard_VIRTU E3.json

* Update E3NG v1.2S - 0.2 nozzle.json

* Update E3NG v1.2S - 0.3 nozzle.json

* Update E3NG v1.2S - 0.4 nozzle.json

* Update E3NG v1.2S - 0.5 nozzle.json

* Update E3NG v1.2S - 0.6 nozzle.json

* Update VIRTU E3 - 0.2 nozzle.json

* Update VIRTU E3 - 0.3 nozzle.json

* Update VIRTU E3 - 0.4 nozzle.json

* Update VIRTU E3 - 0.5 nozzle.json

* Update VIRTU E3 - 0.6 nozzle.json

* Update 0.15mm Slow @E3NG v1.2S.json

* Update 0.10mm Fast VIRTU E3.json

* Update 0.20mm Slow VIRTU E3.json

* Update 0.10mm Standard @E3NG v1.2S.json

* Update 0.10mm Fast @E3NG v1.2S.json

* Update 0.25mm Standard @E3NG v1.2S.json

* Update 0.15mm Standard @E3NG v1.2S.json

* Update 0.15mm Standard VIRTU E3.json

* Update 0.20mm Standard VIRTU E3.json

* Update 0.20mm Standard @E3NG v1.2S.json

* Update 0.25mm Slow VIRTU E3.json

* Update 0.25mm Fast @E3NG v1.2S.json

* Update 0.20mm Slow @E3NG v1.2S.json

* Update 0.10mm Slow VIRTU E3.json

* Update 0.25mm Slow @E3NG v1.2S.json

* Update 0.25mm Standard VIRTU E3.json

* Update 0.10mm Standard VIRTU E3.json

* Update 0.20mm Fast @E3NG v1.2S.json

* Update 0.10mm Slow @E3NG v1.2S.json

* Update 0.15mm Fast @E3NG v1.2S.json

* Update 0.15mm Fast VIRTU E3.json

* Update 0.15mm Slow VIRTU E3.json

* Update 0.20mm Fast VIRTU E3.json

* Update 0.25mm Fast VIRTU E3.json

* Update fdm_machine_common.json

* Profile verification/fix

Fixing profiles with orca_profile_tool.py

* bump version

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-21 22:02:31 +08:00
ExPikaPaka ae7bf43fde Run colour segmentation and vertical shells in parallel
Same output, ~2.6 min for the LOTR map plate, was ~2.9.
2026-09-21 15:22:36 +02:00
ExPikaPaka 8f853c0e22 Faster slicing of colour-painted layers, texture panel tools row
A layer split into ~1000 colour fragments (a colour texture baked over a
large top face) made several per-fragment loops redo whole-layer ClipperLib
work, so slicing took ~33 min; it now takes ~3 min with the same output.

- make_fills: clip the layer's no-overlap area to each expolygon's box
  before intersecting
- discover_vertical_shells: small-piece filter compares only against the
  nearby part of the layer
- bridge_over_infill: whole-layer union/diff/intersections restricted to the
  candidate's neighbourhood; fill boundary expanded once per spacing; anchor
  tree built only from lines crossing the scan range; bbox pre-check in the
  collision test; limiting outline taken directly instead of through
  expand(..., 0.3 * flow.spacing()), which offsets by 0.135 scaled units
  (flow.spacing() is in mm) and only cost a whole-layer pass per candidate
- Bake job: include GUI.hpp for show_error()
- Texture panel: select/erase whole model next to the paint tools, brush
  size slider back on the same row
2026-09-21 14:55:39 +02:00
İlker Kara 1e038bfc53 fix(linux): build deps on older GCC and Ubuntu 22.04 (#15151)
- GMP: name the parameters in the configure probe from
  0001-GMP_GCC15.patch. Unnamed parameters are a hard error on GCC 10
  and older, so configure failed there.
- TBB: turn off the automatic hwloc search so a system libhwloc-dev is
  not picked up.
- scripts/linux.d/debian: run apt update before checking which
  webkit2gtk package exists, and prefer webkit2gtk-4.1 since the build
  requires it. Add automake, which MPFR's autoreconf needs.
2026-09-21 09:36:55 -03:00
barmanr cdcef47e3d test: bump OrcaArena profile version for OTA retry (#15798)
Merged by /bot merge on behalf of @barmanr (id 281100727).
Grants: resources/profiles/OrcaArena, resources/profiles/OrcaArena.json
Head: 32113de7fd
2026-09-21 11:10:10 +00:00
Ian Chua 4f8a31e9db fix: run post_merge_profiles.yml after bot merges PR. (#15797)
# 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?
-->
When a PR is merged using the bot with `/bot merge`, it doesn't invoke
`post_merge_profiles.yml` because post_merge_profiles.yml only starts on
a push or manual dispatch.

# 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-21 18:33:35 +08:00
Ian Chua ced4a31cdb Merge branch 'main' into fix/run-profiles-ci-after-bot-merge 2026-09-21 18:32:58 +08:00
peachismomo 8581df4a8a fix: update pr-merge-bot.yml to run post_merge_profiles.yml after merge 2026-09-21 18:30:25 +08:00
Byeon Ho cheol. 162cf7fd4c Update Cubicon profiles: filament/process tuning and xCeler-Mini compatibility (#14660) 2026-09-21 18:18:57 +08:00
peachismomo 09d67b1bb4 fix: remove some logging 2026-09-21 18:17:15 +08:00
barmanr 64cad00569 fix: update OrcaArena product URL (#15796)
Merged by /bot merge on behalf of @barmanr (id 281100727).
Grants: resources/profiles/OrcaArena, resources/profiles/OrcaArena.json
Head: 794db5d30d
2026-09-21 09:53:13 +00:00
SoftFever 45940a573d Grant pull-requests write so profile PR labels and the partner notice actually post 2026-09-21 17:13:44 +08:00
HanifKoh 8b63628cf9 Restore Plugin HTML After a WebKit Reload (#15737)
Plugin dialog content is injected with SetPage, so on the WebKit backends a
reload (context menu, keyboard shortcut or location.reload()) re-fetches the
SetPage base URL instead of the injected document, and the plugin UI is gone
for good: load_plugin_content() returned early once m_content_loaded was set.

Re-inject the plugin HTML when a main-frame load after the initial swap is
neither that swap nor a page the plugin linked to. m_own_page_load marks the
load our own SetPage caused, and the URL test recognises the reload: the
injected document and the directory a reload re-fetches both report the base
URL, so a load of any other URL is left alone. The test ignores a fragment the
page navigated to, and undoes the escaping the web view applies to what the
resources path holds.

A load reaching the base URL is not enough on its own, because WebKitGTK reports
a navigation that never committed against the document that stayed and then
finishes that document again: a link to a missing file therefore arrives as a
load of the base URL and reads exactly like a reload. So the re-injection also
requires a navigation to the base URL to have committed, which a reload always
does and a failure never does.

A bootstrap page that cannot be loaded is still not recovered from: WebKitGTK
substitutes a stock error page for it, and that load supersedes the swap
whichever way the swap is ordered around it. The file ships, so this is a
broken-install path; nothing here makes it worse than it already was.

No separate MSW path is needed: wxWebViewEdge ignores the SetPage base URL, so
its documents report about:blank and the test never matches there, and WebView2
reloads NavigateToString content from its own history entry anyway.
2026-09-21 17:09:05 +08:00
dremc c8bfd2ad3e Fix instantiation on DREMC Filament (#14659) 2026-09-21 16:53:16 +08:00
Alexander Haibl 3eae4c6997 fix-start-gcode for many creality printers (#14652) 2026-09-21 16:22:32 +08:00
gyarros 83eaf3bcfc FilAr PLA-mate: lower bed temperature to 50 C (#14489) 2026-09-21 16:07:49 +08:00
Davide Garberi 454ef1571b Update generic filament profiles for Creality SPARKX i7 (#14388) 2026-09-21 16:01:14 +08:00
ExPikaPaka 7aef3d1215 Merge remote-tracking branch 'origin/feature/texture_displacement' into feature/texture_displacement
# Conflicts:
#	src/libslic3r/TextureDisplacement.cpp
2026-09-21 09:00:18 +02:00
ExPikaPaka cef6527f9d Texture displacement: per-layer bake, UV pane redesign, unwrap and layer view fixes
- Bake: each layer is sampled only on its own painted area; analytic
  projections used to stack every layer over every painted region, so the
  top layer's texture showed on all of them (colour sampler too)
- Auto resolution follows the texture's texel size and sharpness again
- Unwrap: charts cut by each face's own normal (a cube gives 6 islands, not
  12 triangles); non-disk charts (tubes, closed shells) are split until they
  flatten; connected nets test real triangle overlap, grow from the largest
  chart and are packed side by side
- UV edits are stored per unwrapped copy, so dragging a seam vertex no
  longer moves its copies in neighbouring islands
- UV pane: tool strip with unwrap settings moved in from the panel, sharp
  HiDPI icons, clearer island/edge/selection drawing with hover, texture
  picker from the thumbnail, texture no longer lost on reopen (GL state
  from the 3D view, background upload retries)
- Panel: whole-model select/erase as icons in the tools row; inactive
  layers' paint shown muted; colour textures shown in colour in the picker
- Built-in displacement texture library
- Tests for unwrap segmentation, connected nets, UV edits and per-layer
  sampling
2026-09-21 08:59:26 +02:00
Grant Harkness a5d3aaefd3 Creality K1-family CFS support (K1 SE) — follow-up to #13752 (#14089) 2026-09-21 11:37:50 +08:00
Bilal7828 b8f4aa782e Adds M3D D8500 Enabler Pro(Improved Profile + Printings Process Presets)+ Enabler D7500 Support + M3D Enabler PLA Profile (#11973) 2026-09-21 10:41:24 +08:00
Matias Alejandro Yocca abf76490e4 feature: Add granular print time placeholders for days, hours, minutes, and seconds (#13063) 2026-09-21 10:40:08 +08:00
Drew WingfieldandSoftFever 6e4be42a04 UltiMaker S5 Profile + Local API Support (#14062)
* Add Ultimaker S5 Profile - WIP

* Add Ultimaker physical printer base

* Ultimaker API WIP for base update.

* Fix undefined reference error.

* Add UMS5 profile, add placeholders for testAuth stuff.

* Fix non-const func definitions, make func names align with style guide.

* Localization stuff? IDK if this does anything or is required.

* Add cover image

* Auth code cleanup, add UI button for auth cred generation, implement various auth checks and tests.

* Fix auth stuff

* Clean up code

* Get upload code sort-of working, fix typo

* Update printer settings and start/end gcodes

* Add makeGriffinCompatible preprocessor script to prevent machine crash, update machine profile.

* Fix buildplate size, fix time missing bug, add S5 buildplate model

* Correct capitalization to UltiMaker

* Fix display bug, fix capitalization bug

* Implement credential generation button and logic

* Fix generate auth creds button, add todos, fix capitalization.

* Actually fix generate auth credentials.

* Fix generate auth creds message.

* Update UM S5 machine limits

* Revert accidental commit.

* Fix postprocessor, clean up code.

* Update presets for multi-extruder printing.

* Add Single Extruder and Fast profiles.

* Code cleanup

* Register and validate the UltiMaker S5 presets

* Show the Generate API Key button only for UltiMaker print hosts

The PR added it to the Physical Printer dialog for every host type, where pressing it runs an ordinary connection test and reports "API Key created". Gate it on the selected host type instead.

* Drop unused lambda captures in the UltiMaker host code

clang promotes -Wunused-lambda-capture to an error under the project's -Werror, so the file failed to build on macOS; the five callbacks do not touch this.

* Fix the Windows build of the UltiMaker print host

---------

Co-authored-by: SoftFever <softfeverever@gmail.com>
2026-09-21 01:00:55 +08:00
SoftFever 505a46b280 Move check_profile's downloads to a per-user cache outside the repo
The validator, fixture archives and unpacked fixture trees no longer live under <repo>/.test/check_profiles, so every worktree on a machine shares one copy instead of re-downloading. macOS, Linux and Windows each use their own user cache dir, named apart from OrcaSlicer's per-user dirs. The now-unused /.test/ ignore rule is dropped.
2026-09-21 00:28:50 +08:00
SoftFever cb98d82023 Document filament compatibility specificity and preset naming conventions 2026-09-20 22:36:45 +08:00
Kris Austin b4b4438a47 fix: custom G-code keyword check uses the wrong list and skips the editor (#14908) 2026-09-20 10:24:55 -03:00
hamham999 2cd52edb33 Added and updated legacy and ender printer variants (#13948) 2026-09-20 21:23:00 +08:00
Rodrigo Faselli d650c395ff Preserve MMU paint in outline render (#15776) 2026-09-20 09:25:36 -03:00
Gabriel Monteiro f114ef7cc9 fix(gui): restore zero default top margin for static boxes (#15775) 2026-09-19 20:38:19 +03:00
Kiss Lorand caa15491c3 Fix duplicate timelapse G-code on i3 printers (#15734) 2026-09-19 13:19:54 -03:00
Eric McCann 734dc1f9ac Disambiguate nozzles by the variant too 2026-09-19 07:23:55 -04:00
SoftFever c168d0c8db Stop estimating a prime tower for a single used filament (#15760)
# Description

A plate using one filament no longer reserves or draws a prime tower in
the plater just because multiple filament slots are configured. The
shared footprint estimate gated "no tower" on the purge volume being
zero, but on non-Bambu printers with the shipped defaults that volume
comes from the SEMM flush matrix, which reads every configured slot and
stays nonzero even when only one filament is used — so the preview
showed a tower the print could never contain. The estimate now decides
from the filament count alone, with wrapping detection and smooth
timelapse staying the only reasons a single-filament plate keeps a
tower, matching `Print::has_wipe_tower()`. The gate also moved above the
flush-volume scan so the lone-filament path no longer pays for it.

No change to slicing output — toolpath generation already omitted the
tower for a single used filament; this removes the phantom preview,
placement reservation, and validation footprint.

# Screenshots/Recordings/Graphs
Before the fix:


https://github.com/user-attachments/assets/c5b0ea61-127d-408e-bafe-f1f76c58b996

After the fix:


https://github.com/user-attachments/assets/ac6632b3-5d44-4013-aca3-73f6cfd1139c



## Tests

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

Added a shipped-defaults single-filament case to
`tests/libslic3r/test_wipe_tower_estimate.cpp`: it estimates a 5.04
mm-deep tower before the fix and zero depth after. `[WipeTowerEstimate]`
and the fff_print `[WipeTower]` suites 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-19 12:46:00 +08:00
Ian Bassi 29858ba925 Retry NSIS install and verify makensis on Windows CI (#15768) 2026-09-19 01:16:10 -03:00
Kris Austin 49c811ceb6 fix: H2D extruder sync does nothing on the Motion ability page (#14931) 2026-09-18 20:54:43 -03:00
Kris AustinandRodrigo Faselli 213c6569ab cut the GPU load of moving the mouse over the 3D viewport (#15674)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-09-18 20:19:37 -03:00
Kris Austin e0410db22e fix: hide the CAD gizmos unless the experimental CAD feature is enabled (#15762) 2026-09-18 18:38:55 -03:00
Kris Austin 8eac7aafdb ci: fix the flatpak dependency cache broken by #14709 (#15763) 2026-09-18 17:47:28 -03:00
Ian Bassi f956914a11 Update OrcaSlicer_de.po (#15766) 2026-09-18 16:12:11 -03:00
packerlschupfer f9e9cff53a calib: defensive guards in find_optimal_PA_speed (fix CLI pa-tower SIGSEGV) (#14414) 2026-09-18 14:26:14 -03:00
Ian Bassi 8a17df4a89 Fix some overhang detection (#15694) 2026-09-18 14:23:38 -03:00
SoftFever 60ebbf7daa Stop estimating a prime tower for a single used filament
The no-tower case was gated on there being no purge volume, but the SEMM flush matrix reads every configured slot and is nonzero even when only one filament is used, so the plater preview drew a tower the print would never contain.
2026-09-19 00:38:22 +08:00
Kiss Lorandandyw4z e573fc4680 Align Publish 3MF dialog styling and fix tab-switch flicker (#15695)
* Fix Publish 3MF dialog styling

Use Orca's shared checkbox widget in the Publish 3MF dialog. Keep checkbox labels clickable, preserve toggle event propagation and disabled-row state, and use Windows-only double buffering on the tab page hosts to reduce flicker during page switches.

Also align the dialog's guide-link color with existing Orca dialogs.

* Fix Publish 3MF dialog styling

Use Orca's shared checkbox widget in the Publish 3MF dialog. Keep checkbox labels clickable, preserve toggle event propagation and disabled-row state, and use Windows-only double buffering on the tab page hosts to reduce flicker during page switches.

Also align the dialog's guide-link color with existing Orca dialogs.

* match font size and left margins

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-09-18 19:25:53 +03:00
yw4zandNoisyfox c5f9257878 Compact bbl nozzle UI (#15083)
* init

* drop usage of StaticGroup for ExtruderGroup

* completely remove StaticGroup from project

* fix alignment of "Not installed" text

* fix crash on linux while clicking edit button

* Fix background color on macOS

---------

Co-authored-by: Noisyfox <timemanager.rick@gmail.com>
2026-09-18 23:43:42 +08:00
inslogic3d 9bd4432f27 Add INSLOGIC filament profiles to OrcaFilamentLibrary (#15398)
Merged by /bot merge on behalf of @inslogic3d (id 321604763).
Grants: resources/profiles/OrcaFilamentLibrary/filament/INSLOGIC, resources/profiles/OrcaFilamentLibrary.json
Head: 22a51b32f0
2026-09-18 15:43:33 +00:00
SoftFever 3f001489bf Speed Dial Enhancements (#15562) 2026-09-18 23:21:19 +08:00
Kris Austin 7472b87b67 build: fix the macro redefinition that fails clang Debug builds (#15759)
libslic3r_version.h defines ORCA_CHECK_GCODE_PLACEHOLDERS from the CMake
option, 0 by default, and GCode.hpp then forces it to 1 whenever NDEBUG
is undefined. Clang reports the second definition under
-Wmacro-redefined, which is on by default, so since -Werror (#15660)
every clang Debug build fails at the libslic3r files that include
GCode.hpp. Release and RelWithDebInfo define NDEBUG and never compile
the override, which is why no CI configuration sees it. The override
dates from #3861 and has warned in every Debug build since.

Undefine the macro before overriding it. Debug builds get the same
value the redefinition already produced, so nothing else changes.

Follow-up to #15749, refs #15748.
2026-09-18 11:56:44 -03:00
CliffordandClaude Opus 5 83e729d20e fix(build): drop two unused lambda captures that break non-Release clang builds (#15749)
fix(build): drop two unused lambda captures that break non-Release builds

Both handlers capture this and never use it. They sit inside
#if !BBL_RELEASE_TO_PUBLIC, which CMakeLists defines as $<CONFIG:Release>, so
the code compiles in Debug and RelWithDebInfo but not in Release. Clang warns
on an unused capture under -Wall, and since every warning became an error the
two captures fail any clang build that is not Release - an Xcode scheme on its
default Debug configuration, for instance. The CI matrix builds Release, where
the block does not exist, and GCC does not implement the warning at all, so
nothing in CI can see it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:42:31 -03:00
Ian Chua ce41b5a3cb fix: stale audit mode on_lifecycle_event 2026-09-18 22:33:46 +08:00
Ian Chua 8fd93907fd Merge branch 'main' into feat/plugin-lifecycle-evts 2026-09-18 21:35:09 +08:00
Ian Chua 080f27f602 fix: windows unsubscribed loaded plugin not allowed (#15751)
# 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?
-->
On windows, after installing and loading a plugin, if you try to
unsubscribe from a loaded plugin, and on refresh, it will be an orphaned
plugin.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->
On unsubscribing from a loaded cloud plugin
<img width="1418" height="862" alt="image"
src="https://github.com/user-attachments/assets/9ba8353c-1645-4b21-86ff-2ed235f4d259"
/>

On plugin refresh
<img width="1418" height="862" alt="image"
src="https://github.com/user-attachments/assets/fd58c144-7970-4448-ba3b-e72e52980467"
/>

## 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-18 21:32:57 +08:00
SoftFever 533b68ed9f Fix colour textures loading upside down and crashes on damaged PNGs
Colour height maps now displace the same way up as grayscale ones. A
truncated or corrupt PNG in the texture folder or a project file now
loads as an empty texture instead of aborting the app, and a 16-bit
colour PNG is converted on load rather than displacing as noise.
2026-09-18 20:37:35 +08:00
Eric McCannandCodex 30b3666c47 U1 HF: Resolve nozzle mismatch on-screen warning
Emit U1 nozzle flow metadata through profile end G-code

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

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

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

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 07:54:21 -04:00
Lam Wei Lun 4ea50a33e4 Testing macOS fixes 2026-09-18 19:07:03 +08:00
SoftFever b4aa57fe2c Merge branch 'main' into feature/texture_displacement 2026-09-18 18:58:44 +08:00
Ian Chua 23c77f15cf feat: add CI to generate OPC for OTA workflow (#15624)
# Description

Adds the CI half of the profile OTA pipeline: a push-triggered workflow
that
rebuilds a vendor's binary preset cache (`<vendor>.opc`) whenever its
profile
changes on `main` / `release/*`, and publishes it as a versioned release
asset
for OrcaCloud's OTA Manager to pick up.

### `.github/workflows/post_merge_profiles.yml` (new)

Push-triggered counterpart to `check_profiles.yml` (which only gates
PRs):

- Diffs the push to find which vendors under `resources/profiles/**`
changed.
- Reads the Orca version from `version.inc` and each vendor's 4-part
`version`
from `resources/profiles/<vendor>.json` (fails the run if it isn't
`A.B.C.D`).
- Downloads the prebuilt `generate_system_cache` from this repo's
`nightly-builds` release and builds one `<vendor>.opc` per changed
vendor.
- Packages each as
`<orca_ver>_<vendor>_<profile_version>_<UTCyyyymmddHHMM>.zip` (zip root
`<vendor>.opc`) — the asset-name contract OrcaCloud's release scanner
expects.
- Uploads them to a per-Orca-version release on the profiles repo via a
scoped
  GitHub App token.

It stops there: no changelog, no R2, no OTA webhook — a maintainer still
publishes from the OTA Manager. Job is guarded to
`OrcaSlicer/OrcaSlicer`;
workflow permissions are `contents: read` (the cross-repo write uses the
App
token only).

### `.github/workflows/build_orca.yml`

Two steps on the Linux leg: upload `generate_system_cache` as a CI
artifact,
and (on `main`) deploy it to the `nightly-builds` release as
`generate_system_cache_Linux_Ubuntu2404_nightly` so the workflow above
has a
tool to download. `.opc` is 64-bit little-endian and
platform-independent, so
only the Linux binary is shipped.

### `src/dev-utils/generate_system_cache.cpp`

New `-v` / `--vendor` option to generate the cache for a single vendor
(plus the
always-loaded Orca filament library) instead of all vendors, and an
error if the
named vendor produced no `.opc` (catches typos). Reuses
`PresetBundle::set_vendor_to_validate()` from #14217.

## Operational prerequisites

- Repo secrets `PROFILES_APP_ID` / `PROFILES_APP_PRIVATE_KEY` for a
GitHub App
  with `contents: write` on the target profiles repo.
- `env.PROFILES_OWNER` / `env.PROFILES_REPO` in
`post_merge_profiles.yml` must
  point at the production profiles repo OrcaCloud reads.
- `generate_system_cache_Linux_Ubuntu2404_nightly` only appears after
the first
post-merge nightly `build_orca` run; a profile-only push before then
fails at
  the download step.

## Screenshots/Recordings/Graphs

n/a — CI only.

## Tests

Exercised end-to-end in a staging environment: per-vendor `.opc` zips
published
in the expected `<orca_ver>_<vendor>_<profile_version>_<timestamp>.zip`
layout,
and `generate_system_cache -v <vendor>` confirmed to emit only that
vendor's
`.opc`.

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-18 18:53:26 +08:00
Ian Chua 00f78c18cf Merge branch 'main' into fix/windows-unsub-plugin 2026-09-18 18:51:50 +08:00
SoftFever 1159ca5f7f Merge branch 'main' into weilun/speed_dial 2026-09-18 17:28:24 +08:00
SoftFever 2159ab3f6d Merge branch 'main' into feature/texture_displacement 2026-09-18 17:25:04 +08:00
SoftFever 2dd3ef7cff fix build errors on mac 2026-09-18 17:24:47 +08:00
SoftFever 56561242a7 Merge branch 'main' into feature/texture_displacement 2026-09-18 16:33:18 +08:00
peachismomo 2bfab589ee fix: windows unsubsribe loaded plugin not allowed 2026-09-18 16:07:25 +08:00
Lam Wei Lun 8f4e3dde55 Merge main 2026-09-18 15:59:00 +08:00
SoftFever db91d4b630 Design tab: sketch-first parametric CAD inside the slicer (#15238)
# Description

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

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

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

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

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

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

### Why it belongs in the slicer

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

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

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

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

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

### Not verified

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

# Screenshots/Recordings/Graphs

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


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

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


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

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


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

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


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

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

## Tests

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

Happy to slice this differently — kernel + solver first, GUI second — if
that reviews
better for you.
2026-09-18 14:58:04 +08:00
Ian Chua 4a72a3bba2 Merge branch 'main' into feat/ota-opc-ci 2026-09-18 14:56:00 +08:00
SoftFever c4647c44f2 Add support for labeling profile PRs and improve merge conditions 2026-09-18 14:48:57 +08:00
SoftFever b6c0475dcf fix shell check errors 2026-09-18 14:09:21 +08:00
JAYO3D-Official 52a6ff1764 Add official JAYO filament profiles for Bambu Lab and Creality printers (#15481)
Merged by /bot merge on behalf of @JAYO3D-Official (id 320896770).
Grants: resources/profiles/OrcaFilamentLibrary/filament/JAYO, resources/profiles/OrcaFilamentLibrary.json
Head: fb9a6d70a0
2026-09-18 06:05:03 +00:00
SoftFever f1f68ffc3f Merge branch 'main' into cad-mainline 2026-09-18 14:01:23 +08:00
SoftFever a77209af8f Stop requiring a filament id snapshot update when filaments change 2026-09-18 11:20:57 +08:00
Valerii Bokhan 52f4c68c41 addnorth filament profiles: H2C and A2L support (#14764) 2026-09-17 17:50:30 -03:00
Ian Bassi c833ccdf6f Update localizations and improve strings (#15739) 2026-09-17 14:27:53 -03:00
SoftFever f520e9221f Repair shipped default materials and obsolete settings, and validate them (#15741)
* add orca profile skill

* add default material check

Improve validation for default materials and filament profiles

* Fix default materials and obsolete keys

* clarifying orca-profiles skill
2026-09-18 00:39:46 +08:00
Valerii Bokhan 60b4a61854 Fix: Show indexed coFloatsOrPercents options in unsaved changes dialog (#15472) 2026-09-17 10:56:17 -03:00
Ian Bassi 7065fa9eae Fix extruder clearance help link anchor (#15738) 2026-09-17 09:54:42 -03:00
Ian Bassi 59e40a2c2e Print unsupported walls last (#15411) 2026-09-17 09:14:20 -03:00
Ian Bassi 82e91bd472 Port wipe tower BBS improvements (#15485) 2026-09-17 09:08:50 -03:00
Lam Wei Lun 988108c8ef Fix flatpak test 2026-09-16 18:31:39 +08:00
Lam Wei Lun 075a84093f Fixes issue with showing hidden settings in speed dial. 2026-09-16 16:02:39 +08:00
Lam Wei Lun b01d18bba3 Tab autocomplete. Better search integration of categories and action names 2026-09-16 13:06:16 +08:00
Lam Wei Lun f76e4b1e02 Fixed centering of icons. Disabled zooming in/out on Windows. Added default icons 2026-09-16 12:15:27 +08:00
Lam Wei Lun 435336a3cf Merge branch 'main' into weilun/speed_dial 2026-09-16 10:20:13 +08:00
Tommaso Bianchi 4ebac62519 Extrude accepts a negative distance, and the Bodies card gains Boolean 2026-09-15 14:40:14 +02:00
ExPikaPaka b5ae632bd7 Merge remote-tracking branch 'origin/feature/texture_displacement' into feature/texture_displacement
The remote's 3aefae016f is the same tree as local e245f5d069, and every change
in 013ac898ba (continuous smoothing, world-space bake, mirrored/scaled
placements, plate clamp, UV editor framing/HiDPI, layer scrolling) was already
carried forward and reworked by the local commits. Resolved to the local side.
2026-09-15 11:12:02 +02:00
Ian Chua 173706750d Merge branch 'main' into feat/ota-opc-ci 2026-09-15 15:19:43 +08:00
ExPikaPaka a0430631b8 Texture displacement gizmo icons 2026-09-15 08:58:26 +02:00
ExPikaPaka f3d197c4ca Tests for the step cutter, edge flips, auto resolution and v2 colours 2026-09-15 08:58:26 +02:00
ExPikaPaka 78f873a27f Texture gizmo: one-run pipeline by default, auto resolution, colour fixes, debug view 2026-09-15 08:58:26 +02:00
ExPikaPaka bc28052245 Bump and uvcheck shaders: project in the bake's world frame 2026-09-15 08:58:26 +02:00
ExPikaPaka f802208d4c Texture displacement: step cutter, v2 colours, auto resolution, anchored bake frame 2026-09-15 08:58:26 +02:00
ExPikaPaka 45d10341c1 TextureBake: edge flips along the height field, stage recorder, faster displace 2026-09-15 08:58:26 +02:00
Lam Wei Lun e1d3c90030 Fixes for window rounding 2026-09-14 18:08:34 +08:00
Lam Wei Lun 729638cd95 Merge from main 2026-09-14 17:02:07 +08:00
Lam Wei Lun f070f493e1 Merge branch 'main' into weilun/speed_dial 2026-09-14 16:35:48 +08:00
Lam Wei Lun 9ed8537343 Rounded corner for the dialog 2026-09-14 15:32:51 +08:00
Lam Wei Lun 0131533ae0 Get speed dial to work with translations 2026-09-14 14:59:06 +08:00
Lam Wei Lun 87e278c4b3 Remove unused capture 2026-09-14 12:02:06 +08:00
Lam Wei Lun 1d4b920b30 Merge branch 'main' into weilun/speed_dial
# Conflicts:
#	src/slic3r/GUI/Tab.cpp
2026-09-14 10:37:16 +08:00
Lam Wei Lun 50daf5abf3 Merge main 2026-09-11 17:31:56 +08:00
Lam Wei Lun 966e029b95 Code refactoring for better maintainability 2026-09-11 17:15:57 +08:00
Lam Wei Lun c57b74731e Tooltip bottom bar for process/filament/printer settings 2026-09-11 16:38:14 +08:00
Lam Wei Lun 40693a4c94 merge main and fix conflicts 2026-09-11 15:46:20 +08:00
Lam Wei Lun 70a2b3a814 Code cleanup, dedup, update unit tests 2026-09-11 15:43:46 +08:00
Lam Wei Lun 81458dae86 Add category headers. Alphabetical sorting when no search query. Recent count adjustments 2026-09-11 14:56:59 +08:00
Lam Wei Lun 7c2991d00c Update translations. Code dedup and cleanup 2026-09-11 13:05:39 +08:00
Lam Wei Lun 42bee12481 Speed Dial: replace tile monograms with native SVG icons
Tiles previously rendered a colored monogram (title/source initials plus an ordinal) with a per-id hue. Show the matching native SVG icon instead:

- AppAction/NativeCommand gain an `icon` field; commands get a curated key->icon table and settings inherit their group header's icon.
- Notebook tracks each page's resource icon name and reports it in tab_options(), so the tab picker can show it too.
- Searcher records the group icon so settings keep it through search.
- Web tile rendering swaps monogramFor/hue for an <img>; drop the now-unused hue/text CSS vars. Add a test asserting every non-empty icon resolves to a shipped SVG.
2026-09-11 11:36:24 +08:00
Lam Wei Lun 792ab183e4 Merge branch 'main' into weilun/speed_dial 2026-09-11 11:14:19 +08:00
Ian Chua ffb2946b20 feat: compare with environment variable FOLDER_MERGERS for verified folders 2026-09-10 20:09:37 +08:00
Lam Wei Lun 8bbce371b2 Add Help actions. Add Toggle Developer action. Add warning popup when switching between process mode. Add primitives/handy models actions 2026-09-10 18:34:05 +08:00
Tommaso Bianchi edb6aa1722 Sync cad-mainline with upstream main and carry the value-field + rename work on top 2026-09-10 11:01:24 +02:00
Tommaso Bianchi 31a15cc5e5 Rename snaporca/SnapOrca to orca_cad so the OrcaSlicer PR carries no Snapmaker naming 2026-09-10 10:58:46 +02:00
Lam Wei Lun f520af9901 Merge branch 'main' into weilun/speed_dial 2026-09-10 16:39:47 +08:00
Ian Chua e89a05b1e4 fix: repo name 2026-09-10 16:38:03 +08:00
Ian Chua 02a8011b97 feat: add CI to generate OPC for OTA workflow 2026-09-10 16:27:23 +08:00
Lam Wei Lun 1d44320f30 Updated unit tests 2026-09-10 13:29:57 +08:00
Lam Wei Lun 16d285fea6 Cleanup comments and formatting 2026-09-10 13:00:23 +08:00
Lam Wei Lun 9fc6c45770 Fixes potential focus bug 2026-09-10 12:52:10 +08:00
Lam Wei Lun b4beae4be3 Fixed potential UB 2026-09-10 12:47:43 +08:00
Lam Wei Lun a0ba8e12e5 Change to a pin/bookmark icon.
Added shortcut (Ctrl/Cmd+B) to pin/unpin actions.

Clear unresolved pinned actions when switching between modes.

Fixed scroll-back issue when scrolling to pin action
2026-09-10 12:07:36 +08:00
Lam Wei Lun 2346b5c259 Merge branch 'weilun/speed_dial' of https://github.com/lamweilun/OrcaSlicer into weilun/speed_dial 2026-09-10 12:06:48 +08:00
SoftFever 0c07ec9c3c Merge branch 'main' into weilun/speed_dial 2026-09-10 11:54:13 +08:00
Lam Wei Lun 4c6ce75e78 Merge branch 'main' into weilun/speed_dial 2026-09-10 09:35:43 +08:00
ExPikaPaka 8bf80a2a46 Improve texture displacement smoothing and UV editor framing 2026-09-09 15:25:00 +02:00
Lam Wei Lun 0fb87f05b1 Remove printer connect/disconnect for now 2026-09-09 18:05:49 +08:00
Lam Wei Lun 0709c9931e Slight refactor for opening Plugins in speed dial 2026-09-09 17:36:21 +08:00
Lam Wei Lun 3985200672 Search improvements. Code refactoring so its easier to maintain. Experimental features for speed dial: connect/disconnect from printer. 2026-09-09 16:53:58 +08:00
ExPikaPaka 60c03e706a add alternative baking algorithm 2026-09-09 08:42:37 +02:00
Lam Wei Lun c3a21fa0d4 Switched back to jump to setting instead 2026-09-09 12:26:09 +08:00
Lam Wei Lun cae23a933e Plate controls 2026-09-09 11:29:18 +08:00
Lam Wei Lun a1ee7f099f Merge branch 'main' into weilun/speed_dial 2026-09-09 10:06:18 +08:00
SoftFever 994d2de5d6 Merge branch 'main' into pr/tommasobbianchi/15238 2026-09-08 21:54:22 +08:00
Lam Wei Lun 846a95374f Fixed unit test issue. Added basic object manipulation to actions. Added calibration wizards to actions. Added View controls to actions 2026-09-08 18:23:51 +08:00
Lam Wei Lun 2dce0ad24f Back to spacebar for speed dial shortcut. Integrated inline process settings editing. Optimized saerch results 2026-09-08 16:46:56 +08:00
SoftFever 12d43433dc Merge branch 'main' into pr/tommasobbianchi/15238 2026-09-08 14:28:31 +08:00
Lam Wei Lun b32f28e712 Initial Commit of speed dial improvement 2026-09-07 14:00:28 +08:00
Tommaso BianchiandClaude Opus 5 498e92ee35 The gate covers the rounded rectangle, and stops tripping over the plug-in modal
Two harness fixes, both paid for by hours of chasing product bugs that were not there.

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

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

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

38 checks hold on behemoth against e5659e0f0f.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Buggy client sent a _NET_ACTIVE_WINDOW message with a timestamp of 0

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

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

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

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

WHAT THIS ADDS

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

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

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

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

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

WHAT IS PROVEN, AND WHAT IS NOT

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

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

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

    Buggy client sent a _NET_ACTIVE_WINDOW message with a timestamp of 0

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

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

render_live_quotes picks the entity to speak for like this:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Kernel 66115 assertions / 608 cases green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This also lifts the ordering constraint that had pushed the plater's
background colour and Hide() below the panel construction; they go back
where they were.
2026-08-27 22:50:30 +08:00
SoftFever a24ec03e87 fix flatpak build error 2026-08-27 18:50:35 +08:00
SoftFever ea373c653e delete test result 2026-08-27 18:50:09 +08:00
SoftFever 5a170520d3 refactor: rename SnapOrca references to Orca in CAD components to avoid confusion and update recipe versioning 2026-08-27 18:49:50 +08:00
SoftFever 0aeb6df122 Merge branch 'main' into cad-mainline 2026-08-27 17:06:37 +08:00
ExPikaPaka e4c570a7b7 Move computation to background thread & resolve freeze 2026-08-27 09:20:02 +02:00
ExPikaPaka ca52c08317 Improve adaptive subdivision at border & fix some visual bugs 2026-08-26 09:13:50 +02:00
SoftFever 8056b32450 Merge branch 'main' into feat/plugin-lifecycle-evts 2026-08-26 14:43:41 +08:00
Ian Chua fabd08af1e update GCodeExport to use model id for ctx.name 2026-08-25 15:59:17 +08:00
Ian Chua 33a8265a11 fix: slicer regression test 2026-08-25 15:37:15 +08:00
Ian Chua d96682a798 Merge branch 'main' into feat/plugin-lifecycle-evts 2026-08-25 13:50:00 +08:00
Tommaso BianchiandClaude Opus 5 3fd5c3353a A reference is a reference whatever feature holds it
Port of snaporca 1bb9825db0. Four defects from an independent 20-agent audit,
each verified in the code first; two further findings from the same report were
verified OUT and are not in this commit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

snaporca-ziam snaporca-z8rs

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

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

Three defects found doing it, all fixed here:

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

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

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

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

snaporca-ua9g snaporca-ekt9

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

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

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

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

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

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

snaporca-ghcz snaporca-lnri

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

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

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

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

snaporca-j7gc

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GUI target builds and links against the rebuilt deps image.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified: dep_SLVS builds and installs, libslic3r links against SLVS::slvs, and
the CAD suite is unchanged at 2518 assertions in 194 test cases.
2026-08-20 17:44:19 +02:00
ExPikaPaka 3aa48abcb7 Measure post-process smoothing by edge energy instead of height spread 2026-08-20 07:14:21 +02:00
Ian Chua bf22ef2a82 Merge branch 'main' into feat/plugin-lifecycle-evts 2026-08-20 12:21:50 +08:00
Ian Chua dfd3444ae7 feat: initial draft of lifecycle events API for plugins 2026-08-19 19:20:31 +08:00
SoftFever 6a0524ea85 Merge branch 'main' into pr/tommasobbianchi/15238 2026-08-19 19:15:38 +08:00
ExPikaPaka faa0d9a44a Merge branch 'main' into feature/texture_displacement 2026-08-19 09:13:53 +02:00
SoftFever 7d8c024da5 Merge branch 'main' into cad-mainline 2026-08-18 15:33:31 +08:00
Tommaso Bianchi 6fc3c99e31 Mate connectors: draw the dashed pair line between the two origins
The last unbuilt element of snaporca-wgsc. Two connectors a mate binds are one
object with a gap still in it; drawn as two separate frames they read as
unrelated, and "which two of these five frames are the mate?" had no answer on
screen at all.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  docs/design/mate-connectors/

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

FIX, two parts.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Counts a non-empty model.cad_recipe as geometry.

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

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

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

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

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

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

Also in this change, from the same rig session:

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

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

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

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

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

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

Two things were wrong and they compounded.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two deliberate choices:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things this had to get right:

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

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

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

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

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

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

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

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

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

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

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

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

Found by building the tree rather than reading it. Suite goes from 374 to 423
test cases, 54,424 to 54,620 assertions, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:05:02 +02:00
Tommaso BianchiandClaude Opus 5 d584d66003 Mate conflicts: name what silently wins, don't call it over-constraint
Two enabled mates driving the same body is not an error today — the later one
just wins, and the earlier mate looks ignored with nothing said. A cycle in the
mate graph is worse: composition still produces a result, but an arbitrary,
order-dependent one.

recompute() now fills a mate_conflicts vector of (feature index, reason) before
the geometry pass, so it survives a throw further down. It catches a second mate
on the same target body, a mate positioning a body against itself, and a cycle,
via an iterative three-colour DFS over the body graph. Broken mates are skipped
silently — apply_mate() already errors on those.

Deliberately non-fatal: recompute() still returns true and error stays empty.
Deliberately not "over-constraint" — that word promises DOF analysis from a
solver this kernel does not have.

Port of snaporca ec4ffeb979. Kernel half of snaporca-bioq.
Suite: 2213 assertions / 160 cases green on this fork too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:06:17 +02:00
Tommaso BianchiandClaude Opus 5 3eec65f2bd CoordSys: pick a body first, x-ray the rest
In an assembly the face you want for a mate connector is nearly always behind
another body, and a click only ever returns the frontmost hit. Hiding the
occluder from the Parts list works but means leaving the tool mid-pick.

The CoordSys card now carries a Body chooser. Pick a body and every other one
drops to 0.25 alpha AND stops catching clicks, so the wanted face is both
visible and reachable in one gesture. "(all)" restores normal picking.

Deliberately NOT hit cycling: repeated-click cycling was removed from solid
picking as a charter L5/§10 violation (DesignSketchTool.cpp, "NO CYCLE"), and
re-introducing it here would make "click a face" a multi-click gesture again.

Mechanics: DesignSketchTool::set_pick_only_body() gates body_pickable(), which
every pick path already consults; DesignCanvas::set_xray_focus() drives both it
and the per-body alpha in reload(). The chooser stays a pick FILTER only --
coordsys_body still comes from the actual pick, so nothing in the kernel moves.

Body focus follows the CoordSys card: open_tool() reads it back from the combo
rather than clearing outright, because editing a CoordSys feature loads the card
(and its body) before open_tool runs.

snaporca-bgvk. NOT COMPILED: deps/build lacks OpenVDB so the GUI tree will not
configure here; reviewed by diff only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:45:37 +02:00
Tommaso BianchiandClaude Opus 5 8ff7ba440d CadDocument: reindex mate connectors on feature delete and reorder
mate_cs_a/mate_cs_b are feature indices. remove_feature() remapped sketch_ref
through the deletion but not the mate connectors, and move_feature() swapped
sketch_ref but not the mate connectors. Deleting or reordering any feature
ahead of a connector slid both references onto whatever features landed on
those slots.

Nothing reported it. recompute() only rejects out-of-range and non-CoordSys
targets, and a shifted index normally lands on the assembly's other CoordSys —
an assembly carries at least two by construction. So the mate resolved against
the wrong frames and moved the wrong body, silently.

Extracted a remap lambda in remove_feature() and a swap_ref lambda in
move_feature(), applied to sketch_ref and both mate connectors.

Two tests, both confirmed red before the fix. [mate] tags green here:
395 assertions / 29 cases — the first end-to-end kernel compile of this fork.

Ported from snaporca; CadDocument.cpp is byte-identical across forks again.

Refs: snaporca-kqih

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:14:10 +02:00
ExPikaPaka 8434223f96 Add Parallax preview & fix Undo\Redo history 2026-08-12 10:14:38 +02:00
Tommaso Bianchi 0a2faedc32 Design: draw the mate connector, so its verse and polarity are visible
A mate connector was visible only to a program. resolve_datum_coordsys had exactly ONE consumer
in the whole tree -- McpControl.cpp, the agent socket -- so the frame every mate is built on
could not be seen at all, and the two questions a connector has to answer on sight had no
answer in the viewport: which way does Z point (the VERSE), and which of the pair is anchored
versus about to move (the POLARITY).

The glyph is Onshape's proven core plus the part nobody ships. Disc for the XY plane, one gold
quadrant for the roll -- the only in-glyph answer to "where is X", which matters because
Fastened and Slider lock the clocking -- and a Z arrow drawn on +Z ONLY, never double-headed.
Polarity is carried by the head: a filled cone travels, an open collar receives. Onshape,
Fusion, Inventor and FreeCAD all draw both ends of a mate identically, which is why "which part
moves?" is a standing complaint; nothing here invents new semantics, it just stops hiding them.

Polarity is read from the committed Mate features, not only from the open card. A connector some
mate drives must read as driven whenever it is on screen, or the glyph tells the truth only while
a dialog happens to be open. The card, when open, still wins -- that is the live intent.

Judged on the rig rather than in a mock, which changed three decisions:

- Three RGB axis arms lose to one Z arrow. Rendered side by side (SNAPORCA_GLYPH=A selects the
  Onshape-style trio), the three heads are as large as the 22 px disc, they bury the quadrant, and
  at an oblique angle they pile into a smudge -- and the trio is indistinguishable from the move
  gizmo and the bed triad, which are already RGB arrow trios in this viewport.

- Depth off floats, depth on tears. With GL_DEPTH_TEST off, connectors on faces pointing AWAY from
  the camera drew their discs over the solid, so the part looked covered in frames that were on its
  back. Turning depth on fixed that and immediately z-fought: the disc is exactly coplanar with its
  face and came out a broken dotted arc. Depth ON plus a 0.7*upp lift along Z buys both, and scaling
  the lift by upp keeps it sub-pixel instead of opening a visible gap on zoom-in.

- Foreshortening degenerates an arrow into a dot when the axis points at the camera. It now draws a
  ring instead of silently vanishing, which is what a naive projection does.

Everything is sized in screen pixels via upp = 1/zoom, like every other gizmo here: a connector is
a symbol, not a part, so it must not shrink with the model.

Research and the empirical findings are written up in DESIGN_MATE_CONNECTORS.md section 8b;
rig images in artifacts/shots/g-0*.png, vendor reference glyphs in artifacts/glyphs/.

Not fixed here, and recorded rather than papered over: the quadrant collapses to a blob at a
grazing angle, which is exactly when the roll is hardest to read (F4); roll-undefined in red makes
the least important connector the loudest thing on screen (F5); and a true grazing view, a curved
face, and overlap with the move gizmo are still untested. Also surfaced while testing and unrelated
to drawing: add_mate accepted a mate between two connectors on the SAME body and duly transformed
the body relative to itself -- a concrete instance of the missing validation already filed as G6.

Fork parity unchanged: DesignCanvas.cpp 16, DesignPanel.cpp 30, the other four files 0.
2026-08-05 09:50:09 +02:00
Tommaso Bianchi 24f4076bb5 Design: the Hole and Thread cards say which face they are holding
snaporca-200 asked which of the two models of "the card's face input" is right,
because clicking empty canvas now clears the selection (snaporca-od0) and made
them visibly disagree: Thicken / Shell / Draft read the LIVE selection and their
label reverts to "(pick a solid face)", while Hole / Thread LATCH the face they
were opened or picked on and keep it. The complaint was that Hole then drills a
face you can no longer see selected.

Taken to the rig, that turns out to be the wrong half of the story. With Hole
open and its face picked, a click on empty canvas leaves the Ø6.0 ghost and its
dimension gizmo drawn on that exact face — the card was never operating in
secret, it was showing its target the strongest way a CAD tool can. Meanwhile
Draft, whose behaviour was held up as the honest one, threw the pick away and
had to be told the face again.

So neither model replaces the other. They are different in kind: Thicken /
Shell / Draft are operations whose operand IS the selected face, and Hole /
Thread are placement tools with their own plane state that a pick merely seeds.
The latch is also the kinder of the two now that empty clicks are a deliberate
gesture — a stray one costs Thicken a pick and costs Hole nothing.

What was genuinely missing is that nothing in those two cards NAMED the latched
face, so after such a click the only words on screen were the viewport's
"Nothing selected" over a ghost about to drill. Both cards now carry an "On
face" row, the way the other three already do:

  Hole    Face 5 | (none — uses Hole plane)
  Thread  Face 1 | Edge 2 | (none — uses Thread plane)

Thread names an edge when the cylinder came from a circular rim rather than a
cylindrical face, which the code already distinguished internally and never
said out loud.

Verified on both rigs, every state driven through the GUI: face pick on open
and on live pick, survival across a click on empty canvas, and the fallback
after choosing XY/XZ/YZ from the plane dropdown. Thread's edge branch was
exercised on a revolved tube's rim, its face branch on the same tube's outer
wall.

Filed while here, surfaced by the new row rather than caused by it —
snaporca-uif9: re-editing a stored Hole/Thread from the feature tree restores
f.plane into the dropdown but never clears m_hole_on_face, so the re-edit
silently reuses the PREVIOUS card's latched face. Now visible by name instead
of invisible.

Fork parity unchanged: DesignPanel.cpp 30, DesignPanel.hpp 0.
2026-08-03 15:06:17 +02:00
Tommaso Bianchi 93395b7888 Design: escalate on the entity that was picked, and let the chip follow the window
Both found by Kimi reviewing the previous two commits, both then reproduced here
before being touched.

snaporca-97z. The re-pick escalation required m_solid_sel, m_sel_body, m_sel_face AND
m_sel_edge to all match the previous pick. That looked stricter and was wrong: the
edge branch sets only m_sel_edge and m_solid_sel, leaving m_sel_face as whichever face
the ray happened to enter through — and a shared edge is entered through a different
face depending on which side you view it from. So picking an edge and picking that
same edge again from the other side compared equal edges, unequal faces, and refused
the escalation the status line had just promised. Now the comparison is made at the
level that was picked and nothing else. The edge id is already the stable global one
from edge_index_of, so it identifies the edge without help from the face.

Reproduced on the rig without needing to orbit, since two clicks 8px apart across an
edge enter through different faces:

  pick -> sel=3 body=0 face=5 edge=3
  ray  -> body=0 face=0
  re-pick -> escalated to whole body 0
  pick -> sel=1 body=0 face=-1 edge=-1

Same edge, face 5 then face 0, escalation fires. The old condition could not.

The frame-move case. The chip is anchored at an absolute screen position, and until now
nothing told it the window had moved — only a resize, a status change or a tab switch
re-placed it. Dragging the window by its title bar left it stranded where it was,
verified on the rig by moving the frame and watching it stay put. wxEVT_MOVE on the
top-level frame, alongside the ICONIZE and ACTIVATE binds from the previous commit.

Two related cases are filed rather than bound, because the list of window-geometry
events to chase is exactly what snaporca-lcq argues should stop: a layout change that
translates the canvas without resizing it, and wxEVT_DPI_CHANGED.

Not fixed, deliberately, and recorded on snaporca-97z: clicking the same FACE but
landing within the vertex or edge tolerance resolves to a different kind and so does
not escalate — that is the "smallest thing under the cursor" rule working as
documented; and a vertex re-pick after moving the body compares stale world
coordinates.

Verified on both rigs. The cross-face edge case was exercised on orca_cad;
DesignSketchTool.cpp is byte-identical across the forks, so snaporca inherits it, and
its face-level escalation and empty-click clear were re-checked there directly.

Fork parity unchanged: DesignSketchTool.cpp 0, DesignCanvas.cpp 16.
2026-08-03 14:17:57 +02:00
Tommaso Bianchi 6d1a4078ca Design: the status chip goes away with the window, not just with the page
Found by minimising the app on the rig with a face selected: the whole screen goes
black and the chip is still drawn on the bare desktop. A wxPopupWindow is
override-redirect — the window manager does not own it — so it neither iconises with
its frame nor stacks behind other applications. IsShownOnScreen does not catch this
either: an iconised frame still counts as shown, which is why the guard added for the
tab case sails straight past it.

So the frame has to say so itself: ICONIZE and ACTIVATE, both routed through the same
show_status_hud the page change already uses. Restoring is safe — a popup cannot take
focus, so our own Show() cannot re-trigger either event — and restoring while some
other page is up still leaves the chip down, because show_status_hud(true) goes
through place_status_hud's IsShownOnScreen guard.

Verified on both rigs: chip up, minimise -> screen black and empty, restore -> chip
back with its text and the face still selected. Restore while on Prepare -> chip stays
down.

This is the fourth defect from the same root, so snaporca-lcq now asks the question
these binds keep deferring: whether the line should be canvas content, like the view
cube and the round view buttons, rather than a window that has to be told about every
way a window can stop being visible.

Fork parity unchanged: DesignCanvas.cpp 16.
2026-08-03 13:36:09 +02:00
Tommaso Bianchi 9c3ce9b45e Design: clicking empty space lets go of the selection, and the status line follows its tab
Two things a click on nothing should already have done.

snaporca-od0. A click that hit no geometry left the solid selection standing. A
rubber band swept over empty space has always cleared it (pick_bodies_in_rectangle),
and the two gestures cannot disagree about the same outcome. The visible cost was in
the escalation that landed last commit: "click the face, click away, click the face
again" arrived as the SECOND click on the same face and took the whole body, when the
click away was the user letting go of it. Now the miss clears and says so.

This gives up something real, deliberately: Thicken / Shell / Draft hold their input
face in the panel's selection, so a stray click on empty canvas with one of those
cards open hands that face back. Their handlers already write the "(pick a solid
face)" placeholder and rebuild the ghost when the selection empties, so the card SAYS
it lost the pick rather than confirming against a face the viewport has stopped
highlighting. An orbit drag never reaches this branch — it exits at the 8px budget —
so panning the view still does not deselect.

snaporca-dlj. The status line is a wxPopupWindow, which is a TOP-LEVEL window: hiding
the Design page does not hide it. Select a face, switch to Prepare, and the chip was
still there reading "selected (whole body) — right-click for what applies to it" on a
tab with no such selection and no such menu. Same cause, second symptom: a status
update arriving while the page is hidden anchored against a client size that is not
the size the page will have, and parked the chip on the tab bar. So: an
IsShownOnScreen guard in place_status_hud, show_status_hud(bool) to take it down and
bring it back with its text intact, driven from the page-changed handler.

Verified on both rigs, not by reasoning about it: face 5 selected -> click bed ->
"Nothing selected", tint gone -> click the same face -> face 5 again, NOT the body ->
click it again with no click away -> whole body, so snaporca-gem is intact. Prepare ->
chip gone; back to Design -> chip returns. KEYTRACE across the round trip shows
shift+S then R still reaching the canvas (ui_mode 0 -> 1, Rectangle armed), which is
the focus theft this popup replaced a wxFrame to avoid.

Fork parity unchanged: DesignPanel.cpp 30, DesignCanvas.cpp 16, headers and
DesignSketchTool.cpp 0. MainFrame.cpp is outside that set and was edited per fork.
2026-08-03 11:05:45 +02:00
Tommaso Bianchi c32aa3f8ba Design: clicking the same face twice takes the body, and the status line moves onto the viewport
A click could point at a face, an edge or a vertex, but never at the body those
belong to: offer_selection_kind() can only return BodySolid when all three are
clear, which a viewport click never produces. The rubber band was the only door,
and the status line said "face 5 selected" while the user believed they had taken
the body. A second click on the SAME sub-element now escalates to it (snaporca-gem).

Not the pick cycle that was removed in bc2b741ce9 -- that one was silent and three
deep, so no click had a predictable meaning. Here the status line names the next
click before you make it, and a further click just takes the face under the cursor
again, which needs no teaching. Double-click is untouched: wx sends Down/Up/DClick/Up
and only the first Up carries a pending press, so a fast double-click still zooms to
fit and picks once.

The status line itself moved to the base of the viewport. In the side panel it was
clipped at ~73 characters with no warning and no wrap -- set_status()'s Wrap() never
took effect (snaporca-8cc) -- which silently length-limited every hint in the tab; the
first version of this change lost a clause to it. m_status is kept, hidden, as the
owner of the text and its colour, and the line is drawn in a bottom-left twin of the
readout HUD where there is a whole window's width.

Three defects found driving it on the rig, none of which the build could see:

  * the HUD as a wxFrame took the WM's keyboard focus every time it was raised, and
    the canvas then received NO key events -- every sketch shortcut silently dead.
    Caught with SNAPORCA_KEYTRACE: shift+S logged a line, the following R logged
    nothing. It is a wxPopupWindow now, which cannot be focused. SetFocus() on the
    canvas does not fix it: focus was on another toplevel.
  * zero vertical padding fits the popup tighter than the font's line box and clips
    the glyphs; 6 (what the readout uses) reads as a two-line box. 3 is right.
  * "has a caller chosen a colour?" compared the label's foreground against its
    PARENT's, which differ by default, so every line counted as chosen and the
    neutral text came out the panel's dark grey -- invisible on a dark chip. Compare
    against the colour the label was created with, captured before any caller writes.

Verified on both rigs against fresh binaries: sketch -> extrude -> click face ->
click again -> whole body tinted, offer opens with the body rows live and Create /
Add material correctly greyed. Keyboard drives the whole sequence.

Filed and NOT fixed here: snaporca-od0 -- a bare-plate click does not deselect the
solid, so "click away, click back" escalates. Pre-existing; clearing there would also
drop the face the Thicken/Shell/Draft cards hold, which needs its own pass.

Refs: snaporca-gem, snaporca-8cc, snaporca-od0
2026-08-02 12:12:50 +02:00
Tommaso Bianchi 7e5994b8cb Design: right-click a body row opens the offer, and taking a body always means the same thing
The third door onto the offer, after the viewport right-click and the Menu key. A body ROW is
an unambiguous body, so the offer reports BodySolid and the body verbs act on the row you can
see highlighted — the confirmation a face pick cannot give, since pointing at a face lights the
face and never the body the verb will change. The status line has been promising exactly this
("Body N selected — right-click for what applies to it") since before any handler existed on
that list; the product was advertising a gesture that did nothing.

WHAT THE RIG CAUGHT THAT THE BUILD DID NOT. The first version hung the state normalisation off
wxEVT_TREE_SEL_CHANGED. But SelectItem() on a row that is ALREADY selected fires no selection
event, so a stale vertex from an earlier viewport pick survived — and offer_selection_kind()
tests vertex FIRST, so right-clicking the body row served the VERTEX offer while the row sat
highlighted: Fillet/chamfer/draft greyed, Mirror standing where Repeat belongs, "vertex
selected" still in the status line and the cyan marker still on screen. The happy path (fresh
row, nothing else picked) looked perfect, which is why only the deliberate stale-state sequence
exposed it. Reading the code would not have shown it — SelectItem looks like it selects.

So the normalisation is no longer a selection handler. apply_body_row() is called
UNCONDITIONALLY by both doors, because taking a body from the list means the same state change
however it was asked for. It also clears m_sel_solid_vertex, which the original handler never
did — latent while nothing opened the offer from that list, and immediately fatal once
something did.

Verified on both rigs with the failing sequence itself: pick a vertex, then right-click the
already-selected row. Fillet/chamfer/draft enabled, Repeat back in place, status reads "Body 1
selected", vertex marker gone.

Does NOT touch the feature tree. That needs new selection kinds (offer_selection_kind has no
notion of "a feature is selected") plus verbs the atlas does not contain — Suppress, Rename,
Reorder, Roll back — and is filed separately.
2026-08-02 10:31:21 +02:00
Tommaso Bianchi 6c59898ac0 Design: pointing at part of a body is pointing at the body
Tommaso: "i deleted a body using rubber band selection, but this is not intuitive as all
the ux revolves around clicking". Correct on both counts, and a correction to what I said
last round: the rubber band IS implemented and shipping (pick_bodies_in_rectangle, m_rubber,
the drag branch in on_mouse). What is unbound is whole-body picking via CLICK; I read the
comment about the click path and wrongly generalised it to the gesture as a whole.

The handlers were never the problem either. Move, Mirror, Cut, Mass and Colour all resolve
their target through selected_body_default() / m_sel_solid_body, and that is already set when
you click a FACE — level >= 1 records the body. They would have worked from a click all
along. The only thing keeping them out was the atlas gate: accepts listed body_solid and no
face kind, so offer_selection_kind() returning FacePlanar filtered the rows away. This is
therefore an atlas-only change, no handler edits.

Cut, Split, Mirror, Transform, Mass and Colour now accept face/edge/vertex as well, matching
what Delete Body already did. Edges and vertices are included deliberately, not just faces: a
click resolves to a vertex, an edge or a face depending on where inside the pixel it lands,
so accepting only faces would make Move vanish whenever you clicked near a corner — a flicker
that reads as a bug and gets reported as "sometimes it works".

NOT widened: Extrude on a face means push/pull THAT face, and Thicken consumes the face you
point at. Both have genuine face-specific meaning, so widening them would change what they
do rather than where they can be reached from.

The rubber band keeps its job — it is still the only way to take a body without also naming
one of its faces. It just stops being the only door.

Verified on both rigs from a plain face click: Transform > Move opens with Body = Extrude2
(resolved from the face pick), Modify > Edit / Delete Face / Colour / Delete Body, and
Reference > Mass.
2026-08-02 09:57:53 +02:00
Tommaso Bianchi b2654ebd8a Design: a body knows what made it, so "Delete Body" can exist
Reported by Tommaso: select a body, and there is no Delete in the offer. Two independent
faults stacked behind that.

FIRST, clicking a body never selects the body. Whole-body picking is deliberately unbound
(DesignSketchTool.cpp) pending the rubber band, so a viewport click only ever yields
Face/Edge/Vertex. The offer therefore saw face_planar, and "delete" accepted body_solid but
no face kind, so the row was filtered out entirely — while the status line read "Body 1
face 0 selected", which actively teaches the wrong model.

SECOND, even selecting the body from the Bodies list, Delete refused in red: "Select the
FEATURE that created this body". CadBody had no link back to its maker, so the offer was
advertising a verb it could not perform — worse than the action:null rows fixed earlier this
session, because this one is ENABLED and its refusal reads like user error.

CadBody::source_feature fixes the second. It is stamped in ONE place, the recompute loop,
and the rule is just "still unset?". That is sufficient because of an invariant worth
stating: no feature ever replaces a whole CadBody. Every in-place op writes only .shape
(boolean, cut, mirror-fuse, transform, dress-up — all 8 sites checked), so a body keeps the
stamp it was born with; a consumed body is erased outright, taking its stamp with it; and
the only bodies still at -1 are the ones the current feature just pushed. A feature type
added later needs no change here as long as it keeps to that invariant.

"Delete Body" fixes the first, sitting beside "Delete Face" in Modify and reachable by
pointing at any face/edge/vertex. The two names cannot be confused, and "delete" gave up the
body kinds so both can never appear for one selection. Deleting a body removes the feature
that made it, which is a real edit to the recipe, so it asks first and NAMES the feature — a
body vanishing from the viewport is not evidence of which feature went, and this is the one
action here that cannot be eyeballed.

Multi-body delete is NOT offered. bodies_2 was in the first draft of the verb; the handler
deletes exactly one body, so a two-body selection would have silently deleted whichever was
m_sel_solid_body. Caught before it reached a binary, at the cost of one rebuild.

Verified on BOTH rigs, full round trip: click a face -> Modify > Delete Body -> "Delete
Extrude2?" -> body gone, Sketch1 correctly left behind, panel falls back to the idle hint ->
Undo -> Extrude2 and Body 1 restored.
2026-08-02 09:41:58 +02:00
Tommaso Bianchi 34eb4224a1 Design: fix a wrong issue ref in the Thicken comment
The previous commit cites snaporca-y7q, which does not exist — I wrote the ID from memory
instead of reading it back from the bug I had just filed. The real one is snaporca-kgx,
"Offer: Thicken (and peers) open with the picked face discarded". The comment is corrected
here; the commit message above it cannot be, so this note is the pointer.
2026-08-02 09:03:49 +02:00
Tommaso Bianchi 9d47280a19 Design: a card opened from a face must use, and show, that face
snaporca-y7q. Thicken's opener cleared m_sel_solid_face outright. That was right when the
only door was a toolbar button — a button carries no selection, so pressing Thicken had to
clear and ask you to point at something. The offer inverted it: the verb is now invoked ON
a face, and the same line threw away the only thing the user had said. The card opened
reading "(pick a solid face)" over an immediate "thicken: face not found" — you pointed at
the face and were told none could be found.

Keep the pick when the body combo landed on the body it came from (the index is per-body,
and selected_body_default() returns exactly that body when it is valid).

Two neighbours had the mirror-image flaw, both invisible for the same reason — the value
was right and the ghost updated, so only the label lied:
  - Thicken had NO live label update at all. Nothing outside the opener ever wrote
    m_thicken_face_label, so while the card was open you could pick face after face and it
    still read "(pick a solid face)".
  - Shell and Draft wrote theirs ONLY from the pick handler, which runs while a card is
    already open — so opened from a selection they showed the previous pick, or the
    placeholder over a face they were about to use.

So the label is now written once in open_tool(), which every door goes through. The
edit-feature path already restores m_sel_solid_face from the stored feature BEFORE calling
open_tool, so it agrees rather than fights.

Verified on the snaporca rig: face 4 of an extruded plate, offer > Add material > Thicken
now opens "Face: Face 4" with "Preview — 24 triangles" and confirms to a real Body 2. Draft
opened from a face shows "Face 3" and previews the taper. This fork is code-identical here
bar the two permitted DropDown divergences; it still owes a build of its own (snaporca-5pl).

Project keeps its clear: there "(all edges)" is a legitimate default mode rather than a
failure, so changing it would alter behaviour with no reported problem behind it.
2026-08-02 09:02:52 +02:00
Tommaso Bianchi cfc2555c3a Design: a verb's address is data, so the toolbar widget can stop existing
snaporca-7ih's remaining half. Both flyout factories registered their verbs INSIDE the
widget-building loop, so the ~40 retired tool buttons had to be constructed and then
Hide()n: skipping construction would have deleted 42 offer verbs (26 fly:<family>#<row>
+ 16 Shift+keys) while their rows still rendered and did nothing when picked.

Register first, build second. The addresses are pure data; the widget is one door onto
them, not their owner. A family absent from kBarKeep now returns before any wxWindow is
made. The keep-list stays a one-line data decision, not a structural one.

And close the class of bug for good: the constructor now verifies, once, that every verb
the atlas marks wired resolves to a real registration, logging each break and asserting in
debug. Rows that render and do nothing have shipped three times (edit_feature and sk_move
with action:null, then this) and are invisible from either side alone.

Verified on the snaporca rig by walking the offer, not by reading the code — all four
at-risk address kinds run with no widget behind them: fly:design_rect#2 drew an OBLIQUE
rectangle (the third variant, not the family's first), key:S+E opened Extrude with its
10 mm gizmo, fly:material#4 opened Thicken. Hover hints, icons and nesting intact. This
fork is code-identical here bar the two permitted DropDown divergences; it still owes a
build of its own (snaporca-5pl).

Two hints were wrong and are fixed: Cut said "Split the body with a plane", colliding with
the Split verb one row away and pointing at a card for a value the canvas already offers as
a draggable arrow; Split never said its plane comes from a picked face.

Also, because it blocked the verification and will block the next one: gui-session.sh
killed by full path while its own app_pid() matched by basename, so a differently-pathed
instance survived, held the single-instance lock, and got reported as a healthy session —
a Jul-30 binary nearly passed as this build. It now kills by basename and prints which
binary is actually on screen. Traps 6 and 7 documented.
2026-08-02 08:37:44 +02:00
Tommaso BianchiandClaude Opus 5 96816f725c Design: every offer verb has a hint, shown on hover — and the status line wraps
Mirror of snaporca 2b3e890165 (DesignPanel.cpp applied as a patch; parity 30 / 16, shared
files byte-identical).

All 86 verbs now carry a hint: 55 extracted from the C++ tool definitions so the offer and
the armed-tool hint cannot drift, 31 written by hand. One wxEVT_MENU_HIGHLIGHT binding
shows the hovered verb's hint in the status line. The generator asserts that no wired verb
lacks one.

Also: all 200 status writes go through set_status(), which wraps instead of clipping at the
panel edge; and the empty-document hint is called from on_tab_shown() as well, since
after_tree_edit() never runs on a freshly opened tab.

Verified on the rig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 19:46:49 +02:00
Tommaso BianchiandClaude Opus 5 3037e55f44 Design: hints name the gesture that works, and an empty document says how to start
Mirror of snaporca 033347d062 (parity 30 / 16).

Retiring the toolbar made ten hints untrue: each named an action whose door had moved to
the offer, or a button no longer on the bar. They now name the gesture. An empty document
blanked the status line entirely and now says how to start.

Known and not fixed here: m_status does not wrap, so long hints clip; and the 86 offer
verbs still have no per-verb hint of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 19:31:33 +02:00
Tommaso BianchiandClaude Opus 5 811719b7aa Design: Construction goes back on the sketch bar — a mode must show its state
Mirror of snaporca b3d4cf85af (parity 30 / 16).

Hiding it with the drawing tools was wrong: Construction is a persistent MODE, not a tool —
the Bed checkbox, not the Line button. Q and the offer's Construction row kept toggling a
checkbox nobody could see, so you could not tell whether the next line would be construction
geometry.

Scoping unchanged and already correct: m_tb_sketch is shown only in UiMode::Sketch, so it
appears exactly while a sketch is open or being edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 18:36:18 +02:00
Tommaso BianchiandClaude Opus 5 86f1f96c50 Design: the toolbar is chrome — every tool is reached from the offer
Mirror of snaporca 809aa9df87 (DesignPanel.cpp applied as a patch; parity 30 / 16, shared
files byte-identical).

fadd() and sadd() now gate what reaches the bar: file operations, Bed, Undo/Redo, Delete
selected, Commit to Plate, Confirm/Cancel, plus Place on Face and Section view — the last
two because they are chrome_only in the atlas and have no offer row to fall back on.

The tool buttons are still built and then hidden, deliberately: their fly: addresses and
Shift+key bindings are registered inside the widget-building loops, so not building them
would silently drop 42 verbs from the offer while they still rendered. snaporca-7ih covers
hoisting the registrations so the construction can go too.

Four separators whose groups are now empty were dropped; they rendered as stray rules.

Verified on the rig in both modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 18:17:34 +02:00
Tommaso BianchiandClaude Opus 5 a535b0cb76 Design: the offer draws each verb's icon — set the bitmap BEFORE Append, not after
Mirror of snaporca bcab67f8ce (DesignPanel.cpp applied as a patch; parity 30 / 16, shared
files byte-identical).

tool_atlas.json now names an icon for 80 of 86 verbs, derived from the toolbar's own
definitions rather than invented, and every one of the 54 distinct names was checked to
exist in resources/images first.

The first attempt drew nothing despite a green build: wxGTK builds the GtkMenuItem inside
Append() and reads GetBitmap() there, so setting the bitmap on the returned item is a
silent no-op. append_offer_item() constructs, sets, then appends — the same order Orca's
own append_menu_item() uses.

Verified on the rig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 17:55:52 +02:00
Tommaso BianchiandClaude Opus 5 bc4fb3b680 Design: Text and SVG draw INTO the open sketch instead of beside it
Mirror of snaporca 1fb7786d9a (DesignPanel.cpp and DesignCanvas.cpp applied as patches;
parity 30 / 16, shared files byte-identical).

With a sketch open, Text/SVG outlines become ordinary Line entities via push_closed_lines()
instead of a separate Sketch feature carrying rigid imported_regions — so the letters can
be constrained, trimmed and extruded like anything drawn by hand. The buttons and offer
actions arm Select first when in Sketch mode, since begin_sketch() does not run until a
tool is armed.

add_imported_regions() calls reset_autoedit(): without it the glyph contours entered the
draw-then-edit queue and opened a Length field on the first segment, which freezes the
canvas. Caught on the rig, not by reading.

No sketch open: unchanged — a new Sketch feature, still dropped on a picked face.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 17:38:44 +02:00
Tommaso BianchiandClaude Opus 5 447c71a0d2 Design: Text and SVG join Create — they were excluded on a premise that is not true
Mirror of snaporca 6724ea27c5 (DesignPanel.cpp applied as a patch; parity 30, shared files
byte-identical).

chrome_only's rule is "acts on the DOCUMENT, not on a selection". Text and SVG both call
add_imported_sketch(), which drops the art on a picked solid face via
SketchPlane::from_face() — a selection-consuming profile creator, like Sketch. Now
sk_text / sk_svg in the sketch half's Create row, where their toolbar buttons already sit.

Verified on the rig: Create ends Point, Text, SVG.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 17:14:13 +02:00
Tommaso BianchiandClaude Opus 5 d5c5d5675e Design: a body tool acts on the body you picked, not on the first one
Mirror of snaporca e5e223a794 (DesignPanel.cpp applied as a patch; parity 30, hpp
byte-identical).

Every body combo opened on index 0, so picking a body and pressing Mirror acted on a
different solid while the card showed that other body as the target. Nine sites now read
the viewport selection; Boolean takes the picked body as target and a different one as
tool, since defaulting both to the same body is a no-op.

Verified functionally on the rig: picked the 20x20 body, mirrored, and the new body
measures 20x20 — not the 80x50 one it would have used before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 12:15:46 +02:00
Tommaso BianchiandClaude Opus 5 273cf067e8 Design: record that Shell stays in Remove — decided, not overlooked
Mirror of snaporca e2ef3018cd. Data only, and only the `why` prose in two slots — the
generated DesignOffer.hpp is byte-identical, so there is nothing to rebuild.

Ratified 2026-08-01: the offer deliberately splits the toolbar's dressup family. Shell
hollows a solid so it sits in Remove; Delete Face edits an existing solid so it sits in
Modify. Recorded in both slots' `why` so either half explains the split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 10:01:34 +02:00
Tommaso BianchiandClaude Opus 5 f6e6cd83c1 Design: the fillet row is named for the tools it actually holds
Mirror of snaporca 352cf1c259 (data only — tool_atlas.json + the regenerated
DesignOffer.hpp; both byte-identical across the forks).

"Fillet / chamfer / draft" rather than naming shell too: Shell is in Remove and Delete
Face in Modify. Only the toolbar's dressup dropdown groups all five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 09:08:21 +02:00
Tommaso BianchiandClaude Opus 5 0c2b643b0b Design: the card says which tool it is, and "Dress-up" stops being a word we use
Mirror of snaporca 3677937964 (DesignPanel.cpp applied as a patch; parity 30, shared
files byte-identical).

The card header read "Fillet 1" over a chamfer because the offer's Chamfer address opened
the tool before setting the type, and open_tool() titles the card from that combo. Choose
first, then open.

"Dress-up" removed from the offer row (-> "Fillet / chamfer"), the card field (-> "Type",
it was a label reading Dress-up whose value said Chamfer) and the toolbar tooltip.

Verified on the rig: header "Chamfer 1", field "Type: Chamfer", row "Fillet / chamfer".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 09:00:23 +02:00
Tommaso BianchiandClaude Opus 5 3c998b8a62 Design: a tool's options come from the tool, not from a card on the left
Mirror of snaporca 6d5510734b (DesignPanel.cpp applied as a patch; parity re-checked at
30 lines, shared files byte-identical).

Polygon's Sides/Circumscribed card is deleted — the choice is made in Create > Polygon,
which names the counts and the two fits, because the side count cannot be recovered after
drawing. Dress-up, Combine and Pattern were single verbs hiding several behind a combo
and now name each one in the offer. Fixes fillet and chamfer both carrying key:S+F, which
made the offer's Chamfer open a Fillet.

Built green and verified on the rig: the Dress-up card opened from Chamfer reads Chamfer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 08:48:05 +02:00
Tommaso BianchiandClaude Opus 5 d1d61ce997 Design: every sketch tool has an address in the offer, not just its family
Mirror of snaporca 0c83f59f13 (DesignPanel.cpp applied as a patch, not copied, so this
fork's 30 permitted divergent lines survive; parity re-checked at 30/16 with the shared
files byte-identical).

The sketch dropdown never registered "fly:<family>#<row>" addresses the way
feat_dropdown does for model verbs, so the offer could name a family but only ever arm
its first tool — Rectangle always gave a corner rectangle. Adds the registration, 14
atlas verbs (including the entire array family, which was absent, and rotate/scale), an
action for the sk_move row that previously did nothing when picked, and a second submenu
level so variants nest under their family instead of flattening 19 create tools.

Built green and verified on the rig: Oblique rectangle arms oblique, not corner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 08:20:47 +02:00
Tommaso BianchiandClaude Opus 5 fd1bc092d8 Design: slot Radius caption, keyboard offer, plane combo removal, mass props, docs
Mirror of snaporca ac85277bac..0e7cb3ec78 (six changes, applied as a patch to
DesignPanel.cpp rather than copied, so this fork's 30 permitted divergent lines survive
— parity re-checked afterwards: the five shared files are byte-identical, DesignCanvas.cpp
and DesignPanel.cpp differ by exactly 16 and 30 lines).

- The straight slot's inline field says Radius, which is what it sets. It stores the
  half-width and passed the typed number through unchanged, so 30 produced a 60 mm slot.
- The offer opens from the keyboard (Menu, Shift+F10), anchored on the viewport rather
  than wherever the pointer happens to be. The card hint names the new route.
- The sketch card's Plane combo is gone; the plane comes from the viewport. Also stops
  build_candidate collapsing a face plane to a base plane while editing.
- Mass properties and the dead Edit row are wired into the offer; DesignOffer.hpp is
  regenerated from tool_atlas.json, verified by re-running the generator and diffing.
- docs/rig_build_traps.md + scripts/rig-build.sh, which derives its fork identity from
  project() so it cannot be pointed at the other fork's image or volume.
- docs/design_tab.md refreshed (44 commits stale) + a PR description, with this fork's
  own merge-base and diff shape rather than snaporca's.

Built green in the deps container with the new script and verified on the rig: Menu and
Shift+F10 both open the offer at the viewport centre with the pointer parked off-canvas,
and the sketch card shows no Plane row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 06:21:08 +02:00
Tommaso BianchiandClaude Opus 5 6e9910303b Design: a sketch takes its floating chrome with it when it ends
Confirming a sketch while an inline value field was open left the field behind. The
editor is a top-level frame, so it survived the session that owned it, and inline_busy
stayed set with it — on_mouse_impl then returned true at its first branch for every
later click and the viewport was simply dead. No refusal, no message: exactly the
"click the geometry, nothing happens" the pick bugs above it were mistaken for.

finish() and cancel() now call close_session_chrome(): dismiss the open field
(keep-as-drawn, the same contract the polyline terminators already use), drop the
queue of fields behind it, and clear the corner readout — which had the same defect
for the same reason, sitting on 336.8° over a committed sketch because nothing redraws
the HUD once the tool stops.

Verified on the rig on the exact reported sequence: line on XZ, Return to accept the
length, Confirm with the Angle field still open. The field goes, the sketch commits,
and the next click reaches the pick (pick trace shows down/up consumed) and selects
Sketch1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-08-01 02:35:29 +02:00
Tommaso BianchiandClaude Opus 5 1addba6ea0 Design: editing a quote UPDATES its dimension instead of appending a rival to it
Type a new length into a sketch quote and the number changed while the geometry
sat still, with the solver dropping to "Conflicting constraints" — a broken
constraint state the user never asked for, arrived at by doing the one thing the
status line invited.

An experiment separated the two candidate causes. Same gesture, one variable:
a line committed WITH a driving length refused the edit and conflicted; a line
committed with none (Esc keeps it as drawn) accepted it and visibly shrank. So
the value was never the problem and the solver was not wrong — it was being
handed two contradicting constraints and correctly declining to choose.

The quote-edit path appended unconditionally:

    a.con = int(m_constraints.size());
    m_constraints.push_back(constraint_for(a));

Accepting the length at draw time creates Distance(P0,P1) = 64.9. Clicking the
quote later creates a SECOND Distance on the same two points asking for 30. Over
-constrained by construction. A line with no dimension yet only ever gets one
constraint, which is exactly why this looked intermittent rather than total.

upsert_constraint() finds an existing constraint with the same type and operands,
overwrites its value and returns its index; it appends only when there is none.
Operand order is ignored — a Distance from A to B is the same constraint as B to
A, and so is an Angle. Returning the index matters as much as the update: it
keeps the annotation's `con` pointing at the constraint that is actually live, so
the NEXT edit is an update too rather than reverting to appending after one good
round. upsert_dimension() applies the same rule to the visible quote, which had
been stacking labels reading different values on the same pixel, and keeps the
existing label position so a placed quote does not teleport.

set_dimension_value already did the right thing through a.con. The machinery
existed; these two call sites never consulted it.

Verified on :11 on the exact failing case — draw a line, Return to lock the
length, Confirm, double-click to re-open, click the line, type 30 into the quote:
the line shrinks, the quote reads 30.0 mm, one label not two, and the solver
stays at "3 degrees of freedom" with no conflict.

NOT included: record_dimension_constraint() has the same unconditional push_back
in all six of its branches. It belongs to the legacy Constrain mode with
different selection semantics and I could not exercise it, so it is flagged
rather than changed blind.

Refs snaporca-e1p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 21:35:41 +02:00
Tommaso BianchiandClaude Opus 5 a1fdb9f217 Design: double-click a sketch stroke to edit it — the gesture belongs on the geometry
Selecting a committed sketch line lit the right tree row and then told the user
to go and press Edit in the panel. That is the side-panel dependency this tab
exists to remove, and it made "selectable" true while "editable from the
geometry" stayed false.

on_edit_feature already does the whole job — re-open the entities in the sketch
UI with handles and live quotes — and was only ever reachable from a tree row.
A double-click on a committed stroke now calls it for that feature. Double-click
on empty space still fits the view, so nothing is taken away.

The stroke hit test is now one hit_display_sketch() shared by the click and the
double-click. Two copies of "what is under the pointer" drift, and a double-click
acting on a different entity than the click before it is a miserable thing to
chase. It also reports the entity index, which the tracer prints, so a pick that
lands on the wrong stroke can be seen rather than inferred.

Verified on :11 end to end: draw an open line, commit, double-click it. The
tracer prints "double-click -> edit sketch feature 0 (entity 0)", the panel
reads "Editing sketch — drag a handle or click a quote to edit", and a click
inside the session selects the line with endpoint handles, live quotes and
"1 selected — Delete removes them".

NOT delivered by this commit, found while verifying it: typing a new value into
a length quote is accepted and displayed but the geometry does not move and the
solver drops to "Conflicting constraints". Filed separately — it lives in the
constraint layer, not in selection, and nothing here touches it.

Refs snaporca-e1p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 21:10:08 +02:00
Tommaso BianchiandClaude Opus 5 c5404507c7 Design: an open sketch line can be clicked — region membership is not a licence to be pointed at
Reported from the rig: a committed sketch holding a single open line rendered on
the plate and could not be selected, so it could not be edited or deleted from
the viewport at all.

The viewport pick for committed sketches iterated region_loops(). That function
exists to find EXTRUDABLE regions and, as its own walk comment says, an open
chain "stalls" and is discarded. So for a sketch of open entities it returns an
empty list, the pick loop has nothing to iterate, and every click falls through
to bare plate. Not a tolerance problem and not a focus problem: there was no
candidate geometry to test against.

Whether a stroke bounds a closed region has nothing to do with whether the user
can point at it. The stroke test now covers every non-construction entity, and a
separate entity->region map preserves what a hit REPORTS, so a click inside a
closed loop still names that loop exactly as before. Region membership decides
the report, not whether the hit can happen.

The rest of the path was already written and simply unreachable: the panel's
handler has a region < 0 branch that selects the feature, highlights it in the
tree and says "Sketch selected — Extrude it, or Edit / Delete from the tree".
This makes existing behaviour reachable rather than adding new behaviour.

Verified on :11 with the pick tracer (SNAPORCA_PICK_TRACE=1), which is what
distinguished the two failure modes: before, the click reached the handler and
fell through to handle_solid_click ("no solid data"); after, it is consumed by
the display-sketch test and never reaches it, and the panel reads "Sketch
selected" with Sketch1 lit in the tree.

Construction geometry stays unpickable, matching region_loops' own filter. It is
the same class of bug — a construction line cannot be selected to delete it —
but including it risks construction stealing picks from real geometry, so it is
left as a separate decision.

Refs snaporca-e1p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 19:55:56 +02:00
Tommaso BianchiandClaude Opus 5 9f2b2bc511 Design: sketch means a tool — the offer works inside a sketch, and the app stops
contradicting itself about the plane

Reported from the rig: pick a plane, press Sketch, and you are told to pick a
plane. The app prescribed a sequence and then refused to acknowledge that you
had followed it. Right-click did nothing, so there was no way to reach a
drawing tool except the toolbar this tab exists to retire.

act_sketch was two lines: set the mode, then print "Click a face or a reference
plane in the viewport, then a sketch tool" — unconditionally, without ever
asking whether a plane was already chosen. The plane was never lost; m_ref_plane
held it and begin_sketch captures it when the first tool is armed. The sentence
was simply false.

It now asks. sketch_plane_target() is a companion to sketch_plane_from_selection
that distinguishes "the user chose XZ" from "nothing chosen, falling back to XY"
— a distinction m_ref_plane cannot express on its own, being always a valid
index, so m_plane_picked carries it. With a target the readout names it and the
offer opens on the Create row; without one the old prompt stands, because then
it is true. The card above the status line was a local wxStaticText that nothing
could update, so it went on asking for a plane two inches from a line saying the
plane was chosen. It is a member now and the two are written together.

Right-click inside a sketch was excluded wholesale so that it could end a
polyline chain, abandon an anchor, exit a tool. That made every sketch row in
the atlas unreachable. The honest test is not which mode we are in but whether
the tool actually USED this right-click, and only the tool knows: on_mouse now
wraps on_mouse_impl and records that once, for every terminator, instead of
threading a flag through the twenty-odd sites that consume a RightDown. The
canvas read-and-clears it on the matching release.

Underneath all of it was one confusion — MODE versus SESSION — at four sites.
begin_sketch does not run until the first tool is armed, so is_sketching() is
false for exactly the interval between "press Sketch" and "pick a tool", which
is precisely when the drawing tools must be on offer. The keyboard learned this
once already (snaporca-0ud, whose comment states the rule) and I reintroduced it
in offer_selection_kind and again in show_offer_menu, where the offer built from
the FEATURE map and rendered nine rows that all refused the sketch selection.
Both now call sketch_map_applies(), so they cannot drift apart again. The
keyboard keeps its own split: its "sketching" gates undo and delete-last-entity,
which genuinely need a live session.

Verified on :11 against a fresh build. Pick XZ, press Sketch: card reads
"Drawing on XZ", status reads "Sketching on XZ — pick a tool", offer opens with
Create and Reference live and the six rows needing geometry greyed. Right-click
while idle opens the offer. Right-click as a terminator does NOT — the chain
ends, the line lands on XZ, its length field arms at 49.36 mm. That last one is
the regression the blanket exclusion was buying and the reason this shape of fix
was chosen over a mode test.

Refs snaporca-6vs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 19:21:42 +02:00
Tommaso BianchiandClaude Opus 5 bb403b82cf Design: left-drag sweeps a rubber band, and it takes the whole body
The pick cycle died two commits ago and left no viewport route to a whole
body at all: one click resolves vertex, edge or face, double-click is
already zoom-to-fit, and the only way to take a body was the Bodies list —
a geometry-first violation for as long as it stood. The rubber band is that
route.

Left-drag is the gesture, as asked. That button was orbit, so this canvas
now maps the mouse the way every CAD the user already knows does: left
selects, middle orbits, right pans. The change is a single flag on
GLCanvas3D set only by DesignCanvas, so Prepare and Preview keep the mouse
their users learned. Sketch mode inherits the same mapping, which is the
consistent reading — Design is one modality, not two.

Past an 8 px budget a press becomes a sweep, anchored at the ORIGINAL press
point rather than at the frame where the threshold was crossed, so the
first few pixels are not lost. Below the budget it is still a click and the
existing vertex/edge/face pick runs untouched. Sampling is the display
mesh's triangle vertices plus centroids — the same points the ray pick
tests, already in world coordinates — and the body with the most samples
inside wins, because the selection callback downstream carries one body.
Crossing semantics: touching selects. Enclosed-only for left-to-right and
crossing for right-to-left is the fuller CAD convention and is deferred,
not forgotten; with one selectable body it would have bought nothing.

Two defects fixed on the way, both found by exercising this:

Right-drag pans, and every pan ended by popping the offer over wherever the
camera stopped — the context menu arriving as the reward for moving the
view. The offer is now the release of a STATIONARY right-click, at the same
8 px budget the pick uses.

The selection handler wrote m_status twice. Only the later write ever
reached the screen, so the earlier block had been dead since it was
written, and its labels drifted out of step with the live ones unnoticed —
including a vertex fix I made this morning in the branch that never
renders. Deleted, with a note saying why, rather than left as two writers
for the next person to pick the wrong one.

Verified on :11 against a fresh build: click takes face 5; left-drag across
the body reports "selected (whole body)" with the whole solid tinted and
the camera unmoved; left-drag over empty space clears; stationary
right-click opens the offer; right-drag pans with no menu; middle-drag
orbits. Precedence re-checked after the deletion — face at 25 px from the
corner, vertex from 10 px in.

Refs snaporca-9xw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-31 18:11:59 +02:00
Tommaso Bianchi 33f97d259b Design: vertex picking — a click near a corner takes the corner
Completes the precedence Tommaso asked for: vertex, then edge, then face, all
from ONE click, all decided in screen pixels. Verified on :11 by sweeping into
the right corner of a plate — x=1250 and 1290 report "face 5 selected", x=1308,
1318 and 1323 report "vertex selected" — and the cyan marker lands exactly on
the corner in the render.

The vertex tolerance (11 px) is deliberately LARGER than the edge one (8 px). A
corner lies ON its edges, so equal radii would make vertices unreachable: every
click near one would resolve to the edge underneath. Bigger-wins-first is what
makes the smallest entity actually pickable.

Vertices come from the sampled edge polylines' endpoints rather than a separate
topology walk — every corner of a face is the end of one of its edges, so the
data was already in hand.

The highlight is a camera-facing square scaled by 1/zoom, the same trick the
edge ribbon uses, so it reads as a constant dot at any zoom. This is why vertex
picking did not ship with the previous commit: the Edge render path billboards
a ribbon and degenerates on a two-point input, and a selection you cannot see
is not a selection (L5). Better to add the primitive than to fake the feature.

DesignPanel now distinguishes level 4: a picked corner sets neither face nor
edge, because a corner is not its face, and the offer classifies it as
OfferSel::Vertex — where Plane, Axis and Coord Sys already accept it.

snaporca-9xw (rubber band still open — see the issue).
2026-07-31 12:48:15 +02:00
Tommaso Bianchi b003d20e37 Design: kill the pick cycle — one click selects what is under the cursor
Tommaso, correctly: fix selection before building on it. I had taken the
whole→face→edge click cycle as terrain and hung the tool offer off it, when §10
of the charter already listed that cycle as an L5 violation. An offer can only
ever be as truthful as the selection beneath it, so this is the foundation and
it should have come first.

NOW: one click selects the SMALLEST thing under the pointer — the edge if the
cursor is within tolerance of one, otherwise the face. No repeat clicks, no
state, no memory of what was picked before. Verified by sweeping a column of
single clicks down a plate on :11: y=800..915 all report "face 5 selected", and
y=925/935 — within a few pixels of the front edge — report "edge 3 selected".
One gesture, one deterministic result, which is what L5 asks for.

TOLERANCE IS IN SCREEN PIXELS. The old edge step compared a ray-to-segment
distance in millimetres, so the same gesture meant different things at
different zoom levels. The pointer is a screen object; its tolerance has to be
one too. 8 px, measured against the edge polyline projected through the camera.

WHAT IS NOT HERE, AND WHY IT IS NOT FAKED. Whole-body selection has no viewport
gesture in this commit. Double-click is ALREADY zoom-to-fit, bound earlier in
the same on_mouse, and this pick runs on LeftUp where LeftDClick() can never be
true — so a double-click branch here would have been dead code that reads like
a working feature. I wrote one, found it unreachable, and deleted it rather
than leave it. The body gesture is the rubber band, which is its own piece of
work; until it lands bodies are selected from the Bodies list, and the hole is
named in a comment at the site instead of being left for someone to trip over.

Six status strings that promised the cycle ("click again for a face", "click
again for an edge", "click again to reset") are gone — they described a
behaviour that no longer exists, and a hint that lies is worse than none.

Both forks build. Parity: DesignSketchTool.cpp byte-identical, DesignPanel.cpp
30 divergent lines — the invariant exactly.

snaporca-6vs.
2026-07-31 12:37:21 +02:00
Tommaso Bianchi 7114e316ea Design: the offer ships — right-click the geometry, get what applies to it
Row order is RATIFIED (charter 4.1, 2026-07-31) and this is the first working
implementation of it: right-click in the Design viewport and a vertical list
opens at the pointer with the eight families in their fixed order, the verbs
that apply live, and the ones that do not disabled IN PLACE carrying their
reason.

THE MAP EXISTS ONCE. DesignOffer.hpp is GENERATED from docs/ux/tool_atlas.json
by docs/ux/mockups/gen_offer_table.py — the same file the 113 mockups are drawn
from. A drawing and the product therefore cannot drift apart, which is the only
way row constancy survives contact with a codebase. Never hand-edit the header.

NOTHING IS RE-IMPLEMENTED. Each row routes to the code that already runs that
verb: "key:S+E" through m_keys_feature, "key:L" through m_keys_sketch,
"fly:material#4" through the feature flyout's own action, "btn:colour" through
the standalone button. The offer is a second door onto the same room, so the
toolbar, the shortcuts and the menu cannot drift into three behaviours. The 8
verbs with kernel support but no GUI path show disabled, which is honest and
matches section 10 of the charter.

Right-click only fires the offer when the canvas is IDLE. Right-click already
ends a polyline chain and finishes the move gizmo; taking those over would
break two working interactions to add a third.

Two things the running build corrected, both found by looking at screenshots:

- THE REASON MUST BE TRUE FOR WHAT IS IN FRONT OF THE USER. Taking the first
  refusal in a family printed "Transform needs a body — add or import one
  first" on a document that HAS a body, because the real obstacle was that
  nothing was selected. Now the reason comes from a verb that accepts the
  current selection and fails only on document state; if no verb in the family
  accepts this selection at all, it says "select something first" or says
  nothing. A menu whose whole value is telling the truth cannot ship a lie.
- Classification follows the level the pick cycle has REACHED, not the face the
  ray happened to hit, so the header cannot name a face while the whole body is
  lit. Sketching on the face you merely clicked is untouched — that path is
  sketch_plane_from_selection (snaporca-3a2).

Verified on :11 end to end: nothing selected shows Sketch live with Shift+S and
seven greyed rows each explaining itself; a selected solid shows Move directly
with Shift+Y (one applicable verb, so no submenu and no extra click) and five
families as submenus. Both forks compile and link.

Fork parity re-checked after the port: DesignPanel.cpp 30 divergent lines,
DesignCanvas.cpp 16, every other CAD file byte-identical — the invariant exactly.

snaporca-96r.
2026-07-31 12:20:07 +02:00
Tommaso Bianchi 00948cf767 docs: the offer is a vertical list, and it opens on every machine
Folds the form-factor decision into the charter. 4.1 is rewritten around
Tommaso's proposal — left-click selects, right-click opens a vertical list of
icon / name / shortcut — and the radial is demoted to a "Rejected" subsection
rather than deleted, because it is a good idea that loses on evidence and
somebody will propose it again. The evidence is recorded with it: mean fill of
3.45 of 8, only two live slots on a fresh document, sketch Create needing nine
addresses on an eight-slot ring, names that do not fit around a circle in
translation, and a 380px disc over the model on a 1366x768 screen.

The invariant survives the change of geometry, which is the useful proof: same
eight families, same fixed order, nothing re-sorted or compacted. It is now
stated as ROW constancy, and the one substantive gain is that unavailable verbs
are disabled IN PLACE carrying their own reason, in strings the product already
ships. An empty ring slot was mute; a greyed row teaches. On a first-run
document the offer stops being a mostly-blank control and becomes a map of what
the product does and what you must do first — which is the section 2 audience
in one picture.

Opening it is now a table rather than an assumption, because "right-click" is
not a universal gesture: two-button mouse right-clicks, trackpads two-finger
tap, a one-button Mac LONG-PRESSES or Ctrl-clicks, touch and pen long-press,
and the keyboard uses the Menu key or Shift+F10. The long-press is explicitly
an ADDITIONAL route — 6.2 forbids press-and-hold as a sole path and that
stands, so the rule now names its own exception and closes it — and it must
show that it is charging, or a user who lets go early concludes the product is
broken (L5).

Consequently: 6.2's keyboard bullet describes opening and walking the offer by
key rather than by compass direction; the gate gains question 13 (every new
pointer gesture declares its keyboard equivalent and what a one-button Mac, a
trackpad and a touch screen do) and question 12 now asks whether tool_atlas.json
was updated and the atlas regenerated; section 10 points at the rendered atlas
and says the outstanding thing is ratifying row order, not drawing the map.

snaporca-96r.
2026-07-31 11:57:53 +02:00
Tommaso Bianchi c4990ee956 docs/ux: draw the offer as a vertical list too, and it wins
Tommaso was not sure about the ring and proposed a vertical list: left-click
selects, right-click exposes icon / name / shortcut. Drawn, it is better, and
the reasons are visible in the renders rather than arguable.

THE DISABLED ROW CAN SPEAK. This is the one that decides it. A ring slot that
does not apply is an empty circle: it says nothing, and on a fresh document six
of the eight are empty. A list row that does not apply is greyed IN PLACE with
its own name and its own reason — "Create a sketch, or pick a solid face,
first", "Create a solid body to pattern first" — which are strings the product
already ships and which tool_atlas.json already carries. The first-run picture
stops being a mostly-empty ring and becomes a map of what the product does and
what you must do first. For the audience section 2 puts first, that is the
whole ballgame.

THE OVERFLOW DISAPPEARS. Sketch Create needs nine addresses; a ring of eight
pushed Polygon and Point behind a "More" slot. Nine rows is just nine rows. The
one measured defect in the ring design is not a defect in this one.

SHORTCUTS READ AS A COLUMN. Right-aligned in a list they stack into something
the eye learns passively, which is exactly the graduation path 4.1 claims —
and it is the mechanism by which the power user Tommaso describes stops opening
the menu at all. Around a ring the same keys are eight loose chips.

Also, unglamorously: long translated names fit, arrow keys and screen readers
work natively where a radial needs special handling, and a 324px box costs the
1366x768 machine far less than a 380px disc over the model.

What the ring keeps: equidistant targets and a future flick gesture. Since the
brief is that power users live on the keyboard, that buys less than it looks.

The invariant is untouched — same eight families, same fixed order, nothing
re-sorted, nothing compacted. Only the geometry changed, which is the point:
the map survived a change of form factor, so it was a real map.

Both forms are now rendered side by side for the same states, and the atlas
opens with the pairs.

snaporca-96r.
2026-07-31 11:50:40 +02:00
Tommaso Bianchi eb2fc986a4 docs/ux: the offer atlas — every tool, every state, drawn
The charter fixed the slot-constancy invariant but carried one hand-written
eight-cell table as an illustration, and nothing of the offer exists in the
product. Before any GUI code, the group needs the map itself: what verbs there
are, what each one needs before it can be offered, and what the ring actually
looks like in every situation a user can put it in.

tool_atlas.json is the source of truth and it was extracted from the code, not
from memory: verbs, shortcuts and the exact refusal strings from DesignPanel's
six feat_dropdown call sites and the sk_key table, kernel coverage checked
against CadFeatureType, headless coverage against McpControl's dispatch, and
the selection kinds taken from the callbacks DesignCanvas actually exposes. It
carries each verb's preconditions, so "why is that slot empty" has an answer
already written in the product's own words. The eventual C++ table generates
from this file too — the map exists once.

gen_offer_mockups.py renders it: 20 selection kinds x 2 document states = 40
primary rings, plus 73 sub-rings, plus comparison sheets. 113 states, none of
them hand-drawn, because a human drawing 113 rings is exactly how an address
quietly changes. Everything is framed at 1366x768, the charter's own reach
target, so L11 is tested in the mockups before it is tested in code.

Three things the drawing found that the prose had not:

- MEAN FILL IS 3.45 OF 8. The empty-slot rule is cheap in argument and
  expensive on screen; on a fresh document exactly two slots are live. That
  picture is the anti-clutter thesis made literal and it is the strongest image
  in the set.
- SUB-RINGS MUST ANCHOR ON THEIR PARENT. Fanning them from north put Extrude at
  N, which is Create's address in the primary map, so the second level
  contradicted the first. Anchored, an address is two consistent strokes: Add
  material is NE and its first verb is NE again.
- ONE FAMILY OVERFLOWS, AND ONLY ONE. Sketch-mode Create needs nine addresses
  on an eight-slot ring. That is the ninth-position pressure the charter
  predicted, arriving on schedule and measured rather than argued: either Point
  moves family, or the tail goes to a third level, or the ring is not eight.
  Every model-mode family fits. The generator refuses to wrap a tenth verb onto
  a first — silent collision is the one outcome worse than an ugly ring — and
  reports the overflow instead.

Also fixed while looking at renders: the selection pill sat on top of the north
slot's shortcut chip and hid it, and the scrim at 0.55 swallowed the very face
the ring had been opened on, which is 4.1 failing inside its own mockup.

Fork-neutral: the generator and everything it emits name no product, so both
forks carry byte-identical copies.

snaporca-2is.
2026-07-31 11:40:45 +02:00
Tommaso Bianchi c3d286070e docs: the offer is a fixed address space, not a context menu
Tommaso's requirement, and it changes what the offer IS: a tool must sit in the
same physical position whatever you selected. Click a face, an edge or a text
and fillet is in fillet's place every time. Position becomes an address the hand
learns, and the eye stops being needed.

That kills the ordering rule this section had two commits ago. "Most-used first
for that kind of selection" is adaptive ordering, and adaptive ordering destroys
the one property that makes a spatial menu fast — worse, it destroys it exactly
for the user who has just started to learn the layout. Office 2000 shipped that
idea and withdrew it. So: NO adaptive ordering, ever, in any form.

The invariant, written to survive every future feature: every tool has exactly
one address; that address is identical in every selection type where the tool
appears; slots for inapplicable tools are left EMPTY rather than compacted; and
adding a tool never re-addresses an existing one. Empty slots are the price of
constancy and they are cheap — a compacted offer is denser and unlearnable, a
sparse one is memorised in a week. An empty slot also answers a question ("this
cannot be done to this thing") that a silently-inert tool does not.

Radial rather than a strip, reversing what I proposed last time and for a reason
that only appears once constancy is the requirement: a direction from the click
point is an absolute address that survives the offer opening anywhere on screen
and survives being clamped at a screen edge, while "third item down" does not.
Centre is a hole so the picked geometry stays visible, and it names what is
selected, so a mis-pick is caught before a verb is chosen.

Every slot carries its keyboard shortcut beside the icon and the word. This is
the graduation path and it is why power users never see a conflict: you reach
for the place, the place says "F", and one day your hand types F before the ring
finishes drawing. The offer is the mechanism by which a beginner stops needing
the offer — one interface at two speeds, no advanced mode in between.

Also here: a proposed eight-position compass map across face/edge/body/text
(create, add, remove, dress-up, repeat, transform, reference, modify) offered as
the group's first ratification, with families opening a secondary ring under the
same rule; arrow/numpad direction addressing so the spatial map works from the
keyboard; a gate question 12 that treats re-addressing an existing tool as a
breaking change to every user's muscle memory.

snaporca-2is.
2026-07-31 11:10:46 +02:00
Tommaso Bianchi d7583f0a2d docs: the grammar becomes object-driven, and commit stops being invisible
Two changes to section 4, both from Tommaso.

FIRST: the selection does not merely feed the tool, it DETERMINES WHICH TOOLS
EXIST. Point at a planar face and the product offers the small set of things a
planar face can become; point at an edge and it offers fillet, chamfer and the
sketch tools that can reference it. Nothing else, because nothing else is
possible. This is the largest single thing available to us for a first-time
user, and the reason is worth writing down: a beginner's difficulty is not
operating a tool, it is not knowing which tools apply to what they are looking
at. Sixty icons answer a question they cannot yet ask; a face that offers its
own five verbs teaches the product by being used. It also deletes a whole class
of failure — a tool that silently does nothing because the selection was wrong
becomes unreachable.

The offer is an accelerator, not a toll gate: toolbar and single-letter
shortcuts keep working unchanged and consume the same selection, so an expert
never looks at the offer and a beginner never needs the toolbar. Both routes
land in the same place, which is how one interface serves all three audiences.

SECOND: "click empty space to commit" is withdrawn. It was an invisible gesture
with a destructive meaning — nothing on screen said it, and a stray click
committed a feature still being adjusted. Exactly what L5 forbids. A pending
feature now carries a confirm/cancel puck attached to its own geometry, beside
its handles, with Enter/Escape mirroring it; empty space reverts to the safe
meaning, clear the selection. The puck is an object in the scene, not a dialog:
the camera orbits, the values stay editable, nothing is blocked (L4 intact).

Two cases the rule has to get right or it damages the inner loop: continuous
tools (line, rectangle, circle) still commit each entity on its own gesture — a
tick per line would be miserable — and Enter/Escape end the tool rather than
confirm an entity. And ambiguity resolves toward keeping work: starting another
operation with a valid feature pending commits it rather than discarding it,
because undo reaches everything and the recoverable direction is the right
default.

Section 10 gains the two honest consequences: today a selection offers nothing
(the largest single item of new work this charter asks for) and committing is
still the invisible empty-space click.

snaporca-2is.
2026-07-30 21:20:27 +02:00
Tommaso Bianchi 610acfcd37 docs: reach is the first accessibility, and it gets a law
The charter had accessibility only in the assistive sense — keyboard, contrast,
colour, targets — and said nothing about who can get through the door in the
first place. That was the larger omission. The premise of an OSS CAD tool is
that a kid on a school laptop, with no licence, no account, no fast machine and
nobody to teach them, can open it and make a real thing; a tool that only the
equipped can run reaches people who were already going to design something.

So the fourteen-year-old is now the FIRST of three audiences, ahead of the maker
and the mechanical designer, with an explicit rule that when audiences conflict
the earlier one wins unless someone writes down why not.

L11 states the floor: runs completely on a low-end laptop with integrated
graphics at 1366x768, offline, no account, and no capability withheld behind a
tier, a plugin or a cloud service. Section 6 splits into 6.1 reach and 6.2 the
assistive floor: the reference machine, the small screen as the layout target
rather than the stretch case, files that belong to the user, learnable with no
documentation, plain language at the entry tier, and exploration that is never
punished — undo reaches everything, nothing asks the user to be sure.

Consequences elsewhere: the screen budget is set by the smallest screen we
serve, not the reviewer's monitor; the PR gate gains a reach question; B6 joins
the benchmark (the inner loop on the reference machine, offline, fresh install)
and every task is measured there rather than on a workstation. The side-panel
debt now fails L11 as well as L1 — on that screen the cards leave the model a
strip.

One role addition: the absent audience needs a seat. The kid cannot file an
issue, so someone owns B5/B6 and the group watches real first-timers quarterly.
Approachability is the one thing here that cannot be argued from principle.

snaporca-2is.
2026-07-30 21:17:22 +02:00
Tommaso Bianchi 76690f66e9 docs: the UX charter names only this fork's product
The doctrine is shared but the document is not: each fork's copy now speaks
about its own product only, so it reads as that project's own charter rather
than as a note about a sibling repository. This is a deliberate divergence —
the two copies must NOT be reconciled by a parity sweep. The CAD sources stay
byte-identical; only this doc branches.

snaporca-2is.
2026-07-30 21:11:50 +02:00
Tommaso Bianchi 8cc08845a8 docs: UX guidelines and charter for the Orca-CAD design group
The call with SoftFever settled that Orca-CAD is one of the branches to be
implemented and that a design+dev group forms around it. A group without a
written doctrine reviews by taste, and a CAD reviewed by taste becomes
FreeCAD one locally-reasonable side panel at a time.

So the doctrine is written first, as something a reviewer can FAIL a pull
request against: ten laws each with its own test, the interaction grammar
they compose into, the accessibility floor as a merge requirement, and a
ten-question gate answered in every UI pull request.

The position is Shapr3D's interaction economy, not its feature list —
direct, gestural, almost no chrome, depth revealed by what you touch. Depth
for mechanical designers arrives as progressive disclosure of tools that
never move, in three tiers, non-modal, with assemblies and exploded views
obeying the same point-then-act grammar as a beginner's extrude.

The one thing neither Shapr3D nor FreeCAD has is that we live inside a
slicer: plate, nozzle, material and build volume are known at design time,
so print-domain failures are warnings on the geometry, not a report.

Section 10 is an honest inventory: what already complies, and the six
things that violate the laws today, none of them defended. The appendix
keeps the anti-patterns we have already paid for, because each one is
cheap to reintroduce.

snaporca-2is.
2026-07-30 21:05:33 +02:00
Tommaso Bianchi f14d31d956 scripts/gui-session.sh: bring the headless GUI up without clicking blind
The relaunch sequence was an ad-hoc pile of docker exec one-liners, and it
had a real bug: it dismissed the first-run dialogs by computing the
titlebar close box from `xdotool getwindowgeometry --shell` and clicking
it. When the dialog had already closed, that eval left the geometry
variables stale or empty, the click landed at a garbage coordinate, and it
kept hitting the Sketch button in the toolbar underneath — so the app came
up in sketch mode with a stray Sketch feature that then had to be
cancelled by hand. Three times in one session.

The fix is not a different mechanism. `xdotool windowclose` looks cleaner
and KILLS THE APP: it destroys the GdkWindow out from under the dialog and
the process dies with "GdkWindow unexpectedly destroyed", three
GLib-GObject criticals and a segfault. Measured, not guessed — that is
what the first version of this script did. Escape does not close the Setup
Wizard either, which is why it needs handling at all. So the titlebar
click stays, and what changes is that it refuses to click geometry it has
not validated: the window id is re-resolved immediately before, all four
geometry variables are unset first and must come back numeric, and the
computed point must be inside the screen. Any of those failing logs why
and clicks nothing.

Two further honesty fixes in the status output, both caught by reading it
rather than by it failing: app_pid skipped nothing, so with a container
full of <defunct> instances it printed a dead pid as though the session
were healthy — it now walks /proc/<pid>/stat and ignores zombies. And a
container without x11vnc reported "vnc: DOWN" as if something had broken,
when nothing was ever installed; it now says so, and does not try to start
what is not there.

Also replaces the fixed post-launch sleep with a wait for the main window,
because cold starts under software GL vary by a lot, and adds --status for
diagnosis: "no windows but the desktop is up" means the app died, "cannot
connect at all" means the desktop did. That distinction cost real time to
work out by hand.

Verified on both containers: one run each, wizard closed on validated
geometry, no stray sketch mode, live pid reported, and the app still up.

snaporca-e1p adjacent (tooling, not the tab itself).
2026-07-30 14:35:35 +02:00
Tommaso Bianchi 3f8f46f93f Delete the sketch plane dropdown; the viewport decides
The plane combo is gone. A sketch takes its plane from what is picked in
the viewport: a face on a solid, or one of the reference-plane ghosts,
clicked in 3D. The card is now a single line of instruction instead of a
control.

The combo had become worse than redundant. Once a picked face could be
the plane it displayed a row that CONTRADICTED the actual target — it
still said XY while the sketch went onto the face — so the one place a
user could look to confirm where they were drawing was the one place
guaranteed to be wrong.

What replaces it is state, not UI: m_ref_plane records which reference
plane was last clicked in 3D (0/1/2 = XY/XZ/YZ, >=3 indexes the datums)
and ref_plane_name() turns it into text for the on-geometry hint. Clicking
a ghost plane while a session is live re-planes it immediately, which the
combo's own handler used to do; that behaviour is kept, just driven from
the geometry instead of the widget. A plane click also drops a stale face
pick, so last pick wins in both directions.

populate_plane_choices() stays — seven other pickers use it (Plane base,
Axis A/B, Helix, Project, Mirror, Cut). Those are the next candidates,
tracked on snaporca-e1p; this commit only removes the one that had become
actively misleading.

Also: tessellation now matches Orca's OWN STEP importer, linear deflection
0.003 instead of 0.01 (Format/STEP.hpp default; angular was already 0.5
rad and unchanged). The Design viewport was never using a different
rendering technique — it hosts a real GLCanvas3D, builds a real
Model/ModelVolume and goes through the same reload/GLVolume path and the
same shaders as Prepare and Preview. What differed was the mesh handed to
it: 3.3x coarser than anything else in the application, which is why a
curved face read as faceted beside an imported part. Suite unaffected at
154 cases / 2125 assertions, so nothing depended on the old density.

Verified on :10: the card shows no dropdown, one click on a face then a
sketch tool still reports "on the picked face", and the circle is drawn in
that face's plane (artifacts/shots/h3a2-02-sketch.png, h3a2-03-drawn.png).

snaporca-e1p, snaporca-3a2.
2026-07-30 14:24:29 +02:00
Tommaso Bianchi 3c0843c68c Sketch on the face you clicked, not the one you clicked twice
The previous commit made a picked face the sketch plane and I verified it
by clicking the face TWICE. That was the wrong test. handle_solid_click
cycles whole -> face -> edge, and "First click on a (new) body/face
selects the WHOLE solid; refine on repeat clicks" — so at level 1
m_sel_solid_face is -1, and one click on a face, which is what selecting
a face means to anyone, still fell through to the plane combo. The fix
was real and unreachable, which from the outside is indistinguishable
from no fix at all.

The face id was never missing. handle_solid_click resolves it by ray on
the FIRST click and passes it to on_solid_selection_changed regardless of
the cycle level; the panel simply discarded it whenever level < 2. Keep
it in m_pick_face/m_pick_face_body and let a sketch use it, preferring an
explicit face-level selection when there is one. Nothing about the cycle
changes, so body operations that rely on whole-body selection are
untouched.

The new state is dropped wherever the existing picks are, so a stale face
cannot come back: choosing a body from the Bodies list (an explicit
choice with nothing pointed at), picking a committed sketch loop (last
pick wins), undo/redo (recompute invalidates topology ids), and when a
sketch consumes the face.

Verified on :10 with ONE click, which is the flow that was broken: build
a box, single-click its top face, S then C, and the hint reads "Circle —
click center, then radius · on the picked face" with the circle drawn in
that face's plane (artifacts/shots/g3a2-01-one-click.png,
g3a2-02-sketch.png, g3a2-03-drawn.png).

GUI-only, so the kernel suite is unaffected — plane_of_face and its 154
cases / 2125 assertions are unchanged from the previous commit.

snaporca-3a2.
2026-07-30 14:09:23 +02:00
Tommaso Bianchi 6ce20d78c3 Sketch where the user pointed: a picked face is the sketch plane
Selecting a face and sketching on it is the most common gesture in solid
modelling, and it was impossible. The plane came from a combo holding
XY/XZ/YZ plus datums, and plane_from_choice had no face branch at all, so
the only route onto a face was to build a Coincident datum plane on it
first, confirm that, reopen the sketch and find the datum in the
dropdown. Three extra steps and a junk feature in the tree.

The fix is not another combo row. A new sketch now takes its plane from
what is SELECTED IN THE VIEWPORT: a picked planar face wins outright, and
only when nothing is picked does it fall back to the reference plane —
which is itself normally set by clicking one of the ghost planes in 3D,
not by opening the combo. The tool hint names the target ("Circle — click
center, then radius · on the picked face") so the choice is visible on the
geometry side rather than needing a control to read back.

CadDocument::plane_of_face is the shared derivation, so the sketch path
and the Coincident datum method cannot drift apart. It refuses
non-planar faces: face_normal_world evaluates at the mid parameter, which
on a cylinder or a fillet is a tangent plane at one arbitrary point —
fine for offsetting a datum, wrong as a sketch plane, and silently
sketching on a tangent is worse than declining.

Picking the face also CONSUMES it. Leaving the pick live meant the next
Extrude saw a selected face and push/pulled it instead of extruding the
sketch just drawn — the same trap the imported-art path already guards
against.

Verified on :10 end to end with no combo interaction: build a box, click
its top face twice to cycle whole -> face, press S then C, and the circle
is drawn in the plane of that face with its Radius tab on the geometry
(artifacts/shots/f3a2-03-face.png, f3a2-04-sketch-on-face.png,
f3a2-05-circle-drawn.png). Kernel side: 154 cases / 2125 assertions green
on both forks, including that a cylinder resolves exactly its two flat
caps and refuses the barrel.

Still side-panel-shaped and to be dealt with separately: the Plane combo
remains on the card and now merely displays a stale row when a face is
the real target. It should show the actual target or go away.

snaporca-3a2.
2026-07-30 13:42:11 +02:00
Tommaso Bianchi 7f0a8c85ee An entity sketch that forms no wire fails, instead of extruding a default box
entities_to_wire handles exactly two shapes: one lone closed entity
(Circle/Ellipse), or a chain of open ones (Line/Arc/EllipseArc/BSpline).
Anything else -- a circle coexisting with a line, two circles -- returns a
null wire. build_sketch_wire answered that by falling through to its
legacy tail, which ends in a rectangle built from width/height. For an
entity sketch those fields are whatever they were initialised to, so the
extrude produced a box the user never drew, silently and with ok:true.
That is how the ellipse+stray-arc case in the P2 Tier-B.1 verification
turned into a default-rectangle solid.

Throw there instead. The legacy profile/shape paths below are still
reached by sketches that legitimately carry no entities at all, so the
enum and profile constructors are untouched -- only the case where
entities exist and cannot be turned into a wire now fails, which is
exactly the case that was fabricating geometry.

This does NOT implement the multi-loop support the issue asks for. Doing
that properly means deciding containment -- a circle inside a rectangle is
a hole, a circle beside it is a second region -- and make_extrude_regions
cannot be reused because it takes flattened Vec2d contours for imported
Text/SVG art and would discard the analytic circle. Guessing containment
would trade a visible failure for a wrong solid, which is the opposite of
the point. Left scoped on snaporca-88v.

Also converts the three float comparisons in the two test cases added this
session from Approx to WithinAbs/WithinRel, per tests/CLAUDE.md, which
rules Approx out for being asymmetric and double-only. The rest of the
file's pre-existing Approx uses are left alone.

153 cases / 2090 assertions green on both forks; no existing test depended
on the default-rectangle fallback.

snaporca-88v (partial: the silent-fallback half).
2026-07-30 09:56:18 +02:00
ExPikaPaka b753cf2216 Displace the painted patch border and add post-process smoothing 2026-07-29 08:44:16 +02:00
ExPikaPaka 7b98433dd7 Rewrite adaptive subdivision to refine worst-first against a triangle budget 2026-07-28 11:42:40 +02:00
Tommaso Bianchi 5f1811c2c5 A subtraction that removes nothing is an error, not a silent success
A boolean cut whose tool misses the target is a perfectly legal
operation: OCCT reports IsDone(), the shape comes back unchanged, and the
feature lands in the recipe reporting ok:true. Driving the MCP socket,
that produced two consecutive {ok: true, bodies: 1, error: ''} responses
for a hole that was never drilled -- same viewport, same 46939.11 mm3 --
and the tree grew two Hole features that will never cut anything. A
caller, an agent especially, has no signal at all that the thing it asked
for did not happen.

Measure the volume across the op in route_feature's in-place branch and
refuse the no-op. Only for removals: Hole, Thread, and Extrude / Revolve
/ Sweep / Loft in Cut mode. Everything else may legitimately leave the
volume alone -- a Transform certainly does. The tolerance is relative,
because an absolute epsilon is wrong across the mm-to-metre range of real
parts, and a cut that shaves a numerically invisible sliver is a miss
too. The existing rollback in the MCP actions already preserves the
reason, so a missed hole now answers ok:false with the error and undoes
the feature.

The confusion underneath was not itself a bug: hole's x/y are in the
sketch plane's frame, whose origin is describe_scene's modeling_origin,
and describe_tools documented them only as "number, unit mm". Passing the
world centre put the hole 135 mm clear of the solid. All six x/y params
on hole / hole_styled / hole_standard now say which frame they are in,
since the wrong guess was silent.

Regression test drives the reported failure directly: a hole at x=135 on
a 20x20x20 box is rejected and leaves the body untouched, the same hole
at the origin still removes exactly pi*4^2*20, and a cut-mode extrude
whose profile sits at x=200 is rejected too. 152 cases / 2083 assertions
green on both forks.

snaporca-daf.
2026-07-27 07:21:06 +02:00
Tommaso Bianchi 5d05ca30ca Sketch shortcuts: pick the key map by mode, not by whether a session exists
All 17 single-letter sketch shortcuts were dead. The dispatch gate was
circular: the sketch key map was consulted only when
m_viewport->is_sketching() was already true, but is_sketching() is a
whole-session flag whose only riser is begin_sketch(), called from
select_tool() -- which is precisely what every sketch key closure calls.
So the first letter after entering sketch mode fell through to the
feature map, where the keys are Shift+letter, matched nothing, and did
nothing. The mouse worked only because the toolbar flyout row reaches
select_tool() directly, bypassing the gate.

Which key MAP applies is a question about the mode. Split the flag:
'sketching' (live session) still drives undo/redo, Delete and the
section-view branch, where a session genuinely has to exist; a new
'sketch_mode' (m_ui_mode == UiMode::Sketch alone) drives the map choice.

Verified headless end to end, keyboard only: Shift+S, then R draws a
143.4 x 133.7 rectangle on XY reporting 4 degrees of freedom, then F and
L arm Fillet and Line (artifacts/shots/0udb-01..03). Note the toolbar
strip does NOT change when a tool is armed by keyboard -- the family
buttons are flyouts and only show their own pressed state -- so the
pixel diff on that strip, which is how this was originally measured,
reads 0 for a tool that is live. The status line is the surface that
actually reflects the armed tool.

Also fixed, from snaporca-d9i's list: the plane-pick status line said
"press Sketch to draw on it", naming a button that exists only in
Feature mode. It is now mode-aware.

Adds a SNAPORCA_KEYTRACE=1 trace in the CHAR_HOOK printing key, ui mode,
is_sketching, in_text and the focused window's class. It is what
separated "the fix does not work" from "the surface being measured never
moves", and it costs a full GUI build to re-add, so it stays.

snaporca-0ud, partial snaporca-d9i.
2026-07-27 00:40:25 +02:00
Tommaso Bianchi 347b83d887 Sketch fillet: never write a failed solve's geometry back, and commit the op
Two P0s in the same gesture. Filleting a corner of a parametric rectangle
produced either nothing at all or a sharp corner with a stray arc floating
above it.

Trigger (snaporca-cq2): the only routes that ever reached confirm_op were
finishing the whole sketch and an unsignposted click on empty space.
set_tool() dropped a ready op, so typing a radius or dragging the arrow and
then touching any other tool threw the value away. Commit a ready op on tool
change (before m_mode is reassigned — op_ready() and confirm_op() both switch
on it), commit on Enter in the radius editor, and drop the pending op before
Esc's tool downgrade so Esc still cancels rather than applies.

Substitution (snaporca-pl5): libslvs writes its last Newton iterate into the
params whether or not it converged, and SketchSolver read them back
unconditionally, so every REJECTED solve deformed the sketch. The fillet
ladder tries a deliberately over-constrained rung first (a tangent on each
leg, against the legs' own H/V); it is correctly rejected, but its wreckage
then failed rungs 2 and 3, which solve cleanly on their own. The arc ended up
with no constraints at all, the rigid loop won, and the corner snapped shut.
Measured: from pristine geometry rung 1 gives result=INCONSISTENT with 3 bad
constraints, rung 2 gives dof=6 with the arc's radius intact.

Read the geometry back only on success. try_add_constraints then needs no
"restore" re-solve — the entities still hold the prior solved state.

Kernel suite 151 cases / 2072 assertions green on both forks; the GUI check
ran on the Snapmaker fork (9d72c4377a).

Ported from the Snapmaker fork. snaporca-pl5 snaporca-cq2
2026-07-26 23:33:50 +02:00
Tommaso Bianchi 50577d66d0 Dress-up card: say whether Confirm will round the picked edge or the group
The card decides between a single picked edge and a whole face-group from a
viewport pick it never mentioned. With no edge picked the user saw only the
group combo and concluded per-edge rounding did not exist; with an edge picked
the combo still read "All" — the opposite of what Confirm would do.

Adds a Target row that names the actual target and greys the group combo out
while an edge is picked, wired at the same three points Shell already uses:
the selection-changed handler, the re-edit load, and open_tool.

Ported from the Snapmaker fork (33771a0e95). snaporca-40d
2026-07-26 23:33:50 +02:00
Tommaso Bianchi d5dee45acf Port the four DesignPanel fixes that never crossed from the Snapmaker fork
The CAD sources are meant to be byte-identical across the two forks, with exactly
two permitted divergences: DesignPanel.cpp's flyout plumbing (30 lines — mainline's
DropDown is Item-based where the other fork takes three parallel vectors) and
DesignCanvas.cpp's Bed3D::set_shape signature (16 lines). DesignPanel.cpp had drifted
to 105.

Nothing failed to announce this. The kernel suite does not compile the GUI, and all
four defects are interaction-level, so both forks stayed green while only one of them
had the fixes. The parity diff is what found it.

  * combo_append_index and its five call sites. Orca's ComboBox keeps client data in
    its own vector, so Append()'s clientData argument never reaches wxItemContainer
    and m_clientDataItemsType stays wxClientData_None. GetClientData() opens with a
    wxCHECK_MSG, which is an early return — so every read came back NULL and every
    caller resolved it to index 0, silently, because 0 is a legal answer. Affects the
    rib sketch picker, the sheet-body picker, both mate coordinate-system pickers and
    the sweep path picker.

  * Wrap(240) over the card labels. wxStaticText never wraps itself, so a one-sentence
    hint sets its card's minimum width to the width of the whole sentence and every
    control in that card is clipped at the sidebar's right edge.

  * Shell and Draft face-label initialisation in open_tool. Both read the face from the
    live pick at Confirm time, but only the pick handler wrote their labels, so picking
    a face and then opening the card left the card describing one operation while
    Confirm performed another.

  * SurfaceOffset and ThickenSurface added to build_candidate's negative list. Both read
    a sheet body from their own combo and both had it overwritten by the picked solid's
    index. The list is a negative one, so a tool that picks its own body breaks by
    omission — noted in a comment now.

Also drops a stray <cstdio> left behind by a debug probe.

Verified by building the GUI here for the first time since these landed: 461/461,
liblibslic3r_gui.a links. DesignPanel.cpp is back to exactly 30 divergent lines and
every other CAD file is byte-identical again.

snaporca-7xx snaporca-aqu snaporca-gu9 snaporca-c03 snaporca-5pl
2026-07-26 18:05:13 +02:00
Tommaso BianchiandClaude Opus 5 faed169a01 Sketch dimensions: make Tab commit, instead of silently dropping what you typed
The inline editor special-cased Escape and Skip()ped every other key, so Tab fell
through to wx's default navigation. Its popup frame holds exactly one control, so
focus came straight back to that control with its text re-selected.

Type 60, Tab, 40, Enter — expecting to fill two dimensions — and the 60 is gone: Tab
neither committed it nor advanced, so the 40 just replaced the re-selected text. A
re-selected field is pixel-identical to a freshly opened one, so nothing on screen says
a number was dropped. Tab-to-next-dimension is what Onshape, SolidWorks and Fusion do,
which is exactly why it is the key a user reaches for.

Tab now calls do_commit(), the same path Enter takes; the caller's on_commit is already
what walks to the next dimension. Verified by driving the GUI: 37 Tab 24 Enter now
produces a 37.0 x 24.0 rectangle, where before it produced 24 and a mouse-derived value.

snaporca-xah

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 17:48:11 +02:00
Tommaso Bianchi 695932ad87 Drop the degenerate triangle OCCT emits at every filleted corner
A filleted solid arrived on the plate as a broken model: the slicer reported
"8 non-manifold edges" on an 80x50x12 box with r=3 on all edges, and advised
repairing it in another CAD application -- the exact round trip the Design tab
exists to remove. The same box without the fillet committed cleanly.

Measured rather than guessed. Each of the 8 bad edges is degenerate, both
endpoints the same vertex:

    open tri=145  edge=1 face=5 v59(3.000000 3.000000 0.000000) v59(3.000000 3.000000 0.000000)
    open tri=538  edge=1 face=6 v87(3.000000 3.000000 12.000000) v87(...)
    ... one per corner, 8 corners

OCCT triangulates a degenerate surface parameterization with a triangle at the
pole; a corner sphere patch has exactly one. Its two pole nodes are distinct in
the per-face triangulation and collapse to a single vertex when the faces are
welded, leaving a zero-area triangle whose v->v edge can never pair with a
neighbour. its_face_neighbors counts it as open, and the field the object panel
prints as "non-manifold edges" is in fact stats.open_edges.

So the geometry was never wrong -- the B-rep volume matches the Steiner formula
for a box dilated by a ball to 0.016%. Only the bookkeeping was.

Dropping those triangles after the weld removes 8 of 3492 and takes open_edges
to 0. Zero area, so nothing about the shape changes. tri_face is compacted in
the same pass, since it must stay index-aligned with the triangle list that the
face picking and per-body colouring both index into.

Guarded by a new [CadDocument] case that asserts open_edges == 0, no degenerate
triangle survives, and both per-triangle maps still match the triangle count.
The existing suite only ever checked B-rep volumes and areas, which is why a
mesh defect this visible went unnoticed: 150 cases / 2049 assertions green on
both forks.

snaporca-agw
2026-07-26 17:27:40 +02:00
Tommaso BianchiandClaude Opus 5 2c5ddd4102 OCCT link order: put TKFillet/TKOffset before their dependencies, not after
OCCT_LIBS is an explicit single-pass static link order — dependents first, TKernel
deliberately last. The CAD block appended its two extra toolkits to the END of that list,
which puts them after everything they depend on:

    list(APPEND OCCT_LIBS TKFillet TKOffset)

TKOffset references BRepAlgo_Loop, and nm against the built deps prefix shows TKBool is the
only toolkit that defines it (TKTopAlgo, TKBO, TKPrim, TKFillet and TKOffset all define it
zero times). TKBool sits first in the list, so a single-pass linker has passed it long before
it reaches the appended TKOffset and will not go back:

    libTKOffset.a(BRepOffset_MakeLoops.cxx.o): undefined reference to
    BRepAlgo_Loop::BRepAlgo_Loop()

Only one configuration ever objected — the Snapmaker fork Flatpak (aarch64). Ordinary Linux,
macOS and Windows links resolve it regardless, and the mainline fork Flatpaks pass, so six
green platform legs said nothing about whether this list was correct.

Prepended via set() rather than list(PREPEND), which needs CMake 3.15 while this project
supports 3.13.

Worth knowing for later: TKFillet and TKOffset are mutually dependent, 20 symbols needed in
each direction, so a stricter single-pass link could still trip on that pair. It does not on
any current platform, so no --start-group or duplicate entry is added here; if something ever
complains about ChFi or BRepFill symbols, that cycle is the reason.

snaporca-2kj

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 15:28:46 +02:00
Tommaso BianchiandClaude Opus 5 295c030309 DesignPanel.hpp: declare the three wx types it uses but never named
The header forward-declares a long list of wx types and omitted wxBoxSizer, wxTextCtrl and
wxListCtrl. All three are used as pointer members only (m_expr_text, m_var_list,
m_parts_hdr, m_hdr_tree_row), so a forward declaration is all they need — but there was
none. Every ordinary build compiled anyway because the wx/panel.h + wx/scrolwin.h chain
happens to pull the real headers in transitively.

The Snapmaker fork Flatpak build (aarch64) has a wx that does not, and it failed outright:

    DesignPanel.hpp:511: error: 'wxTextCtrl' does not name a type; did you mean 'wxTreeCtrl'?
    DesignPanel.hpp:521: error: 'wxListCtrl' does not name a type; did you mean 'wxFileCtrl'?
    DesignPanel.hpp:663: error: 'wxBoxSizer' does not name a type; did you mean 'wxSizer'?

plus a cascade of "m_var_list / m_expr_text / m_parts_hdr was not declared in this scope".
Not an environment quirk: the header was simply not self-contained, which is exactly what
breaks a reviewer building in an unfamiliar configuration. The mainline fork Flatpaks passed
on both arches, so only that one manifest exposed it.

Audited the rest of the header afterwards: every other wx pointer type is either
forward-declared or genuinely included — only wxScrolledWindow and wxWindow are undeclared,
and both come from the real wx/scrolwin.h include.

snaporca-4dn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 12:27:02 +02:00
Tommaso BianchiandClaude Opus 5 c1b0484495 3mf test: give the BBS save a writable temp dir, instead of the filesystem root
store_bbs_3mf reaches Model::get_backup_path(), which builds
temporary_dir() + "/orcaslicer_model/" + timestamp. temporary_dir() returns a file-static
that ONLY OrcaSlicer.cpp's startup sets, so in a test binary it is the empty string and the
backup path becomes "/orcaslicer_model/..." — absolute, at the filesystem root. An
unprivileged process cannot create that, so the save returned false and the scenario died
on REQUIRE(store_bbs_3mf(sp)).

This was the SINGLE failure in this fork's Unit Tests — 1 of 566, on Linux x86_64, Linux
aarch64 and macOS arm64 — from CI run 30191490709:

    Failed to create backup path "/orcaslicer_model/Sun_Jul_26/08_49_41#5398#1":
    boost::filesystem::create_directories: Permission denied [system:13]

It hid because that job had never run to completion on this branch before: every earlier
run was cancelled by the concurrency group first. It also passed on Windows x64, where the
drive-root path is writable, and it passes in the local build container, which runs as root.
Verified against the same defect in the Snapmaker fork by running the built binary as
uid 1000: permission denied before, 4 assertions passing after.

snaporca-vg8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM
2026-07-26 12:22:38 +02:00
Tommaso BianchiandClaude Opus 5 5026dd11a6 Fix the solver abort on circle-line tangency; the CAD suite now runs complete
snaporca-tkz, the last quarantined test. Root cause read out of the vendored
source rather than guessed: slvs/constrainteq.cpp, Type::ARC_LINE_TANGENT does

    ExprVector ap = SK.GetEntity(arc->point[other ? 2 : 1])->PointGetExprs();

so it dereferences the ARC'S ENDPOINTS. A full circle entity carries only
point[0], its centre. point[1] and point[2] are zero handles, FindById throws
"Cannot find handle", and the process ABORTS rather than failing the solve —
taking every later test in the binary with it. That is also the wrong equation
for a circle regardless: it only makes the line perpendicular to the radius at
an endpoint that does not exist.

CT::Tangent no longer hands a full circle to that constraint. For a circle it
emits PT_LINE_DISTANCE(centre, line) = radius, which is precisely what tangency
to a circle means. Arcs keep the ARC_LINE_TANGENT path they are built for.

One limitation, stated rather than buried: the slvs C API takes a constant
distance and offers no way to reference the circle's radius parameter, so the
radius is captured when the constraint is emitted. That is exact whenever the
radius is fixed or is simply not driven by another constraint in the same
solve, and re-solving restores tangency if something else moves it. Tying them
would need an auxiliary point constrained onto both the circle and the line.

With this and eeca6794e7, both quarantined tests are gone and the exclusion in
kernel-test.sh goes with them. A green run now means the whole CAD suite
passed, not "everything except the two we gave up on":

    149 cases / 2043 assertions, no filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:33:50 +02:00
Tommaso BianchiandClaude Opus 5 f599ff0ff7 kernel-test.sh: one quarantined case now, not two
Follow-through from eeca6794e7. The header claimed two pre-existing failures
are excluded and named both; the internal-thread case now runs like any other,
so only the solver SIGABRT is left. A comment that lists a test which is no
longer excluded sends the next reader looking for something that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:33:21 +02:00
Tommaso BianchiandClaude Opus 5 df6ef85614 Un-quarantine the internal-thread test: the geometry was right, the test was not
snaporca-kzy was filed as "internal thread cuts too little material". It does
not. Measured on the test's own fixture, a 40x40x20 box:

  plain Ø12 bore   removes 2261 mm3
  internal thread  removes 2157 = 1571 (minor bore) + 586 (groove)

apply_thread bores at the MINOR radius (radius - depth = 5) and then carves the
groove out to radius + depth = 7. A tapped hole therefore keeps the crests
between turns and holds MORE material than a plain clearance hole at the
nominal radius — which is what every real tapped hole does. The test asserted
the opposite, so it was asking for something physically wrong and had been
quarantined for it since it was written.

One hypothesis discarded on the way: that the shortfall was a tessellation
artefact, since chords on a helical surface undercut a concave bore. Exact
BRepGProp::VolumeProperties agreed with the tessellated volume to within
2.5 mm3, so that was not it and is not offered as a hedge.

The reference is now the tap-drill bore the thread actually starts from (Ø10),
against which the groove's 586 mm3 is the meaningful quantity — that is what
"the thread cuts" means. Test re-tagged [CadDocument][thread], so CI covers the
thread path again instead of skipping it.

Also documented the (void)internal in make_thread_profile. It reads like a bug
and is not: the V is the same shape either way and the caller decides, fusing
it onto a shaft or cutting it out of a wall. Someone "fixing" it to point
inward for the internal case would make the groove sweep already-empty bore
space and cut nothing — the exact failure the old comment described.

Suite 148 cases / 2035 assertions, with this test now among them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:32:47 +02:00
Tommaso BianchiandClaude Opus 5 a95e8ee701 Re-edit: list the bodies as of the feature's timeline slot, not the final ones
Found by sweeping the index-space defect class deliberately rather than by
hitting it: that class produced 4 of the 8 defects found by hand yesterday, so
it was worth auditing every combo in the panel that maps a row selection onto
a document index.

Most of it came back clean — the loft sidecar vectors are consistent at all
four read sites, the sheet-body pickers go through the helper everywhere, mate
connectors carry client data. Six did not. A stored target_body indexes the
body list AS IT WAS just before that feature ran during replay, but Transform,
Mirror, Thicken, Rib, Project and DeleteFace all populated their combo from
the live m_doc.bodies. Boolean and Cut already replayed to the right slot.

The failure is concrete: model a body, Thicken it, then Cut something later in
the tree. A Cut replaces one body with two, so every index at or after it
shifts. Reopen the Thicken and the combo lists the post-cut bodies while
selecting the pre-cut index — showing, and on confirm re-targeting, a
different body than the feature actually used. A Boolean that consumes its
tool body shifts them the other way for the same result.

fill_body_choice() does the truncated replay populate_body_choices() already
did, for the single-combo tools. Six call sites, and 60 lines of duplicated
population loops go with them.

Visible change when testing: re-editing an early feature now lists FEWER
bodies, because it lists only those that existed then. That is correct — you
cannot target a body that did not exist yet — and it is what Boolean and Cut
have always done.

The new kernel test pins the invariant the GUI now leans on: a Cut turns one
body into two, and replaying to just before it yields the earlier, shorter
list. If body ordering after a split ever changes, that assumption fails
loudly here instead of silently in a dialog.

NOT click-tested — GUI wiring, compile-verified only. Filed as snaporca-oz7
and added to snaporca-cfi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:32:47 +02:00
Tommaso BianchiandClaude Opus 5 8f06b9dfd8 Design tab: make Cut re-editable, and stop misdescribing Import
on_edit_feature had two types falling into default: with "This feature type
can't be edited yet". Boolean was already handled, so the follow-up note was
stale on that point; the real gap was Cut and Import.

Cut now re-edits like any other feature: plane, offset and target body are
restored and the generic replace_feature path commits the change. The body
list is rebuilt with populate_body_choices(m_edit_index) for the same reason
Boolean does it — a Cut splits one body into two, so the live body list no
longer matches the one this feature's target index was recorded against.
Replaying to just before the feature makes the stored index land on the right
entry.

Import deliberately gets no dialog. An imported solid has no parameters to
re-edit: its geometry is rigid data read from a file, not something rebuilt
from numbers, and moving it is what the Transform feature already does.
Building an "edit" for it would duplicate Transform behind a second name. So
it now says that instead — the previous message implied a dialog was coming
that should not.

Imported 2D Text/SVG art is a different thing and stays re-editable; it
arrives as a Sketch feature carrying imported_regions and is handled above.

The default: arm is kept as a guard so a feature type added later announces
itself rather than silently swallowing the click.

NOT click-tested — GUI wiring, compile-verified only. Added to snaporca-cfi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:32:47 +02:00
Tommaso BianchiandClaude Opus 5 9c199e4a38 Fix the Shellcheck CI job, red since the CAD kernel-test loop landed
The Shellcheck workflow has failed on every push and every scheduled run since
2026-07-24, on exactly one finding: SC2029 in scripts/kernel-test.sh, the file
the CAD branch added. So the CAD work is what turned that job red, and a PR
arriving with a red job is a bad way to open a conversation with a maintainer.

Client-side expansion of $REMOTE is the intended behaviour — it is derived from
$VOL locally and the remote has no such variable, exactly as the rsync
destination two lines down relies on. So this is a disable with a reason, not a
silencing: the note says why the warning does not apply.

Verified by running the workflow's own command over all 24 matched scripts:
exit 0, no findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:46:35 +02:00
Tommaso BianchiandClaude Opus 5 e2b921745d Add user documentation for the Design tab
docs/design_tab_upstream_portability.md explains the subsystem to a
maintainer; nothing explained it to a user. This is that: what the tab is,
how to get a first solid out of it, every tool grouped the way the toolbar
groups them, and the keyboard shortcuts read out of the source rather than
remembered.

The limitations section is deliberate. Rib needing a sketch with an explicit
open line, Surface Loft and Surface Fill having no hands-on verification, mates
composing transforms instead of solving simultaneously, move-face and
replace-face being absent, and the two quarantined kernel tests are all things
a user would otherwise discover by hitting them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:44:27 +02:00
Tommaso BianchiandClaude Opus 5 f347afd22e Document the CAD subsystem's real dependency weight
Maintainers will ask what the Design tab costs before they will look at the
diff, so measure it rather than assert it.

The headline correction: the OCCT delta is THREE toolkits, not two. The
comment in deps/OCCT/OCCT.cmake claimed "TKFillet + TKOffset (3.77 MiB,
Windows only)". Walking OCCT's own adm/MODULES and each toolkit's EXTERNLIB
shows ModelingAlgorithms holds twelve toolkits, that eight of them are built
either way because DataExchange (the STEP path upstream already ships) depends
on them, and that the true delta is TKFeat, TKFillet, TKOffset and TKXMesh —
of which TKXMesh is never produced. So three archives are built: 7.40, 5.38
and 4.42 MiB.

TKFeat is the interesting one. Nothing in the Design tab references it and it
is absent from the TKFillet/TKOffset dependency closure, so it is built for
nothing — OCCT's module flag is all-or-nothing per module. On static-link
platforms that is build time and zero shipped bytes.

Two numbers are deliberately absent, marked as absent, and not approximated:
the Windows DLL delta needs a Windows build (snaporca-gix), and a clean-build
time delta needs the deps prefix built twice on one machine. The old 3.77 MiB
figure is withdrawn rather than reused — it covered two of the three toolkits.

Also recorded: the vendored solver is 9,339 lines under GPLv3 with its LICENSE
preserved, which combines into this AGPLv3 fork without difficulty (AGPLv3
§13), and it is live code driving every sketch constraint — not a carried
corpse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:41:50 +02:00
Tommaso BianchiandClaude Opus 5 e2b58a17f7 Design i18n: extract the tab's strings at all, and translate them into Italian
DesignPanel.cpp was never listed in localization/i18n/list.txt, and xgettext
extracts only what that file names. All 934 of the Design tab's _L() calls
were therefore invisible to every translator in every language — not merely
untranslated, unextractable. Adding the one line is the actual fix; the rest
follows from it.

Regenerating the .pot brings the catalogue from 6007 to 6536 msgids. 549 of
the new ones are now translated into Italian: 5230 to 5779 translated
messages. Verified no existing work was destroyed — every msgid that survived
into the new .pot kept its translation, and the 36 that lost one are genuinely
gone from the sources. msgfmt --check-format passes, which matters here
because a large share of these carry %d / %s / %zu.

CAD terms follow Italian CAD convention rather than literal glosses: Fillet ->
Raccordo, Chamfer -> Smusso, Draft -> Sformo, Rib -> Nervatura, Mate ->
Accoppiamento, Shell -> Svuotamento, Pattern -> Serie, Sheet body -> Corpo
superficie. Strings identical in both languages are deliberately left
untranslated so gettext falls back to the msgid.

The long mixed-filament / Local-Z dithering tooltips are left untranslated on
purpose: slicer internals, outside a Design i18n task, and untranslated before
this commit too.

One string changed rather than translated. The Coord Sys hint read "Without an
edge the frame's rotation about its normal follows world X, not the body" —
that described the defect fixed in 1726e93760, so it was a lie as of that
commit. It now says X comes from the face's first edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:33:53 +02:00
Tommaso BianchiandClaude Opus 5 1726e93760 Mate connectors: derive a face-only frame's X from the face, not from world
datum_frame took a FaceAndDirection frame's Z from the face normal, which
follows the body, but its X from coordsys_x_hint, a world constant, whenever
no explicit edge reference was set. Spinning a body about its own face normal
therefore left the frame bit-identical: the connector could not encode that
rotation at all, so Fastened and Slider mates claimed to fix an orientation
the frame could not see.

X now comes from the face's own first usable edge, which rotates with the
body. The hint survives only as a last resort, for faces that offer no
in-plane direction — a full circular edge has coincident endpoints, and a
seam projects to nothing in-plane.

The new test spins a box 90 degrees about its top-face normal and asserts the
frame's X turned with it. Reverting just the X_tent derivation and rerunning
makes it fail with "1.0 is within 0.000001 of 0.0" — cos(angle) between the
before and after X is exactly 1, i.e. the frame did not move — and that is the
only failure in 2019 assertions, so the test discriminates this defect and
nothing else.

Note for anyone replaying an older document: a face-only connector's frame
can now differ from what that recipe produced before, so a mate built on one
may place its body differently. Nothing in the suite or the golden v3 fixture
changed, but the semantics did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:18:42 +02:00
Tommaso BianchiandClaude Opus 5 f13f2876f6 Tag the two known-broken tests [NotWorking] so CI stops being red on every commit
This fork's Unit Tests job failed on every single commit, because CI runs the
whole ctest suite including the two cases tagged [known-broken] that
scripts/kernel-test.sh has always excluded locally. A job that is red
unconditionally is worse than no job: it trains everyone to ignore it, so the
next genuine regression arrives invisible.

No workflow change was needed. scripts/run_unit_tests.sh already passes
-LE NotWorking, and tests/CMakeLists.txt registers Catch2 tags as ctest labels
via catch_discover_tests(ADD_TAGS_AS_LABELS) — so the exclusion upstream
already ships works as soon as the cases carry the tag. Verified against the
built test tree: 337 tests unfiltered, 335 with -LE NotWorking, i.e. exactly
these two dropped and nothing else.

The second cause recorded in the issue, the test-reporter step failing with
"Resource not accessible by integration: 403" on a fork, is already fixed
upstream: the Publish Test Results step now carries continue-on-error: true.

The comment these cases carried claimed CI kept the bugs visible by reporting
them forever. That is now false and was never a good mechanism anyway, so
visibility moves to the tracker: snaporca-tkz for the solver SIGABRT, and
snaporca-kzy, filed now, for the thread groove volume. Neither is fixed;
neither is forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:13:08 +02:00
Tommaso BianchiandClaude Opus 5 b25335e1b3 Rib: accept a Project feature as its sketch ref
Rib guarded with `sk.type != CadFeatureType::Sketch`, while every other
sketch consumer — Extrude, SurfaceExtrude, SurfaceRevolve, the loft paths —
tests `!= Sketch && != Project`. A Project feature carries a plane and Line
entities, which is all a rib reads, so the guard blocked "project a body
edge, then rib along it" for no stated reason.

The picker in the Design tab offered Sketch features only, so it is widened
to match: a kernel that accepts Project refs and a GUI that never lists them
would have left the path unreachable anyway.

Worth recording for whoever hits this next: Rib also needs a sketch carrying
EXPLICIT entities. A parametric Rectangle sketch (add_sketch with
width/height) has an empty entities vector — build_sketch_wire synthesises
its profile on demand — so rib_entity 0 is out of range there and it fails
with "rib: bad entity". That is why Rib could not be driven headlessly at
all before this change; a Project feature is now the one programmatic way to
produce a ribbable line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:48:16 +02:00
Tommaso BianchiandClaude Opus 5 1cb80f7f9f Project: implement "(all edges)"; stop discarding the failure reason
Two defects found while driving the tools that Phase B wired but nobody had
exercised yet.

apply_project had no all-edges branch: with no face picked and no explicit
edge list it threw "no edges or face selected". That is precisely the state
the Project card opens in, and its label reads "(all edges)" — so the card's
default could never be confirmed. It now projects every edge of the source
body. Edges perpendicular to the target plane collapse to a point when
projected, so segments whose endpoints coincide are dropped instead of being
emitted as zero-length lines that would poison the sketch downstream.

The second defect is why the first one was invisible. 29 of the 31 rollback
sites in McpControl ran `if (!ok) doc.undo();`, and undo() recomputes the
restored feature list — which succeeds and clears doc.error. Every failing
command therefore reported `error: ""`. Yesterday's fix covered 2 sites and I
treated the file as done; it was not. All 31 now capture the reason before
the rollback and restore it after. Failures that read as `""` now read as
"rib: bad entity" / "surface-revolve: revolve failed".

Verified on the running GUI through the control socket: the Project call that
previously returned ok:false now returns ok:true, and failures carry a reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:04:15 +02:00
Tommaso BianchiandClaude Opus 5 08e37296b9 Design tab: give every drawer entry a distinct icon
Six of the surface entries, both Thicken variants, Rib, Axis, Coord Sys,
Mate and Delete Face all reused a sibling's glyph, so a drawer opened as a
column of identical faces and the card that opened rarely matched the entry
clicked. Fixed both halves: entry icons are now unique within their drawer,
and each card header uses the icon of the entry that opens it.

Two new glyphs, design_thicken and design_rib, are the only ones added —
everywhere else an existing icon already carried the right meaning
(design_revolve, design_offset, design_line, design_point,
design_c_coincident, design_delete).

The Surface drawer BUTTON deliberately keeps design_surface; only its
entries may reuse the solid glyphs, because a menu row carries its own
text label while two adjacent toolbar buttons do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 06:44:16 +02:00
Tommaso BianchiandClaude Opus 5 77f2f4d4ad Design tab: datum and curve tools were rejected by the solid-preview check
refresh_preview() exempted only Sketch and Plane from the ghost-preview path.
Axis, CoordSys, Helix and Project produce no solid either, so they fell through
to it, found nothing, and reported "invalid: preview produced no geometry" —
which also DISABLED Confirm, so all four tools were unusable rather than merely
noisy. Guaranteed on an empty document; with a body present the ghost path
finds something and masks it, which is why it survived until the tools were
tried on a fresh project.

All six non-solid tools are now exempt, each with its own ready message. The
list is not a guess: recompute() skips Sketch, Helix, Plane, Axis and CoordSys
outright and routes Project through apply_project(), which emits sketch entities
and no solid — so the panel and the kernel now agree on exactly what is not a
solid. Mate already had its own branch, since it needs Confirm gated on having
two distinct CoordSys features.

Introduced when Axis/CoordSys (batch 1) and Helix/Project (batch 3) were wired
without extending this exemption. Confirmed fixed on hardware: Axis ->
Plane Intersection with XY and XZ now resolves on an empty document, which also
exercises 60b04feea1.

Compiles clean; kernel untouched, suite unaffected at 143 cases / 1980
assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 06:20:40 +02:00
Tommaso BianchiandClaude Opus 5 4d331099de CAD: Axis PlaneIntersection read its plane refs in the wrong index space
axis_plane_a/b are filled by the GUI from populate_plane_choices(), whose rows
are XY / XZ / YZ followed by the datum planes, and the row is stored verbatim.
The kernel's base_plane() indexed datum_planes[ref] directly, so the two spaces
were off by three: picking XY resolved to datum plane 0, and picking the first
datum ran past the end and failed with "plane ref not found". The
PlaneIntersection axis type could not work from the GUI at all.

base_plane() now uses the encoding CadFeature::plane_base already uses — 0/1/2
are the base planes through the modeling origin, >=3 indexes datum_planes[ref-3]
— so there is one convention for plane references instead of two. That also
makes two base planes usable, which the previous code rejected as out of scope
even though XY x XZ is an ordinary way to define the X axis.

Removed the dead find_plane lambda directly above it. It was never called and
half-anticipated this exact offset ("if (ref >= 3) // base plane offset"),
which is presumably where the confusion started.

Tests: the existing parallel-planes case encoded the OLD convention, passing
axis_plane_a = 0 to mean "datum 0" — values the GUI cannot produce — so it is
re-based onto rows 3 and 4. Two new cases cover what the GUI actually emits:
base x base (XY x XZ -> X) and base x datum, the latter pinning the +3 offset.
Both verified to FAIL against the previous indexing, at test_caddocument.cpp
:2325 and :2346.

Found by auditing the remaining tools for the index-space defect class that had
already produced three bugs in the GUI; this is the first instance of it
crossing the GUI/kernel boundary.

Suite 143 cases / 1980 assertions (was 141/1972). GUI compiles clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 05:17:53 +02:00
Tommaso BianchiandClaude Opus 5 0455b0bf96 Design tab: give the Surface drawer its own icon
The Surface drawer used design_extrude, the same face as the Add-material
drawer, so the two buttons were indistinguishable in the feature bar.
design_surface.svg is a draped patch — deliberately unlike design_plane (a flat
parallelogram) and design_extrude (a box with an up-arrow) — with a faint
interior rule so it reads as a skin rather than a solid face. Same visual
language as the other 71: 24x24, no fill, #b6b6b6, stroke-width 0.85, round
caps and joins.

The five surface ENTRIES that also used design_extrude now use it too. That is
not cosmetic tidying: a flyout button's face follows the last-picked entry
(SetBitmap_(icon_names[i])), so changing only the drawer's default icon would
have been undone the moment the user picked anything. Surface Loft keeps
design_loft, which already suits it.

The six rows still share one glyph between them, so they are told apart by
label alone inside the flyout. Per-entry icons belong with snaporca-vrg
(Draft/Shell reusing design_dressup), not here.

Compiles clean; kernel untouched. Icon confirmed legible on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:58:10 +02:00
Tommaso BianchiandClaude Opus 5 c565d6ba86 CAD: undo() never rolled back variables, so a bad one bricked the recipe
checkpoint() snapshotted `features` and undo() restored `features`, but
`variables` is a separate member of CadDocument. Every caller of the documented
checkpoint -> mutate -> recompute -> undo-on-failure pattern therefore failed to
roll a variable back: the bad value stayed in the document and every later
recompute failed, which is exactly the corruption the pattern exists to prevent.
Feature `expr` bindings were unaffected only because expr lives inside
CadFeature and rode along in the features snapshot — which is why the feature
side appeared to work.

This was a kernel gap, not a GUI one: McpControl::action_set_variable has the
same sequence and was equally broken.

The undo/redo stacks now hold a {features, variables} Snapshot. Nothing here is
serialized, so no recipe version change and no golden-fixture regeneration.

Two tests, both verified to FAIL against a faithful reproduction of the bug
(undo() leaving `variables` untouched) at test_caddocument.cpp:4420 and :4441:
one covers restoring a variable's previous value, the other covers removing a
variable that did not exist before the checkpoint. Worth recording that the
first mutation attempt was NOT faithful — it dropped the restore but kept
std::move(variables) into the redo stack, which empties the map as a side effect
and made the second test pass for the wrong reason. A mutation has to reproduce
the original defect, not merely break the code.

Second defect, same area: undo() calls recompute(), which succeeds and clears
doc.error, so the reason an edit was rejected was destroyed before anything
could display it. Six sites — four in DesignPanel, two in McpControl — now carry
the message across the rollback. on_remove_variable additionally asserted
"referenced by a feature expression" as fact; it now offers that as the likely
cause and appends the real error, since that diagnosis is wrong for any other
failure.

Suite 141 cases / 1972 assertions (was 139/1960). GUI compiles clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:56:34 +02:00
Tommaso BianchiandClaude Opus 5 33275f2c74 Design tab: sheet pickers targeted the wrong body; guard Delete Face's face list
The sheet-only pickers filtered correctly and then threw the filtering away. Their
rows are the SHEET bodies, but GetSelection() was passed straight through as an
index into m_doc.bodies. With a solid at 0 and a sheet at 1 — the normal order,
since you extrude a solid before making a surface — the single row is row 0 but
body 1, so Surface Offset and Thicken Surface targeted the SOLID. The kernel then
refused with "target is not a sheet", which reads as a kernel bug rather than a
picker bug, and the row's own label ("Body 2") disagreed with what was targeted.
Four sites per tool were wrong, including the re-edit path, which compared a body
index against the sheet-only row count and so restored the wrong row.

populate_sheet_body_choices() now carries the real body index in client data, and
two helpers make the row/body distinction hard to get wrong again:
sheet_choice_body() reads it back, select_sheet_choice() finds the row holding a
given body. No caller touches GetSelection()/SetSelection() on these pickers.

This is the third instance of the same index-space confusion in this file, after
the 0-based body labels in the interference report and the Rib sketch picker. The
kernel suite cannot catch any of them: the kernel receives whatever index the GUI
computed, and its own tests pass correct ones.

Delete Face was structurally right — its picker uses the all-bodies populate, so
its indices genuinely match, and accumulation appends with a running list. Two
gaps closed: clicking "Add picked face" with nothing picked was a silent no-op,
indistinguishable from a broken button, and the same face could be added twice,
putting a duplicate id into delete_faces that the defeaturing has no reason to
cope with. Re-adding is now a no-op with a message, not an error.

Both confirmed working on hardware. Kernel untouched: 139 cases / 1960 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:46:14 +02:00
Tommaso BianchiandClaude Opus 5 8621f1168e Design tab: show/hide the printer bed; give Placement its own toolbar slot
Port of snaporca ca25352d74. Hand-applied rather than cherry-picked: unlike the
Phase B commits, which only touched DesignPanel.{cpp,hpp} and transfer verbatim,
this one reaches into GLCanvas3D and DesignCanvas, where the forks genuinely
differ — mainline passes m_show_world_axes to _render_bed where Snapmaker passes
a local show_axes, and the surrounding code sits ~90 lines further down. git am
refused, correctly; the six edits were applied against mainline's own context and
the DesignPanel half came across as a patch.

Bed toggle: a "Bed" checkbox in the document/view row, on by default, in that row
rather than in a card because a view option must stay reachable with no tool open.
It drives GLCanvas3D::m_show_bed (default true, so Prepare and Preview are
untouched) and gates _render_platelist as well as _render_bed — hiding the bed
while leaving its grid and outline floating would read as a rendering fault.

Bound to wxEVT_TOGGLEBUTTON, not wxEVT_CHECKBOX: Orca's CheckBox derives from
wxBitmapToggleButton, so a wxEVT_CHECKBOX handler never fires.

Also gives the Placement drawer its own "placement" toolbar slot. put("place")
already holds the Place-on-Face button, and put() formats slot item 0 as the
control and later items as its chevron, so sharing the slot bottom-aligned the
drawer's button like a chevron.

196/196 targets, 0 compile errors, orca-slicer links (165 MB). The build script
still exits non-zero at the AppImage bundling step on libpython3.12.so.1.0 —
that is snaporca-96t, packaging only, and does not affect the binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:33:33 +02:00
Tommaso BianchiandClaude Opus 5 d4904b8e2e Design tab: stop eight tool cards rendering at startup; regroup the drawers
The sidebar opened with eight tool cards stacked in it — Transform, Mirror,
Thicken, Rib, Project, Delete Face, Helix and Mate. A card added to the cards
sizer is visible until something hides it, and close_tool()'s hide-all only
runs on a tool SWITCH, so anything missing from the construction-time hide
block is on screen from the moment the tab opens. Those eight were wired into
close_tool() but never added here. All 37 cards are now hidden at startup.

Worth stating because it invalidates a check I ran while diagnosing this: every
card IS hidden somewhere in the file, so grepping for "hidden anywhere" says
nothing. The block that matters is the one in the constructor.

Second, the drawers mixed unrelated operations, and two group tooltips no
longer described their contents — Dress-up listed eight tools spanning three
different kinds of operation, and Add material still claimed to hold only
extrude/revolve/sweep/loft after Thicken and Rib were added to it.

One concept per drawer now:

  Add material   extrude, revolve, sweep, loft, thicken, rib
                 -> grows new solid material, whether from a profile, a face or
                    a line
  Surface        unchanged; already coherent
  Datum / Curve  plane, axis, coord sys, helix, PROJECT
                 -> reference geometry and derived curves. Project consumes a
                    body but PRODUCES sketch entities, so it is curve creation,
                    not a finishing operation
  Placement      TRANSFORM, MIRROR, MATE                              (new)
                 -> moves a body without changing its shape; a mate places one
                    body relative to another
  Dress-up       fillet/chamfer, draft, shell, delete face
                 -> finishing on the faces and edges of an existing solid
  Hole / thread  unchanged

The new drawer costs no toolbar width: the layout order already contained an
empty put("place") slot between "material" and "plane" with nothing registered
to it. Shift+Y and Shift+Z follow Transform and Mirror; every tool still
appears exactly once.

Compiles clean; kernel untouched, so the suite is unaffected at 139 cases /
1960 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:39:53 +02:00
Tommaso BianchiandClaude Opus 5 6372b3b505 Design tab: M6 variables panel + per-feature expression bindings
M6 landed the kernel side as two plain public maps — CadDocument::variables and
CadFeature::expr — reachable only through MCP's set_variable / set_feature_expr.
Nothing in the GUI could create a variable, so the parametric layer was
unreachable from the Design tab.

Variables get a wxListCtrl (name, expression) with add/edit/remove below the
feature tree, since they are document-scope and must not live in a card that
only exists while a tool is open. Expression bindings get one generic row in the
feature-edit path — a field-name combo plus an expression box — rather than an
extra control on each of 32 cards.

Every mutation copies McpControl's sequence exactly, and the rollback is the
part that matters: checkpoint, mutate, recompute, undo() on failure. Without it
one typo leaves the recipe permanently unrecomputable, since load() replays the
whole list. Removing a variable a feature still references fails that recompute,
so it reports the reference rather than a bare evaluation error.

The field-name combo is deliberately editable: only 11 feature types get a
curated field list, and free text is what makes the other 21 reachable. That is
safe because assign_field() throws "unknown parameter: <name>" for anything it
does not know, inside recompute()'s try block — so a wrong name gives a clear
message and a rollback, never a silently dead binding.

Two fixes on top of the generated wiring:
- make_combo() passes wxCB_READONLY, under which Orca's ComboBox HIDES its text
  ctrl (ComboBox.cpp:51). There is no SetEditable() to undo that, so the field
  combo is constructed directly with style 0; that shows the ctrl with
  wxTE_PROCESS_ENTER and makes GetValue() return typed text.
- the field-list helper had been made a file-static function taking
  DesignPanel::Tool, which required moving Tool out of private and into the
  public API. It is now a private static member instead: 32 values of internal
  card state should not be published to satisfy a signature.

Compiles clean (0 errors); kernel suite unchanged at 139 cases / 1960
assertions. Phase B is complete on this fork — all 16 previously GUI-less tools
plus the variables panel. Not yet exercised on a display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:14:32 +02:00
Tommaso BianchiandClaude Opus 5 cf97b7aba8 Design tab: Mate tool + interference report — the 16 tools are now reachable
Mate completes the set: every CadDocument feature type now has a card. It is the
one that needed CoordSys wired first, since a mate connector IS a CoordSys
feature and cs_a/cs_b are feature indices into the recipe.

The card states what each kind constrains rather than just naming it, because
the five kinds are not distinguishable from their labels — the useful fact is
which DOF each PRESERVES: Fastened fixes all six, Planar leaves in-plane
sliding, Revolute leaves spin, Slider leaves axial travel, Cylindrical leaves
both. The offset/angle spins retitle per kind, since offset is a plane distance
for Planar and a position along the axis for the three joint kinds.

Confirm is blocked, with the reason in the status line, when fewer than two
CoordSys features exist or when A and B are the same one: a mate with cs_a ==
cs_b is meaningless and a dangling index recomputes to nothing useful.

check_interference() gets a button in the feature-tree header behind a rule, not
a tool card — it adds no feature, so it must not checkpoint(), recompute(), or
touch the undo stack, and a card would imply it does. Results go to the status
line as count + worst volume, with the per-pair list in a message box, named as
the parts tree names them.

Fixes on top of the generated wiring:
- the button's sizer adds sat after the closing brace of the block declaring
  trow, so trow was out of scope ("'trow' was not declared in this scope");
- the CoordSys client data was typed const void*, which Append rejects;
- the report labelled bodies 0-based while all 28 other body labels in this
  panel (and the parts tree) are 1-based, so it would have called the tree's
  "Body 2" an interference on "Body 1" — and it was the only unlocalised label.

Compiles clean (0 errors); kernel suite unchanged at 139 cases / 1960
assertions. Still to come: the M6 variables panel. The GUI has not yet been
exercised on a display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:14:32 +02:00
Tommaso BianchiandClaude Opus 5 9f01c78456 Design tab: GUI for 15 tools that only had an MCP method
Every CAD feature added across M1-M8 got a kernel API and an MCP method, and
almost none got a card. The MCP path was the only way to reach them, so from
the Design tab these features did not exist: add_axis and add_coordsys have
been callable since M1 with no UI at all.

Wired here, in three batches:

  datum    Axis (5 construction types), CoordSys
  surface  Surface Extrude / Revolve / Loft / Fill / Offset / Thicken Surface
  body     Transform, Mirror, Thicken, Rib, Project, Delete Face, Helix

Grouped into existing dropdowns rather than widening the 16-slot toolbar:
surfaces get one new "Surface" dropdown, body ops join Dress-up, Thicken/Rib
join Add-material, Helix joins Datum (renamed "Datum / Curve"). Shift+Y and
Shift+Z went to Transform and Mirror; the remaining five are shortcut-less
rather than getting invented chords.

Three places where the UI has to encode a kernel distinction, not just expose
a field:

- Surface Offset and Thicken Surface consume a SHEET body and fail with "target
  is not a sheet" on a solid, so their pickers filter on
  CadDocument::is_sheet_shape() and say so when no sheet exists. Thicken (solid
  face -> plate) is a different tool and is kept visibly separate.
- delete_faces is a vector, so Delete Face accumulates picks via "Add picked
  face" and shows the running list. Supporting one face would have been a
  silent downgrade of the kernel field.
- CoordSys labels its edge pick with the consequence of omitting it: without an
  edge, datum_frame() takes x from coordsys_x_hint (world constant) and the
  frame cannot express rotation about its own normal — which is snaporca-en4,
  and is why a Fastened mate built on a face-only connector cannot fix spin.

The Rib sketch picker needed the 3-arg Append(text, wxNullBitmap, clientdata):
ComboBox's own Append(text, bitmap) hides wxItemContainer's (text, void*), so
the 2-arg call resolves to the bitmap overload and fails with "conversion from
void* to const wxBitmap is ambiguous". The Sweep picker already documents this;
Rib now matches it.

Compiles clean (0 errors) against snaporca-deps; kernel suite unchanged at
139 cases / 1960 assertions. Mate, the interference report and the M6 variables
panel are still to come; the GUI itself has not been exercised on a display yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:14:32 +02:00
Tommaso BianchiandClaude Opus 5 afe6d11375 Make this fork's GUI link: OpenCV was reusing snaporca's JPEG-enabled build
First successful link of orca-slicer in this fork's history (158 MB, 738/738
objects, plus OrcaSlicer_profile_validator). 1633005bba got the test binary
green; the GUI had still never linked.

The blocker was in the deps image, not the code. That commit's message says
OCCT V7_6_0, Boost 1.84.0 and OpenCV 4.6.0 "are pinned identically in both
forks and were reused as-is". That is true of the VERSIONS and false of the
FLAGS: this fork's deps/OpenCV/OpenCV.cmake passes -DWITH_JPEG=OFF,
-DWITH_TIFF=OFF and -DBUILD_TIFF=OFF, and snaporca's does not. So the reused
build shipped lib/opencv4/3rdparty/liblibjpeg-turbo.a, which collides with the
deps' own lib/libjpeg.a — wx pulls that in via wxUSE_LIBJPEG=sys — on
jpeg_stdio_dest. Under -flto that duplicate is fatal, not a warning. snaporca
never sees it because its GUI does not link libopencv_world.a at all.

Fixed by rebuilding OpenCV 4.6.0 in orcacad-deps with this fork's own flags
(read out of the recipe rather than retyped), from the source already cached in
the image, into a clean build dir so no stale cache entry survived, and deleting
the orphaned bundled jpeg archive. The rebuilt libopencv_world.a has no jpeg or
tiff symbol references and its CMake config no longer names either library.
--allow-multiple-definition would have hidden this while leaving OpenCV carrying
codecs mainline deliberately turns off.

Lesson for the next dep: comparing deps recipes by version is not enough, diff
the CMAKE_ARGS.

Two more mounts, same root cause as the CMakeLists.txt mount this script
already documents — the baked tree is snaporca's:

- build_linux.sh, which builds `--target Snapmaker_Orca`; here the target is
  OrcaSlicer and its output name is orca-slicer, so configure passed and ninja
  then died on "unknown target". The binary check was looking for the wrong
  name too.
- scripts/, because the packaging step needs scripts/appimage_lib_policy.sh;
  without it a fully successful link still exited non-zero with "missing
  AppImage helper" and the binary check never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:09:26 +02:00
Tommaso BianchiandClaude Opus 5 ed9d02093e Default both build scripts to orcacad-deps, not the other fork's image
Both scripts defaulted IMAGE to snaporca-deps. That image is Snapmaker-based
and lacks Eigen 5.0.1, CGAL 5.6.3, wx 3.3.2 and Python 3.12 Development.Embed,
so running either script here without an explicit IMAGE= dies at CMake
configure — which is a large part of why this fork reached M8 having never once
compiled (1633005bba). The volume defaults were fixed in that commit; the image
default was missed in both files.

docker-iter-build.sh also never mounted deps_src, so it could not have
configured even with the right image: root CMakeLists.txt:947 FATAL_ERRORs
when deps_src/pybind11/include/pybind11/pybind11.h is absent, and
src/CMakeLists.txt pulls semver/hints/imgui/imguizmo/hidapi from the same tree.
kernel-test.sh got that mount in 1633005bba; this is the same fix for the GUI
build path, needed before the Phase B GUI work can be ported here.

CadDocument.hpp: mate_kind comment, mirrored verbatim from snaporca dbaa104f62
so the header stays byte-identical across forks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:35:25 +02:00
Tommaso BianchiandClaude Opus 5 1633005bba Make this fork actually compile: first green Catch2 run in its history
206/206 targets built, 139 [CadDocument] cases / 1960 assertions passing —
identical to snaporca's suite. Until now this fork had never compiled at all:
CMake died at configure, so the M1-M8 "suite green" figures were snaporca's
alone and the ports rested on patch-apply plus byte-identical sources.

Three fixes here; the deps work is in the orcacad-deps image (see below).

1. kernel-test.sh mounts deps_src. pybind11 is vendored in-tree and CMakeLists
   requires its headers; without the mount the container fell back to the
   image's baked tree, which predates it.

2. tests/libslic3r/test_3mf.cpp: repair the upstream-merge conflict resolution.
   Resolving it as a union dropped the three closing braces of our SCENARIO, so
   upstream's SCENARIO opened inside ours ("a function-definition is not allowed
   here", plus 12 cascading catch2 registry errors). Restored from the pre-merge
   file; whole-file brace balance is now 0 and the case count reconciles as
   5 (ours) + 8 (upstream) - 3 (shared) = 10, with both CAD recipe tests intact.

3. tests/libslic3r/test_caddocument.cpp: REQUIRE_CONTAINS / CHECK_CONTAINS.
   Catch2 v2 (snaporca) spells substring-match Matchers::Contains; v3 (here)
   spells it ContainsSubstring and gives Contains an incompatible meaning,
   range-contains-ELEMENT, which fails to COMPILE against std::string. Four
   sites had been hand-adapted long ago, but M2-M8 kept porting in un-adapted
   Contains calls — 16 of them — and nothing objected because nothing compiled.
   Both forks now use the same find()-based macros, so the assertion lines are
   byte-identical again and future format-patch ports carry across unchanged.
   Five orphaned `using Catch::Matchers::Contains;` lines removed with them.

The deps gap that blocked configure needed five additions on top of
snaporca-deps, built into image orcacad-deps: Eigen 5.0.1, Python 3.12.13
(exact, with Development.Embed), wxWidgets 3.3.2 (was 3.1.5), CGAL 5.6.3
(was 5.4 — mainline's own MeshBoolean.cpp calls CGAL::parameters::default_values,
added in 5.5), plus the pybind11 mount above. OCCT V7_6_0, Boost 1.84.0 and
OpenCV 4.6.0 are pinned identically in both forks and were reused as-is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:46:07 +02:00
Tommaso BianchiandClaude Opus 5 113e75e7b3 kernel-test.sh: stop defaulting to the other fork's build volume
BUILD_VOL defaulted to snaporca_buildcache — snaporca's volume — so running
this fork's kernel test wrote into the other fork's build cache. Defaults to
orcacad_kerneltest now.

docker-iter-build.sh had the identical defect and was fixed to
orcacad_buildcache; this script was missed at the time.

Observed rather than theorised: an orca_cad run under a different deps image
overwrote snaporca_buildcache's CMakeCache.txt, after which snaporca's own
kernel test failed to configure ("Cannot find NLopt library 'nlopt_cxx' in
.../lib/cmake/nlopt/lib") because it inherited the foreign cached paths. No
foreign object files were written — the run died at configure — but the cache
was poisoned, and the volume had to be wiped and rebuilt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:01:59 +02:00
Tommaso BianchiandClaude Opus 5 8c6df84acc Merge upstream/main into cad-mainline (530 commits)
Catches the fork up from 449a4cf9fc (2026-06-28) to d6cb667b89 (2026-07-24).
Upstream touched 2326 files; 17 of them overlap the 164 this branch touches.

16 of the 17 auto-merged, including all three CMakeLists.txt, the build_all.yml
CI workflow, and every GUI file. The CAD core never conflicts: CadDocument,
SketchEngine, SketchSolver, McpControl and test_caddocument are files this fork
adds, so upstream does not touch them.

The one conflict, tests/libslic3r/test_3mf.cpp, was purely additive in all three
hunks and is resolved as a union: our test pinning that store_bbs_3mf embeds the
CAD recipe as Metadata/SnapOrca_cad.bin, upstream's multi-nozzle plate-metadata
round-trip tests, and both sets of includes. All three were verified present
after resolution rather than assumed.

NOT BUILD-VERIFIED, for a reason that predates this merge and is not caused by
it: this fork cannot be configured on nativedev at all. Its CMakeLists has
required Eigen3 5.0.1 since before the merge (line 592 pre-merge), while the
only deps image on the machine is snaporca-deps, built for snaporca's
find_package(Eigen3 3.3). CMake fails at configure, so nothing compiles.

That means this fork's Catch2 suite has never run. Every "suite green" figure
recorded for M1-M8 was snaporca's suite; the ports were verified by patch-apply
plus the CAD sources being byte-identical to snaporca's. Building an orca_cad
deps image with Eigen 5.0.1 is what would finally close that gap.

Pre-merge state is preserved at branch cad-mainline-pre-upstream-2026-07-25
(30d54f0074).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:28:33 +02:00
Tommaso BianchiandClaude Opus 5 30d54f0074 M8c: interference detection
check_interference(min_volume) reports every pair of solid bodies whose
intersection encloses more than min_volume, as {body_a, body_b, volume}. It
reports only — no geometry is mutated, so calling it cannot disturb mates or
placements. Read-only at the MCP surface too: no checkpoint, no recompute.

Sheet bodies are skipped up front: an intersection involving one encloses no
volume, so the boolean would be wasted work. Bodies that merely touch share a
face and enclose nothing, so face-to-face contact is not an interference.

A boolean that fails on one pair must not lose the report for every other pair,
so each pair is guarded — and OCCT raises Standard_Failure, which is not a
std::exception and would otherwise escape.

No new serialized fields, no recipe bump: this reads `bodies`, which is
recompute output and was never serialized.

No separate MCP listing for instances and mates: describe_scene already emits
the feature tree, and Mate has rendered there correctly since M8a fixed
feature_type_name.

Tests assert the exact overlap volume (20*20*4 = 1600 mm^3), both negative cases
(clearly apart, and exact face contact), that sheets are skipped, that the
min_volume gate silences a real overlap, and that a clash created by a Fastened
mate is detected — which ties the M8b placement work to this report.

Suite 139 cases / 1960 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:15:30 +02:00
Tommaso BianchiandClaude Opus 5 6a0031c9e5 M8b: Revolute / Slider / Cylindrical mates
Each kind constrains the DOFs it owns and PRESERVES the rest from the body's
current pose, following the pattern Planar established in M8a. Resolved instead
as "Fastened with a parameter", all three would have been geometrically
identical to Fastened — relabelling rather than behaviour.

  Revolute     fixes position on the axis line; rotation about it survives
  Slider       fixes orientation and perpendicular position; axial position survives
  Cylindrical  fixes the axis line only; rotation and axial position both survive

No new serialized fields, no recipe bump, no fixture regeneration: mate_kind is
already an int and mate_offset / mate_angle already exist.

The minimum-rotation z-alignment (including the antiparallel 180 deg case fixed
in M8a) is now a shared make_z_align lambda rather than a second copy.

Fixes a rotation-about-pivot bug found by the no-op tests: R_full was built as a
rotation about the origin with a translation to oB appended, instead of a proper
rotation about oB (translation = oB - R*oB). It moved bodies that were already
correctly placed, and accounted for three of the seven initially failing cases.

Testing notes, both of which cost real debugging time here:

- Mates are defined on connector FRAMES, but the convenient thing to measure is
  CentreOfMass(), and the two coincide only when the body is symmetric about its
  connector. Five expectations in this milestone asserted the centroid while
  meaning the connector. These tests assert on the mated face's centroid.

- A CoordSys built from a face ALONE takes its z from the face normal (which
  follows the body) but its x from coordsys_x_hint, a world constant. Such a
  frame cannot see rotation about its own normal, so no mate can correct or
  preserve a spin it does not encode. The Slider and Cylindrical rotation tests
  pin coordsys_edge to an edge of their own body; without that both passed
  vacuously, one of them for a wrong implementation.

The Cylindrical rotation test was verified to fail when its mate kind is mutated
to Slider, and the Slider test failed at axis_aligned == 2 before the connectors
were edge-pinned. Neither is green by accident.

Known wart: mate_angle is silently ignored for Slider, whose rotation is fully
constrained. Defensible but undiagnosed at the API surface.

Suite 134 cases / 1927 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:30:37 +02:00
Tommaso BianchiandClaude Opus 5 b13ca01ccc M8a: assembly mates — Fastened + Planar (recipe v3)
An assembly is a multi-body document. An instance is already expressible as
Transform with xf_copy=true, and a mate connector is already a CoordSys
feature, so this adds exactly one feature type: Mate.

No constraint solver. A mate rigidly transforms the body carrying connector B
so that B's frame lands on connector A's, applied in feature order like every
other feature. Chains resolve by composition; closed kinematic loops do not
converge (last mate wins) and are out of scope.

The vendored SolveSpace in src/libslic3r/slvs/ was evaluated for 3D extension
and rejected: it is built and linked but has zero callers, and SketchEngine's
solver is hand-rolled. Extending it would mean adopting a dependency to write
more code than the alternative.

- CadFeatureType::Mate appended; six fields (mate_kind, mate_cs_a, mate_cs_b,
  mate_offset, mate_angle, mate_flip) appended at the END of both cereal lists
- SNAPORCA_CAD_RECIPE_VERSION 2 -> 3; v2 blobs are rejected, as by design there
  is no migration path. Golden fixture renamed to cad_recipe_v3.bin and
  regenerated once, extended with two CoordSys + one Mate so the new fields are
  tripwired by the field-order assertions
- datum_frame() extracted from resolve_datum_coordsys() so a mate can resolve
  its connectors against the in-progress bodies vector during replay
- apply_mate dispatched early-return, so Mate is deliberately absent from
  starts_new (unreachable for that dispatch style)
- Planar: the degenerate branch splits on the sign of zB.z_target — antiparallel
  needs a 180 deg rotation about a perpendicular axis, which an earlier revision
  silently skipped, leaving the body's normal inverted
- MCP: mate command, named bare to match the other 38 methods

Drive-by: feature_type_name() was missing Mirror, ThickenSurface, SurfaceOffset,
SurfaceLoft and SurfaceFill, which reported as "Unknown" to MCP clients.

Suite 122 cases / 1741 assertions green. Note that kernel-test.sh builds only
libslic3r_tests, so McpControl.cpp is reviewed but not compiled here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:51:22 +02:00
Tommaso BianchiandClaude Opus 4.8 9c28be5860 M7c: SurfaceLoft + SurfaceFill (open skins from profiles / a boundary)
The two remaining ways to create a sheet body. SurfaceLoft skins 2+ profile
sketches without end caps via a new SketchEngine::make_loft_surface — a
sibling of make_loft with the ThruSections solid flag false, so no existing
call site changes. SurfaceFill patches a single closed boundary wire into a
smooth face with BRepOffsetAPI_MakeFilling, adding each boundary edge as a
C0 constraint.

Purely additive: two enum values appended to CadFeatureType, reusing the
existing loft_profile_refs/loft_ruled and sketch_ref fields. No new cereal
fields, recipe stays v2, golden fixture unchanged (30773). MCP
surface_loft/surface_fill added as pure additions. Suite 107 cases / 1553
assertions green, including a test contrasting the open skin against the
solid loft of the same profiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 09:56:46 +02:00
Tommaso BianchiandClaude Opus 4.8 2da75d28ca M7b: thicken-from-surface + surface offset
Two features bridging sheet bodies back to solids and to other sheets:
ThickenSurface feeds a whole sheet shell to MakeThickSolidBySimple (the same
OCCT recipe the face-level Thicken already uses) and appends the result as a
solid; SurfaceOffset offsets a sheet's shell along its normals via
MakeOffsetShape::PerformBySimple, keeping it open. Both refuse a non-sheet
target with a clear error.

Purely additive: two enum values appended to CadFeatureType, reusing the
existing target_body / thicken_thickness / thicken_flip / plane_offset
fields. No new cereal fields, recipe stays v2, golden fixture unchanged
(30773). MCP thicken_surface/surface_offset added as pure additions.
Suite 103 cases / 1520 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 09:41:51 +02:00
Tommaso BianchiandClaude Opus 4.8 01e474e17c M7a: surface bodies — SurfaceExtrude + SurfaceRevolve (open shells)
Two body-producing features that emit an open shell instead of a capped
solid: SurfaceExtrude (prism of a sketch wire, no end caps) and
SurfaceRevolve (revolve of a wire about an in-plane axis, no caps). Each
appends a new sheet body whose TopoDS_Shape has no TopAbs_SOLID.

Purely additive: two enum values appended at the end of CadFeatureType,
reusing existing serialized fields (sketch_ref/distance,
revolve_angle/revolve_axis). No new cereal fields, recipe stays v2, golden
fixture unchanged. is_sheet_shape() derives sheet-ness from the OCCT shape
type (bodies are not serialized). MCP surface_extrude/surface_revolve added
as pure additions. Suite 99 cases / 1474 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 09:19:02 +02:00
Tommaso BianchiandClaude Opus 4.8 15ea0a813f M6: Variables & equations — parametric expressions driving feature dimensions
Add named document variables (CadDocument::variables) and per-feature
expression bindings (CadFeature::expr, field-name -> expression). On
recompute(), variables are evaluated topologically (cycle detection), then
each feature's expr entries are evaluated and written into its numeric fields
before geometry runs. Self-contained shunting-yard evaluator (+ - * /, parens,
unary minus, sqrt/abs/sin/cos/tan(deg)/min/max, pi). assign_field allow-lists
the 33 dimension fields + pattern_count; unknown names error loudly.

Additive: recipe stays v2 (fields appended to both cereal lists, golden
fixture regenerated). MCP set_variable / set_feature_expr are pure additions.
Suite 95 cases / 1440 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 08:11:06 +02:00
Tommaso BianchiandClaude Opus 4.8 aa30575369 M5c: pattern-on-curve — replicate a body along a sketch curve
Extend CadFeatureType::Pattern (no new enum) with a curve mode: when
pattern_curve_sketch >= 0 it takes precedence over linear/circular. The guide
entity is sampled at equal-parameter points via a file-local sample_entity_2d()
(Line lerp, Arc angle-lerp, cubic-BSpline Bernstein, p0->p1 fallback), and each
seed copy is translated by (P_i - P_0) and fused. Two serialized fields
(pattern_curve_sketch/pattern_curve_entity) appended to both symmetric cereal
lists (version stays 2, golden fixture regenerated 30269->30517). MCP:
pattern_on_curve. 3 new [CadDocument][pattern] tests; suite 89/1395.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 04:15:31 +02:00
Tommaso BianchiandClaude Opus 4.8 a606dfe00a M5b: rib — thin stiffening wall grown from an open sketch line
New CadFeatureType::Rib (appended). A straight open Line entity in a sketch
is offset ±thickness/2 along its in-plane perpendicular into a thin rectangle,
extruded rib_depth along the sketch-plane normal, and fused to the target body.
Line-only for now (ponytail; polyline/arc ribs are a later extension) — a
non-line entity fails cleanly at recompute. Four serialized fields
(rib_sketch_ref/rib_entity/rib_thickness/rib_depth) appended to both symmetric
cereal lists (version stays 2, golden fixture regenerated 29525->30269). MCP:
rib. 3 new [CadDocument][rib] tests; suite 86/1376.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 04:03:52 +02:00
Tommaso BianchiandClaude Opus 4.8 0bbf22ceae M5a: hole standards library — counterbore/countersink + ISO/ANSI table
Extend CadFeatureType::Hole (no new enum value) with a style flag
(simple/counterbore/countersink) and the matching geometry: a coaxial
shallow cylinder cut for counterbores, a cone-frustum cut for countersinks.
A file-local hole_std_lookup() resolves screw designations (ISO 273/4762/
10642 metric M3–M10 + common ANSI unified) into clearance/cbore/csink dims;
add_hole_standard() fills the feature from it, add_hole_styled() takes them
explicitly. Six serialized fields appended to both symmetric cereal lists
(recipe version stays 2, golden fixture regenerated 28161->29525). MCP:
hole_styled, hole_standard. 4 new [CadDocument][hole] tests; suite 83/1351.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 03:52:01 +02:00
Tommaso BianchiandClaude Opus 4.8 e19e51b150 M4: delete-face direct edit — remove faces and heal via OCCT defeaturing
New CadFeatureType::DeleteFace: removes a set of global face ids from target_body
and heals the gap via BRepAlgoAPI_Defeaturing (TKBO, already linked), mirroring the
Shell/Draft body-modifying pattern. delete_faces appended to both symmetric cereal
lists (recipe version stays 2, golden fixture regenerated 27913->28161). MCP
delete_face method (pure additions). 3 new [CadDocument][deleteface] tests: remove a
fillet face restores the sharp-box volume, bad index fails safely, round-trip.
Full kernel suite green (79 cases, 1309 asserts).

Move-face / replace-face deferred to snaporca-3c4 / snaporca-tc6 (no clean shipping
OCCT direct-modeling primitive; need research, and replace-face depends on M7 surfaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 03:28:25 +02:00
Tommaso BianchiandClaude Opus 4.8 65caa2ea6e M3c: 2D bridging curve — cubic-Bezier G1 connector between sketch endpoints
Adds SketchEngine::make_bridge (4-pole cubic Bezier, G1-tangent to Line/Arc
endpoints, straight-line fallback for other types) emitted as the existing
BSpline SketchEntity — no new geometry type, no serialized-field change, golden
recipe fixture untouched. CadDocument::add_bridge appends it (non-parametric,
index-validated, throws on bad refs). MCP `bridge` method mirrors action_project.
4 new [CadDocument][bridge] tests; full kernel suite green (76 cases, 1280 asserts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 01:59:47 +02:00
Tommaso BianchiandClaude Opus 4.8 62d39fba27 test(cad): lock construction-geometry flag with real regression tests
The construction flag was already honored (excluded from the extrude wire in
SketchEngine.cpp, participates in the solver, and serialized) but the existing
"construction line excluded" test was a false tripwire: its construction line
ran corner-to-corner inside the square, so the bbox was unchanged whether or not
the line was excluded.

- Strengthen that test: the construction line now runs (-30,0)->(30,0) outside
  the profile, so an exclusion regression breaks the closed wire / bbox.
- Add a serialize/deserialize round-trip test asserting construction survives.
- Lock the flag on-disk: add Sketch_Ctor to the golden fixture with a real edge
  + a construction edge, and assert both flags survive the binary recipe.

Test-only; no kernel change. Recipe version stays 2 (construction was already a
serialized field). Suite: 72 cases / 1246 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-25 00:27:16 +02:00
Tommaso BianchiandClaude Opus 4.8 161d006e92 CAD: Project feature — convert solid edges into a parametric sketch
Onshape-style "Use / Convert entities": pick edges (or a whole face) of an
existing solid and get sketch geometry projected onto a target plane, then
extrude/revolve/edit it like any sketch. Parametric: apply_project re-derives
the feature's entities from the source body on every recompute, so editing the
source updates the projection.

Line edges -> Line entities (exact); circles/arcs whose plane is parallel to
the sketch plane -> Circle/Arc (exact); everything else (incl. non-parallel
circles that project to ellipses) -> sampled Line chain.

Append-only: new enum value Project + project_source_body/project_edges/
project_face fields at the end of save/load; recipe version stays 2. Recompute
loop made non-const solely so apply_project can write back f.entities.

Suite 67->71 cases, 1178->1229 assertions, RC=0. Fixture 25493->26835 B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 23:42:40 +02:00
Tommaso BianchiandClaude Opus 4.8 07b39d45cb CAD: split a body by a picked face; add the missing both-halves cut test
Split-by-face reuses the existing Cut feature rather than adding a new type:
two appended fields (cut_face_body, cut_face) let apply_cut derive the cut
plane from a picked face via SketchPlane::from_face when cut_face >= 0,
otherwise it keeps using the base `plane`. cut_offset / cut_flip still apply
along the derived normal, so the same square-wire split machinery handles
both cases. add_split_by_face() is the convenience entry point; MCP gains a
`split` method (body / face_body / face / keep_upper / keep_lower).

Serialization stays append-only — cut_face_body, cut_face appended to
save/load, recipe version unchanged at 2.

Tests: the previously-missing both-halves plane cut (keep_upper && keep_lower
=> two bodies whose volumes sum to the original), split-by-face via a
top-face plane offset into the interior, keep-upper-only, and a round-trip.
Golden fixture regenerated with a GoldenSplit cut-by-face feature and exact
field-value assertions. Suite 63 -> 67 cases, 1119 -> 1178 assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 21:46:24 +02:00
Tommaso BianchiandClaude Opus 4.8 9da5851534 CAD: Thicken feature — offset a face into a thin solid plate
Pick a face of an existing body, offset it by a wall thickness along its
normal, and append the resulting thin solid as a new body. Onshape-parity
Tier-2 item; the kernel had Shell (hollow a whole solid) but no way to turn
a single face into a plate.

Kernel: CadFeatureType::Thicken, add_thicken()/apply_thicken() as a
body-level op next to Transform/Mirror. The picked face is wrapped in a
TopoDS_Shell and offset via BRepOffsetAPI_MakeThickSolid::MakeThickSolidBySimple;
the result is orientation-normalised to positive volume (same convention as
apply_mirror). Serialization stays append-only — thicken_face,
thicken_thickness, thicken_flip appended to save/load, recipe version
unchanged at 2.

MCP: `thicken` method (body/face/thickness/flip) plus the missing
feature_type_name() case.

Tests: 6 new [CadDocument] cases (plate volume within 1%, flip direction,
bad face id, zero thickness, fuse-with-source, round-trip). Golden fixture
regenerated with a GoldenThicken feature and exact field-value assertions.
Suite 57 -> 63 cases, 1054 -> 1119 assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 18:34:15 +02:00
Tommaso BianchiandClaude Opus 4.8 0100c95ed1 CAD: Transform feature — move/rotate a body as a real B-rep operation
Until now move and rotate lived only in the GUI as m_body_xform, a display
transform. That made them a correctness hole, not a missing tool: a moved body
recomputed and booleaned at its ORIGINAL position, and the move was not in the
recipe at all, so it vanished on save/reload. Only export_step consulted the
transform, which is why the discrepancy stayed hidden.

CadFeatureType::Transform makes it a real feature: rotate angle_deg about
xf_axis through xf_pivot, then translate by xf_translate, applied to the target
body with BRepBuilderAPI_Transform. xf_copy=true keeps the source and appends
the transformed body instead of mutating in place, which covers Onshape's
Transform/copy in the same feature.

Rotation is composed before translation (trsf = tr * rot) so the pivot means
what a user expects — the point the body turns about, not a point that then
drifts with the translation. A rotation with a degenerate axis is refused
rather than silently skipped; a zero angle skips the rotation entirely so a
pure move needs no axis at all.

The decisive test is not the bbox arithmetic but "moved body participates in a
later boolean at its new position": two coincident boxes, one moved to partial
overlap, fused. The fused volume must be strictly greater than one box (the
move took effect in the kernel) and strictly less than both (they still
intersect). With a display-only transform the first assertion fails.

Serialization stays append-only; recipe version unchanged at 2. Golden fixture
regenerated with a GoldenTransform feature carrying distinctive literals so a
field reorder shows up as obviously wrong values. Also fills in the Helix arm
of feature_type_name(), missing since the helix commit.

Kernel suite 51 -> 57 cases, 985 -> 1054 assertions, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 17:50:08 +02:00
Tommaso BianchiandClaude Opus 4.8 017bb08a1d CAD: helix / spiral curve, consumable as a Sweep path
Adds CadFeatureType::Helix — the missing input for Sweep. Sweep already
existed but could only follow a sketch, so springs, coils, augers and
non-standard-pitch threads were unreachable. Helix + the existing Sweep now
gives all of them with no further work.

Built the OCCT way: a 2D line on a Geom_CylindricalSurface (Geom_ConicalSurface
when helix_taper_deg != 0) turned into an edge and lifted to 3D with
BRepLib::BuildCurves3d — a true analytic helix, not a sampled polyline, so a
swept spring is smooth rather than faceted. The axis is the plane normal
through the plane origin, matching how Revolve and Plane already work.

Sweep's path resolution is widened to accept either a Sketch (unchanged
behaviour) or a Helix, and rejects anything else with a clear error. Helix
itself is skipped in route_feature and recompute — like the datum features, it
produces no body and exists to be consumed.

Invalid input is refused rather than approximated: non-positive radius or
pitch, negative height, a turn count above 10000 (which would hang OCCT), and
a taper that would drive the radius negative before reaching the top all fail
with a specific error.

Serialization: helix_radius/pitch/height/left_handed/taper_deg appended at the
very end of both save and load, identical order, after the coordsys block.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Helix is appended to the end of
CadFeatureType. Golden fixture regenerated with distinctive literals and
field-value assertions; all pre-existing assertions pass unchanged.

Tests assert analytic values. The one that actually proves it is a helix and
not a circle or a spiral: arc length of r=5 pitch=2 height=10 measured with
BRepGProp::LinearProperties against 5*sqrt((2*pi*5)^2 + 2^2), WithinRel 1e-3.
Plus bounding box (2r in X and Y, height in Z), the conical top radius, the
left-handed winding compared at equal parameter, and the integration test:
a circle r=1.5 swept along a 5-turn helix gives one valid solid of ~1115 mm^3
(WithinRel 0.1 — pipe sweeping is not exact).

MCP: `helix` method registered in describe_tools().

Ported from snaporca 6cdc6b5459. Two fork-specific adjustments: the two new
error-message assertions use Catch2 v3's ContainsSubstring (v2's Contains does
not exist here), and the golden fixture is copied rather than regenerated
because this fork cannot be compiled locally — make_golden_doc_v1() is
byte-identical across both forks, so the blob is provably the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 16:39:36 +02:00
Tommaso BianchiandClaude Opus 4.8 93e80bc407 CAD: datum axis and datum coordinate system
Adds CadFeatureType::Axis and ::CoordSys — reference geometry that produces
no solid, modelled on the existing Plane datum feature.

Axis construction methods (AxisType): TwoPoints, FaceNormal,
CylinderCenterline, PlaneIntersection, AlongEdge. The centreline case is the
useful one: it gives a real axis through an existing hole or boss.

Coordinate systems (CoordSysType): PointWorld and FaceAndDirection. The
latter Gram-Schmidts the picked references, so the stored frame is
orthonormal even when the user's X hint is not perpendicular to the face
normal; the third axis is derived by cross product rather than stored, so it
cannot drift out of sync.

Revolve and pattern are deliberately NOT rewired to consume these — this
commit adds the reference geometry only and changes no existing behaviour.

Serialization: all axis_*/coordsys_* fields appended at the very end of both
CadFeature::save and load, identical order, after mirror_keep_original.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Axis and CoordSys are appended to the
end of CadFeatureType so existing type ordinals are unchanged. Golden fixture
regenerated with distinctive non-default literals and field-value assertions
for every new field; all pre-existing assertions pass unchanged.

Tests assert analytic values: two-point axis direction exactly +Z with unit
length, cylinder centreline collinear with Z and on the true axis, parallel
planes fail cleanly, and the Gram-Schmidt frame is orthonormal to 1e-9 with
X x Y == Z. Degenerate input (identical points) fails with a non-empty error
rather than producing NaNs.

MCP: `axis` and `coordsys` methods registered in describe_tools().

Ported from snaporca 242d4efecb. The golden fixture is copied rather than
regenerated because this fork cannot be compiled locally (Eigen 5.0.1 vs the
build image's 3.3); make_golden_doc_v1() is byte-identical across both forks,
so the two fixtures are provably the same blob.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 15:58:13 +02:00
Tommaso BianchiandClaude Opus 4.8 c980725e9d CAD: mirror body feature (reflect a solid about a plane)
Adds CadFeatureType::Mirror: reflect a target body about a plane using
gp_Trsf::SetMirror + BRepBuilderAPI_Transform. BooleanMode::New keeps the
mirrored copy as its own body (mirror_keep_original decides whether the
source survives); BooleanMode::Add fuses it back into the source, so an
overlapping mirror does not double-count volume.

Serialization: mirror_keep_original is appended at the very end of both
CadFeature::save and load (append-only contract). The mirror plane reuses
the existing `plane` member and the body selector reuses `target_body`,
as Cut already does. Golden fixture regenerated at the current
SNAPORCA_CAD_RECIPE_VERSION = 2; the existing field-value assertions all
still pass unchanged, and the reorder tripwire was re-verified after
regeneration (swapping draft_face/draft_angle in `load` alone still
fails the golden test).

Tests assert analytic values: mirrored volumes equal (8000 each) with the
reflected centroid, Add on a non-overlapping asymmetric body gives exactly
2x volume, Add across an intersecting plane gives strictly less than 2x,
and an invalid body index fails cleanly with a non-empty error.

MCP: `mirror` method registered in describe_tools().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 11:52:29 +02:00
ExPikaPaka 0df1424101 Add adaptive subdivision 2026-07-24 09:03:06 +02:00
Tommaso BianchiandClaude Opus 4.8 b807be3c4a CAD mass properties: volume / area / centre of mass / inertia
Port of snaporca c2821af783. New GeometryEngine::mass_properties over BRepGProp
(separate VolumeProperties/SurfaceProperties), bounds-checked
CadDocument::body_mass_properties, and a mass_properties MCP method. Query-only:
no CadFeature, no serialization, no version change. Analytic tests
(WithinRel/WithinAbs): cube 8000/2400/COM(0,0,10)/inertia 533333, cylinder
500pi/300pi, hollow = solid-500pi, invalid index -> valid=false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 23:18:42 +02:00
Tommaso BianchiandClaude Opus 4.8 8edc12c8f1 CAD recipe v2: legible version-mismatch errors, refuse old files cleanly
Port of snaporca 04c3d579a7. Bump SNAPORCA_CAD_RECIPE_VERSION 1 -> 2;
deserialize_recipe sets a user-facing error distinguishing too-new / too-old
/ corrupt instead of a silent bare false. No per-version migration by design.

Golden fixture regenerated at v2 (v1 retired), field-value reorder tripwire
unchanged. Fork adjustment: Catch2 v3 string matcher ContainsSubstring
(not v2's Contains) in the two new version-mismatch tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 22:32:56 +02:00
Tommaso Bianchi d04b02cd0e test: golden on-disk fixture that actually detects a serialization reorder
CadFeature::save/load is append-only by contract, and ~20 planned features
each append fields. The existing roundtrip test cannot police that: it writes
and reads with the same code, so any self-consistent ordering passes. Only a
blob written by older code and stored on disk can detect that the format moved.

The first attempt at this test passed while the defect was present. I proved
it by swapping draft_face (int) with draft_angle (double) in both save() and
load() -- a genuine byte-layout change -- and it still reported 613 assertions,
exit 0. It asserted only derived geometry: body count, per-body volume, feature
types. The golden document had no Draft feature, so those fields sat at their
defaults, the reorder scrambled values nothing read, and the recomputed solids
came out byte-identical.

So the fixture now asserts the DATA, not what the data produces:

- make_golden_doc_v1() builds 22 features across 14 types (Draft, Shell,
  Revolve, Pattern, Cut, Hole, Chamfer, Fillet, Extrude taper/symmetric,
  Thread, Sweep, Loft, Boolean, Plane) with distinctive non-default literals
  (draft_angle 7.25, shell_thickness 1.375, revolve_angle 217, pattern_count 5)
  so a reorder produces visibly wrong values rather than swapped defaults.
- Layer 1 reads the committed blob with raw cereal and asserts field by field,
  independent of recompute, so a geometry regression cannot mask a format break.
- Layer 2 keeps the geometry checks as a separate concern.

Verified to trip, twice, by deliberate breakage rather than by assertion:
  draft_face   <-> draft_angle   -> draft_face reads 1075642368 (0x401d0000),
                                    the high half of double 7.25
  revolve_angle <-> revolve_axis -> revolve_angle reads 0.0, not 217.0
Both revert clean to 758 assertions / 30 cases.

Also scoped the "regenerate the fixture" hint to the feature-count check only.
It was in scope for every assertion in the block, so a detected reorder told
you to run [.regen] -- which would bake the corrupted layout in as the new
golden and permanently disarm the test. A guard must not advise disabling
itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q

Ported from snaporca f9e0f99bcb. The patch needed fuzz: this fork's copy of
test_caddocument.cpp carries a Catch2 v3 include, a `using Catch::Approx`, and
an extra [Deviation] case appended after line 1611 -- exactly where these hunks
land. Verified after applying: new symbols present, the [Deviation] case
intact, braces balanced, and only WithinAbs/WithinRel used (both exist in v3).
Compile and test verification here is CI, not local: this fork needs Eigen
5.0.1 while the local deps image ships 3.3.
2026-07-23 14:41:31 +02:00
Tommaso BianchiandClaude Opus 4.8 3621785984 build: headless CAD-kernel test loop, and quarantine two broken tests from it
Adds scripts/kernel-test.sh: build the libslic3r_tests target and run the CAD
kernel tags, exit code as the whole contract. Clean build 4m36s, incremental
18s, no display needed -- every [CadDocument] case builds a CadDocument,
recompute()s it and asserts on geometry.

Four things this had to get right, each found by it going wrong first:

- SLIC3R_GTK=3 and BUILD_TESTS=ON are mandatory. src/CMakeLists.txt turns
  SLIC3R_GTK into 'wx-config --toolkit=gtk<N>', so omitting it asks for
  toolkit "gtk", nothing matches, and configure dies with the thoroughly
  misleading "Could NOT find wxWidgets" -- while wx-config sits right there
  in the deps prefix, working. BUILD_TESTS=ON is what creates the target.
- Configure runs unconditionally. Guarding on "CMakeCache.txt exists" is
  wrong because a FAILED configure writes that file too, after which the
  guard skips reconfiguring forever and every later run silently reuses the
  poisoned cache, ignoring corrected flags.
- A new build volume is always built clean. Cloning a warm cache from
  another tree is a correctness trap: rsync preserves source mtimes, ninja
  compares them against foreign object timestamps, concludes everything is
  current and relinks stale objects. That produced a binary containing NO
  [CadDocument] tests at all -- while exiting 0. A green run that tests
  nothing is worse than a red one.
- --host builds where the deps image already lives, staged per volume so
  parallel workers never share a tree.

The two [known-broken] tags: "entity constraints: tangent/midpoint/symmetric/
angle" aborts inside the vendored solver (slvs/dsc.h FindById, "Cannot find
handle"), and SIGABRT is fatal to the Catch2 process -- that single case took
the suite down at 12 of 31, so a green baseline was unreachable. The thread
groove case is a plain pre-existing assertion failure. Both are excluded from
the dev loop's default filter ONLY; ctest in CI still runs and reports them,
so neither bug is hidden. Baseline is now 603 assertions / 29 cases green,
which is what makes "I broke nothing" a meaningful statement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 13:47:25 +02:00
Tommaso BianchiandClaude Opus 4.8 52a8ca965c docs: capability gap analysis of the Design tab against Onshape
Enumerated from the source rather than from recollection: CadFeatureType and
add_* in CadDocument.hpp, Tool in DesignPanel.hpp, Mode in
DesignSketchTool.hpp, SketchConstraintType + SketchEntity::Type in
SketchEngine.hpp, and the JSON-RPC dispatch in McpControl.cpp.

Findings worth stating up front:

- The 2D sketcher is at or near Onshape parity -- 19 constraints, every entity
  type including B-splines and elliptical arcs, trim/extend/offset/mirror and
  both array kinds. Very little is missing there.
- The gaps are all breadth beyond sketching: assemblies/mates, surface
  modelling, sheet metal, drawings, and variables/configurations.
- The most defensible criticism is the absence of variables and expressions.
  Every dimension is a literal double, so the feature tree is parametric in
  structure but not in value -- "change one number and the model updates" is
  only half delivered. It is also the cheapest Tier 1 item to close.

The doc separates platform capabilities (version control, FeatureScript, FEA,
rendering, cloud PDM) into their own tier rather than counting them as missing
tools: that is Onshape-the-platform, not Onshape-the-modeller, and holding a
slicer tab to it would not be a fair comparison.

One entry is a correctness gap rather than a missing feature: move/rotate body
(m_body_xform) is display-only and never enters the B-rep, so a moved body
exports and booleans at its original position while the viewport shows it
moved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-23 13:10:24 +02:00
Tommaso BianchiandClaude Opus 4.8 8aea63a919 docs: rewrite the upstream brief from measurement, correcting two errors
The 2026-06-21 assessment was written before the persistence work landed and
got two load-bearing facts wrong. Both are corrected here against the branch
itself rather than from recollection:

1. It called OCCT "a dependency mainline OrcaSlicer has never carried" and
   built its whole conclusion on that. False: deps/OCCT/ exists at the
   merge-base, and upstream already links it from Format/STEP.cpp,
   Format/svg.cpp and Shape/TextShape.cpp. The real dependency diff is one
   line -- BUILD_MODULE_ModelingAlgorithms OFF -> ON -- costing a measured
   3.77 MiB of Windows DLLs (TKFillet + TKOffset; TKBool already arrives
   transitively via DataExchange).

2. It described the vendored SolveSpace solver as LGPL. False:
   src/libslic3r/slvs/LICENSE is GPL-3.0. Harmless for us, but a licence
   must not be misstated in a document aimed at upstream.

It also claimed no changes to Model, which stopped being true when 3MF
recipe persistence added a std::string there.

The rewrite replaces prose estimates with counted figures: 138 new files,
23 modified upstream files at +457/-75, nothing deleted, 99.3 % of the diff
in new files. That reframes the ask from "adopt a CAD kernel" to "widen a
build flag you already carry", which is the argument that actually has a
chance upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-22 20:16:31 +02:00
Tommaso BianchiandClaude Opus 4.8 2db59bb85a Design: trace the solid-pick path behind SNAPORCA_PICK_TRACE
Selection failures on a real desktop kept looking identical from the UI
whether the ray missed the solid, the click was rejected as a drag, or the
press never reached the tool at all. The status line added in edb1adbfa3
reports WHICH body/face was picked, so it distinguishes "picked" from
"silence" and nothing finer -- not enough to tell those three apart.

This narrates the whole press -> release -> ray path on stderr, one distinct
line per failure mode:

  down x= y=                      the press reached the tool
  up with no pending press        the press was eaten upstream
  up ... drift=N / rejected       the click-vs-drag threshold decided
  ray tris=N -> body= face= t=    the ray reached the solid (or missed)
  no solid data (bodies= mesh=)   the pick pointers were never wired

Gated on getenv("SNAPORCA_PICK_TRACE"), cached in a function-local static,
so a normal build pays one load and prints nothing. It ships enabled-on-
demand because the failing environment is a real X session with a real
mouse, which the headless rig cannot reproduce.

First use retired a wrong theory of my own: the trace showed drift=0 on
three consecutive picks, so the widened threshold in edb1adbfa3 was not
what fixed anything. The reported symptom is best explained by a stale
pre-orcawidgets binary left running alongside the current one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-22 19:06:05 +02:00
ExPikaPaka 23b6020344 Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-07-21 14:24:39 +02:00
ExPikaPaka d9f679d154 Rename reserved GLSL word on AMD GPU 2026-07-21 14:07:51 +02:00
SoftFever 5cae3337a7 Merge branch 'main' into feature/texture_displacement 2026-07-21 19:50:35 +08:00
ExPikaPaka 39bfceca6d Fix typo again 2026-07-21 12:53:00 +02:00
ExPikaPaka 219cddf40e Fix typo after cleanup 2026-07-21 11:28:51 +02:00
ExPikaPaka 129b612af5 Merge branch 'feature/texture_displacement' of https://github.com/OrcaSlicer/OrcaSlicer into feature/texture_displacement 2026-07-21 10:12:16 +02:00
ExPikaPaka 61d2d4355a Cleanup 2026-07-21 10:12:09 +02:00
ExPikaPaka ab023f3f6d Add texture projection frame overlay, fix remeshing and subdivision 2026-07-21 09:44:25 +02:00
Tommaso BianchiandClaude Opus 4.8 edb1adbfa3 Design: widen the solid-pick click threshold, and report what got picked
Ports snaporca-cad f5c7e74e9b.

The LeftUp pick discarded anything moving more than 4 px total since the
press. A hand-held mouse drifts that much during an ordinary click, so
real clicks were thrown away as drags and it read as "selection does not
work". Use 8 px per axis, GTK's own drag threshold.

Also report the pick on the status line (body / face / edge). A solid
pick previously set no text at all, so its only feedback was the viewport
highlight, and a pick that registers but draws faintly looked identical
to one that never fired.

DesignSketchTool.cpp copied verbatim (identical between the forks apart
from this change). DesignPanel.cpp took the status hunk only, since this
fork keeps mainline's Item-based DropDown in feat_dropdown/ToolFlyout.

Not confirmed on hardware yet, on either fork; this fork remains
uncompiled (needs Eigen 5.0.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-20 20:57:46 +02:00
Tommaso BianchiandClaude Opus 4.8 7858cd6b6a Sketch: open the dimension field on the right monitor, and stop it freezing the view
Ports snaporca-cad c741fb9677.

SketchInlineEditor::open() clamped its position with
wxGetClientDisplayRect(), which describes only the PRIMARY monitor. On a
multi-head desktop (the reporting machine runs 5760x1080 across screens
at +0, +1920 and +3840) a field anchored on the left or right screen was
clamped onto the middle one and left invisible, while m_awaiting_length
made the sketch tool consume every mouse event until it was answered:
orbit and pan died after any sketch, with Enter the only way out. Clamp
to the display the anchor is actually on instead.

Also let drags and the wheel through that freeze, so a field that lands
somewhere unexpected degrades to odd placement rather than a dead
viewport.

Both files were byte-identical between the forks apart from this change,
so they are copied verbatim. Verified on the snaporca side by the user;
this fork is still uncompiled (needs Eigen 5.0.1, which the available
deps image does not provide).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-20 10:14:02 +02:00
Tommaso BianchiandClaude Opus 4.8 b3e7d65cca Design: port the Orca-widget rebuild and the mouse-press fix from snaporca
Mirrors snaporca-cad a016dffb48 + 4b828c4798 so the two forks do not drift:

- Sidebar on Orca's widget set: ComboBox for all 25 pickers, StaticBox
  frames for the tool cards / feature tree / bodies list, framed double
  spins, and no stack of full-width action buttons (Prepare's left panel
  is parameters only).
- Toolbar: document group + undo/redo + mode-gated tools, commit far
  right, ordered via keyed slots, at Prepare's 40 px / 4 px geometry.
- Polygon options and a new Move/Rotate card (distance, axis, angle) in
  the sidebar instead of inline in the toolbar row.
- DesignSketchTool: stop consuming the LeftDown over a solid. Orca starts
  a rotate drag on the press, so swallowing it killed orbit/pan whenever a
  body was on screen. The pick now resolves on LeftUp within 4 px.

DesignSketchTool.{cpp,hpp} were byte-identical across the forks and are
copied verbatim. DesignPanel.cpp needed one hand-merge: mainline's
DropDown is Item-based (DropDown::Item{text,tip,icon}, DropDown(items&))
where snaporca passes parallel vectors, so feat_dropdown/ToolFlyout keep
the mainline form. Checked field-by-field against this fork's
Widgets/DropDown.hpp.

scripts/docker-iter-build.sh: mount the root CMakeLists.txt and cmake/
rather than inheriting the baked copies, which silently drops the
SLIC3R_CAD gate, and stop defaulting to snaporca's build volume - sharing
it made the two forks overwrite each other's cache and binary.

NOT COMPILED. This fork needs Eigen 5.0.1 (find_package(Eigen3 5.0.1
REQUIRED)) while the available snaporca-deps image supplies 3.3, so it
needs its own deps build to verify. The behaviour above was verified only
on the snaporca side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-20 07:48:36 +02:00
Tommaso BianchiandClaude Opus 4.8 f4469b8450 Design: use Orca's teal CheckBox instead of raw wxCheckBox
Design drew raw OS wxCheckBoxes (grey square + inline text) where Prepare shows
Orca's teal check, one of the most visible reasons the two panels looked
unrelated. The six sidebar checkboxes (extrude flip, hole through, thread
internal, revolve flip, boolean keep-tool, loft ruled) now use Widgets/CheckBox:
the widget carries no text, so each label moves into the row's left column,
which is also Prepare's row idiom and matches the label/control grids.

CheckBox is a wxBitmapToggleButton, so its per-control Binds and the panel-wide
preview refresh listen for wxEVT_TOGGLEBUTTON as well; boolean/loft get a
label+control row instead of a bare full-width control. Checkboxes align to the
left edge of the control column, as Prepare aligns its own.

The two sketch-toolbar checkboxes are left alone — they live in the top toolbar,
not the left panel.

Verified on :10: Flip direction renders as a teal check with a white tick and
toggles correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 13:15:35 +02:00
Tommaso BianchiandClaude Opus 4.8 d25ff88d76 Design tools: label-left/control-right rows, and drop the empty-state gaps
Second half of the Prepare alignment. The 14 tool forms were plain 2-column
wxFlexGridSizers whose control column never grew, so every control sat at its
natural width right next to its label — nothing lined up, and it looked nothing
like Prepare's "label ......... [value]" rows.

- two_col_form() builds the grid with a growable control column; all 14 forms use
  it and their 56 controls are added with wxEXPAND, so controls fill one aligned
  column at the panel edge.
- The sketch-session Plane row is a box sizer, not a grid, so it gets a stretch
  spacer for the same effect.
- The DoF readout collapses when empty, and the Bodies block starts hidden — both
  reserved a blank line on a fresh document, leaving dead space above the action
  buttons that Prepare does not have.

Verified on :10: Extrude edit shows Extrude dist / End / 2nd dist / Taper /
Result as aligned label+control rows; Plane row right-aligns; no Bodies box on an
empty document.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 12:23:15 +02:00
Tommaso BianchiandClaude Opus 4.8 7d2a63ffcb Design sidebar: align with Prepare's visual language
The two tabs used different idioms for the same concepts, which reads as two
different apps: Design was 264 px against Prepare's ~467 (the canvas edge jumped
on every tab switch), used bare micro-labels where Prepare uses icon + Head_14
card headers with a rule, hung its row actions on a loose strip under the tree
instead of in the section header, and drew raw OS-default wxButtons next to
Prepare's Orca-styled ones.

- Width now tracks Prepare's sidebar at runtime (sync_sidebar_width() reads the
  live width on tab activation) rather than being hardcoded, so the two cannot
  drift apart if Orca changes its sidebar.
- "Feature tree" and "Bodies" use the card_header() helper the panel already had
  (icon + Label::Head_14) plus a wxStaticLine, exactly as the tool cards do.
- The six row actions moved into the Feature tree header, Prepare-style, at
  header weight (24 px) instead of 36 px control weight.
- Buttons are Orca Buttons (ButtonType::Expanded, full width); Commit to Plate
  gets ButtonStyle::Confirm as the tab's primary action.
- Margins/spacing come from SidebarProps (ContentMargin/TitlebarMargin/
  ElementSpacing) instead of hardcoded 12/6/4.

Verified on :10: sidebars are the same width and share the header idiom.
Still to do: label-left/control-right rows inside the tool dialogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 12:09:44 +02:00
Tommaso BianchiandClaude Opus 4.8 b9e927876d Design: move bodies into their own Parts list (Onshape-style)
Bodies were appended INSIDE the feature tree, after the features, and only when
there was more than one body. Two consequences:

- With a single body — sketch + extrude, the common case — no body row existed
  at all, so the solid could not be selected from the tree. That also blocked
  Move (it requires a selected body), the show/hide eye and every body-targeted
  op; the viewport was the only way to select.
- With several features the Bodies group was pushed past the tree's auto-sized
  height (capped at 9 rows) and clipped out of view, so bodies became
  unreachable as history grew.

Bodies now live in their own list under the feature tree, mirroring Onshape's
Features + Parts split that the rest of the tab already follows. The list is
hidden while empty, sizes to its content (scrolls past 6), keeps the selected
row across a recompute, and greys hidden bodies as before. Selecting in either
list clears the other, so only one thing is ever "the target".

Verified on :10: single body -> Body 1 listed, selectable, Move opens the gizmo
on it (previously impossible); two imported solids -> both listed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 11:35:11 +02:00
Tommaso BianchiandClaude Opus 4.8 8d529ad413 Size the Design move/rotate gizmo from the body, like Orca's Prepare gizmos
The Design gizmo already had both move arrows and rotation rings, but drew them
at a fixed 70 px arm regardless of the body: on a 40 mm cube everything
collapsed into a ~100 px tangle buried inside the solid, so the rings were
effectively invisible and the tool read as "move only, no rotate".

Orca's Prepare gizmos size themselves from the selection's bounding sphere
(GLGizmoRotate3D: m_radius = Offset + sphere radius) so the handles always clear
the object. Same rule here: DesignPanel passes the body's bounding-sphere radius
(scale-aware) into the gizmo, and move_gizmo_arm() returns
max(70 px, 1.25 * radius) — the screen-space floor keeps it grabbable on a tiny
body or when zoomed far out. Ring radius and BOTH hit-tests derive from that one
helper, so picking cannot drift from what is drawn.

Verified on a 40 mm cube: rings now encircle the body, arrow drag moves with a
live mm readout, ring drag rotates live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-19 08:36:26 +02:00
Ian Bassi 9f1722ed3b Merge branch 'main' into feature/texture_displacement 2026-07-17 09:07:31 -03:00
ExPikaPaka 8247514ae2 Fix cmake config 2026-07-17 09:04:39 +02:00
ExPikaPaka 00f55639d3 Add new icons 2026-07-16 08:52:15 +02:00
ExPikaPaka 68d754d946 Add texture displacement documentation 2026-07-16 08:43:35 +02:00
ExPikaPaka dc5a48bdfd Add texture displacement toolbar icon and textures 2026-07-16 08:43:27 +02:00
ExPikaPaka 05083bb6ab Add texture displacement gizmo and UV editor 2026-07-16 08:43:21 +02:00
ExPikaPaka a393b21642 Add texture displacement bump and UV-check shaders 2026-07-16 08:43:08 +02:00
ExPikaPaka a7c8dcc58d Add texture displacement baking, LSCM unwrap and remesh core 2026-07-16 08:42:55 +02:00
Tommaso BianchiandClaude Opus 4.8 f4160595b0 Kill the remaining Design-tab UI freezes
query_topology: indexing a body face-by-face was quadratic — face_by_index
re-walks the explorer and edge_by_index rebuilds the whole indexed map on every
single call. On a 15.7k-face / 25.6k-edge imported solid this blew past the
MCP 15 s main-thread timeout with the UI frozen throughout. GeometryEngine
gains faces_of()/edges_of(), which enumerate once in the very same order (ids
stay interchangeable with the _by_index accessors, so fillet/up_to_face targets
are unaffected). Measured on that body: 15 s timeout -> 0.46 s.

Feature ops: every commit-time m_doc.recompute() (fillet, cut, shell, boolean,
extrude, ...) now goes through recompute_guarded(), which runs the rebuild on a
worker thread. Live-preview/drag paths stay inline on purpose — yielding inside
a drag would be worse than the stall.

run_off_ui_thread(): the progress dialog is now created only after 300 ms, so a
fast op does not flash a dialog, while input stays blocked (wxWindowDisabler)
for the whole operation since the worker owns the document.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-15 00:13:10 +02:00
Tommaso BianchiandClaude Opus 4.8 01aa6903f0 Harden the GL canvases against dropped frames and UI-thread freezes
Viewport: GLCanvas3D::on_idle() cleared m_dirty even when
_refresh_if_shown_on_screen() rendered nothing because the canvas was not on
screen yet — a frame requested while the notebook was still showing a page got
silently swallowed and the viewport stayed blank until some later event dirtied
it again. _refresh_if_shown_on_screen() now reports whether it rendered, and
on_idle keeps the canvas dirty when it did not. Covers Design/Prepare/Preview.

Freeze: a big STEP (17.8 MB) spent ~40-50 s inside OCCT on the UI thread
(read_step_solids + recompute), so the window stopped repainting and the
compositor marked the app unresponsive. Both now run on a worker thread behind
an app-modal pulsing progress dialog: the UI keeps painting and the document
cannot be touched while the worker owns it. OCCT's Standard_Failure is not a
std::exception, so the worker catches it explicitly — an escaping exception
would terminate the process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-15 00:04:25 +02:00
Tommaso BianchiandClaude Opus 4.8 6d9368276c Fix Design tab not painting on hardware GL after a page switch
request_repaint() only invalidates the canvas (Refresh()) on the hardware-GL
path and relies on a wxEVT_PAINT to follow. When the notebook re-shows the
Design page, that invalidation is issued mid-show and dropped: no paint event
arrives, the canvas never renders, and the pane stays blank until another tab
switch forces an expose. Software GL renders directly, so it never showed there.

force_repaint() defers past the show, then Refresh() + Update() for a
synchronous paint. Called from on_tab_shown().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-14 23:26:58 +02:00
Tommaso BianchiandClaude Opus 4.8 343a0439f1 Import a triangle mesh as an editable B-rep body (mesh2step port)
Opening an STL/OBJ in the Design pane now rebuilds it into a real OCCT B-rep
solid that the face/edge feature tools can operate on, instead of a print mesh.

GeometryEngine::mesh_to_brep is a native C++ port of mesh2step
(github.com/tommasobbianchi/mesh2step): vertices and edges are shared across
triangles at construction time (vertex cache by deduped index, edge cache by
unordered index pair), so no BRepBuilderAPI_Sewing pass is needed to rebuild the
topology afterwards, and watertightness falls out of the edge-usage counts for
free. An open mesh is returned as a shell and reported as such — never dressed up
as a fake solid.

It runs in-process on the OCCT kernel libslic3r already links, so no STEP file is
written or re-read. That is not an optimisation but the whole point: a faceted
STEP of a 62k-triangle mesh is ~149 MB and OCCT's STEPControl_Reader takes >300 s
to parse it back, so routing this through a file would hang the GUI.

Coplanar neighbours are merged (ShapeUpgrade_UnifySameDomain, 5° default) so the
body arrives with pickable CAD faces rather than one face per triangle — on the
20,656-triangle test part that is 20,614 faces down to 4,784. Without it the
import is technically a solid but nothing you can meaningfully fillet or extrude.

- Design pane: "Import mesh" button + Shift+M; warns above 50k triangles.
- MCP: import_mesh {path, tolerance, merge_angle_deg}, returning the full
  conversion stats so a caller can tell an honest solid from an open shell.
- Catch2: cube round-trip (exact volume, 12 faceted faces, 6 after merge), open
  mesh stays a shell, and the scale-independent sliver rule that a naive
  area < tolerance^2 test would get wrong.

Verified end-to-end on the real 20,656-triangle ir3v2 hotend STL: reproduces
mesh2step's Python run exactly (20,614 kept, 42 degenerate, 0 boundary edges,
2 non-manifold edges, not watertight) and the resulting body's bbox matches the
one FreeCAD reports for the same part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-11 07:02:33 +02:00
Tommaso BianchiandClaude Opus 4.8 c618965a4c Add -DSLIC3R_CAD compile-time gate for the Design/CAD tab
Gate the entire parametric Design/CAD subsystem behind a single CMake
option so the fork can be built with or without it. With SLIC3R_CAD OFF
the build is behaviour-neutral against upstream OrcaSlicer; this is the
"parallel build" for upstream integration discussion.

Gated surface:
- option(SLIC3R_CAD) + add_definitions(-DSLIC3R_CAD)
- deps/OCCT/OCCT.cmake: BUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD}
  (OFF matches upstream OCCT exactly; ON adds only TKFillet + TKOffset)
- TKFillet/TKOffset link, add_subdirectory(slvs) + libslvs
- CAD kernel + GUI sources, GLGizmoPrimitive (needs GeometryEngine),
  CAD Catch2 tests
- 11 upstream C++ hook sites (GLCanvas3D, MainFrame, GLGizmosManager)
- TabPosition and gizmo EType enums switched to implicit numbering so
  OFF reproduces upstream indices exactly

Verified on behemoth both ways: OFF and ON link snapmaker-orca +
libslic3r_tests (exit 0); ON shows the Design tab and passes 8 [design]
+ 1 [Deviation] tests, OFF omits them and shows upstream's tab layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 18:12:53 +02:00
Tommaso BianchiandClaude Opus 4.8 546cef5f42 Windows packaging: assert every linked OCCT toolkit has a DLL to ship
The previous guard only caught an empty glob. That is the wrong invariant.
The glob ships whatever the deps prefix holds, which is not the same as what
libslic3r links.

This fork sets BUILD_MODULE_ModelingAlgorithms=ON; upstream OrcaSlicer sets it
OFF. Upstream's DataExchange module pulls in most ModelingAlgorithms toolkits
transitively, but not TKFillet and TKOffset -- those two exist only because we
turned the module on. So a source build against a deps tree carried over from
upstream has 40 of the 42 OCCT DLLs. The glob copies all 40, the build
succeeds, and the slicer dies at launch with "error 126 (dependency not
found)". Reported by SoftFever, who named exactly those two DLLs.

CI is unaffected: the deps cache key is hashFiles('deps/**'), so flipping the
OCCT flag invalidated it and every shipped artifact has all 42.

Publish OCCT_LIBS from libslic3r as the single source of truth and check each
toolkit has a DLL, so the list can never drift from what we link. Verified
against a simulated stale prefix: the configure fails naming exactly
TKFillet.dll;TKOffset.dll, and passes when both are present.

Windows-only: every call site of the copy function is inside if (WIN32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 08:48:11 +02:00
Tommaso BianchiandClaude Opus 4.8 d568e89c34 Windows packaging: fail the configure when the OCCT glob matches nothing
The glob that replaced the hand-maintained OCCT DLL list traded a loud
failure for a silent one. The old code named each DLL explicitly, so an
unpopulated deps prefix made file(COPY) error out at configure time. A glob
that matches nothing instead yields an empty list, copies nothing, and leaves
the install manifest without a single TK*.dll -- producing a package that dies
at launch with "error 126 (dependency not found)".

This only bites source builds whose CMAKE_PREFIX_PATH lacks bin/occt (OCCT's
install layout varies by version); CI populates it, so every shipped artifact
is intact. Restore the loudness rather than ship a slicer without OpenCASCADE.

Windows-only: every call site of the copy function is inside if (WIN32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 08:39:56 +02:00
Tommaso BianchiandClaude Opus 4.8 3d9b36f108 Add Romanian (ro_RO) localization
Romanian was absent from this fork and from upstream, so there was no
catalog to reuse. Machine-translated with a local qwen3.6 model against the
union of both forks' .pot files; 99.0% coverage (5472 singular + 9 plural).
Header credits it as machine translation pending native review.

Registering wxLANGUAGE_ROMANIAN in supported_languages[] is what actually
exposes the language: the Preferences dropdown filters the installed
dictionaries against that allowlist, so a valid .mo alone would ship a
language nobody could select.

Translations that failed to preserve their printf/boost placeholders were
left empty rather than shipped, since msgfmt --check-format is fatal in
run_gettext.sh and would break the build on all three platforms. Those 57
strings fall back to English. Verified: run_gettext.sh exits 0 and the
compiled catalog has 0 placeholder mismatches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-10 07:14:13 +02:00
ExPikaPaka 3514249197 Removed files that were accidently added 2026-07-09 08:40:37 +02:00
ExPikaPaka 7f2598d0d6 POC 2026-07-08 08:50:47 +02:00
Tommaso BianchiandClaude Opus 4.8 5e272d4f5c CI: build on push to cad-mainline
The push trigger listed main/release/belt-printer but not our working branch
cad-mainline, so pushes to it never auto-built. Add cad-mainline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-03 07:06:18 +02:00
Tommaso BianchiandClaude Opus 4.8 493acb6059 Windows packaging: bundle ALL OCCT DLLs via glob (fix launch error 126)
The portable/installer hardcoded an OCCT DLL list that drifted from what
libslic3r actually links (OCCT_LIBS) + their transitive OCCT deps. The
CAD build links TKFillet/TKOffset/TKBool (and pulls TKFeat/TKBin*/TKIGES/
TKRWMesh/TKSTL/TKVRML/TKXDEIGES/TKXml* transitively) which were absent
from the shipped zip -> OrcaSlicer.dll failed at launch with error 126
(dependency not found). Replace both hardcoded lists (the file(COPY) and
the install manifest) with a glob over the built occt/ dir so the package
can never drift from the build again. Windows-only: OCCT is Shared solely
on Windows (deps/OCCT/OCCT.cmake); macOS/Linux static-link it. Reported by Marc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-03 00:52:05 +02:00
Tommaso BianchiandClaude Opus 4.8 f8175fc9a4 Design: fix hole placement (top-face default) + invisible internal thread
Hole: with no face explicitly picked, the tool fell back to the XY datum at
z=0 (the model's underside), so placing a hole from a top view read parallax-
shifted. Default to the solid's top face (top_face_index_of) so the footprint
sits on the surface being viewed; the XY/XZ/YZ dropdown still overrides.

Internal thread: the bore was re-cut at the nominal radius, which coincides
with an existing hole's wall — the coincident faces fouled the groove boolean
so it removed ~nothing (invisible thread). Cut the bore at the minor diameter
(radius - depth) instead: strictly inside any existing wall, leaving it clean
for the groove; on solid stock it forms the tap-drill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-02 20:18:41 +02:00
Tommaso BianchiandClaude Opus 4.8 2a9a433127 Design: rename 'near' lambda to 'is_near' (MSVC windows.h macro clash)
Windows minwindef.h defines legacy 'near'/'far' as empty macros, so MSVC
mangled `auto near = ...` (C2513) and every near(...) call (C2679/C2678 on
Vec2d). gcc/clang were unaffected, so only the Windows build failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-02 14:04:55 +02:00
Tommaso BianchiandClaude Opus 4.8 3ba6feb7a7 tests: fix hex-escape-out-of-range in CAD recipe 3mf test
gcc reads "\x10cad..." as one escape (c/a/d are hex digits → 0x10CAD > 255).
Split the string literal so the \x10 escape terminates. clang let it slide;
gcc (Linux/Windows CI) errored and blocked the OrcaSlicer build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-02 13:06:28 +02:00
Tommaso BianchiandClaude Opus 4.8 1673c2c760 Design tab: keyboard shortcuts, plane/axis toggles, section view
- Keyboard shortcuts (Onshape-style, three scoped layers): feature tools on
  Shift+letter, 2D sketch tools on single letters (in-sketch), view/nav on
  single letters (out-of-sketch). Dispatched via the DesignPanel CHAR_HOOK.
- View toggles: P = origin planes, A = world axis triad (render_view_helpers).
- Section view (non-destructive): a single horizontal clip that hides half the
  model to inspect inside, showing only the solid remaining half (no ghost).
  Left-panel "Section View" button or X toggles it; PageUp/PageDown move the
  plane; "Flip Section" button or F shows the opposite half. Never a body/Cut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-01 23:19:18 +02:00
Tommaso BianchiandClaude Opus 4.8 de16d566b2 Design tab: value-label titles + 5 sketch/UX fixes
Six fixes to the Design tab, all live-verified on :10:
- Datum-plane re-pick: wxEVT_CHOICE on m_draw_plane re-planes the live sketch
- Delete-feature dismisses its lingering settings card (on_delete_feature)
- Revert rotation-orbit regression (no feed_bodies/reload on close_tool)
- Sketch undo/delete via focus-independent CHAR_HOOK (Ctrl+Z/Y, Delete)
- Fix undo hang: reset_autoedit() clears dangling auto-edit sequence
- Titled value fields: each inline dim field shows its role (Width, Height,
  Radius, Angle, Length, Side, Distance, Major, Minor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-01 19:19:23 +02:00
Tommaso BianchiandClaude Opus 4.8 5a005d4728 Design: in-canvas gizmos for Draft/Cut + operand highlight for Boolean/Sweep/Loft
Closes the gizmo-parity gap (bd snaporca-4h5): the five solid features that were
card-only now give in-canvas feedback like their siblings.

Draft — angle-arc drag gizmo (clone of the Revolve gizmo): once a side face is
picked, a cyan arc anchored at the face centroid shows the taper angle; drag the
tip or type the angle, the live ghost tapers with it. Axis = world +Z (the neutral
pull direction), clamped [-89, 89].

Cut — plane offset-arrow + cutting-plane rectangle (clone of the Shell arrow, adds
a wire rectangle in the cut plane sized to the target body bbox). The arrow drags
the signed offset along the plane normal; the rectangle rides at the cut position;
the ghost splits live. (Also covers bd snaporca-1gh / snaporca-mmr.)

Boolean / Sweep / Loft — operand highlighting (new by-index highlight infra):
- Boolean tints the target body teal-green and the tool body orange (per-index
  body tint added to the DesignCanvas GLVolume colour loop + set_operand_bodies).
- Sweep tints the profile sketch cyan and the path sketch magenta.
- Loft tints every selected profile sketch green.
  Sketch tints reuse the DisplaySketch overlay via a feature-index -> colour map
  (sketch_hl_color); DisplaySketch struct unchanged. All self-gate by active tool
  and clear on close_tool.

Wiring mirrors the existing gizmo pattern 1:1 (m_*_active / render_* / set_* /
clear_* / update_* in refresh_preview / DesignCanvas passthroughs / render dispatch
+ on_mouse drag branch). Both forks; DesignSketchTool.{cpp,hpp} + DesignCanvas.hpp
+ DesignPanel.hpp byte-identical across forks. Built clean on both. Draft, Cut and
Boolean highlight live-verified on :10; Sweep/Loft sketch tint is code-complete and
build-clean (visual check pending — needs hand-drawn profile/path sketches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-01 10:19:33 +02:00
Tommaso BianchiandClaude Opus 4.8 8cbf5b393b Design: Measure-style dimension labels (+ projection fix), New Design, tree auto-fit, i18n pin, UX
Sketch dimension labels — now identical to the Prepare/Preview Measure gizmo:
- draw_text repurposed to draw_dim_label: white ImGui text in a translucent-white box,
  mirroring GLGizmoMeasure::render_dimensioning exactly (push_common_window_style sets the
  text colour, BringWindowToDisplayFront, imgui_internal.h).
- ROOT-CAUSE FIX: world_to_screen_px multiplied two Eigen Transform3d objects
  ((proj * view).matrix()); a projection is not affine so Eigen mangled it -> garbage screen
  coords, so labels never appeared. Now proj.matrix() * view.matrix() like Measure. (That
  helper was previously [[maybe_unused]] dead code, never exercised.)
- Leaders: offset clear of the sketch line (no longer coincident with the geometry), single
  point-to-point dimension line + arrows, neutral colour, width 0.6 -> 0.2.
- dim_text appends mm/in on linear dims (angles keep the degree sign).

New Design + delete:
- New "New Design" button wipes the whole document (confirm dialog) — the clear-all the
  per-row Delete can't give. CadDocument::clear() now also clears bodies + display_body_meshes
  (it left them stale, so solids lingered after a clear).
- on_delete_feature: a Body-row selection now shows a helpful hint (bodies are recomputed
  results with no directly-removable feature) instead of silently doing nothing.

Feature tree: auto-fits its content (refresh_tree clamps height 1..9 rows, scrolls past),
instead of a fixed 140px block.

i18n (Design tab pinned English, per the UX contract):
- Restore the lost #undef _L / #define _L(s) wxString::FromUTF8(s) override atop DesignPanel.cpp;
  wrap all ~54 dropdown options in _L so the single lever governs them. feature_type_name left
  untranslated (machine-facing MCP JSON).

UX: per-card Value Confirm/Cancel buttons removed — the single ribbon action bar owns value
confirm/cancel via an m_value_cont guard in tool_confirm/tool_cancel. "needs a body" status
messages unified.

Both forks; DesignSketchTool.{cpp,hpp} + CadDocument.cpp byte-identical across forks. Built
clean; New Design / feature-delete / rotation / tree auto-fit live-verified on :10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 23:21:04 +02:00
Tommaso BianchiandClaude Opus 4.8 6e11cfc49b Design: Measure-style dimension labels + restore English-only pin + UX consistency
Dimensions (uniform with Prepare/Preview):
- Repurpose DesignSketchTool::draw_text -> new draw_dim_label that renders each
  sketch dimension as the exact Prepare "Measure" gizmo label: white ImGui text
  in a translucent-white box, positioned via the existing world_to_screen_px
  projection inside the active ImGui frame. All 29 label call sites convert with
  no churn; the bespoke Hershey vector font is retired.
- dim_text appends mm/in on linear dims (angles keep the degree sign).
- Placed-dimension leaders simplified to a single point-to-point line + arrows in
  a neutral colour (no extension lines), matching the Measure look.

i18n (Design tab pinned English, per the UX contract):
- Restore the lost "#undef _L / #define _L(s) wxString::FromUTF8(s)" override atop
  DesignPanel.cpp so one lever de-translates the whole tab, ending the half-EN/IT
  state. Wrap all ~54 dropdown options in _L so the single lever governs them.
- feature_type_name left untranslated (it feeds the MCP JSON, machine-facing).

UX consistency:
- Remove the per-card Confirm/Cancel buttons from the Value card; the single ribbon
  action bar now owns value confirm/cancel via an m_value_cont guard in
  tool_confirm/tool_cancel (one confirm surface, per contract).
- Unify the eight divergent "needs a body" status messages to one template.

Both forks; DesignSketchTool.{cpp,hpp} byte-identical across forks. Built clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 22:01:54 +02:00
Tommaso BianchiandClaude Opus 4.8 f341ee2677 MCP bridge: expose all 16 methods live + fix optional-param required-ness
- snaporca_mcp_bridge.py: _param_schema maps array/object types + carries
  per-param description; _FALLBACK_TOOLS expanded slice-1 -> full 16-method
  surface (socket-down at client startup still shows the whole toolset)
- describe_tools: give genuinely-optional params a default (body -1, profile [],
  shell.face -1, extrude.distance2 0 / up_to_face -1) so the bridge no longer
  marks them required (draft.face/edge/path/a/b/reference stay required)

Product code byte-identical to snaporca (md5 match). orca_cad slicer builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 18:05:54 +02:00
Tommaso BianchiandClaude Opus 4.8 b93b9d558c Mirror [Deviation] surface_deviation test into test_caddocument.cpp
orca_cad's test_geometry.cpp is upstream Catch2 v3 with no CAD suite, so the
surface_deviation check lives alongside the CAD tests here. Built + ran green
in orca_cad's kernel-test docker: All tests passed (4 assertions). Both forks
now carry the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 17:55:06 +02:00
Tommaso BianchiandClaude Opus 4.8 94cde2af21 MCP slice 5: body-targeted fillet/chamfer, ordered slice contours, surface-deviation validate, widen Build
- fillet/chamfer accept optional `body` (sets target_body; edge id resolved against THAT body)
- slice_body chains section segments into ordered contours, each flagged closed/open
- validate_against adds surface_deviation (one-sided Hausdorff via BRepExtrema_DistShapeShape)
- new actions pattern/shell/draft; extrude gains end=blind|symmetric|two_sided|through_all|up_to_face + taper/flip/distance2
- GeometryEngine::surface_deviation helper (byte-identical to snaporca; Catch2 [Deviation] test lives in snaporca, whose test_geometry.cpp carries the CAD suite)

Product code byte-identical to snaporca (md5 match). Both forks build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 17:52:08 +02:00
Tommaso BianchiandClaude Opus 4.8 d2f97a752b MCP slice 4: widen Build (revolve, fillet, chamfer, hole, boolean) + profile
Reconstruction is no longer box-only. Five new actions, all thin
wrappers over existing CadDocument::add_* (the same kernel the GUI
calls): revolve (sketch -> add_revolve), fillet/chamfer targeted at a
measured edge id from query_topology, hole (add_hole), and boolean
(union/subtract/intersect over two bodies). extrude and revolve also
accept an optional closed profile=[[x,y],...] -> add_sketch_profile,
the Measure->Build bridge: feed a measured/sliced contour straight back.

Shared helpers plane_from / bool_from / profile_from. Each action is
transactional (checkpoint -> add -> recompute -> undo on failure ->
refresh) and returns post-state. Live-verified: fillet, chamfer (clean
edge), hole, profile-extrude (L), revolve (ring), boolean union (3->2
bodies). MCP method catalogue now 13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 16:45:01 +02:00
Tommaso BianchiandClaude Opus 4.8 8db9544a1a MCP slice 3: import_step + validate_against (close the RE loop)
Add the Build-input and Validate ends of the reverse-engineering loop.
import_step loads a STEP as native B-rep bodies (the reference part to
measure), reusing the GUI Import path (read_step_solids -> Import
features -> recompute). validate_against compares a body to a reference
({step:path} | {body:id}) and reports volume delta %, bbox delta, and
centroid offset via OCCT GProp + Bnd_Box — the RE skill's actual
acceptance metric (the "scarto %"); surface-deviation heat-map is the
upgrade path.

With this the full Understand -> Measure -> Build -> Validate loop is
live: 8 methods (describe_tools/describe_scene/query_topology/measure/
slice_body/import_step/validate_against/extrude). Verified: import a STEP
then validate body vs same STEP = 0.0%% delta / 0.0mm offset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:52:20 +02:00
Tommaso BianchiandClaude Opus 4.8 5b46b1ed1f MCP slice 2 (Measure): query_topology, measure, slice_body
The "evidence" half of the reverse-engineering loop — read-only
perception that turns a body into measured numbers. query_topology
lists faces (centroid/normal, cylinder radius+axis when round) and
edges (length, circle radius); measure returns distance (and angle when
both refs have a direction) between two {face|edge|point} refs;
slice_body cross-sections a body by a base plane at an offset and
returns world polylines (sections-as-evidence). All reuse GeometryEngine
topology accessors; slice_body adds one BRepAlgoAPI_Section call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:42:44 +02:00
Tommaso BianchiandClaude Opus 4.8 294bbccd0b Add stdio MCP bridge for the control socket
Zero-dependency Python stdio MCP server that exposes the app's control
socket as first-class MCP tools. Builds the tool list live from the
app's own describe_tools reply (introspection drives the schema), and
forwards tools/call to the Unix socket. Falls back to the slice-1 tool
set and reports a clear error when the app socket is down — never
crashes. Registered via a workspace .mcp.json (ssh stdio to the build
host); activates on the next session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:37:39 +02:00
Tommaso BianchiandClaude Opus 4.8 4f8a0d133f Add MCP control surface (slice 1): socket server + describe/extrude
Predispose the Design tab to run under an external MCP agent. New
McpControl.{cpp,hpp} embeds a line-delimited JSON-RPC 2.0 server over a
Unix domain socket, off unless env SNAPORCA_MCP is set. Requests are
marshalled onto the wx main thread and run through the SAME CadDocument
kernel the GUI uses, via two thin DesignPanel hooks (mcp_doc /
mcp_after_change) — no parallel engine.

Slice-1 methods: describe_tools (introspection -> the bridge builds tool
schemas), describe_scene (feature tree + per-body bounding boxes), and
extrude (centred rectangle sketch -> new solid). Every op returns its
full post-state. POSIX-only; Windows compiles to a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:32:51 +02:00
Tommaso BianchiandClaude Opus 4.8 5ecb319dee Reject .f3d import with a STEP redirect hint
Fusion 360 .f3d uses Autodesk's closed ShapeManager kernel — no offline
reader exists and it is input-only even in Autodesk's own cloud API, so
native import/export is infeasible. Intercept .f3d at priv::load_files
(the single funnel for drag-drop and File > Open) and show a dialog
pointing the user to export STEP from Fusion, then load any other files
in the batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 15:00:05 +02:00
Tommaso BianchiandClaude Opus 4.8 aa2907f730 Design: STEP export of the CAD model
Add an "Export STEP…" button to the Design panel that writes every body to a
.step file as native B-rep (not mesh).

- CadDocument::export_step: compound all bodies (applying their per-body Move
  display transform so the STEP matches what Commit ships) and write via OCCT
  STEPControl_Writer (AsIs). Full error handling incl. OCCT Standard_Failure.
- DesignPanel::on_export_step: bake any open preview, wxFileDialog save, export
  at the displayed body positions, status feedback.

Kernel write path verified with a standalone OCCT box->STEP->readback check
(1 solid, non-null) against the same OCCT build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 08:21:03 +02:00
Tommaso BianchiandClaude Opus 4.8 30713f0f23 Design: re-editable Boolean features
Boolean features can now be re-edited from the feature tree (op, target/tool
body, keep-tool, fuzzy tolerance), funnelling through the same replace_feature
path as every other editable feature.

- on_edit_feature: route CadFeatureType::Boolean to the Boolean card.
- load_feature_into_dialog: populate the Boolean card from the saved feature.
- populate_body_choices(as_of_feature): when re-editing, list the bodies as they
  existed just before the boolean (replay the recipe truncated to that slot) so a
  consumed tool body still appears and the saved target/tool selections round-trip
  instead of collapsing to one entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 07:56:30 +02:00
Tommaso BianchiandClaude Opus 4.8 71b7a73e86 Design tab: reference planes at bed centre, slot dims, hole cube handle, real threads
Port of the snaporca CAD work to the mainline fork.

- Onshape default planes (XY/XZ/YZ) at the bed centre (transparent, labelled);
  modeling origin unified to the bed centre (CadDocument::modeling_origin);
  world-axis triad moved to the bed centre on the Design canvas only.
- Datum plane: clickable ghost-plane base pick + draggable offset arrow.
- Slot: dims reassessed to inter-centre distance / radius / angle; fixed the
  duplicate cap-arc radius quote.
- Hole: 3D cube move-handle on the face; binds to the face on the first click;
  decluttered side-distance construction lines.
- Thread: derive the M spec (diameter/pitch/depth) from a picked cylindrical
  surface or circular edge (GeometryEngine::circle_of_edge); fuse the helical
  ridge onto the existing body; MakePipeShell fixed-binormal sweep (uniform, no
  twist) + self-intersection/param guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-30 00:24:39 +02:00
Tommaso BianchiandClaude Opus 4.8 852804450c Design re-edit: seed build_candidate from the edited feature (fix new-box bug)
Mirror of snaporca-cad 75045ff. Re-editing an Extrude spawned a NEW misplaced
box: build_candidate() rebuilt the feature from live tool state, but GUI
extrudes store their profile as `entities` (sketch_ref = -1) which the edit
card never restores, so the candidate had an empty profile and replace_feature
swapped in a degenerate extrude. Fix: when editing, seed the candidate from the
feature being edited and skip add-time structural re-derivation (profile source,
up-to-face, target body); the card still overrides scalar params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 07:49:32 +02:00
Tommaso BianchiandClaude Opus 4.8 b355d04d3a CAD re-edit: mid-timeline rebuild tests + non-silent Edit fallback
Mirror of snaporca-cad a82cb0d. snaporca-88g — three [CadDocument] tests
proving recompute() replays the whole timeline, so editing any feature (not
just the last) rebuilds downstream: mid-timeline extrude edit -> fillet
rebuilds; first-feature sketch edit propagates the chain; the same survives
serialize_recipe()/deserialize_recipe(); a sketch->extrude->hole->chamfer
chain rebuilds hole+chamfer on a mid-edited extrude. All 3 pass (34 assertions,
Catch2 v3). on_edit_feature edits the tree-selected feature at any position
(13/16 types); Import/Boolean/Cut get a status message instead of a silent
no-op (full dialogs = follow-up snaporca-nu9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 06:30:01 +02:00
Tommaso BianchiandClaude Opus 4.8 a121e30a13 Design canvas: gate direct-render hacks behind sw-GL detection
Mirror of snaporca-cad f3b665b. DesignCanvas had ~38 call-sites doing
`set_as_dirty(); render()` directly, tagged "llvmpipe: force repaint" —
software-GL workarounds from the headless :10 test box. On hardware GL this
bypasses the dirty/refresh cycle and burns frames outside the paint event.
Funnel all 38 through a new request_repaint() helper that branches on the
cached GL renderer string: hardware GL gets set_as_dirty()+wxGLCanvas::Refresh()
(render() runs inside the paint cycle), software GL keeps the direct render(),
undetected backend takes the safe direct path. Behaviour on the llvmpipe :10
box is byte-identical; hardware GL invalidates the canonical editor way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 06:11:22 +02:00
Tommaso BianchiandClaude Opus 4.8 2929f66881 Design tab i18n: remove _L override, route strings through gettext
Mirror of snaporca-cad 0f53a09. DesignPanel.cpp pinned every _L() to
wxString::FromUTF8 via a TU-local #define, hardcoding English and bypassing
the gettext catalog — a hard review-blocker for upstream (OrcaSlicer ships
~20 locales). Remove the override so the 463 _L("...") call-sites route
through the real Slic3r::GUI::I18N::translate (wxGetTranslation). All args
are string literals; untranslated strings fall back to the source msgid, so
English is byte-identical while other locales now translate when .po entries
exist. xgettext scans source text, so .pot extraction is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-29 06:04:42 +02:00
Tommaso BianchiandClaude Opus 4.8 36b2bd8bfc CAD persistence: implement recipe round-trip on the BBS 3mf backend (mirror)
Mirror of snaporca-cad ad822bf. The earlier mirror (2c0140afb9) implemented
persistence in the PrusaSlicer 3mf.cpp, but the GUI saves/loads projects via the
BBS-native backend (store_bbs_3mf / load_bbs_3mf), so the recipe was never
written to nor read from GUI-saved projects.

- bbs_3mf.cpp: BBS_CAD_RECIPE_FILE = "Metadata/SnapOrca_cad.bin"; writer
  _add_cad_recipe_file_to_archive (called after layer-height in store_bbs_3mf);
  reader branch in _load_model_from_file's metadata dispatch loop (iterate +
  iequals on m_filename — mz_zip_reader_locate_file does not work on these
  archives).
- Plater.cpp: load_files carries the Model-level cad_recipe onto q->model().
- test_3mf.cpp: [3mf] test asserting store_bbs_3mf embeds the recipe entry
  byte-for-byte (read back via miniz, Catch2 v3).

Verified on behemoth: libslic3r_tests + the new [3mf] BBS test pass; orca-slicer
GUI links clean. Round-trip verified live on snaporca-cad (identical code path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 21:53:36 +02:00
Tommaso BianchiandClaude Opus 4.8 e985d95dfb Add missing color_palette.svg (Design colour-tool icon)
DesignPanel's per-body colour button references icon "color_palette" but the
asset was never ported with the CAD subsystem. On mainline OrcaSlicer the
missing bitmap throws during MainFrame construction → segfault at startup
(create_scaled_bitmap "Could not load bitmap: color_palette"). Copied from
snaporca-cad resources; runtime-loaded, no rebuild needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 19:37:50 +02:00
Tommaso BianchiandClaude Opus 4.8 2c0140afb9 Mirror CAD persistence (K1+K2+G1) from snaporca-cad onto mainline OrcaSlicer
Dual-fork mandate: port the parametric-recipe 3MF persistence from
snaporca-cad commits ed19eac+147e1c4 so a saved project reopens with the
editable CAD feature tree, not just the baked mesh.

Shared kernel/format/GUI (identical to snaporca-cad):
- CadDocument serialize_recipe/deserialize_recipe (cereal BinaryArchive,
  versioned) + CadFeature split save/load + imported_solid<->BRep string.
- Model::cad_recipe carried through 3MF zip entry Metadata/SnapOrca_cad.bin
  (writer + binary-verbatim reader branch).
- DesignPanel on_commit() stamps the recipe; on_tab_shown() rehydrates a
  loaded project via load_recipe() (deserialize -> feed_bodies + refresh_tree).

Mainline-only adapters (no snaporca-cad counterpart — Catch2 v3 vs v2):
- tests/libslic3r/test_caddocument.cpp: <catch2/catch_all.hpp> +
  `using Catch::Approx;` (v3 scopes Approx under Catch::).
- tests/libslic3r/CMakeLists.txt: register test_caddocument.cpp (the
  original CAD port had left it out of the test build).

Verified on behemoth (snaporca-deps toolchain): libslic3r_tests clean;
[CadDocument] 17/18 (only the pre-existing tangent-to-circle SIGABRT fails,
identical to snaporca-tkz); K1 serialize round-trip + version-reject pass
(13 assertions); new [3mf] "CAD recipe blob survives a 3mf save/load cycle"
passes byte-for-byte; orca-slicer GUI links clean (186/186, DesignPanel.cpp
compiled). Interactive :10 click-through pending (no Design-tab automation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 17:05:44 +02:00
Tommaso BianchiandClaude Opus 4.8 c22351f63a Fix GUI build on mainline OrcaSlicer: DropDown Item API + Bed3D::set_shape
Two Snapmaker->mainline API divergences surfaced once the ported CAD
subsystem compiled (the whole libslic3r CAD kernel + vendored slvs solver
built clean):

- DesignPanel: Snapmaker's DropDown took 3 parallel vectors
  (texts/tips/icons); mainline's is Item-based (std::vector<DropDown::Item>).
  Collapsed both flyout structs (FeatFlyout, ToolFlyout) to one Item vector.
  Event/selection path unchanged (both emit wxEVT_COMBOBOX + SetInt).
- DesignCanvas: mainline Bed3D::set_shape added extruder_areas/heights
  params before custom_model; pass empty vectors.

Build verified: links clean (orca-slicer, 280/280). Runtime reaches the GTK
event loop equivalently to the proven-good snaporca binary; full visual
Design-tab verification pending an interactive :10/x11vnc session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 15:14:15 +02:00
Tommaso BianchiandClaude Opus 4.8 0f4060c0a9 Orca-Cad: port SnapOrca Design (parametric CAD tab) onto mainline OrcaSlicer
Grafts the sketch-first CAD environment from snaporca-cad onto the mainline
OrcaSlicer/OrcaSlicer base (vs snaporca's Snapmaker/OrcaSlicer base):
- 133 new files: CadDocument/SketchEngine/GeometryEngine/SketchConstraints/
  SketchSolver/SketchInference/ThreadStandards + vendored libslvs solver;
  DesignPanel/DesignCanvas/DesignSketchTool/SketchInlineEditor GUI; GLGizmo
  Primitive/Sketch; 75 design icons; Catch2 tests.
- Integration hooks ported to mainline's diverged versions: Design tab in
  MainFrame, embedded design viewport + sketch overlay + per-canvas chrome
  suppression in GLCanvas3D/PartPlate, gizmo registration, Plater accessors,
  CMake wiring (libslvs subdir, CAD sources, OCCT ModelingAlgorithms=ON).

Structural integration complete; build verification pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-06-28 12:40:38 +02:00
9809 changed files with 634395 additions and 393538 deletions
Symlink
+1
View File
@@ -0,0 +1 @@
.claude
@@ -0,0 +1,86 @@
# DELEGATION SPECIFICATION: HARNESS-DRIVEN VALIDATION LOOP
slug: sketch-focus-arbiter · repo: /home/tommaso/projects/apps/orca_cad · branch: cad-mainline
## 1. TARGET GOAL
**Functional Objective.** Keyboard input in the Design tab is routed by WHAT THE KEY IS, not by
which widget the window manager decided to focus. Adopted from FreeCAD's
`DrawSketchKeyboardManager::detectKeyboardEventHandlingMode`
(src/Mod/Sketcher/Gui/DrawSketchKeyboardManager.cpp), which never queries focus at all:
- digit, `-`, `.`, `,` -> the open value field
- Backspace / Delete -> the open value field (when one is open)
- Enter / Return / Tab -> commit the field, control returns to the view
- a letter -> the sketch-tool shortcut map, as today
- Esc -> the existing CadLevel LIFO (DesignInteraction.hpp), unchanged
- anything else -> sticky: whoever had it keeps it
Observable postcondition: for EVERY sketch tool that opens a value field, a value typed
immediately after the field appears — with NO click into the field — is the value committed.
Today the prefill is committed instead whenever the WM withholds focus.
**Target Files / Scope (writable).**
src/slic3r/GUI/CAD/DesignPanel.cpp (the arbiter lives in the existing wxEVT_CHAR_HOOK)
src/slic3r/GUI/CAD/DesignCanvas.cpp/.hpp (forwarding entry points only)
src/slic3r/GUI/CAD/SketchInlineEditor.cpp/.hpp (accept a programmatically delivered character)
scripts/CAD/check-gui-click-edit.py (F2P oracle — authoring exception, see §4)
Everything else read-only. No dependency additions, no reformatting.
**Open Bindings.**
- The in-canvas ImGui field on wip/in-canvas-value-field is NOT in scope. Default: the arbiter
is implemented against the CURRENT wxFrame field on cad-mainline, because content-based
routing makes the window's focus irrelevant either way. If it later moves in-canvas the
arbiter is unchanged.
- Tools whose field is opened by a toolbar button rather than a gesture (Constrain path) are
covered by the same arbiter but are not in the F2P tool list. Default: assert them in P2P only.
## 2. HARNESS ENVIRONMENT & GROUND TRUTH
The rig container `orcacad-gui` on nativedev IS the harness. Xvfb `:11` + openbox, the app under
test, `xdotool` for synthetic input, and an MCP socket at `/tmp/mcp.sock` that reports sketch
state as JSON. It is a closed loop: drive input, read geometry back, assert. No window manager
politics, no human.
Harness interface (ordered; each slot one invocation, one exit code):
S1 sync docker cp <file> orcacad-gui:/OrcaSlicer/<path>
S2 build docker exec orcacad-gui ninja -C /OrcaSlicer/build orca-slicer
S3 restart docker exec orcacad-gui /OrcaSlicer/scripts/CAD/start-headless-gui.sh
S4 F2P docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-click-edit.py --attach
S5 P2P docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-sketching.py
**F2P.** `scripts/CAD/check-gui-click-edit.py`. For each of Line, Rectangle, Circle, Slot,
Polygon, Ellipse and Rounded rectangle: arm the tool, draw it, and type a value that differs
from the prefill WITHOUT clicking the field. Assert the committed value equals the typed value.
The ladder must FAIL against unmodified cad-mainline — that is what proves it asserts something.
**P2P.** `scripts/CAD/check-gui-sketching.py`, the existing gesture ladder, minus anything red at
baseline. NOTE: it calls `focus_field()` — one click into the field before typing — which is the
workaround this whole task removes. It stays green as a regression guard; it is NOT evidence.
**Test Integrity Constraint.** `focus_field()` in check-gui-sketching.py must NOT be deleted to
make things pass, and check-gui-click-edit.py must NOT be weakened. Either invalidates the run.
## 3. VERIFICATION COMMANDS
1. Static: `docker exec orcacad-gui ninja -C /OrcaSlicer/build orca-slicer` (warnings delta only;
this repo configures no linter — the compiler is the static gate. Absolute-zero is NOT the gate.)
2. Harness: `docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-click-edit.py --attach`
3. Regression: `docker exec -e DISPLAY=:11 orcacad-gui python3 /tmp/check-gui-sketching.py`
## 4. CONVERGENCE LOOP — ceiling 8 iterations
EDIT (scoped) -> EXECUTE S1..S5 -> PARSE the ladder's per-tool assertions and the [UX]/[KEYTRACE]
lines -> PATCH from the parsed cause. On ceiling without convergence: stop, report the last diff
and the unresolved failure set. Do not report success.
F2P authoring exception: check-gui-click-edit.py is writable, and must be shown RED against
unmodified source before any source edit counts.
## 5. TERMINATION CRITERIA
- [ ] S2 exits 0, and introduces no compiler warning absent from the baseline.
- [ ] S4 ALL_PASSED — every tool commits the typed value, no click into the field.
- [ ] S5 shows zero regressions against its recorded baseline pass count.
- [ ] F2P proven red without the fix (source stashed, ladder re-run, must FAIL).
## 6. GUARDRAILS
Zero-assumption: no completion claim without captured stdout and exit codes. Oracle supremacy:
the ladder's verdict overrides my judgement. Blast radius: §1 files only. Baseline obligation:
run §3 once before the first edit and record it.
+188
View File
@@ -0,0 +1,188 @@
---
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, 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
This skill describes how OrcaSlicer system profiles are drafted and shaped: the rules, equations and
patterns a profile follows. Use it to draft new profiles, modify existing ones, fix profile issues and
review profile changes.
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.
## References
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 |
| --- | --- |
| 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 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) |
| 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; 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 |
| 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) |
## Rules
1. **Bump the `version` of every bundle you change**, `OrcaFilamentLibrary.json` included when affected.
Increment the last component and carry `.99` into the third (`02.04.00.99` → `02.04.01.00`). The
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` writes the four
`*_list` arrays from the files on disk; `check` fails unless the index equals its output. Each index
entry's `name` must equal the file's `name`.
3. **Generate ids; never invent or copy them.** Keep existing ids during ordinary tuning. New presets
normally omit them until `generate-id`; bases carry no `setting_id`. BBL's own `setting_id`s and a wrongly
inherited `filament_id` need the explicit handling in [ids.md](references/ids.md).
4. **A name is an identity; preserve shipped selectable names.** Every reference (`inherits`,
`compatible_printers`, `default_*`, `printer_model`) is the exact, case-sensitive `name`. Renaming or
deleting a shipped selectable preset, or flipping its `instantiation` from `"true"` to `"false"`,
needs `renamed_from` (a `;`-separated string) on a selectable successor
([migration rules](references/vendor-bundle.md#renamed_from)); update in-tree references too.
5. **Values are strings or arrays of strings.** `"instantiation": "false"`, never `false`. A
`machine_model`'s `nozzle_diameter` is a `;`-separated string; a `machine`'s is an array. Custom
G-code is one string. Wrong types can abort loading of the bundle or of every vendor
([failure scopes](references/vendor-bundle.md#failure-scopes)).
6. **Unknown keys are dropped silently.** Confirm every new key exists in
`src/libslic3r/PrintConfig.cpp`; a key a neighbouring file writes is no evidence it exists. `check` rejects,
and `normalize` removes, known obsolete keys, but neither detects an arbitrary misspelling. A key
missing from the definitions may be a legacy name the loader still renames
(`tool_change_gcode` → `change_filament_gcode`) or whose value it rewrites (`DirectDrive` →
`Direct Drive`); check `PrintConfigDef::handle_legacy` before removing one, and write the current name
in new edits.
7. **Write overrides only.** Inherit the bundle's bases and restate just what differs; follow the
bundle's existing layering and style, except that a new filament prefers the library's bases. A
value that every preset of a group shares goes on the group's base
([shared bases](references/shared-bases.md)).
8. **One load error can discard a whole vendor bundle**: an unresolved `inherits`, a missing indexed
file, two selectable presets with one name, an unknown `printer_model` or `printer_variant`, a
filament with no resolvable `filament_id`, `nil` in a non-nullable key. `inherits` and `include`
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
1. **Inspect the diff and the neighbouring presets.** Read their `name`, parent chain and children:
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
formatting in existing files. Match filename case exactly and use
[cross-platform names](references/naming.md#filenames-and-paths).
2. **Author explicit metadata.** Set `type` yourself (`machine` vs `machine_model` especially), and use
`"from": "system"` and a string `instantiation` on config presets. Omit ids on new presets unless
[ids.md](references/ids.md) requires special handling; retain them on existing ones. Complete
compatibility, defaults, assets and any rename migration using the task reference.
3. **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
python3 scripts/orca_profile_tool.py normalize --vendor "<Vendor>"
python3 scripts/orca_profile_tool.py update-index --vendor "<Vendor>"
python3 scripts/orca_profile_tool.py generate-id --vendor "<Vendor>"
python3 scripts/orca_profile_tool.py check
```
Writing commands accept `--dry-run`. `normalize` changes content and can reformat entire files;
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
not use `normalize --force` for routine edits. An error in a bundle you did not touch predates your
change: confirm it on a clean checkout and report it rather than fixing it in the same change.
5. **Validate:**
```bash
./scripts/check_profile.sh --vendor "<Vendor>" # development loop
./scripts/check_profile.sh # full tree before the PR
```
On Windows use `scripts\check_profile.bat -Vendor "<Vendor>"` / `scripts\check_profile.bat`. Logs
land in a per-user cache dir ([validation.md](references/validation.md)). Id checks stay tree-wide
under `--vendor`, and filament-only bundles skip the default slice check. Under `--vendor` read only
`profile_tool` and `validate_slice`: the other three checks fail on library presets that name other
vendors' printers ([why](references/validation.md#the-five-checks)).
6. **Verify the changed behaviour.** Slice newly added non-default processes and filaments
[explicitly](references/validation.md#checking-a-copy-of-the-tree), and
[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 look
| Symptom | Start here |
| --- | --- |
| 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 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`, 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) |
| 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) |
| 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) |
| 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 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
When this skill and the checkout disagree, the checkout wins: `scripts/orca_profile_tool.py` for the
tool and its flags; `src/libslic3r/Preset*.cpp` for loading and compatibility;
`src/libslic3r/PrintConfig.cpp` for keys, types, nullable options, the variant key sets and legacy
handling; `src/dev-utils/OrcaSlicer_profile_validator.cpp` and `.github/workflows/check_profiles.yml`
for validation coverage; `docs/HLSD/filament_id.md` for filament identity. The wiki's
[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.
## 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.
@@ -0,0 +1,286 @@
# Filament profiles and OrcaFilamentLibrary
`OrcaFilamentLibrary` is the filament-only bundle the loader reads **first**, so any vendor's filament may
inherit a library preset by name. It is the only cross-bundle parent: vendor-to-vendor inheritance
always fails.
## Where a filament goes
| Contribution | Location |
| --- | --- |
| Generic material for all printers | `OrcaFilamentLibrary/filament/Generic <mat> @System.json` |
| A brand's product, all printers | `OrcaFilamentLibrary/filament/<Brand>/` |
| A brand's tune for one printer vendor | `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/` (recommended); `<PrinterVendor>/filament/<Brand>/` also works |
| A printer vendor's tune of a generic, or its own product | `<PrinterVendor>/filament/` |
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
filament brand should contribute to: `OrcaFilamentLibrary/filament/<Brand>/` is the brand's own folder,
while a printer vendor's folder belongs to that printer vendor.
Library layout: `filament/base/fdm_filament_*.json` material roots, root-level
`Generic <mat> @System.json` generics, and one subfolder per brand, which may nest printer-specific
tunes one level deeper. Adding a brand means adding a folder here; the folder name is a directory label
only, and `filament_vendor` inside the JSON is the real vendor string.
## The three-part shape
```jsonc
// OrcaFilamentLibrary/filament/Polymaker/Fiberon PA6-CF @base.json — the product root: identity + material values
{ "type": "filament", "name": "Fiberon PA6-CF @base", "from": "system",
"instantiation": "false", "inherits": "fdm_filament_pa",
"filament_id": "OFkOviHk", // minted here by generate-id; every child inherits it
"filament_vendor": ["Polymaker"], "filament_type": ["PA6-CF"], /* … */ }
// OrcaFilamentLibrary/filament/Polymaker/Fiberon PA6-CF @System.json — the selectable all-printer shim, 7 keys
{ "type": "filament", "name": "Fiberon PA6-CF @System", "from": "system",
"instantiation": "true", "inherits": "Fiberon PA6-CF @base",
"setting_id": "…", "compatible_printers": [] }
// 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"],
"compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", …] }
```
- `@base` is the convention for a root; a root is really `instantiation: "false"`. A base carries
**no** `setting_id`, no `compatible_printers` and no `filament_settings_id`. Only the `setting_id`
half is enforced; the other two are unchecked, so a neighbouring base that carries them is no model.
- Every `@System` shim must be `"instantiation": "true"`; one set to `"false"` would ship but could
never be selected, and no check catches it. The shim exists only for products in the library.
- `filament_cost`, `filament_density`, `filament_type` and `filament_vendor` belong on the root and
should not appear in a printer tune.
- A brand `@base` duplicated across bundles is legal (a vendor bundle may keep its own copy of a
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.
## Colour is a runtime property
`filament_id` identifies a product, not a colour; filament sync and AMS read the colour from the spool
at runtime. A product ships one all-printer preset and the colour is chosen at runtime, never a sibling
preset that differs only by colour. A material family (PLA vs PLA Matte vs PLA Silk) is a new product;
a colour is not. A printer tune keeps the product alias and does not multiply per colour either.
CI does not catch this (per-colour presets pass `check`), so it is a review call.
## The two most common contributions
**A printer vendor tuning a generic.** Keep the `Generic X` alias so it shadows the library preset on
your printers, inherit `Generic X @System`, declare **no** `filament_id` (inheriting the library's is
correct: the product really is the library's generic), and give it a non-empty `compatible_printers` in
its own file:
```jsonc
// <Vendor>/filament/Generic PETG @Acme One 0.4 nozzle.json
{ "type": "filament", "name": "Generic PETG @Acme One 0.4 nozzle", "from": "system",
"instantiation": "true", "inherits": "Generic PETG @System",
"filament_flow_ratio": ["0.95"], "filament_max_volumetric_speed": ["10"],
"compatible_printers": ["Acme One 0.4 nozzle"] }
```
**A printer vendor's own branded product.** Give it a `@base` root on a material base so `generate-id`
can mint the id, then one instantiated leaf per printer in the same bundle. Inheriting
`Generic X @System` directly gives the product the generic's id, which the tool cannot fix
([ids.md](ids.md#what-generate-id-does-and-does-not-fix)). No `@System` shim: that is only for a product
entering OrcaFilamentLibrary.
```jsonc
// <Vendor>/filament/Acme Aura PETG @base.json — instantiation false, no setting_id
{ "type": "filament", "name": "Acme Aura PETG @base", "from": "system",
"instantiation": "false", "inherits": "fdm_filament_pet",
"filament_vendor": ["Acme"], "filament_type": ["PETG"] } // filament_id minted here
// <Vendor>/filament/Acme Aura PETG @Acme One 0.4 nozzle.json
{ "type": "filament", "name": "Acme Aura PETG @Acme One 0.4 nozzle", "from": "system",
"instantiation": "true", "inherits": "Acme Aura PETG @base",
"filament_max_volumetric_speed": ["11"],
"compatible_printers": ["Acme One 0.4 nozzle"] }
```
Omit `filament_settings_id` from new presets: it is runtime bookkeeping the app rewrites to the preset
name.
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`
- **Library fallbacks** (`@System`): empty `[]` or absent, so they are offered on all printers except
where [alias shadowing](#alias-shadowing) supplies a printer-specific tune.
- **Library printer-specific tunes**: non-empty, listing exact printer **variant** names. These
supersede a same-alias fallback just like a tune in a printer vendor's bundle.
- **Instantiated filaments in every other vendor**: non-empty, listing exact printer **variant** names.
Enforced twice, but not identically: `validate_system` reads the resolved config, so an inherited list
satisfies it, while `check` reads the file's **own** key. Write the list in the file itself. This is
the most common filament CI failure.
- Emptying it to "make it apply everywhere" fails that check *and* collides with the library generic's
`filament_id` on every printer.
- Copying a base's full printer list onto a nozzle-specific tune produces duplicate combobox entries: a
real shipped bug twice over.
## Overlapping coverage: one variant, one profile per product
`filament_id` is the **product** key, not the preset key: every preset of one product shares it
(`<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. `validate_system` reports `Ambiguous AMS filament match: N filament presets share filament_id "X"
and are all compatible with printer "Y"`; `orca_profile_tool.py check` does **not** see it and passes.
Resolve the overlap by **specificity**: keep the variant on the most specific profile and remove it from
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
use the name only as a secondary, often vague hint; decide by the lists, with a judgement call on the
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
others vary (`@<printer model>`, a printer serial, or Creality's `@<Model>-all`). A name never overrides
the list; see [preset naming](naming.md#filament) for the shapes.
Specificity, most to least:
1. **Variant-specialized**: lists a single printer variant (BBL-style
`… @<Vendor> <Model> <nozzle> nozzle`).
2. **Model-specialized**: lists the variants of one printer model (BBL-style `… @<Vendor> <Model>`). It
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.
4. **Generic / catch-all**: vendor-wide, covering many unrelated models (often the bare
`Generic <mat> @<Vendor>`).
Rules:
- 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 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
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
profile, the next level down keeps it; when the model has specialized profiles, the model-level one
wins over the family and generic ones.
- Moving coverage is preferred over deleting. If a profile must be deleted, remove the more general one,
not the specialized profile that carries the tune.
- 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 keep generic entries that no longer cover the model. This rule applies equally when adding or
fixing defaults.
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.
**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 loop.
## Alias shadowing
A printer-specific filament in either the library or a vendor bundle supersedes the library fallback on
the printers it lists. The matching key is the **alias**: the preset name up to the **first** `@`,
right-trimmed (no `@` → the whole name). So `QIDI ABS-GF@Q2-Series` aliases to `QIDI ABS-GF`.
A library preset with an empty `compatible_printers` is hidden on every printer that a same-alias preset
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:
- **Only an unrestricted library fallback can be shadowed.** Two printer-specific presets sharing an
alias do not hide each other; overlapping lists for the same product trip the ambiguous-match error
above instead.
- This is why adding `Generic PLA @<printer>` to a vendor silently removes the library
`Generic PLA @System` from that printer. That is intended, and the reason a vendor tuning a generic
must **keep the `Generic X` alias**.
The literal spelling `Generic <mat> @System` is load-bearing beyond shadowing: when a user preset, an
imported preset or a 3MF project names a parent that no longer resolves and contains `Generic`, the
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` is minted from the triple `(filament_vendor, filament_type, alias)`. `filament_vendor` and
`filament_type` are therefore **identity, not decoration**: editing either, or the alias, re-mints the
id. Read `docs/HLSD/filament_id.md` before changing any of them, and see [ids.md](ids.md) for the
tooling.
A filament with no resolvable `filament_id` anywhere in its `inherits` chain is a **hard load error**
that discards the vendor bundle. The id inherits across bundles, so a vendor's `Generic ABS @X`
inheriting `Generic ABS @System` gets the library's id for free; a vendor's own product must resolve its
own.
- `filament_type` **must be a JSON array**: the one vector key `check` rejects as a scalar outright. A
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
and adhesion 1.0. Prefer a value from `MaterialType::all()` in
`src/libslic3r/MaterialType.cpp`, or add a row there.
- Generics use `filament_vendor: ["Generic"]`, which `fdm_filament_common` already defaults to.
## `"nil"`
`"nil"` is legal only in an option defined as nullable (`add_nullable`, or `nullable = true`, in
`src/libslic3r/PrintConfig.cpp`). Anywhere else it fails the file, and with it the **whole bundle**
(`Failed loading configuration file`, after `Deserializing nil into a non-nullable object` or
`Invalid value provided for parameter <key>: nil`). To leave a non-nullable key unset, omit it; do not
write `nil`.
In filament presets a minority of the `filament_*` keys are nullable, plus `long_retractions_when_ec`
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.
## Tuning per nozzle and per variant
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_flow_ratio`, `slow_down_layer_time`, `nozzle_temperature` and `pressure_advance` (switched on
by `enable_pressure_advance`).
Use measured values for the material, hotend, extruder and nozzle combination. Neither maximum
volumetric speed nor pressure advance has a universal nozzle-only lookup table. When cloning a 0.4
preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure-advance value, or a
required direction of change, from diameter alone.
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
retraction overrides carry one value per variant of `filament_extruder_variant` (Standard, High Flow, …). The
exact key set is [`filament_options_with_variant`](extruder-variants.md#the-four-key-sets);
`slow_down_min_speed` and `fan_max_speed` are not in it. Keep every such array at exactly that width, even where the
setting does not differ per variant, and measure the High Flow variant rather than copying Standard
([extruder-variants.md](extruder-variants.md#filament)).
## Bed temperature is twelve keys, not one
There is no single "bed temperature". The plate type selected for the printer (`Cool Plate`,
`Engineering Plate`, `High Temp Plate`, `Textured PEI Plate`, `Textured Cool Plate`, `Supertack Plate`)
picks one of six keys, each with an `_initial_layer` twin: `cool_plate_temp`, `eng_plate_temp`,
`hot_plate_temp`, `textured_plate_temp`, `textured_cool_plate_temp` and `supertack_plate_temp`.
`textured_cool_plate_temp` is the one most often forgotten. A non-BBL printer with
`support_multi_bed_types` off hides the plate selector and uses the printer preset's `default_bed_type`
(High Temp Plate, `hot_plate_temp`, when unset or invalid), but a loaded project or a CLI config can
still carry another plate. So set every plate the printer plausibly has, as the sibling presets in the
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.
@@ -0,0 +1,156 @@
# `setting_id` and `filament_id`
Orca-generated ids are deterministic hashes of identity. **Never invent an id or copy a sibling's
`setting_id`.**
Use `scripts/orca_profile_tool.py`; the two special cases are
[a wrongly inherited filament id](#what-generate-id-does-and-does-not-fix) and
[BBL's authoritative setting ids](#bbls-exception-precisely).
`docs/HLSD/filament_id.md` is the authoritative design document for `filament_id`: the id landscape,
the checks CI runs, and the Bambu catalog map. This page is the tooling half.
| | `setting_id` | `filament_id` |
| --- | --- | --- |
| Identifies | one selectable preset | one filament **product** |
| Key hashed | `<vendor folder>/<type>/<name>` | `filament_product/<filament_vendor>/<filament_type>/<alias>`, using the resolved (inherited) first values and the name up to the first `@`, right-trimmed |
| Shape | 16 base62 characters | `OF` + 6 base62 characters |
| Required on | every `instantiation: "true"` preset | every **instantiated** filament, own or inherited |
| Forbidden on | bases (`instantiation` not `"true"`) | — (a product's root base is exactly where it belongs) |
| Scope | unique across the whole tree | shared by every preset of the product, in every bundle |
`<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's alias, or
editing its `filament_vendor` or `filament_type`, also changes its `filament_id`, and the old id is not
forwarded.
## The tool
`scripts/orca_profile_tool.py` takes a subcommand:
| Command | Does |
| --- | --- |
| `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` |
| `normalize` | rewrites profile files into their canonical shape |
| `trim` | deletes profile files no `<Vendor>.json` list references |
| `update-index` | rebuilds the `*_list` sections from the files on disk |
The order after adding, renaming or deleting files is `normalize` → `update-index` → `generate-id` →
`check`. Each step feeds the next, so it is not interchangeable. The
[authoring workflow](../SKILL.md#creating-or-modifying-a-profile) has the commands.
> **`trim` deletes.** It removes every profile file the index does not list, including the one you just
> added and have not registered yet. Register first, or skip `trim` entirely: it is a cleanup sweep, not
> part of landing a profile. Preview with `--dry-run`.
**Register, then mint.** The `filament_id` pass reads `<Vendor>.json`'s `filament_list`, not the
filesystem (the `setting_id` pass walks the filesystem, so a bundle whose index has not landed yet is
still assignable). A new filament file is therefore invisible to `generate-id`'s `filament_id` pass
until it is registered; its `setting_id` is written regardless.
- `--dry-run` works on every writing command (`generate-id`, `normalize`, `trim`, `update-index`) and
writes nothing.
- `--filament-id` / `--setting-id` narrow `generate-id` to one pass; they exclude each other, and
passing neither writes both.
- `--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
write. `--vendor` on `check` narrows the per-vendor checks only; the `setting_id` and `filament_id`
passes stay tree-wide.
- `--profiles DIR` points any command at another tree; see
[Checking a copy of the tree](validation.md#checking-a-copy-of-the-tree).
- `--profile-type` narrows `normalize`, `trim` and `update-index` to `machine_model`, `process`,
`filament` or `machine`.
- Exit codes: 0 clean, 1 errors found (`generate-id` still writes what it could), 2 argparse misuse.
- Output is ANSI-coloured; searching for the literal `[ERROR]` still works.
`generate-id` is **idempotent and byte-preserving**: BOM and CRLF are kept, and each pass touches only
its one key line. A legitimate `generate-id` diff is one or two changed lines per file: a new instantiated
filament gets both a `filament_id` and a `setting_id`. `normalize` is the opposite by design (it
rewrites whole files into canonical shape), which is why `check` demands it already be a no-op. A file
committed with CRLF line endings changes on every line under `normalize`; read the diff before
committing it.
Exit 1 from `generate-id` does not mean nothing was written: it writes every id it can and reports the
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
Writes:
- a `setting_id` into any instantiated preset that lacks one, or whose value does not match the formula;
- strips a `setting_id` from a base;
- deletes the misspelled 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;
- rewrites a **declared** `filament_id` that is not the mint of its own triple.
Refuses to write (reports only): a base62 collision between two products, an empty `filament_vendor` or
`filament_type`, a broken `inherits` chain, and roots of one filament resolving different
`(filament_vendor, filament_type)` pairs.
**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.
It happens when a branded filament inherits a generic for its settings:
```jsonc
{ "name": "Phrozen Aura PETG @Phrozen Arco 0.4 nozzle",
"inherits": "Generic PETG @System" } // resolves the library generic's id: wrong product
```
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"`.
Two fixes, in order of preference:
1. **Give the product a `@base` root** inheriting a material base (`fdm_filament_pet`,
`fdm_filament_pla`, …) with its own `filament_vendor` and `filament_type`. No `fdm_filament_*` base
carries a `filament_id`, so the filament now resolves none and `generate-id` mints it on the root.
This is the product-root shape ([the three-part shape](filament-profiles.md#the-three-part-shape)).
2. **Declare the tool-computed id on the preset itself.** Use the expected value `check` reports, or
compute it with the function below; this is not a manually chosen id. First make sure the preset
resolves the right `filament_vendor` and `filament_type`: with neither set, the triple resolves
through the generic parent and the branded product is minted under vendor `Generic`. If you need the
id before the file exists:
```bash
python3 -c "import sys; sys.path.insert(0,'scripts'); from orca_profile_tool import generate_filament_id as g; print(g('Polymaker','PLA','PolyLite PLA'))"
# -> OF5CgdDq
```
The quoting works unchanged in cmd and PowerShell; only swap `python3` for `py -3`.
`generate_preset_setting_id('<vendor folder>', '<type>', '<name>')` is the `setting_id` equivalent.
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
The exception covers **`setting_id` assignment only**, keyed on the *folder* name `BBL`:
- The tool never mints or replaces a `setting_id` in `BBL/`: those are Bambu's own ids. A new
instantiated BBL preset with no `setting_id` therefore **cannot be fixed by the tool**, yet the
presence rule still applies to it: carry over Bambu's authoritative id by hand.
- BBL is not exempt from anything else: bases still get their `setting_id` stripped, ids must still be
unique across the tree, and BBL `filament_id`s are minted like everyone else's, as `OF…` ids.
## Ids other systems compose
No id from another system is the mint of a triple, so `check` rejects one used as a `filament_id` like
any other bad id: same error, same remedy, whoever wrote it. Three such spaces exist near the tree;
recognise them so you do not copy one into a profile:
- **Bambu's `GF…` catalog**: external and opaque, correlated to Orca's ids by the generated
`resources/printers/bambu_filament_ids.json`. `blacklist.json` and
`BBL/filament/filaments_color_codes.json` reference Bambu catalog ids by design. The rule is about
`filament_id` and nothing else: every BBL `setting_id` starts with `G`, and that is Bambu's own
preset id, not a leaked catalog id.
- **Qidi's `QD_…`**: composed at runtime by the printer's filament box
(`QD_<series>_<vendor>_<typeidx>`), not a preset id.
- **`P` + 7 hex digits, and `"null"`**: what the app gives a *user*-created filament.
## Tests
`python3 -m unittest discover -s scripts/tests -t scripts` (`py -3 -m …` on Windows) runs the tool's
unit tests. Note the `-t scripts` argument; without it the imports fail. CI runs them as the first,
non-`continue-on-error` step of the profile job; see [validation.md](validation.md#ci).
@@ -0,0 +1,232 @@
# Printer models and variants
Both live in `resources/profiles/<Vendor>/machine/`; models go in `machine_model_list`, variants and
shared bases in `machine_list`. Every one of them is registered. A bundle may nest further subfolders
under `machine/`, so recurse rather than globbing `machine/*.json`.
## `machine_model`: a record, not a config preset
The loader reads a fixed set of keys from a `machine_model` and stores only these (`version` and `url`
are recognised and discarded):
`name`, `model_id`, `nozzle_diameter`, `machine_tech`, `family`, `bed_model`, `bed_texture`,
`hotend_model`, `default_materials`, `not_support_bed_type`, `image_bed_type`,
`bottom_texture_end_name`, `bottom_texture_rect`, `bottom_texture_rect_longer`, `middle_texture_rect`,
`use_double_extruder_default_texture`.
**Everything else is silently dropped**, a printer config key such as `default_bed_type` or 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
silently if its index entry has no name or its `nozzle_diameter` yields no sizes; `check` also requires
the file's own `name`.
```json
{
"type": "machine_model",
"name": "Phrozen Arco",
"machine_tech": "FFF",
"family": "Phrozen",
"model_id": "Phrozen Arco",
"nozzle_diameter": "0.4",
"bed_model": "Phrozen Arco_buildplate_model.stl",
"bed_texture": "Phrozen Arco_buildplate_texture.svg",
"hotend_model": "",
"default_materials": "Generic PLA @Phrozen Arco 0.4 nozzle"
}
```
| Field | Notes |
| --- | --- |
| identity | **the `name` of the `machine_model_list` entry**, which is what a variant's `printer_model` must equal. `check` forces it to equal the file's `name`, so they coincide. |
| `model_id` | a *separate* cloud/device printer type. Optional, and not required to be unique. Not the model's identity; changing it changes device matching. |
| `machine_tech` | only 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 (`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**, 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. |
### Assets
`bed_model`, `bed_texture` and `hotend_model` are paths relative to the **vendor folder** (named by the
vendor id). Convention: `<Model>_buildplate_model.stl` and `<Model>_buildplate_texture.svg`. An
empty string is the legal "none", and is the norm for `hotend_model`.
**Nothing checks that the files exist.** A missing `hotend_model` falls back to
`resources/profiles/hotend.stl`; a missing `bed_model` makes the bed render as a generic custom bed, and a missing
`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.
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.
## `machine`: the variant
```json
{
"type": "machine",
"name": "Phrozen Arco 0.4 nozzle",
"inherits": "fdm_machine_common",
"from": "system",
"setting_id": "lvaYKTUZr5C9jSwk",
"instantiation": "true",
"printer_model": "Phrozen Arco",
"printer_variant": "0.4",
"nozzle_diameter": ["0.4"],
"default_print_profile": "0.20mm Standard @Phrozen Arco 0.4 nozzle",
"default_filament_profile": ["Generic PLA @Phrozen Arco 0.4 nozzle"],
"printable_area": ["0x0", "300x0", "300x300", "0x300"],
"printable_height": "300"
}
```
Minimum viable key set: `type`, `name`, `from`, `instantiation`, `setting_id`, `inherits`,
`printer_model`, `printer_variant`, `nozzle_diameter`, `printable_area`, `printable_height`,
`default_print_profile`. `default_filament_profile` is optional; when written it
is an array (`["Generic PLA @System"]`), while the model's `default_materials` is a `;`-separated
string. Unlike a `machine_model`, a `machine` **is** a config preset, so a key belonging to another
preset type is a reported error and is removed; a misspelled key is still dropped silently.
### `printer_model` and `printer_variant`
1. `printer_model` is non-empty and names a model of this bundle exactly.
2. `printer_variant` is non-empty and an exact token of that model's `;`-separated `nozzle_diameter`
list.
3. For instantiated presets, when validating: split `printer_variant` on `+`; each token must start
with a number (a trailing non-numeric suffix such as `HF` is ignored), and the resulting **set** must
equal the set of `nozzle_diameter` values.
Rules 1 and 2 are loader-enforced: failing either discards the whole bundle. Rule 3 only raises a
validation error: the preset still loads, but the validator exits non-zero.
`nozzle_diameter` lists one entry **per extruder**; `printer_variant` lists the **distinct** diameters
joined with `+`: `["0.4","0.4","0.6","0.6"]` against `"0.4+0.6"` passes because the comparison is on
sets.
Write `printer_variant` as a bare diameter matching the model's list, with no unit. The conventional
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
the `+` form is legal under rule 3. A `printer_variant` is **not** required to be unique within a
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 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 keys
- `default_print_profile` is a **scalar**, matched by exact preset name; not a `;` list. The named
process must be compatible with this printer through its resolved list or condition.
`validate_slice` attempts to select it and rejects generic Default fallbacks, but compatibility
updates can choose another compatible preset, and `check` does not resolve the name. Check the exact
default reference yourself.
- `default_filament_profile` is an **array**, one name per element. Entry 0 is the filament preselected
when the printer is chosen; entry *i* is the preferred replacement when filament *i* is incompatible,
and any listed name outranks an unlisted one. The validator checks every entry. The list of a
printer's filaments is the model's `default_materials`: a new filament goes into `default_materials`;
put it first in `default_filament_profile` only if it should become the preselected one.
- `printable_area` is an array of `"XxY"` strings: four points for a rectangle; a delta or other circular bed
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
The conventional machine root is a base named `fdm_machine_common`, with `fdm_klipper_common` on top
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.**
## Adding a printer to an existing bundle
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
above. Bed assets and `<Model>_cover.png` go directly in `<Vendor>/`.
3. Add at least one process per variant naming it in `compatible_printers`
([process-profiles.md](process-profiles.md#adding-a-quality-tier-or-a-nozzles-processes)).
4. 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
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
(a smaller diff, but the sibling's edits now reach this file too). Follow the bundle.
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
retraction if the vendor tunes it.
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.
## Multi-extruder, IDEX and tool changers
Per-extruder vectors hold one value per extruder (`len(nozzle_diameter)`), and a wrong length raises no
error: a short vector acts as padded with its **first** value, not the last (`extruder_offset`
`["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 (`printer_extruder_options`: `extruder_type`,
`nozzle_diameter`, `default_nozzle_volume_type`, `extruder_printable_height`, `min_layer_height`,
`max_layer_height`, …, given in full with the [key sets](extruder-variants.md#the-four-key-sets), plus
`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`,
`extruder_colour`, `min_layer_height` and `max_layer_height`. Size the variant sets to the variant
length × stride, one value per variant (per extruder when there is no layout; a (normal, silent)
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
the layout when the extruders differ. A single `["0x0"]` `extruder_offset` on a dual or
multi-extruder machine pads every extruder to the same offset, so the offset never applies.
- 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.
Structure to copy: `Custom/machine/fdm_toolchanger_common.json` + `Custom/machine/MyToolChanger 0.4
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. Take the structure from them and
the widths from the [sizing equation](extruder-variants.md#sizing-equation). Both are list-less, so
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
The keys are `machine_start_gcode`, `machine_end_gcode`, `change_filament_gcode`,
`machine_pause_gcode`, `before_layer_change_gcode` and `layer_change_gcode`. Each is one string with
embedded `\n`. Never split G-code into a JSON array of lines: the loader joins array elements with `,`
into a single line (a one-element array is equivalent to the string): a two-element
`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 G-code is actually expanded, which means `validate_slice`:
```bash
./scripts/check_profile.sh --vendor "<Vendor>" validate_slice
# Windows: scripts\check_profile.bat -Vendor "<Vendor>" validate_slice
```
What the sweep covers is in [validation.md](validation.md#validate_slice); a printer whose output has no
`CP TOOLCHANGE START` fails it, because its `change_filament_gcode` never expanded.
@@ -0,0 +1,94 @@
# Preset naming
A preset's `name` is its identity, not decoration. The index registers it; `inherits`,
`compatible_printers`, `printer_model` and the `default_*` keys reference it by the exact,
case-sensitive string; `renamed_from` migrates it; `setting_id` and `filament_id` hash it
([ids.md](ids.md)). Treat a name change as an identity change that needs
[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.
## `machine_model`
`<Model>`, vendor-prefixed: `Bambu Lab X1 Carbon`, `Creality K1`, `Prusa CORE One`. Each variant's
`printer_model` names it verbatim (a mismatch discards the bundle), and its index entry equals the
file's `name`. It is also the stem of `<Model>_cover.png` and, by convention, of the bed assets
(`<Model>_buildplate_model.stl`).
## `machine` (variant)
`<Model> <nozzle> nozzle` is near-universal (`Bambu Lab X1 Carbon 0.4 nozzle`). Casing varies
(`nozzle` / `Nozzle`): match the bundle, not this page. A variant that is not nozzle-specific (a
special toolhead, a multi-material build, IDEX copy and mirror modes such as
`<Model> COPY MODE (0.4 nozzle)`) may drop or reshape the suffix; it is still an exact reference. `printer_variant`
holds the nozzle token: `0.4`, a suffixed `0.4HF`, or `0.4+0.6` for mixed nozzles
([rules](machine-profiles.md#printer_model-and-printer_variant)).
## `process`
`<layer height>mm <quality> @<target>`. The quality word stays before `@` and the printer target after
it: a printer model in the quality position leaves the tier undescribed. The `@<target>` is a label,
and need not equal any variant name; compatibility comes from `compatible_printers` or the
condition. The quality ladder and per-nozzle labels are in
[process-profiles.md](process-profiles.md#naming).
## `filament`
`<Product> @<target>`. The product half, up to the first `@` and right-trimmed, is the **alias**:
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. Convention only; a base is really
`instantiation: "false"` without `setting_id`
([the three-part shape](filament-profiles.md#the-three-part-shape)).
- `@System`: the OrcaFilamentLibrary selectable shim, and the convention for an all-printer product
(`<Product> @System`, empty `compatible_printers`). Not enforced, so a deviation is worth a review
comment. The literal `Generic <mat> @System` is also load-bearing for project recovery
([alias shadowing](filament-profiles.md#alias-shadowing)).
- Printer tunes. BBL's shape is the reference: `@<Vendor>` (vendor-wide), `@<Vendor> <Model>` (one
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)).
- No colour in the product name: `<Product> <Colour>` presets are not authored; colour is chosen at
runtime ([colour](filament-profiles.md#colour-is-a-runtime-property)).
## Bases
| Type | Base names |
| --- | --- |
| `machine` | `fdm_machine_common`, `fdm_<vendor>_common`, `fdm_klipper_common`, or an established machine-family base |
| `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, `<Product> @base` product roots |
There is no leading-underscore convention. Base names repeat across bundles by design:
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.
## Uniqueness
- 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.
## Filenames and 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`.
@@ -0,0 +1,155 @@
# Process profiles
Processes live in `resources/profiles/<Vendor>/process/`, selectable leaves and shared bases alike, and
every one of them is registered in `process_list`. There are no global processes shared across vendors.
## Naming
`"<layer height>mm <quality> @<target>"` 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 the
layer height / nozzle ratio; it is a naming convention, not a loader constraint:
| Quality | Ratio | 0.2 nozzle | 0.4 | 0.6 | 0.8 |
| --- | --- | --- | --- | --- | --- |
| Extra Fine | 0.2× | — | 0.08 | — | — |
| Fine | 0.3× | 0.06 | 0.12 | 0.18 | 0.24 |
| Optimal | 0.4× | 0.08 | 0.16 | 0.24 | 0.32 |
| Standard | 0.5× | 0.10 | 0.20 | 0.30 | 0.40 |
| Draft | 0.6× | 0.12 | 0.24 | 0.36 | 0.48 |
| Extra Draft | 0.7× | 0.14 | 0.28 | 0.42 | 0.56 |
This is the `fdm_process_single_<lh>_nozzle_<n>` ladder; 0.4 is commonly the unsuffixed nozzle default.
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: it need not equal any printer variant name.
Compatibility comes from the resolved list or condition, not this label.
## Shape
A selectable leaf has `type`, `setting_id`, `name` and `instantiation`, normally `inherits` and
`from`, plus compatibility; its slicing keys, `layer_height` included, normally come from its bases. A
base has `type`, `name`, `instantiation`, `from`, and **no** `setting_id`.
**Target shape: a 7-key leaf.** `OrcaArena` is the cleanest model:
`fdm_process_common` → `fdm_process_arena_common` → `fdm_process_arena_<lh>_nozzle_<n>` → leaf, where the
leaf carries only `type`, `name`, `inherits`, `from`, `setting_id`, `instantiation` and
`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's *layering* is a model too (every leaf inherits a base, names its printers directly and holds no
layer height of its own), but not its content: its leaves carry multi-variant `print_extruder_variant`
arrays that no single-variant vendor needs ([extruder-variants.md](extruder-variants.md#process)).
A bundle has its own `fdm_process_common` as the inherits-less root, since a process inherits only
inside its bundle; starting a new bundle's from another vendor's copy is fine.
Beware leaf-inherits-leaf: a bundle may chain selectable processes several levels deep, so editing one
silently changes others. Check a leaf's children before editing it.
## Compatibility
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
other**: that is the invariant to review against. Unlike filaments, inheriting `compatible_printers` is
legitimate for a process, and no check enforces its presence.
- A non-empty `compatible_printers` makes `compatible_printers_condition` **dead**. Use one or the
other.
- A condition that fails to parse means *compatible with everything*: a warning, not an error. A typo
widens compatibility instead of narrowing it.
- A regex in a condition must match the **whole** string, so wrap the keyword in `.*`; `.` also spans
the newlines inside `printer_notes`.
- A `printer_notes` keyword that prefixes another model's keyword matches both. Guard it with a
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.*/
```
The `[^_a-zA-Z0-9]` exists because `PRINTER_MODEL_COREONE_L` also contains `PRINTER_MODEL_COREONE`.
A leaf listing a whole model family is where a newly added printer is usually forgotten.
## Values to review per nozzle
| Key group | Review |
| --- | --- |
| `line_width` and per-region widths | resolved widths suit the nozzle and layer height |
| `layer_height`, `initial_layer_print_height` | within the printer's `min_layer_height` / `max_layer_height` |
| print speeds | consistent with flow limits and hardware tuning |
| shell layers, wall loops, accelerations, support Z distances | preserve the intended thickness, motion and support behaviour |
**A common starting pattern is line width = nozzle + 0.02 mm**: 0.22 / 0.42 / 0.62 / 0.82 / 1.02. In
that pattern, at 0.4, `inner_wall_line_width`, `sparse_infill_line_width`, `skin_infill_line_width` and
`skeleton_infill_line_width` widen to 0.45 and `initial_layer_line_width` to 0.5; at 0.2,
`initial_layer_line_width` widens to 0.25. Also derived, and easily missed:
`ironing_inset = line_width / 2` (0.11 / 0.21 / 0.31 / 0.41). These are examples, not required values;
preserve intentional vendor tuning and percentage or automatic widths, and validate their resolved
values.
`min_layer_height` and `max_layer_height` are machine keys; no process file sets them.
### Slicing limits
Slicing rejects a process that breaks one of these (the message in italics):
1. `initial_layer_print_height` ≤ the smallest `nozzle_diameter` (with a raft, the nozzle of the raft's
first-layer extruder).
2. `layer_height` ≤ the smallest `nozzle_diameter`: *"Layer height cannot exceed nozzle diameter."*
3. `line_width` and the seven per-region widths (inner and outer wall, sparse infill, internal solid
infill, top surface, skin, skeleton) > `layer_height`: *"Line width too small"*.
`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`: it must not exceed the nozzle diameter, and must exceed
`layer_height` unless `thick_bridges` and `thick_internal_bridges` are both on. The slice sweep starts
from printer defaults rather than enumerating every process: **a new non-default process gets no
dedicated slice coverage in CI.**
## What CI checks on a process
Structure, not content: `process_list` name consistency **and** index coverage the other way, two files
claiming one process name, the `extruder_clearance_radius` / `extruder_clearance_max_radius` conflict
pair, duplicate JSON keys, a file `normalize` would rewrite, the five `setting_id` rules (present on
selectable presets, absent from bases, equal to the formula outside `BBL/`, unique across the tree, and
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**.
The loader derives a missing `setting_id` on the fly, so the validator accepts a process without one;
only `orca_profile_tool.py check` catches it.
Running the validator alone gives a false all-clear.
## Silent failures specific to processes
- **Unknown or misspelled keys are discarded with no error and no warning**, both plain typos
(`inital_layer_height`, `tree_support_bramch_diameter_angle`, `sparse_infill_patter`) and keys
copied from other slicers that Orca never defined.
- Keys on the tool's obsolete list (`adaptive_layer_height`, `overhang_totally_speed`, …) are rejected
by `check`'s normalization pass across preset types; `normalize` removes them. The additional per-key
obsolete warnings read `filament/` only.
- A dangling `compatible_printers` inside an `instantiation: "false"` base is reported only through a
selectable child that inherits it unchanged; it goes unreported when every child overrides the list,
or when the base has no instantiated children.
- Nothing flags an orphan base that nothing inherits, usually the leftover of a half-finished nozzle
addition.
## Adding a quality tier or a nozzle's processes
1. Choose the layer height and quality label using the bundle's existing ladder.
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`.
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 and validate: the
[authoring workflow](../SKILL.md#creating-or-modifying-a-profile).
5. Slice this process explicitly with its intended printer
([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.
@@ -0,0 +1,277 @@
# Reviewing a profile change
Run `./scripts/check_profile.sh` on the applied diff first ([validation.md](validation.md) says what CI
runs), then work through the items below: delivery, identity and backward compatibility first, then the
affected preset types. The table lists the gaps CI cannot see, so only a reviewer catches them.
| Not checked by CI | Consequence |
| --- | --- |
| The `version` bump | The change never reaches an upgrading user; an absent `version` hides the vendor from the setup wizard |
| 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 |
| `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 non-default process | `validate_slice` gives non-default quality tiers no dedicated coverage |
| Whether the intended default survived compatibility selection | The sweep can select a different compatible preset |
| A dangling `compatible_printers` inside 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 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 |
| Plate temperatures for plates the printer has | The user's plate reads an unset or inherited temperature |
| 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?
For **every** bundle whose folder the diff touches, `resources/profiles/<Vendor>.json` must have its
`version` incremented: last component, carrying `.99` into the third component. A library change means
bumping `OrcaFilamentLibrary.json`.
*Why:* nothing in CI checks it, and the app reinstalls a bundled profile set only when its version is
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?
`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
omission yourself. Three things are still yours:
- **The index diff belongs to this change.** `update-index` rewrites whole `*_list` sections. If the
bundle had drifted, the author's PR now carries someone else's reordering; ask for it in a separate
commit rather than reviewing it inline.
- **A deleted selectable preset needs a successor** as in item 4. `update-index` removes its
registration; `validate_custom` detects the break only for names covered by released fixtures.
- **`normalize` edits content, not just layout.** It drops `version` and `is_custom_defined` from preset
files, removes obsolete keys, deletes six print-speed keys from filament profiles, and resolves
`extruder_clearance_radius` against `extruder_clearance_max_radius` by keeping the larger
([what normalize changes](validation.md#normalize-and-update-index-are-part-of-the-check)). Check that
the keys it removed were meant to go.
Index order is dependency order, not alphabetical: parents and include templates before the presets
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`
and take the whole vendor bundle down; an unindexed file gets reviewed, merged and never loads.
## 3. Are ids generated, not written?
No hand-typed or copied `setting_id` / `filament_id`. Instantiated presets have a `setting_id`; bases do
not. `check` enforces all of that; what it cannot tell you is whether the identity *should* have moved.
A rewritten or removed `filament_id` means a product's identity moved (a renamed alias, or an edited
`filament_vendor` / `filament_type`), and the old id is not forwarded anywhere. Confirm that was
intended, and that a new id is not a rename in disguise.
*Why:* a duplicate `filament_id` on one printer makes AMS spool matching a coin toss; a copied
`setting_id` breaks preset identity. See [ids.md](ids.md).
## 4. Does anything disappear for existing users?
A rename, a deletion, or a flip of `"instantiation": "true"` → `"false"` on a shipped selectable preset
removes the name from the preset collection. It needs `renamed_from` on a selectable successor
([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
presets are dropped with no error at all. Commit `33923464ae` reverted exactly this for Cubicon;
`6943b6ddc3` redid it correctly with `renamed_from`. CI's `validate_custom` catches the shipped-name
case, but not an inert `renamed_from`.
## 5. Is `compatible_printers` right?
Exact printer **variant** names, non-empty on every instantiated filament outside OrcaFilamentLibrary,
and written in the preset's own file ([SKILL.md rule 9](../SKILL.md#rules); the resolved-vs-own-key trap
is in [filament-profiles.md](filament-profiles.md#compatible_printers)). A `machine_model` name instead
of a variant name is the usual mistake: `check` passes it, the validator reports
`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
variant to the most specific preset and remove it from the more general ones, which is preferred over
deleting a profile. Then repoint the machine's `default_filament_profile` and the model's
`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).
*Why:* real shipped bugs twice (`b7b3418baf` filaments "showing up everywhere", `ff83aa41ef` duplicate
Flashforge entries). The Python `check` passes on an overlap; only the full `check_profile.sh`
(`validate_system`) reports `Ambiguous AMS filament match`.
## 6. One product, one all-printer preset; colour is not a preset
No presets that differ only by colour: `filament_id` identifies a product, and the colour comes from the
spool at runtime. An all-printer library product is a `<Product> @System` shim with an empty
`compatible_printers` ([colour](filament-profiles.md#colour-is-a-runtime-property)).
*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.
- 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 weaker:
the sweep attempts the named default, then updates compatibility and rejects generic Default fallbacks,
so another compatible process can conceal a bad reference. Inspect it even after a pass.
## 8. Types and spellings
Every value a string or an array of strings; `filament_type` an array; `instantiation` the string
`"true"` / `"false"`; custom G-code one string, never an array of lines ([SKILL.md rule 5](../SKILL.md#rules)).
Check index metadata and model `nozzle_diameter` especially: wrong types there can abort loading for
**every** vendor.
The part only a reviewer can do: check new setting keys against `src/libslic3r/PrintConfig.cpp`. A
misspelled key is silently discarded ([rule 6](../SKILL.md#rules)), the single most common way a profile
edit does nothing while CI stays green.
## 9. Blast radius of a base edit
A change to `fdm_*_common.json` or any other base reaches every child at once. Ask which presets it
touches: several reverts in this repo are exactly this (`41d1b0d3c8`, `dc491166a8`). Also check whether
the edited leaf has children of its own: 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).
## 10. Do the numbers make sense for the nozzle and material?
Check resolved widths and layer heights against the nozzle, temperatures against the material (PLA
values under an ASA name print wrong), and flow limits / pressure advance against the actual hardware
and material. The patterns in [process-profiles.md](process-profiles.md#values-to-review-per-nozzle)
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
numbers nobody measured.
## 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
`resources/profiles/<vendor folder>/`, in exact case. Nothing checks them.
## 13. `default_materials` and `default_filament_profile` (checked by CI)
`check` fails on a `default_materials` / `default_filament_profile` name that matches no filament file,
and `validate_system` on a variant with no compatible system filament in `default_materials`, so a
dangling entry no longer reaches review. When compatibility moves between profiles of a product, the
machine's `default_filament_profile` and the model's `default_materials` must be repointed at the most
specific profile that still covers the variant, dropping generic entries that no longer apply; the same
[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
python3 scripts/orca_profile_tool.py check --vendor "<Vendor>" # py -3 on Windows
```
## 14. Per-extruder vectors (not checked) and variant arrays (widths and layout checked)
One entry per extruder for the plain per-extruder vectors; the variant sets are sized to the variant
length, `len(printer_extruder_variant)` or one per extruder when the resolved preset has no layout (a
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).
`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
changed non-default tier explicitly with its intended printer
([on a copy of the tree](validation.md#checking-a-copy-of-the-tree)).
## 16. Do the preset names follow the conventions?
Check **every newly added profile and intentional name change**, including models and bases, against
[the naming conventions](naming.md#checking-names): no printer model in a process quality position, no
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 mistakes
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
A finding is **one defect**: its file (or quoted lines), what breaks at runtime or in CI, and the fix.
Split independent defects into separate findings even when they live in one file: five id problems in
one bullet get one fix and four survivors. 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 | Means |
| --- | --- |
| blocker | the bundle fails to load, or a preset is unreachable at runtime |
| 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: 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
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.
@@ -0,0 +1,447 @@
# Validating profiles
```bash
./scripts/check_profile.sh # everything CI runs
./scripts/check_profile.sh --vendor "<Vendor>" # development loop
./scripts/check_profile.sh profile_tool validate_slice # named checks only
```
```bat
scripts\check_profile.bat :: the same three, on Windows
scripts\check_profile.bat -Vendor "<Vendor>"
scripts\check_profile.bat profile_tool validate_slice
```
`check_profile.bat` is a shim around `check_profile.ps1`: same checks, same order, same logs. Its flags
take PowerShell spellings (`-Vendor`, `-ProfilesDir`, `-Validator`, `-Download`, `-Refresh`,
`-WorkDir`, `-LogLevel`), and positional check names are unchanged. `-p`, `-v` and `-l` are aliases,
so `-v Elegoo -l 2` reads the same on both platforms. It passes `-ExecutionPolicy Bypass` because a
default Windows client refuses to run a checked-out `.ps1` at all. The `.ps1` finds Python itself,
probing `py -3`, then `python`, then `python3`; run the tool by hand with `py -3` for the same reason.
Every check 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` (the same report CI posts on the PR) under a
per-user cache dir:
| Platform | Cache dir |
| --- | --- |
| 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
| 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, 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 (`Ambiguous AMS filament match`), printer defaults that name no compatible system filament |
| `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_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 defaults to on, so the duplicate-`filament_id` check runs whether or not you
pass it, and `validate_system` already fails on duplicates. The binary's own `--help` ("Off unless this
flag is present") does not reflect that default.
**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.
### `validate_custom`: the backward-compatibility gate
It downloads one fixture archive per past release (v1.9.0 onwards) of *generated mock* user presets: a
`<vendor>_<preset>_orca_test` copy of every system preset that release shipped, cut with the
validator's own `-g 1` mode. It unpacks each over a copy of the current tree and loads it. Each entry
holds only `inherits` plus a canned diff, so the one failure it adds over `validate_system` is a shipped
preset name disappearing. This is what makes a rename, a deletion or an `instantiation` flip a CI
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`
It slices a two-colour cube on every instantiable printer in the tree, sequentially, forcing the prime
tower. It selects `default_print_profile` and the first `default_filament_profile`, then updates
compatibility; that update can select a different compatible preset. Confirm the intended defaults
yourself rather than treating a passing sweep as proof that those exact presets were sliced.
A printer fails if it cannot be selected, falls back to a Default preset, throws, produces no G-code,
or emits no `CP TOOLCHANGE START` (`change_filament_gcode` never expanded). Non-default processes and
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`
`check` is one subcommand of the tool that also owns `fix-variant`, `generate-id`, `normalize`,
`trim` and `update-index`; [ids.md](ids.md#the-tool) has the writing half.
| Catches | Scope | Function in the tool |
| --- | --- | --- |
| two files in one bundle claiming one type + name, indexed or not | per vendor | `check_preset_name_uniqueness` |
| a file on disk that no `*_list` references (**an error, not a warning**) | per vendor | `check_index_coverage` |
| an index entry whose `name` disagrees with the file, or whose `sub_path` is missing | per vendor | `check_name_consistency` |
| a file `normalize` would rewrite, an index `update-index` would rebuild | per vendor | `check_normalized` |
| duplicate JSON keys in a file | every file read | the JSON loader |
| an instantiated non-library filament with no non-empty `compatible_printers` of its own | per vendor | `check_filament_compatible_printers` |
| `extruder_clearance_radius` alongside `extruder_clearance_max_radius` | per vendor | `check_conflict_keys` |
| 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` |
| 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` |
| 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` |
Because the id and model-name checks stay tree-wide, a vendor-scoped run can and does fail on another
vendor's files, and it saves seconds, not minutes.
Unscoped, the per-vendor pass covers every bundle. The only exclusion is the stray `user/` directory
(below); OrcaFilamentLibrary is held to the same rules as any vendor, its sole exemption being that a
library filament may leave `compatible_printers` empty. `check_normalized` covers every bundle with an
index.
Notes that matter:
- Exit codes: **0** clean, **1** errors found, **2** argparse misuse. Warnings never change the exit
code.
- A nonexistent `--vendor` is a hard error: `[ERROR] unknown vendor "<V>" in <dir>`, exit 1.
- `--vendor ""` means all vendors; `check_profile.sh` relies on that. `--vendor` is repeatable
(`check --vendor A --vendor OrcaFilamentLibrary`); `check_profile.sh` takes one.
- A **stray directory** under `resources/profiles/` still gets counted as a vendor by the per-vendor
pass and warned about (`No profiles found for vendor: <dir> at …/<dir>.json`, and the "Checked
vendors" count goes up by one): 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*.
- Each remedy is printed once for the whole run, not once per file, as a `[WARNING]` under the errors
(`2 unreferenced file(s) above: delete them, or run … update-index`). Read those lines: they name the
command that fixes the batch.
- When there are errors or warnings, the trailing summary suggests `normalize`. That is right for the
shape errors and misleading for everything else: an id error needs `generate-id`, a dangling
`default_materials` needs a human.
- 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
profile CI. Use `orca_profile_tool.py check` for current id validation.
### Obsolete keys
`check` always reports per-key warnings for obsolete options in filament profiles. Its normalization
check also rejects obsolete keys across all preset types (`normalize would remove <key>`); `normalize`
removes them.
### Default-material references
The materials check finds `default_materials` / `default_filament_profile` entries naming a preset that
does not exist. It reads each `machine/` file's own key (a model's `default_materials`, a variant's
`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
`check` fails when either command would still change something, so they are not optional polish: the
file that gets reviewed has to be the file that ships. What `normalize` changes is narrow and fixed:
- adds a missing `type`;
- deletes a `version` or `is_custom_defined` key from a *preset* file;
- deletes six print-speed keys from filament profiles (`initial_layer_print_speed`, `outer_wall_speed`,
`inner_wall_speed`, `infill_speed`, `top_surface_speed`, `travel_speed`);
- deletes the obsolete keys the loader ignores (the `ignore` set in `PrintConfigDef::handle_legacy`),
across preset types;
- 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
`machine` only if its name contains `nozzle` (case-insensitive), otherwise `machine_model`. That
heuristic cannot reliably classify shared machine bases or unusually named variants.
The tool's obsolete-key set is checked against the loader's ignore list by a unit test. Active options
are preserved, including live keys whose *values* the loader rewrites (`extruder_type`: `DirectDrive` →
`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:
- **Formatting and key order on their own.** A file with none of those problems is skipped entirely, so
4-space indent, a missing trailing newline, and a file that leads with `compatible_printers` all pass
`check`. They stay latent until something else trips `normalize` and the whole file reformats inside
an unrelated diff. (`normalize --force` rewrites every file; do not run it on a shipped bundle.)
- **A misspelled setting key.** `inital_layer_height` and `sparse_infill_densiti` pass `check` cleanly.
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
Built from `src/dev-utils/OrcaSlicer_profile_validator.cpp` with `-DORCA_TOOLS=ON`. Both scripts use a
local build under `build*/` when one exists, else they download the nightly into the `validator`
subdirectory of the cache dir. `check_profile.sh` searches Release, then RelWithDebInfo, then Debug
(each under `build*/src/<config>` and `build*/*/src/<config>`), then single-config `build*/src`, and
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`.
If your build lives somewhere else, point at it with `--validator` / `-Validator`, or set
`ORCA_PROFILE_VALIDATOR` (`$env:ORCA_PROFILE_VALIDATOR` in PowerShell).
| Flag | Meaning |
| --- | --- |
| `-p <dir>` | profile tree (also becomes the data dir) |
| `-l <n>` | log level; CI uses 2 |
| `-v <Vendor>` | load only that vendor **plus** OrcaFilamentLibrary |
| `-s` | slice sweep |
| `-o <dir>` | with `-s`, save each printer's G-code there |
| `-f` | no-op (see above) |
| `-g 1` | regenerate user-preset fixtures; takes a value, and wipes the user preset dir first |
On ARM64 Linux the nightly is x86-64 only: the script warns and downloads anyway, producing a binary
that will not run. Build it locally with `-DORCA_TOOLS=ON` and pass `--validator`.
Running the validator directly uses the profile tree as its data directory and can create `user/`
there. Prefer the wrappers, which stash existing user presets and restore them afterward. After a
direct run, inspect `user/` and remove only empty directories created by that run; fixtures or
pre-existing user files may be present.
## Checking a copy of the tree
Use `--profiles DIR` on the Python tool and `-p DIR` on the validator. The wrappers' `--profiles` /
`-ProfilesDir` passes the tree to both, so one run validates a copy fully:
```bash
./scripts/check_profile.sh --profiles "<tree>"
# Windows: 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
Editing this checkout's `resources/profiles` does not update a separately installed application. Test
with a build using the edited resources and a bumped bundle version: the updater installs newer bundles
under `<data_dir>/system/`, and the preset cache also depends on the bundle version. Use Help ▸ Show
Configuration Folder to locate the active data directory:
| Platform | Default data directory |
| --- | --- |
| macOS | `~/Library/Application Support/OrcaSlicer` |
| Linux | `$XDG_CONFIG_HOME/OrcaSlicer`, or `~/.config/OrcaSlicer` when unset |
| Windows | `%APPDATA%\OrcaSlicer` |
A portable `data_dir` next to the executable takes precedence. Use a separate test configuration for a
clean-install check; preserve the normal configuration and user presets.
## Error → remedy
| Message | Fix |
| --- | --- |
| `can not find inherits <parent> for <preset>` | parent missing, unregistered, misspelled, or listed **after** the child |
| `can not find include` | the template is misspelled, registered after the includer, or selectable |
| `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"` |
| `contains incorrect keys: <keys>, which were removed` | a key valid for a different preset type |
| `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](machine-profiles.md#printer_model-and-printer_variant) |
| `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 |
| `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 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` / `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 and it declares no profile type` | set the correct `type` explicitly, then `normalize` and `update-index` |
| `[ERROR] … normalize would <change>` / `<V>.json: update-index would rebuild <lists>` | run that command and commit the result |
| `[ERROR] <V> has N <type> profiles named "<name>"` | identify the intended preset and remove or rename the duplicate; use `trim --dry-run` only for deliberate unindexed-file cleanup |
| `Duplicate key error in <file>: Duplicate key detected: <key>` | a key written twice in one file; keep the intended one |
| `… 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)) |
| `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 |
| `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) |
| `"<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_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
`.github/workflows/check_profiles.yml`, job **"Check profiles"**, runs on `pull_request` into `main` or
`release/*` touching `resources/profiles/**`, `resources/printers/**`, `scripts/**`,
`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 tests
(run them locally after changing `scripts/`, with `py -3` on Windows, and keep `-t scripts` or the
imports fail). That step is deliberately **not** `continue-on-error`: a broken tool makes everything it
then says about the profiles worthless. Every check after it is `continue-on-error` with a final gate,
so one run reports all five results. On failure a second workflow posts or replaces a single PR comment
marked `<!-- profile-validation-comment -->`, 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
self-merge a PR limited to their own `resources/profiles/<Vendor>/` folder with no human review, so
whatever CI does not check is what ships unreviewed. Its denied patterns refuse `^scripts/` and any
`.py`, so a PR that touches the tooling always needs a maintainer.
@@ -0,0 +1,214 @@
# Vendor bundles
A bundle is `resources/profiles/<Vendor>.json` (the index) plus `resources/profiles/<Vendor>/`. The
**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
uses the `name`.
## The index
```json
{
"name": "Phrozen",
"version": "02.04.00.03",
"force_update": "0",
"description": "Phrozen configurations",
"machine_model_list": [ { "name": "Phrozen Arco", "sub_path": "machine/Phrozen Arco.json" } ],
"machine_list": [ … ],
"process_list": [ … ],
"filament_list": [ … ]
}
```
The loader reads `name`, `version`, `url` and the four `*_list` arrays. `description` is only logged;
`force_update` is read by the profile updater, never by the loader. `sub_path` is relative to the
**vendor folder**.
| List | Holds |
| --- | --- |
| `machine_model_list` | `machine_model` records (the printer product) |
| `machine_list` | printer variants **and** shared machine bases |
| `process_list` | selectable processes **and** shared process bases |
| `filament_list` | selectable filaments **and** shared filament bases |
### Three registration rules
1. **Everything is registered, bases included.** Every preset file on disk has exactly one entry in the
matching list, and no unindexed preset file is left in the tree.
2. **Parents before children, includes before includers.** The lists load processes first, then
filaments, then printers, each in index order, and `inherits` and `include` resolve only against
presets of that type already loaded from it. A parent
listed after its child produces `can not find inherits <parent> for <child>` and the bundle is
discarded; an include listed after its includer is `can not find include`, a counted error that
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, and `update-index` writes an index that satisfies all three from the
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
`check` compares them.
The loader itself reports none of this: an unregistered file, or an entry with a misspelled key
(`"subpath"`), is silently dropped. (A misspelled `sub_path` value is a `check` error naming the entry.)
`BBL/cli_config.json` and, in `BBL/filament/`, `filaments_color_codes.json`, `filament_id_map.json`,
`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`
Four components, `MM.mm.pp.bb`, compared as a version number in which the fourth is folded into the
third (`patch × 100 + build`). Write all four components, zero-padded.
- **Bump the version for every bundle the change touches.** The app installs bundled profiles only
when their version is newer than the installed one, and the `.opc` preset cache is also keyed on
the version. Nothing in profile CI checks the bump.
- **Keep the last component ≤ 99.** `02.04.00.100` and `02.04.01.00` both read as
`2.4.100`. A bundle that
reaches `.99` carries into the third component (`02.03.02.99` → `02.03.03.00`).
- An **absent** version is worse than a stale one: it reads as `0.0.0`, which is not a valid version.
`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
*unparseable* version is not silent: it discards the whole bundle (`vendor <V>'s config version: <s>
invalid`).
## Common preset keys
| Key | Value |
| --- | --- |
| `type` | `machine_model`, `machine`, `process` or `filament` |
| `name` | the preset name, the identity every reference uses; the filename is *not* authoritative |
| `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) |
| `from` | `"system"` for shipped presets |
| `setting_id` | generated; required on instantiated presets, forbidden on bases |
| `renamed_from` | `;`-separated old names this preset supersedes ([below](#renamed_from)) |
These are config-preset keys; `machine_model` records have their own
[key set](machine-profiles.md#machine_model-a-record-not-a-config-preset). Keep `from` as `"system"`:
the bundle loader ignores it, but loading the file as a CLI config accepts only `system`, `user` or
`User` and handles their inheritance differently.
`instantiation` is the one metadata key the validator gates: a missing key or any value other than the
strings `"true"` / `"false"` is a counted error (`Missing instantiation attribute for <name>`) that fails
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` and `include`
`inherits` resolves by exact name **within the same bundle**, plus one exception: filaments may inherit
from OrcaFilamentLibrary, which is loaded first. Vendor-to-vendor inheritance always fails, and an
unresolved `inherits` discards the bundle. You can inherit from an instantiated preset as well as from a
base.
`"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.
- 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
(`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; a preset that gains
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
in-tree `inherits` (exact lookup), it does **not** satisfy the index-name rule, the validator reports
an in-tree reference that only resolves through it (`references renamed compatible_printers "OLD"
(now "NEW")`), and `machine_model` records never read it at all.
- Only one preset may claim a given old name; two that do is a counted error
(`… was marked as renamed from "Y" … as well`). But the redirect is **inert while a live preset still
carries that name**, and nothing checks *that*, so a neighbour's `renamed_from` is no model.
## Failure scopes
| Scope | Cause |
| --- | --- |
| **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** | 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` |
| **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) |
| **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 |
| **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 (`unexpected end of input`), not "file
not found".
Selectable preset names are a **single namespace across every vendor**: a duplicate within one vendor
is a hard bundle failure, and a duplicate across vendors is reported as `Found duplicated preset: <name>
in vendor: <vendor>` and still counts as an error. `check` catches the within-bundle case earlier and
more precisely, bases included, and including an *unindexed* twin, which is one `sub_path` edit away
from silently becoming the parent every child resolves to (the first registered preset of a name wins,
so index order decides). Base names, by contrast, repeat across bundles by design:
every bundle may have its own `fdm_process_common` ([uniqueness](naming.md#uniqueness)).
## Starting a whole new vendor bundle
Nothing generates one; copy the smallest bundle that resembles the hardware. **`Voxelab`** is the
minimal shape: a shared machine base, the model, one variant, a shared process base, two processes, and
an empty `filament_list`, so the printer takes the library generics. Do *not* start from a bundle that
carries local `fdm_filament_*` copies, which drift from the library, or filament presets that restate
most of their parent, the style this skill advises against.
Write the machine files **last**, so you only visit them once:
1. **Choose the names first**: model, variant(s), process(es). Everything else references them
([naming.md](naming.md)).
2. `resources/profiles/<Vendor>.json`: `name`, `version` (`01.00.00.00`), `force_update: "0"`,
`description`, and all four `*_list` arrays (empty is fine: `update-index` fills them once the files
exist, so this step only needs the bundle metadata to be right).
3. The shared bases: `<Vendor>/machine/fdm_machine_common.json` and
`<Vendor>/process/fdm_process_common.json`, both `"instantiation": "false"` with no `setting_id`. For
a Klipper printer add your own `<Vendor>/machine/fdm_klipper_common.json` inheriting the machine
base; there is no shared one, because a `machine` preset can only inherit inside its own bundle.
4. One selectable process per variant, each naming its variant in `compatible_printers`.
5. Bed assets and `<Model>_cover.png`, all directly in `<Vendor>/`. None of them is needed for the
bundle to load, and nothing in CI checks them; but the bed files are inert unless the
`machine_model` names them in `bed_model` / `bed_texture`, and the cover is found by convention as
`<the name you gave the model in machine_model_list>_cover.png`.
6. The `machine_model` record and the `machine` variants, now that every value they reference exists;
the minimum key sets and the `default_*` shapes are in
[machine-profiles.md](machine-profiles.md#machine-the-variant).
7. Run the tool and validate: follow
[Creating or modifying a profile](../SKILL.md#creating-or-modifying-a-profile). `generate-id` is not
optional for a new bundle: the validator loads presets that have no `setting_id`, but `check` fails
every one of them.
## `resources/profiles_template/`
A separate tree (`Template.json` + `Template/`) holding filament and process templates. It is **not** a
scaffold for shipped profiles: the app's "create a custom printer / filament" dialog reads it, so
editing it changes what users get when they create a custom preset. `check_profile.sh`'s validator
checks default to `resources/profiles` (redirectable with `-p`), and so does `orca_profile_tool.py`
(redirectable with `--profiles`); neither covers this tree.
@@ -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()
+244
View File
@@ -0,0 +1,244 @@
---
name: orca-wxwidgets
description: Use when writing, modifying, reviewing or debugging any OrcaSlicer GUI code under src/slic3r/GUI, or when deciding how a wxWidgets API behaves in OrcaSlicer — dialogs, frames, panels, the sidebar, Preferences, device/monitor pages, custom widgets, popups and menus, sizers and layout, painting, DPI scaling, dark mode, colours, icons, fonts, translated strings, settings fields, keyboard shortcuts, mouse capture and focus, events, CallAfter, threads and timers, WebView, the OpenGL canvas, AUI docking, clipboard, drag and drop, file dialogs — and for platform-specific UI bugs on Windows, macOS or Linux GTK/X11/Wayland such as popups that close at once, a frozen or unclickable UI, collapsed or clipped dialogs, invisible dark-mode icons, or crashes on close, even when the task never mentions wxWidgets.
---
# OrcaSlicer wxWidgets GUI
This skill is how OrcaSlicer's wxWidgets GUI is written, changed, fixed and reviewed. Its core is
**wxWidgets 3.3.2 API usage** — the documented contracts, the per-platform behaviour and the known
limitations of the wx version Orca pins — layered with the OrcaSlicer conventions, wrappers, custom
widget library and stable component designs a contributor must follow. Every reference file opens
with a numbered **Rules** checklist (what a diff is checked against), followed by sections that give
the wx contract, the correct code shape, platform differences, the Orca layer, and pitfalls as
wrong → right pairs with the commit that fixed each one.
## Ground truth: the wx tree in deps/
Orca pins **wxWidgets 3.3.2** from the fork `github.com/SoftFever/Orca-deps-wxWidgets` (tag `v3.3.2`,
`deps/wxWidgets/wxWidgets.cmake`), built static (Flatpak: shared). Facts about this build that change
how you read the wx docs:
- **Linux builds against GTK3** (`DEP_WX_GTK3` defaults ON in `deps/CMakeLists.txt`). Target GTK3 on
both X11 and Wayland; GTK-guarded code must still compile on GTK2, an opt-out build Orca does not ship.
- **wx asserts never fire.** wx is built with `wxBUILD_DEBUG_LEVEL=0` and `libslic3r_gui` with
`wxDEBUG_LEVEL=0`. Wherever the docs say a call "asserts", Orca silently ignores it, returns early or
corrupts state. Check preconditions yourself; do not expect a debug build to catch misuse.
- **No SVG in wx** (`wxUSE_NANOSVG=OFF`): `wxBitmapBundle::FromSVG*` does not exist. Orca rasterises
its SVG icons itself (`BitmapCache`, `create_scaled_bitmap`, `ScalableBitmap`).
Look things up in the source the app is built from — it beats memory, and 3.3 changed real behaviour:
```bash
WX=$(find deps -maxdepth 5 -type d -path '*dep_wxWidgets-prefix/src/dep_wxWidgets' | head -1)
# macOS: deps/build/<arch>/dep_wxWidgets-prefix/src/dep_wxWidgets Linux: deps/build/dep_wxWidgets-prefix/...
# If deps are not built: git clone --depth 1 -b v3.3.2 https://github.com/SoftFever/Orca-deps-wxWidgets
grep -n "CaptureMouse" -A 30 $WX/interface/wx/window.h # documented contract (doxygen source)
grep -rn "@onlyfor\|not implemented" $WX/interface/wx/popupwin.h # documented platform limits
ls $WX/docs/doxygen/overviews/ # eventhandling.h, sizer.h, high_dpi.md, windowdeletion.h, ...
grep -n "IsDark" $WX/docs/changes.txt # what changed in 3.3 (changes_32.txt for 3.2)
grep -n "NotifyCaptureLost" -r $WX/src/osx $WX/src/gtk $WX/src/msw # what each port actually does
```
`interface/wx/<class>.h` is the documentation; `src/common` holds shared behaviour and
`src/{msw,osx,gtk,unix,generic}` the per-port implementation. When the docs and the source disagree,
the source is what runs — the references mark such facts **[source]**. Orca-side design docs live in
`docs/HLSD/` (`keyboard-shortcuts.md`, `deferred-page-construction.md`, `design-tab.md`,
`printer-agent.md`).
## Golden rules
1. **Interactive controls are Orca widgets** (`Button` + `SetStyle(...)`, `::CheckBox`, `::ComboBox`,
`::TextInput`, `SpinInput`, `SwitchButton`, `RadioGroup`, `TabCtrl`); the bottom row of every dialog is
`DialogButtons`. Never `wxButton`, `wxSpinCtrl`, `wxCheckBox`, `wxChoice` or a single-line `wxTextCtrl`
in new code; multi-line text is a raw `wxTextCtrl`. Inside `Slic3r::GUI` write `::CheckBox` etc. —
`Field.hpp` has classes with the same names. → `orca-widgets.md`
2. **DPI.** Layout pixel values go through `FromDIP(n)` (or `n * em_unit()`); `wxBitmap` constructor
sizes, image-list sizes and GL viewports are physical and are not `FromDIP`'d (icon heights passed to
`create_scaled_bitmap`/`ScalableBitmap`/`Button` are DIP). Top-level windows are `DPIDialog`/
`DPIFrame`; `on_dpi_changed` re-rasterises bitmaps (and re-sets them on the controls showing them),
calls each widget's `Rescale()`, re-applies stored sizes and finishes with
`GetSizer()->SetSizeHints(this)`; it never runs on macOS. → `dpi-bitmaps-fonts.md`, `sizers-layout.md`
3. **Dark mode.** Ask `wxGetApp().dark_mode()`, never `wxSystemSettings::GetAppearance().IsDark()`. Use
palette colours through `StateColor` (specific states first, `Normal` last) and pass a literal light
colour through `StateColor::darkModeColorFor()`; give every panel you create an explicit palette
background before creating widgets in it; end each dialog constructor with
`wxGetApp().UpdateDlgDarkUI(this)`; re-apply hand-picked colours and name-selected icons in
`on_sys_color_changed()` (a cached or long-lived window must also be reached from
`MainFrame::on_sys_color_changed`). → `colours-dark-mode.md`
4. **Strings.** Mark user text with `_L` / `_u8L` / `L` (and the `_CONTEXT` / `_L_PLURAL` forms) — never
`_()`, which xgettext does not extract. Build messages with `format_wxstr(_L("… %1% …"), arg)`;
convert with `from_u8()` / `into_u8()` (`from_path()` / `into_path()` for paths), never `ToStdString()`
or an implicit `std::string` → `wxString`. → `strings-i18n-files.md`
5. **Messages to the user** use the `MsgDialog` family (`MessageDialog`, `RichMessageDialog`,
`WarningDialog`, `ErrorDialog`, `InfoDialog`, `show_error`/`show_info`), never `wxMessageBox` once the
GUI exists. ESC and the close box return `wxID_CANCEL`, so test for the positive answer
(`== wxID_YES`); `show_error` is asynchronous. → `windows-dialogs.md`
6. **Events.** `Bind()` with handlers taking the event **by reference**; no new static event tables.
`Skip()` every non-command event you do not fully replace (focus, size, key, DPI, colour change, mouse
on custom widgets), and any event — command events included, e.g. `::CheckBox`'s
`wxEVT_TOGGLEBUTTON` — bound on an Orca widget, wx control or window whose own class also handles it:
your later-bound handler runs first. Unbind lambdas bound on other objects. → `events.md`
7. **Threads.** Only the main thread touches wx. Workers marshal with `wxGetApp().CallAfter([by-value
captures]{ … })` and the lambda re-checks liveness (a `std::shared_ptr<std::atomic<bool>>` alive flag,
`is_closing()`) before touching anything — `wxWeakRef` is main-thread only, never created, copied or
tested on a worker; never `wxPostEvent` from a worker. UI-initiated background work is a `Job` on a
`Worker`. → `threads-timers-app.md`, `events.md`
8. **Lifetime.** Heap windows die by `Destroy()`, not `delete`; modal dialogs end with
`EndModal(wxID_*)` (an id, never a `wxOK`/`wxCANCEL` style bit); ESC and the close box never run an
Orca Cancel button's handler, so cancel cleanup goes where every path ends. No window work on the stack
of a mouse handler or a WebView script-message callback — `CallAfter` it. → `windows-dialogs.md`
9. **Layout.** `SetSizerAndFit(sizer)` on top-level windows (AGENTS.md rule), plain `SetSizer` on child
panels; proportion is the second `Add` argument; a scrolled window needs `SetScrollRate` and
`FitInside()` after content changes; re-wrap a label with `Wrap(-1); Wrap(w);` or use `Label`. Never
commit a size or wrap from a width not laid out yet: a `wxDefaultSize` child is 20×20 on every port
until the first sizer layout. → `sizers-layout.md`
10. **Mouse capture.** `if (!HasCapture()) CaptureMouse();` / `if (HasCapture()) ReleaseMouse();` at every
site; `wxEVT_MOUSE_CAPTURE_LOST` *cancels* the gesture (never commits); release before a modal, hide or
destroy. macOS never sends capture-lost, and a leaked capture there leaves the app alive but
unclickable (keyboard still works). → `mouse-keyboard-focus.md`
11. **Popups.** Derive from Orca's `PopupWindow` and pass `wxPU_CONTAINS_CONTROLS` when it hosts
controls; size it before `Position()`; on macOS anchor a hover-driven popup flush to its opener; on
MSW call `BindUnfocusEvent()` when it must close with the frame; use a frameless
`wxDialog` for content that needs typing focus or hosts a WebView. → `popups-menus.md`
12. **Painting.** Draw only in the `wxEVT_PAINT` handler, change state then `Refresh()`; never draw
through `wxClientDC` (no effect on macOS and Wayland). → `painting-custom-widgets.md`
13. **Cross-platform.** Every change works on Windows, macOS and Linux GTK3 under X11 and Wayland. No
global pointer coordinates (`wxGetMousePosition()`) in logic that must work on Wayland; decide
X11/Wayland at runtime with `is_running_on_wayland()`. → `platforms.md`
14. **Registration.** New sources go into `SLIC3R_GUI_SOURCES` in `src/slic3r/CMakeLists.txt` (platform-only
files into the `if (WIN32)` / `if (APPLE)` blocks, CAD UI into `if (SLIC3R_CAD)`, `GUI/DeviceCore` /
`GUI/DeviceTab` files into their own `CMakeLists.txt`), and a new file with translatable strings into
`localization/i18n/list.txt`. → `orca-architecture.md`, `strings-i18n-files.md`
## The standard dialog
Exemplars: `src/slic3r/GUI/CloneDialog.cpp` (minimal), `FilamentPickerDialog.cpp` (larger, `Create*()`
helpers), `PurgeModeDialog.cpp` (custom-painted clickable cards). Full conventions, `DialogButtons` id
semantics and the `MsgDialog` API are in `windows-dialogs.md` §8–§9.
```cpp
class MyDialog : public DPIDialog
{
public:
explicit MyDialog(wxWindow* parent)
: DPIDialog(parent ? parent : static_cast<wxWindow*>(wxGetApp().mainframe), wxID_ANY,
_L("My Dialog"), wxDefaultPosition, wxDefaultSize,
wxCAPTION | wxCLOSE_BOX) // always pass a style: DPIAware's default is wxDEFAULT_FRAME_STYLE
{
SetBackgroundColour(*wxWHITE); // light palette colour, dark-mapped by UpdateDlgDarkUI
SetFont(Label::Body_14);
auto* sizer = new wxBoxSizer(wxVERTICAL);
// ... Orca widgets, sizes via FromDIP(n), text via _L() ...
sizer->Add(content_sizer, 1, wxEXPAND | wxALL, FromDIP(10));
auto* btns = new DialogButtons(this, {"OK", "Cancel"}); // untranslated: DialogButtons calls _L() and assigns wxID_OK/wxID_CANCEL
btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { /* apply */ EndModal(wxID_OK); });
btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); });
sizer->Add(btns, 0, wxEXPAND);
// cancel cleanup that must also run on ESC / close box: after ShowModal() returns, or in wxEVT_CLOSE_WINDOW
SetSizerAndFit(sizer);
CenterOnParent(); // after fitting: DPIAware centred the empty window
wxGetApp().UpdateDlgDarkUI(this); // last, after every child exists
}
protected:
void on_dpi_changed(const wxRect&) override
{
// msw_rescale() ScalableBitmaps and re-SetBitmap() them, Rescale() Orca widgets, re-apply FromDIP/em
// sizes (no msw_buttons_rescale(): it would override the DialogButtons' style height), then:
GetSizer()->SetSizeHints(this);
Refresh();
}
};
MyDialog dlg(this); // modal on the caller's stack
if (dlg.ShowModal() == wxID_OK) { /* read results */ }
```
## Orca widgets at a glance
| Orca widget (`src/slic3r/GUI/Widgets/`) | Instead of | Must know |
|---|---|---|
| `Button` | `wxButton` | ctor `(parent, text, icon_name, style, iconSize, id)` — id is last; style with `SetStyle(ButtonStyle::…, ButtonType::…)`; emits `wxEVT_BUTTON`; not a `wxButton` (no dialog default/escape emulation) |
| `::CheckBox` | `wxCheckBox` | no label parameter — pair with a `wxStaticText`/`Label`; emits `wxEVT_TOGGLEBUTTON` (never `wxEVT_CHECKBOX`), and a handler bound on it must `Skip()` or the bitmap is not refreshed |
| `::ComboBox` (+ `DropDown`) | `wxComboBox`/`wxChoice` | `wxCB_READONLY` for choices; `SelectAndNotify(n)` fires the event; ignores the ctor id; `void*` client data only |
| `::TextInput` | single-line `wxTextCtrl` | text via `GetTextCtrl()`; bind `wxEVT_TEXT_ENTER`/`wxEVT_KILL_FOCUS` on the widget, never on a parent |
| `SpinInput` | `wxSpinCtrl` | non-negative integers only (`-` cannot be typed); commits on Enter, kill-focus and arrow steps with `wxEVT_SPINCTRL`, a plain `wxCommandEvent` — bind a `wxCommandEvent&` handler and read `GetValue()` |
| `DialogButtons` | `wxStdDialogButtonSizer` | untranslated labels (custom ones written `L("…")`); OK/Cancel close by id, Yes/No/Apply/Confirm do not |
| `Label` | `wxStaticText` | `LB_AUTO_WRAP`, `LB_HYPERLINK` (only through `SetWindowStyleFlag`); also hosts the font table `Label::Head_*`/`Label::Body_*` |
| `SwitchButton`, `RadioGroup`, `TabCtrl`, `LabeledStaticBox`, `StaticLine`, `ProgressBar`, `PopupWindow` | toggle, `wxRadioBox`, notebook bar, `wxStaticBox`, `wxStaticLine`, `wxGauge`, `wxPopupTransientWindow` | quirks per widget in `orca-widgets.md` |
Disabling a parent does not repaint Orca widgets as disabled — enable/disable them individually (`::CheckBox`, a native
button, is the exception: it greys with its parent).
Containers stay raw (`wxPanel`, `wxBoxSizer`, `wxScrolledWindow`, `wxSimplebook`).
## Where to read next
| You are … / the symptom is … | Read |
|---|---|
| creating or closing a dialog or frame, `Destroy`/`delete`, modal results, liveness of a window pointer, message boxes | `references/windows-dialogs.md` |
| binding or emitting events, `Skip()`, propagation, custom events, `CallAfter`, `UPDATE_UI`, idle | `references/events.md` |
| worker-thread callbacks, background jobs, timers, `wxYield`/nested loops, progress dialogs, startup/shutdown | `references/threads-timers-app.md` |
| a sizer, a dialog that is collapsed, too big or clipped, a scrolled list, label wrapping, relayout after DPI change | `references/sizers-layout.md` |
| a custom-drawn control or a new widget in `Widgets/`, flicker, paint/erase, `wxGCDC`, best size | `references/painting-custom-widgets.md` |
| `FromDIP`/`em_unit`, rescale on DPI change, icons and bitmaps, image lists, fonts | `references/dpi-bitmaps-fonts.md` |
| colours, dark mode, theme toggle, `StateColor`, icons invisible in dark mode | `references/colours-dark-mode.md` |
| drag gestures and mouse capture, a frozen/unclickable UI on macOS, hover, wheel, keyboard shortcuts, focus, tooltips, cursors | `references/mouse-keyboard-focus.md` |
| popups and dropdowns (closing at once, not closing), context menus, the menu bar, `MenuFactory` | `references/popups-menus.md` |
| text/combo/check/radio/spin controls, book controls, `wxGrid`, `wxDataViewCtrl`, the object list | `references/controls-dataview.md` |
| WebView pages and dialogs, JS ↔ C++ messages, the GL canvas, ImGui overlays, docking panes, the top bar, camera view | `references/webview-gl-aui-media.md` |
| translations, string conversion and formatting, file/dir dialogs, clipboard, drag and drop, logging, `AppConfig` | `references/strings-i18n-files.md` |
| where new code goes, `GUI_App`/`MainFrame`/`Plater` structure, lazy pages, Preferences, notifications | `references/orca-architecture.md` |
| which Orca widget to use and its quirks | `references/orca-widgets.md` |
| adding or changing a print/filament/printer setting, a settings field, per-object overrides | `references/orca-settings-ui.md` |
| platform `#ifdef`s, wx build options, Wayland gaps, title bars, a bug on one platform only | `references/platforms.md` |
| old code, a wx call that behaves differently than you remember, wx-version migration | `references/wx-33-changes.md` |
**Reviewing a GUI diff:** for each area the diff touches, check it against that file's `## Rules`
list. **Debugging a UI bug:** find the symptom in the table above; most recurring Orca UI bugs are a
known class with a pitfall entry and a fixing commit.
## Platform gotchas worth memorising
- **macOS:** capture-lost is never sent (a leaked capture freezes all clicks); transient popups hover-
dismiss across a gap — anchor flush and re-verify the cursor; native modals (file/dir dialogs, native
message boxes) and generic progress dialogs re-activate the main window, so re-raise a secondary window
afterwards with a deferred, liveness-guarded `Raise()`; a live menu accelerator consumes the key before
any wx key event; Control+click arrives as a right-click.
- **Windows:** `IsDark()` and `wxSYS_COLOUR_*` follow the system app mode, not Orca's theme — use
`dark_mode()`; menu bitmaps follow `check_dark_mode()`; windows are not double-buffered by default in
3.3.2; `ProcessLeftDown` is never called for popups; a popup that must close with the frame calls
`BindUnfocusEvent()`.
- **Linux GTK3:** dialogs without size hints collapse (only sizer-fitting calls, not `Fit()`, are
replayed at the first `Show()`); command events from a popup's children are not stopped at the popup
(MSW/macOS stop them); chained popups need `transient_for` set to the mapped parent right before
showing; native borders leak through custom widgets (`RemoveButtonBorder`/`RemoveInputBorder`).
- **Wayland:** no global pointer position, no window positioning, `wxClientDC`, `Update()` and `SetIcon`
do nothing, no floating AUI panes, GL is EGL only.
## Editing this skill
The skill describes how the pinned wxWidgets behaves and how Orca GUI code must be written, not what
the GUI tree currently contains. State rules, contracts, mechanisms, stable component designs and code
shapes; never usage counts, census lists, lists of today's offenders or dated measurements. Test each
sentence: if a change to Orca code the sentence does not name, with the wx pin unchanged, could make it
false, state the rule behind it or give a generic example instead, and cut it if there is no rule
behind it. Example files named as models to copy, and commit hashes cited as the reason for a rule,
are fine.
Keep the skill in step with what it describes. A change that alters a contract the skill states (an
Orca widget's API or quirk, a shared helper such as `DPIAware` or `UpdateDlgDarkUI`, the Shortcuts
registry) updates the skill in the same change. Moving the wx pin (`GIT_TAG` in
`deps/wxWidgets/wxWidgets.cmake`) means re-verifying every wx citation and **[source]** fact against
the new tree, and adding that version's behaviour changes the way `wx-33-changes.md` records 3.3's.
Cite wx by path relative to the wx tree root with line (`interface/wx/window.h:3805`), which stays
valid because the version is pinned. Cite Orca by file and symbol, never by line, and commits as the
rationale for a rule. Mark behaviour the wx docs don't state, or contradict, as **[source]**. Each
concept lives in one reference file and the others point to it. Verify every new claim in the wx tree
or the Orca code before adding it, and never drop a fact, qualifier or example to save words.
`evals/evals.json` holds the regression tasks (planted-defect review patches in `evals/files/`); rerun
them with and without the skill after substantial edits.
@@ -0,0 +1,204 @@
{
"skill_name": "orca-wxwidgets",
"evals": [
{
"id": 1,
"name": "report-issue-dialog",
"prompt": "In the OrcaSlicer repo, add a new dialog that opens from the Help menu called 'Report Issue'. It needs a dropdown to pick the issue type (Bug / Feature request / Question), a multiline text box for the description, a checkbox 'Include system info', and OK/Cancel buttons at the bottom. It has to look correct in dark mode and on high-DPI screens.",
"files": [],
"assertions": [
"The dialog class derives from DPIDialog and overrides on_dpi_changed(const wxRect&) with a body that rescales its custom widgets (Rescale()/msw_rescale) and re-lays out (Layout/Fit/Refresh or equivalent)",
"The dropdown is Orca's custom ComboBox (::ComboBox from Widgets/ComboBox.hpp), not wxComboBox/wxChoice",
"The checkbox is Orca's custom ::CheckBox (constructed without a label) paired with a separate wxStaticText/Label for the text",
"The bottom row is DialogButtons constructed with untranslated labels such as {\"OK\", \"Cancel\"} (not _L(\"OK\")), and handlers end the dialog with EndModal(wxID_OK/wxID_CANCEL), not Destroy()",
"The multiline description is a raw wxTextCtrl created with wxTE_MULTILINE, not TextInput (TextInput::DoSetSize keeps the inner control at its single-line height), explicitly made dark-safe (UpdateDarkUI/UpdateDlgDarkUI or StateColor::darkModeColorFor colours), and its text is read via GetValue()",
"Every hard-coded pixel size/padding goes through FromDIP(n) (or em_unit multiples); no raw pixel literals in sizes/borders",
"All user-visible strings are wrapped in _L(...)",
"The top-level sizer is installed with SetSizerAndFit (or SetSizer followed by SetSizeHints)",
"wxGetApp().UpdateDlgDarkUI(this) is called as the last step of the constructor, after all children exist",
"The new .cpp/.hpp are added to SLIC3R_GUI_SOURCES in src/slic3r/CMakeLists.txt",
"A Help-menu item is added in MainFrame's menu construction (append_menu_item or equivalent) that opens the dialog with ShowModal()"
]
},
{
"id": 2,
"name": "new-print-setting",
"prompt": "Add a new print setting to OrcaSlicer: 'seam_transition_gap', a float in mm, default 0.1, range 0-2, shown on the Quality page in the Seam group with a proper tooltip. It should be saved with process presets and searchable.",
"files": [],
"assertions": [
"A ConfigOptionDef is added in PrintConfig.cpp via this->add(\"seam_transition_gap\", coFloat) with label, tooltip, sidetext \"mm\", min 0, max 2, a mode, and default ConfigOptionFloat(0.1)",
"label/tooltip/sidetext in PrintConfig.cpp use the L(\"...\") extraction marker, not _L/_u8L",
"A ((ConfigOptionFloat, seam_transition_gap)) entry is added to the matching PRINT_CONFIG_CLASS_DEFINE block in PrintConfig.hpp",
"The key is appended to s_Preset_print_options in Preset.cpp",
"TabPrint::build() gets optgroup->append_single_option_line(\"seam_transition_gap\", ...) inside the Quality page's Seam option group",
"The answer states that search indexing comes automatically from get_option/append_single_option_line (no manual search registration)",
"Slicing invalidation is handled: the key is added to PrintObject::invalidate_state_by_config_options (or Print::invalidate_state_by_config_options) under an appropriate step"
]
},
{
"id": 3,
"name": "macos-hover-popup-dismiss",
"prompt": "OrcaSlicer bug in a feature branch: the sidebar's filament-sync button now opens a small hover menu (code below). On macOS the menu appears and then disappears before the user can click either item — moving the mouse from the button down into the menu closes it, and sometimes it closes while the cursor is already over it. It works on Windows. Find the causes and fix the code.\n\n```cpp\n// Widgets/FilamentSyncMenu.hpp/.cpp (new, opened when the cursor enters the sidebar's sync button)\nclass FilamentSyncMenu : public PopupWindow\n{\npublic:\n explicit FilamentSyncMenu(wxWindow* parent) : PopupWindow(parent, wxBORDER_NONE)\n {\n auto* sizer = new wxBoxSizer(wxVERTICAL);\n auto* colours = new Button(this, _L(\"Sync colours only\"));\n auto* all = new Button(this, _L(\"Sync colours and types\"));\n colours->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { Dismiss(); wxGetApp().sidebar().sync_ams_list(); });\n all->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { Dismiss(); wxGetApp().sidebar().sync_ams_list(true); });\n sizer->Add(colours, 0, wxEXPAND | wxALL, FromDIP(4));\n sizer->Add(all, 0, wxEXPAND | wxALL, FromDIP(4));\n SetSizerAndFit(sizer);\n\n m_timer.SetOwner(this);\n Bind(wxEVT_TIMER, [this](wxTimerEvent&) { Dismiss(); });\n Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent&) { m_timer.StartOnce(300); });\n Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent&) { m_timer.Stop(); });\n }\n\n void popup_under(wxWindow* btn)\n {\n wxPoint pos = btn->ClientToScreen(wxPoint(0, 0));\n Position(pos, {0, btn->GetSize().y + FromDIP(6)});\n Popup();\n }\n\nprivate:\n wxTimer m_timer;\n};\n\n// Sidebar constructor:\nm_sync_menu = new FilamentSyncMenu(ams_btn);\nams_btn->Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& e) { m_sync_menu->popup_under(ams_btn); e.Skip(); });\n```",
"files": [],
"assertions": [
"Identifies the opener-to-popup gap as a cause: the FromDIP(6) offset leaves a dead zone, and crossing it from the button into the menu ends the hover (LEAVE / hover-timer path) and dismisses the menu on macOS",
"Identifies that on macOS ENTER/LEAVE events around a wxPopupTransientWindow arrive spuriously or out of order (capture handling), so the hover timer must not trust them: re-verify the real cursor position with GetClientRect().Contains(ScreenToClient(wxGetMousePosition())) (or equivalent geometry) before starting/stopping the timer or dismissing",
"Fix anchors the menu flush with (or slightly overlapping) the button instead of leaving a gap, at least on macOS",
"Adds wxPU_CONTAINS_CONTROLS to the PopupWindow style because the menu hosts interactive Button children",
"Guards against calling Popup() again while the menu is already shown (re-entering the button) and/or debounces reopening right after a dismissal",
"The fix is gated to macOS where needed (#ifdef __WXOSX__/__APPLE__) or argued harmless elsewhere, and does not rely on unexplained SetFocus/CallAfter/Raise hacks"
]
},
{
"id": 4,
"name": "dark-mode-icons",
"prompt": "Users report that a couple of the AMS status icons in OrcaSlicer are nearly invisible when dark mode is on, but fine in light mode. What's going on and how do we fix it properly?",
"files": [],
"assertions": [
"Explains that BitmapCache::load_svg recolors dark-mode icons by literal substitution of a fixed palette (e.g. #262E30 -> #EFEFF0) and off-palette colors pass through unchanged",
"Offers the asset fix: re-author the SVG fills in palette colors (e.g. near-black line art as #262E30)",
"Offers the variant fix: a *_dark asset selected by name from wxGetApp().dark_mode()",
"States that name-selected variants must be re-picked/reloaded on a runtime theme switch (on_sys_color_changed / sys_color_changed / msw_rescale path)",
"Investigates the actual AMS icon code/assets (e.g. checks both branches of a dark_mode() ternary for a copy-paste _light/_light bug, or inspects the SVG fills)"
]
},
{
"id": 5,
"name": "custom-painted-drag-widget",
"prompt": "In OrcaSlicer, add a small owner-drawn widget for the filament area of the sidebar: a horizontal strip of colour swatches (one per filament) where the user can click-and-drag across swatches to select a contiguous range. Hovered swatches should highlight, it must emit an event with the selected range when the drag ends, and it must look right with high-DPI and dark mode. Write the widget.",
"files": [],
"assertions": [
"Paints only inside a wxEVT_PAINT handler using wxAutoBufferedPaintDC/wxBufferedPaintDC/wxPaintDC with SetBackgroundStyle(wxBG_STYLE_PAINT); never draws through wxClientDC; triggers repaint with Refresh()/RefreshRect()",
"CaptureMouse() on press is guarded (if (!HasCapture())) and ReleaseMouse() is guarded (if (HasCapture()))",
"Handles wxEVT_MOUSE_CAPTURE_LOST by ending the gesture: drag state reset and repaint, no recapture (cancelling rather than committing the selection is what the wx contract asks for)",
"Ensures capture cannot leak: release on every end-of-drag path regardless of drag flags (and/or in the destructor), mentioning the macOS consequence (UI unclickable) or the wx contract",
"Sizes use FromDIP (or em_unit) and the widget reports a size via DoGetBestSize/DoGetBestClientSize or SetMinSize, rescaled on DPI change (Rescale/msw_rescale/wxEVT_DPI_CHANGED)",
"Colours go through StateColor / StateColor::darkModeColorFor or are re-derived from wxGetApp().dark_mode() and re-applied on theme change (sys_color_changed/on_sys_color_changed)",
"Defines a custom event with wxDECLARE_EVENT/wxDEFINE_EVENT (e.g. a wxCommandEvent carrying the range) and uses Bind(), not a new static event table",
"Hover handling uses event-relative coordinates (evt.GetPosition()), not wxGetMousePosition(), and only refreshes when the hovered index actually changes"
]
},
{
"id": 6,
"name": "worker-thread-progress",
"prompt": "In OrcaSlicer, we're adding a non-modal 'Firmware download' dialog. A network library calls our progress callback (int percent, std::string status) on its own worker thread, and a completion callback (bool ok, std::string error) at the end. The dialog shows a progress bar and status text and has a Cancel button; the user may also close the dialog at any time while the download continues. Implement the dialog and the callback wiring.",
"files": [],
"assertions": [
"No wx/GUI calls are made on the worker thread; callbacks marshal to the main thread via CallAfter (wxGetApp().CallAfter / window CallAfter) or wxQueueEvent",
"Deferred lambdas capture data by value (copies of percent/status strings), not references to worker-owned data",
"Deferred work re-checks the dialog's liveness inside the lambda (shared_ptr<atomic<bool>> alive flag, wxWeakRef, or registry lookup) because the dialog can be closed before the callback runs",
"If events are used across threads, they are heap-allocated/owned (wxQueueEvent with new/Clone, or wxThreadEvent) — not wxPostEvent/AddPendingEvent with wxString payload from the worker",
"Closing the dialog cancels or detaches the download callbacks and the non-modal dialog is destroyed with Destroy() (not delete), with any wxTimer stopped first",
"Uses Orca UI conventions: DPIDialog base, Orca ProgressBar or custom widgets, DialogButtons/Button for Cancel, _L strings, FromDIP sizes, UpdateDlgDarkUI",
"Strings crossing to the GUI are converted with from_u8() (UTF-8 std::string -> wxString), not implicit/ToStdString conversions"
]
},
{
"id": 7,
"name": "scrolled-dynamic-layout",
"prompt": "OrcaSlicer bug on Linux: in a settings dialog, a wxScrolledWindow holds a list of rows that the user can add with a '+' button. After adding rows, the scrollbar doesn't appear/update and new rows are cut off; when the dialog first opens on GTK it's sometimes collapsed to a tiny size; and some German labels in the rows are clipped. Explain the causes and give the correct wx code patterns to fix all three.",
"files": [],
"assertions": [
"For the scroll issue: after adding rows, call FitInside() on the scrolled window (or SetVirtualSize/Layout so the virtual size updates), with SetScrollRate set so scrollbars are enabled",
"Explains that changing children doesn't change the scrolled window's size, so automatic layout doesn't run — Layout() (and FitInside) must be called explicitly after adding content",
"For the collapsed dialog: uses SetSizerAndFit on the top-level dialog or SetSizer + sizer->SetSizeHints(dialog) so the min size is set (GTK needs the min-size hint), and avoids an unconditional Fit() in on_dpi_changed/refresh paths",
"For clipped labels: avoids fixed widths/heights on wxStaticText (use -1 / best size) and re-Wrap()s after SetLabel or uses Label with LB_AUTO_WRAP; mentions translations being longer",
"Mentions InvalidateBestSize()/Layout() of the containing hierarchy (or parent->Layout()) after dynamic content changes, and/or Freeze()/Thaw() around bulk row creation",
"Notes GTK specifics: Linux builds wx against GTK3 by default (deps/CMakeLists.txt DEP_WX_GTK3 ON) and/or the first GTK size pass can run with a bogus tiny client width, so wrap/height calculations need a guard"
]
},
{
"id": 8,
"name": "keyboard-shortcut",
"prompt": "Add a global keyboard shortcut Ctrl+Shift+E (Cmd+Shift+E on macOS) to OrcaSlicer that exports the current plate's G-code (same as the existing export action). It must work on all three platforms and show up wherever OrcaSlicer documents shortcuts.",
"files": [],
"assertions": [
"Explains that wxACCEL_CTRL / 'Ctrl' in menu accelerator strings maps to Cmd on macOS (and WXK_RAW_CONTROL / wxACCEL_RAW_CTRL is the real Control key)",
"Registers the shortcut through Orca's shortcut registry (Shortcut enum + shortcut_table in src/slic3r/GUI/Shortcuts.cpp, Global context dispatched from MainFrame's wxEVT_CHAR_HOOK) and/or the menu item whose accelerator text is derived from it, dispatching to the existing export handler",
"Checks for conflicts with existing shortcuts (registry defaults, GLCanvas3D/ObjectList contexts, menu items) before choosing the binding",
"The shortcut appears in KBShortcutsDialog by virtue of the registry (or is explicitly added there), and the answer checks/handles a conflict with an existing default chord",
"Accounts for platform differences in Orca's menus: native wxMenuBar on macOS vs BBLTopbar/custom menus on Windows/Linux, so the shortcut works where no native menubar exists"
]
},
{
"id": 9,
"name": "webview-dialog",
"prompt": "Add a dialog to OrcaSlicer that shows a local HTML page from resources/web/ in a WebView. The page has a 'Done' button that posts a JS message; when it arrives the dialog must close and return a result to the caller. It must work on Windows (Edge), macOS (WKWebView) and Linux (WebKitGTK).",
"files": [],
"assertions": [
"Creates the browser through Orca's WebView wrapper (WebView::CreateWebView in Widgets/WebView.hpp), not wxWebView::New directly",
"Receives messages via wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED using the 'wx' handler the wrapper registers (window.wx.postMessage / postMessage), without adding a duplicate script message handler (which throws an uncatchable NSException on WKWebView)",
"Closing/ending the dialog from the script-message handler is deferred with CallAfter (not done synchronously inside the WebView callback stack)",
"Builds the local page URL from Orca's resources dir (resources_dir()/from_u8 + file:// URL or wxFileName::FileNameToURL), handling paths portably",
"Accounts for asynchronous backend creation (Edge): no script runs/calls before the page is loaded (wxEVT_WEBVIEW_LOADED) or the wrapper's deferral",
"Dialog follows Orca conventions: DPIDialog base, on_dpi_changed, UpdateDlgDarkUI/dark-mode handling, EndModal with a result id"
]
},
{
"id": 10,
"name": "macos-frozen-ui",
"prompt": "On macOS, sometimes after dragging the handle of the new ratio bar widget inside a dialog in OrcaSlicer, the whole app stops responding to mouse clicks — even the window's close button — but Cmd+S still saves, timers keep running and the 3D view still repaints. Windows is fine. What is going on, how do we confirm it, and how should widget code like this be written?",
"files": [],
"assertions": [
"Identifies a leaked wx mouse capture (CaptureMouse without matching ReleaseMouse) as the cause, explicitly not a deadlock/hang",
"Explains why only the mouse is dead: on macOS wx routes all mouse events to the capturing window while key events are unaffected",
"Explains why macOS differs: wxEVT_MOUSE_CAPTURE_LOST is not delivered on macOS (wxOSX never generates it; the docs' @onlyfor{wxmsw} is stale, wxGTK sends it too) so nothing ever unwinds the leaked capture",
"Fix: guard CaptureMouse with !HasCapture() and ReleaseMouse with HasCapture(), release on every exit path (mouse-up independent of drag flags, capture-lost handler, before the dialog closes/destructor)",
"Handles wxEVT_MOUSE_CAPTURE_LOST (required by the wx contract wherever it can fire) without recapturing",
"Gives a way to confirm/locate it (e.g. sampling the process / checking GetCapture(), or auditing every CaptureMouse call site)"
]
},
{
"id": 11,
"name": "review-planted-wx-defects",
"prompt": "Review this proposed OrcaSlicer change before it is merged: the patch at .claude/skills/orca-wxwidgets/evals/files/filament-notes.patch adds a non-modal 'Filament notes' dialog (src/slic3r/GUI/FilamentNotesDialog.hpp/.cpp). It is not applied to the tree; read it from that path and use the repository for context. Report every correctness, cross-platform (Windows/macOS/Linux GTK, X11 and Wayland), threading, lifetime, DPI, dark-mode and wxWidgets API-misuse problem you find, each with its consequence and the fix. Do not report style nits.",
"files": [
"files/filament-notes.patch"
],
"assertions": [
"D1: Flags FromDIP() called on the SwatchPreview object inside its own base-class initializer (before wxPanel is constructed) as invalid/UB, and fixes it (parent->FromDIP / static wxWindow::FromDIP(sz, parent) / SetMinSize after construction)",
"D2: Flags the mouse-capture handling: CaptureMouse unguarded, ReleaseMouse gated on m_dragging rather than HasCapture(), and no wxEVT_MOUSE_CAPTURE_LOST handler — with the consequence (leaked capture freezes mouse input on macOS; wx's capture asserts are compiled out in Orca, so elsewhere the misuse fails silently) and the guarded pattern",
"D3: Flags hit-testing with ScreenToClient(wxGetMousePosition()) in the motion handler (unreliable on Wayland, stale vs the event) and fixes with e.GetPosition()",
"D4: Flags drawing the hover outline through wxClientDC outside the paint handler (no effect on macOS or GTK3 Wayland; on MSW and X11 it races and is overwritten by the next paint) and fixes by storing m_hover and Refresh()/RefreshRect() + drawing in the paint handler",
"D5: Flags the hard-coded wxColour(\"#009688\") pen as not dark-mode aware (should go through StateColor::darkModeColorFor or a dark-mode branch, re-derived on theme change)",
"D6: Flags SetValue() inside the wxEVT_TEXT handler (SetValue emits wxEVT_TEXT again: recursion/re-entrancy and caret reset) and fixes with ChangeValue() (plus an insertion-point restore or guard)",
"D7: Flags the wxEVT_KILL_FOCUS handler that does not call e.Skip() (breaks native focus handling of the text control)",
"D8: Flags that the wxStaticBoxSizer's child wxTextCtrl is created with the dialog as parent instead of box->GetStaticBox()",
"D9: Flags the raw pixel size wxSize(400, 160) (needs FromDIP)",
"D10: Flags m_notes->GetValue().ToStdString() as lossy for non-ASCII text and fixes with into_u8()/ToUTF8()",
"D11: Flags `delete this` in the wxEVT_CLOSE_WINDOW handler and fixes with Destroy() (deferred deletion after pending events)",
"D12: Flags SetSizer()+Layout() on the top-level dialog without SetSizerAndFit/SetSizeHints/Fit (no initial size / min size; GTK collapse) per the project rule",
"D13: Flags m_status->SetLabel() called on the worker thread (GUI call off the main thread)",
"D14: Flags the worker's wxPostEvent(this, evt) with a wxString payload: posting a wxString-carrying event from a worker thread is unsafe (use wxQueueEvent with a heap event / CallAfter), and `this` may be destroyed because the destructor detaches the thread — needs an alive flag/join/cancellation",
"D15: Flags that wxEVT_MENU is bound on m_more_btn although PopupMenu() is called on the dialog (menu events go to the invoking window and propagate up, never to a child), and that Bind runs on every popup (accumulating handlers); fix: bind on the dialog once or use GetPopupMenuSelectionFromUser",
"D16: Flags the heap wxTimer that is never stopped or deleted (leak; a pending tick can fire into the destroyed dialog) and fixes with Stop()+delete in the destructor or a by-value member",
"D17: Flags that start_sync() move-assigns m_sync_thread while the previous std::thread is still joinable (never joined or detached; a finished thread stays joinable), so the second sync ('Reload from cloud', live once the D15 routing is fixed) calls std::terminate; fix: no reassigned thread member (a detached worker per request behind the D14 alive flag) or detach/join the previous thread before reassigning, and/or refuse a reload while a sync is in flight",
"D18: Flags that SwatchPreview never releases the capture if it is destroyed while holding it (no guarded ReleaseMouse() in its destructor or on the dialog's close path): e.g. Esc, which DPIDialog maps to Close(), pressed with the button held on the strip destroys the panel while it is still on wx's capture stack (the 'Destroying window before releasing mouse capture' assert, or in Orca's assert-free build a dangling capture that freezes or crashes mouse input, notably on macOS); fix: drop the capture, or if (HasCapture()) ReleaseMouse() in the destructor / before close",
"Does not report false defects about correct code (e.g. DialogButtons' untranslated labels, wxBG_STYLE_PAINT + wxAutoBufferedPaintDC, Bind with lambdas)"
]
},
{
"id": 12,
"name": "review-subtle-wx-defects",
"prompt": "Review this proposed OrcaSlicer change before it is merged: the patch at .claude/skills/orca-wxwidgets/evals/files/preset-note.patch adds a sidebar 'note chip' widget and a modal 'Preset note' editor dialog (src/slic3r/GUI/PresetNoteDialog.hpp/.cpp). It is not applied to the tree; read it from that path and use the repository and the wxWidgets source for context. Report every correctness, cross-platform, i18n, DPI, dark-mode, lifetime and wxWidgets/Orca API-misuse problem you find, each with its consequence and the fix. Do not report style nits.",
"files": [
"files/preset-note.patch"
],
"assertions": [
"P1: Flags the StateColor built with the Normal entry first: colorForStates returns the first matching entry and Normal (mask 0) matches every state, so the Hovered/Pressed colours never show; fix: list Pressed, Hovered, then Normal last",
"P2: Flags the wxEVT_SIZE lambda taking wxSizeEvent by value: Skip() on the copy does not reach the original, so the event counts as handled and default/base size handling stops; fix: take the event by reference",
"P3: Flags that the capture-lost handler routes through on_left_up and therefore fires EVT_NOTE_CHIP_CLICKED (commits a click) when capture is lost; per the wx contract capture loss must cancel the operation: reset m_pressed/visual state without emitting the event and without recapturing",
"P4: Flags _(\"Preset note\") as never extracted for translation (Orca's xgettext keywords are L/_L/_u8L/..., not _), fix: _L",
"P5: Flags wxBitmapBundle::FromSVGFile as unavailable in Orca's wx build (NanoSVG off / no wxHAS_SVG — does not compile) and replaces it with Orca's ScalableBitmap/create_scaled_bitmap/ScalableButton icon loading by name",
"P6: Flags Bind(wxEVT_TEXT_ENTER, ..., m_input->GetId()) on the dialog as never firing: TextInput re-dispatches TEXT_ENTER only to the wrapper (ProcessEventLocally, no propagation to parents) and the inner control's handler does not Skip; fix: bind on m_input (the wrapper) or on GetTextCtrl()",
"P7: Flags RichMessageDialog::SetYesNoLabels as having no effect in Orca (labels are stored but never applied to the buttons), fix: MsgDialog::SetButtonLabel(wxID_YES/wxID_NO, ...) or a dialog with custom buttons",
"P8: Flags EndModal(wxCANCEL): wxCANCEL is a style flag, not the return code wxID_CANCEL, so ShowModal returns the wrong value; fix: EndModal(wxID_CANCEL)",
"P9: Flags that restore_original() runs only in the Cancel button handler: ESC (DPIDialog's char hook -> Close()) and the close box end the dialog with wxID_CANCEL without running the custom Button's handler, so the draft is not cleared; fix: do cleanup on every non-OK exit (after ShowModal returns, or in a close/EndModal path)",
"P10: Flags m_preview->SetLabel(...) followed by Wrap(FromDIP(360)) on every update: in wx 3.3.2 Wrap() is a no-op when the width equals the last wrap width, so updated labels stay unwrapped; fix: Wrap(-1) then Wrap(w), or wxST_WRAP / Orca Label with LB_AUTO_WRAP",
"P11 (not a defect): Does not claim that m_options->Enable(...) leaves the ::CheckBox or its wxStaticText label looking enabled: ::CheckBox is a native wxBitmapToggleButton, so the ancestor's disable greys it on every port without calling its Enable() override (MSW: NotifyWindowOnEnableChange -> DoEnable -> EnableWindow, and the owner-drawn button paints its SetBitmapDisabled art; GTK3: native insensitivity, and the button's wxGtkImage draws a greyed copy of its current bitmap while !IsEnabled(); macOS: setEnabled:NO, and AppKit dims the image), and wxStaticText greys natively. Suggesting pin->Enable() to get the designed disabled art on GTK is at most a nit",
"P12: Flags that the patch registers neither new file: they are not in SLIC3R_GUI_SOURCES in src/slic3r/CMakeLists.txt (never compiled), and PresetNoteDialog.cpp is not in localization/i18n/list.txt (run_gettext scans only the listed files, so its _L strings never reach OrcaSlicer.pot); fix: add both entries",
"Does not report false defects about correct code: the weak_ptr-guarded CallAfter is not a use-after-free (alive is checked before `this` is used), GetSizer()->SetSizeHints(this) in on_dpi_changed is fine, DialogButtons' untranslated labels are correct"
]
}
]
}
@@ -0,0 +1,252 @@
diff --git a/src/slic3r/GUI/FilamentNotesDialog.hpp b/src/slic3r/GUI/FilamentNotesDialog.hpp
new file mode 100644
--- /dev/null
+++ b/src/slic3r/GUI/FilamentNotesDialog.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+#include "GUI_Utils.hpp"
+
+#include <wx/timer.h>
+
+#include <string>
+#include <thread>
+#include <vector>
+
+class Button;
+class TextInput;
+
+namespace Slic3r { namespace GUI {
+
+// Strip of filament colour swatches; hovering a swatch outlines it.
+class SwatchPreview : public wxPanel
+{
+public:
+ SwatchPreview(wxWindow* parent);
+ void set_colours(const std::vector<wxColour>& colours);
+
+private:
+ void on_left_down(wxMouseEvent& e);
+ void on_left_up(wxMouseEvent& e);
+ void on_motion(wxMouseEvent& e);
+ void draw_hover(int index);
+
+ std::vector<wxColour> m_colours;
+ bool m_dragging{false};
+ int m_hover{-1};
+};
+
+// Non-modal editor for the user's notes on a filament, synced with the cloud.
+class FilamentNotesDialog : public DPIDialog
+{
+public:
+ FilamentNotesDialog(wxWindow* parent, const std::string& filament_id);
+ ~FilamentNotesDialog() override;
+
+protected:
+ void on_dpi_changed(const wxRect& suggested_rect) override;
+
+private:
+ void start_sync();
+ void on_sync_done(wxCommandEvent& e);
+ void show_more_menu();
+
+ std::string m_filament_id;
+ TextInput* m_name_input{nullptr};
+ wxTextCtrl* m_notes{nullptr};
+ wxStaticText* m_status{nullptr};
+ SwatchPreview* m_preview{nullptr};
+ Button* m_more_btn{nullptr};
+ wxTimer* m_autosave_timer{nullptr};
+ std::thread m_sync_thread;
+};
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/FilamentNotesDialog.cpp b/src/slic3r/GUI/FilamentNotesDialog.cpp
new file mode 100644
--- /dev/null
+++ b/src/slic3r/GUI/FilamentNotesDialog.cpp
@@ -0,0 +1,170 @@
+#include "FilamentNotesDialog.hpp"
+
+#include "GUI_App.hpp"
+#include "I18N.hpp"
+#include "Widgets/Button.hpp"
+#include "Widgets/DialogButtons.hpp"
+#include "Widgets/TextInput.hpp"
+
+#include <wx/dcbuffer.h>
+#include <wx/dcclient.h>
+#include <wx/menu.h>
+#include <wx/statbox.h>
+
+namespace Slic3r {
+// Provided by the cloud layer; blocking network call.
+std::string fetch_remote_note(const std::string& filament_id);
+
+namespace GUI {
+
+wxDEFINE_EVENT(EVT_NOTES_SYNC_DONE, wxCommandEvent);
+
+SwatchPreview::SwatchPreview(wxWindow* parent)
+ : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(240), FromDIP(28)))
+{
+ SetBackgroundStyle(wxBG_STYLE_PAINT);
+ Bind(wxEVT_PAINT, [this](wxPaintEvent&) {
+ wxAutoBufferedPaintDC dc(this);
+ dc.SetBackground(wxBrush(GetBackgroundColour()));
+ dc.Clear();
+ const wxSize sz = GetClientSize();
+ const int w = sz.x / std::max<int>(1, m_colours.size());
+ dc.SetPen(*wxTRANSPARENT_PEN);
+ for (size_t i = 0; i < m_colours.size(); ++i) {
+ dc.SetBrush(wxBrush(m_colours[i]));
+ dc.DrawRectangle(int(i) * w, 0, w, sz.y);
+ }
+ });
+ Bind(wxEVT_LEFT_DOWN, &SwatchPreview::on_left_down, this);
+ Bind(wxEVT_LEFT_UP, &SwatchPreview::on_left_up, this);
+ Bind(wxEVT_MOTION, &SwatchPreview::on_motion, this);
+}
+
+void SwatchPreview::set_colours(const std::vector<wxColour>& colours)
+{
+ m_colours = colours;
+ Refresh();
+}
+
+void SwatchPreview::on_left_down(wxMouseEvent& e)
+{
+ m_dragging = true;
+ CaptureMouse();
+}
+
+void SwatchPreview::on_left_up(wxMouseEvent& e)
+{
+ if (m_dragging) {
+ m_dragging = false;
+ ReleaseMouse();
+ }
+}
+
+void SwatchPreview::on_motion(wxMouseEvent& e)
+{
+ const wxPoint p = ScreenToClient(wxGetMousePosition());
+ const int w = GetClientSize().x / std::max<int>(1, m_colours.size());
+ const int idx = w > 0 ? p.x / w : -1;
+ if (idx != m_hover) {
+ m_hover = idx;
+ draw_hover(idx);
+ }
+ e.Skip();
+}
+
+void SwatchPreview::draw_hover(int index)
+{
+ wxClientDC dc(this);
+ dc.SetPen(wxPen(wxColour("#009688"), 2));
+ dc.SetBrush(*wxTRANSPARENT_BRUSH);
+ const int w = GetClientSize().x / std::max<int>(1, m_colours.size());
+ dc.DrawRectangle(index * w, 0, w, GetClientSize().y);
+}
+
+FilamentNotesDialog::FilamentNotesDialog(wxWindow* parent, const std::string& filament_id)
+ : DPIDialog(parent, wxID_ANY, _L("Filament notes"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE)
+ , m_filament_id(filament_id)
+{
+ SetBackgroundColour(*wxWHITE);
+ auto* sizer = new wxBoxSizer(wxVERTICAL);
+
+ m_name_input = new TextInput(this, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(300), -1));
+ m_name_input->GetTextCtrl()->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
+ wxString v = m_name_input->GetTextCtrl()->GetValue();
+ v.Trim(false);
+ m_name_input->GetTextCtrl()->SetValue(v.Upper());
+ m_autosave_timer->StartOnce(1000);
+ });
+ m_name_input->GetTextCtrl()->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent&) {
+ m_autosave_timer->StartOnce(1);
+ });
+ sizer->Add(m_name_input, 0, wxEXPAND | wxALL, FromDIP(10));
+
+ auto* box = new wxStaticBoxSizer(wxVERTICAL, this, _L("Notes"));
+ m_notes = new wxTextCtrl(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(400, 160), wxTE_MULTILINE);
+ box->Add(m_notes, 1, wxEXPAND | wxALL, FromDIP(6));
+ sizer->Add(box, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(10));
+
+ m_preview = new SwatchPreview(this);
+ sizer->Add(m_preview, 0, wxEXPAND | wxALL, FromDIP(10));
+
+ m_status = new wxStaticText(this, wxID_ANY, wxEmptyString);
+ sizer->Add(m_status, 0, wxLEFT | wxRIGHT, FromDIP(10));
+
+ m_more_btn = new Button(this, _L("More..."));
+ m_more_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { show_more_menu(); });
+ sizer->Add(m_more_btn, 0, wxALL, FromDIP(10));
+
+ auto* btns = new DialogButtons(this, {"OK", "Cancel"});
+ btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { Close(); });
+ btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { Close(); });
+ sizer->Add(btns, 0, wxEXPAND);
+
+ m_autosave_timer = new wxTimer(this);
+ Bind(wxEVT_TIMER, [this](wxTimerEvent&) {
+ wxGetApp().app_config->set("filament_note_" + m_filament_id, m_notes->GetValue().ToStdString());
+ wxGetApp().app_config->save();
+ });
+
+ Bind(EVT_NOTES_SYNC_DONE, &FilamentNotesDialog::on_sync_done, this);
+ Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent&) { delete this; });
+
+ SetSizer(sizer);
+ Layout();
+ wxGetApp().UpdateDlgDarkUI(this);
+
+ start_sync();
+}
+
+FilamentNotesDialog::~FilamentNotesDialog()
+{
+ if (m_sync_thread.joinable())
+ m_sync_thread.detach();
+}
+
+void FilamentNotesDialog::start_sync()
+{
+ m_sync_thread = std::thread([this]() {
+ m_status->SetLabel(_L("Syncing..."));
+ const std::string remote = fetch_remote_note(m_filament_id);
+ wxCommandEvent evt(EVT_NOTES_SYNC_DONE);
+ evt.SetString(wxString::FromUTF8(remote));
+ wxPostEvent(this, evt);
+ });
+}
+
+void FilamentNotesDialog::on_sync_done(wxCommandEvent& e)
+{
+ m_notes->SetValue(e.GetString());
+ m_status->SetLabel(_L("Synced"));
+}
+
+void FilamentNotesDialog::show_more_menu()
+{
+ wxMenu menu;
+ menu.Append(wxID_CLEAR, _L("Clear notes"));
+ menu.Append(wxID_REVERT, _L("Reload from cloud"));
+ m_more_btn->Bind(wxEVT_MENU, [this](wxCommandEvent& e) {
+ if (e.GetId() == wxID_CLEAR)
+ m_notes->Clear();
+ else
+ start_sync();
+ });
+ PopupMenu(&menu, m_more_btn->GetPosition() + wxPoint(0, m_more_btn->GetSize().y));
+}
+
+void FilamentNotesDialog::on_dpi_changed(const wxRect&)
+{
+ m_more_btn->Rescale();
+ Layout();
+ Refresh();
+}
+
+}} // namespace Slic3r::GUI
@@ -0,0 +1,242 @@
diff --git a/src/slic3r/GUI/PresetNoteDialog.hpp b/src/slic3r/GUI/PresetNoteDialog.hpp
new file mode 100644
--- /dev/null
+++ b/src/slic3r/GUI/PresetNoteDialog.hpp
@@ -0,0 +1,57 @@
+#pragma once
+
+#include "GUI_Utils.hpp"
+#include "Widgets/StaticBox.hpp"
+#include "Widgets/StateColor.hpp"
+
+#include <memory>
+
+class TextInput;
+
+namespace Slic3r { namespace GUI {
+
+wxDECLARE_EVENT(EVT_NOTE_CHIP_CLICKED, wxCommandEvent);
+
+// Rounded chip that shows a preset's note in the sidebar; clicking it opens the editor.
+class NoteChip : public StaticBox
+{
+public:
+ NoteChip(wxWindow* parent, const wxString& text);
+ void set_text(const wxString& text);
+ void Rescale();
+
+protected:
+ void doRender(wxDC& dc) override;
+
+private:
+ void on_left_down(wxMouseEvent& e);
+ void on_left_up(wxMouseEvent& e);
+ void on_capture_lost(wxMouseCaptureLostEvent& e);
+
+ wxString m_text;
+ bool m_pressed{false};
+ StateColor m_bg;
+};
+
+// Modal editor for the note attached to a preset.
+class PresetNoteDialog : public DPIDialog
+{
+public:
+ PresetNoteDialog(wxWindow* parent, const wxString& preset_name, const wxString& note);
+ wxString get_note() const;
+
+protected:
+ void on_dpi_changed(const wxRect& suggested_rect) override;
+
+private:
+ void update_preview();
+ void restore_original();
+
+ TextInput* m_input{nullptr};
+ wxStaticText* m_preview{nullptr};
+ wxPanel* m_options{nullptr};
+ wxString m_original;
+ std::shared_ptr<bool> m_alive;
+};
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/PresetNoteDialog.cpp b/src/slic3r/GUI/PresetNoteDialog.cpp
new file mode 100644
--- /dev/null
+++ b/src/slic3r/GUI/PresetNoteDialog.cpp
@@ -0,0 +1,178 @@
+#include "PresetNoteDialog.hpp"
+
+#include "GUI_App.hpp"
+#include "I18N.hpp"
+#include "MsgDialog.hpp"
+#include "Widgets/CheckBox.hpp"
+#include "Widgets/DialogButtons.hpp"
+#include "Widgets/Label.hpp"
+#include "Widgets/TextInput.hpp"
+
+#include <wx/bmpbndl.h>
+#include <wx/statbmp.h>
+
+namespace Slic3r { namespace GUI {
+
+wxDEFINE_EVENT(EVT_NOTE_CHIP_CLICKED, wxCommandEvent);
+
+NoteChip::NoteChip(wxWindow* parent, const wxString& text)
+ : StaticBox(parent, wxID_ANY)
+ , m_text(text)
+{
+ m_bg = StateColor(std::pair{wxColour("#F1F1F1"), (int) StateColor::Normal},
+ std::pair{wxColour("#DBDBDB"), (int) StateColor::Hovered},
+ std::pair{wxColour("#CECECE"), (int) StateColor::Pressed});
+ SetBackgroundColor(m_bg);
+ SetCornerRadius(FromDIP(4));
+ SetMinSize(wxSize(-1, FromDIP(24)));
+
+ Bind(wxEVT_LEFT_DOWN, &NoteChip::on_left_down, this);
+ Bind(wxEVT_LEFT_UP, &NoteChip::on_left_up, this);
+ Bind(wxEVT_MOUSE_CAPTURE_LOST, &NoteChip::on_capture_lost, this);
+ Bind(wxEVT_SIZE, [this](wxSizeEvent e) {
+ Refresh();
+ e.Skip();
+ });
+}
+
+void NoteChip::set_text(const wxString& text)
+{
+ m_text = text;
+ Refresh();
+}
+
+void NoteChip::Rescale()
+{
+ SetMinSize(wxSize(-1, FromDIP(24)));
+ Refresh();
+}
+
+void NoteChip::doRender(wxDC& dc)
+{
+ StaticBox::doRender(dc);
+ dc.SetFont(Label::Body_12);
+ dc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#262E30")));
+ const wxSize ext = dc.GetTextExtent(m_text);
+ dc.DrawText(m_text, FromDIP(8), (GetSize().y - ext.y) / 2);
+}
+
+void NoteChip::on_left_down(wxMouseEvent& e)
+{
+ m_pressed = true;
+ if (!HasCapture())
+ CaptureMouse();
+ Refresh();
+}
+
+void NoteChip::on_left_up(wxMouseEvent& e)
+{
+ if (HasCapture())
+ ReleaseMouse();
+ if (m_pressed) {
+ m_pressed = false;
+ wxCommandEvent evt(EVT_NOTE_CHIP_CLICKED, GetId());
+ evt.SetEventObject(this);
+ GetEventHandler()->ProcessEvent(evt);
+ }
+ Refresh();
+}
+
+void NoteChip::on_capture_lost(wxMouseCaptureLostEvent& e)
+{
+ wxMouseEvent up(wxEVT_LEFT_UP);
+ on_left_up(up);
+}
+
+PresetNoteDialog::PresetNoteDialog(wxWindow* parent, const wxString& preset_name, const wxString& note)
+ : DPIDialog(parent ? parent : static_cast<wxWindow*>(wxGetApp().mainframe), wxID_ANY,
+ _("Preset note"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX)
+ , m_original(note)
+ , m_alive(std::make_shared<bool>(true))
+{
+ SetBackgroundColour(*wxWHITE);
+ SetFont(Label::Body_14);
+ auto* sizer = new wxBoxSizer(wxVERTICAL);
+
+ auto* icon = new wxStaticBitmap(this, wxID_ANY,
+ wxBitmapBundle::FromSVGFile(from_u8(resources_dir() + "/images/note.svg"), wxSize(16, 16)));
+ sizer->Add(icon, 0, wxALL, FromDIP(10));
+
+ m_input = new TextInput(this, note, wxEmptyString, wxEmptyString, wxDefaultPosition,
+ wxSize(FromDIP(360), -1), wxTE_PROCESS_ENTER);
+ sizer->Add(m_input, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(10));
+ m_input->GetTextCtrl()->Bind(wxEVT_TEXT, [this](wxCommandEvent& e) {
+ update_preview();
+ e.Skip();
+ });
+ Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { EndModal(wxID_OK); }, m_input->GetId());
+
+ m_preview = new wxStaticText(this, wxID_ANY, wxEmptyString);
+ sizer->Add(m_preview, 0, wxEXPAND | wxALL, FromDIP(10));
+
+ m_options = new wxPanel(this);
+ m_options->SetBackgroundColour(*wxWHITE);
+ auto* opt_sizer = new wxBoxSizer(wxHORIZONTAL);
+ auto* pin = new ::CheckBox(m_options);
+ opt_sizer->Add(pin, 0, wxALIGN_CENTER_VERTICAL);
+ opt_sizer->Add(new wxStaticText(m_options, wxID_ANY, _L("Pin note to sidebar")), 0,
+ wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(6));
+ m_options->SetSizer(opt_sizer);
+ m_options->Enable(!note.empty());
+ sizer->Add(m_options, 0, wxALL, FromDIP(10));
+
+ auto* btns = new DialogButtons(this, {"OK", "Cancel"});
+ btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
+ if (m_input->GetTextCtrl()->GetValue().length() > 500) {
+ RichMessageDialog dlg(this, _L("The note is very long. Keep it anyway?"), _L("Preset note"),
+ wxYES_NO | wxICON_QUESTION);
+ dlg.SetYesNoLabels(_L("Keep"), _L("Shorten"));
+ if (dlg.ShowModal() != wxID_YES)
+ return;
+ }
+ EndModal(wxID_OK);
+ });
+ btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
+ restore_original();
+ EndModal(wxCANCEL);
+ });
+ sizer->Add(btns, 0, wxEXPAND);
+
+ SetSizerAndFit(sizer);
+ update_preview();
+ CenterOnParent();
+ wxGetApp().UpdateDlgDarkUI(this);
+}
+
+wxString PresetNoteDialog::get_note() const { return m_input->GetTextCtrl()->GetValue(); }
+
+void PresetNoteDialog::update_preview()
+{
+ m_preview->SetLabel(wxString::Format(_L("Shown in the sidebar as: %s"), get_note()));
+ m_preview->Wrap(FromDIP(360));
+
+ // Persist the draft a moment later, once typing settles.
+ std::weak_ptr<bool> alive = m_alive;
+ wxGetApp().CallAfter([this, alive] {
+ if (alive.expired() || IsBeingDeleted())
+ return;
+ wxGetApp().app_config->set("preset_note_draft", into_u8(get_note()));
+ });
+}
+
+void PresetNoteDialog::restore_original()
+{
+ m_input->GetTextCtrl()->ChangeValue(m_original);
+ wxGetApp().app_config->set("preset_note_draft", "");
+}
+
+void PresetNoteDialog::on_dpi_changed(const wxRect&)
+{
+ m_input->Rescale();
+ GetSizer()->SetSizeHints(this);
+ Refresh();
+}
+
+}} // namespace Slic3r::GUI
@@ -0,0 +1,858 @@
# Colours and dark mode
Covers `wxColour`, system colours and the appearance API, `wxEVT_SYS_COLOUR_CHANGED`, wxMSW's own dark
mode, colour inheritance and the places native controls ignore colours, `StateColor`, Orca's dark-mode
machinery (the dark-mode state, the `Update*DarkUI` walk, NppDarkMode, the runtime switch per platform) and
dark-mode icons. Read it before setting any colour, adding a dialog/panel, or debugging "wrong colour in
dark (or light) mode".
Contents: [Rules](#rules) · [wxColour](#wxcolour) · [System colours and appearance](#system-colours-and-appearance) ·
[wxEVT_SYS_COLOUR_CHANGED](#wxevt_sys_colour_changed) · [wxMSW dark mode](#wxmsw-dark-mode-mswenabledarkmode-setappearance-wxdarkmodesettings) ·
[Window colours and native-control limits](#window-colours-inheritance-and-native-control-limits) ·
[StateColor](#statecolor) · [Orca dark-mode state](#orca-dark-mode-state) ·
[The Update*DarkUI walk](#the-updatedarkui-walk) · [NppDarkMode](#nppdarkmode-windows) ·
[Runtime theme switch](#runtime-theme-switch-and-re-applying-colours) · [Dark-mode icons](#dark-mode-icons)
## Rules
1. Ask Orca, not wx, whether the UI is dark: `wxGetApp().dark_mode()`. Never `wxSystemSettings::GetAppearance().IsDark()`
or `SelectLightDark()` in GUI code — on Windows they report the system app mode — and do not read the
`dark_color_mode` key directly (macOS `dark_mode()` ignores it). → [Orca state](#orca-dark-mode-state)
2. Theme with palette colours, not `wxSYS_COLOUR_*`: on Windows wx serves its dark palette whenever the
system app mode is dark, and macOS system colours are dynamic so the dark map cannot key them. → [System colours](#system-colours-and-appearance)
3. Write colours as `wxColour("#RRGGBB")` or `wxColour(r, g, b)`. A packed integer `wxColour(0xRRGGBB)` is
read as `0x00BBGGRR`; `StateColor` integers are the opposite (`0xRRGGBB`). → [wxColour](#wxcolour)
4. Give every `wxPanel`/`wxScrolledWindow` you create an explicit palette background (`*wxWHITE`, `#F8F8F8`, …),
and set it **before** creating Orca widgets inside it. → [Window colours](#window-colours-inheritance-and-native-control-limits), [Update*DarkUI](#the-updatedarkui-walk)
5. Background colour is never inherited; foreground only at creation, only from the immediate parent, only for
`wxControl`-like classes. Set colours on the window that shows them. → [Window colours](#window-colours-inheritance-and-native-control-limits)
6. In a `StateColor`, list specific states first and `Normal` last; negate with `Not*`/`Disabled`, never `~X`. → [StateColor](#statecolor)
7. A literal light colour given to `SetForegroundColour`/`SetBackgroundColour`/`wxPen`/`wxBrush` goes through
`StateColor::darkModeColorFor()` when it is a `gDarkColors` key, else branch on `dark_mode()`. → [Re-applying colours](#runtime-theme-switch-and-re-applying-colours)
8. End every dialog constructor (after all children exist, before `ShowModal()`) with `wxGetApp().UpdateDlgDarkUI(this)`;
frames use `UpdateFrameDarkUI`; a subtree built after the app-wide pass ends with `UpdateDarkUIWin(this)`.
`UpdateDarkUI(win)` themes one window only. → [Update*DarkUI](#the-updatedarkui-walk)
9. Apply deliberate non-palette colours **after** the walk, and again on theme change; the dark walk rewrites every
visited window's foreground. → [Update*DarkUI](#the-updatedarkui-walk)
10. Re-apply every construction-time colour and every name-selected icon in `on_sys_color_changed()` (DPIDialog/DPIFrame)
or a `sys_color_changed()` chained from the owner, ending with `Refresh()`. A long-lived (cached, lazily built,
hidden) window must be reached from `MainFrame::on_sys_color_changed`. → [Runtime switch](#runtime-theme-switch-and-re-applying-colours)
11. A `wxEVT_SYS_COLOUR_CHANGED` handler on a TLW or container calls `Skip()` and is idempotent. Do not rely on the
event reaching nested children in Orca. → [wxEVT_SYS_COLOUR_CHANGED](#wxevt_sys_colour_changed)
12. Do not call `wxApp::SetAppearance()` or change the `MSWEnableDarkMode(DarkMode_Auto)` → `NppDarkMode::InitDarkMode()`
order in `GUI_App::on_init_inner`. → [wxMSW dark mode](#wxmsw-dark-mode-mswenabledarkmode-setappearance-wxdarkmodesettings)
13. An explicit `dark_color_mode` ("0" or "1") wins in both directions; `check_dark_mode()` is only the fallback for an
unset key. → [Orca state](#orca-dark-mode-state)
14. A new theme-switch path first sets the `dark_color_mode` key (Windows: `app_config->set` + `save()`; macOS/Linux:
`update_dark_config()`), then refreshes the state computed from `dark_mode()`: `m_is_dark_mode` (`Update_dark_mode_flag()`,
which `update_dark_config()` already calls), StateColor's `gDarkMode` and the label colours (`init_label_colours()`)
and, on Windows, NppDarkMode's `g_darkModeEnabled` (`force_colors_update()`). `dark_mode()` itself is live; wx's own
MSW mode follows the system and is not Orca's to set. → [Orca state](#orca-dark-mode-state)
15. Author single-tone SVGs in the substitution palette (uppercase hex, `#262E30` for near-black line art); anything
else needs a `*_dark` asset chosen by name in code that re-runs on theme change. → [Icons](#dark-mode-icons)
16. Never use a native `wxButton`'s `SetBackgroundColour` for styling (MSW turns it owner-drawn, macOS ignores it):
use Orca `Button` + `SetStyle()`. → [Window colours](#window-colours-inheritance-and-native-control-limits)
17. Re-apply text-control colours after every `Enable()` (macOS resets them). → [Window colours](#window-colours-inheritance-and-native-control-limits)
18. Windows menu bitmaps follow wx's menu state (`check_dark_mode()`), not `dark_mode()`. → [Icons](#dark-mode-icons)
## wxColour
**Contract.** Constructors: `()`, `(r, g, b, a = wxALPHA_OPAQUE)`, `(unsigned long|long|int|unsigned int)` ("A
packed RGB value", `interface/wx/colour.h:98-101`), `(const wxString&|const char*|const wchar_t*)`;
`wxColour(bool) = delete` (`include/wx/colour.h:225-240`; `docs/changes.txt:246-248`: code "unintentionally
and mistakenly using wxColour ctor from bool … doesn't compile any longer").
| Fact | Cite |
|---|---|
| Packed integers are `0x00BBGGRR` (`0xAABBGGRR` for `SetRGBA`): "Notice the right-to-left order of components!" `wxColour(0x009688)` is R=0x88 G=0x96 B=0x00, not Orca teal. Only symmetric greys (`0xEEEEEE`) read the same both ways. wx writes its own MSW dark palette this way (`wxColour(0x9e5315)` is a blue). | `interface/wx/colour.h:179-183`; `include/wx/colour.h:77-83`; `src/msw/darkmode.cpp` `wxDarkModeSettings::GetColour` |
| `Set(const wxString&)` accepts colour-database names, CSS `rgb(r,g,b)`/`rgba(r,g,b,a)` (case-insensitive) and `#` + 6 hex digits; returns `false` on failure. **[source]** the parser also takes `#rgb`, `#rgba`, `#rrggbbaa`, but the documented form (and XRC, "but not "#rgb"") is `#RRGGBB` — write that. | `interface/wx/colour.h:288-301`; `src/common/colourcmn.cpp` `FromString`; `docs/doxygen/overviews/xrc_format.h:229` |
| The string ctor is `{ Set(colourName); }`: a typo silently yields `IsOk() == false`. An invalid colour passed to `SetBackgroundColour`/`SetForegroundColour` means "reset to the default colour". | `include/wx/colour.h:235`; `interface/wx/colour.h:239-243`; `interface/wx/window.h:2438-2439` |
| 3.3 changed `wxColourDatabase` to CSS values ("GREEN" is `#008000` in the CSS scheme, `#00ff00` traditionally; wxGTK already used CSS); `UseScheme()` reverts. **[source]** stock objects did not change: `*wxGREEN` is still (0,255,0), so `*wxGREEN != wxColour("green")`. | `docs/changes.txt:31-33`; `interface/wx/gdicmn.h:999-1013`; `src/common/gdicmn.cpp:516`, `:805-807` |
| `wxTransparentColour` = `wxColour(0,0,0,wxALPHA_TRANSPARENT)`: valid, black, alpha 0. `IsTransparent()/IsOpaque()/IsTranslucent()` are new in 3.3.1. `Alpha()` returns `wxALPHA_OPAQUE` "on platforms where alpha is not yet supported". | `include/wx/colour.h:31`; `interface/wx/colour.h:110-114`, `:260-282` |
| `GetLuminance()` = 0.299R + 0.587G + 0.114B on 0..1. | `interface/wx/colour.h:212-222` |
| `ChangeLightness(ialpha)`: 0 = black, 100 = unchanged, 200 = white; returns a copy. **[source]** values are clamped to 0..200 and the result is built as `wxColour(r,g,b)` — alpha is dropped. | `interface/wx/colour.h:365-377`; `src/common/colourcmn.cpp` `wxColourBase::ChangeLightness` |
| `MakeDisabled(brightness = 255)` "modifies the object in place and returns the object itself". **[source]** each channel becomes `brightness + 0.4·(c − brightness)` — it keeps 40% of the colour and moves 60% toward `brightness`, so on a dark background the default makes a "disabled" colour light. | `interface/wx/colour.h:336-342`; `src/common/colourcmn.cpp` `MakeDisabled`, `AlphaBlend` |
Alpha in window colours: **[source]** MSW brushes are `CreateSolidBrush(COLORREF)` (alpha dropped) and pen/brush
transparency is style-based, so a solid brush of a transparent colour paints **black** through GDI. Use
`*wxTRANSPARENT_BRUSH` or `wxGCDC`/`wxGraphicsContext` (`src/msw/brush.cpp:187`; `include/wx/brush.h:60-68`).
Background styles and transparent windows: see `references/painting-custom-widgets.md`.
**OrcaSlicer.** Build colours from hex strings or RGB triples, never from names. Text over user/filament
swatches is chosen by luminance (`clr.GetLuminance() < 0.51 ? *wxWHITE : *wxBLACK`, `wxExtensions.cpp` swatch
helpers, `PresetComboBoxes.cpp`). A packed `wxColour(0x…)` literal is harmless only for a symmetric grey
(`wxColour(0xEEEEEE)`); integer literals in `StateColor` contexts are RGB.
**Pitfalls**
- **Rule:** Never write a packed integer `wxColour`; keep `StateColor` integers as they are.
**Why:** `wxColour(unsigned long)` is BGR; `StateColor(unsigned long)`/`append(unsigned long, …)` byte-swap the value
so it is RGB (`Widgets/StateColor.cpp` `StateColor::append`). "Fixing" one to look like the other inverts R and B.
```cpp
// Wrong: R=0x88 G=0x96 B=0x00 (olive), not #009688
label->SetForegroundColour(wxColour(0x009688));
// Right:
label->SetForegroundColour(wxColour("#009688"));
// Also right (StateColor integers are 0xRRGGBB, alpha byte 0 = opaque):
box->SetBorderColor(StateColor(std::make_pair(0x009688, (int) StateColor::Hovered),
std::make_pair(0xDBDBDB, (int) StateColor::Normal)));
```
Cite: `interface/wx/colour.h:179-183`; `Widgets/StateColor.cpp` `StateColor::append(unsigned long, int)`.
- **Rule:** Validate colours from external data with `Set()`.
**Why:** an invalid colour reaching a setter silently resets the window to its default colour.
```cpp
// Wrong: wxColour c(user_str); win->SetBackgroundColour(c);
// Right:
wxColour c; if (!c.Set(user_str)) c = fallback; win->SetBackgroundColour(c);
```
Cite: `include/wx/colour.h:235`; `interface/wx/window.h:2438-2439`.
- **Rule:** `ChangeLightness(80)` darkens by 20%; there are no negative arguments. On dark backgrounds pass a dark
`brightness` to `MakeDisabled` or use a palette disabled colour (`#6B6B6B`/`#ACACAC`, mapped in dark).
Cite: `interface/wx/colour.h:365-377`, `:336-342`.
## System colours and appearance
**Contract.** `wxSystemSettings::GetColour(index)`: the values "map 1:1 the native values supported by the Windows'
GetSysColor function. Note that other ports … usually map the same colour to various wxSYS_COLOUR_* values"; "The
returned colour is always valid" (`interface/wx/settings.h:44-48`, `:398-407`). New in 3.3.2:
`wxSYS_COLOUR_GRIDLINES`, `wxSYS_COLOUR_LISTBOXHIGHLIGHT` (`interface/wx/settings.h:120-141`). `wxSYS_COLOUR_FRAMEBK` = BTNFACE.
`wxSystemSettings::GetAppearance()` returns `wxSystemAppearance` (`interface/wx/settings.h:288-371`):
| Method | Contract |
|---|---|
| `IsDark()` | "checks the appearance of the current application and not the other applications on the system, so under MSW … will return false even if dark mode is used system-wide unless the application opted in using dark mode using wxApp::MSWEnableDarkMode()" (`:332-346`). An incompatible 3.3 change (`docs/changes.txt:71-73`). |
| `AreAppsDark()` (3.3.0) | system-wide app dark mode "even if it's not enabled for this particular application"; same as `IsDark()` off MSW (`:308-321`). |
| `IsSystemDark()` (3.3.0) | the "Windows mode", which can differ from the "app mode" (`:348-358`). |
| `IsUsingDarkBackground()` | luminance fallback, "generally not very useful to call directly" (`:360-370`). |
| `GetName()` | "only implemented for macOS", e.g. "NSAppearanceNameAqua"; empty elsewhere (`:323-330`). |
| `wxSystemSettings::SelectLightDark(light, dark)` (3.3.0) | "just a convenient helper using wxSystemAppearance::IsDark()" (`:460-473`); literally `GetAppearance().IsDark() ? dark : light` (`include/wx/settings.h:234-237`). |
**Platforms** (all **[source]**):
| Port | `IsDark()` | `GetColour()` |
|---|---|---|
| MSW | `wxMSWDarkMode::IsActive()` ‖ luminance fallback (`src/msw/settings.cpp:431-442`). Under `DarkMode_Auto`, `IsActive()` is uxtheme's `ShouldAppsUseDarkMode()` (`src/msw/darkmode.cpp` `ShouldUseDarkMode`) — the **system app mode**, not the app's own choice. `AreAppsDark()/IsSystemDark()` read `AppsUseLightTheme`/`SystemUsesLightTheme` from the registry. | `GetSysColor`, except GRIDLINES→BTNFACE, LISTBOXTEXT→WINDOWTEXT, LISTBOXHIGHLIGHT→HIGHLIGHT, LISTBOX→WINDOW, MENUBAR→MENU unless flat menus (`src/msw/settings.cpp:99-148`). LISTBOXHIGHLIGHTTEXT maps only to a raw index `GetSysColor` does not define — use HIGHLIGHTTEXT. **When wx dark mode is active every index is answered by `wxDarkModeSettings::GetColour()` first**; indices it leaves invalid (GRAYTEXT, 3DLIGHT, borders, …) fall back to the light `GetSysColor` value. |
| macOS | `[NSApp effectiveAppearance]` best match is DarkAqua (`src/osx/cocoa/settings.mm` `IsDark`). | WINDOW/LISTBOX = `controlBackgroundColor`, BTNFACE = `windowBackgroundColor` (≥10.14), caption/border/MENU/MENUBAR = `windowFrameColor`, BTNTEXT/WINDOWTEXT/MENUTEXT/CAPTIONTEXT/INACTIVECAPTIONTEXT/INFOTEXT/LISTBOXTEXT = `controlTextColor` (GRAYTEXT = `disabledControlTextColor`, HIGHLIGHTTEXT = `selectedTextColor`), GRIDLINES = `gridColor`, INFOBK/APPWORKSPACE = `windowBackgroundColor` (commented "bogus"), HOTLIGHT = `linkColor` (`src/osx/cocoa/settings.mm` `GetColour`). The result wraps a **dynamic NSColor**: components resolve at call time under the effective appearance (`src/osx/cocoa/colour.mm`), and as a view background it adapts by itself. |
| GTK3 (the default Linux build) | `IsUsingDarkBackground()`: luminance(WINDOWTEXT) − luminance(WINDOW) > 0.2 (`src/common/settcmn.cpp:96-112`); `AreAppsDark()/IsSystemDark()` = `IsDark()` (`:71-84`). wxGTK3 follows the freedesktop portal `org.freedesktop.appearance` `color-scheme` (GNOME's dark style) by setting `gtk-application-prefer-dark-theme` and stripping a `-dark`/`-Dark` theme-name suffix; no portal when `GTK_THEME` is set (`src/gtk/settings.cpp:251-334`, `:369-400`, `:1426-1450`). | From synthetic `GtkStyleContext`s (button, textview, treeview, headerbar, tooltip, menu), **cached** in `gs_systemColorCache` until "notify::gtk-theme-name" or a colour-scheme change (`src/gtk/settings.cpp:728-880`). |
| GTK2 (opt-out build, `-DDEP_WX_GTK3=OFF`) | as GTK3 (luminance of the GTK2 theme); no portal/colour-scheme support (`#ifdef __WXGTK3__`). | `GtkStyle` of helper widgets (`src/gtk/settings.cpp:913ff`). |
| Wayland | no Wayland-specific branch in colour/appearance code. | — |
MSW dark palette (`wxDarkModeSettings::GetColour`, `src/msw/darkmode.cpp:300-370`; "not documented and are subject
to change", `interface/wx/msw/darkmode.h:74-84`): WINDOW/LISTBOX/INFOBK/APPWORKSPACE/ACTIVECAPTION `0x202020`;
the *TEXT indices `0xe0e0e0` except INACTIVECAPTIONTEXT `0xaaaaaa` (GRAYTEXT is left invalid); BTNFACE/GRIDLINES `0x333333`; MENU/INACTIVECAPTION `0x2b2b2b`; MENUBAR/LISTBOXHIGHLIGHT
`0x626262`; HIGHLIGHT/MENUHILIGHT `0x9e5315` (a blue — packed BGR); HOTLIGHT `0xe48435`.
**OrcaSlicer.** Orca does not theme with system colours or with `wxVisualAttributes`/`GetClassDefaultAttributes()`
(the stock advice for matching native controls): it uses a fixed palette with a hand-written dark twin map
(`StateColor`), because the exact-match map needs deterministic RGB that dynamic macOS colours and the MSW dark
palette do not give, and because Windows needs an app-level live toggle wx cannot do (see
[wxMSW dark mode](#wxmsw-dark-mode-mswenabledarkmode-setappearance-wxdarkmodesettings)). `GUI_App::init_label_colours`
still reads `wxSYS_COLOUR_WINDOWTEXT`/`wxSYS_COLOUR_WINDOW` for two light-mode values — on Windows with a dark system
and Orca light those come from wx's dark palette. macOS: Orca's wx is patched to read NSColor components in sRGB
instead of `NSCalibratedRGBColorSpace` (`deps/wxWidgets/0001-macos-use-srgb-colour-components.patch`, commit
a7775296b0 "Fix macOS custom color accuracy"), so RGB read back from native colours matches the expected hex.
**Pitfalls**
- **Rule:** Never branch on wx's appearance in Orca GUI code.
**Why:** Orca calls `MSWEnableDarkMode(DarkMode_Auto)`, so on Windows `IsDark()`/`SelectLightDark()` follow the
system app mode — Windows dark + Orca light reports dark.
```cpp
// Wrong:
auto c = wxSystemSettings::SelectLightDark(wxColour("#FFFFFF"), wxColour("#2D2D31"));
// Right:
auto c = StateColor::darkModeColorFor(wxColour("#FFFFFF")); // #FFFFFF is a gDarkColors key
auto d = wxGetApp().dark_mode() ? wxColour("#EFEFF0") : wxColour("#333333"); // unmapped colour
```
Cite: `src/msw/settings.cpp:431-442`; `src/msw/darkmode.cpp` `ShouldUseDarkMode`; 7d7f26ed69.
- **Rule:** Theme with palette colours, not `wxSystemSettings::GetColour(wxSYS_COLOUR_*)`.
**Why:** MSW returns wx's dark palette whenever the system app mode is dark, regardless of Orca's setting
(`src/msw/settings.cpp:99-107`); on macOS the value is a dynamic colour no `gDarkColors` key matches; on GTK it
is whatever the theme says. Do not cache macOS system-colour RGB across a theme change.
- **Rule:** Raw `wxButton` code that branches on `__WXMAC__` to `wxSYS_COLOUR_BTNFACE`/`BTNTEXT` only keeps the text
in the system colour: NSButton ignores the background colour anyway (see
[native limits](#window-colours-inheritance-and-native-control-limits)). New code uses Orca `Button`.
## wxEVT_SYS_COLOUR_CHANGED
**Contract.** "generated when the user changes the colour settings or when the system theme changes (e.g. automatic
dark mode switching on macOS)". "The default event handler for this event propagates the event to child windows,
since the system events are only sent to top-level windows. If intercepting this event for a top-level window,
remember to either call wxEvent::Skip() on the event, call the base class handler, or pass the event on to the
window's children explicitly" (`interface/wx/event.h:1942-1966`). **[source]** the default handler
(`wxWindowBase::OnSysColourChanged`, `src/common/wincmn.cpp:3011-3027`) sends a fresh event to every
**non-top-level** child and calls `Refresh()`; a child whose own handler does not `Skip()` stops it for its subtree.
**Who sends it** (all **[source]**):
| Port | Trigger |
|---|---|
| MSW | `WM_SYSCOLORCHANGE`, and `WM_SETTINGCHANGE` with "ImmersiveColorSet" (light/dark/accent switch) → `HandleSysColorChange()` on each TLW (`src/msw/window.cpp:3542`, `:5092`, `:5197-5200`). The default MSW handler re-sends a real `WM_SYSCOLORCHANGE` to native children (`wxWindowMSW::OnSysColourChanged`, `:5269-5293`); `wxFrame` also resets its background to `wxSYS_COLOUR_APPWORKSPACE` if `!UseBgCol()` and re-themes the menubar (`src/msw/frame.cpp:476-503`). |
| macOS | per-NSWindow KVO on `effectiveAppearance`, **and** `windowDidChangeBackingProperties` when the window's colour space changes (dragging to a display with another profile) — no theme change involved (`src/osx/cocoa/nonownedwnd.mm:684-740`). Popups are `wxNonOwnedWindow`s and get it too. No defined order across windows. |
| GTK3 | every TLW connects "notify::gtk-theme-name" with `g_signal_connect_after` so the colour cache is cleared before user handlers run (`src/gtk/toplevel.cpp:555-563`, `:951-955`); the portal colour-scheme handler `DoUpdateColorScheme` also loops over `wxTopLevelWindows` (`src/gtk/settings.cpp:251-334`). A dark switch that also renames the theme delivers the event **twice** per TLW. |
| GTK2 (opt-out build) | "notify::gtk-theme-name" only. |
`wxDialogBase::OnSysColourChanged` exists (`src/common/dlgcmn.cpp:561`) but no event table references it.
**Usage.**
```cpp
Bind(wxEVT_SYS_COLOUR_CHANGED, [this](wxSysColourChangedEvent& e) {
e.Skip(); // keep propagation to children (and the wxFrame/wxWindowMSW base work)
recolor(); // idempotent and cheap: may fire twice (GTK3) or without any theme change (macOS)
});
```
**OrcaSlicer.** `DPIAware<P>` (`GUI_Utils.hpp`) binds the event on every platform:
- macOS/Linux: `update_dark_config()` (writes `dark_color_mode` from `GetAppearance().IsDark()` and calls
`Update_dark_mode_flag()`), then the virtual `on_sys_color_changed()`, then `Skip()`.
- Windows: the body is empty and deliberately does not `Skip()` ("Not calling Skip() is what stops the event
propagating on Windows"). That consumes the event at every DPIAware TLW, so wx's
`wxWindowMSW::OnSysColourChanged` and every child handler (Plater's included) never run there. On Windows the
theme switch comes only from Preferences ([runtime switch](#runtime-theme-switch-and-re-applying-colours)).
Elsewhere containers that bind the event without `Skip()` stop it for their subtree (`ButtonsListCtrl` in
`Notebook.cpp` binds an empty handler; `Plater::priv::on_apple_change_color_mode` updates the GL canvases and does
not skip). Long-lived UI is therefore refreshed through the `MainFrame::on_sys_color_changed` fan-out, not by
child handlers.
**Pitfalls**
- **Rule:** A TLW or container handler calls `Skip()`.
**Why:** without it wx's default handler never propagates to the children (`interface/wx/event.h:1951-1956`).
Cite: `src/common/wincmn.cpp:3011-3027`.
- **Rule:** Make the handler idempotent and cheap; never assume one event per theme change.
**Why:** macOS fires on display colour-space changes; GTK3 can fire twice per TLW.
- **Rule:** Never put Orca re-theming in a child's SYS_COLOUR handler; implement `on_sys_color_changed()` /
`sys_color_changed()` and make sure the owner calls it.
**Why:** on Windows the event never reaches children; elsewhere any non-skipping ancestor swallows it.
## wxMSW dark mode: MSWEnableDarkMode, SetAppearance, wxDarkModeSettings
**Contract.** `bool wxApp::MSWEnableDarkMode(int flags = 0, wxDarkModeSettings* settings = nullptr)` (3.3.0,
`@onlyfor{wxmsw}`, `interface/wx/app.h:1418-1465`):
- "experimental"; uses "undocumented, and unsupported by Microsoft, functions"; works on Windows 10 later than
v1809 (including LTSC 2019) and all Windows 11; testing before 20H1 (v2004) "has been limited".
- Flags: default follows the system ("dark mode is only used if it is the default mode for the applications on
the current system"); `DarkMode_Always` forces dark. **[source]** `DarkMode_Auto = 0` exists in
`include/wx/msw/app.h:48` although only `DarkMode_Always` is documented.
- Returns `true` if enabled, `false` "most likely because the system doesn't support dark mode".
- Alternatives: the `msw.dark-mode` system option (1 = `MSWEnableDarkMode()`, 2 = `DarkMode_Always`, settable by
environment variable from outside the app, `interface/wx/sysopt.h:85-89`), or `SetAppearance(System|Dark)`.
- Known limitations (`interface/wx/app.h:1434-1448`): anything `TaskDialog()`-based has no dark mode —
`wxMessageBox()`, `wxMessageDialog`, `wxRichMessageDialog`, `wxProgressDialog`, simple `wxAboutBox()` (wx suggests
`wxGenericMessageDialog`/`wxGenericProgressDialog`); common-dialog wrappers `wxColourDialog`, `wxFindReplaceDialog`,
`wxFontDialog`, `wxPageSetupDialog`, `wxPrintDialog`; `wxTimePickerCtrl`, `wxDatePickerCtrl`, `wxCalendarCtrl`
stay light; toolbar items with `wxToolBar::SetDropdownMenu()` draw the drop-down "almost invisible".
`AppearanceResult wxApp::SetAppearance(Appearance::System|Light|Dark)` (3.3.0, `interface/wx/app.h:1152-1190`):
GTK/macOS follow the system by default and the call is immediate and "affects all the existing windows as well
as any windows created after this call"; "Under MSW, the default appearance is always light" and an app that
wants to follow the system must call it with `Appearance::System`; "the appearance can be only set before any
windows are created and calling this function too late will return AppearanceResult::CannotChange" (only wxMSW
returns it);
`Failure` e.g. "because `GTK_THEME` is defined".
`wxDarkModeSettings` (`interface/wx/msw/darkmode.h:30-111`), passed to `MSWEnableDarkMode()`: `GetColour(wxSystemColour)`
(defaults "not documented and are subject to change"; the doc example names `0x202020` as the default background);
`GetMenuColour(wxMenuColour)` — menu-bar colours match no `wxSystemColour`, affect top-level menus only (items use
`wxOwnerDrawn::SetTextColour()`), "must be valid"; `GetBorderPen()` — invalid pen = system `wxStaticBox` border,
which "doesn't look very well in dark mode"; the base returns grey.
**[source] facts the docs do not state:**
- `SetAppearance` on MSW returns `CannotChange` when any TLW exists **or `MSWEnableDarkMode` was already called**
(`gs_appMode != AppMode_Default`); `SetAppearance(Light)` returns `Ok` without doing anything
(`src/msw/darkmode.cpp` `wxApp::SetAppearance`). macOS `System` sets `NSApp.appearance` to
`[NSAppearance currentAppearance]` rather than nil, pinning the current look (`src/osx/cocoa/utils.mm:485-513`);
GTK3 maps to the portal colour-scheme machinery; GTK2 always returns `Failure` (`src/gtk/app.cpp:355-381`).
- `MSWEnableDarkMode` may be called any time, but dark title bars are applied only to TLWs **created** afterwards
(`EnableForTLW` at the end of TLW creation, `src/msw/toplevel.cpp:513`) and controls are dark-enabled at creation
(`MSWCreateControl` → `AllowForWindow` and, for some controls, `SetForegroundColour(LISTBOXTEXT)`,
`src/msw/control.cpp:133-140`). wx cannot flip existing windows — the reason `SetAppearance` refuses late calls.
- wx takes ownership of the settings pointer (`wxDarkModeModule::SetSettings`) — allocate it with `new`.
- `msw.dark-mode` is read in `wxApp::Initialize` (`src/msw/app.cpp:492-494`): a user's `WX_MSW_DARK_MODE`
environment variable enables wx dark mode before Orca's own call.
- wx's owner-drawn menu path keys on `wxMSWDarkMode::IsActive()` (`src/msw/menuitem.cpp` `wxMenuItem::OnDrawItem`,
`GetColourToUse`; `src/msw/menu.cpp`; menu-bar UAH drawing in `src/msw/darkmode.cpp`).
3.3.x dark-mode fix log: 3.3.2 wxMSW (`docs/changes.txt:297-308`: checkbox accessibility in dark mode, rendering of
several controls, toolbar, menus; also "Revert use of WS_EX_COMPOSITED"), 3.3.1 (`:358-372`: wxStaticBitmap-in-notebook
crash, disabled wxButton bitmaps and wxStaticText, notebook high-contrast background, wxDataViewCtrl light-mode
border regression, selected toolbar buttons, wxComboCtrl, wxTE_RICH wxTextCtrl), 3.3.0 (`:385` "Add experimental dark
mode support to wxMSW"); XRC dark colour variants (`:494`).
**OrcaSlicer.** `GUI_App::on_init_inner` (`#ifdef __WINDOWS__`) calls `MSWEnableDarkMode(DarkMode_Auto)` and then
`NppDarkMode::InitDarkMode(init_dark_color_mode, init_sys_menu_enabled)`. wx's call exists only so that wx-drawn
menus get dark borders; NppDarkMode does the theming (title bars, explorer theme, scrollbars, list headers) because
it can switch live and wx cannot. The code comment "Orca: todo switch to native dark mode support in wxWidgets and
remove NppDarkMode" records the intent; the blocker is that a live Preferences toggle would become restart-only
(`SetAppearance` returns `CannotChange` after startup, existing windows cannot be restyled) and TaskDialog/common
dialogs/pickers stay light anyway — which is also why Orca has its own `MsgDialog` family and `ProgressDialog`.
**[source]** consequences of `DarkMode_Auto`: wx's internal "dark" stays `AllowDark` and follows the **system app
mode**; NppDarkMode's later `SetPreferredAppMode` call does not touch wx's `gs_appMode`. So with Windows light +
Orca dark, wx's dark machinery (menus, `GetColour()`, `IsDark()`) stays off; with Windows dark + Orca light it is
on: `GetColour()` returns the dark palette, new native controls are dark-enabled at creation, `IsDark()` is true.
**Pitfalls**
- **Rule:** Call `MSWEnableDarkMode(DarkMode_Auto)` before `NppDarkMode::InitDarkMode(...)`.
**Why:** wx 3.3 draws some native chrome (context/file menus) itself; without wx dark mode those menus keep a
light white border in dark mode. **[source]** both calls end in the same undocumented uxtheme ordinal 135
(`SetPreferredAppMode`; wx `src/msw/darkmode.cpp` `InitDarkMode`, Orca `dark_mode/dark_mode.hpp`
`AllowDarkModeForApp`) and the last call wins at OS level: NppDarkMode then sets **ForceDark or ForceLight** per
Orca's setting, overriding wx's AllowDark in both directions. In the other order wx's AllowDark would replace
Orca's forced mode. wx's own state still follows the system apps-dark setting; the two coincide only when the
system theme matches what Orca forces.
```cpp
// Wrong: only NppDarkMode knows about dark mode; wx-drawn menus stay light-bordered
NppDarkMode::InitDarkMode(init_dark, sys_menu);
// Right:
MSWEnableDarkMode(DarkMode_Auto); // enable wx 3.3's dark machinery
NppDarkMode::InitDarkMode(init_dark, sys_menu); // then ForceDark/ForceLight overrides AllowDark
```
Cite: bf397a0632 (`GUI_App.cpp`, `GUI_App::on_init_inner`).
- **Rule:** Do not call `wxTheApp->SetAppearance(...)` in Orca.
**Why:** MSW returns `CannotChange` (Orca already called `MSWEnableDarkMode`), GTK2 fails, and macOS pins
`NSApp.appearance` while `GUI_App::dark_mode()` keeps reading the system `AppleInterfaceStyle` — the two would
disagree.
- **Rule:** Do not show wx's TaskDialog-based or common dialogs where dark mode matters; use `MessageDialog` and
friends (`MsgDialog.hpp`, see `references/windows-dialogs.md`).
## Window colours, inheritance and native-control limits
**Contract** (`interface/wx/window.h`):
| Call | Effect |
|---|---|
| `SetBackgroundColour(c)` | "may not affect the entire control and could be not supported at all depending on the control and platform"; does not refresh ("you may wish to call wxWindow::ClearBackground or wxWindow::Refresh"); "will disable attempts to use themes for this window"; returns `false` if the colour was already set (`:2427-2461`). |
| `SetOwnBackgroundColour(c)` | same, "but prevents it from being inherited by the children" (`:2588-2593`). |
| `SetForegroundColour(c)` | "not all native controls support changing their foreground colour so this method may change their colour only partially or even not at all" (`:2561-2585`). |
| `SetOwnForegroundColour(c)` | non-inheritable foreground (`:2622-2627`). |
| `UseBgCol()`/`UseBackgroundColour()`, `UseForegroundColour()` | whether a colour was set for this window (`:2600-2632`). |
| `InheritsBackgroundColour()`/`InheritsForegroundColour()` | the inheritable flag (`:2595-2639`). |
| `ShouldInheritColours()` | "base class version returns false, but … overridden in wxControl where it returns true" (`:2645-2653`). |
| `InheritAttributes()` | called during creation; a child takes an attribute only if the parent set it explicitly (not via `SetOwn*`) and the child did not (`:4052-4075`). |
| `GetClassDefaultAttributes(variant)` → `wxVisualAttributes{font, colFg, colBg}` | "colBg may be wxNullColour if the controls background colour is not solid"; "All of them may be invalid if it was not possible to determine the default control appearance" (`:135-150`, `:4245-4273`). |
**[source] facts the docs do not spell out** (`src/common/wincmn.cpp`):
- **Background is never inherited.** The background branch of `InheritAttributes()` is `#if 0` ("inheriting (solid)
background colour is wrong as it totally breaks any kind of themed backgrounds", `:1524-1553`). Only font and
foreground are copied, only at creation time (`InheritAttributes()` runs from the ports' creation code), only
from the **immediate** parent.
- `ShouldInheritColours()` is false for `wxWindow`/`wxPanel` (`include/wx/window.h:1682`), `wxAnyButton`
(`include/wx/anybutton.h:105`), `wxTextCtrl` (`include/wx/textctrl.h:872`), `wxControlWithItems` (`wxChoice`,
`wxListBox`, …; `include/wx/ctrlsub.h:447`), `wxTreeCtrl` (`include/wx/treectrl.h:399`); true for other
`wxControl`s (`wxStaticText`, `wxCheckBox`, …; `include/wx/control.h:98`).
So `panel->SetForegroundColour(x)` reaches only static-text-like direct children created afterwards.
- `GetBackgroundColour()`/`GetForegroundColour()` **never return an invalid colour**: unset, they return
`GetDefaultAttributes()`, falling back to `GetClassDefaultAttributes()` = `wxSYS_COLOUR_BTNFACE` /
`wxSYS_COLOUR_WINDOWTEXT` (`:1556-1605`). Test `UseBgCol()` to know whether a colour was really set.
- `SetBackgroundColour`/`SetForegroundColour` call `SetThemeEnabled(!hasBg && !fg.IsOk())` /
`SetThemeEnabled(!hasFg && !bg.IsOk())`: once either colour is set, theming is off (`:1640-1661`).
**How children still look like their parent** (visual inheritance ≠ `GetBackgroundColour()`; **[source]**):
- MSW: a child without its own background paints the nearest ancestor's **explicit** brush while
`HasTransparentBackground()` holds (`src/msw/window.cpp` `wxWindowMSW::MSWGetBgBrush`). Containers (`wxPanel`)
report transparent if an ancestor has an inheritable background (`SetBackgroundColour`, not
`SetOwnBackgroundColour`; `src/common/containr.cpp:162-175`); `wxStaticText`, `wxCheckBox`, `wxStaticBox`,
`wxStaticBitmap`, `wxHyperlinkCtrl`, book controls, MSW `wxRadioButton`/`wxSlider` always do.
- macOS: a bare window with the default `wxBG_STYLE_ERASE` is cleared with its **own** `GetBackgroundColour()`
(`wxWindowMac::MacDoRedraw`, `src/osx/window_osx.cpp:1950-1983`; the `wxWindowDC` background is
`GetBackgroundColour()`, `src/osx/carbon/dcclient.cpp:89`) — the class default `windowBackgroundColor`, a dynamic
system grey.
- GTK3: a bare `wxPanel` is theme-enabled (`src/common/panelcmn.cpp:100`) and renders its own GTK style-context
background (`gtk_render_background` in `wxWindowGTK::GTKSendPaintEvents`, `src/gtk/window.cpp`), which depends on
the theme.
This is why "dark-theme bugs that only show on macOS" exist: MSW hides an unset panel behind the ancestor's brush,
macOS paints the system grey.
**Native controls that ignore colours** (**[source]**):
| Port | Behaviour |
|---|---|
| MSW | `wxButton`/`wxToggleButton` `SetBackgroundColour`/`SetForegroundColour` switch the native button to `BS_OWNERDRAW` (`src/msw/anybutton.cpp` `wxAnyButton::MakeOwnerDrawn`, `:1324-1390`) — the colour works but native theming is gone. `wxCheckBox`/`wxRadioButton` foreground makes them owner-drawn when themes are active (`src/msw/control.cpp` `MSWMakeOwnerDrawnIfNecessary`); 3.3.2 fixed checkbox accessibility in that mode (`docs/changes.txt:299-300`). Static-text-like children paint the ancestor brush only if that ancestor's background is inheritable. |
| macOS | `SetBackgroundColour` reaches the NSView only if it `respondsToSelector:setBackgroundColor:` and the style is not `wxBG_STYLE_TRANSPARENT` (`src/osx/cocoa/window.mm:3514-3531`): **NSButton (`wxButton`) ignores the background colour**. Foreground uses `setTextColor:` when the view has it (`:3885-3887`); stock NSButton has none. `wxStaticText` is an NSTextField with `setDrawsBackground:NO` — its background is never painted (`src/osx/cocoa/stattext.mm:154`). `wxTextCtrl`'s `setEnabled:` resets the text colour: multi-line (`wxNSTextView`) always, to `controlTextColor`/`disabledControlTextColor`; single-line (`wxNSTextField`) when it does not draw its background, to `controlTextColor`/`secondarySelectedControlColor` (`src/osx/cocoa/textctrl.mm`). |
| GTK3 | `SetBackgroundColour/SetForegroundColour/SetFont` install a per-widget CSS provider `*{color:..;background:..;font:..}` at `GTK_STYLE_PROVIDER_PRIORITY_APPLICATION` (`src/gtk/window.cpp` `wxWindowGTK::GTKApplyStyle`); `wxButton`/`wxCheckBox` apply it to their inner label too (`src/gtk/button.cpp:325-335`, `src/gtk/checkbox.cpp:233-237`). User CSS (priority USER) can still override. |
| GTK2 | colours applied with `gtk_widget_modify_style` — pixmap-engine themes may ignore them. |
**OrcaSlicer — `Utils/MacDarkMode.mm`** installs process-wide Objective-C categories/swizzles that affect *every*
window, so stock-wx advice about NSTextField/NSButton colours differs in Orca:
- every `NSTextField` is created with `drawsBackground = false` (category `NSTextField (drawsBackground)`), so with
the `textctrl.mm` rule above a custom text colour is lost on every `Enable()` and the background is not painted;
- `NSButton (NSButton_Extended)` adds `textColor`/`setTextColor:` (via the attributed title), which is what makes
`wxButton`/`wxCheckBox` foreground colours work on macOS; `setBezelStyle:` forces bordered (except shadowless
square); focus rings are off for NSButton and NSTextField; `NSTableHeaderCell`'s font is forced to `Label::sysFont(13)`;
- the main-frame title text colour is forced (`set_title_colour_after_set_title`; `NSTextField (textColor)` swizzle
for that one field), and `set_miniaturizable` makes the titlebar transparent over a dark NSWindow background.
Orca's widgets work with these limits: `Label` sets keyed colours (`SetForegroundColour("#262E30")`, background =
`StaticBox::GetParentBackgroundColor(parent)`) so the walk can map them — commit d408db2fde replaced `wxStaticText`
with `Label` to fix Linux dark mode this way; `TextInput::Enable` re-applies the inner control's background and
foreground from its `StateColor`s after enabling; `StaticBox::Create` copies the parent background into its own wx
background (a `StaticBox` parent's `background_color.defaultColor()`, the midpoint of a gradient, else
`parent->GetBackgroundColour()`), used for the corners outside the rounded rectangle.
**Pitfalls**
- **Rule:** Give every `wxPanel`/`wxScrolledWindow` you create an explicit palette background (typically
`SetBackgroundColour(*wxWHITE)`), even when white "already looks right".
**Why:** the walk re-keys a background only if it is a `gDarkColors` key. A bare panel paints its class default
(`wxSYS_COLOUR_BTNFACE`; on macOS the appearance-dependent `windowBackgroundColor`), which is never a key, so it
shows as a system-grey patch against Orca's palette in either theme. MSW hides this by painting the ancestor's
explicit brush, so it surfaces on macOS. An explicit palette colour is the key that lets the walk re-map it in
both directions.
```cpp
wxPanel* p = new wxPanel(parent); // Wrong: unkeyed; macOS shows system grey, not #2D2D31 / #FFFFFF
wxPanel* p = new wxPanel(parent);
p->SetBackgroundColour(*wxWHITE); // Right: #FFFFFF is a key → #2D2D31 in dark
```
Cite: f7f0c82abb / 90ecdb8f03 (`HMSPanel.cpp`, `StatusPanel.cpp`, `UpgradePanel.cpp`,
`DeviceTab/wgtDeviceNozzleRack*.cpp`, `SelectMachine.cpp`); `src/common/wincmn.cpp:1524-1553`.
- **Rule:** Set the parent's background before creating Orca widgets in it.
**Why:** `StaticBox`, `Label`, `CheckBox`, `SwitchButton`, `RadioGroup` snapshot the parent background at construction; a later
change leaves wrong-coloured corners/label backgrounds.
- **Rule:** Set colours on the control itself, not on an ancestor.
**Why:** background never inherits; foreground inherits only at creation, from the immediate parent, and not for
`wxPanel`, `wxTextCtrl`, buttons or item controls.
```cpp
// Wrong: dlg->SetForegroundColour(c); // after children exist, or on a wxPanel above them
// Right: label->SetForegroundColour(c); // per control
```
- **Rule:** Re-apply text-control colours after every `Enable()`.
**Why:** macOS NSTextField/NSTextView reset their text colour in `setEnabled:`, and Orca's swizzle makes every
NSTextField non-background-drawing. `TextInput::Enable` is the model.
- **Rule:** Never style a native `wxButton` with `SetBackgroundColour`; use `Button` + `SetStyle(...)`.
**Why:** MSW turns the native button owner-drawn (theming gone); macOS ignores the background colour.
## StateColor
`StateColor` (`Widgets/StateColor.hpp/.cpp`) is an ordered list of `(wxColour, int mask)` pairs plus the global
light→dark map `gDarkColors`. Orca widgets (`StaticBox` and subclasses) take `StateColor`s and resolve them at paint
time.
**Contract** (from the code):
- State bits: `Normal = 0`, `Enabled = 1`, `Checked = 2`, `Focused = 4`, `Hovered = 8`, `Pressed = 16`; the negations
`Disabled`, `NotChecked`, `NotFocused`, `NotHovered`, `NotPressed` are the same bits `<< 16` and mean "bit must be
off". Masks OR together (`StateColor::Checked | StateColor::Enabled`). The `(int)` cast on the enum is required
for `std::pair` deduction.
- `colorForStates(states)` returns the **first** entry whose on-bits are all set and off-bits all clear;
`takeFocusedAsHovered_` (default true) lets a `Hovered` entry also match `Focused` (`setTakeFocusedAsHovered(false)`
to style focus separately). No match → `wxColour(0, 0, 0, 0)` (transparent black — black through GDI).
- `colorForStates()` passes the result through the dark map when StateColor's `gDarkMode` is set, **at paint time**:
owner-drawn widgets switch theme on `Refresh()` with no extra code. `colorForStatesNoDark()` skips mapping;
`defaultColor()` = `colorForStates(0)`.
- Single-colour ctors `StateColor(wxColour)`, `(wxString)`, `(unsigned long)` store a `Normal` entry. Integer forms
(`StateColor(unsigned long)`, `append(unsigned long, int)`, `std::make_pair(0x009688, …)`) are **`0xRRGGBB`**; an
alpha byte of 0 is treated as opaque — the opposite of `wxColour(unsigned long)`.
- `gDarkColors` is a `std::map` ordered by `wxColour::GetRGBA()`: an **exact RGBA** match. Non-opaque or near-miss
colours never map. Examples: `#FFFFFF→#2D2D31`, `#009688→#00675b`, `#262E30→#EFEFF0`, `#DFDFDF→#3E3E45`,
`#D4D4D4→#4D4D54`, `#DBDBDB→#4A4A51`, `#000000→#FFFFFE`, `#F8F8F8→#36363C`, `#F1F1F1→#36363B`, `#EEEEEE→#4C4C55`,
`#6B6B6B→#818183`, `#ACACAC→#65656A`, `#363636→#B2B3B5`, `#F0F0F1→#333337`. Near-white variants (`#FFFFFE` as the
twin of `#000000`, `#FEFFFF`, `#FFFEFE`) exist so the reverse map stays usable. `#F0F0F0`, `#333333`, `#5C5C5C`
are not keys.
- Static helpers: `SetDarkMode`, `darkModeColorFor` (returns its input unless `gDarkMode` is set), `lightModeColorFor`
(the reverse map, built once with `emplace` so the smallest `GetRGBA()` key wins; not gated by `gDarkMode` — it
maps in either mode), `GetDarkMap()`, LAB math
`GetLAB`, `GetLightness`, `SetLightness`, `LightenDarkenColor`, `GetColorDifference`/`LAB_Delta_E`.
**Reverse-map and chaining hazards** (exact consequences of the table):
- duplicate dark targets collapse: `#E8E8E8 → #3E3E45 → #DFDFDF`; `#EDFAF2 → #283232 → #E5F0EE`;
- legitimate light colours that equal some dark twin are rewritten by the light walk: `#909090 → #6B6A6A`,
`#D9D9D9 → #FFFEFE`, `#808080 → #2B3436`, `#FFFFFE → #000000`;
- the dark map chains when a value is also a key: a second dark pass takes `#FFFEFE → #D9D9D9 → #27272A`.
**Usage.**
```cpp
StateColor bg(std::pair{wxColour("#DFDFDF"), (int) StateColor::Disabled},
std::pair{wxColour("#D4D4D4"), (int) StateColor::Pressed},
std::pair{wxColour("#D4D4D4"), (int) StateColor::Hovered},
std::pair{wxColour("#FFFFFF"), (int) StateColor::Normal}); // Normal LAST
box->SetBackgroundColor(bg); // StaticBox API ("Color"), not wxWindow::SetBackgroundColour
// in doRender(wxDC& dc): dc.SetBrush(background_color.colorForStates(state_handler.states()));
```
How a widget attaches and repaints its `StateColor`s (`StateHandler`, `doRender`) is in
`references/painting-custom-widgets.md`; per-widget colour setters are in `references/orca-widgets.md`.
**OrcaSlicer.** Pick existing keys for new UI. `HyperLink` deliberately uses `#009687` (not a key) so its teal is
*not* mapped. `Button::SetStyle` detects dark mode with `darkModeColorFor("#FFFFFF") != "#FFFFFF"` for the focus
border, and `Button::Rescale()` re-runs `SetStyle` (when a style was set), so calling `Rescale()` on a theme change
refreshes it. A `Button` never given a style keeps its constructor colours, whose `*wxLIGHT_GREY` hover/disabled
entries are not keys — always call `SetStyle()`.
`DialogButtons` sets its own background with `darkModeColorFor(wxColour("#FFFFFF"))`. Widgets that take a plain
`wxColour` (e.g. `ProgressBar`) paint it unmapped — map it yourself. `StaticLine` maps line/text colours at paint time.
**Pitfalls**
- **Rule:** Most specific entries first, `Normal` last.
**Why:** `Normal` (mask 0) matches every state, so entries after it are dead. Check the order of any table you copy;
existing tables are not guaranteed correct (an `Enabled` entry after `Normal` never matches).
```cpp
// Wrong: hover never shows
StateColor c(std::pair{wxColour("#FFFFFF"), (int) StateColor::Normal},
std::pair{wxColour("#D4D4D4"), (int) StateColor::Hovered});
// Right:
StateColor c(std::pair{wxColour("#D4D4D4"), (int) StateColor::Hovered},
std::pair{wxColour("#FFFFFF"), (int) StateColor::Normal});
```
Cite: `Widgets/StateColor.cpp` `StateColor::colorForStates`.
- **Rule:** Negate with the `Not*` enumerators, never `~`.
**Why:** `(int) Hovered | NotFocused` = `0x40008` means "hovered and not focused"; `(int) Hovered | ~Focused` =
`0xFFFFFFFB` sets every on-bit and never matches.
- **Rule:** Do not pass data colours (filament/extruder/user colours) through `darkModeColorFor` or leave them as
window backgrounds the walk visits; paint them in a paint handler or re-set them after the walk.
**Why:** a white filament `#FFFFFF` maps to `#2D2D31`.
- **Rule:** Never use a dark-twin value as a light-mode colour (`#FFFFFE`, `#D9D9D9`, `#909090`, `#808080`, …).
**Why:** the light walk rewrites it through the reverse map.
## Orca dark-mode state
| State | Set by | Read by |
|---|---|---|
| app_config `dark_color_mode` ("1"/"0") | Windows: the Preferences checkbox (`PreferencesDialog::create_item_darkmode`, `#ifdef _WIN32`); `AppConfig::set_defaults` sets `"0"` when empty (`#ifdef _WIN32`). macOS/Linux: overwritten from `GetAppearance().IsDark()` at startup (`GUI_App::on_init_inner`, `#ifndef __WINDOWS__`) and by `update_dark_config()` on every SYS_COLOUR event. | `GUI_App::dark_mode()` (non-macOS); `Plater::priv::on_change_color_mode` and other GL/web code that reads the key directly — new code calls `dark_mode()` instead |
| `GUI_App::m_is_dark_mode` | `GUI_App::Update_dark_mode_flag()` (= `dark_mode()`) | `GUI_App::UpdateDarkUI` (the walk) only |
| `StateColor` file-static `gDarkMode` | `StateColor::SetDarkMode`, called only from `GUI_App::init_label_colours()` | `darkModeColorFor`, `colorForStates` |
| NppDarkMode `g_darkModeEnabled` (Windows) | `NppDarkMode::InitDarkMode` / `SetDarkMode` | title bars, explorer theme, scrollbars, DVC header |
| wx's own MSW mode | `MSWEnableDarkMode(DarkMode_Auto)` → follows the **system app mode** | wx internals, `IsDark()`, `GetColour()` |
`GUI_App::dark_mode()` (static, recomputed every call) is the only query GUI code uses:
- macOS: `wxPlatformInfo::Get().CheckOSVersion(10, 14) && mac_dark_mode()` (10.12/10.13 gave false positives);
`mac_dark_mode()` (`Utils/MacDarkMode.mm`) reads the `AppleInterfaceStyle` user default == "Dark" — the **system**
preference. The config key is ignored. wx's `IsDark()` (which feeds `update_dark_config()`) reads
`[NSApp effectiveAppearance]`; the two normally agree.
- elsewhere: `"1"` → true, `"0"` → false, otherwise `check_dark_mode()` (`GUI_Utils.cpp`:
`wxSystemSettings::GetAppearance().IsDark()`). On Windows the default makes the fallback effectively unreachable;
on Linux the key is only empty before `on_init_inner` writes it, so "dark" means "the GTK theme's window colours
are dark".
`check_dark_mode()` keeps `IsDark()` deliberately; switching it to `AreAppsDark()`/`IsSystemDark()` is not a required
3.3 migration: off MSW the three are identical (`src/common/settcmn.cpp:71-84`), and on MSW it is reached only for an
unset key and for Windows menu bitmaps, where matching wx's own menu state is the point.
There is **no "follow system" setting and no in-app override on macOS/Linux**: the app mirrors the OS and re-syncs
the config on each SYS_COLOUR event. `SUPPORT_DARK_MODE` is defined unconditionally in `libslic3r/AppConfig.hpp`, and
`_MSW_DARK_MODE` is defined to 1 on every platform in `GUI_App.hpp` — neither is a platform gate. Windows-only code
sits under `__WINDOWS__`/`_WIN32` (NppDarkMode sources are added only `if (WIN32)` in `src/slic3r/CMakeLists.txt`).
The other theme-dependent `GUI_App` colours (`m_color_label_modified`, `m_color_label_sys` `#363636`/`#B2B3B5`,
`m_color_label_default`, `m_color_highlight_default` `#F1F1F1`/`#36363B`, `m_color_window_default`, button
label/background) are computed by `init_label_colours()`.
**Pitfalls**
- **Rule:** An explicit `dark_color_mode` wins in both directions; `check_dark_mode()` is a last-resort fallback for
an unset key.
**Why:** on Windows, once `MSWEnableDarkMode(DarkMode_Auto)` is active, `IsDark()` reports the system app mode, so
an explicit "0" falling through to it made dark → light switching silently fail.
```cpp
// Wrong: explicit "0" falls through to the contaminated system query
return app_config->get("dark_color_mode") == "1" ? true : check_dark_mode();
// Right: explicit choice wins in both directions
const auto& val = app_config->get("dark_color_mode");
if (val == "1") return true;
if (val == "0") return false;
return check_dark_mode(); // unset key only
```
Cite: 7d7f26ed69 (`GUI_App.cpp`, `GUI_App::dark_mode`, the non-Apple branch).
- **Rule:** Any new code path that changes the theme first sets `dark_color_mode` (Windows: `app_config->set` +
`save()`; macOS/Linux: `update_dark_config()`), then refreshes the cached state: `wxGetApp().Update_dark_mode_flag()`
(`m_is_dark_mode`, read by the walk), `wxGetApp().init_label_colours()` (StateColor's `gDarkMode` and the label
colours) and, on Windows, `wxGetApp().force_colors_update()` (NppDarkMode's `g_darkModeEnabled` via
`NppDarkMode::SetDarkMode(dark_mode())`, the main frame's title bar, and the walk flag `update_ui_from_settings()`
consumes). `dark_mode()` itself is live; wx's own MSW mode follows the system and is not set by Orca.
**Why:** they are updated at different moments; `update_dark_config()` does not touch StateColor's flag — for the
main frame that happens in `MainFrame::on_sys_color_changed` — and after startup only `force_colors_update()` calls
`NppDarkMode::SetDarkMode`.
## The Update*DarkUI walk
`GUI_App` helpers (`GUI_App.hpp/.cpp`):
| Helper | Does |
|---|---|
| `UpdateDarkUI(win, highlited = false, just_font = false)` | One window. `just_font` is unused. Skips `wxBU_AUTODRAW` buttons (`wxButton` or Orca `Button`). Windows only: a `wxButton` with id `wxID_OK`/`wxID_CANCEL` gets `wxNO_BORDER`, palette background/foreground (→ owner-drawn) and four hover/focus handlers **bound again on every call**. Then, using `m_is_dark_mode` (not `dark_mode()`): **dark** — `bg = darkModeColorFor(GetBackgroundColour())`, set only if it changed (exact key); `fg = darkModeColorFor(GetForegroundColour())`, then if ΔE(bg, fg) < 10 → LAB L = 90, if L(fg) < 45 → L = 70, and **fg is always set**; **light** — `lightModeColorFor` on background and foreground, each set only if changed. |
| `update_dark_children_ui(win)` (file-static) | recursive over `GetChildren()` at call time: a `ScalableButton` gets `ScalableButton::UpdateDarkUI()` (= `msw_rescale()`: `UpdateDarkUI(this, m_has_border)` plus re-rasterized icons), anything else `UpdateDarkUI(child)`. `GetChildren()` includes owned TLWs, so dialogs parented to the window are walked too. |
| `UpdateDarkUIWin(win)` | the walk. |
| `UpdateDlgDarkUI(dlg)` / `UpdateFrameDarkUI(frame)` | Windows: `NppDarkMode::SetDarkExplorerTheme` + `SetDarkTitleBar` on the HWND (both follow Orca's mode in both directions); then the walk. |
| `UpdateDVCDarkUI(dvc, highlited)` | **Windows-only body** (no-op on macOS/Linux): `UpdateDarkUI`, dark list header via `NppDarkMode::SetDarkListViewHeader`, header attr text colour `NppDarkMode::GetTextColor()`, `SetAlternateRowColour(m_color_highlight_default)` for `wxDV_ROW_LINES`, forces `wxBORDER_SIMPLE`. |
| `UpdateAllStaticTextDarkUI(parent)` | **Windows-only body**: `UpdateDarkUI(parent)` and `m_color_label_default` on direct `wxStaticText` children. |
Consequences: an unmapped background stays as it is; an unmapped dark foreground becomes grey (L≈70) instead of
dark-on-dark; every visited window ends with an explicit foreground (on MSW that makes raw buttons/checkboxes
owner-drawn); dark → light is lossy (lifted greys are not restored; shared twins collapse, see
[StateColor](#statecolor)); the walk only calls `Set{Background,Foreground}Colour`, so `wxPaintDC` painting is
unreachable by it. The source comment on `UpdateDarkUIWin` ("Don't use this function for Dialog contains
ScalableButtons") is stale: all three entry points run the same `update_dark_children_ui`, which already handles
`ScalableButton`; the only difference is the Windows HWND theming. Use `UpdateDlgDarkUI` for dialogs because of
that theming.
App-wide passes: `MainFrame`'s constructor ends with `UpdateDarkUIWin(this)` (on all platforms); a theme switch runs
`update_dark_children_ui(mainframe)` from `GUI_App::update_ui_from_settings`; the lazily built main-window tabs re-run
`UpdateDarkUIWin(this)` after insertion. Everything created later themes itself.
Raw wx controls do not follow Orca's mode by themselves (on Windows wx themes native controls by the *system* app
mode, see [wxMSW dark mode](#wxmsw-dark-mode-mswenabledarkmode-setappearance-wxdarkmodesettings)). When one cannot
be replaced by an Orca widget, make it dark-safe through the walk (`UpdateDarkUI(ctrl)` for a single control created
after the pass, `UpdateDlgDarkUI(dlg)` for its dialog) or with explicit `darkModeColorFor()` colours.
**Pitfalls**
- **Rule:** Theme a dialog with `UpdateDlgDarkUI`, not `UpdateDarkUI`.
**Why:** `UpdateDarkUI` touches one window; children keep light defaults and the Windows title bar stays light.
```cpp
// Wrong:
wxGetApp().UpdateDarkUI(this);
// Right:
wxGetApp().UpdateDlgDarkUI(this);
```
Cite: 465f634988 (`AMSMaterialsSetting.cpp` `AMSMaterialsSetting::Show`).
- **Rule:** After building a runtime-created subtree (widgets constructed after the initial pass), end the
constructor with one `wxGetApp().UpdateDarkUIWin(this)`; for an ad-hoc `wxDialog`, call
`wxGetApp().UpdateDlgDarkUI(&dlg)` after all children exist and before `ShowModal()` (the order relative to `Fit()`
does not matter).
**Why:** the app-wide pass themes only windows that existed when it ran; HMS notify items, device
firmware/nozzle panels, one-off confirmation dialogs appear with light defaults in dark mode on every platform.
One subtree walk also themes child labels and beats sprinkling per-widget `darkModeColorFor` calls.
```cpp
// Wrong: dialog built and shown with hard-coded light colours only
dlg.SetSizer(main_sizer); dlg.Fit(); dlg.ShowModal();
// Right:
dlg.SetSizer(main_sizer); dlg.Fit();
wxGetApp().UpdateDlgDarkUI(&dlg);
dlg.ShowModal();
```
Cite: f7f0c82abb (`HMSPanel.cpp`, `DeviceTab/uiDeviceUpdateVersion.cpp`, `DeviceTab/wgtDeviceNozzleSelect.cpp`,
`SelectMachine.cpp` `SelectMachineDialog::show_timelapse_storage_dialog`).
- **Rule:** Apply deliberate non-palette colours after the walk (and again on theme change).
**Why:** in dark mode the walk rewrites every visited window's foreground — white text on an accent chip becomes
mid-grey.
```cpp
wxGetApp().UpdateDlgDarkUI(this);
m_badge->SetForegroundColour(*wxWHITE); // after the walk
```
- **Rule:** Run the dialog walk once per theme state; do not call it from paint or size handlers.
**Why:** the map chains (`#FFFEFE → #D9D9D9 → #27272A`) and the Windows OK/Cancel branch stacks handlers per call.
## NppDarkMode (Windows)
Vendored Notepad++ dark-mode code in `src/slic3r/GUI/dark_mode.cpp/.hpp` and `src/slic3r/GUI/dark_mode/*.hpp`,
compiled only on Windows. Namespace `NppDarkMode`:
| Function | Does |
|---|---|
| `InitDarkMode(bool dark, bool sys_menu)` | loads the uxtheme entry points, records the system-menu setting, `SetDarkMode(dark)`. |
| `SetDarkMode(bool)` | sets `g_darkModeEnabled`; `AllowDarkModeForApp(dark)` → `SetPreferredAppMode(ForceDark|ForceLight)` (Windows 1903+) or `AllowDarkModeForApp` (1809); flushes menu themes when the system-menu setting is on; scrollbar fix. |
| `SetDarkTitleBar(HWND)` | allows dark for the window per `IsEnabled()`, refreshes the title-bar colour, applies the explorer theme. |
| `SetDarkExplorerTheme(HWND)` | `SetWindowTheme(hwnd, IsEnabled() ? L"DarkMode_Explorer" : nullptr, nullptr)`. |
| `SetDarkListViewHeader(HWND)` | dark `ItemsView` theme on a list header. |
| `GetTextColor()` | `0xF0F0F0` when enabled, else `wxSYS_COLOUR_WINDOWTEXT` — which is wx's dark-palette `0xe0e0e0` when the Windows app mode is dark and Orca is light **[source]**. |
Unlike wx's MSW dark mode it switches live: every function follows `g_darkModeEnabled` in both directions, so
re-running them on existing HWNDs re-themes them. `GUI_App::force_colors_update()` re-arms it
(`NppDarkMode::SetDarkMode(dark_mode())`, `SetDarkTitleBar(mainframe)`); `UpdateDlgDarkUI`/`UpdateFrameDarkUI`
apply it per TLW. Its `#if wxVERSION_NUMBER < 3300` block that themed the tooltip window is dead:
`wxToolTip::GetToolTipCtrl()` is private in 3.3 (`include/wx/msw/tooltip.h:92`), and wx dark-enables the
tooltip window itself through `wxMSWDarkMode::AllowForWindow`, so tooltips follow wx's mode (the system app
mode), not Orca's **[source]** (`src/msw/tooltip.cpp:321`). `update_dark_ui(wxWindow*)` (`GUI_Utils.cpp`, `_WIN32`), which `DPIAware`'s constructor and
`force_color_changed()` call, has an empty body.
## Runtime theme switch and re-applying colours
```text
Startup (GUI_App::on_init_inner): init_label_colours() [StateColor::SetDarkMode] → Update_dark_mode_flag()
→ (non-Windows) dark_color_mode := GetAppearance().IsDark()
→ (Windows) MSWEnableDarkMode(DarkMode_Auto); NppDarkMode::InitDarkMode(dark_mode(), sys_menu)
MainFrame ctor ends with UpdateDarkUIWin(this)
Windows — Preferences "Enable dark Mode" (PreferencesDialog::create_item_darkmode, the only toggle):
1 set + save dark_color_mode 2 Update_dark_mode_flag()
3 force_colors_update(): NppDarkMode::SetDarkMode(dark_mode()), SetDarkTitleBar(mainframe), m_force_colors_update
4 update_ui_from_settings():
mainframe->force_color_changed() [_WIN32: update_dark_ui (empty) + MainFrame::on_sys_color_changed()]
update_scrolls(mainframe), update_scrolls(&m_settings_dialog)
update_dark_children_ui(mainframe) (all platforms when m_force_colors_update)
5 PreferencesDialog::set_dark_mode() → UpdateDlgDarkUI(this)
6 wxPostEvent(plater, EVT_GLCANVAS_COLOR_MODE_CHANGED) → Plater::priv::on_change_color_mode (GL canvases, sidebar)
macOS / Linux — system switch: wxEVT_SYS_COLOUR_CHANGED at every DPIAware TLW (order undefined)
→ update_dark_config() [config + m_is_dark_mode] → on_sys_color_changed() → Skip()
MainFrame::on_sys_color_changed(): init_label_colours() → force_colors_update() → update_ui_from_settings()
[update_dark_children_ui(mainframe)] → fan-out below
Plater::priv::on_apple_change_color_mode → GL canvases
```
At startup `init_label_colours()` and `Update_dark_mode_flag()` run before the key is rewritten from the system, and
the later re-check only fires when `dark_mode()` changes after that rewrite. On Linux (where `dark_mode()` reads the
key) a start after the GTK theme changed while Orca was closed therefore leaves StateColor's flag and
`m_is_dark_mode` on the previous session's value — including for the `MainFrame` constructor's walk — until the next
`MainFrame::on_sys_color_changed`. **[source; consequence inferred from the call order in
`GUI_App::on_init_inner`, not observed]**
`MainFrame::on_sys_color_changed` is the **registry for long-lived UI**: `DiffPresetDialog::if_built()`,
`m_tabpanel->Rescale()`, `m_param_panel->msw_rescale()`, `plater()->sys_color_changed()` (→ `Sidebar::sys_color_changed`
→ …), `MonitorPanel::when_built`, `CalibrationPanel::when_built`, every `Tab::sys_color_changed()` (tabs, model tabs,
plate tab), `MenuFactory::sys_color_changed(m_menubar)` (its body is compiled out with `#if 0`, so menu-bar item
icons are not re-rasterized; the cached context menus are, via `Plater::sys_color_changed` →
`MenuFactory::sys_color_changed()`), `WebView::RecreateAll()`, then `Refresh()`. A cached,
hidden or lazily built window must be added here or chained from something here: the walk reaches dialogs parented
to the main frame (they are in `GetChildren()`), but only their colours (and `ScalableButton` icons) — not their
title bar, `ScalableBitmap`-based images or `on_sys_color_changed()`. Commit bab3c72e4f fixed the compare dialog keeping old row colours by calling
`diff_dialog.on_sys_color_changed()` from this fan-out (the dialog is not destroyed on close).
Who gets what on a switch:
| Window | Windows (Preferences) | macOS/Linux (system) |
|---|---|---|
| MainFrame subtree | walk + fan-out | walk + fan-out |
| Open dialog parented to the main frame | walk only (Preferences re-themes itself) | its own `on_sys_color_changed()` via the OS event + the walk |
| Hidden cached dialog parented to the main frame | walk only, unless chained in the fan-out | walk + its own handler (the OS event reaches every TLW) |
| Modal dialogs (`dialogStack`, via `DPIAware::ShowModal`) | cannot be open across a Preferences change | live |
**What a dialog must do to survive a toggle:**
1. Derive from `DPIDialog` (`DPIAware<wxDialog>`, `GUI_Utils.hpp`; `on_sys_color_changed()` is a protected virtual
no-op by default) and use palette colours / Orca widgets.
2. End the constructor with `wxGetApp().UpdateDlgDarkUI(this)` — it also sets the Windows dark title bar and
explorer theme the children walk alone does not.
3. Override `on_sys_color_changed()` to re-create `ScalableBitmap`s (`msw_rescale()` + re-`SetBitmap`), re-pick
`*_dark` icon names, re-apply construction-time and owner-drawn colours, call widget `Rescale()` where styles
depend on the theme, then `Refresh()`. It runs on macOS/Linux from the OS event; on Windows only if the main-frame
fan-out calls it.
4. If it outlives a show (cached singleton, lazily built), register it in `MainFrame::on_sys_color_changed`.
**Pitfalls**
- **Rule:** Never hand a literal light-theme colour straight to `SetForegroundColour`/`SetBackgroundColour`/`wxPen`/`wxBrush`;
wrap it in `StateColor::darkModeColorFor(...)` when it is a `gDarkColors` key, or branch on
`wxGetApp().dark_mode()` with an explicit dark counterpart when it is not.
**Why:** `darkModeColorFor` is an exact RGBA lookup; an unmapped colour (`#F0F0F0`, `#333333`, `#5C5C5C`) passes
through unchanged and renders as a light patch, or — set directly with no walk after it — dark-on-dark text. This
also applies to owner-drawn `wxPaintDC` painting (popup borders/fills), which the walk cannot reach.
```cpp
label->SetForegroundColour(wxColour("#009688")); // Wrong: stays light-theme teal
label->SetForegroundColour(StateColor::darkModeColorFor("#009688")); // Right: → #00675b in dark
// unmapped colour: branch explicitly
msg->SetForegroundColour(wxGetApp().dark_mode() ? wxColour("#EFEFF0") : wxColour(0x33, 0x33, 0x33));
```
Cite: f7f0c82abb (`SelectMachine.cpp` `SelectMachineDialog::Enable_Auto_Refill`, `show_timelapse_folder_popup`;
`Plater.cpp` `HoverLabel`).
- **Rule:** Any colour chosen at construction time (chip backgrounds, per-state label colours, `dark_mode()`-dependent
picks) is re-applied on a live switch: override `on_sys_color_changed()` (DPIDialog/DPIFrame) or add a
`sys_color_changed()` method that the owner's `sys_color_changed()` chains to, ending with `Refresh()`.
**Why:** `dark_mode()` is live but a value computed once in a constructor is not; the walk fixes mapped colours only,
not unmapped colours or platform-conditional picks.
```cpp
// Wrong: colours set once in the ctor, never again
HoverLabel(...) { SetBackgroundColour(extruder_group_chip_bg()); ... }
// Right: also re-apply on theme switch, chained from the parent
void HoverLabel::sys_color_changed() { SetBackgroundColour(extruder_group_chip_bg()); /* label fg */ Refresh(); }
void ExtruderGroup::sys_color_changed() { if (hover_label) hover_label->sys_color_changed(); ...; Refresh(); }
void Sidebar::sys_color_changed() { ...; for (auto* ext : extruders) ext->sys_color_changed(); }
```
Cite: f7f0c82abb (`Plater.cpp`: `HoverLabel::sys_color_changed`, `ExtruderGroup::sys_color_changed`,
`Sidebar::sys_color_changed`).
- **Rule:** In a dialog's `on_sys_color_changed()` that derives colours through `darkModeColorFor`/`StateColor`,
call `wxGetApp().init_label_colours()` first.
**Why:** `darkModeColorFor` uses StateColor's flag, which only `init_label_colours()` refreshes (startup and
`MainFrame::on_sys_color_changed`). On macOS each NSWindow delivers the event from its own KVO with no defined
order, so a dialog handler can run before the main frame's and see the old flag. **[source; the ordering risk is
inferred, not observed]**
## Dark-mode icons
Icons are SVGs in `resources/images/` (a PNG of the same name is only a fallback, with no dark substitution), named
without extension, rasterized by Orca itself (wx has no SVG
support in Orca's build — see `references/dpi-bitmaps-fonts.md` for sizing, `ScalableBitmap` and `BitmapCache`
mechanics). Dark mode reaches icons in two regimes:
1. **Palette substitution.** `create_scaled_bitmap()` (`wxExtensions.cpp`) passes `wxGetApp().dark_mode()` to
`BitmapCache::load_svg`, which text-replaces palette colours in the SVG before nanosvg parses it. An SVG drawn
purely in substitution colours is dark-correct automatically.
2. **`*_dark` asset variants**, chosen by name in code, for everything else.
**Substitution table** (`BitmapCache::load_svg`; replacement by `BitmapCache::nsvgParseFromFileWithReplace`):
| Mode | Replacements |
|---|---|
| light | `"#00FF00"` → `"#52c7b8"`; unquoted `#949494` → `#7C8282` (icon line colour) |
| dark | `"#262E30"` → `"#EFEFF0"` and unquoted `#262E30` → `#EFEFF0`; `"#323A3D"` → `"#B3B3B5"`; `"#808080"` → `"#818183"`; `"#CECECE"` → `"#54545B"`; `"#6B6B6B"` → `"#818182"`; `"#909090"` → `"#FFFFFF"`; `"#00FF00"` → `"#FF0000"`; `"#009688"` → `"#00675b"`; `"#F1F1F1"` → `"#36363B"`; unquoted `#DBDBDB` → `#4A4A51` (border), `#F0F0F1` → `#333337` (disabled background) |
| dark, name contains `toggle_on` | additionally unquoted `#009688` → `#00675b` |
| `new_color` argument | replaces the `"#009688"` slot in both modes (in dark mode it overrides the `#00675b` mapping too) |
| name contains `printer_thumbnail` | no replacement at all |
| both modes | the key `"#0x00AE42"` is malformed (contains `0x`) and matches nothing real — dead |
Mechanics: literal, **case-sensitive** `boost::replace_all` over the raw file text, applied in `std::map` key order,
so all quoted keys (`"` = 0x22) run before unquoted ones (`#` = 0x23). Quoted keys match only a full attribute value
written `="#RRGGBB"` (`fill="#262E30"`, `stroke="…"`); they never match CSS `style="fill:#…"` or single quotes. Only the
unquoted keys (`#262E30`, `#DBDBDB`, `#F0F0F1` in dark; `#949494` in light) reach style attributes. The cache key
contains size, scale, `-dm`, `-gs` and the `new_color` string, so light and dark rasterizations are cached
separately. This SVG map is separate from, and not identical to, `gDarkColors`.
`create_scaled_bitmap` re-queries the mode on every call, with two exceptions: on Windows `menu_bitmap = true`
(`create_menu_bitmap`) uses `check_dark_mode()`; `bitmap2 = true` routes to `create_scaled_bitmap2` →
`BitmapCache::load_svg2`, which applies **no** palette substitution (only `#D9D9D9`/`fill-opacity` from
`array_new_color`). Re-rasterizing is therefore the theme hook: `ScalableBitmap::msw_rescale()` and
`ScalableButton::msw_rescale()` re-run `create_scaled_bitmap`, so the DPI path doubles as the theme path
(`Tab::sys_color_changed` calls `msw_rescale()` on every cached button/bitmap and rebuilds its `wxImageList`). The
walk reaches `ScalableButton`s (`ScalableButton::UpdateDarkUI` = `msw_rescale()`), but not `ScalableBitmap`
members — they are not windows; their owner calls `msw_rescale()` and re-`SetBitmap`s. `ScalableBitmap::msw_rescale()`
re-creates from name, size, grayscale and resize only: `new_color` and `bitmap2` are not re-applied.
**When a `*_dark` variant + explicit re-pick is required:** whenever the icon's colours are not in the substitution
table (multi-colour artwork, brand colours, off-palette greys like `#1F1F1F`), or the dark rendition is not a 1:1
colour mapping of the light one. The code chooses the `_light`/`_dark` name from `wxGetApp().dark_mode()` **in code
that re-runs on theme change**:
```cpp
// in the ctor AND in on_sys_color_changed():
m_icon = ScalableBitmap(this, wxGetApp().dark_mode() ? "icon_dark" : "icon", 20);
m_static_bmp->SetBitmap(m_icon.bmp());
```
The substitution still runs on whichever file is loaded, so variant files must use
off-palette colours or the `_dark` asset is recoloured a second time (a `#262E30` in a `_dark` file still becomes
`#EFEFF0`). Re-rasterizing a stored name (`ScalableBitmap::msw_rescale`) does **not** switch variants. Reference
pattern: `AmsHumidityLevelList` (`AmsMappingPopup.cpp`) preloads both variants as `ScalableBitmap`s and picks
`hum_level_img_dark`/`hum_level_img_light` by `dark_mode()` inside its render path, so a `Refresh()` re-picks.
Alternatively re-run the name selection inside `on_sys_color_changed()`/`msw_rescale()`.
**Menu bitmaps.** Menu items built with `append_menu_item(..., icon_name, ...)` use `create_menu_bitmap` (16 px,
no window, `menu_bitmap = true`), and the icon name is remembered per item id (not on GTK). On Windows the SVG substitution
then uses `check_dark_mode()` as its dark flag — wx's own answer,
which is what wx uses to draw menus (its owner-drawn menu path keys on `wxMSWDarkMode::IsActive()`), so the icon
matches the menu background even when Orca's mode differs from the Windows app mode. On a theme change Windows menu
icons are re-rasterized by `msw_rescale_menu` (a no-op elsewhere) from the stored icon names;
`MenuFactory::sys_color_changed()` does this for the cached context menus (the menu-bar overload is compiled out). Menus themselves are covered in
`references/popups-menus.md`.
**Pitfalls**
- **Rule:** Author single-tone SVG icons in the colours `load_svg` substitutes — for near-black line art use
`#262E30` (uppercase), not an arbitrary near-black like `#1F1F1F` or `#333333`.
**Why:** recolouring is a literal, case-sensitive string replacement on a fixed palette; an off-palette fill (or
lowercase `#262e30`) passes through untouched and the icon disappears against the dark background. The fix is a
colour change in the asset; no code change.
```xml
<path d="..." fill="#1F1F1F"/> <!-- Wrong: not substituted, invisible in dark mode -->
<path d="..." fill="#262E30"/> <!-- Right: → #EFEFF0 in dark mode -->
```
Cite: f658aad7ca (`resources/images/ams_drying.svg`); `BitmapCache::load_svg`, `BitmapCache::nsvgParseFromFileWithReplace`.
- **Rule:** When an icon exists as `*_light`/`*_dark` variants, select the variant matching the mode — `_dark` in
dark mode — and make the selection run inside code that re-executes on theme change, not once.
**Why:** the classic copy-paste bug returned the `_light` name in both branches, so dark mode showed
near-invisible light-theme humidity glyphs; nothing else corrects it because the variant files are authored
off-palette. A selection made only when the data changes keeps the old variant after a live switch until the data
changes again.
```cpp
// Wrong: light asset in dark mode
if (wxGetApp().dark_mode()) return "hum_level" + std::to_string(hum_level) + "_no_num_light";
else return "hum_level" + std::to_string(hum_level) + "_no_num_light";
// Right:
if (wxGetApp().dark_mode()) return "hum_level" + std::to_string(hum_level) + "_no_num_dark";
else return "hum_level" + std::to_string(hum_level) + "_no_num_light";
```
Cite: 668654da5f (`AMSDryControl.cpp` `get_humidity_level_img_path`); live-switch shape: `AmsHumidityLevelList`
(`AmsMappingPopup.cpp` `AmsHumidityLevelList::doRender`).
- **Rule:** Do not "fix" a Windows menu icon by passing `wxGetApp().dark_mode()`.
**Why:** `create_menu_bitmap` deliberately follows `check_dark_mode()` (wx's menu state = the Windows app mode),
not Orca's setting; the icon must match the background wx draws.
Cite: `wxExtensions.cpp` `create_scaled_bitmap`, `create_menu_bitmap`.
- **Rule:** Pass the real window to `create_scaled_bitmap`/`ScalableBitmap` and re-create bitmaps in
`on_sys_color_changed()` as well as `on_dpi_changed()`; a missing icon name throws `Slic3r::RuntimeError`.
Cite: `wxExtensions.cpp` `create_scaled_bitmap`.
@@ -0,0 +1,963 @@
# Standard controls, data views and Orca's widget event contracts
The value and event contracts of wx's standard controls (text, choice/combo, check/radio, spin, slider, gauge,
static text, hyperlink, static bitmap, book controls, splitter, collapsible pane, list/tree/grid), the
`wxDataViewCtrl` model/renderer machinery with its native-vs-generic limits, `wxVariant` and validators; then
which events Orca's replacement widgets emit and where to bind them, and the `ObjectList` / `ObjectGrid` designs.
Read it before writing or reviewing code that sets a control's value, reacts to its events, or touches a data view.
Contents: [Rules](#rules) · [Native vs generic](#native-vs-generic-and-silent-asserts) ·
[Programmatic changes](#events-from-programmatic-changes) · [wxTextCtrl](#wxtextctrl--wxtextentry) ·
[wxStaticText](#wxstatictext-labels-mnemonics-markup) · [Item containers](#item-containers) ·
[Check and radio](#check-boxes-and-radio-buttons) · [Spin, slider, gauge](#spin-controls-slider-gauge) ·
[Book controls](#book-controls) · [Splitter, pane, hyperlink, bitmap](#splitter-collapsible-pane-hyperlink-static-bitmap) ·
[List and tree](#wxlistctrl-wxtreectrl-image-lists) · [wxGrid](#wxgrid) · [Validators](#validators) ·
[DVC per port](#wxdataviewctrl-native-vs-generic) · [DVC model](#wxdataviewmodel-contract) ·
[DVC control API](#wxdataviewctrl-control-api) · [Renderers and editing](#custom-renderers-and-in-place-editing) ·
[wxVariant](#wxvariant-with-custom-objects) · [Orca widgets](#orca-replacement-widgets-event-contracts) ·
[ObjectList](#objectlist-objectdataviewmodel-extrarenderers) · [ObjectGrid](#objectgrid-gui_objecttable)
## Rules
1. Model→view refreshes use the quiet setters (`ChangeValue`, `ChangeSelection`). `wxTextEntry::SetValue`,
`wxBookCtrlBase::SetSelection`, `wxTreeCtrl::SelectItem` and macOS `wxDataViewCtrl::Select` *do* emit events.
→ [Programmatic changes](#events-from-programmatic-changes)
2. Never call `SetLabel`/`SetLabelText`/`GetLabel` on a `wxTextCtrl` to set or read its text; the setters are
silent no-ops in 3.3 and `GetLabel` returns the label, not the text. → [wxTextCtrl](#wxtextctrl--wxtextentry)
3. To consume Enter, handle `wxEVT_TEXT_ENTER` (needs `wxTE_PROCESS_ENTER`) and do not `Skip()`; skipping lets
Enter activate the dialog's default button. → [wxTextCtrl](#wxtextctrl--wxtextentry)
4. Call `OSXDisableAllSmartSubstitutions()` (wxOSX-only API, so inside `#ifdef __WXOSX__`) on every text control
that holds G-code, paths, URLs or code. → [wxTextCtrl](#wxtextctrl--wxtextentry)
5. Do not rely on `SetHint`/`SetMaxLength` on multi-line controls (hints: MSW and GTK2 only; max length: MSW and
GTK only). → [wxTextCtrl](#wxtextctrl--wxtextentry)
6. Show user data (preset, filament, file names) in labels and page titles with `SetLabelText` /
`wxControl::EscapeMnemonics`; quote it with `wxMarkupParser::Quote` inside markup.
→ [wxStaticText](#wxstatictext-labels-mnemonics-markup)
7. Typed `wxClientData` belongs to the control (never delete it); `::ComboBox` supports only untyped `void*`
data, which the caller owns. → [Item containers](#item-containers), [::ComboBox](#combobox)
8. A 3-state checkbox is read with `Get3StateValue()`; every radio group starts with `wxRB_GROUP`.
→ [Check and radio](#check-boxes-and-radio-buttons)
9. Read spin values after the commit event (`wxEVT_SPINCTRL`), not from `wxEVT_TEXT`.
→ [Spin](#spin-controls-slider-gauge)
10. Book pages are created with the book as parent; in `PAGE_CHANGED` use `event.GetSelection()`.
→ [Book controls](#book-controls)
11. Pixel-valued setters take `FromDIP(n)` (`SetMinimumPaneSize`, `SetRowHeight`, column widths), and are
re-applied on rescale. → [Splitter](#splitter-collapsible-pane-hyperlink-static-bitmap), [DVC API](#wxdataviewctrl-control-api)
12. Never dereference `begin()` of a wx selection range (`wxGrid::GetSelectedBlocks()`) without comparing it to
`end()`; never assume `wxDataViewCtrl::GetSelection()` is valid. → [wxGrid](#wxgrid)
13. A `wxGridCellChoiceEditor` subclass whose `m_control` is not a `wxComboBox` overrides `Reset()`,
`GetValue()` and `SetParameters()` too. → [wxGrid](#wxgrid)
14. Validators only filter keystrokes; parse and range-check values on commit. → [Validators](#validators)
15. Never `delete` a `wxDataViewModel`; keep exactly the references you mean to own. → [DVC model](#wxdataviewmodel-contract)
16. Change the model first, then notify (`ItemAdded`/`ItemDeleted`); after deletion use the pointer only as an
ID. Use `Cleared()` only when everything changed. → [DVC model](#wxdataviewmodel-contract)
17. `GetValue` fills the variant type the column's renderer expects; a mismatch shows nothing.
→ [DVC model](#wxdataviewmodel-contract)
18. With `wxDV_MULTIPLE` use `GetSelections()`/`HasSelection()`; `event.GetItem()` of `SELECTION_CHANGED` may be
invalid on GTK and macOS. → [DVC API](#wxdataviewctrl-control-api)
19. Bracket programmatic `Select`/`UnselectAll`/model mutations with a suppress flag that the
`SELECTION_CHANGED` handler checks. → [DVC API](#wxdataviewctrl-control-api)
20. Give a data view with custom renderers an explicit `SetRowHeight` sized for the tallest content; repeat it
after `SetFont` and in the rescale handler. → [DVC API](#wxdataviewctrl-control-api)
21. Never carry a `wxDataViewItem` across a deferred call without re-validating it against the model.
→ [DVC model](#wxdataviewmodel-contract), [ObjectList](#objectlist-objectdataviewmodel-extrarenderers)
22. Check `!v.IsNull() && v.GetType() == "<Class>"` before every `obj << variant`; `dynamic_cast` the editor in
`GetValueFromEditorCtrl`. → [wxVariant](#wxvariant-with-custom-objects), [Renderers](#custom-renderers-and-in-place-editing)
23. A compound in-place editor (TextInput, `::ComboBox`) commits by calling the renderer's `FinishEditing()` /
`CancelEditing()` itself. → [Renderers](#custom-renderers-and-in-place-editing)
24. On macOS, `CreateEditorCtrl` never runs natively: veto `START_EDITING`, `CallAfter` the renderer's own
`StartEditing`, then `SetCustomRendererPtr/Item`, all under `#ifdef __WXOSX__`.
→ [Renderers](#custom-renderers-and-in-place-editing)
25. Bind `::TextInput`/`::SpinInput` `wxEVT_TEXT_ENTER`/`wxEVT_KILL_FOCUS` on the widget itself (or its
`GetTextCtrl()`), never on a parent filtered by the widget's id. `::ComboBox` ignores the id passed to its
constructor, so bind on the combo (or `SetId()` after construction if an id filter is unavoidable).
→ [Orca widgets](#orca-replacement-widgets-event-contracts)
26. `::CheckBox`/`SwitchButton` emit `wxEVT_TOGGLEBUTTON`, and a handler bound on the widget itself must `Skip()`;
`RadioGroup::SetSelection` always emits; `SpinInput`'s `wxEVT_SPINCTRL` is a plain `wxCommandEvent`.
→ [Orca widgets](#orca-replacement-widgets-event-contracts)
27. Expect Orca's colours on every generic data view and `RenderText` on MSW: `ObjectList` installs a
process-global `wxRendererNative`. → [ObjectList](#objectlist-objectdataviewmodel-extrarenderers)
## Native vs generic, and silent asserts
Orca builds wx with `wxBUILD_DEBUG_LEVEL=0` and `libslic3r_gui` with `wxDEBUG_LEVEL=0`, so `wxASSERT`/`wxFAIL`
vanish and `wxCHECK_*` returns early without a message (`include/wx/debug.h:229-231, 340-368`). Every "asserts" in
the docs below means, in Orca, that the call silently does nothing (a `wxCHECK`) or carries on with bad state (a
`wxASSERT`). Linux builds target GTK3 (GTK2 is only the `-DDEP_WX_GTK3=OFF` opt-out); GTK2-only behaviour is noted
where it differs.
Which implementation a control uses decides most platform differences:
| Control | MSW | GTK | macOS | Cite |
|---|---|---|---|---|
| `wxDataViewCtrl` | generic | native `GtkTreeView` | native `NSOutlineView` | `include/wx/dataview.h:36-44` |
| `wxBitmapComboBox` | native (owner-drawn CB) | native | generic `wxOwnerDrawnComboBox` | `include/wx/bmpcbox.h:27-28,112-119` |
| `wxTreeCtrl` | native | generic | generic | `include/wx/treectrl.h:27-28` |
| `wxListCtrl` | native | generic | generic | `include/wx/listctrl.h:29-34` |
| `wxHyperlinkCtrl` | native | native | generic | `interface/wx/hyperlink.h:90` |
| `wxGrid` | generic | generic | generic | `src/generic/grid.cpp` |
## Events from programmatic changes
The general wx rule is that setters do not send events. The exceptions are the bugs:
| Call | Event emitted? | Cite |
|---|---|---|
| `wxTextEntry::SetValue(s)` | **yes**, one `wxEVT_TEXT`, even when `s` equals the current text | `interface/wx/textentry.h:539-542`; [source] `src/common/textentrycmn.cpp:236-254`, `src/msw/textctrl.cpp:1133-1145` |
| `wxTextEntry::ChangeValue(s)` | no | `interface/wx/textentry.h:177-178` |
| `wxTextEntry::Clear()` | yes (= `SetValue("")`) | `interface/wx/textentry.h:193-194` |
| `wxTextCtrl::SetLabel/SetLabelText` | nothing at all (3.3) | `docs/changes.txt:111-113`; `src/common/textcmn.cpp:934-937` |
| `SetSelection/SetStringSelection` on wxChoice, wxComboBox, wxListBox, wxRadioBox | no | `interface/wx/ctrlsub.h:102-103,126` |
| `wxComboBox::SetValue` | `wxEVT_TEXT` if editable; none with `wxCB_READONLY` | `interface/wx/combobox.h:256-272` |
| `wxComboBox::Popup()/Dismiss()` | DROPDOWN/CLOSEUP, except on wxOSX | `interface/wx/combobox.h:279-281,292-294` |
| `wxCheckBox::SetValue/Set3StateValue`, `wxRadioButton::SetValue`, `wxCheckListBox::Check` | no | `interface/wx/checkbox.h:170-180`, `interface/wx/radiobut.h:117-118`, `interface/wx/checklst.h:135-137` |
| `wxSpinCtrl[Double]::SetValue/SetRange` | no; `SetRange` may silently clamp the value | `interface/wx/spinctrl.h:186-197,220-231,432-441` |
| `wxBookCtrlBase::SetSelection` | **yes**, PAGE_CHANGING (vetoable) + PAGE_CHANGED | `interface/wx/bookctrl.h:175-183` |
| `wxBookCtrlBase::ChangeSelection` | no | `interface/wx/bookctrl.h:192-199` |
| `AddPage/InsertPage(select=true)` | yes, except for the very first page | `interface/wx/bookctrl.h:256-259` |
| `DeletePage/RemovePage` | yes if the selection shifts, except when deleting the last page | `interface/wx/bookctrl.h:286-295` |
| `wxTreeCtrl::SelectItem` | **yes**, SEL_CHANGING (vetoable) + SEL_CHANGED | `interface/wx/treectrl.h:866-868` |
| `wxDataViewCtrl::Select/SetSelections/UnselectAll` | generic: no; GTK: suppressed; **macOS: `SELECTION_CHANGED`** | [source] `src/generic/datavgen.cpp:6396-6412`; `src/gtk/dataview.cpp:5268-5282`; `src/osx/dataview_osx.cpp:687-738`, `src/osx/cocoa/dataview.mm:1824-1832` |
| `wxDataViewModel::ItemChanged/ValueChanged/ChangeValue` | `wxEVT_DATAVIEW_ITEM_VALUE_CHANGED` | `interface/wx/dataview.h:116-117,331,392` |
| generic `wxDataViewCtrl::Expand/Collapse` | EXPANDING/EXPANDED, COLLAPSING/COLLAPSED; `Collapse` can also send SELECTION_CHANGED when it hides selected rows | [source] `src/generic/datavgen.cpp:4110-4150` |
Orca's widgets add their own rows; see [Orca widgets](#orca-replacement-widgets-event-contracts).
Pitfalls:
- **Rule:** A model→view refresh must not loop back through the view's change handler.
**Why:** `SetValue` and `SetSelection` on a book fire synchronously inside the refresh; the handler writes the
value back, marks the preset dirty, or rebuilds the page it is running in. On macOS a programmatic DVC
`Select` re-enters the selection handler.
```cpp
// Wrong
text->SetValue(v); book->SetSelection(i); dvc->Select(item);
// Right
text->ChangeValue(v); book->ChangeSelection(i);
m_suppress = true; dvc->Select(item); m_suppress = false; // handler: if (m_suppress) return;
```
Cite: the table above; `ObjectList` uses `m_prevent_list_events` for the same reason.
## wxTextCtrl / wxTextEntry
Contract:
- `wxTE_PROCESS_ENTER` is required for `wxEVT_TEXT_ENTER` (`interface/wx/textctrl.h:1562-1564`). If no handler
exists, *or the handler calls `Skip()`*, Enter is processed internally or "used to activate the default button
of the dialog" (`interface/wx/textctrl.h:1324-1331`). `wxComboBox` has the same flag and semantics
(`interface/wx/combobox.h:42-48`); for `wxBitmapComboBox` it is documented as Windows-only
(`interface/wx/bmpcbox.h:29-33`).
- `wxTE_PROCESS_TAB` has no effect on single-line controls under wxGTK (`interface/wx/textctrl.h:1332-1338`).
- `wxEVT_TEXT` "is however not sent during the control creation" (`interface/wx/textctrl.h:1556-1560`). A
`SetValue` right after the constructor does send it, which is harmless only while nothing is bound.
- `IsModified()` is true only after user edits; `SetValue`/`ChangeValue` reset it (`interface/wx/textentry.h:170-172`,
`interface/wx/textctrl.h:1880-1886`). Use it on commit to skip writing back untouched values.
- Styles fixed at creation: `wxTE_READONLY`, `wxTE_PASSWORD` and the wrap styles can change later on GTK but not
on MSW; every other style except alignment is creation-time only (`interface/wx/textctrl.h:1398-1402`). Toggle
editability with `SetEditable(bool)`, not `SetWindowStyleFlag`.
- Multi-line positions are not string indices on `\r\n` platforms: use `GetRange()`, not
`GetValue().Mid(GetInsertionPoint())` (`interface/wx/textctrl.h:1405-1418`).
- `SetMaxLength(n)` works on single-line controls everywhere, on multi-line ones only in wxMSW and wxGTK; extra
input is discarded and `wxEVT_TEXT_MAXLEN` sent (`interface/wx/textentry.h:398-416`). It does not filter
`SetValue`.
- `SetHint()` is native on MSW, macOS and GTK ≥ 3.2. Without native support (GTK2) wx's fallback requires you to
`Skip()` focus and `wxEVT_TEXT` events and avoid `WriteText`/`Replace` while the control is empty. Hints are
ignored with `wxTE_PASSWORD`, and on multi-line controls they work only on MSW and GTK2 — so not on macOS or on
Orca's GTK3 Linux build (`interface/wx/textentry.h:467-486`).
- `wxTE_RICH` is MSW-only (prefer `wxTE_RICH2`); `wxTE_NOHIDESEL` is MSW-only (`interface/wx/textctrl.h:1347-1363`).
Platforms — macOS: quote and dash smart substitution are "enabled by default" (`interface/wx/textctrl.h:2114-2134`);
`OSXDisableAllSmartSubstitutions()` turns them off (`:2136-2144`). A single-line control also replaces pasted
newlines with spaces unless `OSXEnableNewLineReplacement(false)` (`:2094-2113`). These `OSX*` methods are
`@onlyfor{wxosx}` and declared only in `include/wx/osx/textctrl.h`, so calls must sit under `#ifdef __WXOSX__` or
the MSW and GTK builds fail to compile.
OrcaSlicer: interactive single-line text uses `::TextInput` ([below](#textinput)); raw `wxTextCtrl` remains for
multi-line text. Field's `TextCtrl::BUILD` calls `OSXDisableAllSmartSubstitutions()` under `#ifdef __WXOSX__`; copy
that for any G-code or path editor.
Pitfalls:
- **Rule:** Never use `SetLabel`, `SetLabelText` or `GetLabel` for a text control's content.
**Why:** `wxTextCtrlBase::SetLabel` is `wxFAIL_MSG("Use SetValue() or ChangeValue() instead.")` and nothing else
(`src/common/textcmn.cpp:934-937`); `SetLabelText` calls the virtual `SetLabel` (`include/wx/control.h:64-67`);
`GetLabel()` returns `m_labelOrig`, not the text (`include/wx/control.h:61`). The assert is compiled out, so the
control silently never updates, on every platform (3.3 made this consistent; MSW used to treat it as `SetValue`).
```cpp
ctrl->GetTextCtrl()->SetLabel(s); // Wrong: no-op
ctrl->GetTextCtrl()->ChangeValue(s); // Right (or SetValue if listeners must react)
```
Cite: `docs/changes.txt:111-113`.
- **Rule:** Handle `wxEVT_TEXT_ENTER` without `Skip()` unless you want the default button activated as well.
**Why:** a skipped Enter falls through to the dialog's default button and closes it.
```cpp
txt->Bind(wxEVT_TEXT_ENTER, [](wxCommandEvent& e) { commit(); e.Skip(); }); // Wrong in a dialog
txt->Bind(wxEVT_TEXT_ENTER, [](wxCommandEvent&) { commit(); }); // Right
```
Cite: `interface/wx/textctrl.h:1324-1331`.
- **Rule:** Disable macOS smart substitutions on code, path and URL editors.
**Why:** typed `"` becomes `”` and `--` becomes `—`, silently corrupting G-code macros and paths.
```cpp
auto* ed = new wxTextCtrl(this, wxID_ANY, gcode, wxDefaultPosition, sz, wxTE_MULTILINE); // Wrong alone
#ifdef __WXOSX__ // Right: add this
ed->OSXDisableAllSmartSubstitutions();
#endif
```
Cite: `interface/wx/textctrl.h:2114-2144`; `include/wx/osx/textctrl.h:154`; Field `TextCtrl::BUILD`.
- **Rule:** Don't put the only explanation of a multi-line field in `SetHint()`, and don't count on
`SetMaxLength()` there.
**Why:** multi-line hints are ignored on macOS and GTK3; multi-line max length is ignored on macOS.
Cite: `interface/wx/textentry.h:414-415, 485-486`.
## wxStaticText: labels, mnemonics, markup
Contract:
- `SetLabel` treats every `&` as a mnemonic marker; a literal ampersand must be `&&`. `SetLabelText()` shows text
verbatim, and `wxControl::EscapeMnemonics()` escapes it (`interface/wx/control.h:174-195, 379-387`). This applies
to `wxStaticText`, `wxCheckBox`, `wxRadioButton`, `wxButton` labels and to book page titles: all `wx*book`
classes interpret mnemonics in page text (`interface/wx/bookctrl.h:141-147`), and since 3.3 wxListbook and
wxChoicebook do too (`docs/changes.txt:128-130`).
- `SetLabelMarkup()` needs well-formed markup or the label "won't be shown at all" (returns false, keeps the old
label). A bare `&` is still a mnemonic. Multi-line markup works only on GTK and macOS; the generic version used on
MSW handles single lines (`interface/wx/control.h:338-347`). Quote untrusted text with
`wxMarkupParser::Quote()` (`include/wx/private/markupparser.h:137-141`); the header is private, but Orca's wx
install copies `include/wx/private` (`deps/wxWidgets/wxWidgets.cmake`, `copy_private_headers`).
- Without `wxST_NO_AUTORESIZE`, `SetLabel` resizes the control to its best size but never re-lays-out the parent;
call the parent's or sizer's `Layout()` after a size-changing label. `SetLabel` is a no-op when the text is
unchanged (`interface/wx/stattext.h:30-36, 107-117`; `src/common/stattextcmn.cpp:334-352`).
- Ellipsizing (`wxST_ELLIPSIZE_START/MIDDLE/END`): the best size is the full text extent on every port (GTK even
switches ellipsizing off to measure, `src/gtk/stattext.cpp:239-295`), so a label ellipsizes only when something
constrains its width — an explicit min/initial width, or `wxST_NO_AUTORESIZE` plus a sizer-assigned size.
- Wrapping (`Wrap(w)` caches its width in 3.3.2, so `SetLabel(new); Wrap(sameWidth)` leaves the new label
unwrapped — [source] `src/common/stattextcmn.cpp:259-264`; `wxST_WRAP`; Orca's `::Label`): see
`references/sizers-layout.md` §wxStaticText wrapping.
Pitfalls:
- **Rule:** User-provided names go through `SetLabelText` / `EscapeMnemonics`, and through
`wxMarkupParser::Quote` inside markup.
**Why:** an `&` in a preset or file name disappears or underlines the next character; a `<` makes the markup
invalid and the label shows nothing.
```cpp
new wxStaticText(p, wxID_ANY, preset_name); // Wrong
auto* st = new wxStaticText(p, wxID_ANY, ""); st->SetLabelText(preset_name); // Right
book->AddPage(pg, wxControl::EscapeMnemonics(filament_name)); // Right for page titles
lbl->SetLabelMarkup("<b>" + wxMarkupParser::Quote(name) + "</b>"); // Right for markup
```
Cite: `interface/wx/control.h:174-195, 338-347`. Escaping translated strings: `references/strings-i18n-files.md`.
## Item containers
`wxChoice`, `wxComboBox`, `wxBitmapComboBox`, `wxListBox`, `wxCheckListBox`, and Orca's `::ComboBox` derive from
`wxItemContainer`.
Contract:
- Client data: the control *owns* typed `wxClientData*` and deletes it in `Delete()`, `Clear()` and its destructor;
untyped `void*` is never touched. All items of one control use one kind, fixed by the first `Append(..., data)`
or `SetClient*` call (`interface/wx/ctrlsub.h:172-185, 466-480`). Asking for the other kind is a silent `wxCHECK`
returning `nullptr` (`src/common/ctrlsub.cpp:182-191, 221-230`); mixing kinds fails only a compiled-out
`wxASSERT` (`:213-214`).
- `SetStringSelection` matches case-insensitively and takes the first hit (`interface/wx/ctrlsub.h:128-131`); MSW
wxComboBox "doesn't behave correctly" with items differing only in case (`interface/wx/combobox.h:24-28`).
- While the dropdown is open only `GetCurrentSelection()` reflects the highlighted item
(`interface/wx/choice.h:148-161`, `interface/wx/combobox.h:200-208`). In a `wxEVT_COMBOBOX` handler `GetValue()`
already returns the new value (`interface/wx/combobox.h:53-56`).
- `wxComboBox::IsEmpty()` is ambiguous and does not compile; use `IsListEmpty()` or `IsTextEmpty()`
(`interface/wx/combobox.h:219-249`).
- With `wxCB_READONLY`, `SetValue(s)` requires `s` to be in the list (case-insensitive) (`interface/wx/combobox.h:264-267`).
- `wxComboBox` DROPDOWN/CLOSEUP exist on wxMSW, GTK ≥ 2.10 and wxOSX/Cocoa (`interface/wx/combobox.h:64-73`), but
`Popup()`/`Dismiss()` never send them on wxOSX (`:279-281`).
- `wxChoice::SetColumns` is GTK-only (`interface/wx/choice.h:164-172`). `wxLB_MULTIPLE` equals `wxLB_EXTENDED`
on GTK2 (`interface/wx/listbox.h:34`). In `wxEVT_CHECKLISTBOX`, `event.IsChecked()` is invalid; use `GetInt()`
with `wxCheckListBox::IsChecked(i)` (`interface/wx/checklst.h:18-23`).
Platforms — macOS: `wxBitmapComboBox` is a `wxOwnerDrawnComboBox`; its `Select()` uses `ChangeValue` and sends no
event (`src/generic/odcombo.cpp:1041-1060`), and all bitmaps must share one size (`interface/wx/bmpcbox.h:12-13`).
OrcaSlicer: `Slic3r::GUI::BitmapComboBox` (`BitmapComboBox.hpp`) wraps `wxBitmapComboBox` where a native bitmap
combo is still used (e.g. `apply_extruder_selector` in `wxExtensions.cpp`, `PrintHostDialogs`); on macOS it overrides `OnAddBitmap`/`OnDrawItem`
because the generic owner-drawn base would size items from Retina-scaled bitmaps. Settings fields use
`::ComboBox`, which has different client-data rules ([below](#combobox)).
Pitfalls:
- **Rule:** Never delete typed client data yourself; free `void*` data yourself (fetch the pointers before
`Clear()`/`Delete()`, which only drop them).
```cpp
combo->Append(name, new wxStringClientData(id)); // typed: the control owns and deletes it
delete combo->GetClientObject(i); // Wrong: double free on Delete()/Clear()/destruction
auto* d = static_cast<wxStringClientData*>(combo->GetClientData(i)); // Wrong kind: silent nullptr
auto* d = static_cast<wxStringClientData*>(combo->GetClientObject(i)); // Right; never delete d
```
Cite: `interface/wx/ctrlsub.h:172-185`; `src/common/ctrlsub.cpp:221-230`.
## Check boxes and radio buttons
Contract:
- 3-state checkboxes need `wxCHK_3STATE`; users reach the third state only with `wxCHK_ALLOW_3RD_STATE_FOR_USER`
(`interface/wx/checkbox.h:50-56`). Read them with `Get3StateValue()`. `IsChecked()` asserts on a 3-state box
(`include/wx/checkbox.h:57-62`), so in Orca it silently returns `GetValue()`; `Set3StateValue(wxCHK_UNDETERMINED)`
on a 2-state box is likewise only an assert (`interface/wx/checkbox.h:179-185`).
- Radio groups form from consecutive sibling buttons; `wxRB_GROUP` starts a new group and that button is the
initial selection (`interface/wx/radiobut.h:15-21`). `wxRB_SINGLE` takes a button out of any group, but only on
MSW and GTK (≥ 3.3.0); elsewhere such a button "can't be turned off" (`:31-39`). `SetValue(false)` on a grouped
button is invalid — select another. On MSW the focused radio button is always the selected one (`:120-130`).
- `wxRadioBox::SetSelection(n)` needs a valid `n`, never `wxNOT_FOUND` (`interface/wx/radiobox.h:292-295`), and
sends no event.
OrcaSlicer: interactive check boxes are `::CheckBox`/`SwitchButton`, radio rows are `::RadioGroup`; their event
contracts differ from these ([below](#checkbox-and-switchbutton)).
Pitfalls:
- **Rule:** Put `wxRB_GROUP` on the first button of *every* group.
**Why:** without it, adjacent groups merge into one and selecting in one clears the other.
Cite: `interface/wx/radiobut.h:15-21`.
## Spin controls, slider, gauge
Contract:
- Typed spin text "is not validated until the control loses focus"; it is then clamped and `wxEVT_SPINCTRL` is
sent only if the value differs from the last one sent. Raw typing produces `wxEVT_TEXT`
(`interface/wx/spinctrl.h:36-45`). `wxSpinCtrlDouble` sends `wxEVT_SPINCTRLDOUBLE` instead and also commits on
Enter (`:276-279`). Add `wxTE_PROCESS_ENTER` for `wxEVT_TEXT_ENTER` (`:18-22`).
- `wxEVT_SPINCTRL` is declared with the `wxSpinEvent` class (`include/wx/spinctrl.h:22`); `wxSpinEvent::GetValue()`
and `GetPosition()` both return `GetInt()` (`include/wx/spinbutt.h:104-107`).
- `wxSlider`: handle `wxEVT_SLIDER`. `wxEVT_SCROLL_CHANGED` exists on MSW only (`interface/wx/slider.h:35-38, 103`).
`wxSL_LEFT/TOP` work on Windows and GTK3 only; `wxSL_BOTH` and `wxSL_SELRANGE` on Windows only; tick marks
(`wxSL_AUTOTICKS`) need Windows or GTK ≥ 2.16 (`interface/wx/slider.h:32-66`).
- `wxGauge`: `Pulse()` switches to indeterminate mode until the next `SetValue()`; under wxMSW `SetRange` in
indeterminate mode controls the bounce span (`interface/wx/gauge.h:141-161`).
OrcaSlicer: integer spinners are `::SpinInput` ([below](#spininput)); progress bars are Orca's `ProgressBar`
(`references/orca-widgets.md`). Raw `wxSlider` remains in Field's `SliderCtrl`.
## Book controls
Contract:
- A page must be created with the book as its parent and added once; the book owns and deletes it
(`interface/wx/bookctrl.h:253-254, 273`). `RemovePage` detaches without deleting, and you then own it (`:324-330`).
- `GetSelection()` inside a `PAGE_CHANGED` handler may return the old or the new page depending on the platform; use
`event.GetSelection()` (`interface/wx/bookctrl.h:160-166`).
- `wxSimplebook` has no UI; switch with `ChangeSelection()`. `SetSelection()` sends PAGE_CHANGING/CHANGED
(`interface/wx/simplebook.h:17-31`); `ShowNewPage()` adds and selects (`:130-138`).
- `wxNotebook`: `wxNB_LEFT/RIGHT/BOTTOM` are unsupported on themed MSW (`interface/wx/notebook.h:60-63`); the themed
MSW page background is disabled with `wxNB_NOPAGETHEME`, an explicit page `SetBackgroundColour`, or app-wide with
`wxSystemOptions::SetOption("msw.notebook.themed-background", 0)` (`:76-108`).
OrcaSlicer: three tab mechanisms, not interchangeable —
- `Notebook` (`Notebook.hpp`): a custom `wxBookCtrlBase` with a `ButtonsListCtrl` header, used for MainFrame's
Home/Prepare/Preview/Device tabs. Header clicks arrive internally as `wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED`; the book
itself emits `wxEVT_BOOKCTRL_PAGE_CHANGING/CHANGED` from `SetSelection` and nothing from `ChangeSelection`,
matching wx.
- `::TabCtrl` (`Widgets/TabCtrl`): a tab *bar* only; content switching is the caller's ([below](#radiogroup-tabctrl)).
- `wxSimplebook`: headerless page stacks (e.g. `StatusPanel`, `SelectMachine`, `ReleaseNote`).
Pitfalls:
- **Rule:** Sync code uses `ChangeSelection`; a `PAGE_CHANGED` handler reads `event.GetSelection()`.
```cpp
book->SetSelection(i); // Wrong in a sync routine whose PAGE_CHANGED handler rebuilds UI
book->ChangeSelection(i); // Right
int sel = book->GetSelection(); // Wrong inside PAGE_CHANGED
int sel = event.GetSelection(); // Right
auto* pg = new MyPage(dialog); book->AddPage(pg, t); // Wrong: page must be the book's child
auto* pg = new MyPage(book); book->AddPage(pg, t); // Right
```
Cite: `interface/wx/bookctrl.h:160-199, 253-254`.
## Splitter, collapsible pane, hyperlink, static bitmap
- `wxSplitterWindow`: the default minimum pane size is 0, so the user can drag a pane shut. `SetMinimumPaneSize()`
takes pixels — pass `FromDIP(n)` (`interface/wx/splitter.h:300-317`). `Unsplit()` only hides the removed pane
(`:452-466`). Sashes resize at idle time; `UpdateSize()` forces it before `Show` (`:468-479`).
- `wxCollapsiblePane`: put children on `GetPane()`, not on the pane control; re-layout on
`wxEVT_COLLAPSIBLEPANE_CHANGED`; the pane resizes its top-level window unless `wxCP_NO_TLW_RESIZE`
(`interface/wx/collpane.h:54-96`).
- `wxHyperlinkCtrl`: if the `wxEVT_HYPERLINK` handler `Skip()`s, or there is none, wx calls
`wxLaunchDefaultBrowser` (`interface/wx/hyperlink.h:55-58`). Orca's `Slic3r::GUI::HyperLink` and `::Label` with
`LB_HYPERLINK` are the styled alternatives (`references/orca-widgets.md`).
- `wxStaticBitmap`: native versions are meant for small icons and only the generic one supports every
`SetScaleMode`; use `wxGenericStaticBitmap` for large images. MSW centres a smaller bitmap, other ports draw it at
the origin (`interface/wx/statbmp.h:11-23, 146-152`). `SetBitmap` takes a `wxBitmapBundle` (`:132`); Orca builds
bundles from its own SVG rasteriser (`references/dpi-bitmaps-fonts.md`).
## wxListCtrl, wxTreeCtrl, image lists
- Virtual list (`wxLC_REPORT|wxLC_VIRTUAL`): call `SetItemCount()` and override `OnGetItemText` (optionally
`OnGetItemImage`/`OnGetItemAttr`) (`interface/wx/listctrl.h:128-134`). `EditLabel()` asserts without
`wxLC_EDIT_LABELS` (`docs/changes.txt:68-69`).
- Images: prefer `SetImages(std::vector<wxBitmapBundle>)`; `wxImageList` is discouraged
(`interface/wx/withimages.h:21-40`) and is measured in physical pixels in 3.3 (`docs/changes.txt:85-88`); calling
its methods on an invalid (unsized) list now asserts, i.e. fails silently in Orca (`docs/changes.txt:53-56`).
`Assign*` transfers ownership, `Set*ImageList` does not (`interface/wx/withimages.h:90-110`). Sizing:
`references/dpi-bitmaps-fonts.md`.
- `wxTreeCtrl`: `SelectItem` emits events ([table](#events-from-programmatic-changes)).
`Delete/DeleteChildren/DeleteAllItems` send `wxEVT_TREE_DELETE_ITEM` for every item; `DeleteChildren` does not
clear `SetItemHasChildren` (`interface/wx/treectrl.h:318-344`). The tree owns `wxTreeItemData`.
## wxGrid
Contract:
- `GetSelectedBlocks()` returns a range of unordered, possibly overlapping blocks (`interface/wx/grid.h:5090-5109`).
[source] It is empty when nothing is selected, and the grid cursor cell is not part of it
(`src/generic/grid.cpp:11295-11302` returns an empty `wxGridBlocks()` without a selection object, otherwise the
selection's blocks).
- `FreezeTo(row, col)` returns false (an assert, silent in Orca) for out-of-range values, merged cells or the native
header (`interface/wx/grid.h:5747-5772`); [source] also when rows/columns were reordered or drag-moving is enabled
(`src/generic/grid.cpp:5742-5750`). In 3.3 it freezes even when the grid is too small (`docs/changes.txt:48-51`).
- Editor contract: `EndEdit` must not modify the grid — it stores the value and returns true if it changed;
`ApplyEdit` writes it after `wxEVT_GRID_CELL_CHANGING` was not vetoed (`interface/wx/grid.h:610-638`). Editors,
renderers and attrs are ref-counted and the setters take ownership (`:1263-1305, 1739-1770, 2681-2701`).
- A custom table sends `wxGridTableMessage` via `ProcessTableMessage()` whenever rows or columns are added or
removed (`interface/wx/grid.h:1832-1840`). `AssignTable()` takes ownership and may be called once (`:3088-3108`).
- `wxGridCellChoiceEditor::Combo()` is a C-cast of `m_control` to `wxComboBox*`
(`include/wx/generic/grideditors.h:394`); `Reset()`, `GetValue()`, `EndEdit()` and `SetParameters()` use it
(`src/generic/grideditors.cpp:1538-1605`), and Esc in any editor calls `Reset()` (`:87-93`).
Pitfalls:
- **Rule:** Treat wx selection ranges as possibly empty: compare `begin()` with `end()` before dereferencing, and
fall back to the (row, col) that triggered the event.
**Why:** the docs never promised a non-empty range; with no selection 3.3 returns an empty range, and the
unchecked `begin()->GetLeftCol()` in `ObjectGrid` crashed on cell deselect after the 3.3 upgrade. The data-view
analogue: `wxDataViewCtrl::GetSelection()` is invalid with no selection *or* with more than one.
```cpp
auto left = grid->GetSelectedBlocks().begin()->GetLeftCol(); // Wrong
auto blocks = grid->GetSelectedBlocks(); // Right
auto it = blocks.begin();
int left = (it == blocks.end()) ? col : it->GetLeftCol();
```
Cite: 46e47cec0a (`GUI_ObjectTable.cpp`, `GridCellSupportEditor::DoActivate`).
- **Rule:** A `wxGridCellChoiceEditor` subclass that puts any other control (e.g. `::ComboBox`) in `m_control` must
also override `Reset()`, `GetValue()` and `SetParameters()` — or derive from `wxGridCellEditor` instead.
**Why:** shadowing the non-virtual `Combo()` does not change the base methods, which still C-cast `m_control` to
`wxComboBox*`; Esc calls `Reset()` on it.
Cite: `include/wx/generic/grideditors.h:394`, `src/generic/grideditors.cpp:87-93, 1561-1605`.
- **Rule:** Clamp `FreezeTo` arguments to `[0, GetNumberRows()]` / `[0, GetNumberCols()]` and check its result.
Cite: `src/generic/grid.cpp:5742-5750`.
## Validators
- `SetValidator` stores a `Clone()` (`interface/wx/window.h:3369-3371`). `wxTextValidator` filters `wxEVT_CHAR` and
pasted text (`src/common/valtext.cpp:60-61, 277-301`) but never `SetValue`. `wxFILTER_DIGITS` rejects `-`, `.` and
`+`; `wxFILTER_NUMERIC` allows `.`, signs and `e/E` but "is not the same behaviour of wxString::IsNumber()"
(`interface/wx/valtext.h:43-52`).
- Data transfer is automatic only for dialogs: `ShowModal()/Show()` → `InitDialog()` → `TransferDataToWindow()`;
the default `wxID_OK` handler runs `Validate() && TransferDataFromWindow()`. Panels must call `InitDialog()`
themselves (`docs/doxygen/overviews/validator.h:93-131`). A custom OK handler that calls `EndModal` skips
validation.
OrcaSlicer: validators are keystroke filters only — a `wxTextValidator` with `wxFILTER_DIGITS`, `wxFILTER_NUMERIC` or
`wxFILTER_INCLUDE_CHAR_LIST` set on `TextInput::GetTextCtrl()` (e.g. `Preferences.cpp`, `calib_dlg.cpp`) and inside
`SpinInput`. `TransferDataTo/FromWindow` is not used: values are read, parsed and range-checked explicitly on commit.
Keep that pattern.
Pitfalls:
- **Rule:** Pick the filter for the full value range, and still parse and range-check on commit.
```cpp
ctrl->GetTextCtrl()->SetValidator(wxTextValidator(wxFILTER_DIGITS)); // Wrong if negatives are valid
ctrl->GetTextCtrl()->SetValidator(wxTextValidator(wxFILTER_NUMERIC)); // Right, plus ToDouble + range check
```
Cite: `interface/wx/valtext.h:43-52`.
## wxDataViewCtrl: native vs generic
All cites `interface/wx/dataview.h`. MSW uses the generic implementation; wxGTK and wxOSX use native controls
(915-917, 1039-1041, 3953-3955). Orca's macOS wx has `wxUSE_NATIVE_DATAVIEWCTRL 1`, so `ObjectList`,
`DiffViewCtrl`, `ParamsViewCtrl` and the other subclasses are `NSOutlineView`s there.
| Feature | Generic (MSW) | GTK | macOS | Cite |
|---|---|---|---|---|
| Multi-column sort (`AllowMultiColumnSort()` reports it) | yes | no | no | 915-926, 1039-1041 |
| `wxDataViewVirtualListModel` truly virtual | yes | yes | no ("not supported by macOS") | 604-614 |
| Item attr `SetBackgroundColour` | since 2.9.4 | since 3.1.1 | since 3.1.4 | 714-720 |
| Item attr `SetStrikethrough` | yes | yes | ignored | 729-733 |
| Current item | may be unselected | may be unselected | always selected; `SetCurrentItem` selects in multi-selection | 1456-1458, 1655-1665 |
| `IsExpanded()` | correct | correct | may be true for leaves (documented bug) | 1597-1603 |
| `CreateEditorCtrl()` | called | called | **never called** | 2626-2629 |
| `wxDataViewRenderer::SetAlignment()` | yes | yes | ignored; aligns as the column header | 2079-2085 |
| `IsEditCancelled()` / veto `EDITING_DONE` | yes | documented as unavailable; [source] text commits (`src/gtk/dataview.cpp:2240-2246`) and custom-editor commits (`src/common/datavcmn.cpp:791-853`) do go through `DoHandleEditingDone`, so veto works | documented as unavailable; [source] native text commit reaches the model before `EDITING_DONE` | 3942-3958 |
| Icon+text markup (`EnableMarkup` on `wxDataViewIconTextRenderer`) | no | yes | no | 2215-2218 |
| `EnableDropTargets` | full | first format only | full | 1377-1385 |
| `SetDragFlags()` | honoured | ignored | ignored | 4012-4018 |
| `GetDropEffect()` | real | `wxDragNone` | `wxDragNone` | 4030-4037 |
| `GetProposedDropIndex()` from `ITEM_DROP_POSSIBLE` | yes | no | yes (all ports from `ITEM_DROP`) | 4052-4060 |
| Generic mouse events (`wxEVT_LEFT_DOWN`…) | yes (bind on `GetMainWindow()`) | "notably it doesn't work in wxGTK" | not all | 994-997 |
| Explorer theme (`wxSystemThemedControl`) | on by default since 3.1.0; `EnableSystemTheme(false)` disables it | — | — | 1000-1003 |
| `GetMainWindow()` ≠ the control | yes | no | no | 1505-1513 |
| `SetAlternateRowColour`, `SetHeaderAttr` | yes | no | no | 1636-1646, 1675-1689 |
| `SetRowHeight` (uniform rows, raise only) | yes | yes | yes (3.1.1+) | 1715-1733 |
| `GetCountPerPage()` | yes | needs ≥ 1 item | yes | 1746-1751 |
| `GetTopItem()` | yes | may be unimplemented | may be unimplemented | 1755-1761 |
| `wxEVT_DATAVIEW_COLUMN_REORDERED` | yes | not sent | yes | 3863-3866 |
| `wxDataViewColumn::SetWidth()` | immediate | applied "only slightly later" (`GetWidth()` returns the old width, 0 initially; widths set before showing apply when visible) | immediate | 2793-2797 |
Other per-port facts: editing starts on a slow double-click or a platform key — "F2 is typical on Windows, Space
and/or Enter is common elsewhere" (2604-2607, 1915-1918); a custom renderer's `StartDrag()` is "Not yet supported"
(2717-2719); `RenderText()` should be used inside `Render()` so text matches native renderers (2708-2714). Calling
`Collapse()` from an event handler was fixed in 3.3.1 (`docs/changes.txt:346`; the generic re-check is described
under [DVC control API](#wxdataviewctrl-control-api)).
OrcaSlicer: `ParamsViewCtrl` (`EditGCodeDialog`) and `DiffViewCtrl` (`UnsavedChangesDialog`) use
`wxDataViewIconTextRenderer::EnableMarkup` only under `#ifdef __linux__`, matching the GTK-only markup row; on MSW
and macOS they use Orca's `BitmapTextRenderer(use_markup = true)`, which handles markup itself. `ObjectList`
force-sets column widths on macOS because the column constructor's width is not applied on 4K/5K screens (see
`ObjectList::create_objects_ctrl`).
## wxDataViewModel contract
**You implement** `IsContainer`, `GetParent`, `GetChildren`, `GetValue` and `SetValue`
(`interface/wx/dataview.h:16-19, 383-385`).
**Ownership.** The model is a `wxRefCounter` and "cannot be deleted directly" (its destructor is protected,
`include/wx/dataview.h:291-294`). `AssociateModel` adds a reference (`interface/wx/dataview.h:1341-1345`;
`src/common/datavcmn.cpp:1248-1263`), and the control drops it in its destructor or when another model (or
`nullptr`) is associated. Either `DecRef()` once after associating, or hold it in `wxObjectDataPtr`
(`interface/wx/dataview.h:68-92`), or keep one deliberate owning reference and `DecRef()` it in your destructor.
Detaching with `AssociateModel(nullptr)` is safe only while you hold your own reference — with the
DecRef-after-associate pattern it destroys the model.
**`wxDataViewItem` is an opaque `void*`.** It must be unique and stable for the item's whole life; `nullptr` means
both "invalid" and "the invisible root" (`interface/wx/dataview.h:788-800`). The ports keep the pointer: GTK stores
it in `GtkTreeIter::user_data` (`src/gtk/dataview.cpp:1843-1845`), the generic control in its tree nodes, Cocoa in
its buffers. Once the node is deleted every copy dangles — copies captured in lambdas and "last selected" members
included — and a later allocation can reuse the address, so a liveness scan by pointer can be fooled.
**Notification ordering (all ports):**
```cpp
// add: insert into the model first, so GetChildren(parent) already returns it
parent_node->Append(node); model->ItemAdded(wxDataViewItem(parent_node), wxDataViewItem(node));
// delete: remove from the model first, then notify (pointer used only as an ID), then free
parent_node->Remove(node); model->ItemDeleted(wxDataViewItem(parent_node), wxDataViewItem(node)); delete node;
// IsContainer(parent) must already be correct after the removal
```
Why each port needs it [source]:
- Generic `ItemDeleted` scans its own nodes because the item "was already removed from the model by the time
ItemDeleted() is called", then asks `IsContainer(parent)` (`src/generic/datavgen.cpp:3260-3310`).
- GTK `ItemAdded` looks the item up in `GetChildren(parent)` and silently returns ("adding non-existent item?") if
it is not there (`src/gtk/dataview.cpp:3995-4010`); GTK `ItemDeleted` checks `IsContainer(parent)` (`:1885-1895`).
- macOS `ItemAdded/ItemDeleted` re-query the parent's children with `reloadItem:reloadChildren:`; for the root parent
they call `reloadData`, reloading the whole outline (`src/osx/cocoa/dataview.mm:2284-2300, 2393-2400`).
- Notifications for an item the control never realised (collapsed parent) are ignored on purpose
(`src/generic/datavgen.cpp:3127-3147`, `src/gtk/dataview.cpp:1830-1838`).
**`Cleared()`** means "everything changed", not "emptied" (`interface/wx/dataview.h:54-56, 141-151`). Every port
discards its tree: generic resets selection and the current row (`src/generic/datavgen.cpp:3404-3425`), GTK does
BeforeReset+AfterReset (`src/gtk/dataview.cpp:1998-2001`), macOS also scrolls to the top
(`src/osx/cocoa/dataview.mm:2384-2391`). Save and restore expansion and selection around it. Re-associating a model
on macOS likewise reloads the outline from scratch ([source] `wxCocoaDataViewControl::AssociateModel` replaces the
data source).
**ItemChanged vs ValueChanged.** Both end in `wxEVT_DATAVIEW_ITEM_VALUE_CHANGED` (`interface/wx/dataview.h:328-336,
388-393`). `ChangeValue()` is `SetValue()` + `ValueChanged()` (`:116-117, 376-381`). On macOS `ValueChanged` calls
`model->GetParent(item)` (`src/osx/dataview_osx.cpp:263-268`), so `GetParent` must work for any live item.
**GetValue/HasValue types.** `GetValue` must fill the type the renderer expects; on a mismatch "nothing will be
shown and a debug error message will be logged" (`interface/wx/dataview.h:258-267`) — concretely
`CheckedGetValue` nulls the value when `!IsCompatibleVariantType()` (`src/common/datavcmn.cpp:854-885`). Container
rows show only column 0 unless `HasContainerColumns()` returns true or `HasValue()` is overridden
(`interface/wx/dataview.h:271-314`).
Pitfalls:
- **Rule:** Mutate the model, then notify; never notify about a node you have already freed or not yet inserted.
**Why:** GTK silently drops an `ItemAdded` the model does not report; generic and GTK call `IsContainer(parent)`
during `ItemDeleted`; a freed node read during notification is a use-after-free.
```cpp
delete node; model->ItemDeleted(parent, wxDataViewItem(node)); // Wrong order (and any read is UAF)
model->ItemAdded(parent, wxDataViewItem(node)); parent_node->Append(node); // Wrong order
```
Cite: `src/gtk/dataview.cpp:3995-4010`, `src/generic/datavgen.cpp:3260-3310`.
- **Rule:** Use `ItemAdded/ItemDeleted/ItemChanged` for local changes, not `Cleared()`.
**Why:** `Cleared()` drops selection and expansion on every port and scrolls to the top on macOS.
- **Rule:** Moving a subtree is delete + re-add, and the re-add must announce every descendant again (see
`ObjectDataViewModel::AddAllChildren`).
**Why:** the control discarded the subtree with the deleted node; native GTK otherwise shows the moved node
childless ("just to add a deleted item is not enough on Linux").
## wxDataViewCtrl control API
Contract:
- `GetSelection()` returns an invalid item when nothing *or more than one item* is selected
(`interface/wx/dataview.h:1532-1540`; `src/common/datavcmn.cpp:1329-1337`). With `wxDV_MULTIPLE` use
`HasSelection()`/`GetSelections()`. GTK and macOS build `SELECTION_CHANGED` from `dv->GetSelection()`
(`src/gtk/dataview.cpp:4507-4516`, `src/osx/cocoa/dataview.mm:1824-1832`), so in multi-selection
`event.GetItem()` can be invalid there; generic passes a concrete row — the clicked one, or the first selected
row of a Shift range (`src/generic/datavgen.cpp:4822-4827`).
- `UnselectAll()` "only has effect if multiple selections are allowed" (`interface/wx/dataview.h:1709-1713`);
`SetSelections` silently ignores invalid items (`:1699`).
- [source] `Select()` and `EnsureVisible()` expand ancestors themselves on every port
(`src/generic/datavgen.cpp:6396-6398, 6503-6505`, `src/gtk/dataview.cpp:5272, 5335`,
`src/osx/dataview_osx.cpp:584-591, 687-694`). On GTK calling them before `AssociateModel` is a silent
`wxCHECK_RET` (`src/gtk/dataview.cpp:5270, 5332`).
- [source] Selection events differ: GTK suppresses them for programmatic selection
(`SelectionEventsSuppressor`, `src/gtk/dataview.cpp:5268-5322`) but deleting a selected row emits
`SELECTION_CHANGED` through GTK's "changed" signal (`:4507-4516`); macOS emits `SELECTION_CHANGED` for
`Select`/`SetSelections`/`UnselectAll` (`src/osx/cocoa/dataview.mm:2504-2540, 1824-1832`).
- `SetRowHeight(h)` works on generic, GTK and macOS, only for uniform rows, and only *raises* the height above the
renderers' minimum (`interface/wx/dataview.h:1715-1733`). [source] On macOS the floor is the font's line height
and renderer `GetSize()` is not consulted (`src/osx/cocoa/dataview.mm:2615-2628`); per-item row height is
unsupported (`:2630-2633`); and the control's `SetFont` resets the row height to that default (`:2645-2651`).
- `EditItem(item, col)` "doesn't do anything if the item or this column is not editable"
(`interface/wx/dataview.h:1361-1369`).
- On MSW bind mouse and motion events on `GetMainWindow()`; generic mouse events do not work on wxGTK
(`interface/wx/dataview.h:994-997, 1505-1513`). Use a custom renderer's `ActivateCell` instead (`:2593`).
- [source] The generic `Collapse` re-checks whether a `SELECTION_CHANGED` handler already collapsed the node
(`src/generic/datavgen.cpp:4119-4129`).
Pitfalls:
- **Rule:** With `wxDV_MULTIPLE`, never take `GetSelection()` or `event.GetItem()` as "the" selection.
```cpp
auto it = dvc->GetSelection(); if (it.IsOk()) apply(it); // Wrong: invalid once two are selected
wxDataViewItemArray sels; dvc->GetSelections(sels); // Right
auto* node = (Node*)event.GetItem().GetID(); if (node) ... // Right: tolerate a null item on GTK/macOS
```
Cite: `interface/wx/dataview.h:1532-1540`.
- **Rule:** Do not assume a native `wxDataViewCtrl` grows its rows to fit a custom renderer's `GetSize()` — set an
explicit `SetRowHeight` for the tallest custom content, on all platforms, after any `SetFont` on the control,
and again in the rescale handler with the new `em`.
**Why:** on macOS the native row is the font line height, so custom-drawn content (the filament colour badge)
overflows into adjacent rows; `SetRowHeight` can only raise it, a later `SetFont` resets it, and wx never
rescales a value passed to a setter, so after a DPI or theme change the badge stops fitting. Setting it on every
platform keeps spacing consistent (MSW is the generic control, Linux the native GTK one).
```cpp
// create_objects_ctrl(): SetRowHeight(2 * em + FromDIP(2));
// msw_rescale(): SetRowHeight(2 * em + FromDIP(2)); // repeat with the new em
```
Cite: d5638273c6 (`ObjectList::create_objects_ctrl`, `ObjectList::msw_rescale`). General rescale rule:
`references/dpi-bitmaps-fonts.md`.
- **Rule:** Before moving a selected item (delete + re-add), `Unselect` it and re-`Select` it afterwards.
**Why:** the control's selection is not reliably updated by `ItemDeleted`; `ObjectList::update_plate_values_for_items`
does this ("hotfix for wxDataViewCtrl selection not updated after wxDataViewModel::ItemDeleted()").
## Custom renderers and in-place editing
**Renderer contract.** Implement `Render(rect, dc, state)`, `GetSize()`, `SetValue(variant)` and `GetValue(variant)`;
call `RenderText()` inside `Render` (`interface/wx/dataview.h:2702-2718`). The constructor's `varianttype` is checked
against model values by `IsCompatibleVariantType()` (`:1965-1980, 2063-2076`). Editing needs `HasEditorCtrl()` →
true, `CreateEditorCtrl()` and `GetValueFromEditorCtrl()`.
**Editing flow** (common code: generic, GTK custom renderers, and any explicit `renderer->StartEditing()`):
1. `wxEVT_DATAVIEW_ITEM_START_EDITING` is sent; a veto aborts (`src/common/datavcmn.cpp:715-725`).
2. `CreateEditorCtrl(GetMainWindow(), rect, CheckedGetValue(...))` runs; the value is **null** after a type
mismatch or when `HasValue(item, col)` is false (`:854-885`). The returned control is stored as `m_editorCtrl`
(`:733`); returning `nullptr` cancels.
3. wx pushes a `wxDataViewEditorCtrlEvtHandler` onto *the returned control only* and focuses it — on native GTK on
idle (`:742-751`). That handler commits on Enter, cancels on Esc and commits on kill-focus unless focus moved to
a child of the editor (`src/common/datavcmn.cpp:1129-1198`). Key and focus events of a compound editor's children
never reach it.
4. `FinishEditing()` calls `GetValueFromEditorCtrl(m_editorCtrl, value)` (`:799`), then hides the editor and deletes
it **later** via `wxPendingDelete` (`:765-785`). `Validate()` runs after the editor is gone
(`interface/wx/dataview.h:2125-2135`). `wxEVT_DATAVIEW_ITEM_EDITING_DONE` follows; if allowed,
`model->ChangeValue()` raises `VALUE_CHANGED` (`src/common/datavcmn.cpp:783-853`).
**macOS (native).** `CreateEditorCtrl` "will be never called there" (`interface/wx/dataview.h:2626-2629`).
[source] `EditItem` → `StartEditor` → `[NSOutlineView editColumn:row:…]` starts native text editing of the cell
(`src/osx/dataview_osx.cpp:757-760`, `src/osx/cocoa/dataview.mm:2564-2566`); `START_EDITING` comes from
`textShouldBeginEditing:`, and vetoing it returns NO to native editing (`src/osx/cocoa/dataview.mm:1885-1904`).
`wxEVT_DATAVIEW_ITEM_EDITING_STARTED` follows from `textDidBeginEditing:` (`:1936`) and is not vetoable
(`wxDataViewRendererBase::NotifyEditingStarted` never checks `IsAllowed()`, `src/common/datavcmn.cpp:756-763`).
A custom cell's `objectValue` is a `wxCustomRendererObject` (`wxDataViewCustomRenderer::MacRender`,
`src/osx/cocoa/dataview.mm:2926-2929`), so the field shows its description `wxCustomRendererObject: 0x…`
(`src/osx/cocoa/dataview.mm:117-160, 1112-1123`). On commit `wxDataViewRenderer::OSXOnCellChanged` builds a
**"string"** variant and calls `model->ChangeValue()` directly (`src/osx/cocoa/dataview.mm:2766-2797`), before
`EDITING_DONE` is sent (`:1944-1955`) — the model's `SetValue` must type-check, and vetoing `EDITING_DONE` cannot
stop it. `SetCustomRendererPtr`/`SetCustomRendererItem` are undocumented wxOSX-only members
(`include/wx/osx/dataview.h:245-259`); `wxDataViewCtrl::FinishCustomItemEditing` reads them to close a custom
editor when native editing starts (`src/osx/dataview_osx.cpp:779-786`, called from `src/osx/cocoa/dataview.mm:1930`).
Pitfalls:
- **Rule:** In `GetValueFromEditorCtrl()`, `dynamic_cast` the editor to the expected type and return `false` on
mismatch; in `CreateEditorCtrl()`, check the incoming variant before extracting.
**Why:** wx only ever passes the control your `CreateEditorCtrl` returned, so the cast is defence in depth against
app-side bookkeeping confusion — cheap insurance where a wrong `static_cast` reads garbage and crashes on commit.
It matters most on macOS, where native editing never calls `CreateEditorCtrl` at all and Orca's editor exists only
because `start_filament_editor` calls `StartEditing` directly.
```cpp
auto* c = static_cast<::ComboBox*>(ctrl); // Wrong
auto* c = dynamic_cast<::ComboBox*>(ctrl); // Right
if (!c || c->GetSelection() < 0) return false;
```
Cite: c965b2a5b3 (`ExtraRenderers.cpp`, `BitmapChoiceRenderer::GetValueFromEditorCtrl`;
`BitmapTextRenderer::GetValueFromEditorCtrl` uses `wxDynamicCast`); `src/common/datavcmn.cpp:733, 799`.
- **Rule:** On macOS, do not open a custom-renderer column editor through `EditItem()` or let the native
start-editing path run: `Veto()` `wxEVT_DATAVIEW_ITEM_START_EDITING`, `CallAfter` a call to the renderer's own
`StartEditing(item, GetItemRect(item, column))`, then `SetCustomRendererPtr`/`SetCustomRendererItem`. Guard against
re-entry and an already-open editor (`renderer->GetEditorCtrl()`), re-validate the item inside the lambda, and keep
it all under `#ifdef __WXOSX__`.
**Why:** `EditItem()` on Cocoa enters native text editing of the `wxCustomRendererObject` instead of your editor,
glitching the cell and crashing on commit. The re-entry guard is needed because `StartEditing` itself sends
`START_EDITING` (`src/common/datavcmn.cpp:716-725`), which the same handler would otherwise veto.
```cpp
void ObjectList::OnStartEditing(wxDataViewEvent& event) {
#ifdef __WXOSX__
if (event.GetColumn() == colFilament) {
if (m_starting_filament_editor) return; // our own StartEditing re-entering
event.Veto();
CallAfter([this, item = event.GetItem()] {
if (!is_live_model_item(item)) return;
start_filament_editor(item); // StartEditing + SetCustomRendererPtr/Item
});
return;
}
#endif
```
Cite: c965b2a5b3 (`ObjectList::OnStartEditing`, `ObjectList::start_filament_editor`).
- **Rule:** A compound editor commits by calling the renderer's `FinishEditing()` (or `CancelEditing()`) from its
own commit event.
**Why:** wx's Enter/Esc/kill-focus handler sits only on the top editor window; a `::ComboBox` or TextInput editor's
inner controls never reach it, and kill-focus commit is unreliable on Linux.
```cpp
c_editor->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { e.StopPropagation(); FinishEditing(); });
```
Cite: `BitmapChoiceRenderer::CreateEditorCtrl`; `src/common/datavcmn.cpp:742-751, 1129-1198`.
- **Rule:** Do not touch the editor after `FinishEditing()`/`CancelEditing()`, and do not rely on vetoing
`EDITING_DONE` outside MSW.
**Why:** the editor is queued on `wxPendingDelete` and dies at the next idle or yield; on macOS the value is
already in the model when `EDITING_DONE` arrives.
## wxVariant with custom objects
`wxIMPLEMENT_VARIANT_OBJECT(T)` (and the older `IMPLEMENT_VARIANT_OBJECT`) generates `T& operator<<(T&, const
wxVariant&)`, which only `wxASSERT`s the type and then C-casts `GetData()` (`include/wx/variant.h:525-532`). The
custom type name is the wxObject class name (`GetType()` → `GetClassInfo()->GetClassName()`, `:515-518`); a null
variant's type is `"null"` (`interface/wx/variant.h:420-425`). wx's own `wxBitmap << variant` extraction is generated
the same way.
Pitfalls:
- **Rule:** Check `!v.IsNull() && v.GetType() == "<Class>"` before every `obj << v` extraction.
**Why:** the assert is compiled out of Orca in every configuration, so a wrong-typed variant is read as garbage and
a null one dereferences `nullptr`. Wrong types really happen: `CheckedGetValue` passes a null variant to
`CreateEditorCtrl` after a model/renderer type mismatch, and native macOS editing hands the model's `SetValue` a
`"string"` variant built from the field text (`src/osx/cocoa/dataview.mm:2766-2797`).
```cpp
DataViewBitmapText data; data << variant; // Wrong: blind extraction
if (variant.IsNull() || variant.GetType() != wxT("DataViewBitmapText")) // Right
return false;
DataViewBitmapText data; data << variant;
```
Cite: c965b2a5b3 (`ExtraRenderers.hpp`, `ObjectDataViewModelNode::SetValue`).
Note: `wxObject`'s default copy and assignment share ref-data with correct refcounting
(`include/wx/object.h:324-338`); `DataViewBitmapText`'s field-copying operators are harmless but not required.
## Orca replacement widgets: event contracts
Orca's interactive controls replace raw wx ones (catalog, constructors and quirks: `references/orca-widgets.md`).
They emit the native event *types* through `GetEventHandler()->ProcessEvent`, so command events propagate to parents
like native ones and stop at dialogs (`wxWS_EX_BLOCK_EVENTS`) and, on MSW and macOS, at popups (not on wxGTK,
`references/events.md` §5) — but ids, event objects, event classes and setter side effects differ. "Ancestor" in the
Bind column means an ancestor bound by event type; binding an ancestor by the widget's id is discouraged
(`references/events.md` §10):
| Widget | Replaces | Emits (user action) | Id / object | Setter side effects | Bind |
|---|---|---|---|---|---|
| `::TextInput` | `wxTextCtrl` | inner `wxEVT_TEXT` (propagates); `wxEVT_TEXT_ENTER`, `wxEVT_KILL_FOCUS` re-sent to the wrapper only | TEXT: inner ctrl's id/object; ENTER/KILL_FOCUS: wrapper's id, inner object | `GetTextCtrl()->SetValue` → `wxEVT_TEXT` | TEXT: wrapper or `GetTextCtrl()`; ENTER/KILL_FOCUS: wrapper or `GetTextCtrl()`, never a parent |
| `::ComboBox` | `wxComboBox`/`wxChoice` | `wxEVT_COMBOBOX` (int = index, string = text); DROPDOWN/CLOSEUP | COMBOBOX: combo's auto-generated id (the ctor `id` is ignored) and object; DROPDOWN/CLOSEUP: id 0, no object | `SetSelection`/`SetValue` → `wxEVT_TEXT` when the text ctrl is shown or `CB_NO_TEXT`; `SelectAndNotify` → `wxEVT_COMBOBOX` | COMBOBOX on the combo (or an ancestor); DROPDOWN/CLOSEUP on the combo without id |
| `::SpinInput` | `wxSpinCtrl` | `wxEVT_SPINCTRL` (a `wxCommandEvent`); `EVT_SPINCTRL_TEXT` per parsable keystroke; inner `wxEVT_TEXT` | SPINCTRL / SPINCTRL_TEXT: spinner id/object | `SetValue(int)` → `EVT_SPINCTRL_TEXT` + `wxEVT_TEXT`, no `wxEVT_SPINCTRL` | SPINCTRL on the spinner or an ancestor, handler takes `wxCommandEvent&` |
| `::CheckBox`, `SwitchButton` | `wxCheckBox` | `wxEVT_TOGGLEBUTTON` | native | `SetValue` silent | on the widget (with `Skip()`) or an ancestor; never `wxEVT_CHECKBOX` |
| `::RadioGroup` | `wxRadioBox` | `wxEVT_COMMAND_RADIOBOX_SELECTED` | group id, no object | **every** `SetSelection` emits | on the group or an ancestor |
| `::TabCtrl` | tab bar | `wxEVT_TAB_SEL_CHANGING` then `wxEVT_TAB_SEL_CHANGED` (plain `wxCommandEvent`) | tab ctrl id/object; CHANGING carries the old index | `SelectItem(i)` emits both when `i` changes | on the ctrl or an ancestor; cannot veto |
| `Button` | `wxButton` | `wxEVT_BUTTON` | button id/object | — | on the button or an ancestor |
### TextInput
`::TextInput` (`Widgets/TextInput.cpp`, `TextInput::Create`; ctor `TextInput(parent, text, label = "", icon = "",
pos, size, style)`) is a `StaticBox` frame around a real `wxTextCtrl` (on MSW the `TextCtrl` subclass in
`Widgets/TextCtrl.h`). Style flags pass through to the inner control (alignment flags are stripped); tooltips forward.
- It always ORs in `wxTE_PROCESS_ENTER`, and its `wxEVT_TEXT_ENTER` handler does not `Skip()`: Enter in a TextInput
never activates a dialog's default button and never propagates to parents.
- The inner control's `wxEVT_TEXT_ENTER` and `wxEVT_KILL_FOCUS` are re-dispatched with the wrapper's id via
`ProcessEventLocally`, which runs the wrapper's own handlers but [source] never `TryAfter`, so never the parents
(`src/common/event.cpp:1582-1589`; the doc at `interface/wx/event.h:626-650` says otherwise).
- `wxEVT_TEXT` is a command event from the inner control and propagates normally, carrying the inner control's id
and object. Key, char and focus-in events exist only on `GetTextCtrl()`.
- The value API lives on `GetTextCtrl()`. `TextInput::SetLabel()` sets the painted side label, not the text.
- A handler bound later on `GetTextCtrl()` for ENTER or KILL_FOCUS runs before the wrapper's internal one
(dynamic handlers run most-recently-bound first, `docs/doxygen/overviews/eventhandling.h:475-482`) and must
`Skip()` so `OnEdit()` and the re-dispatch still run.
- `TextInput` has no id parameter: its `StaticBox` is created with `wxID_ANY`, so the wrapper id seen by
ENTER/KILL_FOCUS handlers is auto-generated.
### ComboBox
`::ComboBox` (`Widgets/ComboBox.cpp`) is `wxWindowWithItems<TextInput, wxItemContainer>` with an owned `DropDown`
popup; ctor `ComboBox(parent, id, value = "", pos, size, n, choices[], style)`. The `id` argument is ignored:
the constructor calls `TextInput::Create`, which creates the window with `wxID_ANY`, so `GetId()` is auto-generated
and a parent bound with the id you passed never fires. `wxCB_READONLY` hides the text control and paints the value;
`CB_NO_DROP_ICON`/`CB_NO_TEXT` are Orca style flags.
- `SetSelection(n)` sends no `wxEVT_COMBOBOX` (matching wx) and returns early when `n` is already selected. When the
inner text control is shown (editable) or `CB_NO_TEXT` is set, it — and `SetValue` — go through
`GetTextCtrl()->SetValue()`, which sends a propagating `wxEVT_TEXT`. `SelectAndNotify(n)` selects and sends
`wxEVT_COMBOBOX`.
- `SetLabel`/`GetLabel` are the displayed value; `SetTextLabel` writes the wrapper's painted label directly.
- Only untyped `void*` client data is supported: every `Append` overload calls
`SetClientDataType(wxClientData_Void)`, and the caller owns and frees the data. `DeleteOneItem()` skips the base
class's client-object reset.
- `Clear()`, `Insert()`, `Set()` and `Delete()` (via `DoClear`/`DoInsertItems`/`DoDeleteOneItem` →
`DropDown::Invalidate(true)`) reset the selection to -1 without clearing the shown text; call
`SetSelection`/`SetValue` afterwards.
- Mouse-wheel selection is disabled. `GetDropDown()` exposes the popup (e.g. `SetUseContentWidth(true)`).
### SpinInput
`::SpinInput` (`Widgets/SpinInput.cpp`; ctor `SpinInput(parent, text, label = "", pos, size, style, min = 0,
max = 100, initial = 0, step = 1)`) is integer-only: an inner `TextCtrl` with `wxTextValidator(wxFILTER_DIGITS)` plus
two arrow `Button`s with key-repeat; `SetValue/GetValue/SetRange/SetStep`.
- It commits — clamps, then sends `wxEVT_SPINCTRL` — on Enter, kill-focus and arrow keys only if the value changed,
but on every arrow-button press and key-repeat tick even when the value is pinned at `min`/`max`
(`SpinInput::createButton`, `onTimer`); mouse-wheel stepping is disabled (`EVT_MOUSEWHEEL` is commented out of
the event table, so `mouseWheelMoved` never runs). `EVT_SPINCTRL_TEXT` (int + string) is the live
per-keystroke event.
- `wxEVT_SPINCTRL` is built as a `wxCommandEvent` with no `SetInt`: bind with a `wxCommandEvent&` handler and read
`GetValue()` from the spinner; a `wxSpinEvent&` handler would read `GetPosition()` == 0 from a mis-typed object.
- `SetValue(int)` clamps and sends no `wxEVT_SPINCTRL`, but sends `EVT_SPINCTRL_TEXT` and a propagating `wxEVT_TEXT`
(it uses the inner `SetValue`). `SetRange` does not re-clamp the current value — set the range before the value.
- Typing accepts digits only; `-` cannot be typed, so negative ranges need another control.
- Enter and kill-focus are re-dispatched locally, as in TextInput. Dialogs sometimes `Disable()` a SpinInput to
force a commit before reading; that depends on the platform delivering `wxEVT_KILL_FOCUS` to the focused inner
control when it is disabled, which wx does not promise — read the inner text and commit explicitly when it matters.
### CheckBox and SwitchButton
`::CheckBox` (ctor `CheckBox(parent, id = wxID_ANY)`, no label — pair it with a `wxStaticText`/`Label`, as
`CloneDialog` does) and `SwitchButton` are `wxBitmapToggleButton`s. They emit `wxEVT_TOGGLEBUTTON`, never
`wxEVT_CHECKBOX`; `SetValue` sends nothing. The half state is drawn only (`SetHalfChecked`/`IsHalfChecked`); any click
clears it via the widget's own `wxEVT_TOGGLEBUTTON` handler (bound in the constructor), which also swaps the on/off
bitmap. Handlers bound later on the same widget run first, so they must `Skip()`, or the bitmap keeps showing the old
state and the half state is never cleared (`PreferencesDialog::create_item_bambu_cloud` marks this with "let
CheckBox::update() refresh the bitmap"). Inside `Slic3r::GUI` write `::CheckBox` — `Field.hpp` declares a `Slic3r::GUI::CheckBox` field class.
### RadioGroup, TabCtrl
- `::RadioGroup::SetSelection(i)` **always** sends `wxEVT_COMMAND_RADIOBOX_SELECTED` — for programmatic calls and
for an unchanged index too — the opposite of `wxRadioBox`. Guard the handler or set a flag around sync code.
- `::TabCtrl::SelectItem(i)` sends `wxEVT_TAB_SEL_CHANGING` (cannot be vetoed; `sendTabCtrlEvent` always returns true)
then `wxEVT_TAB_SEL_CHANGED`; switching content is the caller's job. `TabCtrl::AssignImageList` is Orca's own
method, not `wxWithImages`. `SelectItem` also sends a synthetic `wxEVT_CHECKBOX` (id 0, object = the tab `Button`)
to the old and new tab buttons to toggle their `StateHandler` Checked state; the state handler `Skip()`s it, so it
propagates to the TabCtrl's ancestors — an ancestor bound to `wxEVT_CHECKBOX` without an id filter receives it.
### Field widgets
Settings fields (`Field.cpp`, built by `OptionsGroup::build_field`) wrap these widgets — `TextCtrl` → `::TextInput`
(a raw multi-line `wxTextCtrl` when `opt.multiline`), `CheckBox` → `::CheckBox`, `SpinCtrl` → `::SpinInput`,
`Choice` and `PrinterAgentChoice` → `::ComboBox` (`choice_ctrl`; a `Choice` is editable with `wxTE_PROCESS_ENTER`
for open-enum GUI types without a dynamic list, `wxCB_READONLY` otherwise), `ColourPicker` → `wxColourPickerCtrl`,
`PointCtrl` → two `::TextInput`, `StaticText` → `wxStaticText(wxST_ELLIPSIZE_MIDDLE)`, `SliderCtrl` → `wxSlider` +
`wxTextCtrl`, `PluginConfigField` → `Button`. Programmatic field updates use the
`m_disable_change_event` bracket around `SetValue`, which works because wx delivers `wxEVT_TEXT` synchronously. Field
machinery, pooling and the bind-with-id rule: `references/orca-settings-ui.md`.
Pitfalls:
- **Rule:** Bind TextInput/SpinInput ENTER and KILL_FOCUS on the widget (or its `GetTextCtrl()`), never on a parent;
bind `::ComboBox` events on the combo, never by the id passed to its constructor.
```cpp
dialog->Bind(wxEVT_TEXT_ENTER, &Dlg::on_enter, this, input->GetId()); // Wrong: never fires
input->Bind(wxEVT_TEXT_ENTER, &Dlg::on_enter, this); // Right
panel->Bind(wxEVT_TEXT, h, input->GetId()); // Wrong: TEXT carries the inner id
input->Bind(wxEVT_TEXT, h); // Right
auto* c = new ::ComboBox(this, ID_MODE); Bind(wxEVT_COMBOBOX, h, ID_MODE); // Wrong: ID_MODE is ignored
c->Bind(wxEVT_COMBOBOX, h); // Right
```
Cite: `TextInput::Create`; `ComboBox::ComboBox`; `src/common/event.cpp:1582-1589`.
- **Rule:** Bind `::CheckBox`/`SwitchButton` with `wxEVT_TOGGLEBUTTON` (and `Skip()` when bound on the widget itself),
and `SpinInput` with a `wxCommandEvent&` handler.
```cpp
cb->Bind(wxEVT_CHECKBOX, h); // Wrong: never sent
cb->Bind(wxEVT_TOGGLEBUTTON, [](wxCommandEvent& e) { apply(); }); // Wrong: stale bitmap
cb->Bind(wxEVT_TOGGLEBUTTON, [](wxCommandEvent& e) { apply(); e.Skip(); }); // Right
spin->Bind(wxEVT_SPINCTRL, [](wxSpinEvent& e) { use(e.GetPosition()); }); // Wrong
spin->Bind(wxEVT_SPINCTRL, [spin](wxCommandEvent&) { use(spin->GetValue()); }); // Right
```
Cite: `CheckBox::CheckBox`, `SwitchButton::SwitchButton`; `SpinInput::sendSpinEvent`.
- **Rule:** Treat an editable `::ComboBox`'s `SetSelection`/`SetValue` as emitting `wxEVT_TEXT`, and a
`RadioGroup::SetSelection` as emitting its selection event.
**Why:** both fire synchronously inside model→view refreshes and re-enter change handlers.
## ObjectList, ObjectDataViewModel, ExtraRenderers
`ObjectList` (`GUI_ObjectList.cpp/.hpp`) is the sidebar's `wxDataViewCtrl` (`wxDV_MULTIPLE | wxNO_BORDER |
wxDV_NO_HEADER`) over `ObjectDataViewModel` (`ObjectDataViewModel.cpp/.hpp`), a custom `wxDataViewModel` of
`ObjectDataViewModelNode`s typed by the `ItemType` bitmask (`itPlate, itObject, itVolume, itInstanceRoot, itInstance,
itSettings, itLayerRoot, itLayer, itInfo`) with columns `ColumnNumber` (`colName, colHeight, colPrint, colFilament,
colSupportPaint, colColorPaint, colSinking, colEditing`). Per-object, per-part and per-layer overrides appear as an
`itSettings` child; selecting it opens the model-scope tabs (`TabPrintModel` → `TabPrintPlate/Object/Part/Layer`),
which reuse the `OptionsGroup`/`Field` machinery bound to the items' `ModelConfig`s instead of a preset config. Which
options are offered: `references/orca-settings-ui.md`.
**Model design.**
- Ownership: `ObjectList::create_objects_ctrl` does `new ObjectDataViewModel; AssociateModel(m_objects_model);`
without an immediate `DecRef`, and `ObjectList::~ObjectList` calls `m_objects_model->DecRef()` — ObjectList owns one
reference for its lifetime. That is what makes the macOS bulk-update pattern safe: `add_objects_to_list` and
`update_plate_values_for_items` detach with `AssociateModel(nullptr)` and reattach afterwards, so the outline
reloads once [source] instead of once per notification.
- Each `ObjectDataViewModelNode*` *is* its `wxDataViewItem` ID; children live in the parent's pointer array.
`ObjectDataViewModel::Delete` removes the node from its parent (or from `m_plates`/`m_objects`), calls
`ItemDeleted(parent, item)`, then deletes it.
- `HasContainerColumns()` returns true so container rows draw their icon columns; `IsContainer(invalid)` is true for
the root.
- Re-parenting (`ReparentObject`, `ReorganizeChildren`, `ReorganizeObjects`) is remove → `ItemDeleted` → insert →
`ItemAdded`; `ReorganizeChildren` and `ReorganizeObjects` then call `AddAllChildren`, which re-announces the subtree
and expands the moved node. `ReparentObject` (plate change) does not; `ObjectList::update_plate_values_for_items`
re-expands and re-selects the item itself.
- `GetColumnType` returns `"DataViewBitmapText"` for `colName`/`colFilament`, but wx 3.3 never calls it (deprecated,
`include/wx/dataview.h:285-289`); what wx checks is the renderer's `varianttype`, which both ExtraRenderers set to
`"DataViewBitmapText"`. `ObjectDataViewModelNode::SetValue` type-checks `"DataViewBitmapText"` for those columns.
**Renderers** (`ExtraRenderers.cpp/.hpp`; `ENABLE_NONCUSTOM_DATA_VIEW_RENDERING` is 0, so both are
`wxDataViewCustomRenderer`s):
- `BitmapTextRenderer` (name column): editor is a `wxTextCtrl` with `wxTE_PROCESS_ENTER`; editing is gated by
`set_can_create_editor_ctrl_function`; `GetValueFromEditorCtrl` refuses names with illegal filename characters
and `ObjectList::OnEditingDone` reports `WasCanceled()` through a deferred warning.
- `BitmapChoiceRenderer` (filament column): editor is a `::ComboBox` (`wxCB_READONLY | CB_NO_DROP_ICON |
CB_NO_TEXT`) filled from `get_extruder_color_icons()`. It force-opens the popup on focus — on GTK deferred with
`CallAfter` and an `IsShownOnScreen()` check, because the editor "may receive focus before its native window is
mapped" (popup parenting: `references/popups-menus.md`) — and calls `FinishEditing()` itself on `wxEVT_COMBOBOX`.
**macOS editing.** The filament editor is opened by the veto + `CallAfter` + `start_filament_editor` pattern
([above](#custom-renderers-and-in-place-editing)); `wxEVT_DATAVIEW_ITEM_ACTIVATED` on `colFilament` calls
`start_filament_editor` on macOS and `EditItem` elsewhere. While starting, `m_filament_editor_item` tells the
renderer's callbacks which item is being edited (selection may differ). The bitmap columns are created
`wxDATAVIEW_CELL_EDITABLE` on macOS only, so a click starts native editing, and `ObjectList::OnEditingStarted`
(non-MSW branch) treats the resulting `EDITING_STARTED` as a per-cell click and runs the column's action
(printable toggle, paint gizmos, sinking, settings reset). Its `event.Veto()` there has no effect — only
`START_EDITING` is vetoable ([above](#custom-renderers-and-in-place-editing)); stopping native editing needs a veto in
`OnStartEditing`, as the filament column does.
**Selection and events.**
- `m_prevent_list_events` brackets programmatic `Select`/`UnselectAll`/model mutation; the `SELECTION_CHANGED`
handler returns early on macOS when it is set, and `ObjectList::selection_changed` checks it on every port —
covering GTK's selection events from drag-and-drop and row deletion.
- With Shift held the handler recovers the last-clicked item from `GetSelections()`, because the event item is not
reliable in multi-selection; a null `event.GetItem()` is tolerated.
- `is_live_model_item` (macOS only) re-validates a deferred item by scanning `GetAllChildren` for its pointer. It
cannot detect a freed node whose address was reused; prefer re-resolving from object/volume indices when you can.
**Row height and fonts.** `SetRowHeight(2 * em + FromDIP(2))` in `create_objects_ctrl` and in `msw_rescale`
(d5638273c6). `ObjectList::ObjectList` calls `SetFont(Label::sysFont(13))` on every platform, before
`create_objects_ctrl` — necessary on macOS because the control's `SetFont` resets the row height. The macOS-only
"don't `SetFont`" guard lives in `DPIAware`'s constructor and concerns only a top-level window's default font
(`references/dpi-bitmaps-fonts.md`).
**Global renderer on MSW.** `ObjectList::ObjectList` calls `wxRendererNative::Set(new wxRenderer)` — a
`wxDelegateRendererNative` overriding `DrawItemSelectionRect`, `DrawFocusRect`, `DrawTreeItemButton` and
`DrawItemText` with Orca colours (through `StateColor::darkModeColorFor`). `Set` replaces "the global renderer"
(`interface/wx/renderer.h:651-657`): once `ObjectList` exists, every generic `wxDataViewCtrl` on MSW
(`src/generic/datavgen.cpp:2735-2974`) and every `RenderText` call (`src/common/datavcmn.cpp:1102`) draws with them.
`DrawItemText` draws at the rect's top-left without alignment or ellipsis. Expect this when a new MSW data view looks
"wrong". Dark styling of data views (`UpdateDVCDarkUI`): `references/colours-dark-mode.md`.
Pitfalls:
- **Rule:** Every deferred lambda that holds a `wxDataViewItem` re-validates it before use.
**Why:** the model can be rebuilt between queueing and execution; the item is a raw node pointer.
```cpp
CallAfter([this, item] { start_filament_editor(item); }); // Wrong
CallAfter([this, item] { if (!is_live_model_item(item)) return; // Right (or re-resolve by index)
start_filament_editor(item); });
```
Cite: c965b2a5b3 (`ObjectList::is_live_model_item`). Liveness rules for deferred calls: `references/events.md`
§CallAfter.
## ObjectGrid (GUI_ObjectTable)
`ObjectTableDialog` (a `DPIDialog`, opened by `Plater::PopupObjectTable`) hosts `ObjectGrid` (a `wxGrid`) with
`ObjectGridTable` (a `wxGridTableBase` set with `AssignTable`).
- Per-cell editors and renderers are installed with `SetCellEditor`/`SetCellRenderer`, which take ownership.
- `GridCellFilamentsEditor` and `GridCellChoiceEditor` derive from `wxGridCellChoiceEditor` but create a
`::ComboBox` as `m_control` and shadow `Combo()`; they override `BeginEdit`/`EndEdit`. Any change to them, or a new
editor of this shape, must account for the base `Reset()`/`GetValue()`/`SetParameters()` cast
([wxGrid](#wxgrid)).
- `GridCellSupportEditor::DoActivate`, the copy path in `ObjectGrid::OnKeyDown` and `ObjectGrid::paste_data` handle
an empty `GetSelectedBlocks()` (46e47cec0a).
@@ -0,0 +1,958 @@
# DPI, bitmaps and fonts
How wx 3.3.2 maps DIP, logical and physical pixels on each platform, how DPI changes reach a window,
and how OrcaSlicer sizes layout (`FromDIP`, `em_unit`), rescales (`DPIAware`), rasterizes icons
(`BitmapCache`, `create_scaled_bitmap`, `ScalableBitmap`) and chooses fonts (`Label` table). Read it
for any fixed size, icon, bitmap, image list, font, or `on_dpi_changed` work, and when a bug looks
like "too small / too big / blurry / clipped on another monitor".
Contents: [Rules](#rules) · [Pixel kinds per platform](#pixel-kinds-per-platform) ·
[FromDIP / ToDIP / FromPhys](#fromdip--todip--fromphys--tophys) ·
[Scale factors and GetDPI](#scale-factors-and-getdpi) ·
[Choosing a size unit](#choosing-a-size-unit-fromdip-em_unit-text-metrics) ·
[wxEVT_DPI_CHANGED](#wxevt_dpi_changed) · [DPIAware rescale path](#dpiaware-rescale-path-orca) ·
[wxBitmap](#wxbitmap-physical-size-scale-factor-logical-size) ·
[wxBitmapBundle](#wxbitmapbundle-and-its-limits-in-orcas-build) ·
[Orca icon pipeline](#orca-icon-pipeline) ·
[Image lists, art provider, wxImage](#image-lists-art-provider-wximage) ·
[Window icons](#window-icons) · [Displays](#displays-and-ppi) · [wxFont](#wxfont) ·
[Orca fonts](#orca-fonts-label-table-sysfont-initsysfont)
## Rules
1. Hard-coded layout pixel values (sizes, min sizes, borders, gaps, spacers) go through
`FromDIP(n)` (or `n * em_unit(this)` in em-based code), never raw ints. §Choosing a size unit
2. Do not `FromDIP` values that are not logical pixels: `wxBitmap` ctor sizes and `GetSize()`,
`wxImageList` sizes and `wxBitmapBundle::GetBitmap(size)` are physical; bundle default sizes
and `wxArtProvider::GetBitmapBundle` sizes are DIP. §wxBitmap, §wxBitmapBundle
3. On Linux, widths that must hold text come from text metrics, best sizes or `em_unit`, not
from a fixed `FromDIP` width. §Choosing a size unit
4. Call `FromDIP` on a created window, or on `parent` in base-ctor arguments; when the window
may be null use the static `wxWindow::FromDIP(x, win)`, never `win->FromDIP(x)` (fd80ded5a8).
§FromDIP
5. Pick bitmap resolution with `GetDPIScaleFactor()`; never with `GetContentScaleFactor()`
(always 1 on MSW) or `GetDPI().x / 96.0` (72-based on macOS). §Scale factors
6. Every top-level window is a `DPIDialog`/`DPIFrame` whose `on_dpi_changed` re-rasterizes named
bitmaps and re-sets them on controls, calls each Orca widget's `Rescale()`, re-applies every
size stored from `em_unit`/`FromDIP`, then re-establishes the minimum and fits. An empty
override is acceptable only for a trivial dialog with no bitmaps and no stored sizes.
§DPIAware rescale path
7. Every `wxEVT_DPI_CHANGED` handler you bind — on a child, a control, or on the TLW from a
component — calls `Skip()`; only DPIAware's own TLW handler deliberately does not.
§wxEVT_DPI_CHANGED
8. In a DPIAware window, never rely on wx's MSW top-level auto-resize; size the window in
`on_dpi_changed`. §wxEVT_DPI_CHANGED, §DPIAware rescale path
9. `DPIAware::scale_factor()` is the display scale only on MSW (Orca's `get_dpi_for_window` is a
96 stub elsewhere); use `GetDPIScaleFactor()`/`FromDIP` for anything else. §DPIAware
10. On GTK `em_unit` is measured from the font, with the same formula in the ctor and the
rescale path (40eab797c6). §DPIAware
11. Icons are SVG resource names (no path, no extension) passed to `create_scaled_bitmap`,
`ScalableBitmap` or `Button`, with the real window and an explicit size;
`wxBitmapBundle::FromSVG*` does not exist in Orca's wx. §Orca icon pipeline
12. Layout that depends on a bitmap uses its logical size (`ScalableBitmap::GetBmpSize()`,
`wxBitmap::GetLogicalSize()`), not `GetSize()`/`GetWidth()`. §wxBitmap
13. Owner-drawn offscreen bitmaps use `CreateWithDIPSize(sz, GetDPIScaleFactor())` or
`CreateWithLogicalSize(GetClientSize(), GetDPIScaleFactor())`, not `wxBitmap(FromDIP(sz))`.
§wxBitmap
14. Draw a bundle with `GetBitmapFor(win)`, never `GetBitmap(GetDefaultSize())`; bundle size
queries take a created, non-null window. §wxBitmapBundle
15. `wxImageList` sizes are physical and must equal the added bitmaps' sizes; prefer
`SetImages()` with bundles. §Image lists
16. Fonts come from `Label::Head_*`/`Label::Body_*`; never literal point sizes. §Orca fonts
17. After `wxFont::SetFaceName` check `IsOk()` and fall back. §wxFont
18. Private fonts are registered only by `Label::initSysFont()`, early in
`GUI_App::on_init_inner`; never `AddPrivateFont` on macOS. §Orca fonts
19. On MSW a `wxMemoryDC` sizes text for its bitmap's scale factor: give the bitmap the window's
`GetDPIScaleFactor()` before selecting it; any `Label` font then renders correctly. §wxFont
20. Never index `wxDisplay` with an unchecked `GetFromWindow()` result. §Displays
## Pixel kinds per platform
Contract (`docs/doxygen/overviews/high_dpi.md:139-149`): "Under MSW, logical pixels are always
the same as physical pixels, but are different from DIPs, while under all the other platforms
with DPI scaling support (currently only GTK 3 and macOS), logical pixels are the same as DIP, but
different from physical pixels." Conversions: DIP↔logical with `FromDIP/ToDIP`, physical↔logical
with `FromPhys/ToPhys`, DIP↔physical by multiplying/dividing by `GetDPIScaleFactor()`.
| | MSW | macOS | GTK3 (Linux default; X11 and Wayland) | GTK2 (opt-out build) |
|---|---|---|---|---|
| logical (all window/DC API) | = physical | = DIP (points) | = DIP | = physical |
| `FromDIP(x)` | x·DPI/96, rounded | identity | identity | identity [source] |
| `GetContentScaleFactor()` | always 1 | backing scale (1 or 2) | integer GDK scale | 1 |
| `GetDPIScaleFactor()` | DPI/96 (1.25, 1.5, 1.75…) | = content scale | = content scale | 1 |
| `GetDPI()` | per window, 96-based | 72 × scale | 96 × scale | 96 |
| bitmap scale factor | stored; drives bundle selection and `wxMemoryDC` text size; never changes drawn size [source] | stored; drawn size = physical / scale | stored; drawn size = physical / scale | not stored |
| `wxEVT_DPI_CHANGED` | PMv2 manifest + Win10 1703 | on backing-scale change [source] | GTK ≥ 3.10, wx ≥ 3.3.0 | never |
| app-level HiDPI | per-monitor v2 manifest | `NSHighResolutionCapable`, `NSPrincipalClass` | automatic; fractional scales rounded to an integer | only global `GDK_SCALE`/`GDK_DPI_SCALE` |
Platforms:
- **MSW.** Orca ships its own manifest, `src/dev-utils/platform/msw/OrcaSlicer.manifest.in`:
`<dpiAware>true/pm</dpiAware>` and `<dpiAwareness>permonitorv2,permonitor</dpiAwareness>`. That
is the per-monitor v2 awareness wx needs to send DPI events (`interface/wx/event.h:3585-3590`).
On Windows versions that only honour the `permonitor` (v1) fallback, wx's
`IsPerMonitorDPIAware()` accepts only PMv2, so no DPI handling runs there [source]
`src/msw/nonownedwnd.cpp:IsPerMonitorDPIAware, wxNonOwnedWindow::HandleDPIChange`.
- **macOS.** `src/dev-utils/platform/osx/Info.plist.in` (the template `src/CMakeLists.txt`
configures) sets `NSPrincipalClass=NSApplication` (the key `docs/doxygen/overviews/high_dpi.md:339-341` requires) and
`NSHighResolutionCapable=true`. Its standard PPI is 72, not 96 (`include/wx/display.h`
`wxDisplay::GetStdPPIValue`; documented `interface/wx/display.h:196-211`).
- **GTK3** (the Linux build: `option(DEP_WX_GTK3 … ON)` in `deps/CMakeLists.txt`, Flatpak too).
"wxGTK only supports integer scaling factors currently and fractional scales are rounded to
the closest integer" (`docs/doxygen/overviews/high_dpi.md:348-351`). A Wayland compositor's fractional scale therefore
reaches wx as an integer GDK scale.
- **GTK2** (only with `-DDEP_WX_GTK3=OFF`). `wxHAS_DPI_INDEPENDENT_PIXELS` is defined only for
`__WXGTK3__ || __WXMAC__ || __WXQT__` (`include/wx/features.h:115-120`), so GTK2 takes the
"real conversion" branch of `FromDIP`. But the GTK2 `wxDisplayImplGTK` does not override
`GetScaleFactor()` (only under `GTK_CHECK_VERSION(3,10,0)`, `src/gtk/display.cpp`), and the base
`GetPPI()` is `GetStdPPI()*GetScaleFactor()` = 96 (`include/wx/private/display.h:99-103`). Net
effect [source]: `FromDIP` is the identity on GTK2 too, and GTK2 HiDPI exists only through the
global env vars (`docs/doxygen/overviews/high_dpi.md:353-355`). Code guarded for GTK must still compile there.
Exceptions to "every API takes logical pixels" (`docs/doxygen/overviews/high_dpi.md:169-183`): sizes passed to `wxBitmap`
constructors and returned by `GetWidth/GetHeight/GetSize` are **physical**;
`wxBitmapBundle::GetPreferredBitmapSizeFor()` is physical (`GetPreferredLogicalSizeFor()` is the
logical twin); the bundle **default size** (`FromSVG` argument, `GetDefaultSize()`) is **DIP**.
`wxGLCanvas` drawing is also physical (see `references/webview-gl-aui-media.md`).
## FromDIP / ToDIP / FromPhys / ToPhys
**Contract** (`interface/wx/window.h:1089-1121`): "A DPI-independent pixel is just a pixel at the
standard 96 DPI resolution … this scaling may be already done by the underlying toolkit (GTK+,
Cocoa, ...) automatically. This method performs the conversion only if it is not already done by
the lower level toolkit." It "is only needed when using hard coded pixel values. It is not
necessary if the sizes are already based on the DPI-independent units such as dialog units or if
you are relying on the controls automatic best size determination and using sizers". A component
equal to `-1` is returned unchanged, so `wxSize(FromDIP(490), -1)` keeps "unspecified"
(`interface/wx/window.h:1113-1116`). `ToDIP` is the inverse; the doc's use case is persisting window geometry
DPI-independently (`interface/wx/window.h:1166-1189`).
**Static overloads** `FromDIP(sz|pt|d, const wxWindow* w)` (`interface/wx/window.h:1141-1163`) accept
`w == nullptr`, but are "discouraged as passing NULL will prevent your application from correctly
supporting monitors with different resolutions". [source] With null on MSW the DPI comes from
`wxDisplay().GetPPI()`, the primary display (`src/common/wincmn.cpp` `GetDPIHelper`); on
macOS/GTK the conversion is the identity regardless.
**FromPhys/ToPhys** (`interface/wx/window.h:1233-1321`): physical↔logical; "does nothing under MSW, but divides
the input value by the content scale factor under the other platforms", rounding to the closest
integer ("15 physical pixels are translated to 8"). The static form with a null window uses "the
content scale factor of the main screen if supported" (`interface/wx/window.h:1270-1282`); [source] that is
macOS only, 1 elsewhere (`src/common/wincmn.cpp` `GetContentScaleFactorFor`). Use them for genuinely physical quantities only (bitmap pixel
sizes, GL viewports), never for layout constants.
**Platforms** [source]:
- MSW rounds per call (`wxMulDivInt32`, `include/wx/private/rescale.h`), so
`FromDIP(a) + FromDIP(b)` can differ from `FromDIP(a + b)` by 1 px at 125 %/175 %. Convert the
sum when two values must line up.
- MSW `GetDPI()` on a window without an HWND (two-step creation, or `this` inside a base-class
argument) falls back to the top-level parent's HWND, else to the screen DC — the primary
monitor (`src/msw/window.cpp` `wxWindowMSW::GetDPI`; the "possibly wrong DPI" log is compiled
out in Orca).
- The `interface/wx/window.h:1101-1106` example `wxBitmap bmp(FromDIP(32, 32))` contradicts the physical-bitmap
rule (`docs/doxygen/overviews/high_dpi.md:173-178`) and gives a 1x bitmap on macOS/GTK3; use the `wxBitmap` creation helpers instead
(§wxBitmap).
**OrcaSlicer.** `FromDIP(n)` is the convention for every fixed size in new code. Shared
macros build on the member form and expand only inside a `wxWindow` member function:
`ICON_SINGLE_SIZE`/`ICON_SIZE` (`GUI_Utils.hpp`, `FromDIP(16)`; their comment says not to change
them and to define new sizes locally) and `MSG_DIALOG_BUTTON_SIZE` (`MsgDialog.hpp`).
`create_scaled_bitmap` uses the static form because its `win` argument may be null.
**Pitfalls**
- **Rule:** Never call a member function through a `wxWindow*` that is allowed to be null; use
the static null-safe overload.
**Why:** `win->FromDIP()` on null is UB; clang assumes `this != nullptr` and deletes later
`win ? … : …` checks, turning the fallback into a call through a null vtable. This crashed
LLVM/clang-cl builds at startup while MSVC survived by luck. The static overload falls back to
the primary-display DPI on MSW and is the identity on macOS/GTK.
```cpp
unsigned h = win->FromDIP(px_cnt); // Wrong: UB when win == nullptr
unsigned h = wxWindow::FromDIP(px_cnt, win); // Right: static, null-safe
```
Cite: fd80ded5a8 (`src/slic3r/GUI/wxExtensions.cpp` `create_scaled_bitmap`).
- **Rule:** In base-class constructor arguments convert through the parent, not `this`.
**Why:** `this` is not yet a constructed window there; on MSW (and GTK2) the member form calls
the virtual `GetDPI()` on it, which is UB, and even a constructed window without an HWND
reports the primary monitor's DPI. On macOS/GTK3 the member form is the identity, so the bug
shows only on Windows.
```cpp
MyPanel(wxWindow* p) : wxPanel(p, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(300), -1)) {} // Wrong
MyPanel(wxWindow* p) : wxPanel(p, wxID_ANY, wxDefaultPosition, wxSize(p->FromDIP(300), -1)) {} // Right
```
Cite: [source] `src/msw/window.cpp` `wxWindowMSW::GetDPI`.
## Scale factors and GetDPI
**Contract.**
- `GetDPIScaleFactor()` (`interface/wx/window.h:1605-1626`): "1 for standard DPI screens or 2 for '200%
scaling' and, unlike for GetContentScaleFactor(), is the same under all platforms. This factor
should be used to increase the size of icons and similar windows whose best size is not based
on text metrics … should *not* be used for window sizes expressed in pixels, as they are
already scaled by this factor by the underlying toolkit under some platforms. Use FromDIP() for
anything window-related instead." It answers "how many physical pixels per DIP", i.e. which
raster resolution to produce.
- `GetContentScaleFactor()` (`interface/wx/window.h:1576-1603`): "the factor mapping logical pixels of this
window to physical pixels"; on platforms without pixel mapping (MSW) it "always returns 1.0".
Note in the doc: it equalled `GetDPIScaleFactor()` in wx 3.1.0–3.1.3 only. Use it for physical
buffers (GL, `FromPhys`), not to choose icon sizes.
- `GetDPI()` (`interface/wx/window.h:2285-2295`): per window, can differ between windows on Windows 10;
`wxSize(0,0)` if unavailable. On macOS it is 72-based: `wxWindowMac::GetDPI()` is
`MakeDPIFromScaleFactor(GetDPIScaleFactor())` = 72 × scale [source] `src/osx/window_osx.cpp`.
**Platforms** [source]: macOS `GetContentScaleFactor()` is the `NSWindow`'s
`backingScaleFactor`, or the main screen's when the view has no window yet
(`src/osx/cocoa/window.mm` `wxWidgetCocoaImpl::GetContentScaleFactor`). GTK returns
`gtk_widget_get_scale_factor` (an integer; 1 on GTK2), and `GetDPIScaleFactor()` is the same value
(`src/gtk/window.cpp` `wxWindowGTK::GetContentScaleFactor/GetDPIScaleFactor`).
**Pitfalls**
- **Rule:** Derive a scale with `GetDPIScaleFactor()` or `wxDPIChangedEvent::Scale*`, never by
dividing a DPI by 96.
**Why:** macOS's standard PPI is 72 (`interface/wx/display.h:203-205`), so `GetDPI().x / 96.0`
is 1.5 on a 2x Retina screen and 0.75 on a 1x one.
```cpp
double s = GetDPI().x / 96.0; // Wrong on macOS
double s = GetDPIScaleFactor(); // Right
```
- **Rule:** Choose icon/raster resolution from `GetDPIScaleFactor()`, not
`GetContentScaleFactor()`.
**Why:** content scale is always 1 on MSW (`interface/wx/window.h:1587-1592`), so icons never grow there.
## Choosing a size unit: FromDIP, em_unit, text metrics
Two scaling currencies coexist in Orca, plus the text metrics wx recommends:
| Unit | Use for | Value per platform |
|---|---|---|
| `FromDIP(n)` | fixed sizes in new code: icon sizes, borders, gaps, control heights, min sizes not driven by text | MSW n·DPI/96; macOS/GTK identity |
| `em_unit` (`em_unit(this)`, `wxGetApp().em_unit()`, `DPIAware::em_unit()`) | the settings code (`Tab`, `OptionsGroup`, `Field`, `ObjectList` columns) and any size that must follow text on Linux; sizes written as multiples, `wxSize(65 * em, 30 * em)` | MSW `max(10, 10 × scale_factor)`; macOS always 10; GTK width of "m" in the window font − 1 (min 10) |
| text metrics (`GetTextExtent`, best sizes, `ConvertDialogToPixels`) | widths that hold translated text | follow the font everywhere |
The overview prefers text metrics or dialog units over pixel values and calls `FromDIP` "the
simplest change" (`docs/doxygen/overviews/high_dpi.md:71-78`).
**Platforms.** On GTK, text follows the font DPI and the desktop text-scaling factor (Xft DPI,
`GDK_DPI_SCALE`) while `FromDIP` stays the identity at GDK scale 1, so a fixed `FromDIP` width
that fits a label on MSW/macOS can clip it on Linux. That is why `DPIAware` measures `em_unit`
from the font on GTK (§DPIAware rescale path).
**OrcaSlicer.** `em_unit(wxWindow*)` (`wxExtensions.cpp`) walks to the top-level parent
(`find_toplevel_parent`) and returns that `DPIDialog`'s or `DPIFrame`'s own `em_unit()`, else
`wxGetApp().em_unit()`; the per-window value matters when windows sit on monitors with different
DPI. `wxGetApp().em_unit()` is 10 until `GUI_App::update_fonts` copies the main frame's value
(called from `MainFrame::on_dpi_changed` and at main-frame setup).
**Pitfalls**
- **Rule:** Size boxes that contain text from the text, not from a fixed `FromDIP` width.
```cpp
label->SetMinSize(wxSize(FromDIP(120), -1)); // Wrong: clips on GTK text scaling
label->SetMinSize(wxSize(label->GetTextExtent(text).x + FromDIP(8), -1)); // Right (or N * em, or -1 + sizer)
```
Cite: `docs/doxygen/overviews/high_dpi.md:71-78`; [source] `DPIAware::update_em_unit` comment (`GUI_Utils.hpp`).
## wxEVT_DPI_CHANGED
**Contract** (`interface/wx/event.h:3560-3593`): sent "to each wxTopLevelWindow affected by the
change, and all its children recursively (post-order traversal)" — on a move to a monitor with a
different DPI or a system DPI change. "You should almost always call event.Skip() … as many
controls rely on processing this event in order to update their appearance". The TLW's default
handler "only sets the new window size, by scaling the current size by the DPI ratio … and also
ensuring that the window is still bigger than its best size"; to prevent it, handle the event on
the TLW, `SetSize()` there and do *not* Skip. Documented generators: wxMSW "if and only if" Windows
10 1703+ with a PerMonitorV2 manifest; wxGTK "when using GTK 3.10 or later and only since
wxWidgets version 3.3.0".
Helpers (`interface/wx/event.h:3610-3660`): `GetOldDPI()`, `GetNewDPI()`, `Scale(wxSize)`, and since 3.3.0
`Scale(wxPoint)`/`Scale(wxRect)`, `ScaleX/ScaleY` — old-DPI→new-DPI via `wxMulDivInt32`. Prefer
them to `GetNewDPI()/96` (72-based on macOS).
**Platforms** [source unless noted]:
| Port | Who generates it | What wx itself rescales |
|---|---|---|
| MSW | `WM_DPICHANGED` → `wxNonOwnedWindow::HandleDPIChange` (`src/msw/nonownedwnd.cpp`), only for PMv2-aware windows | `wxWindowMSW::MSWUpdateOnDPIChange` (`src/msw/window.cpp`) recurses from the TLW through every non-TLW child; for each window it first rescales `m_min/maxWidth/Height`, invalidates best size, re-creates the window font at the new PPI (`MSWUpdateFontOnDPIChange`; the TLW's override `wxTopLevelWindowMSW::MSWUpdateFontOnDPIChange` only re-selects its icons and leaves the TLW font alone), rescales sizer borders, spacer sizes and nested-sizer min sizes (`UpdateSizerOnDPIChange`; window items keep their min size because each window scales its own), then recurses into its children, then sends the event to that window. If the TLW's event was **not processed**, wx `SetSize()`s the TLW to the suggested rect inflated to the sizer's min size |
| macOS | `windowDidChangeBackingProperties` when the backing scale changes (`src/osx/cocoa/nonownedwnd.mm`), DPIs built from the 72-based std PPI (`wxWindowBase::WXNotifyDPIChange`). Undocumented. The same notification sends `wxSysColourChangedEvent` when the colour space changes | nothing (logical = DIP) |
| GTK3 | TLW configure event when `GetContentScaleFactor()` changed (`src/gtk/toplevel.cpp` `wxTopLevelWindowGTK::GTKConfigureEvent`, `__WXGTK3__` only); the initial scale is captured at creation, so there is no event at startup; DPIs are 96 × integer scale | nothing (logical = DIP) |
| GTK2 | never | — |
The default TLW resize exists only on MSW; on macOS/GTK3 logical sizes do not change with DPI.
Handler order: children receive the event before their TLW (post-order, documented; [source]
`src/common/wincmn.cpp` `NotifyAboutDPIChange`; MSW recursion in `MSWUpdateOnDPIChange`).
Dynamic handlers run most recently bound first, before static tables
(`docs/doxygen/overviews/eventhandling.h:475-482`), so a handler you `Bind` on
a control runs before the control's own (`wxBookCtrlBase`, `wxComboCtrlBase`, `wxTreeCtrlBase` bind
one; wxMSW `wxStaticBitmap` uses a static table entry, `src/msw/statbmp.cpp`, and MSW button
bitmaps bind one, `src/msw/anybutton.cpp`).
**What wx rescales vs what Orca must re-apply:**
| Item | MSW (done by wx before the event) | macOS / GTK3 | Orca's `on_dpi_changed` must |
|---|---|---|---|
| window min/max sizes | rescaled by the DPI ratio | unchanged (logical = DIP) | re-set only if it recomputes them anyway |
| window font set by `SetFont` | re-created at the new PPI for non-TLW windows; the TLW keeps its old-PPI font [source] | unchanged (points) | nothing: a `wxFont` stores points and every window or DC `SetFont` re-adjusts it to its target's PPI; `DPIAware::rescale` re-reads it and updates `em_unit` |
| sizer borders, spacers, nested-sizer min sizes | rescaled | unchanged | nothing |
| TLW size | resized only if the TLW event is unprocessed — never for DPIAware windows | unchanged | re-establish min size, `Fit()`/`SetSize()` |
| values given to setters (`SetRowHeight`, column widths, `SetItemMinSize`, custom-widget sizes, cached pixel members) | never | never | re-apply from `FromDIP`/`em_unit` |
| bitmaps on native controls | reselected from the control's bundle; for Orca's single bitmaps that is the old raster, unscaled or integer-upscaled | GTK never; macOS from the bundle | `msw_rescale()` + `SetBitmap()` |
| Orca widgets (cached measures, named icons) | never | never | call `Rescale()` |
**Pitfalls**
- **Rule:** Call `Skip()` in every `wxEVT_DPI_CHANGED` handler you bind, on a child, a control or
the TLW (DPIAware's own handler is the one exception).
**Why:** your dynamically bound handler runs first; without `Skip()` the control's own handler
(book controls, combo controls, MSW static bitmaps and button images) never runs and keeps its
old-DPI appearance. Bound on a `DPIDialog`/`DPIFrame` (as `m_parent` is here), it also starves
DPIAware's own handler, so `on_dpi_changed` never runs.
```cpp
m_parent->Bind(wxEVT_DPI_CHANGED, [this](wxDPIChangedEvent& e) { UpdateButtons(); }); // Wrong
m_parent->Bind(wxEVT_DPI_CHANGED, [this](wxDPIChangedEvent& e) { UpdateButtons(); e.Skip(); }); // Right
```
Cite: `interface/wx/event.h:3572-3575`; `src/slic3r/GUI/Widgets/DialogButtons.cpp`
`DialogButtons::on_dpi_changed`.
- **Rule:** Do not add a second `wxEVT_DPI_CHANGED` handler on a `DPIDialog` expecting wx to
resize the dialog; put the sizing in `on_dpi_changed()`.
**Why:** DPIAware's TLW handler never Skips, so the event counts as processed and wxMSW skips
its "scale size / ensure ≥ best size" resize.
Cite: [source] `src/msw/nonownedwnd.cpp` `wxNonOwnedWindow::HandleDPIChange`.
## DPIAware rescale path (Orca)
`template<class P> DPIAware : public P` (`src/slic3r/GUI/GUI_Utils.hpp`) wraps `wxDialog`/`wxFrame`;
`DPIFrame` (typedef) and `DPIDialog` (subclass) are the instantiations every Orca top-level window
uses. Its modal, ESC and dark-mode parts are in `references/windows-dialogs.md` and
`references/colours-dark-mode.md`; this section is the DPI part.
**Constructor.**
- `m_scale_factor = get_dpi_for_window(this) / 96` and `m_prev_scale_factor` = the same.
`get_dpi_for_window` (`GUI_Utils.cpp`) is real only on Windows (`GetDpiForWindow`, falling back
to `GetDpiForMonitor` or the DC); on Linux and macOS it is a `// TODO` stub returning
`DPI_DEFAULT` (96), so `m_scale_factor` starts at 1.0 there.
- `m_normal_font = get_default_font_for_dpi(this, dpi)` (MSW: `SystemParametersInfoForDpi`
message font for that DPI; elsewhere `wxSYS_DEFAULT_GUI_FONT`), applied with `SetFont` except
on macOS (`#ifndef __WXOSX__`, comment "Don't call SetFont under OSX to avoid name cutting in
ObjectList"). The window font is set before `em_unit` is measured because the default window
font is the primary display's.
- `update_em_unit()`:
```cpp
#if !defined(__WXGTK__)
m_em_unit = std::max<size_t>(10, 10.0f * m_scale_factor); // MSW: DPI-based; macOS: always 10
#else
m_em_unit = std::max<size_t>(10, this->GetTextExtent("m").x - 1); // GTK: from the font
#endif
```
**Bindings.**
- `wxEVT_DPI_CHANGED`, non-macOS only (`#ifndef __WXOSX__`): stores
`GetNewDPI().x / 96` in `m_scale_factor` and calls `rescale(wxRect())` if
`m_can_rescale && (m_force_rescale || is_new_scale_factor())`. It does **not** `Skip()`: the
dialog sizes itself, so wx's MSW TLW resize is suppressed. On GTK3 this handler fires (wx 3.3)
and sets a real integer scale from the 96-based DPI; on macOS it is not bound, so
`on_dpi_changed` never runs there; on GTK2 there is no event.
- `wxEVT_MOVE_START`/`wxEVT_MOVE_END` (wxMSW-only events, `interface/wx/event.h:5006-5014`): START
clears `m_can_rescale`, so a DPI event during an interactive drag only records the new factor;
END rescales with the move rect if the factor changed, else re-arms `m_can_rescale`. The DPI
handler itself is what rescales; MOVE_END only performs a rescale that was deferred.
- `enable_force_rescale()` makes the next DPI event rescale even if the factor is unchanged.
**`rescale(suggested_rect)`**: `Freeze()` → `m_normal_font = GetFont()` (the TLW font, which wxMSW
does not re-create on a DPI change; harmless, because a `wxFont` stores points and is re-adjusted
to the PPI of whatever window or DC it is set on) → `update_em_unit()` → pure virtual `on_dpi_changed(suggested_rect)`
→ `Layout()` → `Thaw()` → `m_prev_scale_factor = m_scale_factor`. `suggested_rect` is empty on the
DPI-event path and the moved window rect on the MOVE_END path; do not rely on it.
**What `on_dpi_changed` does** (the canonical shape):
```cpp
void MyDialog::on_dpi_changed(const wxRect&) {
m_logo.msw_rescale(); // ScalableBitmap: new raster at the new DPI
m_logo_ctrl->SetBitmap(m_logo.bmp()); // the control holds its own copy
m_ok_btn->Rescale(); // every Orca widget (Button, TextInput, ComboBox, ...)
const int em = em_unit();
msw_buttons_rescale(this, em, {wxID_CLOSE}); // native stock-id wxButtons only
m_list->SetMinSize(wxSize(-1, 16 * em)); // re-apply stored sizes
SetMinSize(wxSize(65 * em, 30 * em)); // explicit minimum + Fit(), or
Fit(); // GetSizer()->SetSizeHints(this) in place of both
Refresh();
}
```
`AboutDialog::on_dpi_changed` is this shape (ScalableBitmap + `SetBitmap`, html fonts re-derived
from `GetFont()`, `msw_buttons_rescale`, min sizes, `Fit()`, `Refresh()`).
`PreferencesDialog::on_dpi_changed` shows the child walk: recurse `GetChildren()` and call
`Rescale()` on each Orca widget found by `dynamic_cast`; inside `namespace Slic3r::GUI` write the
types qualified (`::CheckBox`, whose method is `Rescale()`, not `msw_rescale()`), because
unqualified `CheckBox` names the `Field` class there (`references/orca-widgets.md`). wx 3.3's
`CallForEachChild(functor)` (`interface/wx/window.h:602-624`) does the same recursive walk, the
window itself included; it also descends into owned top-level children ([source]
`include/wx/window.h` `wxWindowBase::CallForEachChild`).
**Cascade.** `MainFrame::on_dpi_changed` is the root for the main window: `update_fonts`, the
tab panel / top bar / buttons `Rescale()`, `plater()->msw_rescale()`, the param panel, lazily
built pages through `when_built`, then a `SetSize(sz + 1)`/`SetSize(sz)` jiggle (with
un-maximize/re-maximize) to force a full redraw. Child panels expose `msw_rescale()`/`Rescale()`
and are called from their owner; they do not get `on_dpi_changed`.
**Self-rescaling components.** `DialogButtons` binds its parent's `wxEVT_DPI_CHANGED` in the ctor,
unbinds in the dtor, restyles and `Skip()`s. Being bound after DPIAware's handler, it runs first.
This is the model for a component that must rescale without its owner's help. Keep one-time
`Bind` calls out of the restyle function such a handler runs: `Bind` does not deduplicate, so a
handler bound there runs once more after every DPI change.
**Helpers.**
- `msw_buttons_rescale(dlg, em, ids)` (`wxExtensions.cpp`; all platforms despite the name) calls
`SetMinSize(wxSize(-1, 2.5 * em))` on whatever window has each id. Meant for native `wxButton`s;
an Orca `Button` with that id (every `DialogButtons` OK/Cancel) takes it too and loses its style
height through `Button::SetMinSize`, so leave it out for Orca buttons
(`references/sizers-layout.md` §Layout on DPI change).
- `scale_factor()`/`prev_scale_factor()` are meaningful only on MSW (and on GTK3 after a DPI
event); `em_unit()`, `normal_font()` are the per-window values.
**Pitfalls**
- **Rule:** On GTK derive `em_unit` from the current font, and use the same computation in the
ctor and the rescale path (one helper).
**Why:** on GTK `DPIAware` starts with scale 1.0 because Orca's `get_dpi_for_window` is a stub
there; only the GTK3 `wxEVT_DPI_CHANGED` sets a real (integer) scale. The defect 40eab797c6 fixed: the ctor
measured the font while `rescale()` used `max(10, 10 × scale)` — e.g. 20 at 2× — so controls laid
out with a different em after a Linux DPI change than at construction.
```cpp
m_em_unit = std::max<int>(10, 10.0f * m_scale_factor); // Wrong: in rescale(), on every platform
update_em_unit(); // Right: same platform-branched helper as the ctor
```
Cite: 40eab797c6 (`src/slic3r/GUI/GUI_Utils.hpp` `DPIAware::update_em_unit`).
- **Rule:** In `on_dpi_changed`/`msw_rescale`, re-apply every size computed from `em_unit` or
`FromDIP` at construction (row heights, column widths, min sizes, cached pixel members).
**Why:** wx never rescales values passed to setters (`SetRowHeight`, column widths) on any
port; wxMSW only rescales stored min/max sizes and sizer spacers. Stale values clip or
overflow after a DPI or theme change (the object-list filament badge stopped fitting its row).
```cpp
// ctor: SetRowHeight(2 * em + FromDIP(2));
// msw_rescale(): SetRowHeight(2 * em + FromDIP(2)); // must repeat with the new em
// GetColumn(cn)->SetWidth(m_columns_width[cn] * em);
```
Cite: d5638273c6 (`src/slic3r/GUI/GUI_ObjectList.cpp` `ObjectList::create_objects_ctrl`,
`ObjectList::msw_rescale`). Layout side: `references/sizers-layout.md`.
- **Rule:** In `on_dpi_changed`, re-establish the minimum and resize: `GetSizer()->SetSizeHints(this)`
(does both), or `SetMinSize(...)` then `Fit()`/`SetSize()`; never leave a dialog without a
minimum that re-`Fit()`s on refresh paths.
**Why:** on MSW nothing else resizes a DPIAware dialog (wx's TLW resize is suppressed), so
`Refresh()` alone leaves it at its old physical size. A `Fit()` on a dialog without size hints
can collapse it on GTK when children are transiently zero-sized (after iconizing the main
window); with hints in place `Fit()` is safe. `wxSizer::SetSizeHints(win)` "first calls Fit()
and then wxTopLevelWindow::SetSizeHints()" (`interface/wx/sizer.h:937-970`), so a `Fit()` right
before it is redundant, and one after it re-applies the best size without the display clamp
(`references/sizers-layout.md` §Fitting functions); plain `Fit()` sets no minimum.
```cpp
Layout(); Fit(); // Wrong: ctor, no enforced minimum
void on_dpi_changed(const wxRect&) override { Refresh(); Fit(); }
Layout(); Fit(); v_sizer->SetSizeHints(this); // Right: ctor (the Fit() is redundant)
void on_dpi_changed(const wxRect&) override { GetSizer()->SetSizeHints(this); Refresh(); }
```
Cite: f760f4e462 (`src/slic3r/GUI/calib_dlg.cpp` `FlowRateCalibrationDialog`; the GTK collapse
mechanism is from the commit message, not visible in source); mechanism in
`references/sizers-layout.md`.
- **Rule:** Do not size anything from `scale_factor()`/`prev_scale_factor()` off MSW.
**Why:** `get_dpi_for_window()` returns a hard-coded 96 on Linux and macOS, so the factor is 1
at construction there (on GTK3 it can later jump to the event's integer scale; on macOS it
never changes).
```cpp
int w = int(120 * scale_factor()); // Wrong: 120 px on a Retina Mac / 2x GTK3 at startup
int w = FromDIP(120); // Right
```
## wxBitmap: physical size, scale factor, logical size
**Contract.**
- Size arguments of `wxBitmap` constructors and `GetWidth/GetHeight/GetSize` are physical
(`docs/doxygen/overviews/high_dpi.md:173-178`, `interface/wx/bitmap.h:788-800`).
- `CreateWithDIPSize(size, scale)` (`interface/wx/bitmap.h:486-521`): physical size = `size × scale`, rounded;
afterwards `GetDIPSize() == size`, `GetScaleFactor() == scale`. For fixed (compile-time) sizes.
`CreateScaled` is its older synonym (`interface/wx/bitmap.h:579`).
- `CreateWithLogicalSize(size, scale)` (since 3.3.0, `interface/wx/bitmap.h:523-558`): for sizes from
`GetClientSize()` etc. with `scale = GetDPIScaleFactor()`; physical = `size` on MSW,
`size × scale` where `wxHAS_DPI_INDEPENDENT_PIXELS` is defined.
- `GetLogicalSize()` (`interface/wx/bitmap.h:685-706`): physical / scale factor on DPI-independent ports,
`GetSize()` elsewhere; "must be used in any computations involving the sizes expressed in
logical units" (`docs/doxygen/overviews/high_dpi.md:176-178`). `GetScaledSize/Width/Height` are its older synonyms.
`GetDIPSize()` (`interface/wx/bitmap.h:645-659`) is the same value on all platforms and "should not be used
as window or device context coordinates".
- `SetScaleFactor(scale)` (`interface/wx/bitmap.h:951-966`) changes no pixels, only the apparent drawn size,
"in the ports in which logical and physical pixels differ (i.e. wxOSX and wxGTK3, but not
wxMSW)". The doc of `GetScaleFactor()` says it "always returns 1 under the other platforms"
(`interface/wx/bitmap.h:744-751`) — **[source] contradicted on MSW**: `wxGDIImage` stores the factor "to use
the correct sizes in the code which uses it to decide on the bitmap size to use"
(`src/msw/gdiimage.cpp` `wxGDIImage::SetScaleFactor`, `GetDIPSize`); bundle selection reads
it (§wxBitmapBundle) and the MSW memory DC sizes text by it (§wxFont). GTK2 has no scale storage (`include/wx/gtk/bitmap.h`, `__WXGTK3__` only).
- `wxBitmap(const wxImage&, int depth, double scale)` exists on all three ports [source], but the
MSW one ignores `scale` (`double WXUNUSED(scale)`, `include/wx/msw/bitmap.h:68`); call
`SetScaleFactor()` afterwards on MSW. The `scale` argument does not resize: it declares that
the image is already sized for that backing scale (Orca's comments in `BitmapCache.cpp`
`wxImage_to_wxBitmap_with_alpha` and `BitmapComboBox.cpp` say the same). `wxBitmap(img, dc)`
inherits the DC's scale (`interface/wx/bitmap.h:370-385`).
- `wxBitmap(const wxCursor&)` is invalid on GTK under Wayland (`interface/wx/bitmap.h:388-401`).
- wxMSW `wxBitmap::Create(size, dc)` no longer multiplies by the DC's content scale
(`docs/changes.txt:94-96`).
**Offscreen drawing.** "The scaling factor of the bitmap determines the scaling factor used by
this device context" (`interface/wx/dcmemory.h:41-58`); `wxMemoryDC(wxDC*)` does **not** inherit
the DC's scaling (`interface/wx/dcmemory.h:80-89`). The cross-platform shape needs no `#ifdef`:
```cpp
wxBitmap bmp;
bmp.CreateWithDIPSize(wxSize(24, 24), GetDPIScaleFactor()); // fixed-size art
{ wxMemoryDC mdc(bmp); mdc.SetFont(GetFont()); /* draw in logical coords: FromDIP() values */ }
dc.DrawBitmap(bmp, pos); // 24 DIP, sharp on Retina/GTK3
// back buffer: bmp.CreateWithLogicalSize(GetClientSize(), GetDPIScaleFactor());
```
[source] On MSW the memory DC does not scale coordinates (logical = physical there, so a
`CreateWithDIPSize` bitmap is drawn with `FromDIP` coordinates), but it does size text by the
bitmap: `wxMemoryDCImpl::DoSelect` records the selected bitmap's scale factor and
`wxMemoryDCImpl::SetFont` adjusts every font to `GetPPI()` = 96 × that factor
(`src/msw/dcmemory.cpp`). The macOS memory DC applies the bitmap scale to its graphics context
(`src/osx/core/dcmemory.cpp`). Back-buffering and DC coordinates are in
`references/painting-custom-widgets.md`.
**OrcaSlicer.** `SwitchButton::Rescale` is the legacy manual HiDPI pattern: on macOS it measures
with `dc.GetFont().Scaled(scale)`, draws into a `scale ×` image and wraps it with
`wxBitmap(img, -1, scale)`, using `mac_max_scaling_factor()`; on MSW it draws into a scale-1
bitmap with `GetFont().Scaled(GetDPIScaleFactor())` (compensating the memory DC's 96-PPI text) and
tags the result with `SetScaleFactor` afterwards. New owner-drawn caches use `CreateWithDIPSize`/`CreateWithLogicalSize` +
`wxMemoryDC` instead.
**Pitfalls**
- **Rule:** Create drawn bitmaps with a DIP size and the window's scale.
**Why:** bitmap sizes are physical and `FromDIP` is the identity on macOS/GTK3, so the first
form is a 1x bitmap upscaled (blurry) on Retina and 2x GTK3; on MSW its scale factor stays 1, so
text drawn into it through a `wxMemoryDC` comes out at 100 % size.
```cpp
wxBitmap bmp(FromDIP(wxSize(32, 32))); // Wrong
wxBitmap bmp; bmp.CreateWithDIPSize(wxSize(32, 32), GetDPIScaleFactor()); // Right
```
Cite: `docs/doxygen/overviews/high_dpi.md:173-178`, `interface/wx/dcmemory.h:41-56`.
- **Rule:** Lay out from logical bitmap sizes.
**Why:** physical ≠ logical off MSW; `GetSize()` of a 2x bitmap is twice its drawn size.
```cpp
int w = bmp.GetWidth() + FromDIP(4); // Wrong: double width on Retina
int w = bmp.GetLogicalSize().x + FromDIP(4); // Right (ScalableBitmap::GetBmpWidth() for Orca icons)
```
Cite: `interface/wx/bitmap.h:685-706`.
## wxBitmapBundle and its limits in Orca's build
**Contract.**
- Any API taking `const wxBitmapBundle&` accepts a `wxBitmap` through the implicit converting
constructor (`interface/wx/bmpbndl.h:110-116`). This is how every Orca bitmap reaches wx
controls: Orca code does not build bundles itself.
- Selection (`docs/doxygen/overviews/high_dpi.md:245-255`, `interface/wx/bmpbndl.h:52-62`): use the closest existing bitmap without
scaling; scale only when the mismatch is large. The overview says "equal or greater than 1.5";
**[source]** the code scales only when the target scale is **greater than** 1.5 × the largest
available, and then by an integer factor (or rounds the target scale)
(`src/common/bmpbndl.cpp` `wxBitmapBundleImpl::DoGetPreferredSize`).
- Single-bitmap bundle [source] (`bmpbndl.cpp` `wxBitmapBundleImplSet::Init`,
`GetNextAvailableScale`): default size = `GetDIPSize()` of the smallest bitmap; its available
scale = (DIP size / default size) × `GetScaleFactor()`. Consequences: a 16 px bitmap with
scale 1 is shown unscaled at 150 % and upscaled to 32 px at 175 %/200 %; a 24 px bitmap tagged
`SetScaleFactor(1.5)` has DIP size 16 and is used as-is at 150 %.
- `GetBitmap(size)` (`interface/wx/bmpbndl.h:415-428`): size "in physical pixels"; dynamically created sizes
are cached until exit ("avoid calling it for many different sizes"). [source] the result gets
`SetScaleFactor(size.y / GetDefaultSize().y)` (`bmpbndl.cpp` `wxBitmapBundle::GetBitmap`), so
`GetBitmap(GetDefaultSize())` always yields a scale-1 bitmap at the DIP size — a downscaled 1x
bitmap on HiDPI.
- `GetBitmapFor(win)`, `GetPreferredBitmapSizeFor(win)` (physical),
`GetPreferredLogicalSizeFor(win)` (logical) take a "Non-null and fully created window"
(`interface/wx/bmpbndl.h:392-441`); null hits a `wxCHECK` and returns `wxDefaultSize` silently in Orca.
- `FromBitmaps(vec)` / `FromBitmaps(b1, b2)` (`interface/wx/bmpbndl.h:158-169`): all bitmaps valid, sizes
physical, the smallest defines the default size. `FromImpl(new MyImpl)` takes ownership ("must
not call DecRef()", `interface/wx/bmpbndl.h:200-212`). `FromFiles` also looks in a `2.0x` subdirectory since
3.3.2 (`interface/wx/bmpbndl.h:231-247`). A custom `wxBitmapBundleImpl` implements `GetDefaultSize()` (DIP),
`GetPreferredBitmapSizeAtScale()` (physical; may defer to `DoGetPreferredSize()` when
`GetNextAvailableScale()` is overridden) and non-const `GetBitmap(size)` (`interface/wx/bmpbndl.h:522-560`).
- Auto-update on DPI change happens only on MSW and macOS (`docs/doxygen/overviews/high_dpi.md:205-209`); GTK controls
keep the bitmap chosen at set time.
- The overview asks for art usable unscaled at least at 100 % and 200 % (or a single SVG), and
advises against shipping only a high-resolution version to be downscaled on 1x displays
("contours become more blurry", `docs/doxygen/overviews/high_dpi.md:189-197`). In Orca the SVG
route is `BitmapCache`, not a bundle (below).
**Orca's build.** `deps/wxWidgets/wxWidgets.cmake` passes `-DwxUSE_NANOSVG=OFF` (7658cf9076,
duplicate symbols with Orca's own nanosvg) and LunaSVG stays off, so `wxHAS_SVG` — defined only for
`wxHAS_RAW_BITMAP && (wxUSE_NANOSVG || wxUSE_LUNASVG)` (`include/wx/features.h:96-98`) — is
undefined. `wxBitmapBundle::FromSVG`, `FromSVGFile` and `FromSVGResource` do not exist (compile
error; `interface/wx/bmpbndl.h:272-275` says to check `wxHAS_SVG`). Knock-on effects [source]: the Tango art
provider returns empty bundles (`src/common/arttango.cpp`, `!wxHAS_SVG` branch), the std
provider's SVG logo is absent (`src/common/artstd.cpp`), and wxAUI tab/dock buttons fall back to
1-bit XBM art (`src/aui/tabart.cpp`, `src/aui/dockart.cpp`). Orca rasterizes SVG itself
(`BitmapCache::load_svg`) and recolours it for dark mode, which a stock bundle could not do.
**Why Orca keeps single bitmaps + explicit rescale** rather than bundles: no SVG bundles in this
build; GTK does not auto-update bundles anyway; owner-drawn widgets must re-measure on DPI change;
and the icon raster must also change on a theme switch. The MSW `SetScaleFactor` tagging in
`create_scaled_bitmap` makes the implicit single-bitmap bundle report the intended DIP size
(§Orca icon pipeline).
If a stock wx control ever needs auto-updating multi-resolution art, the Orca-compatible shape
is a bundle implementation backed by `BitmapCache` (not existing Orca code; a sketch):
```cpp
struct OrcaSvgBundleImpl : wxBitmapBundleImpl {
std::string name; wxSize def; // def in DIP
wxSize GetDefaultSize() const override { return def; }
wxSize GetPreferredBitmapSizeAtScale(double s) const override { return def * s; }
wxBitmap GetBitmap(const wxSize& sz) override { // sz is physical
static Slic3r::GUI::BitmapCache cache;
wxBitmap* b = cache.load_svg(name, 0, sz.y, false, wxGetApp().dark_mode());
return b ? *b : wxBitmap();
}
};
// wxBitmapBundle::FromImpl(new OrcaSvgBundleImpl{...}); // takes ownership
```
On macOS `BitmapCache` already multiplies by its own `m_scale`; such an impl would need a cache
whose scale is 1.
**Pitfalls**
- **Rule:** Never call `wxBitmapBundle::FromSVG*` in Orca.
```cpp
auto b = wxBitmapBundle::FromSVGFile(path, wxSize(16, 16)); // Wrong: does not compile here
ScalableBitmap icon(this, "cog", 16); // Right (or create_scaled_bitmap("cog", this, 16))
```
Cite: `include/wx/features.h:96-98`, `deps/wxWidgets/wxWidgets.cmake`.
- **Rule:** Draw a bundle at the bitmap the window needs.
**Why:** the size argument of `GetBitmap` is physical and the result is forced to scale 1.
```cpp
wxBitmap b = bundle.GetBitmap(bundle.GetDefaultSize()); // Wrong: 1x, downscaled on HiDPI
wxBitmap b = bundle.GetBitmapFor(this); // Right; draw at b.GetLogicalSize()
```
Cite: [source] `src/common/bmpbndl.cpp` `wxBitmapBundle::GetBitmap`; `interface/wx/bmpbndl.h:425`.
- **Rule:** Pass a created, non-null window to bundle size queries.
```cpp
bundle.GetPreferredBitmapSizeFor(nullptr); // Wrong: wxDefaultSize, silently
bundle.GetPreferredBitmapSizeFor(this); // Right, after Create()
```
Cite: `interface/wx/bmpbndl.h:399`.
## Orca icon pipeline
Icons are SVG files in `resources/images/`, referenced by **name string without extension**
(`BitmapCache` resolves `Slic3r::var(name + ".svg")`, then `".png"`). The entry points live in
`src/slic3r/GUI/wxExtensions.hpp/.cpp` and `BitmapCache.hpp/.cpp`.
**`create_scaled_bitmap(name, win = nullptr, px_cnt = 16, grayscale, new_color, menu_bitmap,
resize, bitmap2, array_new_color)`**:
```cpp
static BitmapCache cache; // process-wide, never cleared
unsigned h = wxWindow::FromDIP(px_cnt, win) + 0.5f; // static overload: win may be null
bool dark = menu_bitmap (MSW only) ? check_dark_mode() : wxGetApp().dark_mode();
wxBitmap* b = cache.load_svg(name, 0, h, grayscale, dark, new_color, resize ? em_unit(win) * 0.1f : 0);
if (!b) b = cache.load_png(name, 0, h, grayscale, ...); // neither found: throws Slic3r::RuntimeError
#ifdef __WXMSW__
b->SetScaleFactor(win ? win->GetDPIScaleFactor() : wxWindow::FromDIP(100, nullptr) / 100.0);
#endif
return *b;
```
`px_cnt` is the icon height in DIP. A missing icon name throws. `bitmap2 = true` routes to
`create_scaled_bitmap2`/`load_svg2` (semi-transparent filament art, no dark recolour).
Raster per platform [source]:
| Platform | Physical height | Scale factor | Drawn (logical) size |
|---|---|---|---|
| MSW | `FromDIP(px, win)` | `win->GetDPIScaleFactor()` (primary-display ratio when `win` is null) | `FromDIP(px)` px; the tag makes the implicit bundle's DIP size `px`, so wx uses it unscaled |
| macOS | `px × BitmapCache::m_scale` (SVG) | `m_scale`, via `wxBitmap(image, -1, m_scale)` | `px` points |
| GTK3 | `px` | 1 | `px`; at GDK scale 2 it is drawn upscaled (no HiDPI raster on GTK3) |
| GTK2 | `px`, round-tripped through PNG to fix broken alpha (`wxImage_to_wxBitmap_with_alpha`) | — | `px` |
Why the MSW tag matters: at 200 % `FromDIP(16)` is a 32 px raster; untagged (scale 1) its
implicit bundle has a 32-DIP default size and wx doubles it again to 64 px, while tagged 2.0 its
DIP size is 16 and it is used as-is. A raster kept from an older DPI is reselected by the same
rule after a DPI change: a 16 px / scale-1 bitmap stays 16 px at 150 % and is upscaled to 32 px
at 200 % — hence the re-`SetBitmap` in `on_dpi_changed` (§wxBitmapBundle selection).
`BitmapCache` [source]:
- `m_scale` (macOS only) is `mac_max_scaling_factor()` read when the cache is constructed — for
the static cache in `create_scaled_bitmap`, at the first icon load. Despite its name,
`mac_max_scaling_factor()` (`src/slic3r/Utils/MacDarkMode.mm`) loops over the screens but
reads `objectAtIndex:0` each time, i.e. it returns the backing factor of the first screen (the
one with the menu bar). Icons are therefore rasterized once for that screen: 1x on a Retina
laptop whose primary display is a 1x external monitor, with no re-rasterization when windows
move.
- `load_svg` keys the cache by name, height, `m_scale`, `-dm` (dark), `-gs` (grayscale) and
`new_color`; dark-mode recolouring by palette substitution is in
`references/colours-dark-mode.md`.
- `load_png` never applies the Retina factor (`wxImage_to_wxBitmap_with_alpha(image)` with scale
1; resized with `wxIMAGE_QUALITY_BILINEAR`) and gets no dark recolour.
**`ScalableBitmap(parent, icon_name = "", px_cnt = 16, grayscale, resize, bitmap2, new_color)`**
holds `{m_parent, m_icon_name, m_px_cnt, m_grayscale, m_resize, m_bmp}`.
- `msw_rescale()` re-runs `create_scaled_bitmap(m_icon_name, m_parent, m_px_cnt, m_grayscale,
"", false, m_resize)` — it is the DPI path on every platform and also the theme-switch path,
because it re-reads the dark flag. It does **not** re-apply `new_color` or `bitmap2`.
- `m_parent` is a raw pointer; the parent must outlive the `ScalableBitmap`.
- `GetBmpSize()/GetBmpWidth()/GetBmpHeight()` return the scaled (logical) size on Apple and
`GetSize()` elsewhere — equivalent to `GetLogicalSize()` given the scales above.
- `bmp()` returns the bitmap; wx controls that were given it keep their own copy.
**`ScalableButton(parent, id, icon_name, label, size, pos, style = wxBU_EXACTFIT | wxNO_BORDER,
use_default_disabled_bitmap, bmp_px_cnt = 16)`** is a native `wxButton` with a scaled bitmap. An
explicit `size` is stored in em/10 units (`size * 10 / em`) and re-applied as `m * em / 10` in
`msw_rescale()`; `UpdateDarkUI()` is `msw_rescale()`; on GTK it calls `RemoveButtonBorder`. New
code uses `Widgets/Button` instead.
**Orca widgets.** `Button(parent, text, icon = "", style = 0, iconSize = 0, id)` keeps its icon
as a `ScalableBitmap` (20 px when `iconSize <= 0`). `Button::Rescale()` re-rasterizes a **named**
icon, re-measures and re-applies the style; an icon set through `SetIcon(const wxBitmap&)` has no
name and cannot be re-rasterized, so prefer `SetIcon(const wxString&)`. Other widgets' `Rescale()`
follow the same idea (`references/orca-widgets.md`, `references/painting-custom-widgets.md`).
**Menu icons.** `create_menu_bitmap(name)` = `create_scaled_bitmap(name, nullptr, 16, false, "",
true)`: created without a window, so at primary-display DPI on MSW, and on MSW the dark variant
follows `check_dark_mode()`. `msw_rescale_menu` exists only on MSW. Menus
are in `references/popups-menus.md`.
**Pitfalls**
- **Rule:** Pass the real window (not `nullptr`) and re-create the bitmap in `on_dpi_changed`.
**Why:** a null window means primary-display DPI on MSW (wrong raster and wrong scale tag on a
secondary monitor); `em_unit(nullptr)` falls back to the main frame's em for `resize`.
```cpp
m_icon = ScalableBitmap(nullptr, "cog", 16); // Wrong
m_icon = ScalableBitmap(this, "cog", 16); // Right; m_icon.msw_rescale() in on_dpi_changed
```
- **Rule:** After `msw_rescale()`, hand the new bitmap to every control that displays it.
**Why:** `msw_rescale()` replaces only the `ScalableBitmap`'s own `m_bmp`; a `wxStaticBitmap` or
native button keeps the old copy (on MSW wx merely rescales that old raster).
```cpp
m_icon.msw_rescale(); // Wrong alone
m_icon.msw_rescale(); m_bmp_ctrl->SetBitmap(m_icon.bmp()); // Right
```
Cite: `src/slic3r/GUI/AboutDialog.cpp` `AboutDialog::on_dpi_changed`.
- **Rule:** Pass a bare resource name and an explicit DIP height.
**Why:** the third argument is `px_cnt`, not a bitmap type, and the name is resolved as
`var(name + ".svg"|".png")`. `px_cnt = 0` means "the asset's own height": it dereferences
`parent`, and on Retina macOS it stores the physical height, which doubles the icon [source]
`ScalableBitmap::ScalableBitmap`.
```cpp
ScalableBitmap(this, Slic3r::var("logo.png"), wxBITMAP_TYPE_PNG); // Wrong: path + type as px_cnt
ScalableBitmap(this, "logo", 16); // Right
```
- **Rule:** Author new icons as SVG.
**Why:** `load_png` is never Retina-scaled and never dark-recoloured.
- **Rule:** Re-apply `new_color`/`bitmap2` art yourself on rescale.
**Why:** `ScalableBitmap::msw_rescale()` drops both, so a recoloured icon reverts to its default
colours after a DPI or theme change. Keep the colour and rebuild with the full constructor.
## Image lists, art provider, wxImage
**`wxImageList`** (`interface/wx/imaglist.h:37-39, 60-63`): "Use of this class is not recommended
in the new code as it doesn't support showing DPI-dependent bitmaps. Please use
wxWithImages::SetImages() instead"; "the size is specified in physical pixels and must correspond
to the size of bitmaps … that will be added". 3.3 made the size physical and makes calls on an
invalid list assert (`docs/changes.txt:53-56, 85-88`) — silently in Orca's assert-free build.
When a list is unavoidable, `wxBitmapBundle::CreateImageList(win, bundles)` builds one at the
consensus size (public but undocumented, [source] `include/wx/bmpbndl.h`).
```cpp
auto* il = new wxImageList(FromDIP(16), FromDIP(16)); il->Add(bmp_of_other_size); // Wrong
auto sz = bmps[0].GetSize(); auto* il = new wxImageList(sz.x, sz.y); // Right (or SetImages(bundles))
```
**`wxArtProvider`** (`interface/wx/artprov.h:285-330`): `GetBitmap(id, client, size)` returns
that physical size; "applications using wxWidgets 3.1.6 or later should prefer calling
GetBitmapBundle()". `GetBitmapBundle(id, client, size)` takes the DIP default size — "this
implies that wxWindow::FromDIP() must not be used with it". The provider stack is native → Tango
→ std [source] `src/common/artprov.cpp`; in Orca's build Tango contributes nothing (no SVG), so
non-native ids come from low-resolution XPMs. `GetBitmap(id, client, FromDIP(wxSize(16, 16)))` is a
16-physical-pixel bitmap on Retina/GTK3 (drawn upscaled); hand the bundle to the control instead.
```cpp
wxArtProvider::GetBitmapBundle(wxART_WARNING, wxART_OTHER, FromDIP(wxSize(16, 16))); // Wrong: double-scaled on MSW
wxArtProvider::GetBitmapBundle(wxART_WARNING, wxART_OTHER, wxSize(16, 16)); // Right; give the bundle to the control
```
**`wxImage`** resizing (`interface/wx/image.h:28-89`): `wxIMAGE_QUALITY_NEAREST` is no longer an
alias of `NORMAL` since 3.3.0 (`docs/changes.txt:98-99`; `wxIMAGE_QUALITY_FAST` is the speed
synonym). `NORMAL` (default) = bilinear down to an integer multiple, then box average; `HIGH` =
box average when shrinking, bicubic when enlarging; `BILINEAR`, `BICUBIC`, `BOX_AVERAGE` explicit.
High-quality scaling "may not work as expected when using a single mask colour for
transparency" — use alpha (`interface/wx/image.h:1016-1019`). `Rescale` mutates and returns `*this`; `Scale`
returns a copy. `wxInitAllImageHandlers()` registers the compiled handlers: in Orca there is no
TIFF (`wxUSE_LIBTIFF=OFF`), WebP is built in, and SVG is not an image handler.
`wxImage::SetDefaultLoadFlags(0)` drops `Load_Verbose` warnings for images created afterwards
(`interface/wx/image.h:1825-1838`). For pixel-exact glyphs choose `NEAREST`; for icons `BILINEAR` (what
`load_png` uses) or `HIGH`; for photos `HIGH`.
## Window icons
`wxTopLevelWindow::SetIcon` (`interface/wx/toplevel.h:505-525`): "In wxMSW, icon must be either
16x16 or 32x32"; under Wayland it "doesn't do anything … create a `.desktop` file".
`SetIcons(wxIconBundle)` (`interface/wx/toplevel.h:527-546`): MSW wants 16 and 32, "preferably both"; also a
no-op on Wayland, where the icon comes from the desktop file matched by app id
(`wxApp::SetClassName`, `interface/wx/app.h:761-768`). [source] wxMSW picks the small and big
icon from the bundle at the window's DPI-aware system-metric sizes with `FALLBACK_NEAREST_LARGER`
and re-picks them on every DPI change (`src/msw/toplevel.cpp` `wxTopLevelWindowMSW::DoSetIcons`,
`MSWUpdateFontOnDPIChange`), so a bundle with 16/20/24/32/48 px entries stays sharp at any
scale; `SetIcon` is a one-icon bundle. `wxIconBundle(file,
type)` loads every icon in the file (`interface/wx/iconbndl.h:53`); `GetIcon(size, flags)` falls
back per `FALLBACK_SYSTEM` (default), `FALLBACK_NEAREST_LARGER` or `FALLBACK_NONE`
(`interface/wx/iconbndl.h:29-42, 141-158`).
Orca: the main frame takes its icon from the executable's resource on MSW and from
`OrcaSlicer_128px.png` elsewhere (`MainFrame.cpp` `main_frame_icon`).
```cpp
SetIcon(wxIcon(path_to_multi_size_ico, wxBITMAP_TYPE_ICO)); // Wrong: one size, scaled for both slots
SetIcons(wxIconBundle(path_to_multi_size_ico, wxBITMAP_TYPE_ICO)); // Right
```
## Displays and PPI
Window placement on displays is in `references/windows-dialogs.md`; the resolution side:
- `wxDisplay(const wxWindow*)` (since 3.1.2) is the display showing the window, "falling back to
the default display if it is not shown at all or positioned outside of any display"
(`interface/wx/display.h:35-50`). `GetFromWindow(win)` returns `wxNOT_FOUND` when the window is
on no display (`interface/wx/display.h:115-126`). [source] on macOS it picks an intersecting display with the
same backing scale as the window, else `wxNOT_FOUND` (`src/osx/core/display.cpp`
`wxDisplayFactoryMacOSX::GetFromWindow`).
- `GetPPI()` is the scaled resolution, `wxSize(0,0)` if unknown (`interface/wx/display.h:158-168`);
`GetRawPPI()` is unscaled, new in 3.3.2 (`interface/wx/display.h:170-181`); `GetScaleFactor()` = PPI / std
PPI (`interface/wx/display.h:183-194`); `GetStdPPIValue()` is 96, 72 on Apple (`interface/wx/display.h:196-221`).
- `IsConnected()` (3.3.0): objects go stale after a display configuration change; recreate them on
`wxEVT_DISPLAY_CHANGED`, do not cache `wxDisplay` (`interface/wx/display.h:223-241`).
- [source] `wxDisplay(unsigned n)` only `wxASSERT`s the index and then indexes a vector
(`src/common/dpycmn.cpp`), so `(unsigned)wxNOT_FOUND` reads out of bounds in Orca's
assert-free build. `GUI_App::window_pos_sanitize`/`window_pos_center` show the checked pattern.
```cpp
wxDisplay(wxDisplay::GetFromWindow(win)).GetClientArea(); // Wrong: wxNOT_FOUND → out of bounds
wxDisplay(win).GetClientArea(); // Right (or check != wxNOT_FOUND first)
```
## wxFont
**Contract.**
- Sizes are points (1/72 in): `wxFontInfo(double pointSize)` (fractional since 3.1.2,
`interface/wx/font.h:323-330`), or pixels via `wxFontInfo(wxSize)` / `SetPixelSize`, which is
"directly supported only under wxMSW and wxGTK currently; under other platforms a font with the
closest size … is found using binary search" (`interface/wx/font.h:1124-1138`). Prefer
`SetFractionalPointSize` to the legacy integer `SetPointSize` (`interface/wx/font.h:1100-1123`).
- `MakeBold/MakeLarger/MakeSmaller/Scale` mutate; `Bold/Larger/Smaller/Scaled` return copies
(`interface/wx/font.h:853-999`); Larger/Smaller use a factor of 1.2.
- `SetFaceName(face)` (`interface/wx/font.h:1021-1036`): if the face does not exist "the font is invalidated (so
that IsOk() will return false) and false is returned" ([source] `src/common/fontcmn.cpp`
`wxFontBase::SetFaceName` → `UnRef()`; the check is `wxFontEnumerator::IsValidFacename`, which
caches the face list on first use for the session (`src/common/fontenumcmn.cpp`); only the
Unix `AddPrivateFont` invalidates that cache).
- `wxFont::AddPrivateFont(path)` (`interface/wx/font.h:719-751`):
- macOS: does nothing but check that the file exists inside `Resources/Fonts` of the bundle;
the app must ship it there and set `ATSApplicationFontsPath`. [source] it compares the path
with `GetResourcesDir() + "/Fonts"` and `wxLogError`s otherwise (`src/osx/fontutil.cpp`).
- MSW: "must be called before any wxGraphicsContext objects have been created";
[source] `AddFontResourceEx(FR_PRIVATE)`, remembered for GDI+ (`src/msw/font.cpp`).
- Unix: needs Pango ≥ 1.38, else returns false and logs. [source] creates one fontconfig config
on the first call, then on **every** call adds the file, re-installs the config into Pango's
font map (`pango_fc_font_map_set_config`) and invalidates the face-name cache
(`src/gtk/font.cpp` `wxFontBase::AddPrivateFont`).
**DPI** [source]:
- On MSW a `wxFont` stores points; `SetFractionalPointSize` computes `lfHeight` at the primary
screen PPI and relies on `WXAdjustToPPI()` later (`src/msw/font.cpp`
`wxNativeFontInfo::SetFractionalPointSize`).
- `wxWindow::SetFont/GetFont` adjust the window's copy to its own PPI
(`src/common/wincmn.cpp` `wxWindowBase::SetFont/GetFont` → `WXAdjustFontToOwnPPI`), and
non-TLW window fonts are re-adjusted on DPI change (`src/msw/window.cpp`
`MSWUpdateFontOnDPIChange`; `src/msw/toplevel.cpp` overrides it for TLWs to re-select icons
only).
- `wxMSWDCImpl::SetFont` adjusts to the DC's window PPI when it has a window (`src/msw/dc.cpp`).
A `wxMemoryDC` has none; `wxMemoryDCImpl` overrides `SetFont`/`GetPPI` instead and adjusts every
font to 96 × the scale factor of the bitmap selected into it, re-applied at each `SelectObject`
(`src/msw/dcmemory.cpp` `wxMemoryDCImpl::DoSelect/SetFont/GetPPI`). With a scale-1 bitmap text is
laid out at 100 % whatever the monitor and whatever font object is passed: `GetFont()` of a
150 % window is re-adjusted down to 96 PPI too. A `wxGCDC`/`wxGraphicsContext` created from that
memory DC follows the same rule: the GDI+ context has no window, and its DPI is 96 × the bitmap's
scale factor (`src/msw/graphics.cpp` `wxGDIPlusRenderer::CreateContext(const wxMemoryDC&)`,
`wxGDIPlusContext::GetDPI`) — which is what `StaticBox::render`'s MSW anti-aliasing block hands to
`doRender` (`references/painting-custom-widgets.md`).
- So a global `wxFont` such as `Label::Body_14` is DPI-correct on any monitor when set on a window,
a window DC, or a memory DC whose bitmap was given the window's `GetDPIScaleFactor()` before it
was selected (`CreateWithDIPSize`/`CreateWithLogicalSize`, or `SetScaleFactor` then
`SelectObject`, as `get_extruder_color_icon` in `wxExtensions.cpp` does).
- On macOS and GTK a point is a fixed number of logical pixels (1 on macOS, 4/3 at 96 DPI on
GTK), so fonts need no DPI handling.
**Pitfalls**
- **Rule:** Check `IsOk()` after `SetFaceName` and fall back.
```cpp
font.SetFaceName("X"); dc.SetFont(font); // Wrong: invalid font if X is missing
if (!font.SetFaceName("X")) font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); // Right (Label::sysFont pattern)
```
Cite: `interface/wx/font.h:1030-1032`.
- **Rule:** On MSW, give a memory-DC bitmap the window's scale factor before selecting it; the
font object does not fix memory-DC text size.
**Why:** the MSW memory DC sizes text for 96 × the selected bitmap's scale factor, so text drawn
into a scale-1 `FromDIP`-sized bitmap comes out at 100 % size, too small at 125–200 %, even with
the window's own `GetFont()`.
```cpp
wxBitmap bmp(FromDIP(wxSize(24, 24))); // Wrong: scale 1
wxMemoryDC mdc(bmp); mdc.SetFont(GetFont()); // text at 96 PPI on a 150 % monitor
wxBitmap bmp; bmp.CreateWithDIPSize(wxSize(24, 24), GetDPIScaleFactor()); // Right
wxMemoryDC mdc(bmp); mdc.SetFont(Label::Body_12); // text at 144 PPI
```
Cite: [source] `src/msw/dcmemory.cpp` `wxMemoryDCImpl::DoSelect`, `wxMemoryDCImpl::SetFont`;
`src/slic3r/GUI/wxExtensions.cpp` `get_extruder_color_icon` (`SetScaleFactor` before
`SelectObject`).
## Orca fonts: Label table, sysFont, initSysFont
**The table** (`src/slic3r/GUI/Widgets/Label.hpp/.cpp`): static fonts `Label::Head_48, 32, 24, 20,
18, 16, 15, 14, 13, 12, 11, 10` (bold) and `Label::Body_16, 15, 14, 13, 12, 11, 10, 9, 8` (regular),
built by `Label::initSysFont()`. Convention: `Head_*` for titles and section headers, `Body_*` for
content; `Body_14` is the dialog workhorse (`SetFont(Label::Body_14)` on dialogs and controls),
`Body_12`/`Body_13` for dense secondary text. `Label` widgets default to `Body_14`.
**`Label::sysFont(size, bold)`**:
```cpp
#ifndef __APPLE__
size = size * 4 / 5; // integer arithmetic
#endif
wxString face = "HarmonyOS Sans SC";
if (wxLocale::GetSystemLanguage() == wxLANGUAGE_KOREAN) face = "NanumGothic";
wxFont font{size, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, bold ? wxFONTWEIGHT_BOLD : wxFONTWEIGHT_NORMAL, false, face};
font.SetFaceName(face);
if (!font.IsOk()) { font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); if (bold) font.MakeBold(); font.SetPointSize(size); }
```
- The 4/5 factor approximates the 72-vs-96 PPI difference (a point is 1 logical px on macOS,
4/3 px at 96 DPI elsewhere), so the same `Body_N` looks alike on macOS and MSW/Linux. That is
why literal point sizes are wrong: 12 pt is 12 logical px on macOS but 16 px at 100 % on MSW, so
`wxFont(12, …)` looks a third larger there.
- Integer truncation makes some entries identical on MSW/Linux: `Body_16`/`Body_15` and
`Head_16`/`Head_15` are 12 pt, `Body_11`/`Body_10` and `Head_11`/`Head_10` are 8 pt. Pick the
next step down when a visible difference matters.
- The Korean face is keyed on the **system** language (`wxLocale::GetSystemLanguage()`), not on
Orca's UI language.
- The statics are created once and stay DPI-correct on MSW because wx adjusts fonts per window,
window DC and memory-DC bitmap scale (§wxFont).
**`Label::initSysFont()`** runs near the start of `GUI_App::on_init_inner()` (after the log
target and the macOS deep-link handler), before any window or `wxGraphicsContext` exists, which
satisfies the MSW `AddPrivateFont` rule. On MSW and Linux it registers
`resources/fonts/HarmonyOS_Sans_SC_{Bold,Regular}.ttf` and `NanumGothic-{Regular,Bold}.ttf`
with `wxFont::AddPrivateFont` (`wxUSE_PRIVATE_FONTS=ON` in `deps/wxWidgets/wxWidgets.cmake`). On
Linux it skips all four calls when fontconfig already knows both families (e.g. installed
system-wide in a Flatpak): Orca's comment records that `AddPrivateFont` triggers a Pango crash in
`ensure_faces()` on Pango ≥ 1.48 because `FcConfigAppFontAddFile` invalidates Pango's cached font
map. macOS never calls it: the bundle plist sets `ATSApplicationFontsPath = fonts/`, and wx's
macOS `AddPrivateFont` would reject any path outside `Resources/Fonts`.
**App fonts.** `GUI_App::init_fonts()`/`update_fonts()` derive `normal_font()`, `small_font()`,
`bold_font()`, `link_font()` and `code_font()` from the `Label` statics (`update_fonts` uses
`Body_14`; `init_fonts` overrides small/bold sizes on macOS; the code font is
`wxFONTFAMILY_TELETYPE` at the small size). The splash screen's `scale_font` works around MSW
`SetFractionalPointSize` using the primary PPI by computing `lfHeight` for the splash's own DPI.
**macOS notes.** `DPIAware` and the main frame skip their default-font `SetFont` on macOS ("name
cutting in ObjectList"); that is about the window default font, not a ban — dialogs set
`Label::Body_14` normally. `OG_CustomCtrl` draws a focused URL label underlined but not bold on
macOS (workaround for a Big Sur bold-font rendering issue).
**Pitfalls**
- **Rule:** Use the `Label` table, never hard-coded point sizes or ad-hoc faces.
```cpp
title->SetFont(wxFont(12, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD)); // Wrong
title->SetFont(Label::Head_12); // Right
```
Cite: `src/slic3r/GUI/Widgets/Label.cpp` `Label::sysFont`.
- **Rule:** Do not call `wxFont::AddPrivateFont` for Orca's fonts outside `Label::initSysFont`.
**Why:** macOS rejects paths outside `Resources/Fonts` (fonts load through the plist); MSW
requires the call before any graphics context, and [source] does not invalidate the
session-cached face list, so a `SetFaceName` check made before registration keeps rejecting the
private face; on Linux every call re-installs Pango's fontconfig map, which crashes Pango ≥ 1.48.
Cite: `interface/wx/font.h:719-751`; `Label::initSysFont`.
@@ -0,0 +1,958 @@
# Events: binding, dispatch, posting and deferred calls
How wx 3.3.2 finds, runs, propagates, queues and drops event handlers, and how OrcaSlicer code
binds, emits and defers. Read it before writing any `Bind`/`Unbind`, `Skip()`, `ProcessEvent`,
`wxPostEvent`/`wxQueueEvent` or `CallAfter`, before defining a custom event, and when debugging a
handler that never runs, runs twice, runs on a dead object, or swallows a widget's own behaviour.
Contents: [Rules](#rules) · [1 Dispatch order](#1-dispatch-order) ·
[2 Bind and Unbind](#2-bind-and-unbind) · [3 Static event tables](#3-static-event-tables) ·
[4 Skip discipline](#4-skip-discipline) · [5 Propagation](#5-propagation) ·
[6 Emitting events synchronously](#6-emitting-events-synchronously) ·
[7 Posting and queueing](#7-posting-and-queueing) · [8 CallAfter](#8-callafter-and-the-liveness-rule) ·
[9 Custom events and payloads](#9-custom-events-and-payload-ownership) · [10 Ids](#10-window-and-event-ids) ·
[11 UPDATE_UI](#11-wxevt_update_ui) · [12 Idle events](#12-idle-events) ·
[13 Event filters](#13-event-filters) · [14 Pushed handlers and blockers](#14-pushed-handlers-wxeventblocker-setevthandlerenabled) ·
[15 Exceptions](#15-exceptions-in-handlers) · [16 How Orca widgets emit events](#16-how-orca-widgets-emit-events)
Build fact that shapes every pitfall below: OrcaSlicer builds wx with `-DwxBUILD_DEBUG_LEVEL=0`
(`deps/wxWidgets/wxWidgets.cmake`) and `libslic3r_gui` with `-DwxDEBUG_LEVEL=0`
(`src/slic3r/CMakeLists.txt`). `wxASSERT`/`wxFAIL` compile to nothing and `wxCHECK_*` return silently
(`include/wx/debug.h:314-324, 356-382`). Every event misuse wx would assert on in a debug build
(pushed handler not popped, `Unbind` of an unknown entry, `RemoveFilter` of an unknown filter,
out-of-range window id, bad `FilterEvent` return) is a silent no-op or a later crash in Orca.
## Rules
1. New code binds dynamically (`Bind` with a lambda or method + handler); never add a static event
table (existing widget-internal tables stay where they are). Take the event by reference (`auto&`,
`wxXxxEvent&`). → §2, §3, §4
2. Later-bound handlers run first, and every dynamic handler runs before the static table. A handler
you bind on an Orca widget, a wx control or a window whose base class already bound the same
event must `Skip()` or the internal handler never runs. → §4, §16
3. `Skip()` every non-command event you do not fully replace: focus, size, key-down, unhandled
`wxEVT_CHAR_HOOK` keys, DPI and system-colour changes, TLW activation, mouse events on custom
widgets. Command events are normally not skipped. → §4
4. A binding on an object other than `this` (parent, TLW, canvas, app) must not outlive the handler:
method + `wxEvtHandler` sink is removed automatically but late; lambdas and non-`wxEvtHandler`
sinks are never removed. Unbind in the destructor, or use `EventGuard`. → §2
5. `Unbind` with the same emitter, event type, id range and the same functor object (or the same
method + handler). A lambda literal never matches. → §2
6. `Bind`/`Unbind` on the main thread only; binding twice registers twice. → §2
7. Only command events, `wxEVT_CHAR_HOOK` and Orca's `SimpleEvent`/`Event<T>` family propagate; they
stop at dialogs, and at popups on MSW and macOS but not on wxGTK. Bind on the emitting control, or
on the dialog/popup itself. → §5
8. `wxEVT_DESTROY` bubbles from children: compare `GetEventObject()` with the window. A TLW's destroy
event arrives after its derived members are destroyed. → §5
9. Emit a window's event with `ProcessWindowEvent()` (or `HandleWindowEvent()` from native
callbacks), never `win->ProcessEvent()`; forward to another handler with `ProcessEventLocally()`. → §6
10. Construct the event class declared for the type, and set the event object and id. → §6, §9
11. A handler may destroy the emitter: never touch `this` after a synchronous emit that can lead to
destruction; defer destruction instead. → §6
12. `AddPendingEvent`/`wxPostEvent` only on the main thread; `QueueEvent`/`wxQueueEvent` with a heap
event from any thread. A posted event is dispatched on the object you posted to, bypassing its
pushed handlers. → §7
13. Events and `CallAfter`s queued on a handler are deleted with it. A worker must only post to a
target that outlives the worker (in Orca: `wxGetApp()`), or be stopped first. → §7
14. Order is FIFO only per target; a `CallAfter` that re-queues itself starves the UI. Use `wxTimer`
for retries and polling. → §7
15. Deferred work runs inside any nested loop (`ShowModal`, `wxYield`) and is held back by some
native modal loops; design for reentrancy. → §7
16. Every deferred lambda re-checks the liveness of everything it touches, except the handler it was
queued on. Never capture a bare `this` or `wxDataViewItem` and trust it. → §8
17. `CallAfter` captures are copied: capture by value, `shared_ptr` for move-only state; by-reference
captures only in a blocking marshal. → §8
18. Window work requested from a mouse handler or a webview script-message callback goes through
`CallAfter`. → §8
19. `wxDECLARE_EVENT` in the header, `wxDEFINE_EVENT` in exactly one `.cpp`. → §9
20. A custom event class derived from a concrete wx event overrides `Clone()` and copies every
payload member. Client objects on a `wxCommandEvent` are not owned by the event. → §9
21. Use `wxID_ANY` for controls and bind on the control; do not filter by id on an ancestor. → §10
22. `wxEVT_UPDATE_UI` handlers run every idle pass for every window: keep them trivial. → §11
23. Do not use idle events for periodic work; hidden panels still receive them. → §12
24. `FilterEvent` runs for every event: return `Event_Skip` fast. → §13
25. Pop or remove every pushed handler before its window dies; nest `wxEventBlocker` scopes and push
nothing else inside one. → §14
26. Catch exceptions inside handlers; one that escapes ends the session. → §15
---
## 1. Dispatch order
**Contract.** `wxEvtHandler::ProcessEvent()` searches in this order (`interface/wx/event.h:567-625`,
`docs/doxygen/overviews/eventhandling.h:451-515`):
| Step | What runs | Notes |
|---|---|---|
| 0 | `wxApp::FilterEvent()` and other `wxEventFilter`s (LIFO) | Anything but `Event_Skip` (-1) stops here. Called once per event, not again as it propagates ([source] `src/common/event.cpp:1537-1552`) |
| 1 | `TryBefore()` | Validators on windows |
| 2 | — | If `SetEvtHandlerEnabled(false)`, skip to step 5 (the overview's wording; the interface doc's "skips to step (7)" is inaccurate — [source] `TryHereOnly` returns `false` and `DoTryChain` still runs, `src/common/event.cpp:1582-1591, 1644-1648`) |
| 3 | Dynamic table (`Bind`) | **Most recently bound first**, before the static table (`docs/doxygen/overviews/eventhandling.h:474-483`) |
| 4 | Static event table | Macro order, derived class before base class |
| 4a | Implicit `CallAfter` entry | Runs a queued `wxAsyncMethodCallEvent` only when its event object is this handler ([source] `src/common/event.cpp:1644-1667` `TryHereOnly`) |
| 5 | Next handlers in the chain | For windows: the pushed-handler stack (§14) |
| 6 | `TryAfter()` | Windows propagate to the parent (§5); finally `wxTheApp->ProcessEvent()` |
`ProcessEvent` returns `true` iff some handler ran and did not call `Skip()` (`interface/wx/event.h:619-622`).
Before each handler call wx resets the flag with `event.Skip(false)` ([source]
`src/common/event.cpp:1443-1475` `ProcessEventIfMatchesId`), so a skip in one handler does not carry over to the
next: every handler that wants processing to continue must call `Skip()` itself.
A handler entry matches when the event type matches and the bound id is `wxID_ANY`, or equals the
event id, or the event id falls in `[id, lastId]` (`src/common/event.cpp:1443-1475`).
---
## 2. Bind and Unbind
**Contract.**
- Forms: `Bind(tag, functor, id = wxID_ANY, lastId = wxID_ANY, userData = nullptr)` and
`Bind(tag, &Class::method, handlerPtr, id, lastId, userData)` (`interface/wx/event.h:876-957`). The
method form accepts "an arbitrary method (doesn't need to be from a wxEvtHandler derived class)";
the handler pointer "must always be specified". `userData`: "wxWidgets will take ownership of
this pointer" — deleted when the handler is unbound or at program termination.
- Handlers can be bound at any time and removed with `Unbind`
(`docs/doxygen/overviews/eventhandling.h:237-252`). `Connect()` is the legacy form: "please use
[Bind] in any new code" (`interface/wx/event.h:705-706`).
- Lifetime (`docs/doxygen/overviews/eventhandling.h:332-335`), for a handler object not derived from `wxEvtHandler`: "the
lifetime of `myFrameHandler` must be greater than that of `MyFrame` object -- or at least it needs
to be unbound before being destroyed".
- `Unbind` "can only unbind functions, functors or methods which have been added using the Bind<>()
method. There is no way to unbind functions bound using the (static) event tables." Its note:
"functors are compared by their address which, unfortunately, doesn't work correctly if the same
address is reused for two different functor objects. Because of this, using Unbind() is not
recommended if there are multiple functors using the same eventType and id and lastId as a wrong
one could be unbound" (`interface/wx/event.h:958-997`).
**Mechanics** [source]:
- `Bind` takes `const Functor&`, stores a **copy** of the functor and records the **address of the
object you passed** (`include/wx/event.h:524-570` `wxEventFunctorFunctor`, `:3951-3961`). `Unbind`
matches on that address plus the functor type. So a lambda is unbindable when the same lvalue
(a member `std::function`, a named lambda that stays at one address, heap storage) is passed to
both calls; an inline lambda literal never matches. Method + handler pairs match by value and
always work. Captures must be copyable.
- `DoUnbind` requires `entry->m_id == id`; only `lastId == wxID_ANY` and `eventType == wxEVT_NULL`
act as wildcards (`src/common/event.cpp:1806-1824`). A handler bound with `ctrl->GetId()` is not
removed by `Unbind(evt, fn)` (id defaults to `wxID_ANY`), and vice versa. `Unbind` on a different
emitter than the one you bound on returns `false` silently.
- `DoBind` always appends (`src/common/event.cpp:1769-1803`): binding the same handler twice runs it twice.
- No locking in `DoBind`/`DoUnbind`: main thread only.
**Lifetime by handler kind** [source]:
| Handler | Removed automatically when the handler object dies? |
|---|---|
| `src->Bind(evt, &C::m, sink)` where `sink` is a `wxEvtHandler` (any window) other than `src` | Yes. `DoBind` registers a `wxEventConnectionRef` on the sink (`src/common/event.cpp:1793-1802`); the sink's `~wxTrackable` calls `OnSinkDestroyed`, which deletes the entries (`include/wx/event.h:4184-4200`, `src/common/event.cpp:2022-2042`) |
| Lambda or functor (`[this]{…}`), free function | No. `GetEvtHandler()` is null for functors |
| Method of a class not derived from `wxEvtHandler` (`GLCanvas3D`, `Plater::priv`) | No |
| Handlers bound on `this` itself | Deleted with `this` (`~wxEvtHandler`, `src/common/event.cpp:1204-1245`) |
The automatic removal runs **late**: `wxEvtHandler` derives from `wxObject, wxTrackable`
(`include/wx/event.h:3705-3706`), so `~wxTrackable` runs after the derived destructor, after member
destruction, and after the port destructor has destroyed the native window and the children
(`src/osx/window_osx.cpp` `~wxWindowMac`, `src/msw/window.cpp` `~wxWindowMSW`). Events the source
emits during that teardown (activation, focus, size, show) still reach the half-destroyed sink.
Unbind explicitly in the sink's destructor whenever the source can fire while the sink dies.
**Usage.**
```cpp
// Method + wxEvtHandler sink on another window: removed on sink death (late) — unbind early anyway
m_parent->Bind(wxEVT_DPI_CHANGED, &DialogButtons::on_dpi_changed, this);
DialogButtons::~DialogButtons() { m_parent->Unbind(wxEVT_DPI_CHANGED, &DialogButtons::on_dpi_changed, this); }
// Lambda on another object: unbindable only through the same stored object
std::function<void(wxShowEvent&)> m_on_show = [this](wxShowEvent& e) { e.Skip(); /* ... */ };
top->Bind(wxEVT_SHOW, m_on_show);
top->Unbind(wxEVT_SHOW, m_on_show); // same object → matches
```
**OrcaSlicer.**
- `EventGuard` (`src/slic3r/GUI/GUI_Utils.hpp`) is the RAII form: it stores the functor (or method +
handler) on the heap, so its address is stable and the destructor's `Unbind` matches. Use it when
the emitter outlives the handler object, or to drop a binding before the owner's base destructor
runs. The emitter must still be alive when the guard dies.
```cpp
EventGuard on_idle_evt; // member of PlaterWorker (src/slic3r/GUI/Jobs/PlaterWorker.hpp)
, on_idle_evt(plater, wxEVT_IDLE, [this](wxIdleEvent&) { process_events(); })
EventGuard on_progress_evt; // PrintHostQueueDialog binds on itself, unbinds during member destruction
, on_progress_evt(this, EVT_PRINTHOST_PROGRESS, &PrintHostQueueDialog::on_progress, this)
```
- `GLCanvas3D` is not a `wxEvtHandler`: its method bindings on its `wxGLCanvas` are never removed
automatically, so `GLCanvas3D::bind_event_handlers`/`unbind_event_handlers` are a mandatory pair,
and `Plater::priv::set_current_panel` unbinds the canvas of the panel being left before binding
the active one.
- **Binding on another window (popups and child widgets).** When an object binds on a window other
than itself — typically the top-level parent — unbind in its destructor.
`PopupWindow::Create` binds `wxEVT_ACTIVATE` on its top parent (wxGTK), `BindUnfocusEvent()` binds
`wxEVT_ACTIVATE`/`wxEVT_ICONIZE`/`wxEVT_SHOW` (wxMSW), and `PopupWindow::~PopupWindow` unbinds them
(`src/slic3r/GUI/Widgets/PopupWindow.cpp`). The static `GetTopParent` there returns the first
`wxNonOwnedWindow` strictly above its argument (or the root), so for a popup inside another popup
the handlers sit on the outer popup. Because `Create` passes `parent` and the destructor passes
`this`, the two calls resolve to different windows when `parent` is itself a TLW or popup that has a
parent: the wxGTK `Unbind` then misses silently (§2 Mechanics). These method + `this` bindings
would be removed by wx when the popup dies, but only after the teardown window described above; a
lambda binding would never be removed. Popup dismissal itself: see `references/popups-menus.md`.
**Pitfalls.**
- **Rule:** A lambda that captures `this` and is bound on a longer-lived object must be unbound
before `this` dies.
**Why:** functor bindings are not tracked; the emitter later calls into freed memory.
```cpp
// Wrong: dangles after this panel is destroyed
GetParent()->Bind(wxEVT_SHOW, [this](wxShowEvent& e) { e.Skip(); refresh(); });
// Right: method + wxEvtHandler sink, and unbind in the destructor
GetParent()->Bind(wxEVT_SHOW, &MyPanel::on_parent_show, this);
MyPanel::~MyPanel() { GetParent()->Unbind(wxEVT_SHOW, &MyPanel::on_parent_show, this); }
```
Cite: `src/common/event.cpp:1793-1802, 2022-2042`; `docs/doxygen/overviews/eventhandling.h:332-335`.
- **Rule:** Unbind with the same object, id and emitter you bound with.
**Why:** a temporary lambda has a new address; a missing id does not match an id-bound entry. Both
return `false` and leave the handler bound — silently.
```cpp
// Wrong
btn->Bind(wxEVT_BUTTON, fn, btn->GetId()); btn->Unbind(wxEVT_BUTTON, fn);
win->Unbind(wxEVT_SIZE, [this](wxSizeEvent& e) { e.Skip(); });
// Right
btn->Unbind(wxEVT_BUTTON, fn, btn->GetId());
```
Cite: `include/wx/event.h:524-570`; `src/common/event.cpp:1806-1824`.
- **Rule:** Move-only captures do not compile in `Bind` or `CallAfter`; use `std::shared_ptr`.
Cite: `include/wx/event.h:524-570` (functor stored by copy).
---
## 3. Static event tables
**Contract.** `wxDECLARE_EVENT_TABLE()` in the class, `wxBEGIN_EVENT_TABLE(Class, Base)` …
`wxEND_EVENT_TABLE()` in the `.cpp`; entries are searched in macro order, then the base class table
(`interface/wx/event.h:567-625` step 5). They run **after** every dynamic handler for the same event.
**Multiple inheritance.** "it is imperative that the wxEvtHandler(-derived) class is the first class
inherited such that the `this` pointer for the overall object will be identical to the `this`
pointer of the wxEvtHandler portion" (`interface/wx/event.h:376-380`). Put the wx base first in
`class X : public wxPanel, public Other`.
**OrcaSlicer.** New code binds with lambdas (`[this](auto& e)`) or method + `this`. The core widgets
keep their internal handlers in static tables (`DECLARE_EVENT_TABLE()` in `Widgets/StaticBox.hpp`,
`Widgets/Button.hpp`; tables in `Button.cpp`, `TextInput.cpp`, `SpinInput.cpp`, `ComboBox.cpp`,
`DropDown.cpp`, `TabCtrl.cpp`). That is why `Skip()` in user handlers matters (§4): a user `Bind` on
`Button` for `wxEVT_LEFT_DOWN` runs before `Button::mouseDown`. Do not add new static tables; when
changing such a widget, keep its internal handlers where they are.
---
## 4. Skip discipline
**Contract.** `wxEvent::Skip` (`interface/wx/event.h:238-252`): "Without Skip() (or equivalently if
Skip(false) is used), the event will not be processed any more. If Skip(true) is called, the event
processing system continues searching for a further handler function for this event, even though it
has been processed already in the current handler. In general, it is recommended to skip all
non-command events to allow the default handling to take place. The command events are, however,
normally not skipped as usually a single command such as a button click or menu item selection must
only be processed by one handler."
| Event | Rule | Cite |
|---|---|---|
| `wxEVT_SET_FOCUS` / `wxEVT_KILL_FOCUS` | "should almost invariably call wxEvent::Skip()"; a KILL_FOCUS handler "must not call wxWindow::SetFocus()" — defer it (`CallAfter`) | `interface/wx/event.h:3410-3416` |
| `wxEVT_SIZE` | "Sizers … rely on size events to function correctly … call Skip on all size events you catch" | `interface/wx/event.h:5058-5060` |
| `wxEVT_KEY_DOWN` | Not skipping suppresses `wxEVT_CHAR` for that key and "may also prevent accelerators … from working" | `interface/wx/event.h:1454-1461` |
| `wxEVT_CHAR_HOOK` | Propagates upward; handled (not skipped) → no `KEY_DOWN`/`CHAR`. Skip every key you do not handle | `interface/wx/event.h:1485-1508`; keyboard order: `references/mouse-keyboard-focus.md` |
| `wxEVT_PAINT` | The handler "must create a wxPaintDC"; skip only if default painting must also run | `interface/wx/event.h:2274-2285`; `references/painting-custom-widgets.md` |
| `wxEVT_DPI_CHANGED` | "should almost always call event.Skip() … as many controls rely on processing this event"; a TLW handler may deliberately not skip to suppress the default resize | `interface/wx/event.h:3571-3583` |
| `wxEVT_SYS_COLOUR_CHANGED` | The default handler propagates it to children; a TLW handler must Skip, call the base, or forward | `interface/wx/event.h:1950-1955` |
| `wxEVT_ACTIVATE` on a TLW | MSW: `wxTopLevelWindowMSW::OnActivate` (static table) saves and restores the last focused child; macOS: `wxFrame::OnActivate` (static table) installs the frame's menubar. A non-skipping dynamic handler disables both | [source] `src/msw/toplevel.cpp` `wxTopLevelWindowMSW::OnActivate`, `src/osx/carbon/frame.cpp` `wxFrame::OnActivate`; `references/popups-menus.md` §15 |
| Mouse/key events on custom widgets | Dynamic handlers run before the widget's static table: not skipping disables the widget's own press/release/capture logic | [source] `src/common/event.cpp:1644-1667` |
| Command events | Normally not skipped; `Skip()` lets the event continue to the static table, the next handler, then the parent | `interface/wx/event.h:247-251` |
wx's own controls bind some internals dynamically in their constructors — e.g.
`wxBookCtrlBase`, `wxComboCtrlBase` and `wxTreeCtrlBase` bind `wxEVT_DPI_CHANGED`
(`src/common/bookctrl.cpp:60`, `src/common/combocmn.cpp:839`, `src/common/treebase.cpp:172`) — so a
non-skipping handler you bind on
such a control later starves its rescale. DPI propagation order: `references/dpi-bitmaps-fonts.md`.
**OrcaSlicer — deliberate deviations.**
- `DPIAware<P>` (`src/slic3r/GUI/GUI_Utils.hpp`) binds `wxEVT_DPI_CHANGED` on the TLW (not on macOS)
**without** Skip: Orca rescales itself in `rescale()` and suppresses wx's default TLW resize, which
the doc allows. Its `wxEVT_SYS_COLOUR_CHANGED` handler Skips on macOS and Linux but not on Windows,
where the theme is app-forced.
- Because `DPIAware` binds in its own constructor, everything a derived dialog binds on itself later
runs first. `DialogButtons` binds the parent's `wxEVT_DPI_CHANGED` and calls `Skip()`, so it runs
before `DPIAware`'s handler and lets it run. Likewise `DPIAware` maps Esc to `Close()` in a
`wxEVT_CHAR_HOOK` handler on every `DPIDialog`: a derived dialog's own `wxEVT_CHAR_HOOK` handler must
`Skip()` every key it does not consume, or Esc stops closing the dialog (`CloneDialog`'s Enter→OK
hook is the model).
**Pitfalls.**
- **Rule:** Take the event parameter by reference.
**Why:** the functor form compiles with a by-value parameter; the lambda then receives a copy
(`include/wx/event.h:534-545` calls `m_handler(static_cast<EventArg&>(event))`), and `Skip()` on the
copy does nothing — the original counts as handled.
```cpp
// Wrong
ctrl->Bind(wxEVT_KILL_FOCUS, [](wxFocusEvent e) { e.Skip(); commit(); });
// Right
ctrl->Bind(wxEVT_KILL_FOCUS, [](wxFocusEvent& e) { e.Skip(); commit(); });
```
- **Rule:** A handler you add to an Orca widget, for any event the widget handles internally, calls
`Skip()`.
**Why:** your later `Bind` runs first (LIFO, dynamic before static); without Skip the widget's own
handler never runs.
```cpp
// Wrong: CheckBox's internal toggle handler never runs, the bitmap and half-state go stale
cb->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { save(); });
// Right
cb->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { e.Skip(); save(); });
```
The same applies to `GetTextCtrl()->Bind(wxEVT_TEXT_ENTER / wxEVT_KILL_FOCUS / wxEVT_TEXT, …)` on
`TextInput`/`SpinInput`/`ComboBox`, and to raw mouse/key binds on `Button` and other `StaticBox`
widgets. Cite: `src/slic3r/GUI/Widgets/CheckBox.cpp` `CheckBox::CheckBox`;
`src/slic3r/GUI/Preferences.cpp` ("let CheckBox::update() refresh the bitmap").
- **Rule:** Never call `Skip()` (or touch members) after the handler deleted `this` (§6).
---
## 5. Propagation
**Contract** (`docs/doxygen/overviews/eventhandling.h:536-568`):
- "the events of the classes deriving from wxCommandEvent are propagated by default to the parent
window if they are not processed in this window itself … all event classes not deriving from
wxCommandEvent … do not propagate upward." Mouse, motion, enter/leave, size, paint and key events
stay at the window — except `wxEVT_CHAR_HOOK`, which propagates (`interface/wx/event.h:1485-1494`).
- "the event propagation stops when it reaches the parent dialog, if any … The events do propagate
beyond the frames, however." `SetExtraStyle(wxWS_EX_BLOCK_EVENTS)` blocks at any window, or clears
the default on a dialog (`interface/wx/window.h:264-270`).
- The mechanism is `m_propagationLevel` (`interface/wx/event.h:263-279`): `wxEVENT_PROPAGATE_NONE` by default,
`wxEVENT_PROPAGATE_MAX` for command events; any event class may set it in its constructor.
`StopPropagation()` returns the old level for `ResumePropagation()` (`interface/wx/event.h:255-260`);
`wxPropagationDisabler` and `wxPropagateOnce` are RAII helpers (`interface/wx/event.h:346-367`).
**Source facts** [source]:
- **Popups block propagation on MSW and macOS, not on wxGTK.** `wxPopupWindowBase::Create` sets
`wxWS_EX_BLOCK_EVENTS` (`src/common/popupcmn.cpp:129-138`; not in the overview). The MSW and macOS
`wxPopupWindow::Create` call it (`src/msw/popupwin.cpp`, `src/osx/carbon/popupwin.cpp`, which the
Cocoa build uses); the wxGTK one never does (`src/gtk/popupwin.cpp` `wxPopupWindow::Create`). So a
`wxEVT_BUTTON` from a control inside a popup (`wxPopupTransientWindow`, Orca `PopupWindow`,
`DropDown`) that no handler inside consumes stops at the popup on MSW/macOS but bubbles on to the
popup's parent and its ancestors on GTK.
- `wxWindowBase::TryAfter` does not propagate to a parent that `IsBeingDeleted()`
(`src/common/wincmn.cpp:3499-3522`). After a block or the top of the chain, the event still goes
to `wxTheApp` — except `wxEVT_IDLE` (`src/common/event.cpp:1483-1498` `DoTryApp`).
- `wxWindowDestroyEvent` and `wxWindowCreateEvent` derive from `wxCommandEvent`
(`interface/wx/event.h:4554, 2255`): a `wxEVT_DESTROY` handler on a dialog also receives every
child's destroy event (unless the dialog itself is being deleted).
- `wxUpdateUIEvent` is a command event: every unhandled update-UI event walks the parent chain up to
the first blocking window (a dialog; a popup on MSW/macOS) or the root, and then `wxApp` (§11).
**Destroy-event timing** [source]. `wxWindowBase::Destroy()` of a child sends `wxEVT_DESTROY` before
`delete this` (`src/common/wincmn.cpp:559-573`). For a TLW, and for any `delete`, the event is sent
from a base destructor (`src/common/framecmn.cpp` `~wxFrameBase`, `src/msw/toplevel.cpp`
`~wxTopLevelWindowMSW`, `src/gtk/toplevel.cpp` `~wxTopLevelWindowGTK`, `src/osx/dialog_osx.cpp`
`~wxDialog`, `src/osx/nonownedwnd_osx.cpp` `~wxNonOwnedWindow`, `src/osx/window_osx.cpp` `~wxWindowMac`)
— after the derived class destructor and its members are gone. A `wxEVT_DESTROY` handler may clear
an outside pointer to the window, but must not call into the derived object. Cleanup that needs the derived members belongs in the
derived destructor (`WebDialog::~WebDialog` in `src/slic3r/GUI/WebDialog.cpp` documents this choice).
**OrcaSlicer.** The payload events in `src/slic3r/GUI/Event.hpp` — `SimpleEvent`, `IntEvent`,
`Event<T>`, `ArrayEvent<T, N>` — derive from `wxEvent` but set
`m_propagationLevel = wxEVENT_PROPAGATE_MAX`, so they bubble like command events and, like them,
stop at dialogs and (on MSW/macOS) popups.
**Pitfalls.**
- **Rule:** In a `wxEVT_DESTROY` handler bound on a window with children, check the event object.
```cpp
// Wrong: the first child destroyed clears the pointer
m_plugins_dlg->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent&) { m_plugins_dlg = nullptr; });
// Right (GUI_App::open_plugins_dialog)
m_plugins_dlg->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& e) {
if (e.GetEventObject() == m_plugins_dlg) m_plugins_dlg = nullptr;
e.Skip();
});
```
- **Rule:** Do not expect events from inside a dialog or popup at its opener — and do not rely on
popup events *not* arriving there either.
**Why:** `wxWS_EX_BLOCK_EVENTS` (`src/common/dlgcmn.cpp` `wxDialogBase::wxDialogBase`,
`src/common/popupcmn.cpp`) blocks at every dialog, but at popups only on MSW/macOS; on wxGTK an
unconsumed command event from inside a popup reaches the opener's ancestors and any unfiltered
handler there. Bind on the dialog/popup or on the control, consume the event there, or re-emit
explicitly.
- **Rule:** An ancestor that binds a command event without an id filter receives that event from
every descendant control of that type.
---
## 6. Emitting events synchronously
| Call | Use for | Note |
|---|---|---|
| `win->ProcessWindowEvent(e)` = `win->GetEventHandler()->ProcessEvent(e)` | Emitting a window's own event | "ProcessEvent() itself can't be called for wxWindow objects as it ignores the event handlers associated with the window; use this function instead" (`interface/wx/window.h:2736-2744`) |
| `win->HandleWindowEvent(e)` = `GetEventHandler()->SafelyProcessEvent(e)` | Same, from code that must not leak exceptions (native callbacks) | `interface/wx/window.h:2726-2734` |
| `win->ProcessWindowEventLocally(e)` | This window and its pushed handlers only, no propagation | `interface/wx/window.h:2746-2757` |
| `handler->ProcessEventLocally(e)` | Forwarding an event to another handler | "should, be called to forward an event to another handler instead of ProcessEvent() which would result in a duplicate call to TryAfter()" (`interface/wx/event.h:627-651`) |
| `handler->SafelyProcessEvent(e)` | Catches exceptions → `wxApp::OnExceptionInMainLoop` | `interface/wx/event.h:653-666` |
| `handler->ProcessEvent(e)` | Non-window handlers | On a window object it bypasses pushed handlers (Orca `StateHandler`) |
**Emitting shape** (`docs/doxygen/overviews/eventhandling.h:660-671`): construct the event with the
type and `GetId()`, `SetEventObject(this)`, fill the payload, `ProcessWindowEvent(event)`.
**Programmatic changes.** wx controls normally send command events only for user actions. The
documented exceptions include `wxNotebook::AddPage/AdvanceSelection/DeletePage/SetSelection`,
`wxTreeCtrl::Delete/DeleteAllItems/EditLabel` and "All wxTextCtrl methods" — use
`wxTextCtrl::ChangeValue` instead of `SetValue`; `Replace`/`WriteText` have no event-free form
(`docs/doxygen/overviews/eventhandling.h:796-815`). `wxBitmapToggleButton::SetValue` "does not cause a EVT_TOGGLEBUTTON event
to be emitted" (`interface/wx/tglbtn.h:169`). Orca widget setters: §16.
**Reentrancy.** A synchronous emit runs every handler before it returns. If a handler destroys the
emitter, the emitter's code after `ProcessEvent` runs on freed memory. Non-TLW `Destroy()` deletes
immediately (`src/common/wincmn.cpp:559-573`).
**OrcaSlicer models.** `Button::sendButtonEvent` (`Widgets/Button.cpp`): `wxCommandEvent` of
`wxEVT_BUTTON` with `GetId()`, `SetEventObject(this)`, `GetEventHandler()->ProcessEvent`.
`MsgDialog::show_dsa_button` (`MsgDialog.cpp`) makes a label click behave like a checkbox click:
`SetValue(!GetValue())`, then emits `wxEVT_TOGGLEBUTTON` with the checkbox's id and object through its
`GetEventHandler()`. `CloneDialog` (`CloneDialog.cpp`) turns Enter in its spin box into an OK click
from its `wxEVT_CHAR_HOOK` handler by emitting `wxEVT_BUTTON` with `ok_btn->GetId()` through
`ok_btn->GetEventHandler()`, and Skips other keys.
**Pitfalls.**
- **Rule:** Never emit a window's event with `win->ProcessEvent()`.
```cpp
// Wrong: skips handlers pushed on the window (StateHandler, wxEventBlocker)
wxCommandEvent e(wxEVT_BUTTON, GetId()); e.SetEventObject(this); this->ProcessEvent(e);
// Right
wxCommandEvent e(wxEVT_BUTTON, GetId()); e.SetEventObject(this); ProcessWindowEvent(e);
```
Cite: `interface/wx/window.h:2736-2744`.
- **Rule:** Construct the event class the type was declared with.
**Why:** the `Bind` tag ties type and class at compile time, but nothing checks the object you
construct. `wxCommandEvent e(wxEVT_LEFT_DOWN, id)` compiles; handlers then `static_cast` it to
`wxMouseEvent&` (undefined behaviour), and it propagates to parents like a command event. To make
a whole card clickable, call the action directly or emit a semantic event.
```cpp
// Wrong: a "mouse" event that is a wxCommandEvent, emitted past the handler stack
auto forward = [this](wxMouseEvent&) {
wxCommandEvent click(wxEVT_LEFT_DOWN, GetId()); click.SetEventObject(this); this->ProcessEvent(click);
};
// Right: a semantic command event through the handler stack (or call select() directly)
auto forward = [this](wxMouseEvent&) {
wxCommandEvent click(wxEVT_BUTTON, GetId()); click.SetEventObject(this); ProcessWindowEvent(click);
};
child->Bind(wxEVT_LEFT_DOWN, forward);
```
Cite: `src/slic3r/GUI/PurgeModeDialog.cpp` `PurgeModeBtnPanel` (re-dispatches child clicks; not a
model to copy).
- **Rule:** Defer destroying the emitter out of its own handler.
**Why:** `Button::mouseReleased` is still on the stack when your `wxEVT_BUTTON` handler runs.
```cpp
// Wrong
btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { m_panel->Destroy(); }); // m_panel contains btn
// Right
btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
CallAfter([w = wxWeakRef<wxWindow>(m_panel)] { if (w) w->Destroy(); });
});
```
Alternative: `wxTheApp->ScheduleForDestruction(win)` (`interface/wx/app.h:191-212`). Deletion
rules: `references/windows-dialogs.md`.
---
## 7. Posting and queueing
**Contract.**
- `QueueEvent(wxEvent*)` is asynchronous and "takes ownership of the event parameter, i.e. it will
delete it itself … the pointer can't be used any more after the function returns". It "can be used
for inter-thread communication from the worker threads to the main thread. It is safe in the sense
that it uses locking internally", and wakes the idle loop via `wxWakeUpIdle()`
(`interface/wx/event.h:409-466`). `wxQueueEvent(dest, evt)` wraps it (`interface/wx/event.h:5368-5382`).
- `AddPendingEvent(const wxEvent&)` copies the event via `Clone()`, so the original may be on the
stack, but it "can't be used to post events from worker threads for the event objects with
wxString fields (i.e. in practice most of them)"; "Use QueueEvent() to avoid this"
(`interface/wx/event.h:468-488`). The overview adds that you "will need to use the latter [QueueEvent] when
doing inter-thread communication; when you use only the main thread you can also safely use the
former" (`docs/doxygen/overviews/eventhandling.h:618-620`). `wxPostEvent(dest, evt)` = `dest->AddPendingEvent(evt)`, "not
thread-safe for event objects having wxString fields, use wxQueueEvent() instead"
(`interface/wx/event.h:5355-5366`).
- Every posted event class "must implement" `Clone()` (`interface/wx/event.h:125-146`).
- `wxThreadEvent`: `Clone()` unshares the string; its category is `wxEVT_CATEGORY_THREAD`, which
keeps it out of `YieldFor()` calls that do not ask for that category; `SetPayload<T>` needs a
copy constructor that is thread-safe, "i.e. create a copy that doesn't share anything with the
original" (`interface/wx/event.h:3733-3790`). [source] Plain `wxYield()` is `YieldFor(wxEVT_CATEGORY_ALL)`, which
includes `THREAD`; the protection applies to masked yields such as the generic `wxProgressDialog`'s
`YieldFor(wxEVT_CATEGORY_UI|wxEVT_CATEGORY_USER_INPUT)` (`src/generic/progdlgg.cpp`).
**Source facts** [source]:
- `AddPendingEvent` is literally `QueueEvent(event.Clone())` (`include/wx/event.h:3780-3789`). wxString
is always a deep-copy `std::wstring` in 3.3 (`include/wx/string.h:121-132`), so the copy-on-write
rationale is historical; follow the documented rule anyway.
- **Where** a posted event is dispatched: the queue belongs to the handler you posted to, and
`ProcessPendingEvents` calls `SafelyProcessEvent` on that object (`src/common/event.cpp:1368-1440`).
Posting to a `wxWindow*` processes it on the window object itself, **bypassing pushed handlers**
(`StateHandler`, `wxEventBlocker`). Post to `win->GetEventHandler()` if they must see it.
- **When**: pending events run before idle (`src/common/evtloopcmn.cpp:258-330`
`wxEventLoopManual::DoRunLoop`, the MSW loop; per-port table below); a nested loop (`ShowModal()`,
`wxYield()`) processes them too (`src/common/evtloopcmn.cpp:172-192` `DoYieldFor`). An exiting loop
drains them before it returns on MSW (`DoRunLoop` tail) and in macOS modal loops
(`src/osx/core/evtloop_cf.cpp` `wxCFEventLoop::OSXDoRun`, run by `wxModalEventLoop::OSXDoRun` in
`src/osx/cocoa/evtloop.mm`), but not on wxGTK (`src/gtk/evtloop.cpp` `wxGUIEventLoop::DoRun` just
leaves `gtk_main()`), where they run in the enclosing loop's next idle pass. A callback can run inside any
`ShowModal()` or `wxYield()` reached from your code. Nested loops: `references/threads-timers-app.md`.
- **Order**: FIFO only per target handler. The app keeps a list of handlers with pending events and
drains `handler[0]` completely — including events added to it meanwhile — before the next
(`src/common/appbase.cpp:561-603`, `src/common/event.cpp:1368-1440`). `A->CallAfter(f1); B->CallAfter(f2);
A->CallAfter(f3);` runs f1, f3, f2. A callback that keeps queueing new work on any handler keeps
`ProcessPendingEvents` looping and native input and paint starve.
- **Destruction**: `~wxEvtHandler` removes itself from the app's list and deletes its queued events
(`src/common/event.cpp:1234-1238`) — events posted to a destroyed handler are dropped, not delivered.
`DeletePendingEvents` does not take `m_pendingEventsLock` (`src/common/event.cpp:1361-1366`): a worker
posting to an object being destroyed races its destructor, and a worker holding a raw pointer to a
dead target is undefined behaviour.
**Platforms** [source]:
| Port | Pending events (`CallAfter`, posted) | Idle events (`wxEVT_IDLE`, update-UI, deferred TLW deletes) |
|---|---|---|
| MSW | Keep running inside native modal loops (menu tracking, window move/size, `MessageBox`, common dialogs) through a `WH_GETMESSAGE` hook (`src/msw/window.cpp` `wxIdleWakeUpModule::MsgHookProc` → `wxApp::MSWProcessPendingEventsIfNeeded`, `src/msw/app.cpp:717-730`) | Not processed inside those native loops — do not defer repaint/layout to idle if it must happen during a live resize |
| macOS | Processed by a run-loop observer at `kCFRunLoopBeforeTimers` in common modes (`src/osx/core/evtloop_cf.cpp:86-112`) | At `kCFRunLoopBeforeWaiting`. While a native `wxMessageDialog`, `wxFileDialog` or `wxDirDialog` is modal, `wxCFEventLoopPauseIdleEvents` stops **both** (`src/osx/cocoa/msgdlg.mm:62`, `src/osx/cocoa/filedlg.mm:601`, `src/osx/cocoa/dirdlg.mm:123`, `src/osx/core/evtloop_cf.cpp:362-379`): a `CallAfter` queued before or during such a dialog runs only after it closes |
| GTK3 (default build, X11 and Wayland) / opt-out GTK2 | Pending events and idle run from one GLib idle source at `G_PRIORITY_LOW` (`src/gtk/app.cpp:102-153, 631`, `wxApp::DoIdle`): they starve while higher-priority sources (timers, redraw, input floods) are busy; a masked `YieldFor` removes that source before each iteration, so nothing queued runs inside it (`references/threads-timers-app.md` §Event categories and yields) | same source; `RequestMore()` or remaining pending events keep it installed — a busy loop |
**OrcaSlicer.** `GLCanvas3D::post_event` sets the event object and `wxPostEvent(m_canvas, …)` —
asynchronous, dispatched on the `wxGLCanvas`, where `Plater::priv` binds the canvas events.
`BackgroundSlicingProcess` posts with `wxQueueEvent(wxGetApp().mainframe->m_plater, evt.Clone())` and
`new wxCommandEvent(...)`; `SlicingProcessCompletedEvent` carries the worker's exception as a
`std::exception_ptr`. Worker → GUI patterns, `set_queue_on_main_fn`, Jobs:
`references/threads-timers-app.md`.
**Pitfalls.**
- **Rule:** From a worker, post only to an object that outlives the worker.
```cpp
// Wrong: races ~wxEvtHandler, or uses a dangling pointer
std::thread([panel] { wxQueueEvent(panel, new wxThreadEvent(EVT_DONE)); }).detach();
// Right: post to the app and re-validate on the main thread
std::thread([this, alive = m_alive] {
wxGetApp().CallAfter([this, alive] { if (alive->load()) on_done(); });
}).detach();
```
Cite: `src/common/event.cpp:1234-1238, 1361-1366`.
- **Rule:** Use one target for steps that must run in order.
**Why:** `this->CallAfter(a); wxGetApp().CallAfter(b);` gives no order between `a`'s handler queue
and the app's.
- **Rule:** Retry and polling go through `wxTimer` (`StartOnce`), not a self-re-queuing `CallAfter`
or `RequestMore()` (§12). Timers: `references/threads-timers-app.md`.
---
## 8. CallAfter and the liveness rule
**Contract** (`interface/wx/event.h:490-564`). `CallAfter(&T::method, args…)` — 0, 1 or 2 arguments;
"The method being called must be the method of the object on which CallAfter() itself is called" —
and `CallAfter(functor)`. "it is safe to use CallAfter() from other, non-GUI, threads, but … the
method will be always called in the main, GUI, thread context." Its documented purpose: actions that
"can't be performed inside their handlers, e.g. you shouldn't show a modal dialog from a mouse click
event handler as this would break the mouse capture state". The overview adds the alternative of
breaking the capture with `dialog.CaptureMouse(); dialog.ReleaseMouse();` when a dialog really must
be shown from such a handler (`docs/doxygen/overviews/eventhandling.h:878-901`). [source] That pair does not
break a capture taken through wx's capture stack: `ReleaseMouse` re-captures the previous holder
(`src/common/wincmn.cpp:3412-3416`), so release your own capture and `CallAfter` the dialog instead. Capture
rules: `references/mouse-keyboard-focus.md` §Mouse capture.
**Mechanics** [source]. Every overload is `QueueEvent(new wxAsyncMethodCallEvent…(this, …))` on the
object it is called on (`include/wx/event.h:3815-3855`), executed by the implicit entry in
`TryHereOnly` only when the event object is that handler (`src/common/event.cpp:1644-1667`).
Consequences:
- The functor is copied; method-form arguments are stored by value. Captures must be copyable.
- **Target destroyed first → the call is silently dropped**, and its captures are destroyed in
`DeletePendingEvents` (`src/common/event.cpp:1234-1238`). A `CallAfter` queued from the main thread on a window
needs no guard for that window itself.
- **`SetEvtHandlerEnabled(false)` drops pending calls**: `TryHereOnly` returns before the implicit
entry (`src/common/event.cpp:1646-1648`), and the event is consumed.
- Pushed handlers and `wxEventBlocker` do not block `CallAfter` (it is dispatched on the object).
- A TLW after `Destroy()` is not deleted yet: it is put on `wxPendingDelete` and hidden (off macOS, unless
it is the last visible TLW) (`src/common/toplvcmn.cpp:102-142`; wxOSX `src/osx/toplevel_osx.cpp`); pending events run before
the idle-time delete, so `CallAfter`s queued on it still execute on the hidden window.
`IsBeingDeleted()` is documented to cover "scheduled for destruction" (`interface/wx/window.h:3620-3633`)
but stays `false` until the real delete, because `m_isBeingDeleted` is set only by
`SendDestroyEvent` (`src/common/wincmn.cpp:535-557`). Use `wxTheApp->IsScheduledForDestruction(w)`
(`interface/wx/app.h:221`) or an alive flag.
- `wxApp::CallAfter` (Orca: `wxGetApp().CallAfter`) always runs — the app outlives every window — so
every object captured by pointer must be re-validated inside.
**The liveness rule.** Every `CallAfter` or other deferred lambda that touches a window, a model item
or any object other than the handler it was queued on must re-check liveness **inside** the lambda:
capture a `std::shared_ptr<std::atomic<bool>>` alive flag (set to `false` in the destructor), a
`std::weak_ptr` token or a `wxWeakRef`, or re-validate the target (registry lookup, model-item scan)
before touching it. Never capture a bare `this` or `wxDataViewItem` and trust it.
**Why:** the window or item can die between queueing and execution — dialog closed, plugin unloaded,
model rebuilt — and the deferred body then dereferences freed memory. Three fixes independently
added this guard. Self-queued work is the exception: `window->CallAfter(...)` is discarded unexecuted
when that window is destroyed, so the check is needed only for `wxGetApp().CallAfter(...)` and for
objects other than the window it was queued on. When a lambda touches only one window, queuing it on
that window gives the guard for free.
```cpp
// Wrong
CallAfter([this, item] { start_filament_editor(item); });
// Right — re-validate inside the lambda
CallAfter([this, item] {
if (!is_live_model_item(item)) return; // or: if (!alive->load()) return;
start_filament_editor(item);
});
// Right — app-queued work with an alive flag
wxGetApp().CallAfter([this, alive = m_alive, payload]() {
if (alive->load(std::memory_order_acquire))
handle_web_command(payload);
});
```
Cite: 0a0d59b76b (`src/slic3r/GUI/PluginsDialog.cpp/.hpp`, `PluginsDialog::m_alive`,
`PluginsDialog::on_script_message`), c965b2a5b3 (`src/slic3r/GUI/GUI_ObjectList.cpp`,
`ObjectList::is_live_model_item`), b779a7bfed (`src/slic3r/plugin/host/PluginHostUi.cpp`, the
`UiRegistry::is_open` re-check in `ui_create_window`).
**Liveness tools.**
| Tool | Use when | Limits |
|---|---|---|
| Queue on the target itself (`win->CallAfter`) | The lambda touches only `win` | Main thread; still runs for a `Destroy()`ed TLW until the idle delete |
| `std::shared_ptr<std::atomic<bool>>` alive flag (`PluginsDialog::m_alive`) | App-queued work, any thread | Flips at the start of the derived destructor |
| `std::weak_ptr` token (`MachineObject::add_command_error_code_dlg` with `m_token`, `DeviceManager.cpp`) | Non-window objects | — |
| `wxWeakRef<T>` (`interface/wx/weakref.h`) | Main-thread checks of a `wxEvtHandler`/window | Reset in `~wxTrackable`, i.e. after the derived destructor and `DestroyChildren`; tracker list unsynchronised (`include/wx/tracker.h`) — create and test on the main thread only |
| Registry / model re-validation (`UiRegistry::is_open`, `ObjectList::is_live_model_item` in macOS-only code) | Ids or items that may be rebuilt | `is_live_model_item` compares node pointers, so it cannot detect a freed node whose address was reused |
| `wxTheApp->IsScheduledForDestruction(w)` | Detect a `Destroy()`ed TLW still in `wxPendingDelete` | — |
| `GUI_App::is_closing()` | Callbacks during shutdown | Check before posting and again inside the lambda |
Deletion and `wxWeakRef` details: `references/windows-dialogs.md`.
**OrcaSlicer.**
- `run_on_ui_blocking` (`src/slic3r/plugin/host/PluginHostUi.cpp`) captures `fn` and the promise **by
reference** in `wxGetApp().CallAfter` and runs `fn` inline when `wxIsMainThread()` (a `CallAfter` +
`future.get()` on the main thread would deadlock). It is safe only because the caller blocks until
the lambda has run; never copy by-reference captures into a fire-and-forget `CallAfter`.
- Webview script messages arrive synchronously inside the native callback on WebKitGTK and WKWebView;
defer window work from them with `CallAfter` plus an alive check, and in a `WebViewHostDialog`
subclass do it in your own `on_script_message`, because the base dispatches synchronously. Details
and cites (b779a7bfed, f2ccbfc8b5, 0a0d59b76b): `references/webview-gl-aui-media.md`.
**Pitfalls.**
- **Rule:** Do not use `IsBeingDeleted()` to detect a `Destroy()`ed TLW. Use an alive flag or
`IsScheduledForDestruction`.
- **Rule:** Do not disable a handler with `SetEvtHandlerEnabled(false)` while it still has
`CallAfter`s it needs (§14).
---
## 9. Custom events and payload ownership
**Declaring.** `wxDECLARE_EVENT(NAME, Class)` in the header, `wxDEFINE_EVENT(NAME, Class)` in exactly
one `.cpp` — "this is a definition so can't be in a header" (`docs/doxygen/overviews/eventhandling.h:634-640`).
[source] `wxDEFINE_EVENT` expands to `const wxEventTypeTag<T> NAME(wxNewEventType())`
(`include/wx/event.h:105-106`). A namespace-scope `const` has internal linkage unless an `extern`
declaration (`wxDECLARE_EVENT`) precedes it, so a header definition alone gives every translation unit
a **different** event type: binds and posts silently never meet, and nothing fails to link.
`wxDECLARE_EXPORTED_EVENT` exists only for DLL export (`include/wx/event.h:110-115`).
**Choosing the class** (`docs/doxygen/overviews/eventhandling.h:600-613`): `wxEvent` (no payload, no propagation) or
`wxCommandEvent` (int, long, string, client data; propagates). For richer payloads derive a class,
add members and implement `Clone() { return new MyEvent(*this); }` (`docs/doxygen/overviews/eventhandling.h:686-741`);
event-table macros need extra boilerplate, `Bind` does not. `wxEvent::Clone()` is pure virtual
(`include/wx/event.h:1009`), so a direct `wxEvent` subclass without `Clone` does not compile — but
a subclass of a concrete event (`wxCommandEvent::Clone` returns `new wxCommandEvent(*this)`,
`include/wx/event.h:1655`) silently inherits a slicing `Clone`.
**Payload ownership.**
| Payload | Ownership | Cite |
|---|---|---|
| `wxCommandEvent::SetString/SetInt/SetExtraLong` | Copied / by value | `interface/wx/event.h` wxCommandEvent |
| `wxCommandEvent::SetClientData(void*)` | Raw pointer, never owned | — |
| `wxCommandEvent::SetClientObject(wxClientData*)` | "not owned by the event … must be owned and deleted by another object (e.g. a control) that has longer life time than the event object" | `interface/wx/event.h:2210-2216` |
| `wxEvtHandler::SetClientObject(wxClientData*)` | Owned by the handler: "Any previous object will be deleted"; deleted in `~wxEvtHandler`; do not mix with `SetClientData` on the same handler | `interface/wx/event.h:1062-1074`; [source] `src/common/event.cpp:1240-1242` |
| `Bind(…, userData)` | Owned by the binding, deleted on unbind or at exit | `interface/wx/event.h:900-905` |
| `wxThreadEvent::SetPayload<T>` | Copied; T's copy must not share state | `interface/wx/event.h:3775-3790` |
[source] For a `wxEVT_TEXT` event whose event object is a text-entry window,
`wxCommandEvent::GetString()` always returns the control's **current** value and ignores the stored
string (`src/common/event.cpp:430-447`). The copy constructor materialises the stored string
(`include/wx/event.h:1622-1632`), but a queued or cloned copy still has the event object, so its
`GetString()` reads the control live at handling time. If the value at emit time matters, capture it
yourself.
**OrcaSlicer.**
- Custom event types are declared in headers (`GLCanvas3D.hpp` and `Plater.hpp` in
`Slic3r::GUI`; widget events such as `EVT_SPINCTRL_TEXT`, `wxEVT_TAB_SEL_CHANGED` at global scope)
and defined once in the matching `.cpp`.
- Payload classes in `src/slic3r/GUI/Event.hpp`: `SimpleEvent`, `IntEvent` (`get_data()`),
`Event<T>` (`.data`), `ArrayEvent<T, N>` (`.data`). All derive from `wxEvent`, propagate (§5), and
their `Clone()` copies type, data and event object (not the id). `LoadPrinterViewEvent` there shows
the `wxCommandEvent`-derived shape: a copy constructor that copies the extra member, and
`Clone() { return new LoadPrinterViewEvent(*this); }`.
```cpp
wxDECLARE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>); // GLCanvas3D.hpp
wxDEFINE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>); // GLCanvas3D.cpp
post_event(Event<int>(EVT_GLCANVAS_INCREASE_INSTANCES, +1)); // GLCanvas3D, posts on m_canvas
view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event<int>& e) { /* e.data */ });
```
- Simple dialog-level events use `wxCommandEvent` with `SetInt`/`SetString`/`SetEventObject`
(`ReleaseNote.cpp` `EVT_SECONDARY_CHECK_*`; `MsgDialog::show_dsa_button` posts
`EVT_CHECKBOX_CHANGE` with `wxPostEvent(this, event)`).
**Pitfalls.**
- **Rule:** Never put `wxDEFINE_EVENT` in a header.
```cpp
// Wrong (header): one event type per .cpp that includes it
wxDEFINE_EVENT(EVT_MY_THING, wxCommandEvent);
// Right
wxDECLARE_EVENT(EVT_MY_THING, wxCommandEvent); // header
wxDEFINE_EVENT(EVT_MY_THING, wxCommandEvent); // one .cpp
```
- **Rule:** A posted custom event class overrides `Clone()` and copies every payload member.
**Why:** `wxPostEvent`/`wxQueueEvent(evt.Clone())`/`AddPendingEvent` clone the event; an inherited
`Clone` slices it to the base class, and the handler's cast to the derived type reads garbage.
---
## 10. Window and event ids
**Contract.**
- `wxID_ANY` in a constructor asks wx for an id; automatic ids "are always negative and so will never
conflict with the user-specified identifiers which must be always positive". Custom ids belong
above `wxID_HIGHEST` or below `wxID_LOWEST` (`docs/doxygen/overviews/eventhandling.h:863-875`),
"should not have values 0 or 1", and "they are not needed when using wxEvtHandler::Bind()" if you
bind on the control (`docs/doxygen/overviews/windowids.h:32-42`). Stock ids span `wxID_LOWEST`
(5000) to `wxID_HIGHEST` (6000) (`include/wx/defs.h:1786-1787, 1943`).
- `wxNewId()`: "@deprecated Ids generated by it can conflict with the Ids defined by the user code,
use wxID_ANY …" (`interface/wx/utils.h:433-444`). The header does not mark it deprecated, so there is
no compiler warning. [source] It starts at 100, skips the stock range and never reuses an id
(`src/common/utilscmn.cpp:654-663`).
- `wxWindow::NewControlId(count)` reserves auto ids (`interface/wx/window.h:4348-4376`; main thread).
**Platforms** [source]. `wxUSE_AUTOID_MANAGEMENT` defaults ON for Windows builds and OFF elsewhere
(`build/cmake/options.cmake:520-528`). With it (MSW), auto ids are reference-counted in
`-32000..-2000`, handed out upward from -32000, and once that range has been used up the allocator
scans for ids whose window or menu item is gone and **reuses** them — contradicting
`docs/doxygen/overviews/windowids.h:28-30` ("an ID that had never been returned by this function
before"); without it (macOS, GTK) they count down from -2000 to -1000000 and then wrap unchecked
(`src/common/windowid.cpp:187-251`, `include/wx/defs.h:1755-1772`). `wxMenuItem` holds its id as a `wxWindowIDRef`
(`include/wx/menuitem.h`). MSW native ids are 16-bit: `CreateBase` accepts `wxID_ANY`, `0..32766` or
the auto range (`src/common/wincmn.cpp:369-375`; the assert is compiled out in Orca, so an
out-of-range id fails silently).
**OrcaSlicer.**
- `append_menu_item`/`append_submenu` (`src/slic3r/GUI/wxExtensions.cpp`) assign `wxNewId()` for
`wxID_ANY` and bind handlers filtered by that id: `append_menu_item` binds `wxEVT_MENU` on the menu
(dies with it; on MSW on a non-null `event_handler` instead), and with a non-null `parent` both helpers bind `wxEVT_UPDATE_UI` on `parent`, never
unbound. That is safe only because `wxNewId` ids are never reused and the menus are built once; switching them
to `NewControlId` (recycled on MSW) would let stale handlers fire for new items. Menu event routing:
`references/popups-menus.md`.
- **Field window pools** (`src/slic3r/GUI/Field.cpp`, `Builder::build`, `free_window`; not on GTK,
where windows are deleted): option widgets are reused across page rebuilds, and `free_window`
unbinds every dynamic entry that was bound with an explicit id (on the widget and its inner
`wxTextCtrl`). Convention: `Field` handlers on pooled widgets pass the control id as the `Bind` id
(`…, temp->GetId())`); a handler bound without an id survives into the next `Field` that reuses the
widget and dangles. Widget-internal handlers use `wxID_ANY` and survive by design.
`GUI_App::recreate_GUI` releases the old pools through a `wxClientData` it hands to the old
`MainFrame` with `SetClientObject`, so they are freed when that frame's `~wxEvtHandler` runs (§9).
**Pitfalls.**
- **Rule:** Bind on the emitting control, not on an ancestor filtered by id.
**Why:** with recycled ids (MSW) a stale ancestor binding fires for an unrelated new control; an
unfiltered ancestor binding catches every descendant.
```cpp
// Wrong
panel->Bind(wxEVT_BUTTON, &MyPanel::on_ok, this, ok_btn->GetId());
// Right
ok_btn->Bind(wxEVT_BUTTON, &MyPanel::on_ok, this);
```
---
## 11. wxEVT_UPDATE_UI
**Contract** (`interface/wx/event.h:2444-2523`). Update-UI pseudo-events are sent "in idle time" from
`wxWindow::OnInternalIdle`; handlers call `evt.Enable/Check/Show/SetText`. Popup menus are updated
just before showing (`wxMenu::UpdateUI`). "On Windows and GTK+, events for menubar items are only
sent when the menu is about to be shown, and not in idle time." Default mode
`wxUPDATE_UI_PROCESS_ALL`, interval 0. Throttle with `wxUpdateUIEvent::SetMode(wxUPDATE_UI_PROCESS_SPECIFIED)`
plus `wxWS_EX_PROCESS_UI_UPDATES` on the windows that need it, or `SetUpdateInterval(ms)` with
`UpdateWindowUI()` at critical points (`interface/wx/event.h:2470-2479`; `interface/wx/window.h:286-288`).
**Source facts** [source].
- Each idle pass calls `UpdateWindowUI` for every window whose parent is shown on screen
(`src/common/wincmn.cpp:2810-2814`, `src/common/event.cpp:499-530` `wxUpdateUIEvent::CanUpdate`).
The event is a command event: unhandled, it bubbles to the TLW and `wxApp`, and every hop scans
that handler's whole dynamic table. Many `parent->Bind(wxEVT_UPDATE_UI, …, id)` entries on a frame
tax every idle pass.
- Menubar update-on-open applies to macOS too: `wxUSE_IDLEMENUUPDATES` is 0 for MSW, GTK and OSX
(`include/wx/platform.h:535-545`), except wxGTK with a global menu bar, which falls back to idle
updates (`src/common/framecmn.cpp:66-79`).
**Pitfall.**
- **Rule:** Keep update-UI handlers to cheap state reads; never do layout, I/O or model scans in them.
---
## 12. Idle events
**Contract** (`interface/wx/event.h:4383-4441`). Idle events are sent once when the loop becomes idle;
a continuous stream needs `RequestMore()` or `wxWakeUpIdle()`, and "both of these approaches (and
especially the first one) increase the system load". `wxIdleEvent::SetMode(wxIDLE_PROCESS_SPECIFIED)`
plus `wxWS_EX_PROCESS_IDLE` limits recipients. Documented: "The children of hidden windows do not
receive idle events". The "delayed action" idiom binds an idle handler that unbinds itself;
`CallAfter` is simpler.
**Source facts** [source].
- **Contradicts the doc:** `wxWindowBase::SendIdleEvents` recurses into every child without a
visibility check (`src/common/wincmn.cpp:2783-2808`); only update-UI skips children of hidden
windows (`src/common/event.cpp:508-513`). Idle handlers on hidden panels run.
- One `wxIdleEvent` object is reused for all windows in a pass; TLWs pending deletion are skipped,
and `DeletePendingObjects` runs from the app's idle processing (`src/common/appcmn.cpp:408-429`,
`src/common/appbase.cpp:442-462`). `wxYield()` runs pending events and one idle pass
(`src/common/evtloopcmn.cpp:172-192`) — so it can delete `Destroy()`ed TLWs under your feet.
**OrcaSlicer.** `DropDown::Create` binds an empty, non-skipping `wxEVT_IDLE` handler on macOS.
Because dynamic handlers run before the static table, this shadows `wxPopupTransientWindow::OnIdle`
(static table, `src/common/popupcmn.cpp:108-112, 439-471`) and its idle-time capture juggling, so the
capture taken in `Show` is held until the drop-down hides. `FanControlPopupNew` binds the same no-op,
but it is a `wxDialog`, which has no such idle handler to shadow. Popup mechanics:
`references/popups-menus.md`.
**Pitfall.**
- **Rule:** Use `wxTimer` (`StartOnce`) or `CallAfter` instead of idle handlers for periodic or
delayed work.
**Why:** idle fires once per wake-up, does not run inside MSW native modal loops (§7), runs for
hidden panels, and `RequestMore()` busy-loops a core.
---
## 13. Event filters
**Contract.** `wxApp::FilterEvent` and any `wxEventFilter` registered with
`wxEvtHandler::AddFilter` run for every event before anything else, in LIFO order, with wxApp
registered by default (`interface/wx/event.h:1195-1214`). Return `Event_Skip` (-1) to continue,
`Event_Ignore` (0) or `Event_Processed` (1) to stop (`interface/wx/eventfilter.h:85-93`). "having event
filters adds additional overhead to every event … return as quickly as possible"
(`interface/wx/eventfilter.h:18-20`). A standalone filter must be removed with `RemoveFilter` before it is
destroyed (`interface/wx/eventfilter.h:108-114`; misuse is silent in Orca).
**OrcaSlicer.**
- `GUI_App::FilterEvent` only timestamps user input (non-command `wxEVT_CATEGORY_USER_INPUT` events
and main-frame size events) and always returns `Event_Skip` — keep it that cheap.
- `SplashScreen::FilterEvent` (in `GUI_App.cpp`) returns `wxEventFilter::Event_Skip` to disable
`wxSplashScreen`'s own filter, which `Close()`s (and so `Destroy()`s) the splash on any key or mouse
press (`src/generic/splash.cpp:40-45, 105-126`) while `GUI_App::on_init_inner` still holds it
(as a `wxWeakRef<SplashScreen>`). Cite: 4088a36095.
---
## 14. Pushed handlers, wxEventBlocker, SetEvtHandlerEnabled
**Contract.**
- `PushEventHandler(h)`: `h` must not be part of another chain; events reach the most recently pushed
handler first and the window last (`interface/wx/window.h:2779-2809`). `PopEventHandler(deleteHandler
= false)` — an error with nothing pushed (`interface/wx/window.h:2759-2777`); `RemoveEventHandler(h)` removes from
the middle (`interface/wx/window.h:2811-2827`). `SetNextHandler` on a window is not supported — windows use the
stack (`interface/wx/window.h:2843-2851`).
- `wxEventBlocker(win, type = wxEVT_ANY)` discards events of the given types directed to `win`; `win`
"must remain alive until the wxEventBlocker object destruction" (`interface/wx/event.h:287-337`).
- `SetEvtHandlerEnabled(false)`: the handler's dynamic and static tables are skipped, processing
resumes at the chain step (`docs/doxygen/overviews/eventhandling.h:466-471`).
**Source facts** [source].
- **Pop before destroy.** `~wxWindowBase` asserts "any pushed event handlers must have been removed"
(`src/common/wincmn.cpp:468-472`) — compiled out in Orca, so a forgotten pushed handler is a
dangling pointer, not an assert.
- `wxEventBlocker` is a pushed handler whose `ProcessEvent` returns `true` for blocked types
(`src/common/event.cpp:2075-2103`). Its destructor pops whatever is on top; pushing another handler
inside its scope corrupts the stack. It only sees events dispatched through `GetEventHandler()` —
not posted events, `CallAfter`, or a direct `win->ProcessEvent`. Blocked command events count as
processed and do not propagate.
- `SetEvtHandlerEnabled(false)` does not affect handlers bound on other objects for this window's
events, validators, the chain, or propagation — and it drops queued `CallAfter`s (§8).
`wxWindow::Enable(false)` only stops native input; posted and synthetic events still reach the
handlers (`src/common/event.cpp:1644-1648` checks only the handler flag).
**OrcaSlicer.**
- `StateHandler` (`src/slic3r/GUI/Widgets/StateHandler.cpp`) is a `wxEvtHandler` pushed on every
`StaticBox`-based widget (member `StaticBox::state_handler`) and on attached children
(`attach_child`). It binds on itself, tracks enabled/checked/focused/hovered/pressed, always
Skips, and removes itself with `RemoveEventHandler` in its destructor. Member destruction runs
before `~wxWindowBase`, so it is gone in time. Call `remove_child(child)` before destroying an
attached child separately. Emitting through `GetEventHandler()` (§6) is what lets it see
synthesized events such as `EVT_ENABLE_CHANGED`.
- `PrinterWebView::~PrinterWebView` (`PrinterWebView.cpp`) and `WebViewPanel::~WebViewPanel`
(`WebViewDialog.cpp`) call
`SetEvtHandlerEnabled(false)` first, so events raised while the webview and members are torn down
do not reach handlers that touch freed members.
**Pitfall.**
- **Rule:** Pop pushed handlers in reverse push order, before the window dies.
```cpp
// Wrong: blocker2 pops blocker1's entry (or a foreign handler) — stack corrupted, no assert in Orca
auto* b1 = new wxEventBlocker(win); wxEventBlocker b2(win); delete b1;
// Right: nested scopes
{ wxEventBlocker b1(win); { wxEventBlocker b2(win, wxEVT_TEXT); /* … */ } }
```
---
## 15. Exceptions in handlers
**Contract.** `wxApp::OnExceptionInMainLoop` returns `true` to continue the loop or `false` to exit;
the default is to exit "in all ports except under Windows where a dialog is shown"; if it rethrows and
the exception cannot be stored, the program terminates (`interface/wx/app.h:465-480`). Since 3.3.0 the
system option `catch-unhandled-exceptions` set to 0 (environment variable
`wx_catch_unhandled_exceptions=0`) stops wx from catching unhandled exceptions, so the default abort
happens and the backtrace or crash dump is more likely to show where the exception came from
(`interface/wx/sysopt.h:17-23, 40-49`).
[source] If `OnExceptionInMainLoop` throws, `wxEvtHandler::WXConsumeException` exits the current loop
and stores the exception or aborts; the wx comment explains that exceptions "can't propagate through
the C GTK+ code and corrupt the stack" (`src/common/event.cpp:1688-1749`).
**OrcaSlicer.** `GUI_App::OnExceptionInMainLoop` calls `generic_exception_handle()`, whose every
path terminates or rethrows, so its `return false` is never reached: `std::bad_alloc` and
`boost::io::bad_format_string` show a message box and terminate; other `std::exception`s are logged,
reported with `wxLogError` and rethrown (non-`std` exceptions escape unlogged), so the main loop exits
and `wxEntry` ends with the fatal exit code. An exception that escapes any handler ends the session.
Unwinding order and exit code: `references/threads-timers-app.md` §Exceptions in the main loop.
**Pitfall.**
- **Rule:** Catch inside any handler (and any `CallAfter` lambda) that can throw — file, network,
JSON, config parsing — and report the error there; never let an exception cross a native callback.
---
## 16. How Orca widgets emit events
The per-widget table (event types, ids and event objects, which setters emit, where to bind) is
`references/orca-widgets.md` §Event semantics, with per-widget binding pitfalls in
`references/controls-dataview.md` §Orca replacement widgets. The event mechanics behind them:
- **Synchronous, through the handler stack.** Orca widgets emit with `GetEventHandler()->ProcessEvent` (§6), so
the pushed `StateHandler` (§14) sees the event and command events propagate to ancestors (§5). Exceptions:
`SwitchBoard` posts `wxCUSTOMEVT_SWITCH_POS` with `wxPostEvent(this, …)` from `SwitchBoard::on_left_down`
(asynchronous: the handler runs after the click handler returns; no event object, id 0), and `TempInput` posts
`wxCUSTOMEVT_SET_TEMP_FINISH` to its parent (`TempInput::SetFinish`).
- **Local re-dispatch.** `TextInput` and `SpinInput` catch the inner control's `wxEVT_TEXT_ENTER`/
`wxEVT_KILL_FOCUS` and re-send them with the wrapper's id through `ProcessEventLocally` (§6): the wrapper's
handlers run, its parents never see them. The inner `wxEVT_TEXT` propagates normally, with the inner control's id
and object, so bind it on the wrapper with `wxID_ANY`, not the wrapper's id.
- **Internal handlers run after yours** (§4). `Button` keeps LEFT_DOWN/LEFT_UP/MOUSE_CAPTURE_LOST/KEY_DOWN/KEY_UP/
PAINT in its static table (`wxEVT_BUTTON` comes from `Button::mouseReleased`; Space/Enter synthesize LEFT_DOWN/UP
in `Button::keyDownUp`); `::CheckBox` and `SwitchButton` bind their own `wxEVT_TOGGLEBUTTON` handler in the
constructor (clears the half state, refreshes the bitmap, Skips); `TextInput`'s handlers on the inner control run
`OnEdit` and the re-dispatch. A user handler bound on the widget (or on `GetTextCtrl()`) for those events must
`Skip()`. Capture-lost handling: `references/mouse-keyboard-focus.md`.
- **`EVT_ENABLE_CHANGED`** (`StateHandler.hpp`, a `wxCommandEvent` with id 0) comes from the `Enable` overrides of
`Button`, `SpinInput`, `RadioGroup` and the other `StateHandler` widgets. `StateHandler` Skips it, so it
propagates to ancestors like any command event.
- **Id-less events still propagate.** `ComboBox`'s `wxEVT_COMBOBOX_DROPDOWN`/`_CLOSEUP` (id 0, no event object),
`RadioGroup`'s `wxEVT_RADIOBOX` (`wxEVT_COMMAND_RADIOBOX_SELECTED` is an alias, `include/wx/event.h:4910`; no
event object) and `EVT_ENABLE_CHANGED` reach every ancestor bound by type. Bind on the specific widget, never on
an ancestor filtered by its id (§10).
- **Popup relay.** `DropDown` emits `wxEVT_COMBOBOX` on itself (`sendDropDownEvent`, its own id and object); as a
`PopupWindow` it stops an unconsumed event on MSW/macOS only (§5). `ComboBox` binds it on the `DropDown`,
consumes it and re-emits it with its own id and object.
- **Emitting setters.** `RadioGroup::SetSelection` (every call, same index included), `MultiSwitchButton::SetSelection`,
`TabCtrl::SelectItem` (CHANGING carries the old index and cannot veto: `sendTabCtrlEvent` always returns `true`),
`SpinInput::SetValue` (`EVT_SPINCTRL_TEXT` + `wxEVT_TEXT`), `Notebook::SetSelection` (PAGE_CHANGING/CHANGED, as in
wx) and, in an editable or `CB_NO_TEXT` combo, `ComboBox::SetSelection`/`SetValue` (`wxEVT_TEXT` through
`ComboBox::SetLabel`) emit synchronously. Guard model→view refreshes (`references/controls-dataview.md` §Events from programmatic changes).
@@ -0,0 +1,968 @@
# Mouse, keyboard and focus
How input reaches wx windows in the wxWidgets 3.3.2 build Orca ships, and the Orca conventions on
top: mouse capture, mouse and key events, accelerators and Orca's shortcut registry, focus,
tooltips and cursors. Read it when a widget captures the mouse or tracks hover, when adding or
changing a keyboard shortcut, when touching focus, tooltip or cursor code, and when debugging an
"alive but unclickable" UI, lost or phantom clicks, or shortcuts that fire while typing.
wx asserts are compiled out in Orca (`wxDEBUG_LEVEL=0`), so every misuse below that wx documents as
an assert fails silently. "GTK" means wxGTK3, Orca's Linux default (X11 and Wayland); GTK2 is only
an opt-out build (`-DDEP_WX_GTK3=OFF`), noted where it differs. Paths starting `interface/`,
`include/`, `src/`, `docs/` are in the wx tree
(`find deps -maxdepth 5 -type d -path '*dep_wxWidgets-prefix/src/dep_wxWidgets'`); Orca paths are
relative to `src/slic3r/GUI/`.
Contents: [Rules](#rules) · [Mouse capture](#mouse-capture) · [Mouse events](#mouse-events) ·
[Global pointer position and Wayland](#global-pointer-position-and-wayland) ·
[Hover handlers and enter/leave feedback loops](#hover-handlers-and-enterleave-feedback-loops) ·
[Keyboard events](#keyboard-events) · [Accelerators and menu shortcuts](#accelerators-and-menu-shortcuts) ·
[Orca's shortcut registry](#orcas-shortcut-registry) · [Focus](#focus) · [Tooltips](#tooltips) ·
[Cursors](#cursors)
## Rules
1. Capture only with `if (!HasCapture()) CaptureMouse();` and release only with
`if (HasCapture()) ReleaseMouse();`, at every site. → [Capture stack](#the-capture-stack)
2. On button-up, release whenever `HasCapture()` is true, never gated on a gesture flag that other
code can clear; decide whether to *commit* separately. → [Cancel-on-lost pattern](#the-cancel-on-lost-pattern)
3. Every window that captures handles `wxEVT_MOUSE_CAPTURE_LOST` by *cancelling*: reset state and
`Refresh()`. No commit, no `Skip()`, no `CaptureMouse()`, no unguarded `ReleaseMouse()`. Never route it
through the mouse-up/commit path. → [Cancel-on-lost pattern](#the-cancel-on-lost-pattern)
4. Release capture before `Hide()`, `Destroy()`/`delete` and in the destructor. →
[While captured](#modal-dialogs-popups-and-destruction-while-captured)
5. Never show a modal dialog, popup or message box from a handler that runs while the mouse is
captured: release your own capture, then `CallAfter` the dialog. →
[While captured](#modal-dialogs-popups-and-destruction-while-captured)
6. Write capture code as if `wxEVT_MOUSE_CAPTURE_LOST` did not exist on macOS (it is never sent there);
a leaked capture freezes every click in the app. → [macOS](#macos-rerouting-and-the-frozen-ui-diagnosis)
7. Mouse handlers take `wxMouseEvent&` (never by value) and `Skip()` `wxEVT_LEFT_DOWN` so focus still
moves. → [Button state and clicks](#button-state-and-clicks)
8. A handler that counts presses binds `wxEVT_LEFT_DCLICK` too: the second press of a double click
is a DCLICK, not a DOWN, on every port. → [Button state and clicks](#button-state-and-clicks)
9. Wheel: divide `GetWheelRotation()` by `GetWheelDelta()`, filter `GetWheelAxis()`, accumulate for
discrete steps. → [Wheel](#wheel)
10. On macOS, read button state on non-button events (motion, enter/leave, up, wheel) from
`wxGetMouseState()`, not the event. → [Button state and clicks](#button-state-and-clicks)
11. No global screen coordinates in logic that must work on Linux: no `wxGetMousePosition()`,
`wxFindWindowAtPoint()` or cross-window screen-rect tests. Use the event's client position,
`GetClientRect()` and focus tracking; guard unavoidable uses with `is_running_on_wayland()`. →
[Global pointer](#global-pointer-position-and-wayland)
12. Never use `wxGetKeyState()` for non-modifier keys (always false on Wayland); track
`wxEVT_KEY_DOWN`/`wxEVT_KEY_UP`. → [Global pointer](#global-pointer-position-and-wayland)
13. Enter/leave handlers only record hover state, per child window. Apply `Show()`/`Hide()` +
`Layout()` from `wxEVT_IDLE` or `CallAfter`, only when the state differs. No `wxFindWindowAtPoint()`
there, and no `IsShownOnScreen()` as a guard. → [Hover](#hover-handlers-and-enterleave-feedback-loops)
14. To see a child's keys, bind `wxEVT_CHAR_HOOK` on the parent and `Skip()` everything not
handled; `wxEVT_KEY_DOWN`/`wxEVT_CHAR` do not propagate. → [wxEVT_CHAR_HOOK](#wxevt_char_hook)
15. A top-level `wxEVT_CHAR_HOOK` must not consume printable keys, Space or text-editing chords
(Ctrl+A/C/V/X/Z, Delete, Backspace, Home/End, arrows) while a text entry has focus. →
[wxEVT_CHAR_HOOK](#wxevt_char_hook)
16. Test modifiers with `GetModifiers() == wxMOD_…`, not `ControlDown()`; on macOS `wxMOD_CONTROL` is
Cmd and `wxMOD_RAW_CONTROL` is the Control key. → [Modifiers](#modifiers-and-the-cmd-mapping)
17. Match letters, digits and special keys on `wxEVT_KEY_DOWN` and punctuation on `wxEVT_CHAR`;
build chords with `KeyChord::from_event`. → [Key codes](#key-codes)
18. New user-facing shortcuts go through the registry (`Shortcut` enum + `shortcut_table` row + a case in
the context's dispatcher). No raw `wxAcceleratorTable`, hard-coded key test or literal key name in a
label. → [Registry](#orcas-shortcut-registry)
19. Global chords need Ctrl/Alt or a key that types nothing. On macOS a live menu accelerator is
consumed by the menu bar before any wx key event, so the menu handler and the dispatcher case
must do the same thing. → [Accelerators](#accelerators-and-menu-shortcuts)
20. `SetAcceleratorTable()` replaces the window's table; build one table per window. It fires only
while focus is inside that window and stops at the top-level window. → [Accelerators](#accelerators-and-menu-shortcuts)
21. `SetFocus()` only on a user action or when the top-level window `IsActive()`; never from hover or
timers unconditionally, never inside `wxEVT_KILL_FOCUS` (defer with `CallAfter`). Focus handlers
`Skip()`. → [Focus](#focus)
22. Clear a tooltip with `UnsetToolTip()`, not `SetToolTip("")`. A composite that forwards tooltips
overrides `DoSetToolTip` as well as `DoSetToolTipText`. → [Tooltips](#tooltips)
23. Tooltips on disabled controls only through the MSW-gated parent-motion hack
(`Button::EnableTooltipEvenDisabled`); never install parent-motion forwarding on macOS. →
[Tooltips](#tooltips-on-disabled-controls)
24. Set a window cursor once with `SetCursor()`, reset with `wxNullCursor`, never toggle it on
enter/leave. Busy cursors via `wxBusyCursor` RAII, on the main thread. → [Cursors](#cursors)
## Mouse capture
### Contract
`CaptureMouse()` "Directs all mouse input to this window". wx "maintains the stack of windows having
captured the mouse … you must release the mouse as many times as you capture it, unless the window
receives the wxMouseCaptureLostEvent event. Any application which captures the mouse in the
beginning of some operation must handle wxMouseCaptureLostEvent and cancel this operation when it
receives the event. The event handler must not recapture mouse." (`interface/wx/window.h:3805-3819`)
`wxMouseCaptureLostEvent` goes to **all windows on the capture stack** when capture is lost to an
"external" event (a dialog box shown, another application capturing the mouse). It is "not sent if
the capture changes because of a call to CaptureMouse or ReleaseMouse"
(`interface/wx/event.h:3496-3514`). The doc's "currently emitted under Windows only" /
`@onlyfor{wxmsw}` is stale: wxGTK sends it too, wxOSX never does (table below).
`wxMouseCaptureChangedEvent` is MSW-only and is sent to a window that loses capture "even if
wxWindow::ReleaseMouse was called by the application code"; the doc's purpose: "allows an application
to cater for unexpected capture releases" (`interface/wx/event.h:4659-4680`; only `src/msw/window.cpp`
`wxWindowMSW::HandleCaptureChanged` builds it).
### The capture stack
[source] `src/common/wincmn.cpp:3322-3456` (namespace `wxMouseCapture`; the stack is a
`wxVector`, i.e. `std::vector`):
| Call | Behaviour |
|---|---|
| `CaptureMouse()` (:3352) | Asserts (compiled out) if `this` is already anywhere on the stack. Natively releases the current holder, `DoCaptureMouse()`, pushes `this`. A second call on the same window pushes it **twice**. |
| `ReleaseMouse()` (:3371) | Calls `DoReleaseMouse()` **first**, dropping the native capture whoever owns it. Then `wxCHECK_RET(stack.back() == this)` returns silently if this window is not on top. Pops, and if the stack is non-empty **re-captures the new top** (:3412-3416). |
| `HasCapture()` | `AsWindow() == GetCapture()` (`include/wx/window.h:1125`): true only for the top of the stack. |
| `NotifyCaptureLost()` (:3438) | Does nothing while a wx `Capture/ReleaseMouse` is in progress. Otherwise sends the lost event to each stacked window, top first, popping each. The stack ends empty, so no `ReleaseMouse()` is owed afterwards. A handler that leaves the event unprocessed (`Skip()`) hits a compiled-out `wxFAIL` (:3423-3436). |
| `~wxWindowBase` (:452) | Asserts (compiled out) if the window is still on the stack, and does **not** remove it: the stack keeps a dangling pointer. |
Consequences:
- Double capture followed by one release leaves the **same window captured** (the restore step
re-captures it). That is a leaked capture.
- An unguarded `ReleaseMouse()` from a window that is not on top frees the real owner's native
capture and leaves the stack out of step; a later release can re-capture a stale window.
- An unguarded `ReleaseMouse()` inside the lost handler pops the stack while `NotifyCaptureLost`
is iterating it. The loop then pops an entry it never notified, or calls `pop_back()` on an
empty vector (undefined behaviour). A `HasCapture()`-guarded release is a harmless no-op there
(next table).
- The documented way to break capture before a modal, `dialog.CaptureMouse(); dialog.ReleaseMouse();`
(`docs/doxygen/overviews/eventhandling.h:878-901`), is undone by the restore step when the
current holder captured through wx: the release re-captures the previous holder. It only breaks
a capture taken natively, outside wx's stack.
### Per-port delivery
| | MSW | GTK3 / GTK2 | macOS |
|---|---|---|---|
| Native capture | `SetCapture` | `gdk_seat_grab` (GTK ≥ 3.20) or `gdk_pointer_grab`. On an unrealized window `DoCaptureMouse` fails a silent `wxCHECK_RET`, but `wincmn` still pushes it (`src/gtk/window.cpp:6732-6765`) | None. `DoCaptureMouse` sets `wxApp::s_captureWindow` and wx reroutes events (`src/osx/window_osx.cpp:596-612`) |
| Capture-lost sent | `WM_CAPTURECHANGED` → `HandleCaptureChanged` → `NotifyCaptureLost`, then `wxEVT_MOUSE_CAPTURE_CHANGED` (`src/msw/window.cpp:5176-5192`) | GTK `grab-broken-event` (`src/gtk/window.cpp:2636-2646`). `wxDialog::ShowModal` and `wxMessageDialog::ShowModal` release the grab and notify (`src/gtk/dialog.cpp:137`, `src/gtk/msgdlg.cpp:285` → `GTKReleaseMouseAndNotify`) | **Never.** `src/osx` has no `NotifyCaptureLost` call |
| `HasCapture()` inside the lost handler | false: `GetCapture()` returns null while `gs_insideCaptureChanged` (`src/msw/window.cpp:757-768`) | false: `g_captureWindow` is cleared before notifying (`src/gtk/window.cpp:6794-6815`) | n/a |
| Modal dialog shown while captured | Lost event only if something takes the native capture (`WM_CAPTURECHANGED`) | Capture released, lost event sent | Capture kept; the dialog cannot be clicked (`src/osx/dialog_osx.cpp` `ShowModal` has no capture code) |
| Captor destroyed | wx stack dangles | `g_captureWindow` cleared (`src/gtk/window.cpp:3088-3089`); wx stack dangles | `s_captureWindow` dangles; the next mouse event dereferences it |
| `wxEVT_CHAR_HOOK` while captured | Not generated (any native `::GetCapture()`) | Not generated | Generated |
| Enter/leave while captured | Synthesized for the captor only (`src/msw/window.cpp:6019-6074`) | Synthesized for the captor only; grab crossings ignored (`src/gtk/window.cpp:2073-2117, 2384-2456`) | Other views' enter/exit events are rerouted to the captor |
### The cancel-on-lost pattern
```cpp
// EVT_MOUSE_CAPTURE_LOST(MyWidget::mouseCaptureLost) in the event table, or Bind(...)
void MyWidget::mouseDown(wxMouseEvent& e)
{
e.Skip(); // let focus move
m_pressed = true;
if (!HasCapture()) CaptureMouse(); // a second button mid-press must not push twice
Refresh();
}
void MyWidget::mouseReleased(wxMouseEvent& e)
{
e.Skip();
if (HasCapture()) ReleaseMouse(); // always, not only when m_pressed
if (!m_pressed) return;
m_pressed = false;
Refresh();
if (GetClientRect().Contains(e.GetPosition()))
sendButtonEvent(); // commit only on a real button-up inside
}
void MyWidget::mouseCaptureLost(wxMouseCaptureLostEvent&)
{
m_pressed = false; // cancel: no commit, no Skip(),
Refresh(); // no CaptureMouse(), no unguarded ReleaseMouse()
}
MyWidget::~MyWidget() { if (HasCapture()) ReleaseMouse(); }
```
Copy the `HasCapture()` guards from `Widgets/Button.cpp` `Button::mouseDown`/`Button::mouseReleased`,
but not the rest of that class's shape: `Button::mouseReleased` releases only inside its
`pressedDown` branch, and `Button::mouseCaptureLost` routes through `mouseReleased` (the commit
path, see Pitfalls). Take the cancel shape from `Widgets/SwitchButton.cpp`
`ModeSwitchButton::mouseCaptureLost`, minus its `Skip()`.
`Widgets/SpinInput.cpp` `SpinInput::createButton` shows the guards on `Bind` lambdas, with
`wxEVT_LEFT_DCLICK` bound next to `wxEVT_LEFT_DOWN`.
macOS never sends the lost event, so a widget that must survive an interrupted gesture there needs
stand-ins. Cancel and release when the top-level window reports `wxEVT_ACTIVATE` with
`GetActive() == false`, or when a `wxEVT_MOTION` arrives during the gesture while
`!wxGetMouseState().LeftIsDown()`.
### Modal dialogs, popups and destruction while captured
- "you shouldn't show a modal dialog from a mouse click event handler as this would break the mouse
capture state" (`interface/wx/event.h:490-499`). The overview adds that a modal shown while
captured "won't receive any mouse input and appear unresponsive"
(`docs/doxygen/overviews/eventhandling.h:878-901`). Release your own capture, then
`CallAfter([…]{ dlg.ShowModal(); })`. The same applies to events a capturing control emits mid-drag
(sash moves, list selection), and to `wxPopupTransientWindow::Popup()`, which takes capture itself on
macOS (see `references/popups-menus.md`).
- `Destroy()` or `delete` of a capturing window leaves a dangling stack entry (`~wxWindowBase`
never removes it), and on macOS a dangling `s_captureWindow`. `Hide()` leaves the hidden window
holding capture; on macOS every click then goes to it. Release first.
- Moving a top-level window from a custom title bar: prefer the window manager's drag over capture.
`BBLTopbar::OnMouseLeftDown` (`BBLTopbar.cpp`) posts `WM_NCLBUTTONDOWN`/`HTCAPTION` on MSW after a
`CaptureMouse(); ReleaseMouse();` pair and calls `gtk_window_begin_move_drag` on GTK. Its fallback
branch (capture, then `Move()` the frame from `OnMouseMotion`) never runs, because `MainFrame`
creates `BBLTopbar` only off macOS (`#ifndef __APPLE__` in the `MainFrame` ctor).
### macOS rerouting and the frozen-UI diagnosis
[source] `wxNSWindow`/`wxNSPanel` `sendEvent:` calls `WX_filterSendEvent:` first
(`src/osx/cocoa/nonownedwnd.mm:141-165`, called at :188 and :293). While `wxWindow::GetCapture()` is
non-null, every NSEvent of type `NSLeftMouseDown` … `NSMouseExited` goes straight to the capture
window's `wxWidgetCocoaImpl::DoHandleMouseEvent`, and `[super sendEvent:]` never runs. That covers
left/right down/up, moved, left/right dragged, entered and exited (types 1–9), on any wx window. AppKit
does no hit-testing at all, not even for the title-bar buttons. Scroll-wheel and other-button
(middle) events are not rerouted. Key events (type ≥ 10) never are.
Symptom of a leaked capture: the app repaints, logs and runs timers, but no click works anywhere,
including the window's own traffic-light buttons and any modal dialog. The keyboard still works:
Cmd+S and Cmd+Q still save and quit, so nothing needs to be force-killed. The capture is permanent,
because nothing on macOS unwinds the stack.
Diagnosis: run `sample <pid> 3` while moving the mouse over the window.
`WX_filterSendEvent:` → `wxWidgetCocoaImpl::DoHandleMouseEvent` on the main thread is the proof,
because that path is only reachable while a capture is held. Otherwise the main thread idles in
`mach_msg` with all threads clean, so do not hunt for a deadlock. Then audit every `CaptureMouse()`
site:
- the capture is guarded by `HasCapture()`;
- it is released whenever held, never behind a drag flag something else can clear;
- the window has a lost handler, plus macOS stand-ins where gestures can be interrupted;
- the window releases before hide/destroy and in its destructor.
Typical causes: an unguarded capture pushed twice (a second button, or a re-entered DOWN path);
a dialog opened mid-press; a stack-allocated dialog or popup destroyed while a child holds capture.
### OrcaSlicer
- `Button`, `DropDown` and `StepCtrl` in `Widgets/` capture in their own mouse handlers and declare
`EVT_MOUSE_CAPTURE_LOST` in their event tables, but their lost handlers share the
route-through-mouse-up shape the Pitfalls below call wrong; follow the cancel pattern above, not
them.
- `GLCanvas3D` guards every capture with `has_mouse_capture()` and releases in
`GLCanvas3D::mouse_up_cleanup()` (`if (m_canvas->HasCapture())`). `GLCanvas3D` is not a
`wxEvtHandler`: it binds on `m_canvas` in `bind_event_handlers()` and must unbind in
`unbind_event_handlers()`.
### Pitfalls
- **Rule:** Guard both calls.
**Why:** An unguarded `ReleaseMouse()` drops whichever window holds the native capture, then
silently skips the stack bookkeeping. An unguarded `CaptureMouse()` in a second DOWN branch pushes
the window twice, and the single release restores it: a leak, which on macOS is the frozen UI.
```cpp
// Wrong:
void up(wxMouseEvent&) { ReleaseMouse(); }
void down(wxMouseEvent&) { CaptureMouse(); } // in each of LEFT/RIGHT/MIDDLE_DOWN
// Right:
void up(wxMouseEvent& e) { e.Skip(); if (HasCapture()) ReleaseMouse(); }
void down(wxMouseEvent& e) { e.Skip(); if (!HasCapture()) CaptureMouse(); }
```
Cite: `src/common/wincmn.cpp:3352-3421`.
- **Rule:** Cancel on capture-lost; never commit.
**Why:** A default `wxMouseEvent` is at (0,0) (`src/common/event.cpp:576-581`), which is inside
`wxRect({0,0}, GetSize())`. Routing the lost event through mouse-up therefore fires `wxEVT_BUTTON`
when capture is lost mid-press (any `wxDialog::ShowModal` on GTK, an external capture change on
MSW), and in a list or dropdown it commits the hovered item.
```cpp
// Wrong: the lost event runs the commit path
void W::mouseCaptureLost(wxMouseCaptureLostEvent&) { wxMouseEvent e; mouseReleased(e); }
// Right:
void W::mouseCaptureLost(wxMouseCaptureLostEvent&) { pressedDown = false; Refresh(); }
```
Cite: `interface/wx/window.h:3814-3817`.
- **Rule:** Never `Skip()` in a lost handler.
**Why:** The event then counts as unprocessed, which is the case wx asserts on in debug builds
(`src/common/wincmn.cpp:3423-3436`).
- **Rule:** Never assume the lost handler will run.
**Why:** macOS never sends it. The `HasCapture()` guards and the up handler carry the whole release
logic there.
## Mouse events
### Coordinates
The position is in client coordinates "of the window which generated the event". Convert with
`ClientToScreen()` and then the other window's `ScreenToClient()` (`interface/wx/event.h:2782-2786`). While a window holds
capture, positions are relative to the capturing window and can be negative or outside its client
rectangle. On macOS they are converted from the event's NSWindow (`src/osx/cocoa/window.mm`
`wxSetupCoordinates`). `GetLogicalPosition(dc)` applies the DC's device origin, e.g. scrolling
(`interface/wx/event.h:3003`). A `wxGLCanvas` works in physical pixels, so `GLCanvas3D::on_mouse`
multiplies by the retina scale under `ENABLE_RETINA_GL`; see `references/webview-gl-aui-media.md`.
### Enter and leave
"the mouse is considered to be inside the window if it is over the window and not inside one of its
children … the parent window receives wxEVT_LEAVE_WINDOW event not only when the mouse leaves the
window entirely but also when it enters one of its children" (`interface/wx/event.h:2776-2780`).
Per port [source]:
- **MSW:** ENTER is synthesized on the first `WM_MOUSEMOVE`, so a click can arrive with no prior
ENTER; LEAVE comes from `TrackMouseEvent` (`src/msw/window.cpp:6019-6074`). Orca's
`GLCanvas3D::on_mouse` handles this as "Workaround for SPE-832" (`on_enter_workaround`): on MSW, a
non-enter event while the cached position is invalid is treated as the enter.
- **GTK:** crossing events with a grab/ungrab mode are ignored; outside capture wx re-derives the
window under the pointer on each motion (`src/gtk/window.cpp:2073-2117, 2384-2456`; fixes #24339
and #24931–#24933 are in this release, `docs/changes.txt:537, 547`).
- **macOS:** each view's `NSTrackingArea` uses `NSTrackingInVisibleRect`
(`src/osx/cocoa/window.mm:3924`) and covers its children, so do not rely on the parent getting
LEAVE when the pointer moves onto a child. `NSMouseMoved` is delivered only to the deepest view
under the pointer (`src/osx/cocoa/window.mm:1508-1517`).
A composite's hover state must therefore track its children. Orca's `StateHandler`
(`Widgets/StateHandler.cpp`, `StateHandler::attach_child`) keeps per-child state for this; see
`references/painting-custom-widgets.md`. Inside a `wxPopupTransientWindow` on macOS, Orca's
`PopupWindow` (created with `wxPU_CONTAINS_CONTROLS`) synthesizes ENTER/LEAVE itself; re-check geometry there
(`references/popups-menus.md` §6).
### Button state and clicks
- `LeftDown()` means "this event is the press"; `LeftIsDown()` means "the button is held now"; "if
wxMouseEvent::LeftDown returns true, wxMouseEvent::LeftIsDown will also"
(`interface/wx/event.h:2788-2799`). `Dragging()` is MOTION with any button down
(`include/wx/event.h:1850-1853`).
- **macOS:** button flags are filled only for Down/Dragged NSEvents (`mouseChord`,
`src/osx/cocoa/window.mm:645-700`). ENTER/LEAVE, plain motion, UP and wheel events report every
button as up. Use `wxGetMouseState()`, which reads `[NSEvent pressedMouseButtons]`
(`src/osx/cocoa/utils.mm` `wxGetMouseState`). `GLCanvas3D::on_mouse` back-fills the event from
`wxGetMouseState()` only when the event carries no button, "to preserve wx's synthetic right button
for Ctrl+left".
- **macOS Ctrl+click is a right click:** button 0 with `NSControlKeyMask` becomes the right button for
the whole down/drag/up sequence (`src/osx/cocoa/window.mm:664-686`; documented as an emulation hint at
`interface/wx/event.h:2772-2774`). `RawControlDown()` stays true on that `wxEVT_RIGHT_DOWN`, so a
`GetModifiers() == wxMOD_NONE` test fails on it.
- `wxEVT_LEFT_DOWN` handlers "should normally call event.Skip() … otherwise the window under mouse
wouldn't get the focus" (`interface/wx/event.h:2803-2805`). `Skip()` on a by-value copy is lost,
because dispatch checks the original event (`src/common/event.cpp:1459-1477`; see
`references/events.md`).
- **Double click:** on every port the second press arrives as `wxEVT_LEFT_DCLICK` **instead of**
`wxEVT_LEFT_DOWN`, giving DOWN, UP, DCLICK, UP. MSW maps `WM_LBUTTONDBLCLK` (window classes use
`CS_DBLCLKS`, `src/msw/app.cpp:584`). GTK drops the surplus press before `GDK_2BUTTON_PRESS`
(`src/gtk/window.cpp:1798-1817`), and GTK2 also suppresses triple clicks (`:1818-1829`). On wxOSX the
third press of a triple click is a DOWN again, as on MSW (`src/osx/cocoa/window.mm:4105-4140`;
#25886, `docs/changes.txt:316`). `GetClickCount()` is "implemented only in wxMac and returns -1
for the other platforms" (`interface/wx/event.h:2965-2974`).
- **Context menu:** "under MSW the context menu event is generated after EVT_RIGHT_UP … but under
GTK … after EVT_RIGHT_DOWN", so a window handling `wxEVT_CONTEXT_MENU` must not handle (or must
`Skip()`) both right-button DOWN and UP (`interface/wx/event.h:3306-3312`).
### Wheel
`GetWheelDelta()` is "normally 120", and "you shouldn't assume that one event is equal to 1 line"
(`interface/wx/event.h:3020-3055`). [source]:
| Port | `GetWheelDelta()` | `GetWheelRotation()` |
|---|---|---|
| MSW | `WHEEL_DELTA` | raw (`src/msw/window.cpp:6117-6118`) |
| GTK3 | 120 | `120 × delta` from `GDK_SCROLL_SMOOTH`, fractional streams (`src/gtk/window.cpp:2177, 2207-2241`) |
| GTK2 | 120 | ±120 per notch |
| macOS | **10** | precise scrolling deltas from trackpads; non-precise wheels ×10 (`src/osx/cocoa/window.mm:800-850`) |
A diagonal trackpad scroll on macOS sends two events, vertical first, then one with
`GetWheelAxis() == wxMOUSE_WHEEL_HORIZONTAL`. GTK3 smooth scrolling splits it too, horizontal first
(`src/gtk/window.cpp:2207-2241`).
### Pitfalls
- **Rule:** Take mouse events by reference.
**Why:** `Skip()` on a copy does not reach the dispatcher, so the event counts as handled; focus
and default processing stop.
```cpp
// Wrong:
w->Bind(wxEVT_LEFT_DOWN, [](wxMouseEvent e) { /*...*/ e.Skip(); });
// Right:
w->Bind(wxEVT_LEFT_DOWN, [](wxMouseEvent& e) { /*...*/ e.Skip(); });
```
- **Rule:** Bind DCLICK wherever presses are counted (spin arrows, steppers, toggles).
**Why:** every second press of a fast pair is a DCLICK; a DOWN-only handler loses it.
Cite: `Widgets/SpinInput.cpp` `SpinInput::createButton`.
- **Rule:** Don't use `evt.LeftIsDown()` in LEAVE, plain MOTION or UP handlers on macOS.
**Why:** the flags are only filled for Down/Dragged NSEvents.
```cpp
// Wrong: if (evt.Leaving() && evt.LeftIsDown()) keep_drag();
// Right: if (evt.Leaving() && wxGetMouseState().LeftIsDown()) keep_drag();
```
- **Rule:** Normalise wheel steps.
**Why:** the macOS delta is 10, and GTK3/trackpads send fractional streams. Dividing by a
hard-coded 120 makes a macOS wheel notch (rotation 10) 12× too slow; a fixed step per event races
or jitters on trackpad streams.
```cpp
// Wrong:
zoom += evt.GetWheelRotation() / 120.0; // or: rot > 0 ? step : -step per event
// Right:
if (evt.GetWheelAxis() != wxMOUSE_WHEEL_VERTICAL) return evt.Skip();
m_acc += double(evt.GetWheelRotation()) / evt.GetWheelDelta();
while (std::abs(m_acc) >= 1.0) { step(m_acc > 0 ? 1 : -1); m_acc -= (m_acc > 0 ? 1 : -1); }
```
## Global pointer position and Wayland
| API | Contract | GTK on Wayland [source] |
|---|---|---|
| `wxGetMousePosition()` | screen coordinates (`interface/wx/utils.h:360-365`) | `gdk_device_get_position` with no Wayland handling (`src/gtk/window.cpp:7026-7042`). Wayland exposes no global pointer position to clients [external]; Orca's comments record (0,0) |
| `wxGetMouseState()` | position, buttons and modifiers (`interface/wx/utils.h:367-375`) | position unreliable, same path (`src/gtk/window.cpp:2827-2867`) |
| `ClientToScreen()`, `ScreenToClient()`, `GetScreenRect()` | — | add the top-level window's `gdk_window_get_origin()` (`src/gtk/window.cpp:4630-4698`). Without a global origin the result is only meaningful relative to the same top-level window: fine for deltas and hit tests inside one window, wrong across windows or for absolute placement |
| `wxFindWindowAtPoint(pt)` | deepest window at a screen point; disabled children count, hidden ones are skipped (`interface/wx/utils.h:385-394`) | built on screen rectangles, so unreliable. On GTK it is `wxGenericFindWindowAtPoint`, a walk over every top-level window and child (`src/gtk/utilsgtk.cpp:98-101`, `src/common/utilscmn.cpp:1294-1345`) |
| `wxGetKeyState(key)` | "In wxGTK, this function can be only used with modifier keys … when not using X11 backend" (`interface/wx/utils.h:352-358`) | Ctrl/Alt/Shift and Caps/Num/Scroll Lock only; any other key returns false (`src/unix/utilsx11.cpp:2596-2662`) |
| `WarpPointer()` | Apple's HIG forbids it; on Wayland it works only with a compositor implementing the pointer-warp protocol, and mutter also needs a pressed button (`interface/wx/window.h:3895-3914`; `docs/changes.txt:294`) | — |
| `wxUIActionSimulator` | "doesn't work when using Wayland" (`interface/wx/uiaction.h:20`) | — |
| `PopupMenu(x, y)` | — | GTK ≥ 3.22 positions it relative to the window (`gtk_menu_popup_at_rect`, `src/gtk/window.cpp:6520-6565`), so it is safe |
On wxOSX `WarpPointer` synthesizes a `wxEVT_MOTION` to the window (`src/osx/window_osx.cpp:1370-1398`),
so warping from a motion handler recurses. On GTK2, pointer queries and warps go straight to X11.
**OrcaSlicer.** Detect the backend with `Slic3r::GUI::is_running_on_wayland()` / `is_running_on_x11()`
(`LinuxDisplayBackend.hpp`), never with environment variables (`references/platforms.md`). Models:
- `SearchDialog` (`Search.cpp`) dismisses by focus on Wayland (`focus_left_popup`) instead of
comparing `wxGetMousePosition()` with its screen rectangle.
- `BBLTopbar::FindToolByCurrentPosition` (`BBLTopbar.cpp`) uses the last event position and returns
null on Wayland rather than query the global pointer.
- `BBLTopbar::OnMouseMotion` and `Button::OnParentMotion` use `ClientToScreen(event.GetPosition())`
within one window ("wxGetMousePosition() … returns (0,0) on Wayland").
### Pitfalls
- **Rule:** On Linux, never rely on global screen coordinates.
**Why:** wx delegates straight to GDK and does not compensate on Wayland, so hit tests against
`wxGetMousePosition()` silently fail or misfire.
```cpp
// Wrong:
if (!GetScreenRect().Contains(wxGetMousePosition())) Dismiss();
if (wxFindWindowAtPoint(wxGetMousePosition()) != this) /* ... */;
// Right: event-relative, same window (convert through the event's GetEventObject() window)
if (!GetClientRect().Contains(evt.GetPosition())) Dismiss();
// or, for popups and dialogs, focus tracking (focus_left_popup is file-local to Search.cpp):
if (focus_left_popup(this, wxWindow::FindFocus(), related)) Dismiss();
```
- **Rule:** Track held keys with KEY_DOWN/KEY_UP, not `wxGetKeyState(letter)`.
**Why:** it is always false for letters on Wayland, so held-key and repeat detection built on it
(including pruning a held-key record with it) breaks there. Clear such a record from KEY_UP.
## Hover handlers and enter/leave feedback loops
**Rule.** In `wxEVT_ENTER_WINDOW`/`wxEVT_LEAVE_WINDOW` handlers, only record hover state, per child
window, because enter and leave fire per window. Apply `Show()`/`Hide()` + `Layout()` from
`wxEVT_IDLE` or `CallAfter`, and only when the desired state differs from the current one.
`Skip()` the events.
**Why.** A layout change inside the handler moves windows under the pointer, which re-fires
enter/leave. On Wayland compositors that keep hidden-workspace surfaces mapped (Hyprland), GTK
delivers a stream of synthetic leave events, and a handler that re-layouts pegs a CPU core
(69e16cd7ef). The fix that moved show/hide out of the handlers removed the freeze caused by the
dynamically hidden printer edit button (e87625e023). Two wx facts block the obvious guards:
- `wxFindWindowAtPoint()` is a full window-tree walk on GTK and unreliable on Wayland (table above).
- `IsShownOnScreen()` is not a visibility test on any platform. It only checks `IsShown()` up the
parent chain (`src/common/wincmn.cpp:1205-1212`, `interface/wx/window.h:3101-3102`), plus "surface
exists" for `wxGLCanvas` on Unix. It never reflects minimised, occluded or other-workspace state,
which is why `Plater::priv::set_current_panel` says "wxWidgets IsShownOnScreen() is buggy and
cannot be used reliably".
**OrcaSlicer.** The Sidebar printer, nozzle and bed panels (`Plater.cpp`, `Sidebar` ctor) follow
this design:
- each child's ENTER/LEAVE lambda inserts or erases the window in a per-group
`std::shared_ptr<std::unordered_set<wxWindow*>>` (e.g. `printer_preset_hovered`) and sets the border
colour;
- a `wxEVT_IDLE` handler compares `!hovered->empty()` with `btn_edit_printer->IsShown()` and calls
`Show()`/`Hide()` + `Layout()` only on a difference (keep such a handler a cheap comparison: it runs on every
idle pass, hidden panels included, `references/events.md` §12);
- clicking the edit button clears the set inside `CallAfter`, because opening the preset tab sends
no LEAVE (ff4147ede3), and because hiding a button from inside its own event handler crashed on
wxGTK.
```cpp
// Wrong: walks the tree and re-layouts inside the event (feedback loop)
w->Bind(wxEVT_LEAVE_WINDOW, [=](wxMouseEvent& e) {
if (!hit(wxFindWindowAtPoint(wxGetMousePosition()))) { btn->Hide(); panel->Layout(); }
e.Skip(); });
// Right: record, then apply once from idle
w->Bind(wxEVT_ENTER_WINDOW, [=](wxMouseEvent& e) { hovered->insert(w); e.Skip(); });
w->Bind(wxEVT_LEAVE_WINDOW, [=](wxMouseEvent& e) { hovered->erase(w); e.Skip(); });
Bind(wxEVT_IDLE, [=](wxIdleEvent& e) {
if (btn->IsShown() != !hovered->empty()) { btn->Show(!hovered->empty()); panel->Layout(); }
e.Skip(); });
```
Cite: 69e16cd7ef, e87625e023, ff4147ede3 (`Plater.cpp`).
## Keyboard events
### Event order per port
[source] One key press, in order:
| Port | Sequence |
|---|---|
| MSW | thread keyboard hook `wxKeyboardHook` → `wxEVT_CHAR_HOOK` to the focus window (or the active window) — skipped while any native `::GetCapture()` exists or a non-wx modal (IME) is open; with IME open a handled hook does not stop the key reaching the IME (`src/msw/window.cpp:7320-7395`) → `wxGUIEventLoop::PreProcessMessage`: accelerator walk from the focus up to the first `IsTopNavigationDomain(Navigation_Accel)` window, with the text-entry exemption (`src/msw/evtloop.cpp:62-119`) → `IsDialogMessage`, which is never given `VK_ESCAPE` (`src/msw/window.cpp:2769-2778`) → `wxEVT_KEY_DOWN` → `wxEVT_CHAR` |
| GTK | `wxEVT_CHAR_HOOK`, skipped while `g_captureWindow` is set → accelerator walk to the top-level window, no text-entry exemption → `wxEVT_KEY_DOWN` → input method → `wxEVT_CHAR` (`src/gtk/window.cpp:1266-1420`) |
| macOS | the main menu's `performKeyEquivalent:`: a menu-bar key equivalent consumes the key before wx sees it (`src/osx/cocoa/window.mm:1611-1644`) → `wxEVT_CHAR_HOOK`, also during capture → accelerator walk, only if no `wxEVT_CHAR_HOOK` handler processed the event (`src/osx/window_osx.cpp:2547-2590`) → Tab navigation by the first `wxTAB_TRAVERSAL` ancestor unless the window has `wxWANTS_CHARS` (`src/osx/cocoa/window.mm:4009-4046`) → `interpretKeyEvents` (IME) → `wxEVT_KEY_DOWN` → `wxEVT_CHAR` (`:4048-4101`). A disabled window gets no key events (`:1616-1617`) |
### wxEVT_CHAR_HOOK
Contract (`interface/wx/event.h:1487-1508`): "Unlike all the other key events, this event is
propagated upwards the window hierarchy … generated before any other key events". If a handler
processes it without `Skip()`, "neither wxEVT_KEY_DOWN nor wxEVT_CHAR events will be generated
(although wxEVT_KEY_UP still will be)". `DoAllowNextEvent()` handles it and still lets normal events
through (`:1683-1702`). It "is not generated when the mouse is captured". [source] Exceptions and
details:
- **macOS** has no capture check, so the hook fires during drags.
- **wxGTK** reads the allow flag from the original event rather than the hook event, so
`DoAllowNextEvent()` has no effect there (`src/gtk/window.cpp:1266-1282`).
- Propagation stops only at windows with `wxWS_EX_BLOCK_EVENTS` (`src/common/wincmn.cpp:3498-3522`).
`wxDialog` sets it (`src/common/dlgcmn.cpp:127`), and so does `wxPopupWindow` on MSW and macOS
(`wxPopupWindowBase::Create`, `src/common/popupcmn.cpp:135`). wxGTK's `wxPopupWindow::Create`
never calls the base, so a GTK popup does not block (`src/gtk/popupwin.cpp`). Frames do not
either: keys typed in a modeless frame (or a GTK popup) parented to `MainFrame` reach
`MainFrame`'s hook.
`wxEVT_KEY_DOWN`/`wxEVT_CHAR` are not command events and do not propagate
(`docs/doxygen/overviews/eventhandling.h:536-544`). If `wxEVT_KEY_DOWN` is handled without `Skip()`, "the
corresponding char event … will not happen … Not doing may also prevent accelerators defined using
this key from working" (`interface/wx/event.h:1456-1463`).
**OrcaSlicer — dialog keys.** `DPIAware<wxDialog>` (`GUI_Utils.hpp`) binds `wxEVT_CHAR_HOOK`: Esc
calls `Close()`, everything else is skipped. A dialog that needs Esc or Enter itself binds its own
hook. Later `Bind`s run first (`docs/doxygen/overviews/eventhandling.h:474-482`), so the dialog's
hook runs before `DPIAware`'s. Orca's `::Button` is not a `wxButton`, so wx's default-button and
Esc emulation (`EmulateButtonClickIfPresent`) never finds it. Dialogs synthesize the click instead,
as the `CloneDialog` ctor does for Enter in its count field:
```cpp
Bind(wxEVT_CHAR_HOOK, [this, ok_btn](wxKeyEvent& e) {
const int key = e.GetKeyCode();
if ((key == WXK_RETURN || key == WXK_NUMPAD_ENTER) && m_count_spin->GetTextCtrl()->HasFocus()) {
wxCommandEvent evt(wxEVT_BUTTON, ok_btn->GetId());
ok_btn->GetEventHandler()->ProcessEvent(evt);
} else
e.Skip();
});
```
The full Esc/close path is in `references/windows-dialogs.md`.
### Key codes
- Use `GetUnicodeKey()` for printable characters and `GetKeyCode()` for `WXK_*` specials
(`interface/wx/event.h:1329-1346`).
- `wxEVT_KEY_DOWN`/`UP`: ASCII letters are their upper-case code; other Latin-1 characters (`ù`,
`ö`, `²`) are the character itself, not upper-cased; keys producing non-Latin printable characters
report "the ASCII code of the character the same key would produce in the standard US keyboard
layout"; specials are their `WXK_*` (`:1371-1394`). A Cyrillic `ц` gives `'W'`, so Ctrl+letter
shortcuts work across layouts, but an AZERTY key reports its own label (`$` where US has `]`), so
`Ctrl-;`-style punctuation accelerators may be untypeable on some layouts (`:1396-1407`). wxGTK
got the non-Latin mapping in 3.3.0 (#23379, `docs/changes.txt:540`).
- `wxEVT_CHAR` reflects Shift and the layout. Ctrl+letter gives 1..26 (`WXK_CONTROL_A` …,
`:1409-1421`). Exception: on macOS Cmd+letter's CHAR is the letter itself (`'a'`/`'A'`); only the
physical Control key yields 1..26 (`src/osx/cocoa/window.mm:304-307`).
- Documented inconsistencies: Ctrl-Backspace, Ctrl-Enter, and on GTK no CHAR for Ctrl + a letter
mapped to a non-Latin one (`:1423-1433`). Modifier keys generate no CHAR (`:1435`).
- `IsAutoRepeat()` is `@onlyfor{wxosx,wxmsw,wxQt}`, not GTK (`:1595-1601`).
`KeyChord::from_event` (`KeyChord.cpp`) normalises all of this: it up-cases letters, folds numpad
keys onto the main keyboard, maps CHAR control codes 1..26 back to letters, and drops Shift from
CHAR punctuation, because the character already reflects it. Use it rather than testing raw codes.
### Modifiers and the Cmd mapping
| Query | MSW / GTK | macOS |
|---|---|---|
| `ControlDown()`, `wxMOD_CONTROL`, `wxACCEL_CTRL`, `"Ctrl+"` in accelerator strings | Ctrl | **Cmd** |
| `RawControlDown()`, `wxMOD_RAW_CONTROL`, `WXK_RAW_CONTROL`, `wxACCEL_RAW_CTRL`, `"RawCtrl+"` | Ctrl (same value as the above) | the physical Control key (a distinct bit) |
| `CmdDown()`, `wxMOD_CMD`, `wxACCEL_CMD` | deprecated aliases of `ControlDown()`/`wxMOD_CONTROL`/`wxACCEL_CTRL` | same |
Sources: `interface/wx/kbdstate.h:38-130`, `interface/wx/accel.h:15-30`, `include/wx/defs.h:2372-2388`,
`include/wx/accel.h:29-41`.
`interface/wx/kbdstate.h:45` claims `wxMOD_CMD` is `wxMOD_META` on Mac; the code defines `wxMOD_CMD = wxMOD_CONTROL`
everywhere [source]. Prefer `GetModifiers() == wxMOD_CONTROL`: `ControlDown()` alone is also true for
Ctrl+Shift, and for AltGr, which reports as Ctrl+Alt (`interface/wx/kbdstate.h:38-70`).
### wxWANTS_CHARS and navigation
- `wxWANTS_CHARS`: the window "get[s] all char/key events for all keys — even for keys like TAB or
ENTER"; call `Navigate()` yourself for Tab (`interface/wx/window.h:225-232`). On macOS wx's Tab
navigation is skipped for such windows, and otherwise Tab is consumed by the first
`wxTAB_TRAVERSAL` ancestor before `wxEVT_KEY_DOWN` (`src/osx/cocoa/window.mm:4009-4046`). The GL
canvas is created with it (`OpenGLManager::create_wxglcanvas`), so Tab and arrows arrive.
`GLCanvas3D::on_key` does not `Skip()` Tab and arrows and skips everything else "to have EVT_CHAR
generated".
- `wxTAB_TRAVERSAL` "should almost never be used in the application code"
(`interface/wx/window.h:221-224`). A composite with focusable children derives from
`wxNavigationEnabled<Base>` (`interface/wx/containr.h:10-46`).
- `Navigate(flags)` "is equivalent to calling NavigateIn() method on the parent"
(`interface/wx/window.h:2968-2988`). `HandleAsNavigationKey(evt)` (`:2715-2724`) and
`MoveAfterInTabOrder`/`MoveBeforeInTabOrder` (`:2949-2966`) complete the set.
- `DisableFocusFromKeyboard()` removes a window from the Tab chain but keeps click focus
(`interface/wx/window.h:497-506`). `SpinInput`'s arrow buttons use it.
**OrcaSlicer — `::Button`.** `Button::keyDownUp` turns Space/Return into LEFT_DOWN/UP and passes Tab
and arrows to `HandleAsNavigationKey`. The synthesized LEFT_DOWN runs `Button::mouseDown`, so a held
Space/Return holds the mouse capture until its KEY_UP reaches the button. On MSW
`Button::MSWWindowProc` answers `WM_GETDLGCODE` with `DLGC_WANTMESSAGE`.
### Pitfalls
- **Rule:** Catch children's keys with `wxEVT_CHAR_HOOK` on the parent.
```cpp
// Wrong: never sees keys typed in child controls
panel->Bind(wxEVT_KEY_DOWN, &P::on_key, this);
// Right:
panel->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { if (!handle(e)) e.Skip(); });
```
- **Rule:** A top-level hook lets text-editing keys through to a focused text entry.
**Why:** the hook runs before the text control and before MSW's text-entry accelerator
exemption. Eating bare printable keys, Space, Ctrl+C/V/X/A/Z, Delete, Home/End or arrows breaks
them in every field of the window.
```cpp
// Wrong:
Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { if (e.GetKeyCode() == WXK_DELETE) delete_selection(); else e.Skip(); });
// Right:
Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) {
if (e.GetKeyCode() == WXK_DELETE && !dynamic_cast<wxTextEntryBase*>(wxWindow::FindFocus())) delete_selection();
else e.Skip(); });
```
Cite: `MainFrame.cpp` `focus_keeps_space` (text entries, `wxWebView`, `::Button`, `StaticBox`
composites and `wxControl`s keep Space).
- **Rule:** Don't expect hook-based shortcuts during a drag on MSW/GTK, and guard drag state on
macOS, where they do fire.
- **Rule:** Ctrl+letter in `wxEVT_CHAR` is a control code.
```cpp
// Wrong: if (e.GetEventType() == wxEVT_CHAR && e.ControlDown() && e.GetKeyCode() == 'a')
// Right: if (KeyChord::from_event(e) == KeyChord{'A', wxMOD_CONTROL}) // or match on KEY_DOWN
```
- **Rule:** Modifier tests are exact.
```cpp
// Wrong: if (e.ControlDown() && e.GetKeyCode() == 'C') // also AltGr+C, Ctrl+Shift+C
// Right: if (e.GetModifiers() == wxMOD_CONTROL && e.GetKeyCode() == 'C')
```
## Accelerators and menu shortcuts
### Contract
- "An accelerator takes precedence over normal processing" (`interface/wx/accel.h:178`), but it
runs after `wxEVT_CHAR_HOOK` on every port (table above).
- [source] On GTK and macOS a hit is sent as `wxEVT_MENU` with the entry's id and, if unprocessed,
retried as `wxEVT_BUTTON`; macOS treats the key as consumed either way
(`src/gtk/window.cpp:1340-1366`, `src/osx/window_osx.cpp:2563-2590`). On MSW the `WM_COMMAND` goes to
a child window with that id if one exists (a `wxButton` clicks), otherwise it becomes `wxEVT_MENU`
(`src/msw/window.cpp` `wxWindowMSW::HandleCommand`).
- Matching up-cases a–z (`src/generic/accel.cpp:86-88`, `src/osx/accel.cpp:59`) and needs an
**exact** modifier match (`src/generic/accel.cpp:155-180`; `src/osx/accel.cpp:69-84`, which also
matches RawCtrl). Ctrl+Shift+Z needs its own entry.
- `SetAcceleratorTable()` **replaces** the window's single table; nothing merges. The walk runs from
the focused window up to the top-level window, so a table works only while focus is inside its
window, and a dialog never sees its parent frame's table [source].
- Menu strings take `"Label\tCtrl+X"`: modifiers `CTRL`/`RAWCTRL`/`ALT`/`SHIFT` joined by `+` or `-`,
plus the special key names listed in `interface/wx/menuitem.h:469-555`.
`wxAcceleratorEntry::FromString` accepts the bare accelerator or the legacy `"Label\tAccel"`.
`ToRawString()` is untranslated, for config files (`interface/wx/accel.h:124-146`).
`AddExtraAccel` is `@onlyfor{wxmsw,wxgtk}` (`interface/wx/menuitem.h:643-647`).
### Platforms
- **Menu accelerators exist only for menus attached to a frame's `wxMenuBar`** [source]. MSW merges
them in `wxMenuBar::RebuildAccelTable` (`src/msw/menu.cpp:1264-1292`); GTK adds the menu's accel
group to the top-level window in `AttachToFrame` (`src/gtk/menu.cpp:239-247`). A menu shown with
`PopupMenu` treats `"\tCtrl+X"` as display text.
- **MSW:** accelerators are not translated while a `wxTextCtrl`/`wxComboBox`/`wxSpinCtrl` has focus and
the key is a text-editing key: Ctrl+A/C/V/X/Ins/Del/Home/End/Left/Right, Shift+those navigation
keys, bare Del/Home/End, Alt+Backspace (`src/msw/textentry.cpp:1073-1150`). Multi-line controls also
keep Enter (`src/msw/textctrl.cpp:2110-2135`).
- **GTK:** the accelerator walk has no such exemption (`src/gtk/window.cpp:1340-1366`). As menu
accelerators, Shift with non-alphabetic keys does not work, bare arrow keys do not work, and the
listed keys (Tab, the modifiers, locks …) are unsupported (`interface/wx/menuitem.h:562-575`).
- **macOS:** menu-bar items become `NSMenuItem` key equivalents
(`src/osx/cocoa/menuitem.mm` `wxMacCocoaMenuItemSetAccelerator`). They run before any wx key event,
including while a text field has focus. A menu-bar accelerator on a printable key without a
modifier, or on a text-editing chord such as Cmd+C, takes that key from typing.
### OrcaSlicer menus
The native `wxMenuBar` exists only on macOS (`MainFrame::init_menubar_as_editor`, Preferences under
`OSXGetAppleMenu()`). On Windows and Linux the same `wxMenu`s hang off `BBLTopbar`
(`GetTopMenu()`, `SetFileMenu`, `AddDropDownSubMenu`), so their labels are display-only and the keys
are dispatched by the registry (`MainFrame`'s hook, the canvases). Add shortcut-bearing items with
`MainFrame::append_shortcut_item(menu, Shortcut::X, accelerator, label, …)`:
- `accelerator == true` and the binding is menu-safe (`ShortcutRegistry::accelerator()` is non-empty,
i.e. `KeyChord::is_menu_accelerator()`): the label is `label + "\t" + accel`, a live key equivalent
on macOS.
- Otherwise the binding is appended as text. The separator is `" - "` on Apple, so the macOS menu bar
does not take it as a key equivalent, and `"\t"` elsewhere (`MainFrame::shortcut_label`). The source
comment cites #8152: the macOS menu bar "handles the key accelerators automatically and breaks key
handling in normal typing". The macOS Edit menu shows clipboard and undo this way, so that Cmd+C in
a text field copies text rather than objects.
- `MainFrame::update_shortcut_labels()` rewrites every tracked item after a rebinding
(`GUI_App::on_shortcuts_changed`).
On Apple `MainFrame`'s hook keeps Cmd+H (consumed; the app menu hides), Cmd+M (`Iconize()`), Cmd+Q
(posts `wxEVT_CLOSE_WINDOW`) and Cmd+Ctrl+F (`EnableFullScreenView(true)` + `ShowFullScreen`
toggle). Preferences (Cmd+, / Ctrl+P) is a Global registry shortcut.
### Pitfalls
- **Rule:** One table per window, built once from all entries.
```cpp
// Wrong: the second call discards the first table
SetAcceleratorTable(wxAcceleratorTable(1, &copy)); SetAcceleratorTable(wxAcceleratorTable(1, &paste));
// Right:
wxAcceleratorEntry e[] = { copy, paste }; SetAcceleratorTable(wxAcceleratorTable(2, e));
```
- **Rule:** Don't expect `MainFrame`'s keys inside a dialog: each top-level window needs its own
handling (dialogs block `wxEVT_CHAR_HOOK` propagation and the accelerator walk stops at them).
## Orca's shortcut registry
The registry is the one table every key dispatcher, menu label, tooltip and the shortcuts dialog
reads; the design is in `docs/HLSD/keyboard-shortcuts.md`.
**Data model.**
- `KeyChord` (`KeyChord.hpp`) is `{key, modifiers}`: the key as `wxEVT_KEY_DOWN` reports it, plus
`wxMOD_*` limited to CONTROL|SHIFT|ALT|RAW_CONTROL. Its canonical text (`to_string()`, e.g.
`Ctrl+Shift+S`) is platform-neutral and doubles as the wx accelerator string and the config format.
`display()` uses translated modifier names and the macOS glyphs. Predicates:
- `needs_char_event()`: a printable non-alphanumeric key with at most Shift, resolvable only from
the CHAR that follows.
- `is_punctuation()`: such a key with no modifier.
- `is_menu_accelerator()`: Ctrl, Alt or RawCtrl held, or a non-printable key other than Space.
- `is_system_shortcut()`: Alt+F4 and Alt+Space on Windows.
- `to_accelerator_entry()` maps RawCtrl to `wxACCEL_RAW_CTRL` on Apple.
- `Shortcut` (enum) and `shortcut_table` (`Shortcuts.cpp`): each row, written with
`SHORTCUT`/`REPEATING`/`STEPPING`, holds a config key, description, context mask, default chord, and
the `repeatable`/`modifier_variants` flags. `static_assert`s keep the table in enum order and
`section_table` ascending.
- `ShortcutRegistry` (`wxGetApp().shortcuts()`) overlays user overrides from the AppConfig section
`shortcuts`; only overrides are stored, and `none` records an unbound shortcut.
`ShortcutRegistry::load()` drops a Global override that fails `is_menu_accelerator()` ("A Global
chord is seen before any text field").
- Lookups: `lookup(context, chord)` matches exactly. `match()` falls back to `modifier_variants`
shortcuts with Shift/Ctrl added, which are reserved steps (`step_owner()`). `conflicts()` treats
Global as sharing every context.
**Contexts and dispatch.**
| Context | Dispatcher | Event |
|---|---|---|
| `Global` | `MainFrame::handle_global_shortcut`, from `MainFrame`'s `wxEVT_CHAR_HOOK`, before the focused child gets KEY_DOWN/CHAR (a child's own CHAR_HOOK handler still runs first); `Skip()` otherwise | CHAR_HOOK |
| `Plater`, `Preview` | `GLCanvas3D::handle_shortcut` (→ `ShortcutRegistry::match`) from `GLCanvas3D::on_key`; punctuation retried from `GLCanvas3D::on_char` | KEY_DOWN, CHAR |
| `ObjectList` | `ObjectList::dispatch_shortcut`, from `ObjectList::key_event` on CHAR (non-macOS); on macOS from a `wxAcceleratorTable` built by `ObjectList::update_shortcut_accelerators()` (ids from `wxWindow::NewControlId`), because the native data view delivers no keys there | CHAR / accelerator |
| `Painting` | a painting gizmo's `on_tool_shortcut` (`GLGizmosManager`) | KEY_DOWN |
- A Global chord needs Ctrl/Alt or a non-typing key, because it is seen before any text field. Space
counts as typing: the speed dial's bare-Space default is skipped when `focus_keeps_space(FindFocus())`.
- Shortcuts in other contexts may share a chord when their contexts do not overlap.
- The macOS ObjectList table is swapped to `wxNullAcceleratorTable` while a name is being edited and
restored in `ObjectList::OnEditingDone`.
- wxGTK reports no auto-repeat, so the canvases keep one shared record of keys seen going down
(`key_repeats`/`key_released`) and swallow repeats of non-`repeatable` shortcuts.
**Adding a shortcut.**
1. Add the `Shortcut` value and its row in `shortcut_table`, in dialog order (a new section also needs a
`section_table` entry). Pick a default that does not collide inside its contexts; the `[Shortcuts]`
tests check every default.
2. Handle it in the context's dispatcher: `MainFrame::handle_global_shortcut`,
`GLCanvas3D::handle_shortcut`, `ObjectList::dispatch_shortcut`, or a gizmo's `on_tool_shortcut`. A
gizmo opened by a key sets `m_shortcut` in its `on_init()` (e.g. `GLGizmoMove3D::on_init`).
3. Where the UI shows the key, ask the registry: `display()` for tooltips, `accelerator()` (via
`append_shortcut_item`) for menus. Never write a literal key name.
`KBShortcutsDialog::fill_pages` lists registry entries automatically. Only non-rebindable keys and
mouse actions are added there by hand (`fixed(...)`, `mouse(...)`). Edits go through
`GUI_App::on_shortcuts_changed()`, which saves and pushes to menus, canvas tooltips and the macOS
ObjectList table.
**Model for capturing raw chords:** `ShortcutCaptureDialog` (`KBShortcutsDialog.cpp`).
- A `wxWANTS_CHARS` `StaticBox` keeps focus, so the buttons never get the keys; focus is set by
`capture->CallAfter(... SetFocus())` after construction.
- `wxEVT_CHAR_HOOK` on the dialog handles Esc/Enter, records KEY_DOWN chords and `Skip()`s
`needs_char_event()` chords.
- A `wxEVT_CHAR` handler on the box records punctuation.
- Its comment notes that the hook runs before the window procedure, so Windows does not open its
window menu on Alt+Space.
```cpp
// Wrong: ad-hoc key test plus a hand-written label
if (evt.GetKeyCode() == 'E' && evt.ControlDown()) export_gcode();
append_menu_item(menu, wxID_ANY, _L("Export") + "\tCtrl+E", ...);
// Right: registry row + dispatcher case + registry-derived label
case Shortcut::ExportSlicedFile: if (can_export_gcode()) wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_EXPORT_SLICED_FILE)); return false;
append_shortcut_item(export_menu, Shortcut::ExportSlicedFile, true, _L("Export plate sliced file") + dots, ...);
```
## Focus
### Contract
- `SetFocus()` "sets the window to receive keyboard input" (`interface/wx/window.h:567-572`).
`FindFocus()` is static (`:4274-4283`). `HasFocus()` also covers a composite's main child
(`:529-536`).
- `AcceptsFocus()` returning false means the control "doesn't accept input at all" (`:472-480`).
`AcceptsFocusFromKeyboard()` controls Tab-chain membership (`:482-488`). `CanAcceptFocus()` is
`AcceptsFocusRecursively() && IsShown() && IsEnabled()` (`include/wx/window.h:751-766`).
- `SetCanFocus()` "is only implemented by ports which have support for native TAB traversal … A call
to this does not disable or change the effect of programmatically calling SetFocus()"
(`interface/wx/window.h:538-548`).
- Focus handlers "should almost invariably call wxEvent::Skip() … wxEVT_KILL_FOCUS handler must not
call wxWindow::SetFocus()" (`interface/wx/event.h:3405-3416`). The event's `GetWindow()` "may be
NULL" (`:3438-3445`). `wxEVT_CHILD_FOCUS` derives from `wxCommandEvent` and propagates, and its
window is the *direct* child (`:3453-3491`).
- `wxPanel::SetFocus` focuses the first child if "the control has at least one child";
`SetFocusIgnoringChildren()` focuses the panel itself (`interface/wx/panel.h:125-142`).
- `wxGetActiveWindow()` "always returns NULL in the other ports", i.e. on everything but MSW and GTK
(`interface/wx/window.h:4545-4551`).
### Platforms
[source]
- **GTK:** `SetFocus()` calls `gtk_window_present()` on a visible, inactive top-level window, i.e. it
**raises and activates** the window (`src/gtk/window.cpp:5137-5162`). On a not-yet-shown widget the
focus becomes "pending", and `FindFocus()` returns the pending window at once (`:5141-5155,
2777-2791`). While a popup menu is open, `FindFocus()` returns the invoking window.
- **MSW:** disabling the focused control first `Navigate()`s forward (`src/msw/window.cpp`
`MSWEnableHWND`). Any `WM_SETFOCUS`, `WM_KILLFOCUS` or button-down in a window outside
`wxCurrentPopupWindow` dismisses a transient popup that lacks `wxPU_CONTAINS_CONTROLS`
(`src/msw/window.cpp:3018-3033` → `MSWDismissUnfocusedPopup`, `src/msw/popupwin.cpp:218-229`).
- **Modeless windows:** focus requested right after `Show()` can be dropped while activation is still
in flight (Orca comment in `SpeedDialWebDialog`'s ctor). Set it from `CallAfter` or on
`wxEVT_ACTIVATE` (`SpeedDialWebDialog`'s activate handler, see `references/popups-menus.md`); the
modal `ShortcutCaptureDialog` likewise defers its first `SetFocus()` with `CallAfter`.
### OrcaSlicer
- `::Button` overrides `SetCanFocus` to store `canFocus`, which drives `Button::AcceptsFocus()` and
whether `Button::mouseDown` calls `SetFocus()`. In Orca `SetCanFocus(false)` therefore means "never
take focus", unlike the stock GTK-only hint.
- `GLCanvas3D::on_mouse` focuses the canvas on any button-down. On ENTER it focuses the canvas only if
the top-level window `IsActive()` and focus is not in a `wxTextCtrl`, and on MSW only while
`wxCurrentPopupWindow` is null. Stealing focus would trigger `MSWDismissUnfocusedPopup` and close
the search dropdown.
### Pitfalls
- **Rule:** No unconditional `SetFocus()` on hover, ENTER or timers.
**Why:** on GTK it raises and activates the window over whatever the user is doing; on MSW it
dismisses open transient popups; anywhere it steals the caret from a text field.
```cpp
// Wrong:
canvas->Bind(wxEVT_ENTER_WINDOW, [=](wxMouseEvent& e) { canvas->SetFocus(); e.Skip(); });
// Right (wxCurrentPopupWindow is in no wx header: declare
// extern wxPopupWindow* wxCurrentPopupWindow; as GLCanvas3D.cpp does):
canvas->Bind(wxEVT_ENTER_WINDOW, [=](wxMouseEvent& e) {
auto* tlw = dynamic_cast<wxTopLevelWindow*>(wxGetTopLevelParent(canvas));
if (tlw && tlw->IsActive() && !dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus())
#ifdef __WXMSW__
&& !wxCurrentPopupWindow
#endif
) canvas->SetFocus();
e.Skip(); });
```
Cite: `GLCanvas3D::on_mouse`.
- **Rule:** Defer focus changes out of `wxEVT_KILL_FOCUS`, and always `Skip()` focus events.
```cpp
// Wrong: ctrl->Bind(wxEVT_KILL_FOCUS, [=](wxFocusEvent&) { other->SetFocus(); });
// Right: ctrl->Bind(wxEVT_KILL_FOCUS, [=](wxFocusEvent& e) { e.Skip(); other->CallAfter([other] { other->SetFocus(); }); });
```
## Tooltips
### Contract
- `SetToolTip(const wxString&)`, `SetToolTip(wxToolTip*)` and `UnsetToolTip()`
(`interface/wx/window.h:3238-3273`). Setting an **empty string does not remove** the tooltip; the
code says "use SetToolTip(nullptr)" (`src/common/wincmn.cpp:2245-2259`) [source].
- The string overload reaches the virtual `DoSetToolTipText`; the pointer overload and `UnsetToolTip`
reach `DoSetToolTip` (`include/wx/window.h:1484-1489`).
- Statics (`interface/wx/tooltip.h:26-85`): `Enable` "may not be supported on all platforms";
`SetAutoPop`/`SetReshow` "May not be supported (eg. wxCocoa, GTK)"; `SetMaxWidth` is wxMSW-only.
- `wxTipWindow::New()` (3.3.2) returns a `wxTipWindow::Ref` that becomes null when the tip closes
itself; never keep a raw pointer (`interface/wx/tipwin.h:20-110`).
- `wxRichToolTip` is not a window: each `ShowFor()` creates a new one, and the native MSW version
only applies to text controls (`interface/wx/richtooltip.h:70-192`).
| Static [source] | MSW | GTK | macOS |
|---|---|---|---|
| `Enable` | works | sets `GtkSettings` `gtk-enable-tooltips` | no-op |
| `SetDelay` | works | sets `gtk-tooltip-timeout` (`src/gtk/tooltip.cpp:74-128`) | writes `NSInitialToolTipDelay` into `[NSUserDefaults standardUserDefaults]`, the app's persistent defaults (`src/osx/cocoa/tooltip.mm:66-72`) |
| `SetAutoPop`, `SetReshow` | work; Orca's `MainFrame` ctor uses `SetAutoPop(32767)` because larger values fail | no-op | no-op |
### Tooltips on disabled controls
MSW tooltips subclass the tool's HWND (`TTF_SUBCLASS`, `src/msw/tooltip.cpp:131-135`), and a
disabled HWND receives no mouse messages [external], so a disabled control shows no tooltip.
`Button::EnableTooltipEvenDisabled()` (`Widgets/Button.cpp`) works around this:
- it binds the **parent's** `wxEVT_MOTION`/`wxEVT_LEAVE_WINDOW` (`Button::OnParentMotion`/
`Button::OnParentLeave`);
- it pops a `wxTipWindow::Ref` when the pointer is over the disabled button;
- it positions the tip from `ClientToScreen(wxPoint(0, 0))` rather than `wxGetMousePosition()`, which
returns (0,0) on Wayland;
- it is compiled only under `#if defined(_MSC_VER) || defined(_WIN32)`.
- **Rule:** Install parent-window mouse-tracking handlers that simulate tooltips on disabled controls
only on Windows; gate them with `#if defined(_WIN32)`.
**Why:** the hack froze the UI on macOS. The commit records only the symptom. Likely mechanism
[source]: `wxTipWindow` is a `wxPopupTransientWindow`. On wxOSX its `Show(true)` captures the mouse
on its child without a guard, and `OnIdle` keeps capture while the pointer is outside the popup
(`src/common/popupcmn.cpp:335-430, 439-471`). A tip popped from parent MOTION, beside the pointer,
therefore takes every click (see the macOS capture section), and a re-`Popup()` while shown
pushes the capture twice.
```cpp
// Wrong: unconditional
parent->Bind(wxEVT_MOTION, &Button::OnParentMotion, this);
// Right:
#if defined(_MSC_VER) || defined(_WIN32)
parent->Bind(wxEVT_MOTION, &Button::OnParentMotion, this);
parent->Bind(wxEVT_LEAVE_WINDOW, &Button::OnParentLeave, this);
#endif
```
Cite: d0cc4b35ee (`Widgets/Button.cpp` `Button::EnableTooltipEvenDisabled`).
### OrcaSlicer
- `TextInput`, `SpinInput` and `TempInput` override `DoSetToolTipText` to forward the text to the
inner `wxTextCtrl`. Only the string overload forwards; `UnsetToolTip()` and
`SetToolTip(wxToolTip*)` do not reach the inner control.
- `Sidebar::priv::show_rich_tip` (`Plater.cpp`, `_WIN32` only) uses `wxRichToolTip` and recolours
the shown popup's first child for dark mode.
- Option tooltips for settings are assembled by the `Field` machinery (`references/orca-settings-ui.md`).
### Pitfalls
- **Rule:** `UnsetToolTip()` to clear.
```cpp
// Wrong: btn->SetToolTip(""); // keeps an empty tooltip object
// Right: btn->UnsetToolTip();
```
- **Rule:** A composite forwarding tooltips overrides both virtuals, so `UnsetToolTip()` and the
`wxToolTip*` overload reach the inner children.
- **Rule:** Don't call `wxToolTip::SetDelay` on macOS casually: it persists in the user's defaults for
the app.
## Cursors
### Contract
- `SetCursor(c)` "also sets it for the children of the window implicitly"; `wxNullCursor` resets it to
the default (`interface/wx/window.h:3866-3882`). The system shows the window cursor whenever the
pointer is over the window, so set it **once**.
- For high DPI, use `SetCursorBundle()` (3.3.0) (`interface/wx/window.h:3884-3892`). A default `wxCursorBundle()` is
empty and means "no custom cursor", not a blank cursor (`interface/wx/cursor.h:305-317`).
- `wxSetCursor(bundle)` "Globally sets the cursor … overrides any cursor set for the individual
windows … until this function is called again with an empty cursor bundle"
(`interface/wx/gdicmn.h:1371-1389`). `wxSetCursor(wxNullCursor)` is that reset, through the implicit
`wxCursorBundle(const wxCursor&)` (`src/common/curbndl.cpp:174`) [source].
- `wxBusyCursor` is RAII around the nested `wxBeginBusyCursor`/`wxEndBusyCursor` counter, with
`wxIsBusy()` (`interface/wx/busycursor.h`). These are main-thread GUI calls.
- `wxBitmap(wxCursor)` is invalid on GTK/Wayland (`interface/wx/bitmap.h:393-395`).
### OrcaSlicer
- Dialog work uses `wxBusyCursor` RAII (e.g. `PhysicalPrinterDialog.cpp`).
- Jobs wrap their `process()` in `BusyCursored<Job>` (`Jobs/BusyCursorJob.hpp`). Its
`CursorSetterRAII` marshals `wxBeginBusyCursor`/`wxEndBusyCursor` to the main thread through
`ctl.call_on_main_thread`.
- Web dialogs bracket blocking work with `wxSetCursor(wxCURSOR_ARROWWAIT)` …
`wxSetCursor(wxNullCursor)` (`WebViewDialog.cpp`).
### Pitfalls
- **Rule:** Set the hover cursor once and reset with `wxNullCursor`.
**Why:** the system already switches cursors on enter/leave. Toggling by hand fights it, and an
explicit `wxCURSOR_ARROW` overrides the cursor inherited from the parent.
```cpp
// Wrong:
w->Bind(wxEVT_ENTER_WINDOW, [w](wxMouseEvent& e) { w->SetCursor(wxCURSOR_HAND); e.Skip(); });
w->Bind(wxEVT_LEAVE_WINDOW, [w](wxMouseEvent& e) { w->SetCursor(wxCURSOR_ARROW); e.Skip(); });
// Right:
w->SetCursor(wxCURSOR_HAND); // once; w->SetCursor(wxNullCursor) to drop it
```
- **Rule:** Busy cursors from worker threads go through the main thread
(`ctl.call_on_main_thread`, `CallAfter`); never call `wxBeginBusyCursor` from a worker.
@@ -0,0 +1,948 @@
# OrcaSlicer GUI architecture
The map of OrcaSlicer's GUI: which object owns what, how the app starts, rebuilds and shuts down,
how pages are built lazily, and where a new dialog, panel, sidebar control, setting, notification,
menu item or source file belongs. Read it before adding a component or when code has to reach
another part of the GUI; the API detail of each area lives in the file the section points to.
Contents: [The stack](#the-stack-orcas-gui-is-built-on) ·
[Component map](#component-map) · [GUI_App](#gui_app) ·
[Close and shutdown](#close-and-shutdown-sequence) · [MainFrame](#mainframe) ·
[Deferred construction](#deferred-construction-lazy-lazypage-stagedbuild-idlescheduler) ·
[Plater and Sidebar](#plater-and-sidebar) · [Settings placement](#settings-placement-paramspanel-paramsdialog-tabs) ·
[ObjectList](#objectlist) · [3D canvas and ImGui](#3d-canvas-imgui-layer-and-notificationmanager) ·
[Background work](#background-work) · [Device pages](#device-and-monitor-pages) ·
[Web UI](#web-based-ui) · [Preferences](#preferences) · [AppConfig](#appconfig) ·
[Where new code goes](#where-new-code-goes) · [Build registration](#build-registration) ·
[Design docs](#design-docs-docshlsd)
## Rules
1. Reach app-wide objects through `wxGetApp()`. `app_config` is non-null for the whole GUI
lifetime; `plater()` and `mainframe` can be null, and `sidebar()`, `obj_list()`, `model()`
dereference the plater unchecked — test `plater()` first on any path that can run before the
main frame exists or after it closes. → [Accessors](#accessors)
2. Deferred code (CallAfter bodies, agent callbacks, timers) that can run during shutdown checks
`!wxTheApp || wxGetApp().is_closing()` before touching the GUI. → [Close and shutdown](#close-and-shutdown-sequence)
3. Never keep a raw pointer to a `MainFrame` child, a `Tab`, a lazy panel or a cached dialog across
`GUI_App::recreate_GUI` (language switch); `is_closing()` stays false during it. → [recreate_GUI](#recreate_gui-language-switch)
4. A new top-level tab is a `LazyPage<Panel>` with a `LazyInstance<Panel>` panel; a heavy dialog
owned by the main frame is a `Lazy<Dlg>` member. Only the start page and the Prepare plater are
built before the first frame. → [Deferred construction](#deferred-construction-lazy-lazypage-stagedbuild-idlescheduler)
5. Outside `MainFrame`, reach a lazy object only through its statics: `if_built()` for work it can
live without, `ensure()` only to show or navigate to it, `when_built()` for state it would not
pull for itself (and for rescale/recolour of staged panels). → [Reaching a lazy object](#reaching-a-lazy-object)
6. In a `StagedBuild` panel, step-built members start null; timers, handlers and the destructor
check `built()` first; nothing takes focus while off screen. → [Staged construction](#staged-construction-stagedbuild)
7. Background UI construction is a prebuild task run by `IdleScheduler`, never a `wxEVT_IDLE` +
`RequestMore()` loop or a chain of posted events. → [The idle scheduler](#the-idle-scheduler-idlescheduler-prebuildqueue)
8. A component with Orca rescale / recolour hooks must be reached by the explicit fan-out
(`MainFrame::on_dpi_changed` / `on_sys_color_changed`, `Plater::msw_rescale` /
`sys_color_changed`, `Sidebar::msw_rescale` / `sys_color_changed`); nothing calls it otherwise. → [DPI and colour fan-out](#dpi-and-colour-fan-out)
9. Process and model-scope settings are in `ParamsPanel` inside the sidebar; filament and printer
settings are in the modeless `ParamsDialog`. `get_tab()` returns null until the tab is complete. → [Settings placement](#settings-placement-paramspanel-paramsdialog-tabs)
10. `Plater` and `Sidebar` are pimpl'd: new state goes into `Plater::priv` / `Sidebar::priv`. New
events are declared next to their emitter with the `Event.hpp` types; a short-lived listener on
the plater binds through `EventGuard`. → [Plater and Sidebar](#plater-and-sidebar)
11. Docked panes go through `Plater::add_dock_pane` with a stable, untranslated, delimiter-free name;
the window must be a child of the plater. → [Docking](#docking)
12. UI drawn over the 3D view is ImGui inside `GLCanvas3D`, never a wx child window over the GL
canvas; ask for a redraw with `set_as_dirty()` / `request_extra_frame()`. → [3D canvas](#3d-canvas-imgui-layer-and-notificationmanager)
13. `NotificationManager` is called on the UI thread only, and its notifications are visible only
while the plater is shown. → [NotificationManager](#notificationmanager)
14. UI-initiated background work is a `Job` on a `Worker`; slicing is `BackgroundSlicingProcess`;
network agents call back through `wxGetApp().CallAfter`. UI is touched only on the main thread. → [Background work](#background-work)
15. Device UI pulls state from `DeviceManager` on its own timer and mutates `MachineObject` only on
the UI thread. → [Device pages](#device-and-monitor-pages)
16. Web UI goes through Orca's hosts (`WebView::CreateWebView`, `WebViewHostDialog`, `WebPanel`,
`DockPanel`); window operations requested from a script message are deferred and liveness-checked. → [Web UI](#web-based-ui)
17. A preference is a `create_item_*` row in `PreferencesDialog::create_items` that writes
`app_config` and saves at once; its default goes in `AppConfig::set_defaults`; effects needed
after the dialog closes go in `GUI_App::open_preferences`. → [Preferences](#preferences)
18. `AppConfig` values are strings: match the key's own convention (`"true"/"false"` or `"1"/"0"`);
`save()` and every write run on the main thread. → [AppConfig](#appconfig)
19. Every new source file is registered in `src/slic3r/CMakeLists.txt`: `SLIC3R_GUI_SOURCES`, or the
`if (WIN32)` / `if (APPLE)` / `if (SLIC3R_CAD)` blocks, or the `GUI/DeviceCore` / `GUI/DeviceTab`
lists. → [Build registration](#build-registration)
20. A new subsystem whose design is not evident from the code gets `docs/HLSD/<subsystem>.md`; a
change that invalidates an existing HLSD doc updates it in the same PR. → [Design docs](#design-docs-docshlsd)
## The stack Orca's GUI is built on
- **wxWidgets 3.3.2, SoftFever fork.** `deps/wxWidgets/wxWidgets.cmake` fetches
`https://github.com/SoftFever/Orca-deps-wxWidgets` at tag `v3.3.2` and builds it static
(`-DwxBUILD_SHARED=OFF`); Flatpak builds build it shared. Linux builds against **GTK3** by default
(`option(DEP_WX_GTK3 "Build wxWidgets against GTK3" ON)` in `deps/CMakeLists.txt`, `SLIC3R_GTK`
default `"3"`, Flatpak uses gtk3). GTK2 exists only as an opt-out (`-DDEP_WX_GTK3=OFF`) and loses
EGL, WebKit2 and DIP pixels. Code guarded for GTK should still compile on GTK2, but GTK3 under X11
and Wayland is the target. Toolkit and build-option detail: `references/platforms.md`.
- **Asserts are compiled out.** wx is built with `-DwxBUILD_DEBUG_LEVEL=0` and `libslic3r_gui` adds
`wxDEBUG_LEVEL=0` (under `SLIC3R_STATIC`, `src/slic3r/CMakeLists.txt`). `wxASSERT`/`wxFAIL` vanish
and `wxCHECK*` return silently, so API misuse shows up as wrong pixels, dropped calls or corrupted
state, never as an assert dialog.
- **No wx SVG.** `-DwxUSE_NANOSVG=OFF`: `wxBitmapBundle::FromSVG*` does not exist; Orca rasterises
SVG itself (`BitmapCache`, `create_scaled_bitmap`) — `references/dpi-bitmaps-fonts.md`.
- **Orca's own widget library.** New UI code largely does not use raw wx controls: the owner-drawn
widgets in `src/slic3r/GUI/Widgets/` (`Button`, `CheckBox`, `ComboBox`, `TextInput`, `SpinInput`,
`SwitchButton`, `RadioGroup`, `Label`, `DialogButtons`, …) replace them. Reasons: native controls
cannot follow Orca's look or its app-level dark-mode toggle; on Windows wx's native dark mode does
not reach anything built on `TaskDialog()` (`wxMessageBox`, `wxMessageDialog`, `wxRichMessageDialog`,
`wxProgressDialog`) nor the wrapped common dialogs (`wxColourDialog`, `wxFontDialog`, …)
(`interface/wx/app.h:1434-1443`), so Orca shows the `MsgDialog` family and its own generic
`Widgets/ProgressDialog` instead; and on GTK the theme's borders bleed through wrapped native
controls (the widgets call `RemoveButtonBorder` / `RemoveInputBorder` under `__WXGTK__`).
Plain containers stay raw (`wxPanel`, `wxBoxSizer`, `wxScrolledWindow`).
- **Namespaces.** Most widgets are in the global namespace; a few (`DialogButtons`, `HyperLink`,
`ProgressDialog`, `RadioBox`, `WebViewHostDialog`, the AMS/device composites) are in
`Slic3r::GUI`. GUI code inside `Slic3r::GUI` writes `::CheckBox` because `Field.hpp` declares the
settings-field classes `Slic3r::GUI::CheckBox`, `TextCtrl`, `SpinCtrl`, `Choice`, `StaticText`,
which an unqualified name finds first once `Field.hpp` is reachable; `::TextInput` and
`::ComboBox` are qualified the same way by convention (no `Slic3r::GUI` class shadows them).
Catalog and quirks: `references/orca-widgets.md`.
## Component map
| Component | Type, file | Owns / does | Reach it with |
|---|---|---|---|
| `GUI_App` | `wxApp`; `GUI/GUI_App.hpp/.cpp` | process singletons, startup, `post_init`, app idle handler, dark-mode entry points, `recreate_GUI` | `wxGetApp()` |
| `MainFrame` | `DPIFrame`; `GUI/MainFrame.hpp/.cpp` | borderless main window, top bar / menu bar, tab book, preset tabs, idle prebuild, DPI/colour fan-out | `wxGetApp().mainframe` |
| `Plater` | `wxPanel`, pimpl `Plater::priv`; `GUI/Plater.hpp/.cpp` | the Prepare and Preview page: model, three canvases, AUI docking, slicing, job worker, notifications, context menus | `wxGetApp().plater()` |
| `Sidebar` | `wxPanel`, pimpl `Sidebar::priv`; `GUI/Plater.hpp/.cpp` | printer and filament blocks, `ParamsPanel`, object search + `ObjectList`, settings index | `wxGetApp().sidebar()` (unchecked) / `plater()->sidebar()` |
| `ParamsPanel` | `wxPanel`; `GUI/ParamsPanel.hpp` | process and model-scope `Tab`s, reparented into the sidebar | `wxGetApp().params_panel()` (null-safe) |
| `ParamsDialog` | `DPIDialog`; `GUI/ParamsDialog.hpp` | its own `ParamsPanel` with the filament and printer `Tab`s; modeless | `wxGetApp().params_dialog()` (null-safe) |
| `Tab` family | `GUI/Tab.hpp/.cpp` | preset editors (`TabPrint`, `TabPrintPlate/Object/Part/Layer`, `TabFilament`, `TabPrinter`) | `get_tab(Preset::Type)`, `get_plate_tab()`, `get_model_tab(part)`, `get_layer_tab()` |
| `ObjectList` | `wxDataViewCtrl`; `GUI/GUI_ObjectList.hpp` | plate/object/part tree | `wxGetApp().obj_list()` (unchecked) |
| `GLCanvas3D` | wraps a `wxGLCanvas`; `GUI/GLCanvas3D.hpp` | 3D, preview and assemble rendering, gizmos, ImGui overlays | `plater()->canvas3D()`, `get_current_canvas3D()` |
| `NotificationManager` | `GUI/NotificationManager.hpp` | ImGui notifications drawn in the canvas | `wxGetApp().notification_manager()` (null-safe) |
| Jobs | `GUI/Jobs/` | UI-initiated background tasks | `plater()->get_ui_job_worker()` |
| `MonitorPanel` / `StatusPanel` | `GUI/Monitor.hpp`, `GUI/StatusPanel.hpp` | Device tab | `MonitorPanel::if_built()` / `ensure()` |
| `DeviceManager` / `MachineObject` | `GUI/DeviceCore/DevManager.h` (`DeviceManager`), `GUI/DeviceManager.hpp` (`MachineObject`), parts in `GUI/DeviceCore/Dev*` | device state | `wxGetApp().getDeviceManager()` |
| `NetworkAgent` | `Utils/NetworkAgent.hpp` | printer agent + cloud agents façade | `wxGetApp().getAgent()` |
| Web hosts | `Widgets/WebView`, `Widgets/WebViewHostDialog`, `WebViewDialog.hpp` (`WebViewPanel`), `PrinterWebView`, `WebPanel`, `DockPanel`, `WebDialog` | HTML UI | per class |
| `PreferencesDialog` | `DPIDialog`; `GUI/Preferences.hpp` | app settings | `wxGetApp().open_preferences(tab, highlight)` |
| `AppConfig` | `libslic3r/AppConfig.hpp` | persisted app settings | `wxGetApp().app_config` |
| `PresetBundle` | `libslic3r/PresetBundle.hpp` | presets | `wxGetApp().preset_bundle` |
| `ShortcutRegistry` | `GUI/Shortcuts.hpp` | key bindings | `wxGetApp().shortcuts()` |
| `ActionRegistry` | `GUI/ActionRegistry.hpp` | Speed Dial actions | `wxGetApp().action_registry()` |
| `ImGuiWrapper` | `GUI/ImGuiWrapper.hpp` | the app's ImGui context | `wxGetApp().imgui()` |
## GUI_App
### Entry and construction
`GUI_Run` (`GUI/GUI_Init.cpp`) creates the app by hand: `new GUI_App()`, then
`Slic3r::instance_check(argc, argv, single_instance)` using `app_config`, then
`GUI_App::SetInstance(gui)`, `gui->init_params = &params`, and `wxEntry`. When there are command-line
arguments it passes **only `argv[0]`** to `wxEntry`, because wx reports errors for some file names; the real
arguments travel in `GUI_App::init_params` (`GUI_InitParams`). `IMPLEMENT_APP(GUI_App)` is in
`GUI_App.cpp` and `DECLARE_APP(GUI_App)` in `GUI_App.hpp` inside `Slic3r::GUI`, so `wxGetApp()` is
`Slic3r::GUI::wxGetApp()` — write `GUI::wxGetApp()` from `Slic3r` scope outside `GUI`. It expands to
`*static_cast<GUI_App*>(wxApp::GetInstance())` (`include/wx/app.h:941` **[source]**), so it is valid from
`SetInstance` until wx cleanup nulls the instance.
The constructor (`GUI_App::GUI_App`) runs before `wxEntry`, i.e. before wx is initialised. It creates
the `ImGuiWrapper`, `RemovableDriveManager`, `Downloader`, `OtherInstanceMessageHandler`, then calls
`init_app_config()` early (instance checking needs it) and loads the `ShortcutRegistry` from it.
Nothing that needs a running wx (timers, windows, modal prompts, WebView runtime checks) may go in
the constructor; those belong in `on_init_inner` or `post_init`.
### Startup sequence
`GUI_App::OnInit` wraps `on_init_inner()` in a try/catch (`generic_exception_handle`, then a `return false`
that is never reached because the handler terminates or rethrows — `references/threads-timers-app.md` §Startup).
The order inside `on_init_inner` that contributors depend on:
1. Log target, `::Label::initSysFont()` (the `Label::Head_*/Body_*` font table every widget uses),
wxInspector plugin registration, `wxInitAllImageHandlers()`, GTK menu-image and log-filter tweaks.
2. `wxEVT_QUERY_END_SESSION` bound on the app: it sends the main frame a vetoable `wxCloseEvent`
(so the save prompts run), vetoes the session end if that close was vetoed, then calls
`EndModal(wxID_ABORT)` on every dialog in the global `dialogStack`.
3. `init_label_colours()`, `init_fonts()`, `Update_dark_mode_flag()`; the editor's TLS
certificate prompt.
4. `load_language()` — language, colour mode and fonts must be initialised before the first UI
action; the app exits if loading the language fails.
5. Dark-mode initialisation (non-Windows writes `dark_color_mode` from the system appearance;
Windows calls `MSWEnableDarkMode(DarkMode_Auto)` before `NppDarkMode::InitDarkMode`) —
`references/colours-dark-mode.md`.
6. `SplashScreen` (if `show_splash_screen`), held in a `wxWeakRef` and advanced with
`SetText(text, progress)` + `wxYield()` — `references/threads-timers-app.md`.
7. `new PresetBundle`, `new PresetUpdater` and their event bindings; plugin GUI wiring
(`init_plugin_gui_wiring`); networking (`on_init_network`).
8. GTK with EGL: `wxGLCanvas::PreferGLX()` on X11, before any GL canvas exists —
`references/webview-gl-aui-media.md`.
9. `mainframe = new MainFrame()` (creates the plater, the tab book, the preset tabs and the lazy
pages), then `select_tab(TAB_ID_PREPARE or TAB_ID_HOME)` per `starts_on_prepare()`
(`default_page == "1"`).
10. `obj_list()->init()`, `SetTopWindow(mainframe)`, `plater_->init_notification_manager()`,
`load_current_presets()`, `mainframe->Show(true)`; the splash is destroyed; `update_mode()`.
11. The app-level `wxEVT_IDLE` handler is bound.
### post_init and the app idle handler
The idle handler bound at the end of `on_init_inner` runs `post_init()` exactly once (guarded by
`m_post_initialized`, and postponed while a WebView script handler is being added), then on every
idle **saves `app_config` if it is `dirty()`**. `post_init` initialises the WebView2 runtime on
Windows (`init_webview_runtime`, before the first WebView), opens command-line files, loads the GL
resources on the Prepare canvas when the app starts on Prepare (when it starts on Home they load
later as an idle task, so Home paints first), starts the idle prebuild
(`MainFrame::prebuild_pages_when_idle`), and `CallAfter`s the config wizard and update checks — the
code comment: on Mac this is "the only way to popup a modal dialog on start without screwing combo
boxes". If the GL context cannot be made current yet, Linux resets `m_post_initialized` so the next
idle retries (a Wayland surface commits late).
### Accessors
| Accessor | Null? |
|---|---|
| `app_config`, `preset_bundle`, `imgui()`, `shortcuts()`, `action_registry()` | created in the constructor or before `MainFrame`; non-null for the GUI lifetime (`preset_updater` is null in the G-code viewer) |
| `mainframe`, `plater()` | null before `MainFrame` exists |
| `sidebar()`, `obj_list()`, `model()` | dereference `plater_` **without a check** |
| `params_panel()`, `params_dialog()`, `notification_manager()` | null-safe (return null without a main frame / plater) |
| `get_tab(Preset::Type)` | null for a tab not found **or not yet `completed()`**; `tabs_list` / `model_tabs_list` are cleared by `MainFrame::shutdown` |
| `get_model_tab(part)`, `get_layer_tab()` | index `model_tabs_list` **without a bounds check** — undefined once `MainFrame::shutdown` has cleared it |
| `getDeviceManager()`, `getAgent()` | may be null; check before use |
| `em_unit()` | app-wide; per-window value via the free `em_unit(wxWindow*)` — `references/dpi-bitmaps-fonts.md` |
| `dark_mode()` | static, recomputed per call — `references/colours-dark-mode.md` |
| `is_closing()`, `is_recreating_gui()`, `input_idle_ms()` | state flags; `input_idle_ms` is fed by `GUI_App::FilterEvent` |
### recreate_GUI (language switch)
`GUI_App::recreate_GUI` sets `m_is_recreating_gui`, destroys the cached Speed Dial dialog (its
translated strings are injected once), calls `mainframe->shutdown()`, swaps the `Field` control pools
(`switch_window_pools()`; the old pools are released only when the old frame is destroyed), creates a
**new `MainFrame`**, `Destroy()`s the old one, reloads presets, shows the new frame and calls
`prebuild_pages_when_idle()` again. `GUI_App::shutdown` returns early while recreating, so
`is_closing()` never becomes true during a language switch.
Consequences: every `MainFrame` child, `Tab`, lazy panel and cached dialog is a new object afterwards.
The `LazyInstance` statics follow automatically (the new frame's holders replace the old ones); raw
pointers do not. Settings fields are recycled through pools: `references/orca-settings-ui.md`.
### Pitfalls
- **Rule:** On startup and shutdown paths, test `plater()` before `sidebar()` / `obj_list()` / `model()`.
**Why:** those accessors dereference `plater_` unchecked; before `MainFrame` exists they crash.
```cpp
// Wrong: reachable before the main frame exists
wxGetApp().sidebar().update_presets(Preset::TYPE_PRINTER);
// Right
if (Plater* plater = wxGetApp().plater())
plater->sidebar().update_presets(Preset::TYPE_PRINTER);
```
Cite: `GUI_App::sidebar`, `GUI_App::obj_list`, `GUI_App::model`.
- **Rule:** Do not null-check `app_config` inside the GUI; do keep it on the main thread.
**Why:** it is created in the `GUI_App` constructor, before `wxEntry`, so it exists for the whole GUI
lifetime; the hazard is threading ([AppConfig](#appconfig)), not null. Cite: `GUI_App::GUI_App`.
- **Rule:** Do not cache a pointer to a `MainFrame` child or lazy panel in a static or a long-lived
object. **Why:** `recreate_GUI` destroys the old frame; the cached pointer dangles and `is_closing()`
does not warn you.
```cpp
// Wrong
static MonitorPanel* s_monitor = MonitorPanel::ensure();
// Right: ask each time; the statics follow the new frame's holder
if (MonitorPanel* monitor = MonitorPanel::if_built()) monitor->jump_to_HMS();
```
Cite: `GUI_App::recreate_GUI`, `LazyInstance` (`Lazy.hpp`).
## Close and shutdown sequence
Orca's side of shutdown, in order:
1. `MainFrame`'s `wxEVT_CLOSE_WINDOW` handler (bound in the constructor) vetoes, when the event can
be vetoed, if a gizmo is in editing mode, if `Plater::close_with_confirm` (project and preset save
prompts) is cancelled, or if `GUI_App::check_print_host_queue` refuses.
2. Otherwise: `MarkdownTip::ExitTip()`, `wxGetApp().set_closing(true)` (so queued work is inert during
the reset), `m_plater->reset()` (which also saves the AUI perspective to the `window_layout` key),
`MainFrame::shutdown()`, `event.Skip()` (wx's default handler then `Destroy()`s the frame, or — for a
vetoable close while a modal dialog is open — vetoes it after this teardown, `references/windows-dialogs.md` §2).
3. `MainFrame::shutdown()`: stops the idle scheduler (`m_idle.stop()`), shuts down the built Project
panel and plugin pages, removes dock panes, clears the backup callback, cancels all UI jobs
(`get_ui_job_worker().cancel_all()`), unbinds the canvases' handlers (on macOS Cmd+Q delivers a mouse
event after the close handler), resets canvas volumes, **hides the frame** (paint messages into
dying windows crashed), stops the 3D-mouse controller and saves its config, shuts down the
other-instance listener, saves `app_config` if dirty, clears `tabs_list` / `model_tabs_list`, and
calls `GUI_App::shutdown()`.
4. `GUI_App::shutdown()`: removable-drive manager shutdown, login dialog deleted, then (unless
recreating the GUI) stop the HTTP server, `set_closing(true)`, plugin manager shutting down,
printer agent detached and the agent cache cleared.
5. wx deletes all remaining top-level windows, then calls `GUI_App::OnExit`, which stops the HTTP server
and preset sync, deletes `DeviceManager`, `UserManager` and the network agent.
`m_is_closing` is a `std::atomic<bool>`. There is no drain of queued `CallAfter`s at shutdown: queued
app calls are discarded with the app object, and those that still run see `is_closing()`. (The bounded
`drain_pending_events` belongs to `GUI_App::hot_reload_network_plugin`.) The wx side — windows deleted
before `OnExit` (`interface/wx/app.h:358-371`), `wxTheApp` null in `~GUI_App`, exception policy — is in
`references/threads-timers-app.md`.
- **Rule:** Guard deferred GUI work with `!wxTheApp || wxGetApp().is_closing()`.
**Why:** after wx cleanup `wxGetApp()` dereferences a null instance (`wxEntryCleanup` resets the
instance before deleting the app, `src/common/init.cpp:472-487` **[source]**); between the close
handler and `OnExit` the plater has been reset and windows are dying.
```cpp
// Wrong
wxGetApp().CallAfter([this, msg] { handle(msg); });
// Right (as ActionRegistry::init)
if (!wxTheApp || wxGetApp().is_closing()) return;
wxGetApp().CallAfter([this, msg] { if (wxGetApp().is_closing()) return; handle(msg); });
```
Cite: `ActionRegistry::init` (plugin source callbacks), `NetworkAgentFactory.cpp`
(`reject_conflicting_capability`); `GUI_App::init_networking_callbacks` (`message_arrive_fn`) runs
inside `GUI_App`, so it tests its own `is_closing()` before and inside the `CallAfter`.
- **Rule:** A component that owns a thread, timer, socket or dock pane stops it from
`MainFrame::shutdown()` (or its own `shutdown()` called from there), not from its destructor alone.
**Why:** by the time destructors run, the frame is hidden and the plater reset; a timer or thread that
fires in between touches half-destroyed state. `MainFrame::shutdown` is the one place that runs before
any window is deleted, both on exit and on a language switch.
## MainFrame
### Frame, top bar and menu bar
`MainFrame : DPIFrame` uses `BORDERLESS_FRAME_STYLE` (no `wxCAPTION`; no `wxRESIZE_BORDER` on macOS)
and draws its own title bar. Each platform restores the missing decoration differently (MSW strips
`WS_CAPTION` and handles non-client messages in `MainFrame::MSWWindowProc`; GTK adds
`ResizeEdgePanel`s that start a resize drag; macOS `set_miniaturizable` in `Utils/MacDarkMode.mm`) —
`references/platforms.md`.
Off macOS the title bar is `BBLTopbar` (a `wxAuiToolBar` in the frame's sizer, not an AUI pane) that
hosts the File menu, the Edit/View/Help drop-down submenus, the Calibration menu and undo/redo. On
macOS the same menus are attached to a native `wxMenuBar` (`m_menubar`), with Preferences under
`OSXGetAppleMenu()`. `MainFrame::init_menubar_as_editor` builds the `wxMenu`s once and branches only
where they are attached; `generate_help_menu` builds Help. Menu mechanics, `append_menu_item`,
`MenuFactory` and `BBLTopbar` events: `references/popups-menus.md`.
### The tab book
`m_tabpanel` is Orca's `Notebook` (`GUI/Notebook.hpp`, a `wxBookCtrlBase` with a `ButtonsListCtrl`
header that sends `wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED`). Pages are addressed by **string ids**, the
`TAB_ID_*` macros in `MainFrame.hpp` (`TAB_ID_HOME`, `TAB_ID_DESIGN`, `TAB_ID_PREPARE`,
`TAB_ID_PREVIEW`, `TAB_ID_MONITOR`, `TAB_ID_MONITOR_WEB`, `TAB_ID_MULTI_DEVICE`, `TAB_ID_PROJECT`,
`TAB_ID_CALIBRATION`): `AddPage(id, page, text, bmp_name)`, `InsertPage(n, id, …)`,
`FindPageByName`, `SelectPageByName`, `GetSelectedPageName`, `PositionAfter({ids})`. Use the ids, not
indices: pages come and go per printer and per feature flag.
- The **same `Plater` window is inserted twice**, as Prepare and Preview (`MainFrame::update_layout`).
The page-changed handler posts `EVT_GLVIEWTOOLBAR_3D` / `EVT_GLVIEWTOOLBAR_PREVIEW` to the plater, so
"which page" is resolved by id, never by `GetName()` of the window (`MainFrame::select_tab(wxPanel*)`).
- Every other page is a `LazyPage<…>` created in `MainFrame::init_tabpanel`: Home
(`WebViewPanel`), Device (`MonitorPanel`), web Device (`PrinterWebView`), Multi-device
(`MultiMachinePage`), Project (`ProjectPanel`), Calibration (`CalibrationPanel`), and Design
(`DesignPanel`, only under `SLIC3R_CAD` with the feature enabled, order −1 so it is never prebuilt).
- `MainFrame::show_device` inserts and removes the Device, web Device, Multi-device and Calibration
pages depending on the printer and on `use_printer_agents`; a removed page stays registered but is
not prebuilt (its `LazyPage::in_book()` is false).
- Plugin pages are appended by `PluginPages::initialize` (`plugin/host/PluginPages.hpp`) with
namespaced ids (`plugin.<plugin_key>.<name>`) that cannot collide with `TAB_ID_*`.
### Preset tabs
`MainFrame::create_preset_tabs` creates `TabPrint`, `TabPrintPlate`, `TabPrintObject`, `TabPrintPart`,
`TabPrintLayer` on `m_param_panel`, and `TabFilament`, `TabPrinter` on `m_param_dialog->panel()`.
`add_created_tab` moves the plate tab out of `tabs_list` into `plate_tab`, and the model tabs into
`model_tabs_list`, so `tabs_list` holds print, filament and printer. Placement and the settings
pipeline: [Settings placement](#settings-placement-paramspanel-paramsdialog-tabs),
`references/orca-settings-ui.md`.
### DPI and colour fan-out
`MainFrame::on_dpi_changed` and `MainFrame::on_sys_color_changed` call each component they own
explicitly: the tab book and top bar `Rescale()`, the action buttons, `plater()->msw_rescale()` /
`sys_color_changed()` (which go on to the preview, canvas, sidebar, `MenuFactory` and the cached
select-machine dialog), `m_param_panel->msw_rescale()`, every tab's `sys_color_changed()`,
`MenuFactory::sys_color_changed(m_menubar)`, `WebView::RecreateAll()`; lazy panels only through
`X::when_built(...)` and built dialogs through `X::if_built()` (`DiffPresetDialog`). A panel or cached
dialog that is not reached from this chain never runs its `msw_rescale` / `sys_color_changed`. The
DPI mechanics are in `references/dpi-bitmaps-fonts.md`; the colour path (and why Windows reaches it
through `force_color_changed`) is in `references/colours-dark-mode.md`.
- **Rule:** When you add a panel with `msw_rescale()` / `on_sys_color_changed()` hooks, add it to the
fan-out of its owner in the same change.
**Why:** child panels are not top-level windows and get no DPI handling of their own from
`DPIAware`; on Windows the dark-mode toggle reaches components only through this chain.
```cpp
// Right (MainFrame::on_dpi_changed): lazy panels through the statics
CalibrationPanel::when_built([](CalibrationPanel& calibration) { calibration.msw_rescale(); });
// Right (MainFrame::on_sys_color_changed): a lazily built dialog
if (DiffPresetDialog* dialog = DiffPresetDialog::if_built())
dialog->on_sys_color_changed();
```
Cite: `MainFrame::on_dpi_changed`, `MainFrame::on_sys_color_changed`, `Plater::msw_rescale`.
## Deferred construction (Lazy, LazyPage, StagedBuild, IdleScheduler)
Design doc: `docs/HLSD/deferred-page-construction.md`. Startup pays only for what the first frame
shows (the start page and the Prepare plater); every other tab, and heavy dialogs and GL resources,
build on first show or in small units while the user is idle. A click during the idle build waits for
one unit at most. The parts are independent and wx-free where possible (`Lazy`, `StagedBuild`,
`PrebuildQueue` are unit-tested in `tests/slic3rutils`: `test_lazy.cpp`, `test_staged_build.cpp`,
`test_prebuild_queue.cpp`).
### The holder: `Lazy<T>` and `LazyInstance<T>`
`Lazy<T>` (`GUI/Lazy.hpp`) holds a factory and the object it makes: `Lazy(name, order, factory)`.
| Member | Contract |
|---|---|
| `get()` | the object, **null until completely built** (a staged object mid-build is null) |
| `ensure()` | builds whatever is left now (busy cursor + log line) and returns the object; null if the factory returned null or a nested call finds it mid-build |
| `when_built(fn)` | runs `fn` now if built, otherwise once the build completes |
| `build_step()` | one unit: the factory first, then one `StagedBuild` step per call; a nested call (a unit that pumps the loop) does nothing |
| `prebuild_order()` | position in the idle queue; lower first; **negative = never prebuilt** |
The holder does not own the object — its wx parent does. A factory that returns null or a unit that
throws leaves the holder and scheduler able to carry on. `LazyInstance<Self>` is a mixin that gives a
type with one instance app-wide the statics `Self::if_built()`, `Self::ensure()`,
`Self::when_built(fn)`; the `Lazy<Self>` constructor registers itself, and a recreated `MainFrame`'s
holder replaces the old one. All statics are harmless (null / no-op) while no holder exists —
including `when_built`, which then drops `fn`.
### The placeholder page: `LazyPage<Panel>`
`LazyPage<Panel> : wxPanel, Lazy<Panel>` (`GUI/LazyPage.hpp`) is the notebook page (the book needs a
page object to insert and remove by pointer). `LazyPage(parent, name, order, factory)`; the default
factory is `new Panel(parent)`. Its `Show(true)` builds the panel the first time (only once the
top-level frame is shown — `MainFrame::Show` completes the start page on the frame's first show) and
forwards later shows/hides to the panel, so the panel's own `Show()` override stays its activation
hook. A panel built while its page is hidden stays hidden, and `when_built` gives it the dark-UI pass
the frame ran before it existed (`apply_dark_ui_to_lazy_panel`). `pending()` is true only while the page
is in the book.
### Staged construction: StagedBuild
`StagedBuild` (`GUI/StagedBuild.hpp`) splits a constructor too big for one unit: the constructor builds
a skeleton and queues the rest with `add_build_step(fn)`; `add_build_steps_of(child)` forwards a child
panel's steps, and the parent is `built()` only once every child is. Constraints, all from the design:
- members created in steps start null, so a partly built panel can be destroyed;
- timers, event handlers and the destructor that touch step content check `built()` first;
- nothing takes focus while off screen (a unit may run while the user types elsewhere);
- a widget added by a step keeps its place through an empty sizer slot the skeleton creates.
### The idle scheduler: IdleScheduler, PrebuildQueue
`PrebuildQueue` (`GUI/PrebuildQueue.hpp`) orders `LazyBase` tasks by `prebuild_order()` (equal order:
insertion order) and runs one slice of units of the first pending task. `IdleScheduler`
(`GUI/IdleScheduler.hpp/.cpp`, `MainFrame::m_idle`) drives it from a self-owned `wxTimer`:
- it ticks every 250 ms and runs a slice only after 500 ms without user input (`GUI_App::input_idle_ms`,
stamped by `GUI_App::FilterEvent` for non-command user-input events and main-frame resizes);
- a slice spends at most 40 ms, then the next slice is `StartOnce(5)` — a separate timer message, so
paint, timers and input queued meanwhile run first. Posting slices as pending events would not do
that, because wx drains every pending event, including ones posted meanwhile, before the next
native message (`src/common/appbase.cpp` `wxAppConsoleBase::ProcessPendingEvents` loops until the
list is empty **[source]**);
- it skips while `wxEventLoopBase::GetActive()->IsYielding()` (a slice inside a `wxYield()` would build
pages in the middle of the code that yielded) and guards re-entry with `m_in_slice`;
- it stops its timer when nothing is pending, so it costs nothing afterwards.
`MainFrame::prebuild_pages_when_idle` (called from `post_init` and `recreate_GUI`) clears the queue
and registers the GL resources (`GLResourcesPrebuild`), the Prepare settings page one option group at
a time (`ParamsPanel::settings_page_prebuild`), the Prepare layout at the book's page size
(`m_prepare_layout_prebuild`), every lazy page with a non-negative order, and the lazily built
dialogs (`m_diff_dialog`); the queue then runs them by `prebuild_order()`. `MainFrame::shutdown`
stops it. Units should fit in one slice on a fast machine; a constructor over that is staged.
**Platforms.** GTK: a timer that is always due (`g_timeout_add`, default priority,
`src/gtk/timer.cpp`) runs ahead of the lower-priority GLib sources that repaint and that deliver
posted events and idle (wx's single `G_PRIORITY_LOW` idle source, `src/gtk/app.cpp`
`wxApp::WakeUpIdle` **[source]**) — hence the 5 ms gap rather than 0. macOS: wxOSX rejects a 0 ms
timer (`src/osx/core/timer.cpp:74` `wxCHECK_MSG(m_milli > 0, …)` **[source]**; with asserts compiled
out, `StartOnce(0)` silently never fires). Windows: a slice also waits while the native queue holds input
(`GetQueueStatus`), not counting mouse moves, which Windows synthesises when a window appears under the
cursor. GTK GL resources: the prebuild task `gtk_widget_realize`s the hidden canvas before making the
context current, since GTK creates the surface only on realize.
### Reaching a lazy object
| Need | Use |
|---|---|
| work the object can live without (refresh, status update) | `if (X* x = X::if_built()) x->…;` |
| navigating to it or showing it | `X::ensure()->…` (as `MainFrame::jump_to_monitor`) |
| state it would not fetch for itself when constructed; rescale/recolour of a staged panel (null from `if_built()` while mid-build) | `X::when_built([](X& x) { … });` |
A panel that pulls its own state in its constructor only ever needs `if_built()`.
### Usage
The shape to copy for a new tab (`MainFrame::init_tabpanel`):
```cpp
// Panel: one instance app-wide; heavy constructors also derive StagedBuild
class CalibrationPanel : public wxPanel, public StagedBuild, public LazyInstance<CalibrationPanel> { … };
// MainFrame::init_tabpanel: id, order (gaps leave room between neighbours; <0 = never prebuilt)
m_calibration_page = new LazyPage<CalibrationPanel>(m_tabpanel, TAB_ID_CALIBRATION, 30);
m_lazy_pages.push_back(m_calibration_page);
m_tabpanel->AddPage(TAB_ID_CALIBRATION, m_calibration_page, _L("Calibration"), "tab_calibration_active");
// MainFrame::on_dpi_changed / on_sys_color_changed
CalibrationPanel::when_built([](CalibrationPanel& calibration) { calibration.msw_rescale(); });
```
A lazily built dialog is a `Lazy<Dlg>` member of `MainFrame` with `Dlg : DPIDialog,
LazyInstance<Dlg>`, e.g. `m_diff_dialog("compare_presets", 100, [this] { return make_diff_dialog(); })`,
added to the queue in `prebuild_pages_when_idle` if it should prebuild. The panel's constructor must
cope with the main frame already existing and the user being busy elsewhere, and do all its own setup:
the main frame does nothing to a panel after creating it.
### Pitfalls
- **Rule:** Do not `ensure()` a lazy object for optional work.
**Why:** `ensure()` builds the whole object now under a busy cursor, defeating the deferral for a
page the user may never open.
```cpp
// Wrong: a DPI change builds the Device tab
MonitorPanel::ensure()->msw_rescale();
// Right
MonitorPanel::when_built([](MonitorPanel& monitor) { monitor.msw_rescale(); });
```
Cite: `MainFrame::on_dpi_changed`.
- **Rule:** In a staged panel, timer and event handlers return early until `built()`.
**Why:** a step-built member is null until its step runs; the timer can fire, or the book can select
the page, in between.
```cpp
// Right (MonitorPanel::update_all)
if (!built())
return;
```
Cite: `MonitorPanel::update_all`, `MonitorPanel::init_tabpanel` (steps queued before the page is
added, "where built() must already be false").
- **Rule:** Never take focus while built off screen.
**Why:** a unit can run while the user is typing in another control; `SetFocus` steals the
keystrokes.
```cpp
// Wrong
page->SetFocus();
// Right (MonitorPanel page-changed handler)
if (page->IsShownOnScreen())
page->SetFocus();
```
- **Rule:** Put background UI construction into the prebuild queue, not into idle events.
**Why:** an `wxEVT_IDLE` + `RequestMore()` loop busy-loops the CPU (wxGTK keeps its idle source
installed while more is requested, `src/gtk/app.cpp` `wxApp::DoIdle` **[source]**), runs inside
every `wxYield()` (a full yield calls `ProcessIdle()`, `src/common/evtloopcmn.cpp:182-191`
**[source]**), and builds even while the user is clicking or typing, so the input waits behind it.
```cpp
// Wrong
Bind(wxEVT_IDLE, [this](wxIdleEvent& e) { /* build the next part */ e.RequestMore(); });
// Right: a Lazy<…> holder (or a LazyBase task) registered in MainFrame::prebuild_pages_when_idle
m_idle.add(m_diff_dialog);
```
Cite: `IdleScheduler::tick`, `docs/HLSD/deferred-page-construction.md`.
## Plater and Sidebar
### Structure
`Plater` and `Sidebar` (`GUI/Plater.hpp/.cpp`) are pimpl'd (`std::unique_ptr<priv> p`); public methods
forward to `p->`. `Plater::priv` owns the model, `PartPlateList`, the three canvases (`view3D`,
`preview`, `assemble_view` in one sizer inside `panel_3d`), `BackgroundSlicingProcess
background_process`, `PlaterWorker<BoostThreadWorker> m_worker` (`Plater::get_ui_job_worker()`), the
`NotificationManager`, `Mouse3DController`, `MenuFactory menus` and the AUI manager. New private state
and helpers go into `priv` in `Plater.cpp`; the header changes only for a public entry point.
### Event hub and custom events
`Plater::priv::priv` is the hub: it binds Orca events on the canvases (`EVT_GLCANVAS_OBJECT_SELECT`,
`EVT_GLCANVAS_RIGHT_CLICK`, `EVT_GLCANVAS_ARRANGE`, …, posted by `GLCanvas3D::post_event`, which does
`wxPostEvent(m_canvas, …)`) and on the plater itself (`EVT_SLICING_UPDATE`, `EVT_SLICING_COMPLETED`,
`EVT_PROCESS_COMPLETED`, `EVT_EXPORT_BEGAN`, `EVT_GLCANVAS_COLOR_MODE_CHANGED`, …). Events are declared
in the header of the class that emits them (`GLCanvas3D.hpp`, `Plater.hpp`, `NotificationManager.hpp`,
`ParamsDialog.hpp`); `BackgroundSlicingProcess` is handed the ids to post (`set_finished_event`,
`set_export_began_event`).
Payload types are in `GUI/Event.hpp`: `SimpleEvent`, `IntEvent`, `Event<T>`, `ArrayEvent<T,N>`. They
derive from `wxEvent` but set `m_propagationLevel = wxEVENT_PROPAGATE_MAX` (a plain `wxEvent` does not
propagate, a command event does — `interface/wx/event.h:270-273`) and implement `Clone()`, so they can
be posted or queued and travel up to the plater.
```cpp
wxDECLARE_EVENT(EVT_GLCANVAS_ARRANGE, SimpleEvent); // GLCanvas3D.hpp, next to the emitter
wxDEFINE_EVENT(EVT_GLCANVAS_ARRANGE, SimpleEvent); // GLCanvas3D.cpp
post_event(SimpleEvent(EVT_GLCANVAS_ARRANGE)); // GLCanvas3D: wxPostEvent on the wxGLCanvas
view3D_canvas->Bind(EVT_GLCANVAS_ARRANGE, [this](SimpleEvent& evt) { … }); // Plater::priv::priv
wxQueueEvent(wxGetApp().plater(), new SimpleEvent(EVT_MODIFY_FILAMENT, filament_info)); // heap, owned (ParamsDialog)
```
Binding, `Skip`, `CallAfter` and cross-thread rules are in `references/events.md` and
`references/threads-timers-app.md`.
- **Rule:** A short-lived object that listens to plater (or canvas) events binds through `EventGuard`
(`GUI_Utils.hpp`) or unbinds in its destructor, and `Skip()`s.
**Why:** the plater outlives the listener; a handler left bound runs on a freed object. Dynamic
handlers run most recently bound first, so a handler that does not `Skip()` hides the event from the
plater's own handler (dynamically bound handlers are searched in reverse order of registration,
`docs/doxygen/overviews/eventhandling.h:480`). `EventGuard` stores the functor at a stable
address, which is what functor `Unbind` matches on (`interface/wx/event.h:967-970`).
```cpp
// Wrong
wxGetApp().plater()->Bind(EVT_SLICING_UPDATE, [this](SlicingStatusEvent& e) { refresh(); });
// Right: member EventGuard unbinds when the dialog dies
m_slicing_guard = EventGuard(wxGetApp().plater(), EVT_SLICING_UPDATE,
[this](SlicingStatusEvent& e) { refresh(); e.Skip(); });
```
Cite: `EventGuard` (`GUI_Utils.hpp`), `PlaterWorker` (binds the plater's idle/paint through it).
### Sidebar content
`Sidebar::Sidebar` builds, inside `p->scrolled` (a `wxPanel`; the sidebar is itself the AUI pane
`"sidebar"`):
1. the printer block — title bar, `PlaterPresetComboBox* combo_printer`, bed type
(`combo_printer_bed`), nozzle/extruder cards (`ExtruderGroup`), sync and connect buttons;
2. the filament block ("Project Filaments") — `combos_filament`, add / delete / edit, purge mode,
flushing volumes, AMS sync;
3. the **`ParamsPanel` top bar reparented in** (`params_panel->get_top_panel()->Reparent(p->scrolled)`:
"Process" title, global/object switch, mode view);
4. `p->sizer_params` (proportion 2): the object search box, `ObjectList` and the `ObjectLayers`
sizer (`ObjectSettings` is created on `p->scrolled`, but its sizer is added only in the
`#if !NEW_OBJECT_SETTING` branch);
5. the **`ParamsPanel` itself reparented in** with proportion 3.
So the process settings are the full `ParamsPanel` in the sidebar, not a summary group; the process
preset combo is the `TabPrint` page's own `TabPresetComboBox`. `Sidebar::update_presets(type)` refreshes
the combos after a preset change; `Sidebar::jump_to_option(...)` activates a tab row and blinks it;
`Sidebar::settings_index()` (`Search::SettingsIndex`) and `Sidebar::get_searcher()`
(`Search::OptionsSearcher`) are the settings search — `references/orca-settings-ui.md`.
`Sidebar::load_ams_list(obj)` is how device data reaches the filament block.
Spacing constants come from `SidebarProps` (`Plater.hpp`): `TitlebarMargin()`, `ContentMargin()`,
`ContentMarginV()`, `IconSpacing()`, `WideSpacing()`, `ElementSpacing()`, used as
`FromDIP(SidebarProps::ContentMargin())`. A new sidebar control uses them and is added to
`Sidebar::msw_rescale`, `Sidebar::sys_color_changed` and, if mode-dependent, `Sidebar::update_mode`.
### Docking
`Plater::priv` owns `AuiMgr m_aui_mgr` (a `wxAuiManager` subclass whose `CreateFloatingFrame` returns a
themed `FloatFrame : wxAuiFloatingFrame`), managing the plater. Panes: `"sidebar"` (left, no close
button, not top/bottom dockable), `"main"` (`CenterPane()`, the `panel_3d`), `"uv_editor"` (right,
hidden until the texture-displacement gizmo shows it), plus dynamic dock panes. The default perspective
is saved right after `AddPane`; the app-config `window_layout` is applied with
`LoadPerspective(layout, false)` and falls back to the default on failure; `Plater::priv::reset` saves
it back. On Wayland floating is disabled (`wxAUI_MGR_ALLOW_FLOATING` cleared,
`sanitize_window_layout_for_wayland` strips floating state). wx AUI contracts (`Update()` batching,
perspective semantics, floating-frame lifetime): `references/webview-gl-aui-media.md`.
`Plater::add_dock_pane(window, name, caption, dock, size, on_close)` adds a pane: `window` must be a
child of the plater; `dock` is `"left"`, `"right"`, `"bottom"` or `"float"`; `size` is in DIPs; the
name is made unique with `#2`, `#3`…; a saved per-pane layout entry restores its last place. A pane
closed by its own close button is destroyed after `on_close` runs; `remove_dock_pane(window)` destroys
it **without** calling `on_close`; `remove_dock_panes()` runs from `MainFrame::shutdown`.
`show_dock_pane(window, show)` toggles it. `DockPanel : WebPanel` is the plugin pane, named with
`plugin_pane_name(plugin_key, title)` ("stable across sessions … free of wxAuiManager layout
delimiters").
- **Rule:** Name a dock pane with a stable, untranslated identifier free of `|`, `;`, `=` and `\`.
**Why:** `LoadPerspective` restores only panes whose names match, and wx's own parser hides every
pane it does not find (`src/aui/framemanager.cpp:1906-1912` **[source]**, contrary to `interface/wx/aui/framemanager.h:562-565`).
A translated or reused name loses its layout after a language switch or collides.
```cpp
// Wrong
plater->add_dock_pane(panel, into_u8(caption), caption, "right", size, on_close);
// Right
plater->add_dock_pane(panel, plugin_pane_name(plugin_key, title), caption, "right", size, on_close);
```
Cite: `Plater::priv::add_dock_pane`, `DockPanel.hpp`.
### Context menus
Right-click menus are built and cached by `MenuFactory` (`GUI/GUI_Factories.hpp`, `Plater::priv::menus`)
and shown with `Plater::PopupMenu`, which suppresses background-processing updates while the menu tracks
and defers slicing error dialogs (`m_tracking_popup_menu`) to a `CallAfter` after the menu closes. Detail:
`references/popups-menus.md`.
## Settings placement: ParamsPanel, ParamsDialog, Tabs
- `m_param_panel` (a `ParamsPanel`) is created as a `m_tabpanel` child in `MainFrame::init_tabpanel`
and reparented into the sidebar by `Sidebar::Sidebar` (top bar and body separately). It hosts the
process tab and the model-scope tabs; `ParamsPanel::switch_to_object` / `switch_to_global` flip the
sidebar between object and global settings.
- `m_param_dialog` (a `ParamsDialog : DPIDialog`, parented to the plater) owns a second `ParamsPanel`
with the filament and printer tabs. It is **modeless with emulated modality** (a `wxWindowDisabler`
while shown); `Popup()`, the close/validation path and where post-edit work goes:
`references/orca-settings-ui.md` §Where the tabs live; the modality mechanics:
`references/windows-dialogs.md` §6.
The pipeline from `PrintConfigDef` to `Field`, adding a setting, toggles, search and per-object
overrides: `references/orca-settings-ui.md`.
- **Rule:** Null-check `get_tab()`.
**Why:** tabs complete after construction, and `MainFrame::shutdown` clears `tabs_list`.
```cpp
// Wrong
wxGetApp().get_tab(Preset::TYPE_PRINTER)->reload_config();
// Right
if (Tab* tab = wxGetApp().get_tab(Preset::TYPE_PRINTER)) tab->reload_config();
```
## ObjectList
`ObjectList : wxDataViewCtrl` (`GUI/GUI_ObjectList.hpp`) over `ObjectDataViewModel : wxDataViewModel`
(`GUI/ObjectDataViewModel.hpp`), whose nodes are typed by the `ItemType` bitmask (`itPlate`,
`itObject`, `itVolume`, `itInstanceRoot`, `itInstance`, `itSettings`, `itLayerRoot`, `itLayer`,
`itInfo`). It lives in the sidebar, is initialised by `obj_list()->init()` after the main frame is
created, gets keys through the shortcut registry (`ObjectList::dispatch_shortcut`; on macOS a
`wxAcceleratorTable` regenerated by `update_shortcut_accelerators`, because the native control
delivers no key events), and shows context menus through `MenuFactory` + `Plater::PopupMenu`. Per-object
overrides appear as `itSettings` children that open the model-scope tabs. Model ownership, renderers,
drag and drop, native-vs-generic data view: `references/controls-dataview.md`.
## 3D canvas, ImGui layer and NotificationManager
### GLCanvas3D
`GLCanvas3D` is **not a window**: it wraps a `wxGLCanvas* m_canvas` (`get_wxglcanvas()`) created by
`OpenGLManager`, binds its size/idle/key/mouse/paint/focus/timer handlers in `bind_event_handlers`, and
must be unbound before teardown (`Plater::unbind_canvas_event_handlers`, from `MainFrame::shutdown`).
Rendering is idle-driven: handlers mark `set_as_dirty()`, `request_extra_frame()` or
`schedule_extra_frame(ms)`, and `on_idle` renders. Outgoing events go through `GLCanvas3D::post_event`.
The view, preview and assemble canvases and the UV editor share one `wxGLContext`. Paint/idle/swap
details, the shared-context attribute rule and EGL/GLX: `references/webview-gl-aui-media.md`.
### What is ImGui and what is wx
| Drawn with ImGui inside the canvas | wx windows |
|---|---|
| gizmo panels (`GLGizmoBase::on_render_input_window`), `NotificationManager` and its hint / slicing-progress notifications, the preview layer slider (`IMSlider`), `IMToolbar`, the G-code legend (`GCodeViewer`), plate labels (`PartPlate`), and the overlays in `GLCanvas3D::_render_overlays` (plate-select toolbar, variable-layer-height dialog, 3D navigator, toolbar item windows) | everything outside the canvas: sidebar, tabs, dialogs, top bar, Home/Device/Project pages |
`GLToolbar` is not ImGui: its icons are OpenGL-textured quads; only its item option windows are
ImGui callbacks. ImGui input arrives only through the canvas's own handlers (`ImGuiWrapper::update_mouse_data` /
`update_key_data`), so text entry needs canvas focus; ImGui sizes are physical pixels (scale by
`GLCanvas3D::get_scale()`); ImGui strings are UTF-8 (`_u8L`). wx theming, `DPIDialog`, sizers and
`Widgets/` do not apply there.
- **Rule:** Never place a wx child window over the GL canvas; draw the overlay in ImGui or put a wx
window beside the canvas.
**Why:** on GTK the GL canvas is a native child window or, on Wayland, a subsurface drawn outside
GTK, and a wx child over it does not reliably stack above the GL content.
```cpp
// Wrong
auto* banner = new wxPanel(canvas->get_wxglcanvas());
// Right: ImGui from the gizmo / overlay pass, or a sibling of the canvas in the plater layout
void on_render_input_window(float x, float y, float bottom_limit) override; // GLGizmoBase
```
Cite: `docs/HLSD/design-tab.md` (sketch banner "a sibling of the canvas, not a child over it").
### NotificationManager
`NotificationManager` (`GUI/NotificationManager.hpp`) is owned by `Plater::priv` and initialised after
the canvas exists (`Plater::init_notification_manager`; notifications pushed before `init()` are
neither shown nor updated). Push with
`push_notification(NotificationType, NotificationLevel, text, hypertext, callback)`;
`NotificationType::CustomNotification` covers one-offs, and a new `NotificationType` is needed only
when the notification must be closed or updated by type (`close_notification_of_type`). Levels order
importance and fading (`RegularNotificationLevel` fades, `ErrorNotificationLevel` never does). It has
no locking, and it draws only while the plater's canvas renders.
- **Rule:** Push notifications from the UI thread, and use a dialog for messages that must be seen
while Home or Device is shown.
**Why:** the manager's containers are unsynchronised; a notification pushed while the plater is
hidden is not drawn until the user returns to Prepare/Preview.
```cpp
// Wrong: inside Job::process or an agent callback
wxGetApp().notification_manager()->push_notification(text);
// Right
wxGetApp().CallAfter([text] {
if (wxGetApp().is_closing()) return;
if (NotificationManager* nm = wxGetApp().notification_manager())
nm->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel, text);
});
```
## Background work
| Kind | Mechanism | Back to the UI |
|---|---|---|
| UI-initiated task (arrange, orient, fill bed, send) | `Job` subclass in `GUI/Jobs/`; `replace_job(plater->get_ui_job_worker(), std::make_unique<OrientJob>())`, or a dialog-owned `PlaterWorker<BoostThreadWorker>` | `Job::finalize` and `Ctl::call_on_main_thread`, delivered from the owner window's idle/paint |
| slicing and export | `BackgroundSlicingProcess` (`Plater::priv::background_process`) | `wxQueueEvent(plater, evt.Clone())`; `execute_ui_task` for a synchronous UI call |
| network agents, HTTP, preset sync | agent / io threads | `wxGetApp().CallAfter` + `is_closing()`; agents get `set_queue_on_main_fn` |
| geometry | TBB | no wx calls inside |
The contracts (which side runs what, cancellation, the `eptr` rethrow, deadlock rules, platform stalls)
are in `references/threads-timers-app.md`.
## Device and Monitor pages
Design doc: `docs/HLSD/printer-agent.md`; implementing a printer agent: the `orca-printer-communication`
skill. Data flow:
1. `NetworkAgent` (façade over the active `IPrinterAgent` and the cloud agents) calls the callbacks
installed by `GUI_App::init_networking_callbacks` (`set_on_message_fn`, `set_on_local_message_fn`,
`set_on_printer_connected_fn`, `set_queue_on_main_fn`, …) and by `GUI_App::post_init` /
`restart_networking` (`set_on_ssdp_msg_fn`) on its own threads.
2. Each callback returns if `is_closing()`, then `CallAfter`s a by-value lambda that re-checks
`is_closing()` and, on the UI thread, updates the `MachineObject` (`parse_json`), refreshes
`Sidebar::load_ams_list` and `Plater::update_machine_sync_status`. `MachineObject` and
`DeviceManager` state is main-thread-only.
3. The Device UI is **pull-based**: `MonitorPanel : wxPanel, StagedBuild, LazyInstance<MonitorPanel>`
(`GUI/Monitor.hpp`) starts its refresh `wxTimer` in its `Show(true)` override, stops it on hide, and
`on_timer` → `update_all()` reads `DeviceManager::get_selected_machine()` and pushes it into the
`StatusPanel` (`StatusBasePanel : wxScrolledWindow, StagedBuild`), HMS and media pages inside a
`Tabbook`. `DeviceManager::start_refresher` / `stop_refresher` follow the main frame's `wxEVT_SHOW`.
4. Camera playback: `MediaPlayCtrl` selects and tears down the stream backend; the wx parent owns the
rendering window.
New device UI goes inside `MonitorPanel` / `StatusPanel`, reads state on the timer, makes no network
call on the UI path, and stops its timers on hide.
## Web-based UI
| Host | Use |
|---|---|
| `WebView::CreateWebView(parent, url)` (`Widgets/WebView.hpp`) | the sanctioned way to make a browser: backend choice, handlers, user agent, `"wx"` script handler once per view, registration for `WebView::RecreateAll()` theming, a `FakeWebView` stub instead of null on failure. A raw `wxWebView::New` view gets none of these (no theming on colour change, no null safety) |
| `WebViewPanel` (`WebViewDialog.hpp`, `LazyInstance`) | the Home tab |
| `PrinterWebView` (`LazyInstance`) | the web Device tab (Fluidd/Mainsail/printer UIs) |
| `WebViewHostDialog : DPIDialog` (`Widgets/WebViewHostDialog.hpp`) | local-HTML dialogs: `create_webview(resource_path, …)`, pure-virtual `on_script_message(json)`, `handle_common_script_command`, theme user scripts registered once, `apply_theme_live`, `call_web_handler` (C++ → JS). Subclasses include `WebDialog`, `PluginsDialog`, `PluginsConfigDialog`, `SpeedDialWebDialog`, `TerminalDialog`, `PresetBundleDialog`, `ExportPresetBundleDialog` |
| `WebPanel`, `DockPanel : WebPanel` | plugin pages and docked plugin panes |
| `GuideFrame` (`WebGuideDialog.hpp`) | setup wizard |
Script messages arrive synchronously inside the native WebKit delegate / GTK signal on macOS and Linux
(**[source]**; Edge queues them), so a subclass defers every window operation (show, close, create, `EndModal`) with `CallAfter` and
re-checks liveness inside; `handle_common_script_command`'s `close_page` ends the dialog directly and
`call_web_handler` captures `this` in an app `CallAfter`, so a subclass whose lifetime can end first
adds its own guard. Backend rules, creation order, `RunScript` re-entrancy: `references/webview-gl-aui-media.md`.
## Preferences
`PreferencesDialog : DPIDialog` (`GUI/Preferences.hpp`) is a `TabCtrl m_pref_tabs` over the
`PreferencesTab` pages (`General`, `Control`, `Graphics`, `Online`) plus the Associate and Developer pages,
each a `wxFlexGridSizer` of rows built in `PreferencesDialog::create_items` with the
`create_item_title / label / checkbox / combobox / input / spinctrl / decimal_input / button / …`
helpers (title, tooltip, app-config key, …, `wiki_url`). Window focus follows creation order, so rows are
created in display order; an empty tooltip is filled from the title.
- Rows **write `app_config` and `save()` immediately** in their handler; side effects are `param == "…"`
branches inside the row's handler (`create_item_checkbox`).
- The dialog is opened only through `GUI_App::open_preferences(tab, highlight_option)`, which shows it
modally in an inner scope (it must be destroyed before `recreate_GUI`), then handles what must happen
after it closes: canvas focus, reloading the print when sequence options changed, file associations
on Windows, redraw when a render setting changed, a pending language switch (`load_language`,
`ActionRegistry::relocalize_builtins`, `recreate_GUI`).
- The Windows-only dark-mode row (`create_item_darkmode`) is described in
`references/colours-dark-mode.md`.
```cpp
// PreferencesDialog::create_items — a checkbox row bound to an app_config key
auto item_show_splash_scr = create_item_checkbox(_L("Show splash screen"),
_L("Show the splash screen during startup."), "show_splash_screen");
g_sizer->Add(item_show_splash_scr);
// AppConfig::set_defaults — the default for a fresh config
if (get("show_splash_screen").empty())
set_bool("show_splash_screen", true);
```
- **Rule:** Put a preference's runtime effect where it belongs: immediate effects in the row handler,
effects that need the dialog gone (rebuilding the GUI, reloading the print) in
`GUI_App::open_preferences`.
**Why:** `recreate_GUI` while the dialog is alive crashed in `~wxDialogBase` (the inner-scope comment
in `open_preferences`); work done from the row handler runs under the modal loop.
## AppConfig
`AppConfig` (`libslic3r/AppConfig.hpp`) is Orca's own string store, saved as JSON, not `wxConfig`
(comparison with `wxConfig`: `references/strings-i18n-files.md`). Keys live in sections (`"app"` by
default).
| Call | Behaviour |
|---|---|
| `get(key)` / `get(section, key)` | the string, `""` if missing |
| `get_bool(key)` | `get("app", key) == "true" \|\| get("app", key) == "1"` |
| `get_bool(section, key)` | `get(section, key) == "true" \|\| get("app", key) == "1"` — the `"1"` is read from **`"app"`** |
| `set(key, value)`, `set(section, key, value)`, `set_str(section, key, value)`, `set(section, key, bool)` | marks dirty only when the value changes; the `bool` overload writes `"true"`/`"false"`, and a bare `const char*` value selects it — pass a `std::string` or use `set_str` (`references/strings-i18n-files.md` §AppConfig) |
| `set_bool(key, value)` | `"true"`/`"false"` in `"app"` |
| `has(section, key)`, `dirty()`, `save()` | `save()` throws `CriticalException` off the main thread |
| `set_defaults()` | fills missing keys at load (`if (get("k").empty()) set…`) |
Persistence: the app idle handler saves whenever `dirty()` after `post_init`, and `MainFrame::shutdown`
saves if dirty, so a `set` persists on its own; an explicit `save()` is for immediate persistence
(Preferences rows, dark-mode init). Keys use both conventions — `set_bool` keys hold `"true"/"false"`,
others hold `"1"/"0"` (`dark_color_mode`, `default_page`, `sys_menu_enabled`) — so compare with the
key's own convention.
- **Rule:** Read a non-`"app"` boolean with `get(section, key)` and an explicit comparison.
**Why:** `get_bool(section, key)` accepts `"1"` only from the `"app"` section.
```cpp
// Wrong: false when section/key holds "1"
bool on = app_config->get_bool("section", "key");
// Right
bool on = app_config->get("section", "key") == "1";
```
- **Rule:** Write `app_config` on the main thread only.
**Why:** the storage map is unsynchronised and `save()` throws off the main thread.
```cpp
// Wrong: on a worker or agent thread
wxGetApp().app_config->set("key", value);
// Right
wxGetApp().CallAfter([value] { if (!wxGetApp().is_closing()) wxGetApp().app_config->set("key", value); });
```
Cite: `AppConfig::save`.
## Where new code goes
| You add | Put it | Must also |
|---|---|---|
| Modal dialog | `GUI/<Name>Dialog.hpp/.cpp`, `class X : public DPIDialog` | follow the dialog recipe in `references/windows-dialogs.md` (parent fallback `wxGetApp().mainframe`, `on_dpi_changed`, `SetSizerAndFit`, `UpdateDlgDarkUI` last) |
| Message / confirm box | `MessageDialog`, `RichMessageDialog`, `WarningDialog`, `ErrorDialog`, `InfoDialog` (`MsgDialog.hpp`), or `show_error` / `show_info` (`GUI.hpp`) | never `wxMessageBox`; `show_error` is asynchronous (an app `CallAfter` around an `ErrorDialog`), so pass a parent that outlives the call or none; `show_info` is a synchronous modal `MessageDialog` — `references/windows-dialogs.md` |
| Local-HTML dialog | subclass `WebViewHostDialog` | implement `on_script_message`; reuse `handle_common_script_command`; defer window operations; register user scripts once ([Web UI](#web-based-ui)) |
| Top-level tab | `LazyPage<Panel>` + `TAB_ID_*` in `MainFrame::init_tabpanel` | panel derives `LazyInstance<Panel>` (+ `StagedBuild` if heavy); fan-out hooks with `when_built`; statics outside `MainFrame`; no focus off screen |
| Heavy dialog owned by the frame | `Lazy<Dlg>` member of `MainFrame`, `Dlg : LazyInstance<Dlg>` | register in `prebuild_pages_when_idle` to prebuild; `if_built()` in the colour fan-out |
| Sidebar control | `Sidebar::Sidebar`, state in `Sidebar::priv` | `SidebarProps` spacing; add to `Sidebar::msw_rescale`, `sys_color_changed`, `update_mode` |
| Docked pane | `Plater::add_dock_pane` | window is a plater child; stable name; know that `remove_dock_pane` skips `on_close` |
| Print / filament / printer setting | def in `PrintConfig.cpp`, row in `Tab*::build` | the full checklist in `references/orca-settings-ui.md` |
| Per-object setting | `SettingsFactory::OBJECT_CATEGORY_SETTINGS` / `PART_CATEGORY_SETTINGS` | `references/orca-settings-ui.md` |
| Overlay or tool UI in the 3D view | gizmo `on_render_input_window` or `GLCanvas3D::_render_overlays` | ImGui + `_u8L`; redraw via `set_as_dirty()` / `request_extra_frame()`; GL only in the canvas's current context |
| Transient message about the 3D view | `NotificationManager::push_notification` | UI thread; new `NotificationType` only to close/update by type |
| Main-menu item | the shared `wxMenu` in `MainFrame::init_menubar_as_editor` / `generate_help_menu` | `append_menu_item`, or `append_shortcut_item` when it has a shortcut — `references/popups-menus.md` |
| Context-menu item | `MenuFactory` (`GUI_Factories.cpp`) | show with `Plater::PopupMenu` |
| Speed Dial command | the `NativeCommands` catalog (`NativeCommands.cpp`, `NativeCommand{key, title, group, input, icon, runner}`) | `ActionRegistry` stores and dispatches it |
| Keyboard shortcut | `Shortcut` enum + `shortcut_table` (`Shortcuts.cpp`) | handle in the context's dispatcher; labels from the registry — `references/mouse-keyboard-focus.md`, `docs/HLSD/keyboard-shortcuts.md` |
| Preference | a `create_item_*` row in `PreferencesDialog::create_items`; default in `AppConfig::set_defaults` | effects in the row handler; post-close effects in `GUI_App::open_preferences` |
| Background task | `Job` subclass in `GUI/Jobs/` | UI only in `finalize` / `call_on_main_thread`; poll `was_canceled()` — `references/threads-timers-app.md` |
| Device UI | inside `MonitorPanel` / `StatusPanel` | pull from `DeviceManager::get_selected_machine()` on the timer; no network calls on the UI path |
| Reusable control | `GUI/Widgets/` | `references/orca-widgets.md`, `references/painting-custom-widgets.md` |
| Source files | `src/slic3r/CMakeLists.txt` | [Build registration](#build-registration) |
| Tests for wx-free GUI logic | `tests/slic3rutils/test_<subsystem>.cpp` (as `test_lazy.cpp`, `test_shortcuts.cpp`) | list the file in that suite's `CMakeLists.txt` (`tests/AGENTS.md`) |
## Build registration
`src/slic3r/CMakeLists.txt` defines `SLIC3R_GUI_SOURCES`, the list compiled into `libslic3r_gui`. It
covers everything under `src/slic3r` (`GUI/`, `GUI/Widgets/`, `GUI/Jobs/`, `Utils/`, `Config/`,
`plugin/`), with paths relative to `src/slic3r`. Add a new `.cpp`/`.hpp` pair on consecutive lines
(the list is only roughly alphabetical). Additional places:
| File kind | Where |
|---|---|
| Windows-only sources | `if (WIN32) list(APPEND SLIC3R_GUI_SOURCES …)` (the vendored `GUI/dark_mode/` code lives there) |
| macOS Objective-C++ (`.mm`) and their headers | `if (APPLE) list(APPEND SLIC3R_GUI_SOURCES …)` |
| Design/CAD UI | the `if (SLIC3R_CAD) list(APPEND …)` block; shared code that references it is guarded with `#ifdef SLIC3R_CAD` (the root `CMakeLists.txt` adds the definition) |
| `GUI/DeviceCore/`, `GUI/DeviceTab/` | their own `CMakeLists.txt`, included with `add_subdirectory`, which `list(APPEND SLIC3R_GUI_SOURCES …)` and re-export it with `PARENT_SCOPE` |
- **Rule:** Keep platform-only sources out of the shared list.
**Why:** an `.mm` file or a Win32-only header in the shared list breaks the other platforms' builds.
```cmake
# Wrong: in the shared set(SLIC3R_GUI_SOURCES …) list
GUI/GUI_UtilsMac.mm
# Right
if (APPLE)
list(APPEND SLIC3R_GUI_SOURCES
GUI/GUI_UtilsMac.mm
)
endif ()
```
## Design docs (docs/HLSD)
Per `AGENTS.md`, the high-level design of a subsystem goes in `docs/HLSD/<subsystem>.md`, describes
the design as it stands (no phases or before/after framing), and is updated in the same PR when a change
invalidates it. Planning output stays in the gitignored `docs/superpowers/`. GUI-relevant documents:
| Doc | Covers |
|---|---|
| `docs/HLSD/deferred-page-construction.md` | `Lazy`, `LazyPage`, `StagedBuild`, `IdleScheduler`, `PrebuildQueue`, GL-resource prebuild; rules for reaching lazy objects, unit size, order |
| `docs/HLSD/keyboard-shortcuts.md` | `KeyChord`, `Shortcut` / `shortcut_table`, `ShortcutRegistry`, contexts and dispatchers, labels, the shortcuts dialog, "Adding a shortcut" |
| `docs/HLSD/design-tab.md` | the Design (CAD) tab: `SLIC3R_CAD` gate, null-guarded hooks in `GLCanvas3D`, Esc-level contract, generated offer table, project persistence |
| `docs/HLSD/printer-agent.md` | `NetworkAgent`, `IPrinterAgent`, `ICloudServiceAgent`, `DeviceManager` / `MachineObject` ownership, camera playback boundary |
The other HLSD documents cover slicing features and profile data (for example `preset-cache.md`, which
explains how system presets load at startup).
@@ -0,0 +1,707 @@
# OrcaSlicer settings UI: PrintConfig → Tab
How a `ConfigOptionDef` becomes a row in a settings tab, how edits flow back into the config, and everything
a print/filament/printer setting must touch to load, save, show, search, translate, toggle and override.
Read it before adding or changing a setting, writing a `Field` type or custom row widget, adding dependency
rules, or debugging a settings row that does not show, save, search, revert or translate.
Contents: [Rules](#rules) · [Pipeline](#pipeline-at-a-glance) · [ConfigOptionDef](#configoptiondef-the-data-side) ·
[Config classes and preset lists](#config-classes-preset-lists-and-variants) · [Tabs and placement](#tabs-pages-and-where-they-live) ·
[Groups, options, lines](#optionsgroup-option-and-line) · [build_field](#optionsgroupbuild_field) ·
[Field and value flow](#field-and-the-value-flow) · [Control pooling](#field-control-pooling) ·
[Lazy building and OG_CustomCtrl](#lazy-building-and-og_customctrl) · [ConfigManipulation](#configmanipulation-toggles-and-fix-ups) ·
[Search index](#search-index-registration) · [Localization](#localization-of-option-definitions) ·
[Per-object overrides](#per-object-part-layer-and-plate-overrides) · [Checklist](#checklist-adding-a-setting)
## Rules
1. Declare a setting once, in `PrintConfigDef::init_*_params()`, with `label`, `category`, `tooltip`, `sidetext`,
`mode`, limits and a default; mark every user-visible string with `L()`, never `_L()`. → [ConfigOptionDef](#configoptiondef-the-data-side)
2. Put the member in the `PRINT_CONFIG_CLASS_DEFINE` block of the scope it belongs to; the class decides whether
the setting can be overridden per object, part or layer range. → [Config classes](#config-classes-preset-lists-and-variants), [Overrides](#per-object-part-layer-and-plate-overrides)
3. List the key in the `Preset` option list of its preset type; without it the key is absent from the tab's
config and the row cannot be built. → [Config classes](#config-classes-preset-lists-and-variants)
4. A per-variant key is a vector option listed in the matching `*_options_with_variant` set, appended with index
`0`, and toggled with the variant index. → [Variants](#per-extruder-variant-options)
5. Add the row with `optgroup->append_single_option_line(key, wiki_path)` in `TabX::build()`; widget, tooltip,
undo/system icons, dirty tracking and search all come from the def. → [Groups](#optionsgroup-option-and-line)
6. Page titles, group titles, `Line` labels and tooltips are English `L()` strings; translation happens at
display time. → [Localization](#localization-of-option-definitions)
7. Do not rely on `L_CONTEXT` in a def: the display path translates without context. → [Localization](#localization-of-option-definitions)
8. A row built from a custom widget has no `Field`; give its key name branches in `Tab::decorate`,
`Tab::on_roll_back_value` and `ConfigOptionsGroup::back_to_config_value` (plus `Tab::options_list_storage_key`
for a vector key). → [Custom widgets](#custom-widgets-on-a-line)
9. In a `Field::BUILD()`, create controls through a `static Builder<T>` per construction style, re-set every
property, and bind every handler with the control's id. → [Pooling](#field-control-pooling)
10. Bind with an id only events the widget emits with its id; `::ComboBox` sends `wxEVT_COMBOBOX_DROPDOWN/CLOSEUP`
with id 0. → [Pooling](#field-control-pooling)
11. Fields exist only for the active page: null-check `get_field()`, and use `toggle_line` (not field state) for
anything that must hold on every page. → [Lazy building](#lazy-building-and-og_customctrl)
12. Dependent enable/hide rules go in `ConfigManipulation::toggle_*_options`; value fix-ups go in
`ConfigManipulation::update_*_config` through `apply()` under the `is_msg_dlg_already_exist` guard.
→ [ConfigManipulation](#configmanipulation-toggles-and-fix-ups)
13. Set field values from code with `set_value(value, false)`; the `boost::any` must hold the display type that
`ConfigOptionsGroup::get_config_value` produces for the option type. → [Field](#field-and-the-value-flow)
14. Teach `Print::invalidate_state_by_config_options` / `PrintObject::invalidate_state_by_config_options` which
steps the key invalidates; an unknown key reslices everything. → [Checklist](#checklist-adding-a-setting)
15. A setting is searchable, offered by the Speed Dial and listed in the unsaved-changes/compare dialogs only if a
settings tab registers it in a titled group and it has a label. → [Search index](#search-index-registration)
16. Filament and printer tabs live in the modeless `ParamsDialog`; code that must run after editing hooks its
close path, not the line after `Popup()`. → [Placement](#where-the-tabs-live)
17. Renaming or removing a key needs `PrintConfigDef::handle_legacy`, and a new key's default must reproduce the
old behaviour for existing profiles and projects. → [Checklist](#checklist-adding-a-setting)
## Pipeline at a glance
```
PrintConfigDef::init_*_params() def = this->add(key, coX) ... libslic3r/PrintConfig.cpp
PRINT_CONFIG_CLASS_DEFINE(...) ((ConfigOptionX, key)) libslic3r/PrintConfig.hpp
s_Preset_*_options key saved/loaded/diffed per preset libslic3r/Preset.cpp
TabX::build() add_options_page → Page::new_optgroup slic3r/GUI/Tab.cpp
ConfigOptionsGroup::append_single_option_line(key, wiki, idx)
get_option(): m_opt_map["key#idx"], settings_index().add_key(...) slic3r/GUI/OptionsGroup.cpp
create_single_option_line(): Line{label, formatted tooltip}
append_line(): index.set_path / set_line_label
page shown → Page::activate → OptionsGroup::activate → activate_line
→ OptionsGroup::build_field → Field::Create<T> → T::BUILD() slic3r/GUI/Field.cpp
→ OG_CustomCtrl paints labels, sidetext, undo icons slic3r/GUI/OG_CustomCtrl.cpp
edit → Field::on_change_field → OptionsGroup::on_change_OG → ConfigOptionsGroup::on_change_OG
→ change_opt_value(config) → group m_on_change (Page::new_optgroup)
→ Tab::update_dirty() + Tab::on_value_change() → TabX::update()
→ ConfigManipulation::update_*_config → toggle_options() → MainFrame::on_config_changed
```
## ConfigOptionDef: the data side
**Contract.** `ConfigOptionDef` (`src/libslic3r/Config.hpp`) is the static description of one key: type,
default, GUI presentation, limits and legacy names. Defs are registered with `ConfigDef::add(key, type)` (or
`add_nullable`) inside `PrintConfigDef::init_common_params` / `init_fff_params` / `init_sla_params`
(`src/libslic3r/PrintConfig.cpp`); the def map owns the default value object.
```cpp
def = this->add("brim_width", coFloat);
def->label = L("Brim width"); // row label; L() is an extraction marker (no-op)
def->category = L("Support"); // per-object settings grouping (not the tab page)
def->tooltip = L("This is the distance from the model to the outermost brim line.");
def->sidetext = L("mm"); // unit
def->min = 0; def->max = 100; // Field clamps to these
def->mode = comSimple; // visibility gate
def->set_default_value(new ConfigOptionFloat(0.));
```
Enums need three parts: the `enum class` plus `CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(Name)` in `PrintConfig.hpp`,
a `static t_config_enum_values s_keys_map_Name` plus `CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(Name)` in
`PrintConfig.cpp`, and in the def `enum_keys_map = &ConfigOptionEnum<Name>::get_enum_values()` with parallel
`enum_values` (serialized keys) and `enum_labels` (`L()` display labels) — see `wall_generator`
(`PerimeterGeneratorType`).
| Field | Meaning for the GUI |
|---|---|
| `type` | `coFloat/coFloats/coInt/coInts/coString/coStrings/coPercent(s)/coFloatOrPercent(s)/coBool(s)/coEnum(s)/coPoint(s)/…`; picks the `Field` when `gui_type` is `undefined`. |
| `gui_type` | `GUIType {undefined, i_enum_open, f_enum_open, color, select_open, slider, legend, one_string, plugin_picker, plugin_config, printer_agent_select}`; checked first by `build_field`. `slider` is marked "currently unused" in `Config.hpp`. |
| `gui_flags` | `"serialized"`: a vector edited as one `;`-separated string; `"show_value"`: show the value even when enum labels exist. |
| `label` / `full_label` | `label` is the short row label (a sub-label inside a multi-option row). `full_label`, when set, names the setting on its own: sidebar search titles, the per-object "Add Settings" menu, the compare dialogs. The Speed Dial titles a setting with the label its row draws and keeps `full_label`/`label` only as a search alias (`ActionRegistry`). |
| `category` | English group name for per-object settings (`SettingsFactory::get_bundle`, the settings menus, the ObjectList settings item) and the transfer view's fallback in `UnsavedChangesDialog`. Empty = left out of the ObjectList settings item and the "Add Settings" menus (the model tabs still show the key). Search uses the tab page title instead. |
| `tooltip`, `sidetext` | Translated at display; `sidetext` is drawn inside most inputs (see [Field](#field-and-the-value-flow)). |
| `min`, `max`, `max_literal` | `Field` clamps to `[min, max]` with "Value is out of range."; `max_literal` bounds the absolute (non-%) value of `coFloatOrPercent` keys whose `sidetext` contains `"mm "`. Both `min`/`max` bounded → "Range:" line in the tooltip. |
| `mode` | `comSimple < comAdvanced < comExpert < comDevelop`; a row shows when its **first** option's mode ≤ the tab's mode. |
| `nullable` | Vector values may hold nil ("N/A"); used by model-scope overrides. |
| `multiline`, `full_width`, `is_code`, `height`, `width`, `readonly` | Text box shape (in em units for `height`/`width`); `is_code` sets `normal_font()` (not a monospace font, despite the `Config.hpp` comment) and adds the "Edit Custom G-code" button when the group has `edit_custom_gcode`; disabled control (`readonly`). |
| `ratio_over` | For `coFloatOrPercent`: the key a percentage refers to. |
| `aliases`, `shortcut` | Legacy names; one value expanding to several keys. |
| `plugin_type` | Makes the option plugin-backed (`is_plugin_backed()`). |
An unadorned `coBool` def becomes a checkbox with no GUI code at all.
Pitfalls:
- **Rule:** Give `coFloatOrPercent` defs a `sidetext` of the form `"mm or %"` / `"mm/s or %"` and a sensible
`max_literal`.
**Why:** `Field::get_value_by_opt_type` asks "Is it N% or N mm?" by matching the English `sidetext`: a unitless
value above `max` when it contains `"mm/s"`, above `max_literal` when it contains `"mm "` (only that form also
clamps literal values to `max_literal`); another unit text skips the check.
Cite: `Field::get_value_by_opt_type`.
- **Rule:** Give every key that can be overridden per object a non-empty `category`.
**Why:** `is_improper_category` (`GUI_Factories.cpp`) drops empty categories (and `"Extruders"`/`"Wipe options"`
with one filament, `"Support material"` for parts), so the override never appears in the ObjectList settings item
and does not light the Objects switch (`ParamsPanel::notify_object_config_changed`).
Cite: `SettingsFactory::get_bundle`.
## Config classes, preset lists and variants
**Static config classes.** Every key that the slicing core reads is a member of a `PRINT_CONFIG_CLASS_DEFINE`
block in `src/libslic3r/PrintConfig.hpp`:
| Class | Scope | Overridable in model tabs |
|---|---|---|
| `PrintObjectConfig` | per object | object (`TabPrintObject`) |
| `PrintRegionConfig` | per region | object, part (`TabPrintPart`), layer range (`TabPrintLayer`) |
| `MachineEnvelopeConfig`, `GCodeConfig`, `PrintConfig` (derives from both) | global | no |
| `SLA*Config` | SLA | — |
`layer_height` is added for layer ranges, and the plate tab offers the fixed `plate_keys` list in `Tab.cpp`.
**Preset option lists.** `src/libslic3r/Preset.cpp` lists which keys each preset type owns: `s_Preset_print_options`,
`s_Preset_filament_options`, `s_Preset_printer_options` (+ `s_Preset_machine_limits_options` and the nozzle-sized
`PrintConfigDef::extruder_option_keys()`, joined in `Preset::printer_options()`). `PresetBundle` builds each
collection's default config from its list (`prints(Preset::TYPE_PRINT, Preset::print_options(), …)`), so the list
decides what is saved, loaded, diffed, inherited and shown in the tab.
### Per-extruder variant options
Multi-extruder printers store some vectors once per extruder variant. A key joins one of the sets in
`src/libslic3r/PrintConfig.cpp`: `print_options_with_variant`, `filament_options_with_variant`,
`printer_options_with_variant_1` (one value per variant) or `printer_options_with_variant_2` (a normal/silent pair
per variant, stride 2). Printer per-extruder keys sized to `nozzle_diameter` go in
`PrintConfigDef::init_extruder_option_keys` (`m_extruder_option_keys`; a retract key also joins
`m_extruder_retract_keys`, which is asserted sorted).
GUI mechanics: the row is appended with index 0 (`append_single_option_line("outer_wall_speed", wiki, 0)`, field id
`outer_wall_speed#0`). `Tab::switch_excluder` rewrites each group's `m_opt_map` index to the selected variant, so
edits write `values[variant]`, and fills `Page::m_opt_id_map` (`"key#<variant>"` → shown field id).
`Tab::get_config_manipulation` passes a variant index to `toggle_option`/`toggle_line`/`set_option_label` as
`index + 256`; `Page::get_field`/`Page::get_line` see `>= 256` and translate through `m_opt_id_map`. The print-side
variant speeds are nullable vectors (`nullable = true`, `ConfigOptionFloatsNullable`) so a model override can set
one variant's element and leave the others nil (`TabPrintModel::on_value_change`); copy the declaration of an
existing key in the same set.
Pitfalls:
- **Rule:** Add the key to the `Preset` list together with the def and the class member.
**Why:** the tab's config lacks the key, so `ConfigOptionsGroup::get_option` only prints
`No <key> in ConfigOptionsGroup config.` to stderr and the row's value read (`get_config_value`) dereferences a
missing option when the page builds [source].
Cite: `ConfigOptionsGroup::get_option`, `ConfigOptionsGroup::get_config_value`.
- **Rule:** In `ConfigManipulation`, toggle variant keys with the variant index.
```cpp
toggle_field("outer_wall_speed", have_perimeters); // Wrong: finds no field (the row's id is key#0)
toggle_field("outer_wall_speed", have_perimeters, variant_index); // Right
```
Cite: `ConfigManipulation::toggle_print_fff_options`, `Page::get_field`.
## Tabs, pages and where they live
**Classes.** `Tab : wxPanel` (`src/slic3r/GUI/Tab.hpp`) owns a `PresetCollection* m_presets`, the edited
`DynamicPrintConfig* m_config`, its `Page`s and a `ConfigManipulation`. Concrete tabs: `TabPrint`, `TabFilament`,
`TabPrinter`, and the model-scope `TabPrintModel` → `TabPrintPlate`, `TabPrintObject`, `TabPrintPart`,
`TabPrintLayer`. `MainFrame::create_preset_tabs` creates them and `MainFrame::add_created_tab` calls
`Tab::create_preset_tab()` (top bar + `build()`). `GUI_App::get_tab(type)` returns null until a tab is
`completed()`; `get_plate_tab()`, `get_model_tab(part)`, `get_layer_tab()` reach the model tabs.
**Declaring pages.** `TabX::build()` is declarative:
```cpp
auto page = add_options_page(L("Quality"), "custom-gcode_quality"); // English title, page icon
auto optgroup = page->new_optgroup(L("Layer height"), L"param_layer_height"); // L"..." is a wide literal, not L()
optgroup->append_single_option_line("layer_height", "quality_settings_layer_height");
```
`Page::new_optgroup(title, icon, noncommon_label_width, is_extruder_og)` creates a tab group
(`ConfigOptionsGroup(..., is_tab_opt = true)`, or `ExtruderOptionsGroup`), records the page title and preset type
for search (`set_config_category_and_type`), and installs the callbacks: `m_on_change` →
`Tab::update_dirty()` + `Tab::on_value_change()` (called directly; deferring it re-runs `update()`),
`m_get_initial_config` (selected preset), `m_get_sys_config` / `have_sys_config` (parent system preset). On
`TabPrint` pages (model tabs included) `m_split_multi_line` stacks a multi-option row's fields vertically and `m_option_label_at_right`
makes `OG_CustomCtrl` draw sub-labels to the right of the fields. `TabPrinter` creates its "Motion ability",
"Multimaterial" and `"Extruder N"` pages with `add_options_page(..., is_extruder_pages = true)`, which does not
append them to `m_pages`; the caller inserts each at its position.
### Where the tabs live
- **Process.** `TabPrint` and the model tabs sit on `MainFrame::m_param_panel`, a `ParamsPanel` whose top bar
(`get_top_panel()`) and body are reparented into the sidebar's scrolled panel in `Sidebar::Sidebar`: the top bar
above the object list, the body (the tab with its own `TabPresetComboBox`, `Tab::get_combo_box()`) below it.
Print parameters are therefore in the sidebar. `ParamsPanel::switch_to_global` / `switch_to_object` flip its
Global/Objects switch (`m_mode_region`).
- **Filament and printer.** `TabFilament` and `TabPrinter` live on `ParamsDialog::panel()`, a second
`ParamsPanel` inside `ParamsDialog : DPIDialog`, created once with the plater as parent. `ParamsDialog::Popup()`
applies `UpdateDlgDarkUI`, reparents to the main frame on MSW, centres and `Show()`s it — modeless. A
`wxWindowDisabler(this)` created in its `wxEVT_SHOW` handler disables every other shown top-level window while it
is visible and is deleted on hide; the close handler validates (`Tab::validate_filament_temperature_pairs`, may
veto), hides, queues `EVT_MODIFY_FILAMENT` when a filament was being edited, and calls
`Sidebar::finish_param_edit()`. It never destroys the dialog, so the panel and its tabs are reused across opens.
`MainFrame::select_tab(wxPanel*)` given a `ParamsPanel` other than `m_param_panel` opens the dialog.
Modality mechanics: `references/windows-dialogs.md` §6.
- **Sidebar map.** `Sidebar` (pimpl `Sidebar::priv`, `Plater.cpp`) holds the printer block (`combo_printer`,
nozzle/bed-type combos, `ExtruderGroup`s, sync buttons), the filament block (`combos_filament`, add/delete/edit,
flushing-volume button), the `ParamsPanel` top bar, the object-list block (the plate/object/part search bar,
`ObjectList`, `ObjectLayers`; `ObjectSettings` is created but not laid out under `NEW_OBJECT_SETTING`) and the
process `ParamsPanel`. There is no separate process combo
(`Sidebar::priv::combo_print` is never created). It owns the `Search::OptionsSearcher`.
`Sidebar::update_presets(type)` refreshes the combos after a preset change. Full component map:
`references/orca-architecture.md`.
- **No quick-settings group.** `Sidebar::og_freq_chng_params()` returns null in Orca (the frequently-changed
parameters group is compiled out); the process tab itself is the sidebar's settings UI.
Showing the tab of a preset type follows `PlaterPresetComboBox::switch_to_tab`:
```cpp
if (tab->GetParent() == wxGetApp().params_panel())
wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); // process: it is in the sidebar
else {
wxGetApp().params_dialog()->Popup(); // filament/printer
tab->OnActivate();
}
```
**Contract (wx).** `wxWindowDisabler` disables all top-level windows except the skipped one in its constructor and
re-enables them in its destructor; it affects only windows shown and not already disabled at construction
(`interface/wx/utils.h:59-69`, `:87-110`).
Pitfalls:
- **Rule:** Run post-edit work from the `ParamsDialog` close path (or the tab's value-change path), never after
`Popup()`.
```cpp
wxGetApp().params_dialog()->Popup(); refresh_after_edit(); // Wrong: Popup() returns at once
// Right: react in the dialog's close handler / Sidebar::finish_param_edit / EVT_MODIFY_FILAMENT
```
**Why:** the dialog is shown modeless and only emulates modality with `wxWindowDisabler`; the main frame stays
disabled until it hides.
## OptionsGroup, Option and Line
**`Option`** (`OptionsGroup.hpp`) is a *copy* of the def plus the field id (`opt_id`, `"key"` or `"key#idx"`) and
an optional `side_widget`. **`Line`** holds `label`, `label_tooltip`, `label_path` (wiki path), one or more
`Option`s, and optional widgets: `widget` (replaces the fields), `append_widget` extras, `near_label_widget`, plus
`full_width`, `toggle_visible`, `undo_to_sys`. `Line(label, tooltip)` applies `_()` to both, so pass English `L()`
strings. `Line()` is a separator (`OptionsGroup::append_separator()`).
`ConfigOptionsGroup` binds a group to a `DynamicPrintConfig` (or a `ModelConfig`, then `ModelConfig::touch()` runs
after each change). Its API:
- `get_option(key, idx = -1)` → `Option` with id `key` or `key#idx`; records `m_opt_map[id] = {key, idx}`; for tab
groups registers the key in the search index (see [Search](#search-index-registration)).
- `append_single_option_line(key, wiki_path = "", idx = -1)` = `get_option` + `create_single_option_line` (label
`_(label)`, tooltip from `get_formatted_tooltip_text`) + `append_line`.
- `append_single_option_line(const Option&, wiki_path)` appends a modified copy:
```cpp
Option option = optgroup->get_option("small_area_infill_flow_compensation_model");
option.opt.full_width = true; option.opt.is_code = true; option.opt.height = 15; // changes this row only
optgroup->append_single_option_line(option, "quality_settings_wall_and_surfaces#small-area-flow-compensation");
```
**Multi-option rows** build the `Line` by hand (as the "Overhang speed" and "Bridge" rows in `TabPrint::build` and
"Recommended nozzle temperature" in `TabFilament::build`):
```cpp
Line line = { L("Bridge"), L("Set speed for external and internal bridges") };
line.append_option(optgroup->get_option("bridge_speed", 0));
line.append_option(optgroup->get_option("internal_bridge_speed", 0));
optgroup->append_line(line);
```
The row's mode is its first option's `mode`; each field gets a sub-label from its own `label`.
**Wiki link.** A non-empty `label_path` makes the row label a link: hovering highlights it and a click calls
`OptionsGroup::launch_browser` → `https://www.orcaslicer.com/wiki/<path>` with the path appended verbatim, so write
anchors as the wiki slugs them (`page#lowercase-hyphenated`). `append_line` also records the path for the Speed
Dial's "open wiki" action.
**Groups outside tabs.** A `ConfigOptionsGroup` created without `is_tab_opt` (`PhysicalPrinterDialog`,
`BedShapeDialog`) draws a `LabeledStaticBox` with a `wxFlexGridSizer` of `wxStaticText` labels and plain sizer
layout, no `OG_CustomCtrl`, no search registration. The owner calls `activate()`, adds `optgroup->sizer`, sets
`m_on_change`, and loads values (`reload_config()` / `set_value`).
### Custom widgets on a line
`Tab::create_line_with_widget(optgroup, key, wiki_path, widget)` makes a row whose `widget` (a
`std::function<wxSizer*(wxWindow*)>`) replaces the field — bed shape (`printable_area`), `compatible_printers`,
`compatible_prints`, `filament_ramming_parameters`. It presets white-bullet undo icons and the default label colour.
`Line::full_width` with `widget`/extra widgets builds a description row that `append_line` does not register as
options. `near_label_widget` draws a window before the label (in tab groups `activate_line` creates it as a child of
the `OG_CustomCtrl`, which positions it); the group's `rescale_near_label_widget` / `rescale_extra_column_item`
callbacks rescale them on DPI change.
Pitfalls:
- **Rule:** When a key is edited by a custom widget, add it to the name branches in `Tab::decorate` (the
`option_without_field` keys), `Tab::on_roll_back_value` (keyed by group title, then `load_key_value` to refresh
the widget), `ConfigOptionsGroup::back_to_config_value` and, for a vector key stored whole,
`Tab::options_list_storage_key`.
**Why:** `decorate` looks the key up with `get_field()` and skips it when there is none, so the row's modified
colour and undo/lock icons never update; the revert paths have no field to push the restored value into, so the
widget keeps showing the old value [source].
Cite: `Tab::decorate`, `Tab::on_roll_back_value` (`printable_area`, `compatible_prints`, `compatible_printers`).
## OptionsGroup::build_field
`OptionsGroup::build_field(id, def)` switches on `gui_type` first, then on `type`:
| `gui_type` | Field |
|---|---|
| `select_open` | `Choice` (read-only) |
| `i_enum_open`, `f_enum_open` | `Choice` (editable: any value, enum entries as presets) |
| `color` | `ColourPicker` |
| `slider` | `SliderCtrl` |
| `legend` | `StaticText` |
| `one_string` | `TextCtrl` (vector edited as one string) |
| `plugin_picker` / `plugin_config` / `printer_agent_select` | `PluginField` / `PluginConfigField` / `PrinterAgentChoice` (Orca) |
| `type` (when `gui_type` is `undefined`) | Field → widget |
|---|---|
| `coFloat(s)`, `coPercent(s)`, `coFloatOrPercent(s)`, `coString(s)` | `TextCtrl` → `::TextInput` (raw `wxTextCtrl` when `multiline`) |
| `coBool(s)` | `CheckBox` → `::CheckBox` |
| `coInt(s)` | `SpinCtrl` → `SpinInput` |
| `coEnum(s)` | `Choice` → `::ComboBox` (`choice_ctrl`) |
| `coPoint(s)` | `PointCtrl` → two `::TextInput` |
| `coNone` | nothing |
| anything else (`coPoint3`, `coIntsGroups`, …) | throws `Slic3r::LogicError("This control doesn't exist till now")` |
It then wires the field: `m_on_change` / `m_on_kill_focus` → the group (ignored while the group is `m_disabled`),
`m_back_to_initial_value` / `m_back_to_sys_value`, the edit button for `is_code` options when the group has
`edit_custom_gcode`, the plugin picker and the preset type of a `PluginConfigField`. Widget event contracts
(`wxEVT_TOGGLEBUTTON` for `::CheckBox`, commit events of `SpinInput`): `references/controls-dataview.md`
§Field widgets, `references/orca-widgets.md`.
**`Choice` specifics** (`Choice::BUILD`): read-only for plain enums, `select_open` and keys with a registered
`DynamicList`, editable (`wxTE_PROCESS_ENTER`) for open enums; entries are `_(enum_labels[i])`, or untranslated `enum_values` when there are
no labels; an entry gets an icon when `resources/images/param_<enum_value>.svg` exists. Lists computed at runtime
(filament pickers) register a `DynamicList` with `Choice::register_dynamic_list(key, list)` (done in
`Sidebar::Sidebar` for `support_filament`, `sparse_infill_filament_id`, …). A tab may narrow the offered entries per
state by rewriting the field's `m_opt.enum_values/enum_labels` and the combo items, as `TabPrint::toggle_options`
does for `support_style`.
Pitfall:
- **Rule:** A new option type or presentation needs a `gui_type` (or a custom-widget line), not a new `type` case
left unmapped.
**Why:** an unmapped type throws from `build_field` when the page activates, aborting the page build.
## Field and the value flow
`Field` (`src/slic3r/GUI/Field.hpp`, abstract, `Slic3r::GUI`) keeps a copy of the def (`m_opt`), the id
(`m_opt_id`), the vector index (`m_opt_idx`, parsed from `#idx` in `PostInitialize` for most vector types) and the
current `boost::any m_value`. Virtuals: `BUILD()`, `set_value(any, change_event)`, `get_value()`, `enable()`,
`disable()`, `msw_rescale()`, `sys_color_changed()` (MSW only: `UpdateDarkUI` on the window), `propagate_value()`;
`toggle(en)` enables only when not `readonly`. Subclasses: `TextCtrl`, `CheckBox`, `SpinCtrl`, `Choice`,
`ColourPicker`, `PointCtrl`, `StaticText`, `SliderCtrl`, `PrinterAgentChoice`, `PluginField`,
`PluginConfigField`. `Field::Create<T>(parent, def, id)` constructs, runs `PostInitialize()` (em unit,
`parent_is_custom_ctrl`, `BUILD()`, readonly → `disable()`, Ctrl+1..4 tab shortcuts on the window) and returns a
`std::unique_ptr<Field>` owned by `OptionsGroup::m_fields`. The subclass `CheckBox` shadows the global `::CheckBox`
widget inside `Slic3r::GUI` wherever `Field.hpp` is visible, which is why widget code there writes `::CheckBox`
(and qualifies `::TextInput` / `::ComboBox` the same way) (`references/orca-widgets.md`).
`Field` and `Line` derive from `UndoValueUIManager`: the per-row "revert to system" (lock) and "revert to saved"
(undo arrow) icons, their tooltips and the modified label colour (`#F1754E` by default, `label_clr_modified` in
app config) come for free; `Tab::update_changed_ui` / `Tab::decorate` set them from the option status.
**Value types in the `boost::any`** (`Slic3r::GUI::change_opt_value`, `GUI.cpp`):
These are the config-side values that `Field::get_value()`, `on_change_OG` and `Tab::on_value_change` carry.
`Field::set_value` takes the display form that `ConfigOptionsGroup::get_config_value` produces instead: a
`wxString` for float, percent, float-or-percent and string fields, `bool`/`unsigned char` for checkboxes, `int` for
ints and enums, `Vec2d` for points.
| Option type | `any` holds |
|---|---|
| `coFloat(s)`, `coPercent(s)` | `double` |
| `coFloatOrPercent(s)`, `coString`, single `coStrings` element | `std::string` (`"serialized"` `coStrings`: the whole `;`-joined string; `compatible_printers`/`compatible_prints`: `std::vector<std::string>`) |
| `coInt(s)`, `coEnum(s)` | `int` |
| `coBool` | `bool` |
| `coBools` (incl. nullable) | `unsigned char` (`ConfigOptionBoolsNullable::nil_value()` = nil) |
| `coPoint` / `coPoints` element | `Vec2d`; whole `printable_area`-style lists: `std::vector<Vec2d>` |
A wrong type throws `boost::bad_any_cast` inside `change_opt_value`, which logs "Internal error when changing value
for <key>" and leaves the config unchanged.
**Commit points.** Text fields commit on Enter or kill focus (`propagate_value`), not per keystroke; `TextCtrl`
ignores a kill focus raised while its Enter commit is still running (`EnterPressed` guard, e.g. a dialog the commit
opens). `Choice` commits on `wxEVT_COMBOBOX` (editable: also Enter/kill focus); `CheckBox` on `wxEVT_TOGGLEBUTTON`;
`SpinCtrl` on `wxEVT_SPINCTRL`, Enter and kill focus (skipping the first kill focus after an Enter). Validation
(`Field::get_value_by_opt_type`) clamps to the def's limits and reports through `show_error` (asynchronous).
**Flow after a commit.** `Field::on_change_field` (no-op while `m_disable_change_event`) → `OptionsGroup::on_change_OG`
→ `ConfigOptionsGroup::on_change_OG` (resolves `key#idx` through `m_opt_map`, `change_opt_value` on the group's
config, `ModelConfig::touch()` for model configs) → group `m_on_change` → `Tab::update_dirty()` +
`Tab::on_value_change()` (key-specific branches, then `update()`, `Page::update_visibility`, `Layout()`) →
`TabX::update()` (in `TabPrint::update`: `ConfigManipulation::update_print_fff_config`, then, when the update counter
`m_update_cnt` returns to zero, `toggle_options()`, the ObjectList settings refresh and `MainFrame::on_config_changed`
→ `Plater::on_config_change`).
Revert clicks go `OG_CustomCtrl::OnLeftDown` → `ConfigOptionsGroup::back_to_initial_value` / `back_to_sys_value`.
Pitfalls:
- **Rule:** Update a field from code with `set_value(value, false)`; to push a programmatic value into the config,
follow it with `field_changed()` (or `propagate_value()`).
**Why:** `set_value` only brackets the widget update with `m_disable_change_event = !change_event`, so the flag
decides whether events the setter itself emits (e.g. the `wxEVT_TEXT` of `SliderCtrl`'s text box) reach
`on_change_field`; where a setter does emit, `true` re-enters `on_value_change` → `update()`. Most Orca widget
setters emit nothing (`::ComboBox::SetValue`/`SetSelection`, `::CheckBox::SetValue`), so `set_value(v, true)`
alone usually leaves the config unchanged [source].
```cpp
m_optgroup->set_value("print_host", new_url, true); // Wrong: the widget shows it, the config is unchanged
m_optgroup->set_value("print_host", new_url, false); // Right: show it ...
m_optgroup->get_field("print_host")->field_changed(); // ... then commit through the group
```
Cite: `PhysicalPrinterDialog::build_printhost_settings`, `Tab::on_value_change` (`set_value` + `propagate_value`).
- **Rule:** Implement `msw_rescale()` in a new `Field` by calling `Field::msw_rescale()` first (refreshes
`m_em_unit`), then rescaling the widget (`Rescale()`), and size controls in em units. DPI fan-out:
`references/dpi-bitmaps-fonts.md`.
## Field control pooling
Field controls are recycled, not destroyed. `Builder<T>::build(parent, args...)` (`Field.cpp`) takes a window from
its pool when one exists (`Reparent(parent)`, `Enable()`, `Show()`) and otherwise constructs `T(parent, args...)` and
stores the pool pointer in the window's client data. When a page is cleared (`OptionsGroup::clear`), each field
window goes to `free_window`:
- non-GTK: unbind every dynamic handler whose id is a single explicit id (`m_id != wxID_ANY && m_lastId ==
wxID_ANY`) on the window and on its `wxTextCtrl` children, hide, clear the containing sizer, reparent to the main
frame, push back into the pool named by the client data;
- GTK: `delete` the window.
`GUI_App::recreate_GUI` calls `switch_window_pools()` (fresh pools for the new frame) and releases the old pools
when the old frame is destroyed (`release_window_pools()` from a client object on the old frame).
The unbinding walks `wxEvtHandler::GetFirstDynamicEntry/GetNextDynamicEntry`, which wx marks "for internal use
only" (`include/wx/event.h:4027-4033`), and unbinds each entry by its stored functor pointer, which sidesteps
"functors are compared by their address" (`interface/wx/event.h:967-970`) [source]. `Bind`'s `id` defaults to
`wxID_ANY` (`interface/wx/event.h:913-916`); the widgets' own internal handlers are bound that way, which is what
keeps them alive across reuse.
```cpp
// Right: shape of a Field::BUILD
static Builder<::CheckBox> builder; // one static builder per construction style
auto temp = builder.build(m_parent); // may be a reused window
temp->SetValue(check_value); // re-set every property you rely on
temp->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { on_change_field(); e.Skip(); },
temp->GetId()); // explicit id: unbound by free_window
temp->SetToolTip(get_tooltip_text(check_value ? "true" : "false"));
window = temp;
```
Pitfalls:
- **Rule:** Bind every `Field` handler with the control's id.
**Why:** an id-less `Bind` survives `free_window`; when the pooled control is reused by another field, the old
lambda still fires with a dangling `this` (the old `Field` is gone) — a use-after-free on MSW/macOS — and one more
copy of the handler accumulates per reuse. GTK deletes instead, so the bug does not reproduce there.
```cpp
temp->Bind(wxEVT_TOGGLEBUTTON, [this](auto& e) { on_change_field(); }); // Wrong
temp->Bind(wxEVT_TOGGLEBUTTON, [this](auto& e) { on_change_field(); }, temp->GetId()); // Right
```
Cite: `free_window`, `CheckBox::BUILD`.
- **Rule:** Bind with the id only events the widget sends with its id.
**Why:** the id filter must match the event id. `::ComboBox` sends `wxEVT_COMBOBOX` with its id, and `::TextInput`
re-sends `wxEVT_TEXT_ENTER`/`wxEVT_KILL_FOCUS` with the wrapper's id, but `wxEVT_COMBOBOX_DROPDOWN/CLOSEUP` are
built as `wxCommandEvent e(type)` (id 0: `interface/wx/event.h:2137`), and an entry bound with one id matches
only an equal event id (`src/common/event.cpp:1454-1457`) [source], so an id-filtered bind never fires — and an
unfiltered one is never unbound. `Choice::BUILD`'s own `m_is_dropped` binds are such dead binds. Query
`ComboBox::is_drop_down()` instead of tracking those events.
Cite: `ComboBox::ComboBox` (`EVT_DISMISS` lambda), `ComboBox::mouseDown`, `ComboBox::keyDown`,
`ComboBox::ForceDropdownOpen`, `Choice::BUILD`.
- **Rule:** Keep one `static Builder<T>` per distinct constructor style, and re-apply size, label, value, colours
and tooltip in `BUILD()`.
**Why:** a reused window ignores the new constructor arguments (style, size, id, label) — `Choice::BUILD` keeps
separate builders for editable and `wxCB_READONLY` combos for this reason.
- **Rule:** Do not use `SetClientData` on a pooled control; it holds the pool pointer.
**Contract (wx).** `Reparent` removes the window from its parent and inserts it into another; a notebook page must be
removed from its book first (`interface/wx/window.h:731-742`).
## Lazy building and OG_CustomCtrl
**Lazy fields.** Rows are declared at tab build time; controls are created only when a page activates.
`Tab::activate_selected_page` → `Page::activate` → `Page::activate_group` per group (`OptionsGroup::activate` →
`activate_line` → `build_field`; then `update_visibility`, `reload_config`), followed by `update_changed_ui()`,
`toggle_options()` and `update_visibility()`. Switching pages clears the other pages' controls back to the pool
(`Tab::update_current_page_in_background` → `Page::clear`). The idle prebuild builds the selected settings page one
option group per slice (`Page::build_step`, `Tab::page_build_step`, `ParamsPanel::settings_page_prebuild()`; design
in `docs/HLSD/deferred-page-construction.md`, mechanics in `references/orca-architecture.md`). On GTK the page view is
hidden while a page builds, because GTK crashes when it desensitizes a multi-line text view built on screen and hidden
before its first size allocation (`Tab::activate_selected_page`). Building can be cancelled: `activate(throw_if_canceled)`
throws `UIBuildCanceled`, and the group clears itself.
Consequences: `Tab::get_field` / `Page::get_field` return null for any key not on the built active page;
`Tab::toggle_option` acts only on `m_active_page`; `Tab::toggle_line` and `Tab::set_option_label` write
`Line::toggle_visible` / `Line::label` on **every** page, so they persist and reach the search titles before a page
is ever shown.
**`OG_CustomCtrl`** (`OG_CustomCtrl.hpp/.cpp`, a `wxPanel`) hosts the fields of a tab group (`m_use_custom_ctrl`):
`activate_line` creates it with the first line and fields are parented to it. It paints labels (with the label
colour and blinking search highlight), sub-labels, sidetext of fields that do not combine it, the separator lines and
the undo/lock/edit icons in `OnPaint` (`CtrlLine::render`), positions field windows itself
(`correct_window_position`, `CtrlLine::correct_items_positions`; MSW re-fixes positions in a `CallAfter` after
`Page::activate`), shows/hides fields per line in `CtrlLine::update_visibility` (`toggle_visible && first option mode
<= mode`), and handles clicks (`OnLeftDown`: wiki link, revert to saved, revert to system, edit button).
Pitfall:
- **Rule:** Null-check every `get_field()` and never cache `Field*` across page switches.
```cpp
m_active_page->get_field("support_style")->m_opt; // Wrong: null when not on this page
if (auto f = dynamic_cast<Choice*>(m_active_page->get_field("support_style"))) { /* … */ } // Right
```
**Why:** the field object is destroyed with its page's controls; the window behind it is pooled for another key.
## ConfigManipulation: toggles and fix-ups
`ConfigManipulation` (`ConfigManipulation.hpp/.cpp`) is UI-agnostic dependency logic with two roles:
1. **Value fix-ups** — `update_print_fff_config(config, is_global_config, is_plate_config)` (and the filament/printer
`check_*` helpers) detect invalid combinations, warn with `MessageDialog`, and write corrections through
`apply(config, &new_conf)`, which copies the diff and calls the tab's `load_config` callback (`update_dirty()`,
`reload_config()`, `update()`).
2. **Visibility** — `toggle_print_fff_options(config, variant_index, is_global_config)` calls `toggle_field` (grey
out, → `Tab::toggle_option` → `Field::toggle`), `toggle_line` (hide the row, → `Tab::toggle_line`) and
`set_option_label` (rename a row at runtime, e.g. `brim_width` → "Brim ear radius").
`Tab::get_config_manipulation()` builds the callbacks (variant index → `+256`, see [Variants](#per-extruder-variant-options));
`TabX::toggle_options()` calls the `toggle_*` function and adds tab-local tweaks. They run on every page activation,
after every `update()`, and after a variant switch.
```cpp
// fix-up shape (update_print_fff_config)
if (config->opt_float("layer_height") < EPSILON) {
MessageDialog dialog(m_msg_dlg_parent, _L("Layer height too small\nIt has been reset to 0.2"), "", wxICON_WARNING | wxOK);
DynamicPrintConfig new_conf = *config;
is_msg_dlg_already_exist = true; // ShowModal's loop re-enters update() (field kill focus)
dialog.ShowModal();
new_conf.set_key_value("layer_height", new ConfigOptionFloat(0.2));
apply(config, &new_conf);
is_msg_dlg_already_exist = false;
}
// toggle shape (toggle_print_fff_options)
bool have_infill = config->option<ConfigOptionPercent>("sparse_infill_density")->value > 0;
toggle_line("infill_combination_max_layer_height", config->opt_bool("infill_combination") && have_infill);
```
Pitfalls:
- **Rule:** Guard every modal fix-up with `is_msg_dlg_already_exist` and write values only through `apply()`.
**Why:** `ShowModal` runs a nested event loop; focus leaving the edited field commits it again
(`propagate_value` on kill focus) and re-enters `update()` — the guard exists "to except the duplicate call of
the update() after dialog->ShowModal()". Without it the dialog repeats; a direct `set_key_value` on the tab config
skips `load_config`, leaving fields and dirty state stale. Modality rules: `references/windows-dialogs.md`.
- **Rule:** Hide with `toggle_line`, grey out with `toggle_field`; do not call `Show()` on field windows.
**Why:** `OG_CustomCtrl` re-applies `toggle_visible` and mode on every `update_visibility`, and only `Line` state
survives page rebuilds and feeds the Speed Dial (`Tab::setting_row_state`).
- **Rule:** Gate rules that only make sense for the global preset on `is_global_config`.
**Why:** the model tabs run the same `update_print_fff_config` / `toggle_print_fff_options` on an object's config
with `is_global_config == false` (`m_type < Preset::TYPE_COUNT` in `TabPrint`); see
`toggle_line("flush_into_objects", !is_global_config)` and the global-only support checks.
## Search index registration
`Sidebar` owns `Search::OptionsSearcher searcher`; its `Search::SettingsIndex` (`SettingsIndex.hpp`) is reached as
`wxGetApp().sidebar().settings_index()`. Registration is automatic for tab rows:
- `ConfigOptionsGroup::get_option` → `settings_index().add_key(opt_id, type, group title, page title, group icon)`
— **only when `m_use_custom_ctrl`** (tab groups);
- `OptionsGroup::append_line` → `set_path(opt_id, type, label_path)` and `set_line_label(...)` with the label the
row draws (`Search::compose_display_label`: row label, or "row – sub-label" for multi-option rows).
`SettingsIndex::apply/init` → `append_options` then builds two views from the tab config: `options()` filtered by
mode (sidebar search, `SearchDialog`) and `all_options()` for every mode (the Speed Dial, `ActionRegistry`). An
option enters only if its group and category are non-empty and it has a label (`full_label`, else `label`); vector
keys of print/printer presets get one entry per element (`key#i`), filament variant keys `key#0`. Each entry stores the
English and translated label/group/category, so search matches either. `UnsavedChangesDialog` and `DiffPresetDialog`
name settings from the same index and skip a changed key the index lacks; only the extruder-transfer view
(`UnsavedChangesDialog::update_tree(type, config, from, to)`) falls back to the def's label/category, then "Others".
`Sidebar::jump_to_option(key, type, category)` → (model tab if it has the key, else switch to global) →
`Tab::activate_option`, which selects the page by translated category, focuses the field and blinks it
(`get_custom_ctrl_with_blinking_ptr`). `Tab::apply_searcher()` refreshes the index for one tab.
Pitfalls:
- **Rule:** Create tab groups with a non-empty English title; a key that should be findable must be on a tab.
**Why:** `append_options` skips entries with an empty group or category, so `page->new_optgroup("")` rows and
dialog-only keys are invisible to search and the Speed Dial, and their changes are left out of the unsaved-changes
and compare dialogs.
- **Rule:** Keep `is_tab_opt = false` (the default) for `ConfigOptionsGroup`s outside tabs; the one-argument
`ConfigOptionsGroup(parent)` constructor sets it to `true`.
**Why:** a custom-ctrl group registers its keys with the index under its `config_type()`; outside a tab that type
and category are not set up.
## Localization of option definitions
In `PrintConfig.cpp`, `L(s)` is `(s)` and `L_CONTEXT(s, ctx)` returns `s` (`libslic3r/I18N.hpp`): they only mark the
string for xgettext. The defs live in the static `print_config_def`, built before the GUI installs its translate
callback (`Slic3r::I18N::set_translate_callback`) and reused across language switches, so defs, page titles and group
titles hold English and the GUI translates at display time:
| String | Translated in |
|---|---|
| Row label | `OptionsGroup::create_single_option_line` (`_(label)`) and `Line`'s constructor |
| Sub-labels of multi-option rows | `OG_CustomCtrl` / `activate_line` (`_(label)`; labels exactly `"Top"`/`"Bottom"` in the `"Layers"` context) |
| Tooltip | `get_formatted_tooltip_text` (`Field.cpp`): `_(tooltip)` + "parameter name: `key[idx]`" + for keys in the selected print preset's parent, "Default: value+sidetext" and, when `min`/`max` are both bounded, "Range: [min, max]" |
| Sidetext | `Field::BUILD` (`_L(sidetext)`, drawn inside the input when `m_combine_side_text`) or `OG_CustomCtrl::CtrlLine::render` / `activate_line` (`_(sidetext)`) |
| Enum labels | `Choice::BUILD` (`_(enum_labels[i])`) |
| Page titles | `Tab::translate_category` (`"Extruder N"` composed as `_("Extruder")` + N, or "Left/Right Extruder" on BBL printers) |
| Group titles | `OptionsGroup::activate` (`_(title)`) |
| Category (per object) | ObjectList settings item and menus (`_(category)`) |
Translator workflow and catalog rules: `references/strings-i18n-files.md` and the AGENTS.md localization section.
**Contract (wx).** A message with `msgctxt` is found only when the lookup passes the same context
(`interface/wx/translation.h:594-603`); the catalog keys context entries as `context + '\x04' + msgid`, so a
context-less lookup never sees them (`src/common/translation.cpp:1154-1157`) [source].
Pitfalls:
- **Rule:** Mark def strings with `L()`; never `_L()`/`_()` in `PrintConfig.cpp` or in tab titles.
```cpp
def->label = _L("Brim width"); // Wrong: _L is GUI-only; a def is built once, before any language is set
def->label = L("Brim width"); // Right: English marker, translated where it is displayed
```
**Why:** a translated tab or group title also breaks the English keys below (`translate_category`, the search
index); `PrintConfig.cpp`'s `_()` (`Slic3r::I18N::translate`) runs at static init with no callback installed.
- **Rule:** Do not expect `L_CONTEXT` in a def to select a context translation.
**Why:** the display paths call `_()`/`_L()` without context, so `L_CONTEXT("s", "second")` sidetext shows the
context-less `"s"` entry and the translator's context entry is never used; only the hard-coded `"Top"`/`"Bottom"`
`"Layers"` labels are looked up with context. To disambiguate, use a distinct English string or add a context
lookup in the GUI.
- **Rule:** Keep `category`, page and group titles in English and stable.
**Why:** they are keys: page titles match tab items through `translate_category`, categories key
`SettingsFactory::CATEGORY_ICON` (unknown → no icon) and the `*_CATEGORY_SETTINGS` maps, and the index stores the
English form for search.
## Per-object, part, layer and plate overrides
The model tabs reuse the process tab: `TabPrintModel::build()` runs `TabPrint::build()`, inserts a "Frequent" page
(`layer_height`, `sparse_infill_density`, `wall_loops`, `enable_support`), removes every option not in `m_keys`
(`remove_option_if`), and drops empty groups and pages. `m_keys` is `Preset::print_options()` ∩:
| Tab | Keys |
|---|---|
| `TabPrintObject` | `PrintObjectConfig().keys()` ∪ `PrintRegionConfig().keys()` |
| `TabPrintPart` | `PrintRegionConfig().keys()` |
| `TabPrintLayer` | `layer_height` + `PrintRegionConfig().keys()` |
| `TabPrintPlate` | `plate_keys` (`Tab.cpp`), appended whole, so plate-only keys such as `curr_bed_type` survive the intersection |
So any print setting added to `TabPrint::build()` and to `PrintObjectConfig`/`PrintRegionConfig` is overridable
with no extra GUI code. Selecting an object, part, layer range or plate in `ObjectList` runs
`ObjectSettings::update_settings_list` (`GUI_ObjectSettings.cpp`, `NEW_OBJECT_SETTING` path), which hands the
selected `ModelConfig`s to the tabs with `TabPrintModel::set_model_config`; `TabPrintModel::on_value_change` writes
only the edited key into each `ModelConfig` (nil elements for untouched variants) after a `take_snapshot`. The
`ObjectList` shows an `itSettings` child grouped by `def->category` (`SettingsFactory::get_bundle`), and
`ParamsPanel::notify_object_config_changed` highlights the Objects switch when any object or part has overrides.
ObjectList and its data model: `references/controls-dataview.md`.
`SettingsFactory` (`GUI_Factories.hpp/.cpp`):
- `get_options(is_part)` — `PrintRegionConfig` keys, plus `PrintObjectConfig` keys for objects (SLA: object keys
minus `layer_height`); feeds the "Add Settings" menus (`MenuFactory::append_menu_item_settings`) and
`get_bundle`.
- `OBJECT_CATEGORY_SETTINGS` / `PART_CATEGORY_SETTINGS` — curated category → `std::vector<SimpleSettingData>`
(`{name, label, priority}`, `name` = the option key) lists for
the Object Table dialog (`ObjectTableDialog`, `ObjectTableSettings` via `get_visible_options` /
`get_all_visible_options`). They do not decide whether a key can be overridden.
- `CATEGORY_ICON` — category → icon name.
## Checklist: adding a setting
1. **`src/libslic3r/PrintConfig.cpp`** — the `def = this->add("my_option", coX)` block in the right
`init_*_params()` (label, category, tooltip, sidetext, min/max, mode, default; `L()` strings; enum maps if an enum).
2. **`src/libslic3r/PrintConfig.hpp`** — `((ConfigOptionX, my_option))` in the `PRINT_CONFIG_CLASS_DEFINE` block of
its scope (`PrintObjectConfig` / `PrintRegionConfig` / `PrintConfig` / `GCodeConfig` / `MachineEnvelopeConfig`).
3. **`src/libslic3r/Preset.cpp`** — append the key to `s_Preset_print_options` / `s_Preset_filament_options` /
`s_Preset_printer_options` / `s_Preset_machine_limits_options`. Per-variant keys also go into the
`*_options_with_variant` sets in `PrintConfig.cpp`; nozzle-sized printer keys into
`PrintConfigDef::init_extruder_option_keys`.
4. **`src/slic3r/GUI/Tab.cpp`** — `optgroup->append_single_option_line("my_option", "wiki_page#anchor")` on the right
page and group of `TabPrint::build` / `TabFilament::build` / `TabPrinter::build_fff` (index `0` for variant keys).
That alone yields the widget, tooltip, undo/system decoration, dirty tracking, search and Speed Dial entries, and
(for object/region keys) the model-tab rows.
5. **Slicing invalidation** — add the key to the right step list in `Print::invalidate_state_by_config_options` or
`PrintObject::invalidate_state_by_config_options`; a key in neither falls through to `invalidate_all_steps()`
(correct, but every edit reslices everything).
6. **Optional GUI:** dependencies in `ConfigManipulation::toggle_*_options` / `update_*_config` (+ `TabX::toggle_options`);
Object Table exposure in `SettingsFactory::OBJECT_CATEGORY_SETTINGS` / `PART_CATEGORY_SETTINGS`; a runtime list
via `Choice::register_dynamic_list`; enum icons `resources/images/param_<value>.svg`; a custom widget row via
`Tab::create_line_with_widget` plus the name branches in [Custom widgets](#custom-widgets-on-a-line).
7. **Compatibility** — a renamed or removed key needs `PrintConfigDef::handle_legacy` (old key/value → new;
`PrintConfigDef::handle_legacy_composite` when the migration needs other keys of the loaded config); the
default must keep existing profiles and 3MF projects slicing as before; profile edits follow the `orca-profiles`
skill (vendor `version` bump).
@@ -0,0 +1,858 @@
# Orca widget library
The owner-drawn widgets in `src/slic3r/GUI/Widgets/`: which raw wx control each replaces, constructors, the events
each emits and where to bind them, style APIs, and the per-widget quirks behind real bugs. Read it before adding or
changing a control in Orca UI, or when a widget event "never fires", fires twice, or a widget looks wrong after a
DPI or theme change. Writing a *new* widget (StaticBox/StateHandler/render/Rescale) is in
`references/painting-custom-widgets.md`; `StateColor` semantics and the dark map in
`references/colours-dark-mode.md §StateColor`; the `Label` font table in `references/dpi-bitmaps-fonts.md`; raw wx
controls in `references/controls-dataview.md`.
Contents: [Rules](#rules) · [Why the library exists](#why-the-library-exists) ·
[Namespaces](#namespaces-and-the-checkbox-clash) · [Catalog](#catalog) · [Event semantics](#event-semantics) ·
[Custom vs raw](#custom-vs-raw) · [Shared lifecycle rules](#shared-lifecycle-rules) · [Button](#button) ·
[DialogButtons](#dialogbuttons) · [Label and HyperLink](#label-and-hyperlink) · [CheckBox](#checkbox-and-radiobox) ·
[SwitchButton family](#switchbutton-family) · [RadioGroup](#radiogroup) · [TextInput](#textinput) ·
[ComboBox and DropDown](#combobox-and-dropdown) · [SpinInput](#spininput-and-tempinput) · [Tab systems](#tab-systems) ·
[StaticBox, LabeledStaticBox, StaticLine](#staticbox-labeledstaticbox-staticline) · [ProgressBar](#progressbar) ·
[ScrolledWindow](#scrolledwindow) · [PopupWindow](#popupwindow) ·
[ProgressDialog, WebView, device composites](#progressdialog-webview-and-device-page-composites) ·
[Debugging widgets](#debugging-widgets)
## Rules
1. Interactive controls in new or modified UI are Orca widgets (`Button`, `::CheckBox`, `::ComboBox`, `::TextInput`,
`SpinInput`, `SwitchButton`, `RadioGroup`, `TabCtrl`); never `wxButton`, `wxSpinCtrl`, `wxCheckBox`, `wxChoice` or
a single-line `wxTextCtrl` in new code. → §Custom vs raw
2. Every dialog's bottom button row is `DialogButtons`. → §DialogButtons
3. Inside `namespace Slic3r::GUI` write `::CheckBox`, `::TextInput`, `::ComboBox` (also in `dynamic_cast` and
forward declarations, which go at global scope). → §Namespaces
4. Call `Button::SetStyle(ButtonStyle, ButtonType)` on every `Button` you create; re-apply size/font overrides after
`Rescale()`. → §Button
5. User handlers bound on a widget run **before** the widget's own handlers: `Skip()` in `wxEVT_TOGGLEBUTTON`
handlers on `::CheckBox`/`SwitchButton`, in mouse handlers on `Button`, and in ENTER/KILL_FOCUS handlers on
`GetTextCtrl()`, and take events by reference. → §Event semantics
6. Bind widget events on the widget itself (or by event type on an ancestor), not on an ancestor filtered by the
widget's id: `ComboBox` ignores the id you pass, `TextInput`/`SpinInput` take none, and several events carry id 0
or another window's id. → §Event semantics
7. Know which setters emit: `RadioGroup::SetSelection`, `MultiSwitchButton::SetSelection`, `TabCtrl::SelectItem`,
`Notebook::SetSelection`, an editable `ComboBox::SetSelection` (as `wxEVT_TEXT`) and `SpinInput::SetValue` (as
`wxEVT_TEXT` + `EVT_SPINCTRL_TEXT`) do. → §Event semantics
8. `::CheckBox` and `SwitchButton` emit `wxEVT_TOGGLEBUTTON`, never `wxEVT_CHECKBOX`. → §CheckBox and RadioBox
9. Set a container's background colour before creating widgets in it. → §Shared lifecycle rules
10. Enable/disable custom widgets individually; disabling an ancestor leaves them painted enabled (`::CheckBox`, a native button, greys with it). → §Shared lifecycle
rules
11. Call each widget's `Rescale()` from `on_dpi_changed` (types qualified); `RadioGroup`, `Label`, `HyperLink` and
`LabeledStaticBox` have none and `ProgressBar::Rescale()` does nothing. → §Shared lifecycle rules
12. Read and write `TextInput` text through `GetTextCtrl()` (`ChangeValue` for a silent update); bind
`wxEVT_TEXT_ENTER`/`wxEVT_KILL_FOCUS` on the TextInput or its `GetTextCtrl()`, never on a parent. → §TextInput
13. Multi-line text is a raw `wxTextCtrl` with `wxTE_MULTILINE`, not `TextInput`. → §TextInput
14. `ComboBox`: `wxCB_READONLY` for choice semantics, `SelectAndNotify(n)` when listeners must react, `void*` client
data only. → §ComboBox and DropDown
15. `SpinInput` is integer-only and cannot type `-`; set the range before the value; read `GetValue()` after the
commit; bind `wxEVT_SPINCTRL` with a `wxCommandEvent&` handler. → §SpinInput and TempInput
16. `TabCtrl`'s `wxEVT_TAB_SEL_CHANGING` cannot veto; switching content is your job. → §Tab systems
17. Custom `DialogButtons` labels must exist in the translation catalog (write them as `L("…")`). → §DialogButtons
18. `ProgressBar` colours are painted unmapped and its colour-setter names are swapped; use `SetHeight` for thin bars.
→ §ProgressBar
19. Transient popups derive from `PopupWindow`; on MSW call `BindUnfocusEvent()` when the popup must close with the
frame. → §PopupWindow
20. Children of a `LabeledStaticBox` are created with the box as parent; its label is fixed at `Create()`.
→ §StaticBox, LabeledStaticBox, StaticLine
21. Links are `HyperLink`, or a `Label` given `LB_HYPERLINK` through `SetWindowStyleFlag` (the constructor ignores it).
→ §Label and HyperLink
## Why the library exists
Native controls cannot carry Orca's flat, rounded, palette-coloured look, and they cannot follow all of Orca's
theming:
- On Windows the theme is an app-level setting (Preferences "Enable dark Mode", Windows-only) layered on
`MSWEnableDarkMode`, and wx's MSW dark mode does not reach `TaskDialog()`-based dialogs (`wxMessageBox`,
`wxMessageDialog`, `wxRichMessageDialog`, `wxProgressDialog`), the common dialogs or the date/time pickers
(`interface/wx/app.h:1436-1445`). Hence the `MsgDialog` family (`references/windows-dialogs.md`) and Orca's own
`ProgressDialog`.
- On macOS and Linux Orca follows the system appearance; native controls are themed by the toolkit, but any
light palette colour set on them still needs the `UpdateDarkUI` pass (`references/colours-dark-mode.md`).
- GTK theme borders bleed through native controls wrapped inside an owner-drawn frame, so wrappers strip them with
`Slic3r::GUI::RemoveInputBorder` (`TextInput`, `SpinInput`, `ComboBox`) or `RemoveButtonBorder` (`::CheckBox`,
`SwitchButton`) under `__WXGTK__` (`GUI_Utils.cpp`, a CSS provider on GTK3).
Design that every widget shares:
- **Colours are data.** Most widgets derive from `StaticBox` (a `wxWindow` painting a rounded rect, border, optional
vertical gradient and badge) whose colours are `StateColor`s resolved at paint time from state bits tracked by a
pushed `StateHandler` and from the current dark flag, so painted colours follow a theme switch on the next
`Refresh()`. `StaticBox::SetBackgroundColor(StateColor)` is the owner-drawn fill and is **not** wx's
`SetBackgroundColour`. Details: `references/painting-custom-widgets.md`, `references/colours-dark-mode.md §StateColor`.
- **Events mirror wx.** Widgets emit the native event types (`wxEVT_BUTTON`, `wxEVT_TOGGLEBUTTON`, `wxEVT_COMBOBOX`,
`wxEVT_SPINCTRL`, `wxEVT_RADIOBOX`) through `GetEventHandler()->ProcessEvent`, so command events propagate to
parents like native ones (`docs/doxygen/overviews/eventhandling.h:536-552`) and stop at dialogs
(`wxWS_EX_BLOCK_EVENTS`, `interface/wx/window.h:264-270`) and, on MSW and macOS, at popups ([source]
`src/common/popupcmn.cpp:135` sets the same flag; wxGTK's `wxPopupWindow::Create` never calls it,
`src/gtk/popupwin.cpp`). Propagation still works with the `StateHandler` pushed in front because the window's
`TryAfter` forwards to the parent ([source] `src/common/wincmn.cpp:3499-3522`). The differences from native
controls (ids, event objects, setter side effects) are in §Event semantics.
## Namespaces and the ::CheckBox clash
Widgets are in the **global namespace**, except these, which are in `Slic3r::GUI`: `DialogButtons`, `HyperLink`,
`ProgressDialog`, `RadioBox`, the `AMSControl`/`AMSItem` family, the `FanControl` family, `FilamentLoad`, the
`MultiNozzleSync` dialogs/tables, `SideTools`/`SideToolsPanel`, `WebViewHostDialog`, and the non-widget helpers of
`WebHosting.hpp` (namespace `Slic3r::GUI::web_hosting`) (check the header).
The `::` prefix matters because `Field.hpp` declares settings-field classes `Slic3r::GUI::CheckBox`, `TextCtrl`,
`SpinCtrl`, `Choice`, `StaticText` (plus `ColourPicker`, `PointCtrl`, `SliderCtrl`). Inside `namespace Slic3r::GUI`,
once `Field.hpp` is reachable (through `OptionsGroup.hpp`, `Tab.hpp`, …), unqualified `CheckBox` names the Field
class, which is not a `wxWindow`. `TextCtrl` also names the MSW `wxTextCtrl` subclass/typedef in
`Widgets/TextCtrl.h`.
- **Rule:** Qualify global widget types inside `Slic3r::GUI`, especially in `dynamic_cast`.
**Why:** `dynamic_cast<CheckBox*>(child)` compiles (the Field class has `msw_rescale()`), but never matches a
window, so the walk silently skips every `::CheckBox`. `PreferencesDialog::on_dpi_changed` has this shape — copy
its child walk, not its unqualified casts.
```cpp
// Wrong (in namespace Slic3r::GUI):
else if (auto* chk = dynamic_cast<CheckBox*>(child)) chk->msw_rescale(); // Field class: never matches
// Right:
else if (auto* chk = dynamic_cast<::CheckBox*>(child)) chk->Rescale();
```
- **Rule:** Forward-declare global widgets at global scope, before `namespace Slic3r {`.
**Why:** `class Button;` inside `namespace Slic3r::GUI` declares a distinct, never-defined `Slic3r::GUI::Button`;
members of that type cannot hold a `::Button*` and calls on them do not compile.
```cpp
class Button; class ComboBox; // Right: global, as MultiNozzleSync.hpp does
namespace Slic3r { namespace GUI {
class MyPanel : public wxPanel { ::Button* m_ok{nullptr}; ::ComboBox* m_combo{nullptr}; };
}}
```
## Catalog
| Widget (header) | Replaces | Base | Constructor | Must-know |
|---|---|---|---|---|
| `Button` | `wxButton`, `wxBitmapButton`, `ScalableButton` | `StaticBox` | `Button(parent, text, icon = "", style = 0, iconSize = 0, id = wxID_ANY)` | icon = SVG **name** from `resources/images/` (no path/extension), default 20 px; **id is last**; style with `SetStyle(...)`; emits `wxEVT_BUTTON`. §Button |
| `DialogButtons` (`Slic3r::GUI`) | `wxStdDialogButtonSizer`, `CreateStdDialogButtonSizer` | `wxPanel` | `DialogButtons(parent, {non-translated labels}, primary_translated_label = "", left_aligned_count = 0)` | labels → stock ids; styles primary/alert; self-rescales. §DialogButtons |
| `Label` | `wxStaticText` | `wxStaticText` | `Label(parent, text = "", style = 0, size)` (font `Body_14`), `Label(parent, font, text, style, size)` | `LB_AUTO_WRAP`, `LB_PROPAGATE_MOUSE_EVENT`, `LB_HYPERLINK` (via `SetWindowStyleFlag`); hosts the font table. §Label |
| `HyperLink` (`Slic3r::GUI`) | `wxHyperlinkCtrl` | `wxStaticText` | `HyperLink(parent, label = "", url = "", style = 0)` | opens `url` with `wxLaunchDefaultBrowser` on left-down. §Label |
| `::CheckBox` | `wxCheckBox` | `wxBitmapToggleButton` | `CheckBox(parent, id = wxID_ANY)` — **no label** | emits `wxEVT_TOGGLEBUTTON`; `SetHalfChecked`; `Rescale()`. §CheckBox |
| `SwitchButton` | on/off `wxCheckBox`, `wxToggleButton` | `wxBitmapToggleButton` | `SwitchButton(parent = nullptr, id = wxID_ANY)` | `SetLabels(on, off)`; track/thumb/text `StateColor`s; emits `wxEVT_TOGGLEBUTTON`. §SwitchButton family |
| `MultiSwitchButton` | segmented `wxRadioBox` | `StaticBox` | `MultiSwitchButton(parent, id, pos, size, style)` | emits `wxCUSTOMEVT_MULTISWITCH_SELECTION`, also from `SetSelection`. §SwitchButton family |
| `RadioGroup` | `wxRadioBox`, `wxRadioButton` rows | `wxPanel` | `RadioGroup(parent, std::vector<wxString> labels, wxHORIZONTAL/wxVERTICAL, row_col_limit = -1)` | emits `wxEVT_RADIOBOX` from **every** `SetSelection`. §RadioGroup |
| `::TextInput` | single-line `wxTextCtrl` | `wxNavigationEnabled<StaticBox>` | `TextInput(parent, text, label = "", icon = "", pos, size, style)` | value API on `GetTextCtrl()`; `label` is a painted side label. §TextInput |
| `::ComboBox` | `wxComboBox`, `wxChoice`, `wxBitmapComboBox` | `wxWindowWithItems<TextInput, wxItemContainer>` | `ComboBox(parent, id, value = "", pos, size, n = 0, choices = NULL, style = 0)` | `wxCB_READONLY` = choice; `SelectAndNotify`; `void*` client data only. §ComboBox |
| `DropDown` | native combo popup, menu used as a list | `PopupWindow` | `DropDown(parent, std::vector<Item>& items, style = 0)` | holds `items` **by reference**; `Invalidate()` after edits. §ComboBox |
| `SpinInput` | `wxSpinCtrl` | `wxNavigationEnabled<StaticBox>` | `SpinInput(parent, text, label = "", pos, size, style, min = 0, max = 100, initial = 0, step = 1)` | integer, non-negative typing; commits on Enter/kill-focus. §SpinInput |
| `TempInput` | temperature `wxTextCtrl` (device pages) | `wxNavigationEnabled<StaticBox>` | `TempInput(parent, type, text, TempInputType, label, normal_icon, active_icon, pos, size, style)` | warning icon + too-high/too-low states; posts `wxCUSTOMEVT_SET_TEMP_FINISH` to its **parent**. §SpinInput |
| `TabCtrl` | `wxNotebook` tab strip | `StaticBox` | `TabCtrl(parent, id, pos, size, style)` | one `Button` per tab; you switch the content. §Tab systems |
| `Notebook` (`GUI/Notebook.hpp`) | `wxNotebook` | `wxBookCtrlBase` | `Notebook(parent, id, pos, size, side_tools = NULL, style = 0)` | MainFrame's main tabs; pages addressed by name. §Tab systems |
| `LabeledStaticBox` | `wxStaticBox` | `wxStaticBox` | `LabeledStaticBox(parent, label = "", pos, size, style)` | usable as a `wxStaticBoxSizer` box; label fixed at `Create()`. §StaticBox… |
| `StaticBox` | plain bordered `wxPanel` | `wxWindow` | `StaticBox(parent, id, pos, size, style)` | rounded group frame; base of most widgets; `ShowBadge(bool)`. §StaticBox… |
| `StaticLine` | `wxStaticLine` | `wxWindow` | `StaticLine(parent, vertical = false, label = {}, icon = {})` | H/V separator with optional label + icon; `SetLineColour`. Avoid `wxStaticLine`. §StaticBox… |
| `ProgressBar` | `wxGauge` | `wxWindow` | `ProgressBar(parent, id = wxID_ANY, max = 100, pos, size, shown = false)` | plain `wxColour`s, unmapped; `Disable(wxString)`. §ProgressBar |
| `ScrolledWindow` + `MyScrollbar` | `wxScrolledWindow` with slim bars | `wxScrolled<wxWindow>` | `ScrolledWindow(parent, id, pos, size, style, marginWidth = 0, scrollbarWidth = 4, tipLength = 0)` | content on `GetPanel()`; vertical use. §ScrolledWindow |
| `PopupWindow` | `wxPopupTransientWindow` | `wxPopupTransientWindow` | `PopupWindow(parent, style = wxBORDER_NONE)` | per-platform dismissal hooks; MSW part opt-in. §PopupWindow |
| `ProgressDialog` (`Slic3r::GUI`) | `wxProgressDialog` | `wxDialog` | `ProgressDialog(title, message, maximum = 100, parent = NULL, style = wxPD_APP_MODAL \| wxPD_AUTO_HIDE, adaptive = false)` | styled copy of the generic dialog. §ProgressDialog… |
| `WebView` | `wxWebView::New` | static helpers | `WebView::CreateWebView(parent, url)` | see `references/webview-gl-aui-media.md`. §ProgressDialog… |
| `CheckList` | `wxCheckListBox` | `wxWindow` | `CheckList(parent, choices, scroll_style = wxVSCROLL)` | filterable multi-select list (`MultiChoiceDialog`). |
Field classes (`Field.cpp`) build their editors from these widgets (`TextCtrl` → `::TextInput`, `CheckBox` →
`::CheckBox`, `SpinCtrl` → `SpinInput`, `Choice` → `::ComboBox`); the field machinery is in
`references/orca-settings-ui.md`.
## Event semantics
| Widget | Emits on user action | Id / event object | Programmatic setters | Bind |
|---|---|---|---|---|
| `Button` | `wxEVT_BUTTON` (on release inside, Space/Enter) | button id / button | `SetValue(bool)` (Checked look) silent | on the button (Rule 6) |
| `::CheckBox`, `SwitchButton` | `wxEVT_TOGGLEBUTTON` | native id / widget | `SetValue`, `SetHalfChecked` silent (`interface/wx/tglbtn.h:98`) | on the widget; never `wxEVT_CHECKBOX` |
| `::ComboBox` | `wxEVT_COMBOBOX` (int = index, string = text) from popup pick, arrow keys (read-only combo), `SelectAndNotify`; `wxEVT_COMBOBOX_DROPDOWN`/`_CLOSEUP` | COMBOBOX: combo's auto id / combo; DROPDOWN/CLOSEUP: **id 0, no object** | `SetSelection`, `SetValue` silent for COMBOBOX; editable or `CB_NO_TEXT`: they send `wxEVT_TEXT` | on the combo |
| `::TextInput` | inner `wxEVT_TEXT` (propagates); `wxEVT_TEXT_ENTER`, `wxEVT_KILL_FOCUS` re-sent to the wrapper only | TEXT: **inner** id/object; ENTER/KILL_FOCUS: wrapper id, inner object | `GetTextCtrl()->SetValue` sends `wxEVT_TEXT`; `ChangeValue` silent | TEXT: wrapper, inner, or ancestor by type; ENTER/KILL_FOCUS: wrapper or inner |
| `SpinInput` | `wxEVT_SPINCTRL` (a plain `wxCommandEvent`, no int) on commit; `EVT_SPINCTRL_TEXT` (int + string) per parseable keystroke; inner `wxEVT_TEXT` | spinner id / spinner | `SetValue` sends `EVT_SPINCTRL_TEXT` + `wxEVT_TEXT`, no `wxEVT_SPINCTRL` | on the spinner; handler takes `wxCommandEvent&` |
| `RadioGroup` | `wxEVT_RADIOBOX` (int, string) | group id / **no object** | **every** `SetSelection(i)` emits, same index included | on the group |
| `TabCtrl` | `wxEVT_TAB_SEL_CHANGING` (int = old index, not vetoable), then `wxEVT_TAB_SEL_CHANGED` (int = new) | ctrl id / ctrl | `SelectItem(i)` emits both when `i` changes | on the ctrl |
| `Notebook` | tab click posts `wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED` (id = page index), then `wxEVT_NOTEBOOK_PAGE_CHANGING`/`_CHANGED` | — | `SetSelection` emits PAGE_*; `ChangeSelection` silent | on the notebook |
| `MultiSwitchButton` | `wxCUSTOMEVT_MULTISWITCH_SELECTION` (int, string) | id / widget | `SetSelection` emits when the index changes | on the widget |
| `SwitchBoard` | `wxCUSTOMEVT_SWITCH_POS` (int: 1 = left half, 0 = right half), **posted** | id 0 / none | — | on the board |
| `ModeSwitchButton` | none — writes the app mode (`wxGetApp().save_mode`) | — | `SetSelection` silent | — |
| `DropDown` (standalone) | `wxEVT_COMBOBOX` (int, string), `EVT_DISMISS` | COMBOBOX: popup id / popup; EVT_DISMISS: id 0, no object | — | on the DropDown (popups block propagation on MSW/macOS, not on GTK) |
| `TempInput` | `wxCUSTOMEVT_SET_TEMP_FINISH` on commit, **posted to the parent** (int = the ctor's `type`, string = the `TempInputType` number) | id 0 / none | — | on the **parent**; tell inputs apart by the int |
Consequences that recur:
- **Ids.** `ComboBox` calls `TextInput::Create`, which takes no id and creates the window with `wxID_ANY`, so the
combo's `id` argument is ignored; `TextInput` and
`SpinInput` have no id parameter. `parent->Bind(wxEVT_COMBOBOX, h, ID_MY_COMBO)` never fires. Bind on the widget,
or call `SetId()` after construction if an id filter is unavoidable.
- **Handler order.** Dynamic handlers run most-recently-bound first, before static event tables
(`interface/wx/event.h:592-593`). Widgets bind their internal handlers in the constructor (or use event tables),
so yours run first: a handler that does not `Skip()` cuts off the widget's own behaviour (bitmap refresh, commit,
click). A handler taking the event **by value** cannot `Skip()` the real event (`references/events.md §Skip
discipline`).
- **Setters vs wx.** wx's rule is that setters are silent (`interface/wx/ctrlsub.h:102-103`); `wxTextEntry::SetValue`
and `wxBookCtrlBase::SetSelection` are the documented exceptions (`references/controls-dataview.md §Events from
programmatic changes`). The table's emitting setters re-enter change handlers synchronously inside model→view
refreshes: guard with a flag or use the silent variant.
## Custom vs raw
Rules:
1. Interactive controls in new or modified UI: Orca widgets, always. Bottom button rows: `DialogButtons`. When you
modify an older dialog built from raw controls, convert the controls you touch; untouched raw controls are not a
model.
2. Static text: plain `wxStaticText` with a `Label::Body_*` font is fine; use `Label` for auto-wrap and the hyperlink
look, `HyperLink` for a link that opens a URL. Fonts come from the `Label` statics (`Head_10…Head_48` bold for
titles, `Body_8…Body_16` for content, `Body_14` the dialog default); never hardcode point sizes, because
`Label::sysFont` already scales them per platform (`references/dpi-bitmaps-fonts.md`).
3. Containers stay raw: `wxPanel`, `wxBoxSizer`, `wxScrolledWindow`, `wxSimplebook`. `StaticBox`/`LabeledStaticBox`
only for the rounded-border group look; `ScrolledWindow` only for the slim-scrollbar look.
4. If a raw control is unavoidable, make it dark-safe: `wxGetApp().UpdateDarkUI(ctrl)` / `UpdateDlgDarkUI(dlg)` or
explicit `StateColor::darkModeColorFor()` (`references/colours-dark-mode.md`).
5. Every custom widget inside a `DPIDialog`/`DPIFrame` gets its `Rescale()` called from `on_dpi_changed`
(§Shared lifecycle rules).
Mixed is normal: `CloneDialog::CloneDialog` pairs raw `wxStaticText` labels with `SpinInput`, `::CheckBox`,
`ProgressBar` and `DialogButtons` (copy its layout, not its OK handler, which runs a `wxYield()` loop inside a frozen
plater).
No Orca replacement exists — use raw wx (`references/controls-dataview.md`) for: multi-line text (`wxTextCtrl` +
`wxTE_MULTILINE`, as Field does for multi-line options), headerless page stacks (`wxSimplebook`), data views and grids
(`wxDataViewCtrl`, `wxGrid`), `wxSlider`, `wxColourPickerCtrl`, `wxSplitterWindow`, `wxStaticBitmap`, and the
`Slic3r::GUI::BitmapComboBox` wrapper where a `wxBitmapComboBox` is required.
## Shared lifecycle rules
**Background snapshot.** `StaticBox::Create`, `Label`, `SwitchButton` (through `StaticBox::GetParentBackgroundColor`:
a parent `StaticBox`'s default fill — the midpoint for a gradient — else `parent->GetBackgroundColour()`), and
`::CheckBox`, `RadioGroup` (`parent->GetBackgroundColour()`) copy the parent's background colour at construction;
wx itself never inherits a background colour ([source] `src/common/wincmn.cpp:1543-1552`).
- **Rule:** Set the container's background before creating widgets in it.
```cpp
auto* panel = new wxPanel(this);
auto* cb = new ::CheckBox(panel); // Wrong: snapshots the panel's default colour
panel->SetBackgroundColour(*wxWHITE);
// Right: SetBackgroundColour first, then create children
```
**Enable state.** A parent's `Enable()`/`Disable()` never calls a child's virtual `Enable()`: on MSW and macOS it
reaches children through `NotifyWindowOnEnableChange` → `DoEnable()`, on GTK the toolkit propagates sensitivity
natively ([source] `src/common/wincmn.cpp:1147-1201`). Orca widgets update their `Enabled` state bit only from
`EVT_ENABLE_CHANGED`, which their `Enable()` override emits — so after `panel->Disable()` they ignore input
(`IsEnabled()` is false, `interface/wx/window.h:3060-3069`) but the `StateColor`-painted ones (`Button`, `TextInput`,
`ComboBox`, `SpinInput`, `RadioGroup`'s labels, …) still paint enabled colours. [source] `::CheckBox`, a native
`wxBitmapToggleButton`, greys with its parent on every port: MSW `EnableWindow`s it and the owner-drawn button paints
its disabled bitmap (`src/msw/window.cpp:576-593`, `src/msw/anybutton.cpp:846-871`, `:969-972`, `:1502`); GTK3's
button image draws a greyed copy of the current bitmap while `!IsEnabled()` (`src/gtk/image_gtk.cpp:37-42`), though
the disabled bitmap itself needs `IsThisEnabled()` false (`src/gtk/anybutton.cpp:140-146`); macOS sends
`setEnabled:NO` (`src/osx/cocoa/window.mm:3784-3791`) and AppKit dims the image (`NSButtonCell`
`imageDimsWhenDisabled`, default YES).
- **Rule:** Enable/disable each custom widget directly (`RadioGroup::Enable` does this for its own buttons).
```cpp
m_options_panel->Enable(on); // Wrong: widgets keep the enabled look
for (wxWindow* w : std::initializer_list<wxWindow*>{m_combo, m_spin, m_check})
w->Enable(on); // Right: virtual Enable() per widget
```
**DPI.** Widgets size themselves from `FromDIP` values and cached bitmaps; after a DPI change the owning
`DPIDialog::on_dpi_changed` must call `Rescale()` on each (`Button`, `::CheckBox`, `::TextInput`, `::ComboBox`
(also rescales its `DropDown`), `SpinInput`, `SwitchButton`, `MultiSwitchButton`, `TabCtrl`, `StaticLine`,
`ModeSwitchButton`). Exceptions: `RadioGroup`, `Label`, `HyperLink` and `LabeledStaticBox` (its scale is fixed in
`Create()`) have no `Rescale()`; `ProgressBar::Rescale()` is empty; `DialogButtons` rescales itself. Pass sizes through
`FromDIP` (`references/dpi-bitmaps-fonts.md`).
```cpp
void MyDialog::on_dpi_changed(const wxRect&) {
m_ok_btn->Rescale(); m_combo->Rescale(); m_check->Rescale(); // ::CheckBox has Rescale(), not msw_rescale()
m_combo->SetMinSize(wxSize(FromDIP(160), -1)); // re-apply explicit sizes
GetSizer()->SetSizeHints(this); Refresh(); // resize + new minimum (sizers-layout.md)
}
```
**Theme switch.** `StateColor`s follow the dark flag at paint time. What was captured once does not: the wx
background copied at construction, colours baked into bitmaps (`SwitchButton` labels, `::CheckBox`/`RadioGroup`
SVGs), `ProgressBar` colours, `Label` foreground. The `Update*DarkUI` walk re-maps the wx colours among them that
are `gDarkColors` keys (`Label`'s `#262E30`, a palette background) when it reaches the widget, nothing else.
Re-apply them in `on_sys_color_changed()` (call `Rescale()` where
it re-reads colours, e.g. `SwitchButton`), and keep `UpdateDlgDarkUI(this)` as the last constructor line
(`references/colours-dark-mode.md §Runtime theme switch and re-applying colours`).
**Sizing.** No widget implements `DoGetBestSize`: the text/icon widgets set their min size in `messureSize()` whenever
a label, font, icon, style or DPI changes, and the bitmap toggles (`::CheckBox`, `SwitchButton`) size themselves
to their bitmap in `Rescale()`.
Nothing re-lays out the parent: call `Layout()` on the container after changing a widget's content at runtime
(`references/sizers-layout.md`).
**Mouse capture.** `Button`, `DropDown`, `SpinInput`'s arrows, `ModeSwitchButton`, `MyScrollbar`, `StepCtrl` and the
device-page `SideButton`, `ImageSwitchButton` and `AxisCtrlButton` capture the mouse while pressed; a capture leak
shows on macOS as a UI that is alive but unclickable (`references/mouse-keyboard-focus.md §Mouse capture`).
## Button
`Button` (`Widgets/Button.cpp`) is a `StaticBox` with label, optional icon (`ScalableBitmap` from an SVG name), focus
ring and an optional toggle look. `Create` sets `Label::Body_14` and measures; `messureSize()` sets the min size from
text + icon + padding, capped in width by `SetMaxSize` (the label then becomes the tooltip if none is set); a size
given to `Button::SetMinSize` is stored, floors the width and, when its height is > 0, replaces the measured height.
`SetStyle(ButtonStyle, ButtonType)` is the one call that makes it look like an Orca button — don't hand-roll button
colours in new code:
| `ButtonType` | Padding / min size / radius | Font | Use |
|---|---|---|---|
| `Compact` | padding 8×3, radius 8 | `Body_10` | tight spaces |
| `Window` | size and min 58×24, radius 12 | `Body_12` | buttons in windows, away from parameter boxes |
| `Choice` | min 100×32, padding 12×8, radius 4 | `Body_14` | dialog/window choice buttons (`DialogButtons` uses it) |
| `Parameter` | size and min 120×26, radius 4 | `Body_14` | buttons next to parameter boxes |
| `Icon` | padding 5×5, size and min 26×26, radius 4 | — | icon-only; create with `iconSize = 16` and a 16 px icon |
| `Expanded` | min height 32, padding 12×8, radius 4 | `Body_14` | full-width buttons, e.g. inside a static box |
All values go through `FromDIP`. `ButtonStyle::{Regular, Confirm, Alert, Disabled}` picks the background, border and
text `StateColor`s from the `btn_regular/btn_confirm/btn_alert/btn_disabled` tables in `Button.cpp` (palette colours
where the dark map should apply; the focus-border colour is chosen per theme when `SetStyle` runs). `ButtonStyle::Disabled` is a **look**; it does not call `Enable(false)`.
```cpp
auto* export_btn = new Button(this, _L("Export"));
export_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Choice);
export_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export(); });
auto* reload_btn = new Button(parent, wxEmptyString, "refresh", 0, 16); // icon-only (PreferencesDialog)
reload_btn->SetStyle(ButtonStyle::Regular, ButtonType::Icon);
```
Behaviour (`Button::mouseDown`, `Button::mouseReleased`, `Button::keyDownUp`):
- Left-down focuses (if focusable) and captures; left-up releases and sends `wxEVT_BUTTON` (id = window id, event
object = button) when the pointer is inside. Space/Enter synthesise down/up, so a focused Button clicks on key-up;
on MSW it claims `WM_GETDLGCODE` so Enter reaches it instead of the dialog's default-button logic.
- Tab is turned into navigation; arrow keys are swallowed (`HandleAsNavigationKey` handles only Tab, [source]
`src/common/wincmn.cpp:3566-3583`), which is why `DialogButtons` adds its own arrow handler.
- `SetValue(bool)`/`GetValue()` drive the `Checked` state bit (used by `TabCtrl` and `MultiSwitchButton`); it is
visible only with `StateColor`s that have `Checked` entries — the `SetStyle` tables have none, the unstyled default
does. Clicks do not toggle it.
- `SetCanFocus(false)` keeps it out of focus (`AcceptsFocus()` returns the flag). `EnableTooltipEvenDisabled()` shows
the tooltip on a disabled button by watching the parent's motion — MSW only (elsewhere a no-op).
- `SetIndicator(bool)` draws a small dot after the label (TabCtrl uses it); `SetVertical`, `SetCenter`,
`SetPaddingSize`, `SetIconSpacing`, `SetTextColor(StateColor)`, `SetIcon(name | wxBitmap)`.
- `Rescale()` re-rasterises a **named** icon (one set from a `wxBitmap` has no source and stays as is), re-measures and
re-runs `SetStyle` with the stored style/type.
Pitfalls:
- **Rule:** Call `SetStyle` on every Button you create.
**Why:** the unstyled defaults are wx stock colours (`*wxLIGHT_GREY` hover, `*wxBLACK` text) chosen for no Orca
design and partly off the dark map, with generic metrics (padding 10×8, `StaticBox` radius 8, `Body_14`) instead of
a `ButtonType`'s, and no focus border.
- **Rule:** Re-apply your own min size, size or font after `Rescale()`.
**Why:** `Rescale()` re-runs `SetStyle`, which resets min size, padding, radius and font for the type.
```cpp
btn->SetStyle(ButtonStyle::Regular, ButtonType::Choice);
btn->SetMinSize(wxSize(FromDIP(160), FromDIP(32))); // lost on the next Rescale()…
// Right: in on_dpi_changed: btn->Rescale(); btn->SetMinSize(wxSize(FromDIP(160), FromDIP(32)));
```
- **Rule:** A `wxEVT_LEFT_DOWN`/`_UP` handler bound on a Button must `Skip()`.
**Why:** the click logic lives in the Button's static event table, which runs after dynamic handlers.
- **Rule:** Do not rely on Button's capture-lost handling to cancel a press.
**Why:** `Button::mouseCaptureLost` replays release with a default `wxMouseEvent` at (0,0), which is inside the
button, so losing capture mid-press **sends `wxEVT_BUTTON`**. The lost event comes on MSW and GTK and never on
macOS ([source] `src/msw/window.cpp:5186`, `src/gtk/window.cpp:6808-6814`; `interface/wx/event.h:3507` says
Windows only — `references/mouse-keyboard-focus.md §Per-port delivery`). wx's contract is to cancel the
operation (`interface/wx/window.h:3815-3817`). `ModeSwitchButton::mouseCaptureLost` is the correct shape (clear
the pressed flag, no action), minus its `Skip()` (a lost handler must not skip).
## DialogButtons
`Slic3r::GUI::DialogButtons` (`Widgets/DialogButtons.cpp`) is a `wxPanel` that builds the standard bottom row: one
`Button` per label, all `ButtonStyle::Regular` + `ButtonType::Choice`, gaps of `ButtonProps::ChoiceButtonGap()`.
```cpp
auto* dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); // NOT pre-translated
dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { apply(); EndModal(wxID_OK); }); // no Skip()
sizer->Add(dlg_btns, 0, wxEXPAND);
```
The full dialog recipe (DPIDialog, `SetSizerAndFit`, `CenterOnParent`, `UpdateDlgDarkUI`) is in
`references/windows-dialogs.md`.
Constructor contract:
- **Labels** are untranslated; the constructor shows `_L(label)` and matches `label` lower-cased against a stock-id
map: `ok`→`wxID_OK`, `yes`→`wxID_YES`, `apply` and `confirm`→`wxID_APPLY`, `no`→`wxID_NO`, `cancel`→`wxID_CANCEL`,
`open`→`wxID_PRINT` (the map's first `"open"` entry wins), `add`, `copy`, `new`, `save`, `save as`, `refresh`,
`retry`, `ignore`, `help`, `clone`/`duplicate`→`wxID_DUPLICATE`, `select all`, `replace`, `replace all`,
`return`→`wxID_BACKWARD`, `next`→`wxID_FORWARD`, `remove`, `delete`, `abort`, `stop`, `reset`, `clear`,
`exit`/`quit`→`wxID_EXIT`. Other labels keep an auto id.
- **Primary** (`ButtonStyle::Confirm`): the 2nd argument is a *translated* label (`_L("Create")`) naming it;
empty → the only button if there is one, else the first present of `{wxID_OK, wxID_YES, wxID_APPLY, wxID_SAVE,
wxID_PRINT}` in **numeric id order** (a `std::set`: `wxID_SAVE` < `wxID_PRINT` < `wxID_OK` < `wxID_APPLY` <
`wxID_YES`), so `{"Save", "OK"}` makes Save primary. A label that matches no button means no primary. The primary
takes focus only when nothing in the app has focus.
- **Alert** (`ButtonStyle::Alert`): only with ≥ 2 buttons, the first present (numeric order) of `wxID_EXIT`,
`wxID_CLEAR`, `wxID_DELETE`, `wxID_RESET`, `wxID_ABORT`, `wxID_REMOVE`, `wxID_STOP`; `SetAlertButton(translated)`
picks another.
- **`left_aligned_count`** pins the first N buttons to the left (`SetLeftAlignedButtonsCount` later).
- Getters: `GetOK/GetYES/GetAPPLY/GetCONFIRM (= wxID_APPLY)/GetNO/GetCANCEL/GetRETURN/GetNEXT/GetFIRST/GetLAST`,
`GetButtonFromID`, `GetButtonFromLabel(translated)`, `GetButtonFromIndex`.
How clicks close the dialog: a click sends `wxEVT_BUTTON` with the stock id; unhandled, it propagates to
`wxDialogBase::OnButton` (`src/common/dlgcmn.cpp:105,455-478`): the affirmative id (`wxID_OK` by default) runs
`AcceptAndClose()` = `Validate()` + `TransferDataFromWindow()` then `EndDialog(wxID_OK)` (:369-375); `wxID_APPLY`
validates and transfers without closing; `wxID_CANCEL` → `EndDialog(wxID_CANCEL)`; any other id is skipped. So
OK/Cancel close by themselves; Yes, No, Confirm/Apply and custom labels do nothing until you bind them. wx's
ESC→button emulation looks for a real `wxButton` (`EmulateButtonClickIfPresent`, :387-404) and never finds an Orca
`Button`; ESC handling is in `references/windows-dialogs.md`.
DPI: the constructor binds the **parent's** `wxEVT_DPI_CHANGED` (unbound in the destructor) and `Skip()`s it; being
bound after `DPIAware`'s handler it runs first, then the dialog's `on_dpi_changed` — the dialog does not rescale
DialogButtons itself.
Pitfalls:
- **Rule:** A handler that calls `EndModal` must not `Skip()`; bind every button whose default handling is not what
you want.
**Why:** `Skip()` lets the event reach `wxDialogBase::OnButton`, which validates and transfers again and ends the
dialog a second time with its own code (`EndDialog(affirmative id)` / `EndDialog(wxID_CANCEL)`). `EndDialog` only
hides a dialog that no longer reports `IsModal()` (GTK, macOS reset it in the first `EndModal`), but on MSW
`IsModal()` stays true until `ShowModal` returns, so `EndModal` runs again and replaces the return code you passed
([source] `src/common/dlgcmn.cpp` `wxDialogBase::EndDialog`, `include/wx/msw/dialog.h` `IsModal`;
`references/windows-dialogs.md`). Yes/No/Confirm/custom buttons never close on their own.
- **Rule:** Every custom label must be in the translation catalog: write it as `L("Skip for Now")` in the vector, or
make sure the same string appears in a `_L()` elsewhere.
**Why:** the constructor's `_L(label)` translates a variable, which xgettext cannot see, so a label used only there
never reaches `OrcaSlicer.pot` and always shows in English. `L()` is a no-op marker that xgettext extracts
(`references/strings-i18n-files.md`).
```cpp
new DialogButtons(this, {"Download and Install", "Skip for Now"}); // Wrong: untranslatable
new DialogButtons(this, {L("Download and Install"), L("Skip for Now")}); // Right
```
- **Rule:** Don't call `UpdateButtons()` (or `SetLeftAlignedButtonsCount`) repeatedly in your code.
**Why:** each call re-binds `wxEVT_KEY_DOWN` on every button (wx `Bind` does not de-duplicate) and the handler
`Skip()`s, so arrow-key focus moves repeat once per accumulated binding; DPI changes already add one each.
- **Rule:** Don't expect the row to take the dialog's background.
**Why:** the panel paints `darkModeColorFor("#FFFFFF")`, re-applied on every `UpdateButtons()` (DPI change); on a
non-white dialog the row stands out.
## Label and HyperLink
`Label` (`Widgets/Label.cpp`) is a `wxStaticText` with font `Body_14` (or the font passed), foreground `#262E30` and
the parent's background. Style bits (no clash with `wxST_*`):
| Bit | Value | Effect |
|---|---|---|
| `LB_HYPERLINK` | `0x20` | underlined font, `#009688`, hand cursor — **only** when set through `SetWindowStyleFlag` |
| `LB_PROPAGATE_MOUSE_EVENT` | `0x40` | left-down/up are forwarded to the parent's handler with `ProcessEventLocally` (label-relative position, label as event object) and consumed |
| `LB_AUTO_WRAP` | `0x80` | `Label::Wrap(GetSize().x)` on every `wxEVT_SIZE` |
`Label::Wrap(width)` is Orca's own wrapper over the stored text and has no width cache, unlike 3.3.2's
`wxStaticText::Wrap` (`references/controls-dataview.md §wxStaticText`). `Label::split_lines(dc, width, text, out,
max_count)` wraps text for owner-drawn code. The static fonts (`Head_*`, `Body_*`) are built by
`Label::initSysFont()` from `GUI_App::on_init_inner` (`references/dpi-bitmaps-fonts.md`).
`Slic3r::GUI::HyperLink` (`Widgets/HyperLink.cpp`) is a `wxStaticText` with `Head_14` (kept underlined by its
`SetFont`), colour `#009687` — deliberately one off the palette `#009688` so the dark map leaves it alone — hover
`#26A69A`, hand cursor, the URL as tooltip, and `wxLaunchDefaultBrowser(url)` on left-down when the URL is non-empty.
Its `style` argument is unused.
Pitfalls:
- **Rule:** Apply `LB_HYPERLINK` through `SetWindowStyleFlag`, not the constructor; it is a look, not a link.
**Why:** the constructor only stores the bit, and `SetWindowStyleFlag` returns early when the style is unchanged,
so a label created with the bit never gets the look by re-applying it. Clicks still need your handler (or use
`HyperLink`).
On macOS the hyperlink label is set with `SetLabelMarkup`, so quote user text (`wxMarkupParser::Quote`).
```cpp
new Label(this, _L("Learn more"), LB_HYPERLINK); // Wrong: stays plain text
auto* lbl = new Label(this, _L("Learn more")); // Right
lbl->SetWindowStyleFlag(lbl->GetWindowStyle() | LB_HYPERLINK);
lbl->Bind(wxEVT_LEFT_DOWN, [url](wxMouseEvent&) { wxLaunchDefaultBrowser(url); });
```
- **Rule:** For a `HyperLink` with a custom action, leave the URL empty and bind `wxEVT_LEFT_DOWN`.
**Why:** with a URL its own handler opens the browser; your later-bound handler runs first and would have to
`Skip()` to keep it.
## CheckBox and RadioBox
`::CheckBox` (`Widgets/CheckBox.cpp`) is a `wxBitmapToggleButton` (`wxBORDER_NONE`) showing 18 px SVGs
`check_{on,half,off}`, `…_disabled`, `…_focused`. It has **no label**: pair it with a `wxStaticText`/`Label`
(as `CloneDialog::CloneDialog` does). `GetValue()` is a 2-state `bool`.
- Emits `wxEVT_TOGGLEBUTTON` on click (native). `SetValue` emits nothing (`interface/wx/tglbtn.h:98`).
- `SetHalfChecked(true)` is a drawn-only third state (`IsHalfChecked()` exists for the inspector only); the widget's own toggle handler
clears it on any click. `SetValue` does **not** clear it — call `SetHalfChecked(false)` before showing a definite
state.
- `Rescale()` (no `msw_rescale()`) re-rasterises all nine bitmaps and resets size/min size.
- Platform paths: macOS emulates the disabled/focused/hover bitmaps (`CheckBox::Enable` override,
`DoGetBitmap`, `updateBitmap`), but wxOSX hands the `NSButton` `m_bitmaps[State_Current]`/`m_bitmaps[State_Normal]`
directly and calls `DoGetBitmap` only for an `IsOk()` test ([source] `src/osx/anybutton_osx.cpp:84-94`), so only
the hover (`_focused`) art shows and a disabled box shows its normal art dimmed by AppKit; MSW sets a focus bitmap;
GTK strips the theme border.
`Slic3r::GUI::RadioBox` is an older single bitmap radio (`wxBitmapToggleButton`); use `RadioGroup` for new radio
sets.
Pitfalls:
- **Rule:** Bind `wxEVT_TOGGLEBUTTON`, never `wxEVT_CHECKBOX`, and `Skip()` in the handler.
**Why:** `wxEVT_CHECKBOX` is never sent. The widget's own `wxEVT_TOGGLEBUTTON` handler (bound in the constructor)
runs **after** yours and is what swaps the bitmap (`CheckBox::update`); without `Skip()` the value changes but the
box keeps showing the old state.
```cpp
cb->Bind(wxEVT_CHECKBOX, h); // Wrong: never fires
cb->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { apply(); }); // Wrong: bitmap not refreshed
cb->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { e.Skip(); apply(); }); // Right
```
## SwitchButton family
All in `Widgets/SwitchButton.hpp/.cpp`:
- **`SwitchButton`** — `wxBitmapToggleButton` (`wxBORDER_NONE | wxBU_EXACTFIT`), font `Body_12`. Without labels it
shows the `toggle_on`/`toggle_off` SVGs; `SetLabels(on, off)` switches to a two-segment pill drawn into bitmaps from
the track/thumb/text `StateColor`s (`SetTrackColor`, `SetThumbColor`, `SetTextColor`, `SetTextColor2`), narrowed
to `GetMaxWidth()` by shrinking the font. Emits `wxEVT_TOGGLEBUTTON`; `SetValue` is silent; its own toggle handler
(refreshes the bitmap) runs after yours — `Skip()`, as for `::CheckBox`. Every colour/label setter and
`SetBackgroundColour` call `Rescale()`, which re-rasterises the SVGs or, with labels, re-reads the parent background
and re-bakes the pill bitmaps with the current dark mapping: call it on DPI **and** theme change.
- **`ModeSwitchButton`** — the 3-position Simple/Advanced/Expert control (`StaticBox`, `doRender`), plus `SetDevMode`.
It sends no event: a click calls `SelectAndNotify`, which writes the app mode through `wxGetApp().save_mode()` and
is ignored in dev mode or when disabled. `SetSelection` is silent and clamps to 0..2.
- **`MultiSwitchButton`** — a segmented control of `Button`s in an internal `wxScrolledWindow` (scrolls instead of
clipping when squeezed; `SetFitToOptions`). `AppendOption/SetOptions/DeleteAllOptions`, per-option text/client data,
`GetButton(i)`. Emits `wxCUSTOMEVT_MULTISWITCH_SELECTION` (int = index, string = text) whenever the selection
changes — **including programmatic `SetSelection`**.
- **`SwitchBoard`** — a two-label device-page switch (a plain `wxWindow`). It **posts** `wxCUSTOMEVT_SWITCH_POS`
(int = 1 for a click on the left half, 0 for the right half; no id, no event object). Its `Enable()` only flips a
private flag and repaints, and its non-virtual `IsEnabled()` hides the base one: the window stays enabled for wx.
`SetAutoDisableWhenSwitch()` makes a click set that flag off until you re-enable it.
## RadioGroup
`RadioGroup` (`Widgets/RadioGroup.cpp`) is a `wxPanel` of `wxStaticBitmap` radio icons plus `Button` labels in a
`wxFlexGridSizer`; `row_col_limit` (−1 = one row for `wxHORIZONTAL`, one column for `wxVERTICAL`) wraps the items
into a grid — check the row/column arithmetic in `RadioGroup::Create` before relying on a layout. Keyboard:
Right/Down and Left/Up on the focused label move the selection with wrap-around (`SelectNext`/`SelectPrevious`);
only the selected label button is focusable, so Tab enters the group once.
`SetRadioTooltip(i, tip)`, `Enable()` (also enables the label buttons and emits `EVT_ENABLE_CHANGED`). There is no
`Rescale()`.
- **Rule:** Treat every `SetSelection(i)` as an event source.
**Why:** it always sends `wxEVT_COMMAND_RADIOBOX_SELECTED` (= `wxEVT_RADIOBOX`), for programmatic calls and for the
current index too — the opposite of `wxRadioBox::SetSelection` (`interface/wx/ctrlsub.h:102-103`). The constructor
fires one before anyone can bind.
```cpp
m_group->SetSelection(cfg.mode); // Wrong: re-enters on_mode_changed
{ m_syncing = true; m_group->SetSelection(cfg.mode); m_syncing = false; } // Right: handler returns if m_syncing
```
- **Rule:** Don't read `GetEventObject()` in its handler.
**Why:** the event carries the group's id but no event object (null).
## TextInput
`::TextInput` (`Widgets/TextInput.cpp`, `TextInput::Create`) is a `wxNavigationEnabled<StaticBox>` frame around a
real `wxTextCtrl` (on MSW the `TextCtrl` subclass in `Widgets/TextCtrl.h`, which overrides `DoMSWControlColor`).
- `text` is the initial value; `label` is a **painted side label** (`Body_12`), `icon` a 16 px SVG name
(`SetIcon`, `SetIcon_1` for a second icon, `SetStaticTips` for a grey hint line under the label).
- `style` goes to the inner control with `wxTE_PROCESS_ENTER | wxBORDER_NONE` added and alignment bits stripped;
the wrapper keeps the alignment bits and uses them to place label and icons, so typed text is left-aligned unless
you set `wxTE_RIGHT`/`wxTE_CENTRE` on `GetTextCtrl()` afterwards (alignment can change after creation on MSW, GTK
and macOS, `interface/wx/textctrl.h:1398-1399`).
- Inner font `Body_14`, inner context menu disabled (empty `wxEVT_RIGHT_DOWN` handler), tooltips forwarded
(`DoSetToolTipText`), `Enable()` also enables the inner control and recolours it.
- `TextInput::SetLabel()` changes the side label, not the text. The value API is `GetTextCtrl()->GetValue()/
SetValue()/ChangeValue()`; `SetHint`, validators and `SetMaxLength` also go on `GetTextCtrl()`.
Event routing:
- `wxEVT_TEXT` is a command event from the inner control; it propagates to the wrapper and beyond, carrying the
**inner** control's id and event object.
- `wxEVT_TEXT_ENTER` and `wxEVT_KILL_FOCUS` are caught on the inner control, `OnEdit()` runs (ComboBox resolves the
typed text there), then they are re-sent with the wrapper's id via `ProcessEventLocally`, which runs the wrapper's
own handlers but [source] never `TryAfter`, so never the parents (`src/common/event.cpp:1582-1589`; the
`interface/wx/event.h:626-651` text says it calls `TryAfter`). The event object stays the inner control.
- The internal ENTER handler does not `Skip()`: Enter in a TextInput never activates a dialog's default button
(`interface/wx/textctrl.h:1324-1331`).
- Key, char and focus-in events exist only on `GetTextCtrl()`.
Pitfalls:
- **Rule:** Bind ENTER and KILL_FOCUS on the TextInput (or `GetTextCtrl()`), never on a parent; bind TEXT on the
input without an id filter.
```cpp
dialog->Bind(wxEVT_TEXT_ENTER, &Dlg::on_enter, this, input->GetId()); // Wrong: never reaches the dialog
input->Bind(wxEVT_TEXT_ENTER, &Dlg::on_enter, this); // Right
panel->Bind(wxEVT_TEXT, h, input->GetId()); // Wrong: TEXT carries the inner id
input->Bind(wxEVT_TEXT, h); // Right
```
Cite: `TextInput::Create`; `src/common/event.cpp:1582-1589`.
- **Rule:** A handler bound on `GetTextCtrl()` for ENTER or KILL_FOCUS must `Skip()`.
**Why:** it runs before the internal handler (most-recently-bound first), which does `OnEdit()` and the re-dispatch.
- **Rule:** Don't `static_cast` the event object to `TextInput*`.
**Why:** every TextInput event's object is the inner `wxTextCtrl`.
- **Rule:** Use `ChangeValue` for model→view updates.
**Why:** `wxTextEntry::SetValue` sends `wxEVT_TEXT` (`interface/wx/textentry.h:539-542`), [source] even for
identical text (`src/common/textentrycmn.cpp:236-254`).
- **Rule:** Use a raw `wxTextCtrl` with `wxTE_MULTILINE` for multi-line text.
**Why:** `TextInput::DoSetSize` sets only the inner control's width and keeps it vertically centred at the height
it got at creation (its initial best size), so a multi-line TextInput never grows its text area. Hints on
multi-line controls are ignored except on MSW and GTK2 (`interface/wx/textentry.h:485-486`), so not on macOS or
GTK3; on macOS call `OSXDisableAllSmartSubstitutions()`
on any control holding G-code, paths or URLs (`references/controls-dataview.md §wxTextCtrl`).
## ComboBox and DropDown
`::ComboBox` (`Widgets/ComboBox.cpp`) is `wxWindowWithItems<TextInput, wxItemContainer>` plus an owned `DropDown drop`
over its `std::vector<DropDown::Item> items`, so the `wxItemContainer` API (`Append/Insert/Set/Clear/Delete/
GetCount/GetString/FindString/GetSelection/SetSelection/GetStringSelection`) works, and `GetValue/SetValue` mirror the
wxTextEntry side of `wxComboBox`. `ComboBox::SetLabel/GetLabel` are the displayed value — the opposite of
`TextInput::SetLabel` — and `SetTextLabel/GetTextLabel` reach the painted side label.
- `wxCB_READONLY` hides the text control and paints the value (font `Body_14`, focused background `#E5F0EE`): choice
semantics. Without it the combo is editable.
- Orca style flags: `CB_NO_DROP_ICON` (no arrow), `CB_NO_TEXT` (icon-only items).
- Per-item data (`DropDown::Item`): text, `icon` (list), `icon_textctrl` (shown in the closed combo),
`text_static_tips`, `data` (`void*`), `group_key`/`group_label` (a group opens a sub-dropdown), `alias`, `tip`,
`flag`, `style` = `DD_ITEM_STYLE_SPLIT_ITEM` (separator-style header), `DD_ITEM_STYLE_DISABLED` (not selectable by
click), `DD_ITEM_STYLE_DIMMED` (grey, selectable). `Append(text, bitmap, group, clientData, item_style)` overloads,
`SetItems(std::vector<DropDown::Item>)`, `SetItemTooltip/Alias/Bitmap`, `SetFlag`.
- `GetDropDown()` exposes the popup (`SetUseContentWidth(true[, limit])`, `SetAlignIcon`, colours).
`SetKeepDropArrow(true)` keeps the arrow and shows the item icon as a second icon. `ForceDropdownOpen()` opens it
programmatically (data-view editors use this).
- Opening: click (debounced by `DropDown::HasDismissLongTime()`, ≥ 20 ms since the last dismissal, so the click that
dismissed the popup does not reopen it), Enter/Space. Up/Down/Left/Right on a focused read-only combo step the
selection and send `wxEVT_COMBOBOX` (in an editable combo the keys go to the inner text control). Mouse-wheel selection is disabled (handler commented out of the event table). Scrolling an
ancestor `wxScrollHelper` hides the popup.
Events: see §Event semantics. Picking an item sends `wxEVT_COMBOBOX` even when it is already selected. Typing into
an editable combo sends `wxEVT_TEXT` per keystroke and **no** `wxEVT_COMBOBOX`: the commit is `wxEVT_TEXT_ENTER`/
`wxEVT_KILL_FOCUS` on the combo, after `OnEdit()` has matched the text to an item (exact, case-sensitive) and re-set
it (one more `wxEVT_TEXT`).
Pitfalls:
- **Rule:** Use `SelectAndNotify(n)` when listeners must react; `SetSelection(n)` is silent for `wxEVT_COMBOBOX` and
returns early when `n` is already selected.
**Why:** matches wx (`interface/wx/ctrlsub.h:102-103`), but in an **editable** combo (or one with `CB_NO_TEXT`)
`SetSelection`/`SetValue` go through `GetTextCtrl()->SetValue()` and send a propagating `wxEVT_TEXT`; guard
`wxEVT_TEXT` handlers during sync.
- **Rule:** Store only untyped `void*` client data and free it yourself.
**Why:** every `Append` overload calls `SetClientDataType(wxClientData_Void)`, and declaring them hides every base
`wxItemContainer::Append` (the `wxClientData*` and `wxArrayString` ones included); `DeleteOneItem()` bypasses the
base client-object reset. Typed `wxClientData` ownership (`references/controls-dataview.md §Item containers`) does
not apply.
- **Rule:** After `Clear()`, `Insert()`, `Set()` or `Delete()`, call `SetSelection`/`SetValue`.
**Why:** they reset the selection to -1 (`DropDown::Invalidate(true)`) but leave the shown text.
- **Rule:** Bind `wxEVT_COMBOBOX_DROPDOWN`/`_CLOSEUP` on the combo without an id filter.
**Why:** they are created with id 0 and no event object; any ancestor bound without a filter receives them from
every combo below it.
`DropDown` (`Widgets/DropDown.cpp`) standalone: a `PopupWindow` (`wxPU_CONTAINS_CONTROLS`, `wxBG_STYLE_PAINT`,
`wxBufferedPaintDC`) drawing `items`, which it holds **by reference** — the vector must outlive the popup, and
`Invalidate()` must follow any edit of it. `Popup()` on GTK sets the toplevel as transient parent explicitly (a
data-view editor can get focus before wxGTK infers one); `Dismiss()` refuses while its sub-dropdown is shown;
`OnDismiss()` sends `EVT_DISMISS` and stamps the dismissal time; `ShouldDismissOnTopWindowDeactivate()` keeps chained
dropdowns open on Wayland, where mapping a grabbing child popup deactivates the toplevel; on macOS it binds an empty
`wxEVT_IDLE` handler to stop wx's idle-time capture release/re-capture ([source] `src/common/popupcmn.cpp:108-111,
439-472`). It captures without a `HasCapture()` guard, and its capture-lost handler replays release, which can commit
the hovered item. Popup mechanics: `references/popups-menus.md`.
## SpinInput and TempInput
`SpinInput` (`Widgets/SpinInput.cpp`) is a `wxNavigationEnabled<StaticBox>` with an inner `TextCtrl` validated by
`wxTextValidator(wxFILTER_DIGITS)`, two arrow `Button`s (not keyboard-focusable) and a repeat `wxTimer`.
- `text` (if it parses) overrides `initial`; `label` is a painted side label; `style` goes to the inner control
(`wxTE_PROCESS_ENTER` added).
- `SetValue(int|wxString)` clamps to `[min, max]` and stores; `GetValue()` returns the **last committed** value, not
the text being typed; `SetRange(min, max)` stores the bounds without re-clamping; `SetStep`.
- Commits — parse, clamp, `wxEVT_SPINCTRL` — on Enter, on kill-focus and on Up/Down keys (which do not step past a
bound) only if the value changed, but on **every** arrow-button press and auto-repeat tick (while held), even when
the value is pinned at `min`/`max` (`SpinInput::createButton`, `SpinInput::onTimer`). Mouse-wheel stepping is
disabled (handler commented out of the event table).
- `EVT_SPINCTRL_TEXT` (int + string) fires on every keystroke that leaves a parseable integer.
- Enter and kill-focus are re-dispatched to the spinner with `ProcessEventLocally`, as in TextInput. Unlike
TextInput, `SpinInput::onTextLostFocus` never `Skip()`s the inner control's `wxEVT_KILL_FOCUS` (it skips a local
copy), although focus handlers should (`interface/wx/event.h:3410-3412`); a `KILL_FOCUS` handler bound on
`GetTextCtrl()` runs before it and must `Skip()` to keep the commit.
Pitfalls:
- **Rule:** Bind `wxEVT_SPINCTRL` with a `wxCommandEvent&` handler and read `GetValue()` from the spinner.
**Why:** `wxEVT_SPINCTRL` is declared with `wxSpinEvent` (`include/wx/spinctrl.h:22`) but `SpinInput::sendSpinEvent`
sends a plain `wxCommandEvent` without `SetInt`; a `wxSpinEvent&` handler reads `GetPosition()` == 0 from an object
of the wrong type.
```cpp
spin->Bind(wxEVT_SPINCTRL, [](wxSpinEvent& e) { use(e.GetPosition()); }); // Wrong
spin->Bind(wxEVT_SPINCTRL, [spin](wxCommandEvent&) { use(spin->GetValue()); }); // Right
```
- **Rule:** Use `SpinInput` only for non-negative integer ranges.
**Why:** `wxFILTER_DIGITS` rejects `-` (and `.`, `+`) (`interface/wx/valtext.h:43-52`); a negative minimum can be
reached only with the arrow buttons or Up/Down keys.
- **Rule:** Set the range before the value.
**Why:** `SetRange` does not re-clamp an existing value.
- **Rule:** Commit explicitly before reading when the user may still be typing.
**Why:** `GetValue()` is the last committed value. Dialogs sometimes call `Disable()` to force the kill-focus
commit (`CloneDialog`), which depends on the platform delivering `wxEVT_KILL_FOCUS` to the disabled focused child
— not a wx contract. Parse `GetTextCtrl()->GetValue()` and call `SetValue` yourself when it matters.
- **Rule:** Expect `SetValue` to emit `EVT_SPINCTRL_TEXT` and a propagating `wxEVT_TEXT`.
**Why:** it writes through the inner `wxTextCtrl::SetValue`.
`TempInput` (`Widgets/TempInput.cpp`, device pages) is a separate `wxNavigationEnabled<StaticBox>` with normal/active
icons, target and current temperatures (`SetTagTemp`, `SetCurrTemp`) and a warning state (`Warning(bool,
WARNING_TOO_HIGH | WARNING_TOO_LOW | WARNING_UNKNOWN)`); on commit it **posts** `wxCUSTOMEVT_SET_TEMP_FINISH` to its
**parent** (`TempInput::SetFinish`; int = the constructor's `type`, no id, no event object), so bind it on the parent
and tell inputs apart by the int. It guards re-entry with `m_on_changing`, because a handler that opens a dialog
moves focus and re-triggers the kill-focus commit — copy that guard for any commit-on-kill-focus widget whose
handler can show UI.
## Tab systems
Three distinct mechanisms; don't confuse them:
| | `TabCtrl` (`Widgets/TabCtrl.cpp`) | `Notebook` (`GUI/Notebook.hpp`) | `wxSimplebook` |
|---|---|---|---|
| What | a bare tab **bar**: one `Button` per tab in a `StaticBox` | `wxBookCtrlBase` with a `ButtonsListCtrl` header (one `Button` per tab) | standard stacked-page container, no UI |
| Content | **caller** shows/hides it | owns pages | owns pages |
| Events | `wxEVT_TAB_SEL_CHANGING` (int = old) → `wxEVT_TAB_SEL_CHANGED` (int = new), plain `wxCommandEvent`s | posted `wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED` (id = page) → `wxEVT_NOTEBOOK_PAGE_CHANGING`/`_CHANGED` | PAGE_CHANGING/CHANGED from `SetSelection` only (`interface/wx/simplebook.h:28-31`) |
| Use | settings-style category strips (Preferences) | MainFrame's main tabs (Home, Prepare, Preview, Device, …) | headerless page switching (`SelectMachine.hpp`, `ReleaseNote.cpp`, `StatusPanel`) |
`TabCtrl`: `AppendItem(text[, image, selImage, clientData])`, `AppendItem(text, bitmap)`, `DeleteItem`,
`DeleteAllItems`, `SelectItem(i)`/`Unselect()`, `GetSelection`, `SetItemText/Bitmap/Data`, `SetItemBold(i, bool)`,
`SetItemTextColour(i, StateColor)`, `SetItemIndicator(i, bool)` (dot after the label), `SetFont`, `Rescale()`.
`AssignImageList` is Orca's own method (takes ownership), not `wxWithImages`, and nothing draws from that list:
`AppendItem` ignores its `image`, `selImage` and `clientData` arguments — give icons with `AppendItem(text, bitmap)`/
`SetItemBitmap` and data with `SetItemData`. Call `SetFont` before `SetItemBold`: it derives the bold font that
`SetItemBold` applies. Selecting a tab flips the tab Buttons'
`Checked` state by sending them `wxEVT_CHECKBOX` command events (id 0, object = the tab `Button`); those propagate up
the parent chain, so an ancestor bound to `wxEVT_CHECKBOX` without an id/object filter sees them. `PreferencesDialog::
create` is the canonical usage:
```cpp
m_pref_tabs = new TabCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE | wxWANTS_CHARS);
m_pref_tabs->SetFont(Label::Body_14); // before SetItemBold
m_pref_tabs->AppendItem(_L("General")); // one per page (create_items)
m_pref_tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this](wxCommandEvent& e) {
Freeze();
const int sel = e.GetSelection(); // GetInt()
for (size_t i = 0; i < m_pref_tabs->GetCount(); ++i) {
m_pref_tabs->SetItemBold(i, int(i) == sel);
f_sizers[i]->Show(int(i) == sel); // the caller switches content
}
Layout(); Thaw();
});
StateColor item_color(std::make_pair(wxColour("#6B6B6C"), (int) StateColor::NotChecked),
std::make_pair(wxColour("#363636"), (int) StateColor::Normal));
for (size_t i = 0; i < m_pref_tabs->GetCount(); ++i) m_pref_tabs->SetItemTextColour(i, item_color);
m_pref_tabs->SelectItem(0);
```
- **Rule:** Don't try to veto a TabCtrl switch from `wxEVT_TAB_SEL_CHANGING`.
**Why:** it is a plain `wxCommandEvent` and `TabCtrl::sendTabCtrlEvent` always returns true; `SelectItem` continues.
`Notebook`: pages are inserted and addressed by a stable id (`AddPage(id, page, text, bmp_name)`,
`InsertPage(n, id, page, text, bmp_name, bSelect)`, `FindPageByName(id)`, `SelectPageByName(id)`, `GetPageName(n)`,
`GetSelectedPageName()`); a page inserted unselected is hidden at once; one window may sit under two tabs (Prepare
and Preview share the `Plater`). A header click posts `wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED` to the notebook, whose own handler calls
`SetSelection(page)`; a handler bound later (MainFrame) runs first and must `Skip()` to let the switch happen.
`SetSelection` sends the vetoable PAGE_CHANGING and PAGE_CHANGED (`interface/wx/bookctrl.h:177-178`) and hides all
other pages; `ChangeSelection` is silent (`:194-195`). `wxEVT_BOOKCTRL_PAGE_CHANGED` is the same event type as
`wxEVT_NOTEBOOK_PAGE_CHANGED` in Orca's build (`include/wx/bookctrl.h:433-434`); `Notebook` sends the
`wxEVT_BOOKCTRL_*` names.
`wxSimplebook`: switch with `ChangeSelection()` (silent) or `SetSelection()` (events); pages must be created with
the book as parent; page titles are mnemonic-interpreted (`references/controls-dataview.md §Book controls`).
## StaticBox, LabeledStaticBox, StaticLine
`StaticBox` (`Widgets/StaticBox.cpp`) as a container: a rounded, bordered group frame. API: `SetCornerRadius`,
`SetBorderWidth`, `SetBorderColor(StateColor)`/`SetBorderColorNormal`, `SetBorderStyle(wxPenStyle)`,
`SetBackgroundColor(StateColor)`/`SetBackgroundColorNormal`, `SetBackgroundColor2` (vertical gradient),
`SetTopMargin`, `ShowBadge(bool)` (corner badge bitmap), static `GetParentBackgroundColor(parent)`. `wxBORDER_NONE`
makes the border 0. Its children inherit its fill through `GetParentBackgroundColor`. Authoring widgets on it:
`references/painting-custom-widgets.md`.
`LabeledStaticBox` (`Widgets/LabeledStaticBox.cpp`) is a real `wxStaticBox` subclass, so it works as the box of a
`wxStaticBoxSizer`; it paints a rounded border (`Head_14` label in the border gap, `#DBDBDB` border, white fill)
itself (`wxBG_STYLE_PAINT` except on macOS, where `staticbox_remove_margin` is applied and the
`GetBordersForSizer` override sets the side padding other platforms use). It is not focusable. `SetCornerRadius`, `SetBorderWidth`,
`SetBorderColor(StateColor)`, `SetFont`, `Enable`. There is no `StaticGroup` class.
- **Rule:** Create the controls inside the box as children of the box.
**Why:** since 2.9.1 wx "strongly recommends" box children over siblings to avoid repaint problems
(`interface/wx/statbox.h:16-24`); `sizer->GetStaticBox()` is the parent to use.
```cpp
auto* box = new LabeledStaticBox(this, _L("Network"));
auto* sizer = new wxStaticBoxSizer(box, wxVERTICAL);
sizer->Add(new ::CheckBox(box), 0, wxALL, FromDIP(5)); // parent = box
```
- **Rule:** To change the label, recreate the box.
**Why:** the label is captured and measured in `Create()`; there is no `SetLabel` override, so `SetLabel` changes the
native text the widget never paints.
- **Rule:** Don't expect a disabled look.
**Why:** its `StateColor`s list `Normal` before `Disabled`, and the first match wins
(`references/colours-dark-mode.md §StateColor`), so the disabled colours never apply.
`StaticLine` (`Widgets/StaticLine.cpp`): horizontal or vertical separator with optional label and icon;
`SetLineColour(wxColour)`, `SetLabel`, `SetIcon`, `Rescale()`; line and text colours go through `darkModeColorFor` at
paint time. Use it instead of `wxStaticLine`.
## ProgressBar
`ProgressBar` (`Widgets/ProgressBar.cpp`) is a `wxWindow` with a rounded track and fill (MSW draws through a memory
DC + `wxGCDC` for anti-aliasing). `SetValue(step)` (re-enables after `Disable(text)`), `SetProgress(step)` (ignores
negatives), `Reset()`, `ShowNumber(bool)`, `SetRadius`, `SetHeight(h)` (sets min height and radius `h/2`), and
`Disable(wxString text)`, which draws an orange "disabled" bar with `text` and hides `wxWindow::Disable()` (name
hiding: `bar->Disable()` does not compile).
Pitfalls:
- **Rule:** Pass dark-mapped colours and re-set them on theme change.
**Why:** track, fill and text are plain `wxColour`s painted as given; nothing maps them.
```cpp
bar->SetProgressBackgroundColour(StateColor::darkModeColorFor(wxColour("#009688"))); // sets the FILL
```
- **Rule:** Mind the swapped setter names.
**Why:** `SetProgressForedColour` sets the **track** (`m_progress_background_colour`) and
`SetProgressBackgroundColour` sets the **fill** (`m_progress_colour`).
- **Rule:** Use `SetHeight(FromDIP(h))` for a bar thinner than 14 px.
**Why:** `ProgressBar::SetMinSize` returns without doing anything (width included) when the height is below its
`miniHeight` of 14 — so `SetMinSize(wxSize(w, -1))` is ignored too.
- **Rule:** Use `ShowNumber` only with `max == 100`.
**Why:** it draws the raw step followed by `%`.
- `Rescale()` is empty: re-set the height in `on_dpi_changed`.
## ScrolledWindow
`ScrolledWindow` (`Widgets/ScrolledWindow.cpp`) is a `wxScrolled<wxWindow>` that hides the native scrollbars and
draws `MyScrollbar`s (`Widgets/Scrollbar.cpp`) of `scrollbarWidth` in a `marginWidth` strip; content goes on
`GetPanel()` (the scroll target). Its internal panels are sized from the constructor `size`, so pass a real size
(`FromDIP`/`em`-based, as `Search.cpp` does). Use raw `wxScrolledWindow` for ordinary scrolling.
- **Rule:** Create it with `wxVSCROLL`.
**Why:** the mouse-wheel handler forwards to the vertical bar unconditionally and `SetBackgroundColour` touches the
vertical-bar panels unconditionally, so a `wxHSCROLL`-only instance dereferences null pointers; with neither flag
`GetPanel()` is null. With both flags only the vertical bar is built.
- `MyScrollbar` paints on a `wxClientDC` and forces `Refresh(); Update();` — do not copy its painting
(`references/painting-custom-widgets.md`).
## PopupWindow
`PopupWindow` (`Widgets/PopupWindow.cpp`) derives `wxPopupTransientWindow` and adds per-platform dismissal hooks.
Use it for every transient popup, knowing which parts are automatic:
| Port | Added behaviour |
|---|---|
| GTK (X11 and Wayland) | `Create` binds `wxEVT_ACTIVATE` on the first top-level window strictly above the parent (`GetTopParent` starts at the parent's parent, so a popup parented directly to a dialog watches the dialog's own parent); deactivation → `DismissAndNotify()` unless `ShouldDismissOnTopWindowDeactivate()` (virtual) returns false — `DropDown` uses that to keep chained popups open on Wayland |
| MSW | dismissal on toplevel deactivate/iconize/hide is **opt-in**: call `BindUnfocusEvent()` (MSW-only member); the destructor unbinds. Its activate handler does not `Skip()`, so while the popup exists the toplevel's earlier-bound `wxEVT_ACTIVATE` handlers and wx's focus save/restore (`wxTopLevelWindowMSW::OnActivate`, `src/msw/toplevel.cpp:1326`) do not run [source] |
| macOS | with `wxPU_CONTAINS_CONTROLS` it hit-tests and forwards mouse and enter/leave events to child controls (`OnMouseEvent2`); wx documents the flag as MSW focus behaviour only (`interface/wx/popupwin.h:17-26`) |
Command events from controls inside the popup stop at the popup on MSW and macOS ([source]
`src/common/popupcmn.cpp:135`) but bubble on to the popup's parent on GTK, whose `wxPopupWindow::Create` never sets
`wxWS_EX_BLOCK_EVENTS` (`src/gtk/popupwin.cpp`; `references/events.md §5`); bind them on the popup or its children,
as `ComboBox` binds `drop`'s `wxEVT_COMBOBOX`, and don't let an ancestor's unfiltered handler also act on them. When a click on the opener both
dismisses and reopens the popup, gate the reopen on `DropDown::HasDismissLongTime()` or an equivalent timestamp.
Dismissal contracts, per-port mechanics and parenting rules: `references/popups-menus.md`.
## ProgressDialog, WebView and device-page composites
- **`Slic3r::GUI::ProgressDialog`** (`Widgets/ProgressDialog.cpp`): a `wxDialog` copy of wx's generic progress dialog
with Orca styling and a `Button` for Cancel; wx-compatible API (`Update(value, msg, &skip)`, `Pulse`,
`WasCancelled`, `WasSkipped`, `Resume`, `SetRange`). `Update` yields to the event loop
(`YieldFor(wxEVT_CATEGORY_UI | wxEVT_CATEGORY_USER_INPUT)`), so handlers can re-enter; usage rules and the Jobs
alternative: `references/threads-timers-app.md`.
- **`WebView`** (`Widgets/WebView.cpp`): static helpers `CreateWebView(parent, url)`, `LoadUrl`, `RunScript`,
`CheckWebViewRuntime`, `RecreateAll()` + `EVT_WEBVIEW_RECREATED`; `WebViewHostDialog` (`Slic3r::GUI`) is the base of
Orca's web dialogs: `references/webview-gl-aui-media.md`.
- **Device-page composites** (Monitor/Device UI; reuse inside those pages, don't copy their painting as a model):
`SideButton`, `SideTools`/`SideToolsPanel` (`Slic3r::GUI`), `SidePopup` (`SideMenuPopup.hpp`), `StepCtrl`/
`StepIndicator` (`EVT_STEP_CHANGING`/`EVT_STEP_CHANGED`), `TempInput`, `ImageSwitchButton`/`FanSwitchButton`,
`AxisCtrlButton`, `FanControl` family, `AMSControl`/`AMSItem` family/`FilamentLoad`, `MultiNozzleSync` tables
(`Slic3r::GUI`), `CheckList`, `ErrorMsgStaticText`, `AnimaIcon` (`AnimaController.hpp`), `RoundedRectangle`.
## Debugging widgets
wxInspector is compiled into Debug/RelWithDebInfo builds (the top-level `CMakeLists.txt` defines
`WXINSPECTOR_DISABLE` when `BBL_RELEASE_TO_PUBLIC` is set true or, when that variable is undefined, for the Release
configuration; `references/platforms.md §wxInspector`). Every `DPIAware` window installs its accelerator
(`SetupInspectorAccelerator`, Ctrl+Shift+I, Cmd+Shift+I on macOS); the plugins in `src/slic3r/Utils/wxInspectorPlugins/`
(`CustomWidgetsPlugin`) show Button style/type, CheckBox half state, TextInput, SwitchButton, ProgressBar, Label and
LabeledStaticBox properties. The accessors commented "only meant to be used by inspector" (`Button::GetStyle`,
`CheckBox::IsHalfChecked`, `TextInput::GetCornerRadius`, `LabeledStaticBox::GetBorderColor`, …) exist for it — don't
build features on them. A later `SetAcceleratorTable` on a DPIAware window replaces the inspector's table.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,931 @@
# Platforms, the wx build, and platform-specific code
Read this when code has to differ per platform, when a bug shows up on only one OS, toolkit or display
server, or when you need to know how Orca's wxWidgets is built. It covers the wx fork and its build
options, platform macros, per-platform summaries that point into the topic files, and where
platform-specific code lives. It also owns runtime X11/Wayland detection, the Wayland gap list, custom
title bars and window decoration, GTK native-chrome removal, and the cross-platform test checklist.
Contents: [Rules](#rules) · [The wx build Orca uses](#the-wx-build-orca-uses) ·
[Platform macros and native handles](#platform-macros-and-native-handles) ·
[Per-platform summaries](#per-platform-summaries) · [The ifdef landscape](#the-ifdef-landscape) ·
[Runtime X11/Wayland detection](#runtime-x11wayland-detection) · [Wayland gaps](#wayland-gaps) ·
[Window decoration and custom title bars](#window-decoration-and-custom-title-bars) ·
[GTK native chrome and GTK size calls](#gtk-native-chrome-and-gtk-size-calls) ·
[wx 3.3 migration notes](#wx-33-migration-notes) ·
[Cross-platform testing checklist](#cross-platform-testing-checklist)
## Rules
1. Every GUI change must work on Windows (wxMSW), macOS (wxOSX/Cocoa) and Linux wxGTK3, under both
X11 and Wayland. GTK-guarded code must still compile against GTK2, but GTK2 is an opt-out build and
not something Orca ships. → [The wx build](#the-wx-build-orca-uses), [Testing](#cross-platform-testing-checklist)
2. Use the wx toolkit macros (`__WXMSW__`, `__WXOSX__`, `__WXGTK__`, `__WXGTK3__`) when the behaviour
comes from wx. Use `_WIN32` / `__APPLE__` / `__linux__` only for OS APIs and in code that is not
built against wx (`src/libslic3r`). → [Platform macros](#platform-macros-and-native-handles)
3. Guard a declaration in the header exactly as its definition is guarded in the `.cpp`, and call a
guarded helper only under the same guard. → [Platform macros](#platform-macros-and-native-handles)
4. Decide X11 vs Wayland at runtime with `is_running_on_wayland()` / `is_running_on_x11()`, and only
after GTK is initialised. Read `WAYLAND_DISPLAY` / `DISPLAY` / `GDK_BACKEND` only before GTK starts
(`CLI::run`). → [Runtime detection](#runtime-x11wayland-detection)
5. On Wayland, none of these work: global pointer coordinates, positioning top-level windows,
drawing through `wxClientDC`, `Update()`, `SetIcon`, `WarpPointer` (outside narrow conditions),
AUI floating panes, `wxUIActionSimulator`. → [Wayland gaps](#wayland-gaps)
6. Orca's wx has no SVG support and no asserts. Never call `wxBitmapBundle::FromSVG*`. Wherever the
wx docs say a call "asserts", expect a silent failure in Orca and check the precondition yourself.
→ [The wx build](#the-wx-build-orca-uses)
7. Change wx build options or patches only in `deps/wxWidgets/wxWidgets.cmake`, and make the same
change in the wxWidgets module of the Flatpak manifest. → [The wx build](#the-wx-build-orca-uses)
8. On MSW, `MainFrame` draws its own non-client area. Mask `WS_CAPTION` out of every non-client
computation, and handle `WM_NCCALCSIZE` for the maximised case yourself.
→ [MSW title bar](#msw-the-mainframe-custom-title-bar)
9. On Linux, move and resize the borderless main frame through the window manager
(`gtk_window_begin_move_drag` / `gtk_window_begin_resize_drag`). Never call `Move()` or `SetSize()`
from mouse coordinates. → [GTK frame](#linux-gtk-the-borderless-mainframe)
10. To show an undecorated top-level window on Wayland, install an empty client-side titlebar and then
call `gtk_window_set_decorated(false)`, both in the constructor, before control returns to the
event loop.
→ [Undecorated windows on Wayland](#wayland-undecorated-top-level-windows-splash)
11. Remove GTK theme borders from custom-drawn controls with `RemoveButtonBorder` / `RemoveInputBorder`
(`__WXGTK__` only). Call raw GTK size functions only with strictly positive sizes.
→ [GTK native chrome](#gtk-native-chrome-and-gtk-size-calls)
12. Put platform glue where it already lives: Cocoa code in `.mm` files listed in the `APPLE` block of
`src/slic3r/CMakeLists.txt`, Win32 messages in `MSWWindowProc` overrides, and GDK/GTK calls behind
`__WXGTK__` with the GTK header included under the same guard. → [Ifdef landscape](#the-ifdef-landscape)
13. Before fixing something "for platform X", find the wx mechanism that differs there (the
per-platform summaries point to it) and check whether the same bug class exists on the other
platforms. → [Per-platform summaries](#per-platform-summaries)
## The wx build Orca uses
### Source, pin and local patch
- `deps/wxWidgets/wxWidgets.cmake` builds `https://github.com/SoftFever/Orca-deps-wxWidgets` at tag
**`v3.3.2`** (`GIT_SHALLOW ON`, submodules `3rdparty/catch`, `3rdparty/pcre` and `3rdparty/libwebp`
only). The fork carries Orca's build fixes, the clang-cl fix among them; do not duplicate
a fork fix as a local patch under `deps/wxWidgets/` (cc390f11ee removed the local
`0001-Clang-CL-fix.patch` once the fork carried the fix). The fork's clang-cl fix is the MSVC lib-dir
selection in the installed `wxWidgetsConfig.cmake`: it looks for `<prefix>_<arch>_lib` (or `_dll`)
under the consuming compiler's prefix first and then the sibling one (`clang` ↔ `vc`), because cl
and clang-cl share an ABI and either can consume either build
(`build/cmake/wxWidgetsConfig.cmake.in:53-73`).
- **The one local patch** is `deps/wxWidgets/0001-macos-use-srgb-colour-components.patch`, applied
only `if (APPLE)`. The `PATCH_COMMAND` first runs `git checkout -f -- src/osx/cocoa/colour.mm` and
then `git apply`, so the step can run again safely (a7775296b0). The patch makes the wxOSX
`wxColour` component getters (`wxNSColorRefData::Red/Green/Blue/Alpha` and `IsSolid`) convert the
`NSColor` with `[NSColorSpace sRGBColorSpace]` instead of `NSCalibratedRGBColorSpace`, so colours
read back on macOS match their sRGB values (custom-colour accuracy). Colour usage:
`references/colours-dark-mode.md`.
- The checked-out source is the tree that every wx citation in this skill refers to:
`deps/build/<arch>/dep_wxWidgets-prefix/src/dep_wxWidgets` on macOS and
`deps/build/dep_wxWidgets-prefix/src/dep_wxWidgets` on Linux. Find it with
`find deps -maxdepth 5 -type d -path '*dep_wxWidgets-prefix/src/dep_wxWidgets'`. On macOS its
`src/osx/cocoa/colour.mm` already has the patch applied.
- **Flatpak builds wx separately.** `deps/CMakeLists.txt` leaves `dep_wxWidgets` out of the deps
target when `FLATPAK` is set. Instead, `scripts/flatpak/com.orcaslicer.OrcaSlicer.yml` has its own
`wxWidgets` module whose config-opts "mirror deps/wxWidgets/wxWidgets.cmake with FLATPAK=ON,
DEP_WX_GTK3=ON": `-DwxBUILD_TOOLKIT=gtk3`, a shared build (`wxBUILD_SHARED=ON`,
`BUILD_SHARED_LIBS=ON`, `d` debug postfix), and `wxUSE_LIBWEBP=sys`, because the builtin webp
libraries are installed only by static builds. It links with lld and pins the fork's tag
`orca-3.3.2` at a fixed commit. Option and version changes must be made in both files.
### Toolkit per platform
| Platform | wx port | How it is selected |
|---|---|---|
| Windows | wxMSW | default port; the Edge WebView backend is built only for MSVC-family compilers |
| macOS | wxOSX/Cocoa | default port; wx's exported targets add `__WXOSX_COCOA__;__WXMAC__;__WXOSX__` |
| Linux | **wxGTK3** | `deps/CMakeLists.txt` declares `option(DEP_WX_GTK3 "Build wxWidgets against GTK3" ON)` (default ON since 026499c5b7, #10294), which gives `-DwxBUILD_TOOLKIT=gtk3`. The root `CMakeLists.txt` sets `SLIC3R_GTK "3"`, so `src/CMakeLists.txt` finds wx through `wx-config --toolkit=gtk${SLIC3R_GTK}` and `src/slic3r/CMakeLists.txt` links `GTK${SLIC3R_GTK}`. Flatpak uses gtk3 too. |
**GTK2 is an opt-out, not a default.** `wxWidgets.cmake` starts from `_gtk_ver 2` and switches to 3
when `DEP_WX_GTK3` is on, so you get GTK2 only by passing `-DDEP_WX_GTK3=OFF` (and `SLIC3R_GTK=2`).
A GTK2 build loses the following (wx `build/cmake/init.cmake`, `include/wx/features.h`):
- EGL: `wxUSE_GLCANVAS_EGL` is forced OFF unless GTK3 and EGL are both found (init.cmake:559-561), and
`wxHAS_EGL` is set only on GTK3 (:531-540). No EGL means no native Wayland GL.
- WebKit2: GTK2 gets WebKit1 (init.cmake:568-569); GTK3 uses webkit2gtk-4.1 and falls back to 4.0
(:571-577).
- DIP pixels: `wxHAS_DPI_INDEPENDENT_PIXELS` is defined only for `__WXGTK3__ || __WXMAC__ || __WXQT__`
(`include/wx/features.h:115-120`), so GTK2 uses physical pixels and gets no `wxEVT_DPI_CHANGED`.
- Backend detection: Orca's `wxHAVE_GDK_*` macros are not defined, so `get_linux_display_backend()`
always returns `Unknown` ([Runtime detection](#runtime-x11wayland-detection)).
- Border removal: `RemoveButtonBorder` / `RemoveInputBorder` fall back to a global `gtk_rc` style.
`src/slic3r/CMakeLists.txt` also requires `webkit2gtk-4.1`, a GTK3 library, so a GTK2 GUI would load
GTK2 and GTK3 into one process. Treat GTK2 as a compile-compatibility target only.
wx is linked statically (`wxBUILD_SHARED=OFF`) everywhere except Flatpak. On Windows and macOS,
`src/CMakeLists.txt` uses `find_package(wxWidgets 3.3 CONFIG … propgrid)` (`propgrid` is needed by
wxInspector). On Linux it uses the `wx-config` module mode.
### Build options
The options `deps/wxWidgets/wxWidgets.cmake` passes, with the consequence each one has for GUI code:
| Option | Value | Consequence for GUI code |
|---|---|---|
| `wxBUILD_DEBUG_LEVEL` | `0` | wx asserts are compiled out; see [Debug level 0](#debug-level-0-no-wx-asserts) |
| `wxBUILD_SHARED` | `OFF` (Flatpak: `ON`) | static; private wx globals such as `wxCurrentPopupWindow` can be reached with `extern` |
| `wxBUILD_PRECOMP` / `wxBUILD_SAMPLES` | `ON` / `OFF` | build speed only |
| `wxUSE_NANOSVG` | `OFF` | no SVG in wx; see [No SVG](#no-svg-in-orcas-wx). It was disabled to avoid duplicate symbols with Orca's own NanoSVG, which carries an `nsvgRasterizeXY` extension (7658cf9076) |
| `wxUSE_GLCANVAS_EGL` | `ON` | takes effect only on GTK3 with EGL found; with both EGL and GLX built, wx picks EGL even on X11 unless `PreferGLX()` is called → `references/webview-gl-aui-media.md` §EGL vs GLX |
| `wxUSE_OPENGL` | `ON` | `wxGLCanvas` |
| `wxUSE_WEBVIEW` / `wxUSE_WEBVIEW_EDGE` / `wxUSE_WEBVIEW_IE` | `ON` / `ON` only `if (MSVC)` / `OFF` | Edge (WebView2) on Windows, WKWebView on macOS, WebKit2GTK on Linux. `wxUSE_WEBVIEW_CHROMIUM` keeps its default `OFF` (`build/cmake/options.cmake:303`), so Chromium-backend notes never apply. `wxUSE_WEBVIEW_EDGE_STATIC` keeps its default `OFF` (`build/cmake/options.cmake:514`), so the root `CMakeLists.txt` ships `WebView2Loader.dll` from `deps/WebView2/lib/win-<arch>` next to the executable → `references/webview-gl-aui-media.md` §wxWebView backends |
| `wxUSE_WEBREQUEST` | `ON` | `wxWebSession`/`wxWebRequest` are available (used only for a few image downloads) |
| `wxUSE_MEDIACTRL` | `ON` | kept for `wxMediaState`; the camera view is Orca's `wxMediaCtrl3`, not a `wxMediaCtrl` |
| `wxUSE_PRIVATE_FONTS` | `ON` | `wxFont::AddPrivateFont` for the bundled fonts → `references/dpi-bitmaps-fonts.md` |
| `wxUSE_AUI` | `ON` | Plater docking and `BBLTopbar` (a `wxAuiToolBar`) |
| `wxUSE_STC` | `OFF` | no `wxStyledTextCtrl` |
| `wxUSE_DETECT_SM` | `OFF` | no X11 session-manager detection |
| `wxUSE_REGEX` | `builtin` | — |
| `wxUSE_LIBPNG` / `ZLIB` / `LIBJPEG` / `EXPAT` | `sys` (from the deps tree) | — |
| `wxUSE_LIBTIFF` | `OFF` | no TIFF image handler |
| `wxUSE_LIBWEBP` | `builtin` (Flatpak: `sys`) | WebP image handler is available |
| `wxUSE_LIBSDL` / `wxUSE_XTEST` | `OFF` | no SDL audio backend; `wxUIActionSimulator` on X11 uses its non-XTest path (`src/unix/uiactionx11.cpp`) |
Options left at wx defaults that matter: LunaSVG is off (`wxUSE_LUNASVG 0` in the installed
`setup.h`), `wxUSE_STD_CONTAINERS 1`, `WXWIN_COMPATIBILITY_3_0 0`, and `WXWIN_COMPATIBILITY_3_2 1`.
On macOS `wxUSE_NATIVE_DATAVIEWCTRL` is 1 (`references/controls-dataview.md`).
### Debug level 0: no wx asserts
wx is built with `-DwxBUILD_DEBUG_LEVEL=0`, which `build/cmake/init.cmake:245-246` turns into
`-DwxDEBUG_LEVEL=0`. `src/slic3r/CMakeLists.txt` also adds `wxDEBUG_LEVEL=0` to `libslic3r_gui` when
`SLIC3R_STATIC`. The Flatpak module and wxInspector use level 0 as well. At level 0, `wxASSERT`,
`wxFAIL` and `wxTrap` "do nothing at all", while "wxCHECK macros always check their conditions,
setting debug level to 0 only makes them silent in case of failure" (`include/wx/debug.h:229-231`,
`:342-382`).
In practice, misuse that a debug wx would report shows up in Orca as a silent no-op, an early return
or a wrong result: for example a second `ReleaseMouse`, a late `PreferGLX()`, a second `Destroy()` on
a transient popup, a window added to a second sizer, `SetCurrent` on a hidden GL canvas, or a
duplicate AUI pane name. Write "wx would assert in a debug build; in Orca it silently …". When the
wx docs state a precondition, check it in your own code.
### No SVG in Orca's wx
`wxHAS_SVG` is defined only when `wxHAS_RAW_BITMAP && (wxUSE_NANOSVG || wxUSE_LUNASVG)`
(`include/wx/features.h:96-97`), and Orca builds with both off. So `wxBitmapBundle::FromSVG`,
`FromSVGFile` and `FromSVGResource` do not exist (`include/wx/bmpbndl.h:81-101`), using them is a
compile error, the Tango art provider returns empty bundles (`src/common/arttango.cpp`, `!wxHAS_SVG`
branch), and wx's AUI tab and dock-art buttons use their non-SVG bitmap fallbacks
(`src/aui/tabart.cpp:92-137`, `src/aui/dockart.cpp:87-128`) [source]. Orca rasterises SVG with its own
NanoSVG in `BitmapCache::load_svg` (through `create_scaled_bitmap` / `ScalableBitmap`) →
`references/dpi-bitmaps-fonts.md`.
### Private headers
The wx 3.3 CMake install does not copy `wx/private`. The `copy_private_headers` step in
`wxWidgets.cmake` runs after install and copies `include/wx/private`, `include/wx/generic/private`
and `include/wx/gtk/private` to `include/wx` (MSVC) or `include/wx-3.3/wx` (elsewhere). The cmake
comment calls this "for accessibility support". The actual consumers are:
- `Widgets/WebView.cpp`: `wx/private/jsscriptwrapper.h` (Windows and macOS only).
- `ExtraRenderers.cpp`: `wx/generic/private/{markuptext,rowheightcache,widthcalc}.h`, under
`wxHAS_GENERIC_DATAVIEWCTRL`, so MSW only.
- `ExtraRenderers.cpp`: `wx/private/markupparser.h`, under `wxUSE_ACCESSIBILITY`.
Its `wx/gtk/private*` includes are commented out. The per-port headers outside those directories,
`wx/msw/private.h` (`BitmapComboBox.cpp`, `PresetComboBoxes.cpp`, Windows-guarded) and
`wx/osx/private.h` (`Utils/MacDarkMode.mm`), come with the regular wx install. Private headers are
port-specific and unversioned. Include one only under the same macro wx uses for that port or
feature, and re-check it whenever the fork is bumped.
### wxInspector
The deps also build wxInspector (`deps/wxInspector/wxInspector.cmake`, compiled with
`-DwxDEBUG_LEVEL=0`). `DPIAware<P>` derives from `wxInspector::wxInspectable`, and its constructor
calls `SetupInspectorAccelerator(this)`. That calls `SetAcceleratorTable` on the window with
`wxACCEL_CTRL | wxACCEL_SHIFT` + `I` (Cmd+Shift+I on macOS), which toggles an inspection frame showing
the window tree (`src/inspector.cpp` in the wxInspector tree); a later `SetAcceleratorTable` call on a
`DPIAware` window replaces the inspector shortcut. `GUI_App::on_init_inner` registers Orca plugins for it
(`RegisterOrcaInspectorPlugins`, `Utils/wxInspectorPlugins/`). The root `CMakeLists.txt` defines
`WXINSPECTOR_DISABLE` when `BBL_RELEASE_TO_PUBLIC` is set true, or, when that variable is not defined,
for the Release configuration; this turns the whole API into no-op stubs. Use it in
Debug/RelWithDebInfo builds to inspect layouts on each platform.
## Platform macros and native handles
| Macro | Defined when | Use for |
|---|---|---|
| `__WXMSW__` | wxMSW build | wx behaviour on Windows, `MSWWindowProc`, MSW-only wx API |
| `__WXOSX__` (also `__WXMAC__`, `__WXOSX_COCOA__`) | wxOSX build | Cocoa-specific wx behaviour |
| `__WXGTK__` | any wxGTK build | GTK/GDK calls, Linux toolkit behaviour |
| `__WXGTK3__` | GTK ≥ 3.0 | GTK3-only API (CSS providers, DPI events, Wayland) |
| `__WXGTK20__` | GTK ≥ 2.0, **also defined in GTK3 builds** (`build/cmake/setup.cmake:61-72` defines every version macro up to the toolkit version) | `defined(__WXGTK20__) \|\| defined(__WXGTK3__)` in `GUI_App.cpp` is the same as `__WXGTK__` |
| `__WINDOWS__` | defined by wx when `_WIN32`, `__WIN32__` or `__WXMSW__` is defined (`include/wx/platform.h:87-91`) | wx's own Windows checks; Orca's dark-mode code uses it |
| `_WIN32`, `__APPLE__`, `__linux__` | compiler | OS APIs (Win32, Cocoa frameworks, `/proc`), and all of `src/libslic3r` |
| `wxHAS_EGL`, `wxHAS_GLX` | wx `setup.h` (`build/cmake/setup.h.in:1144-1147`) when built with EGL / GLX | GL backend code; test them only after a wx header is included |
| `wxHAVE_GDK_WAYLAND`, `wxHAVE_GDK_X11` | Orca's `cmake/modules/FindGTK3.cmake` (`check_symbol_exists(GDK_WINDOWING_WAYLAND/X11 "gdk/gdk.h" …)`), passed by `src/slic3r/CMakeLists.txt` as PRIVATE definitions of `libslic3r_gui` | only `LinuxDisplayBackend.cpp` needs them; wx headers do not define them |
| `GTK_CHECK_VERSION(a,b,c)` | GTK headers, compile time | branching on GTK API version. `gtk_check_version()` and wx's internal `wx_is_at_least_gtk3(n)` check the runtime version |
The toolkit macros come from wx's compile definitions: `wxTOOLKIT_DEFINITIONS` in
`build/cmake/toolkit.cmake:57-87,157`, exported through the CMake targets and through
`wx-config --cxxflags`. They are therefore defined in every `libslic3r_gui` source, even before the
first wx include; `LinuxDisplayBackend.hpp` relies on this. `src/libslic3r` does not link wx and uses
none of them. Prefer the toolkit macro whenever the difference comes from wx: a later toolkit change
(for example wxGTK on another OS) then keeps the right branch.
**Native handles.** `wxWindow::GetHandle()` returns `WXWidget`:
- wxMSW: the `HWND` (`include/wx/msw/window.h:169`).
- wxOSX: the peer's `NSView*`. Reach the `NSWindow` with `[view window]`, as
`set_miniaturizable(GetHandle())` does.
- wxGTK: `m_widget` (`include/wx/gtk/window.h:140`). For a top-level window this is the `GtkWindow`.
`MainFrame`, `BBLTopbar` and `DropDown` also use `m_widget` directly; wxGTK declares it in a
`public:` implementation block (`include/wx/gtk/window.h:291`), so other classes can read another
window's `m_widget` (`m_frame->m_widget`).
Native calls on these handles bypass wx's bookkeeping. Keep them minimal, guard them with the toolkit
macro, and prefer an existing helper (`GUI_Utils`, `MacDarkMode.mm`, `GUI_UtilsMac.mm`) over new
inline native code.
- **Rule:** Keep the guard on a declaration identical to the guard on its definition (one toolkit
guard, version branches with `GTK_CHECK_VERSION` inside the definition), and include GTK headers
under the same guard as the code that uses them.
**Why:** a header declaring under `__WXGTK3__` while the `.cpp` defines under `__WXGTK__` (or the
reverse) breaks the build on the other GTK configuration or leaves an undefined symbol. The
wrong → right shape is under [GTK native chrome](#gtk-native-chrome-and-gtk-size-calls).
Cite: 477208a969 (`GUI_Utils.hpp` / `GUI_Utils.cpp`).
## Per-platform summaries
Each bullet names the mechanism and the file that owns it.
### MSW (wxMSW)
- **Pixels and DPI:** logical pixels equal physical pixels and `FromDIP` really scales. Per-monitor
DPI change events need the PMv2 manifest (`src/dev-utils/platform/msw/OrcaSlicer.manifest.in`
declares `permonitorv2,permonitor`); under the `permonitor` (V1) fallback on older Windows wx
generates no DPI events, because it accepts only PMv2 [source: `src/msw/nonownedwnd.cpp`
`IsPerMonitorDPIAware`]. wx rescales min sizes, fonts and sizer borders before
`wxEVT_DPI_CHANGED` [source], and `DPIAware` does not `Skip()` it. `wxEVT_MOVE_START/END` exist only on MSW
(DPIAware uses them to defer rescaling while a window is dragged) → `references/dpi-bitmaps-fonts.md`.
- **Dark mode:** `MSWEnableDarkMode(DarkMode_Auto)` runs before `NppDarkMode::InitDarkMode()`. After
that, `IsDark()` reports the OS apps setting, so `dark_color_mode` is consulted first. The runtime
dark-mode toggle is Windows-only. Menu bitmaps choose dark variants through `check_dark_mode()` →
`references/colours-dark-mode.md`.
- **Popups:** the current popup is the wx-internal global `wxCurrentPopupWindow`. Focus changes and
clicks outside dismiss popups that lack `wxPU_CONTAINS_CONTROLS` (`MSWDismissUnfocusedPopup`);
popups with it are dismissed on deactivation, deferred through `CallAfter`. No key dismisses a
popup, and `ProcessLeftDown` is never called. `PopupWindow::BindUnfocusEvent` is MSW-only →
`references/popups-menus.md` §5.
- **Painting:** in 3.3.2 windows are not double-buffered by default (the 3.3.0 global
`WS_EX_COMPOSITED` was reverted, `docs/changes.txt:308`). Custom widgets buffer by hand →
`references/painting-custom-widgets.md`.
- **Modal loops:** idle events do not run inside the Windows sizing/moving modal loop, so the 3D
canvas renders from `on_paint` on MSW (c06a0223a7) → `references/webview-gl-aui-media.md` §GLCanvas3D rendering.
- **Mouse capture:** `wxEVT_MOUSE_CAPTURE_LOST` and `wxEVT_MOUSE_CAPTURE_CHANGED` are delivered →
`references/mouse-keyboard-focus.md`.
- **Controls:** `wxDataViewCtrl` is the generic implementation (`references/controls-dataview.md`).
TaskDialog-based dialogs and common dialogs stay light in wx dark mode
(`interface/wx/app.h:1434-1448`); Orca's `MsgDialog` family is owner-drawn →
`references/windows-dialogs.md`.
- **WebView:** Edge (WebView2). It needs the runtime (checked by `GUI_App::init_webview_runtime`),
creates asynchronously, serves custom schemes as `https://<scheme>.wxsite`, and allows one script
handler → `references/webview-gl-aui-media.md`.
- **Window frame:** custom title bar and non-client handling → [MSW title bar](#msw-the-mainframe-custom-title-bar).
### macOS (wxOSX/Cocoa)
- **Pixels and DPI:** logical pixel = DIP = point. The standard PPI is 72, so `GetDPI()` and
`GetNewDPI()` are 72-based. `wxEVT_DPI_CHANGED` is generated on backing-scale changes even though
the docs don't say so [source: `src/osx/cocoa/nonownedwnd.mm` `windowDidChangeBackingProperties`],
but `DPIAware` binds it only off macOS. `DPIAware` and
`MainFrame::init_tabpanel` skip `SetFont` on macOS ("name cutting in ObjectList") →
`references/dpi-bitmaps-fonts.md`.
- **Menus and keys:** there is a native `wxMenuBar`, and Preferences goes into `OSXGetAppleMenu()`.
Menu key equivalents run before `wxEVT_CHAR_HOOK`, display-only shortcut text uses `" - "`,
`wxMOD_CONTROL` means Cmd, Ctrl+click arrives as a right click [source], and Cmd+letter char events
carry the plain letter [source] → `references/mouse-keyboard-focus.md`, `references/popups-menus.md`.
- **Mouse capture:** capture is a wx-level redirect of every left/right button, motion and
enter/exit event (not wheel or middle-button events), and capture-lost is never sent [source]. A
leaked capture looks like a frozen UI whose keyboard still works →
`references/mouse-keyboard-focus.md`.
- **Popups:** a transient popup toggles mouse capture on idle (since 3.1.7) and dismisses on an
outside click [source]. Since the 3.3 upgrade a hover-opened popup anchored with a gap below its
opener was dismissed as the cursor crossed the gap (#12936); the wx mechanism was not established,
and the fix is to anchor flush → `references/popups-menus.md` §3.
- **Controls:** the native `wxDataViewCtrl` (NSOutlineView) never calls `CreateEditorCtrl`, and the
current item is always selected (`references/controls-dataview.md`). `wxClientDC` cannot draw
(`references/painting-custom-widgets.md`).
- **Colours:** Orca's sRGB `wxColour` patch. Dark mode follows the system only (`mac_dark_mode()`) →
`references/colours-dark-mode.md`.
- **WebView:** WKWebView. Handlers must be registered before `Create`, and adding the same script
handler twice raises an uncatchable NSException → `references/webview-gl-aui-media.md`.
- **Window frame:** a native titled window with a transparent titlebar →
[macOS frame](#macos-a-native-titled-window).
### GTK3 (X11 and Wayland)
- **Pixels and DPI:** logical pixel = DIP, and the scale is an integer ("fractional scales are rounded
to the closest integer"). `wxEVT_DPI_CHANGED` needs GTK ≥ 3.10 and wx ≥ 3.3.0, so `DPIAware`'s
rescale path runs on Linux. `get_dpi_for_window()` is a fixed-96 stub on Linux (and macOS), so
`em_unit` is measured from the font (`DPIAware::update_em_unit`) →
`references/dpi-bitmaps-fonts.md`.
- **Dark mode:** follows the system appearance. `GUI_App::on_init_inner` (non-Windows) and
`update_dark_config` (called from `DPIAware`'s `wxEVT_SYS_COLOUR_CHANGED` handler off Windows)
overwrite `dark_color_mode` from `wxSystemSettings::GetAppearance().IsDark()` →
`references/colours-dark-mode.md`.
- **Sizing:** sizer-fitting calls made on a top-level window that is not yet shown are replayed at
`Show()` (`wxWindow::Fit()` is not), and dialogs collapse without size hints →
`references/sizers-layout.md`.
- **Native chrome:** GTK theme borders and padding show through custom-drawn controls →
[GTK native chrome](#gtk-native-chrome-and-gtk-size-calls).
- **Popups:** `Show()` grabs the pointer (`gdk_seat_grab`) [source]. `PopupWindow` dismisses when the
top-level window is deactivated, and popups are created with `GDK_WINDOW_TYPE_HINT_COMBO` [source] →
`references/popups-menus.md`.
- **Mouse capture:** a grab-broken event or a modal dialog delivers `wxEVT_MOUSE_CAPTURE_LOST`
[source; the docs mark the event MSW-only] → `references/mouse-keyboard-focus.md`.
- **Controls:** `wxDataViewCtrl` is the native GtkTreeView (`references/controls-dataview.md`). `Field`
control pools delete windows on GTK instead of recycling them (`references/orca-settings-ui.md`).
- **WebView:** WebKit2GTK delivers script messages synchronously, with an empty handler name and no
event object, and navigation events synchronously too [source] → `references/webview-gl-aui-media.md`.
- **GL:** EGL or GLX. Orca calls `PreferGLX()` on X11 → `references/webview-gl-aui-media.md` §EGL vs GLX.
- **App init:** `GUI_App::on_init_inner` sets `gtk-menu-images` to TRUE so menu icons show, and
installs a `g_log_set_handler("Gtk", G_LOG_LEVEL_CRITICAL, …)` filter. The filter drops known
harmless criticals (allocation on hidden widgets, events on unrealised widgets, style-context calls
before realisation), so GTK criticals not on that list still reach the log.
- **Window frame:** borderless with WM-driven move and resize → [GTK frame](#linux-gtk-the-borderless-mainframe).
### GTK2 (opt-out build)
Compile-compatibility only. See [Toolkit per platform](#toolkit-per-platform) for what it loses: EGL,
WebKit2, DIP pixels, DPI events and backend detection. GTK-guarded code must still compile here:
use `GTK_CHECK_VERSION` branches as `RemoveButtonBorder` does. No GTK2 runtime behaviour is supported.
### Wayland (GTK3 native backend)
The protocol gives clients no global pointer or window positions, and the compositor places
top-level windows. GL is EGL only, drawn into a subsurface. Popups are `xdg_popup` surfaces and must
form a chain of parents. Window icons come from the `.desktop` file. The full list is under
[Wayland gaps](#wayland-gaps). These Wayland rules are owned by other files:
- hover handlers must short-circuit, because on compositors that keep hidden-workspace surfaces
mapped (e.g. Hyprland) GTK sends a stream of synthetic leave events and `IsShownOnScreen()` stays
true there (69e16cd7ef) →
`references/mouse-keyboard-focus.md`;
- GL blending must keep destination alpha at 1 (d8369e5f75);
- GL post-init must retry until the surface is committed (d2c24fdabb) →
`references/webview-gl-aui-media.md`.
### XWayland (GTK3 X11 backend inside a Wayland session)
Users opt in with `GDK_BACKEND=x11…`. GTK then talks X11, so `is_running_on_x11()` is true and Orca
uses GLX through `PreferGLX()`. `CLI::run` prepares this path before GTK starts (PRIME variables,
`XInitThreads()`, no WebKit compositing workaround; the source comment says multi-monitor handling
is compromised there) → [Runtime detection](#runtime-x11wayland-detection),
`references/webview-gl-aui-media.md` §WebKitGTK on Linux sessions. Intel's XWayland GL exposes a
smaller `GL_MAX_TEXTURE_SIZE`, so the ImGui font atlas is re-packed to fit (22e121f4e4) →
`references/webview-gl-aui-media.md`.
## The ifdef landscape
Platform-specific GUI code falls into recurring categories. Most of it sits in `MainFrame`,
`GUI_App`, `GLCanvas3D`, `GUI_ObjectList`, `Plater`, `wxExtensions`, `Field` and `Widgets/AMSItem`;
start there when looking for prior art. The sites below are exemplars, cited by symbol.
**Focus, capture and popup dismissal** (the largest category) → `references/popups-menus.md`,
`references/mouse-keyboard-focus.md`
- `StatusPanel::on_switch_speed`: on `__WXOSX__` the speed popup gets a `nullptr` parent (the source
comment says "MacOS has focus problem"); elsewhere the parent is the control.
`popUp->BindUnfocusEvent()` runs only under `__WXMSW__` and binds the top parent's
`wxEVT_ACTIVATE` / `wxEVT_ICONIZE` / `wxEVT_SHOW` to `Dismiss()`.
- `GLCanvas3D::on_mouse`, `evt.Entering()` branch: on MSW the canvas does not `SetFocus()` while
`wxCurrentPopupWindow` is non-null. Stealing focus would trigger `MSWDismissUnfocusedPopup` and
close the search dropdown. `wxCurrentPopupWindow` is a wx-internal global (`src/msw/popupwin.cpp`)
that `GLCanvas3D.cpp` declares `extern` itself, which works only because wx is linked statically.
`SearchDialog` and `SearchObjectDialog` override the virtual `MSWDismissUnfocusedPopup`.
- `GLCanvas3D::on_mouse`: the MSW "on_enter workaround" (comment "SPE-832") handles a spurious mouse
event that arrives before `evt.Entering()`; `m_mouse.position` is reset at the end of the function.
- `SearchObjectDialog::Popup`: on `__WXOSX__`, focus moves to `m_object_list` before
`PopupWindow::Popup`, otherwise the text input becomes unusable.
- `SearchDialog::Dismiss`, `SearchObjectDialog::Dismiss`: on Wayland they dismiss by focus tracking
(`focus_left_popup(...)`) instead of hit-testing `wxGetMousePosition()`.
- `PopupWindow::Create`: on GTK it binds the top-level `wxEVT_ACTIVATE` to `topWindowActiavate` →
`DismissAndNotify()`, gated by the virtual `ShouldDismissOnTopWindowDeactivate()`, which `DropDown`
overrides for Wayland popup chains. On `__WXOSX__` with `wxPU_CONTAINS_CONTROLS`,
`PopupWindow::OnMouseEvent2` hit-tests children, re-dispatches mouse events and synthesises
enter/leave.
- `SidePopup::Popup` (`Widgets/SideMenuPopup.cpp`): on `__APPLE__` the menu is anchored flush against
the button with a slight overlap. Since the wx 3.3 upgrade, the transient popup was dismissed as
soon as the cursor entered the gap (#12936, 9a053f15eb). The wx mechanism was not established
(`references/popups-menus.md` §3): [source] `wxPopupTransientWindow::OnIdle` captures the mouse
whenever the cursor is outside the popup rect and releases it inside
(`src/common/popupcmn.cpp:438-471`, a 3.1.7 addition), and `wxPopupWindowHandler::OnLeftDown`
dismisses only on an outside click (`popupcmn.cpp:536+`), so neither explains a dismissal on hover.
**Menu bar and accelerators** → `references/mouse-keyboard-focus.md`, `references/popups-menus.md`
- `MainFrame::MainFrame`: `#ifndef __APPLE__` creates `m_topbar = new BBLTopbar(this)`, a
`wxAuiToolBar` with `BBLTopbarArt : wxAuiDefaultToolBarArt`. macOS gets a plain `wxPanel` top area
plus the native `wxMenuBar` that `MainFrame::init_menubar_as_editor` sets with `SetMenuBar`.
Preferences is added with `append_shortcut_item(..., Shortcut::Preferences, ...)` into
`OSXGetAppleMenu()` on macOS and into `m_topbar->GetTopMenu()` elsewhere.
- `MainFrame::shortcut_label`: items registered with `accelerator=true` whose binding is menu-safe
(`ShortcutRegistry::accelerator()` non-empty) get `"\t" + accelerator`. That is a live key
equivalent only in the macOS `wxMenuBar`; the Windows/Linux menus are popped up from `BBLTopbar`,
where accelerators are display-only and the registry dispatches the keys. Display-only shortcut
text uses the static `sep`, which is `" - "` on macOS and `"\t"` elsewhere, because the native menu
bar would otherwise grab keys that must reach text fields (#8152).
- `KeyChord::from_event` normalises char events: control codes 1–26 with `ControlDown()` become
`'A'..'Z'`, and lowercase becomes uppercase. Match shortcuts through it, never through raw char
codes for Ctrl/Cmd+letter. [source] `wxOSXTranslateCocoaKey` produces `WXK_CONTROL_A+n` only when
the physical Ctrl is held (`src/osx/cocoa/window.mm:305-307`), so a Cmd+letter char event carries
the plain letter. Control-code translation is documented at `interface/wx/event.h:1408-1421`.
- `MainFrame::MainFrame`, `wxEVT_CHAR_HOOK` lambda under `__APPLE__`: Cmd+H is swallowed, Cmd+M calls
`Iconize()`, Cmd+Q posts `wxEVT_CLOSE_WINDOW`, and Cmd+Ctrl+F calls `EnableFullScreenView(true)` and
toggles `ShowFullScreen` (`interface/wx/toplevel.h:700-728`, OSX only). Everything else goes to
`handle_global_shortcut(KeyChord::from_event(evt))`. Cmd+, is the registry's
`Shortcut::Preferences`, not part of the hook.
- `ObjectList::update_shortcut_accelerators`: the native macOS data view gets no key events, so on
macOS a `wxAcceleratorTable` is generated from the shortcut registry.
**Window decoration / custom title bar** → [Window decoration](#window-decoration-and-custom-title-bars)
- `MainFrame::MainFrame`: `set_miniaturizable` (OSX); `m_gdkDecor = 0` and three `ResizeEdgePanel`s
(GTK); the `WS_CAPTION` strip (MSW). `MainFrame::MSWWindowProc` and `AdjustWorkingAreaForAutoHide`
(MSW). `BBLTopbar::OnMouseLeftDown`, `BBLTopbar::OnFullScreen`, `BBLTopbar::MSWWindowProc`.
`SplashScreen::SplashScreen` (Wayland).
**Fonts, sizes, Retina** → `references/dpi-bitmaps-fonts.md`
- The `DPIAware` constructor and `MainFrame::init_tabpanel`: `#ifndef __WXOSX__` around `SetFont`,
"to avoid name cutting in ObjectList".
- `OG_CustomCtrl::CtrlLine::draw_text`: works around the Big Sur bold-font issue. Focused URL labels
are drawn underlined instead of bold and underlined.
- `SwitchButton::Rescale`: on `__WXOSX__` the measuring font is scaled by `mac_max_scaling_factor()`.
That helper reads screen 0's backing factor (`[[NSScreen screens] objectAtIndex:0]`, the menu-bar
screen) on every loop iteration, so despite its name it returns that screen's factor (at least 1),
not the maximum over all screens.
- `BitmapComboBox` (comment block under `#ifdef __APPLE__` in `BitmapComboBox.hpp`): the bitmap
`scale` argument means "the image is already sized for that backing scale". The `scale` parameter
of `wxBitmap(const wxImage&, int depth, double scale)` is not in the interface docs. It exists on
every port (`include/wx/osx/bitmap.h:115`, `include/wx/gtk/bitmap.h:77`), but wxMSW ignores it
(`include/wx/msw/bitmap.h:68`). The portable APIs are `CreateWithDIPSize` and `SetScaleFactor`
(`interface/wx/bitmap.h:491-520, 966`).
**GTK / Wayland** → this file, `references/webview-gl-aui-media.md`, `references/popups-menus.md`
- `CLI::run` (`src/OrcaSlicer.cpp`): sets backend-related environment variables before GTK starts.
- `GUI_App::on_init_inner`: calls `wxGLCanvas::PreferGLX()` when `is_running_on_x11()`, under
`#if defined(__WXGTK__) && wxHAS_EGL`.
- `OpenGLManager::detect_multisample`: on Wayland without `wxHAS_EGL` it skips `IsDisplaySupported()`,
which would go through GLX and crash on a missing X11 display. Multisampling is also off on ChromeOS
(`PlatformFlavor::LinuxOnChromium`).
- `OpenGLManager::init_gl`: loads GLAD through `eglGetProcAddress` on Wayland.
- `DropDown::messureSize`: positive-size `gtk_window_resize` on the GTK wrapper window, plus an idle
poll that synthesises `mouseMove` on the main dropdown while a submenu holds the grab (Mutter drops
motion events outside the grabbing surface). `DropDown::mouseMove` sets the submenu
`gtk_window_set_transient_for` to the mapped main popup at show time. `DropDown::Popup` gives
data-view cell editors an explicit top-level transient parent.
`DropDown::ShouldDismissOnTopWindowDeactivate` handles Wayland chains.
- `CheckBox::CheckBox`, `SwitchButton`, `RadioBox`, `ScalableButton` (`wxExtensions.cpp`),
`ObjColorDialog`, `PresetComboBoxes` call `RemoveButtonBorder`; `TextInput` and `SpinInput` call
`RemoveInputBorder` on their inner `wxTextCtrl`.
- `Plater::priv::priv` together with `sanitize_window_layout_for_wayland`: AUI floating is disabled on
Wayland.
- `GUI_App::window_pos_restore`: skips `SetPosition` on Wayland.
- `BBLTopbar::OnMouseLeftDown` / `OnMouseMotion` convert event coordinates with `ClientToScreen`
instead of calling `wxGetMousePosition()`. `BBLTopbar::FindToolByCurrentPosition` returns null on
Wayland when the last event position is unknown or outside the bar.
- `LinuxDisplayBackend.{hpp,cpp}`: runtime X11/Wayland detection.
**Windows rendering and dark mode** → `references/colours-dark-mode.md`,
`references/painting-custom-widgets.md`
- `_MSW_DARK_MODE` is `#define`d to 1 **on every platform** (`GUI_App.hpp`), so it is not a platform
gate. The MSW-only calls inside those blocks sit under `__WINDOWS__` / `_WIN32`.
`dark_mode.cpp/.hpp` and `dark_mode/{dark_mode,IatHook,UAHMenuBar}.hpp` (the vendored Notepad++
dark mode, `NppDarkMode`) are compiled only under `if (WIN32)` in `src/slic3r/CMakeLists.txt`.
`SUPPORT_DARK_MODE` (`libslic3r/AppConfig.hpp`) gates `GUI_App::dark_mode`.
- `AMSExtText::render`: on `__WXMSW__` it blits the paint DC into a bitmap and renders through a
`wxGCDC` over the `wxMemoryDC` (GDI+ anti-aliasing), then `DrawBitmap`s the result. Other ports call
`doRender(dc)` directly. `StaticBox::render` uses a variant (rounded boxes only) that clears the
bitmap with the background colour instead of blitting. This is the recurring MSW pattern in custom
widgets.
- `GLCanvas3D::on_paint`: renders immediately on MSW (c06a0223a7).
**Native data view** → `references/controls-dataview.md`
- `GUI_ObjectList.cpp` has `__WXOSX__` editing and model paths, because the native macOS control never
calls a custom renderer's `CreateEditorCtrl`.
**macOS Objective-C++ glue** — `.mm` files listed in the `APPLE` block of `src/slic3r/CMakeLists.txt`,
for example:
- `Utils/MacDarkMode.mm`: `mac_dark_mode`, `mac_max_scaling_factor`, `set_miniaturizable`,
`set_title_colour_after_set_title`, the `WKWebView_*` helpers, `initGestures`.
- `GUI/GUI_UtilsMac.mm`: `dataview_remove_insets`, `staticbox_remove_margin`,
`set_window_corner_radius`, declared under `__WXOSX__` in `GUI_Utils.hpp`.
- `GUI/DeepLinkHandlerMac.mm`, `GUI/InstanceCheckMac.mm`, `GUI/Mouse3DHandlerMac.mm`,
`GUI/RemovableDriveManagerMM.mm`, `Utils/RetinaHelperImpl.mm`. (`libslic3r/MacUtils.mm` is registered
in `src/libslic3r/CMakeLists.txt` instead.)
Trackpad gestures: `GLCanvas3D::bind_event_handlers` calls the portable
`EnableTouchEvents(wxTOUCH_ZOOM_GESTURE | wxTOUCH_ROTATE_GESTURE)` plus
`initGestures(m_canvas->GetHandle(), m_canvas)` for the pan recogniser, and `unbind_event_handlers`
calls `initGestures(..., nullptr)`. Deep links: `GUI_App::on_init_inner` → `register_mac_deep_link_handler()`
re-registers the `kAEGetURL` handler after wx installs its own (1f2ed70288, #13119).
**WebView and media** → `references/webview-gl-aui-media.md`
- `WebView::CreateWebView`: `WebViewEdge` on Windows, `WebViewWebKit` on macOS, `wxWebView::New()` on
Linux.
- Camera: `wxMediaCtrl3`, a plain `wxWindow` that decodes on a worker thread and paints a `wxBitmap`
frame on Win32 and a `wxImage` frame elsewhere. No per-platform native player sits
behind it; new camera sources implement `IMediaController` (97955dbab8 and 7e3724b5f3 removed the
`wxMediaCtrl2.cpp/.mm` players).
## Runtime X11/Wayland detection
**API** (`src/slic3r/GUI/LinuxDisplayBackend.hpp`, declared only `#if defined(__WXGTK__)`, namespace
`Slic3r::GUI`):
```cpp
enum class LinuxDisplayBackend { X11, Wayland, Unknown };
LinuxDisplayBackend get_linux_display_backend(); // "Must be called after gtk_init() / wxWidgets initialization."
bool is_running_on_wayland();
bool is_running_on_x11();
```
**Mechanism.** `get_linux_display_backend()` tests `gdk_display_get_default()` with
`GDK_IS_WAYLAND_DISPLAY` and then `GDK_IS_X11_DISPLAY`. Each test is compiled only when
`wxHAVE_GDK_WAYLAND` / `wxHAVE_GDK_X11` is defined. The result is cached in a function-local static
on the **first** call. So:
- Called before GTK has a display, it caches `Unknown` for the rest of the process.
- In a GTK2 build neither macro is defined, so it always returns `Unknown`.
- `Unknown` makes both predicates false. Write every branch so that "neither" is safe. For example,
`GUI_App::on_init_inner` logs "Unknown display backend, defaulting to EGL" and does not call
`PreferGLX()`.
**Usage.**
```cpp
#ifdef __WXGTK__
#include "LinuxDisplayBackend.hpp"
#endif
...
#if defined(__WXGTK__)
if (Slic3r::GUI::is_running_on_wayland()) {
// Wayland-only path
}
#endif
```
**Before GTK starts**, only the environment exists. `CLI::run` (`src/OrcaSlicer.cpp`) branches on
`GDK_BACKEND` (an `x11` prefix means the X11 opt-in). On the default path it forces `GDK_BACKEND=x11`
when wx lacks EGL and `WAYLAND_DISPLAY` is set. It sets `WEBKIT_DISABLE_COMPOSITING_MODE=1`
(non-replacing) only when both `DISPLAY` and `WAYLAND_DISPLAY` are set, and calls `XInitThreads()`
only when `DISPLAY` is set. With neither variable set, the GUI refuses to start ("Neither DISPLAY nor
WAYLAND_DISPLAY set"). The details and the WebKit rule (c12912e0df) are in
`references/webview-gl-aui-media.md` §WebKitGTK on Linux sessions.
**wx's own detectors**, for reference:
- `wxGetDisplayInfo()` (`include/wx/utils.h:731-750`, public header but not in the interface docs)
returns `wxDisplayX11` / `wxDisplayWayland` / `wxDisplayNone` and the native display.
- `wxGTKImpl::IsWayland` / `IsX11` (`include/wx/gtk/private/backend.h`, GTK3 only) are private, and
each caches the answer of its first call.
Orca standardises on `LinuxDisplayBackend`, which keeps GDK headers out of callers.
**Pitfalls**
- **Rule:** Decide the backend with the GDK type check (`is_running_on_wayland()` /
`is_running_on_x11()`), not with `WAYLAND_DISPLAY` / `GDK_BACKEND` in GUI code. Environment
variables are appropriate only before GTK initialises.
**Why:** environment variables describe the session, not the backend GTK actually picked. An
XWayland run has both `DISPLAY` and `WAYLAND_DISPLAY` set while GTK talks X11, and a native Wayland
run usually has `DISPLAY` set too (XWayland available). GUI decisions keyed on the environment
misfire in both cases.
```cpp
// Wrong: GUI code reading the session environment
if (getenv("WAYLAND_DISPLAY"))
m_aui_mgr.SetFlags(m_aui_mgr.GetFlags() & ~wxAUI_MGR_ALLOW_FLOATING);
// Right (shape of Plater::priv::priv)
#if defined(__WXGTK__)
if (Slic3r::GUI::is_running_on_wayland())
m_aui_mgr.SetFlags(m_aui_mgr.GetFlags() & ~wxAUI_MGR_ALLOW_FLOATING);
#endif
```
Cite: 1b71835337 (`GUI_App.cpp`), `src/slic3r/GUI/LinuxDisplayBackend.cpp`.
- **Rule:** Never call the detectors from static initialisers or before `wxEntry` has initialised GTK.
**Why:** `gdk_display_get_default()` is null then, and the function-local cache keeps `Unknown`
forever, which silently disables every Wayland or X11 branch.
## Wayland gaps
wx has no dedicated Wayland document; the documented limits are scattered across the interface
headers. Rows marked [source] or "protocol" come from the implementation or from Wayland itself.
| Area | What happens on Wayland | Cite | Orca handling / owner |
|---|---|---|---|
| Global pointer position | `wxGetMousePosition()` / `wxGetMouseState()` call `gdk_device_get_position` [source], but Wayland gives clients no global pointer position, so the result is not a screen position | `src/gtk/window.cpp` `wxGetMousePosition` | use event coordinates + `ClientToScreen` (`BBLTopbar`); dismiss popups by focus tracking (`SearchDialog::Dismiss`) → `references/mouse-keyboard-focus.md` |
| Top-level position | the compositor places windows, and `SetPosition()` / `Move()` on a TLW have no effect (protocol) | — | `GUI_App::window_pos_restore` restores only size and maximised state; moves go through `gtk_window_begin_move_drag` |
| Window icon | `SetIcon()` / `SetIcons()` "doesn't do anything when using Wayland … create a `.desktop` file" | `interface/wx/toplevel.h:517-521, 538-542` | `src/dev-utils/platform/unix/com.orcaslicer.OrcaSlicer.desktop` (`Icon=OrcaSlicer`, `StartupWMClass=orca-slicer`) |
| App id | `wxAppConsole::SetClassName()` is the xdg `app_id` with GTK ≥ 3.24.22 (and the AUMID on Windows); it must be set before any TLW. wx applies it when a TLW is mapped, and only if it is non-empty [source: `wxTopLevelWindowGTK::GTKHandleMapped`] | `interface/wx/app.h:765-812` | Orca calls only `SetAppName(SLIC3R_APP_KEY)`, so GTK's default applies. On Windows, `SetClassName` would also change shell behaviour (MRU, Shift+middle-click) |
| `wxClientDC` | deprecated in 3.3 ("please use wxInfoDC instead for obtaining information", `interface/wx/dcclient.h:43-46`). Drawing through it "simply doesn't have any effect" on GTK3/Wayland or wxOSX. `CanBeUsedForDrawing()` [source]: false on Wayland only for wxGTK (`src/gtk/dc.cpp`), always false on wxOSX, always true on wxMSW (`include/wx/{osx,msw}/dcclient.h`), although its doc also lists "wxMSW when using double buffering" | `interface/wx/dcclient.h:48-53, 80-88` | draw only in `wxPaintDC` after `Refresh()`/`RefreshRect()` → `references/painting-custom-widgets.md` |
| `wxWindow::Update()` | "doesn't do anything in wxGTK port when using Wayland". [source] wx skips the GDK update calls there because they broke later updates (#25036) | `interface/wx/window.h:2405-2407` | never rely on `Update()` to paint synchronously |
| `WarpPointer()` | works only if the compositor implements the pointer-warp protocol; mutter also needs a mouse button held | `interface/wx/window.h:3902-3907`; `docs/changes.txt:294` | Orca never warps the pointer |
| `wxUIActionSimulator` | "currently doesn't work when using Wayland with wxGTK" | `interface/wx/uiaction.h:20` | not used; Orca also builds `wxUSE_XTEST=OFF` |
| `wxBitmap(const wxCursor&)` | creates an invalid bitmap | `interface/wx/bitmap.h:393-395` | — |
| OpenGL | only EGL; `PreferGLX()` has no effect. Without EGL in the build, wxGTK's `wxGLCanvas` shows a fatal message and refuses to work [source: `src/gtk/glcanvas.cpp` `IsAvailable`]. The EGL surface is a subsurface over the canvas, ready only after map and a frame callback [source: `src/unix/glegl.cpp`] | `interface/wx/glcanvas.h:1094-1095` | `CLI::run` forces X11 when wx lacks EGL; overlays are drawn in GL/ImGui, never as wx children over the canvas → `references/webview-gl-aui-media.md` |
| AUI | the doc note "live resize is always used … for wxOSX and wxGTK3 when using Wayland" is obsolete: "As of wxWidgets 3.3.0 this function always returns false", and `wxAUI_MGR_LIVE_RESIZE` is in the default flags. Floating panes need global positions | `interface/wx/aui/framemanager.h:336-345` | `Plater::priv::priv` clears `wxAUI_MGR_ALLOW_FLOATING`; `sanitize_window_layout_for_wayland` strips floating state from the saved layout → `references/webview-gl-aui-media.md` §wxAuiManager |
| Popups | a GTK popup is an `xdg_popup` only for COMBO/DROPDOWN/POPUP_MENU hints, so wx creates popups with `GDK_WINDOW_TYPE_HINT_COMBO` [source]. A chained popup's parent must be the mapped popup, and mapping it with a grab deactivates the toplevel (Orca's comments in `DropDown::mouseMove`, `DropDown::ShouldDismissOnTopWindowDeactivate`) | `src/gtk/popupwin.cpp:110-114` | `DropDown` transient-for chain, `ShouldDismissOnTopWindowDeactivate` → `references/popups-menus.md` |
| Fractional scale | arrives as an integer GDK scale | `docs/doxygen/overviews/high_dpi.md:348-351` | → `references/dpi-bitmaps-fonts.md` |
| Window decorations | some desktop environments draw a title bar on undecorated windows anyway | — | [Undecorated windows](#wayland-undecorated-top-level-windows-splash) |
| `wxWebViewChromium` | X11 only | `interface/wx/webview_chromium.h:133-146` | not built (`wxUSE_WEBVIEW_CHROMIUM` OFF) |
Wayland history in the change logs (`docs/changes_32.txt`): already in 3.1.5, Orca's previous wx,
were two-finger scrolling (703, 3.1.3), the EGL-based `wxGLCanvas` for Wayland (497) and `wxMediaCtrl`
support (498, both 3.1.5). New with the upgrade: "Many bug fixes for Wayland-specific problem" (397)
and a `wxMediaCtrl` fix (402) in 3.1.6, GDK errors from `PopupMenu()` avoided (317, 3.1.7),
`wxCURSOR_SIZING` fixed (261, 3.2.0). In 3.3, `WarpPointer()` on supported compositors
(`docs/changes.txt:294`) and the EGL/Wayland high-DPI scale fix (`changes.txt:538`).
## Window decoration and custom title bars
Only `MainFrame` replaces the native title bar. Dialogs keep native decorations, chosen through their
style flags (`references/windows-dialogs.md`). `MainFrame` is created with
```cpp
#ifndef __APPLE__
#define BORDERLESS_FRAME_STYLE (wxRESIZE_BORDER | wxMINIMIZE_BOX | wxMAXIMIZE_BOX | wxCLOSE_BOX)
#else
#define BORDERLESS_FRAME_STYLE (wxMINIMIZE_BOX | wxMAXIMIZE_BOX | wxCLOSE_BOX)
#endif
```
There is no `wxCAPTION` on any platform; each port then needs its own handling.
### MSW: the MainFrame custom title bar
**Contract** [source + documented]. Since wx **3.3.0**, `wxTopLevelWindowMSW::MSWGetStyle` adds
`WS_CAPTION` whenever any of `wxCAPTION | wxMINIMIZE_BOX | wxMAXIMIZE_BOX | wxCLOSE_BOX` is set
(`src/msw/toplevel.cpp:133-135`). The 3.3.0 wxMSW change list says "Turn wxCAPTION on automatically if
required by other styles (#23575)" (`docs/changes.txt:581`). Commit eefdabcd98 attributes this to
3.3.2, but it is a 3.3.0 change. `SetWindowStyleFlag()` recomputes the native style through
`MSWGetStyle` and turns the bits back on (`src/msw/window.cpp` `wxWindowMSW::MSWUpdateStyle`).
**OrcaSlicer design** (`MainFrame.cpp`, all under `__WXMSW__`):
- `MainFrame::MainFrame` strips `WS_CAPTION` with `SetWindowLongPtr` and applies it with
`SetWindowPos(..., SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE)`.
Without it Windows 10 showed the native frame behind the custom title bar, a "double window"
(f70d30bf79, #13074).
- `MainFrame::MainFrame` also binds `wxEVT_MAXIMIZE`: the handler clamps the frame to the display's
client area plus the border overshoot (`AdjustWindowRectEx`), moves it there and `Skip()`s, so a
maximised frame does not overlap the taskbar (restored by f70d30bf79).
- `MainFrame::MSWWindowProc`:
- `WM_NCACTIVATE`: sets `lParam = -1` so `DefWindowProc` does not repaint the non-client area, while
the window still receives activation.
- `WM_NCCALCSIZE` with `wParam` TRUE: computes the border with
`AdjustWindowRectEx(&r, GetWindowLongPtr(hWnd, GWL_STYLE) & ~WS_CAPTION, FALSE, 0)` and insets
left, right and bottom by it. When not maximised, the top grows by 1 px so the window can be
resized from its top edge. When maximised, the top is inset by the full border, because Windows
extends a maximised window beyond the screen by the border thickness. Then it returns 0.
- `WM_NCHITTEST`: returns `HTCAPTION` when maximised. Otherwise, over the top bar, points within the
border thickness give `HTTOP` / `HTTOPLEFT` / `HTTOPRIGHT` / `HTLEFT` / `HTRIGHT`, and the rest of
the bar gives `HTCAPTION`.
- `WM_GETMINMAXINFO`: `HandleGetMinMaxInfo` + `AdjustWorkingAreaForAutoHide`, which keeps a
maximised window off an auto-hide taskbar (#8085) and also masks `WS_CAPTION`.
- `BBLTopbar::MSWWindowProc` returns `HTTRANSPARENT` for `WM_NCHITTEST` over empty bar areas and the
title, and `CenteredTitle::MSWWindowProc` always returns it. The frame's `HTCAPTION` answer then
provides native dragging, double-click maximise and Snap. `BBLTopbar::OnMouseLeftDown` does
`CaptureMouse(); ReleaseMouse();` and posts `WM_NCLBUTTONDOWN` with `HTCAPTION`.
**Pitfall**
- **Rule:** For a Windows frame with a custom title bar, handle `WM_NCCALCSIZE` yourself. Mask
`WS_CAPTION` out of the style passed to every `AdjustWindowRectEx`
(`GetWindowLongPtr(hWnd, GWL_STYLE) & ~WS_CAPTION`). In the maximised branch, strip the full border
overshoot on all four sides instead of returning early.
**Why:** wx adds `WS_CAPTION` whenever a min/max/close box is requested, and puts it back when the
style is recomputed. Letting `DefWindowProc` (or an unmasked `AdjustWindowRectEx`) compute the
non-client area subtracts a caption you draw yourself, so a maximised window leaves a gap above the
taskbar.
```cpp
// Wrong: caption height included; maximised case left to DefWindowProc
AdjustWindowRectEx(&b, GetWindowLongPtr(hWnd, GWL_STYLE), FALSE, 0);
if (wPos.showCmd == SW_SHOWMAXIMIZED) break;
// Right
AdjustWindowRectEx(&b, GetWindowLongPtr(hWnd, GWL_STYLE) & ~WS_CAPTION, FALSE, 0);
b.left *= -1; b.top *= -1;
sz->rgrc[0].top += (wPos.showCmd == SW_SHOWMAXIMIZED) ? b.top : 1;
sz->rgrc[0].left += b.left; sz->rgrc[0].right -= b.right; sz->rgrc[0].bottom -= b.bottom;
return 0;
```
Cite: eefdabcd98 (`MainFrame::MSWWindowProc`).
### Linux (GTK): the borderless MainFrame
**wx mechanism** [source, `src/gtk/toplevel.cpp`]:
- At creation, wx turns the style into WM hints (`m_gdkDecor`, `m_gdkFunc`, :880-925).
`wxBORDER_NONE` / `wxSIMPLE_BORDER` call `gtk_window_set_decorated(false)`.
- On Wayland with GTK ≥ 3.10, a bordered window without `wxCAPTION` gets a `gtk_header_bar_new()`
titlebar (:900-906); `BORDERLESS_FRAME_STYLE` is such a style.
- On realise, `GTKHandleRealized` calls `gdk_window_set_decorations(window, m_gdkDecor)` (:400-444).
When a client-side titlebar exists it first sets `m_gdkDecor = 0`, because "Don't set WM
decorations when GTK is using Client Side Decorations".
**OrcaSlicer design:**
- **No WM decorations.** `MainFrame::MainFrame` sets `m_gdkDecor = 0` (a `public:` implementation
member of `wxTopLevelWindowGTK`, `include/wx/gtk/toplevel.h:108-109`) before the frame is
realised, so the window manager draws no title bar, while `m_gdkFunc` keeps move, resize, minimise,
maximise and close. `BBLTopbar` is the only title bar (d50b4cbf3d, #12600 "No more double title
bar").
- **Move.** `BBLTopbar::OnMouseLeftDown`, on empty bar areas and the title, calls
`gtk_window_begin_move_drag(GTK_WINDOW(m_frame->m_widget), 1, x, y, gtk_get_current_event_time())`
with `x, y` taken from `ClientToScreen(event.GetPosition())`. This is a compositor-driven move, and
on Wayland it is the only way a client can move its window.
- **Maximise.** `BBLTopbar::OnFullScreen`, which also handles a double-click on the title, uses
`gtk_window_is_maximized` / `gtk_window_maximize` / `gtk_window_unmaximize` on GTK.
- **Resize.** Three `ResizeEdgePanel`s (bottom, left, right; `BORDER_PX` = 5): transparent `wxPanel`s
(`wxBG_STYLE_TRANSPARENT`, empty `wxPaintDC` paint) that are `Raise()`d above all siblings, so their
GDK windows receive pointer events even over WebKit2GTK or GL surfaces. On motion they set a named
cursor (`gdk_cursor_new_from_name`, `"s-resize"`, `"sw-resize"`, …). On left-down they call
`gtk_window_begin_resize_drag` with the matching `GdkWindowEdge`, unless the frame is maximised or
fullscreen. `MainFrame::update_edge_panels` hides them while maximised or fullscreen, lays them out
along the client edges and raises them again. `MainFrame::shutdown` clears the pointers; wx
destroys the panels as children. They use event-relative coordinates, not a global event filter
keyed on `wxGetMousePosition()`, which cannot work on Wayland (6049c6e234, #12705).
**Pitfall**
- **Rule:** Move and resize the borderless frame through `gtk_window_begin_move_drag` /
`gtk_window_begin_resize_drag` with the current event time. Do not capture the mouse and call
`Move()` / `SetSize()` from global coordinates.
**Why:** on Wayland, clients cannot position top-level windows and get no global pointer position,
so a manual drag does nothing or jumps. On X11, the WM-driven drag also gives edge snapping and
correct multi-monitor behaviour.
```cpp
// Wrong (GTK): manual drag
CaptureMouse(); /* on motion: */ m_frame->Move(::wxGetMousePosition() - m_delta);
// Right (GTK)
wxPoint p = ClientToScreen(event.GetPosition());
gtk_window_begin_move_drag(GTK_WINDOW(m_frame->m_widget), 1, p.x, p.y, gtk_get_current_event_time());
```
Cite: `BBLTopbar::OnMouseLeftDown`; `ResizeEdgePanel::OnLeftDown` (`MainFrame.cpp`).
### macOS: a native titled window
- [source] wxOSX gives a window `NSTitledWindowMask` as soon as any of `wxMINIMIZE_BOX`,
`wxMAXIMIZE_BOX`, `wxCLOSE_BOX`, `wxSYSTEM_MENU` or `wxCAPTION` is set, and adds the
miniaturizable, resizable and closable masks for the matching boxes
(`src/osx/cocoa/nonownedwnd.mm:816-831`). `BORDERLESS_FRAME_STYLE` therefore still produces a titled
window with the standard window buttons. `wxRESIZE_BORDER` is left out on Apple, and the window is
still resizable because `wxMAXIMIZE_BOX` already adds `NSResizableWindowMask`.
- `set_miniaturizable(GetHandle())` (`Utils/MacDarkMode.mm`, called in `MainFrame::MainFrame` under
`__WXOSX__`) sets `titlebarAppearsTransparent`, sets a dark window background colour, ORs in
`NSMiniaturizableWindowMask`, and remembers the title `NSTextField`.
`set_title_colour_after_set_title` re-colours that field white after the title changes.
- There is no `BBLTopbar` on macOS: the top area is a plain `wxPanel`, and the menus live in the
native `wxMenuBar`.
- Fullscreen: Cmd+Ctrl+F calls `EnableFullScreenView(true)` and toggles `ShowFullScreen()`.
`EnableFullScreenView` is OSX-only, and the full-screen button is needed for the animated
fullscreen space (`interface/wx/toplevel.h:700-728`).
### Wayland: undecorated top-level windows (splash)
- **Rule:** To show a truly undecorated top-level window (the splash screen) on Wayland, don't rely on
wx style flags or window-type hints. Install an empty client-side titlebar and disable decoration
on the GTK window in the constructor, before control returns to the event loop. [source] The
`wxSplashScreen` base constructor has already called `Show(true)` (`src/generic/splash.cpp`), so in
`SplashScreen` these calls land right after the show request and before any event is processed; in
a window you show yourself, make them before `Show()`:
```cpp
#if defined(__WXGTK__)
if (Slic3r::GUI::is_running_on_wayland()) {
GtkWidget* empty = gtk_fixed_new();
gtk_widget_set_size_request(empty, 0, 0);
gtk_window_set_titlebar(GTK_WINDOW(GetHandle()), empty);
gtk_window_set_decorated(GTK_WINDOW(GetHandle()), false);
}
#endif
```
**Why:** some Wayland desktop environments ignore splash-typed window properties (wxGTK's
`wxSplashScreen` sets `GDK_WINDOW_TYPE_HINT_SPLASHSCREEN`) and draw a title bar anyway. This
happens even though `wxBORDER_NONE` already makes wx call `gtk_window_set_decorated(false)`. Forcing an empty client-side titlebar removes it on every
desktop environment.
Cite: 1b71835337 (`SplashScreen::SplashScreen` in `GUI_App.cpp`; the splash style is
`wxBORDER_NONE | wxFRAME_NO_TASKBAR`, plus `wxSTAY_ON_TOP` on Apple).
## GTK native chrome and GTK size calls
**Helpers** (`GUI_Utils.hpp`, declared under `__WXGTK__`):
- `RemoveButtonBorder(wxWindow*)` is "for wxButton/wxBitmapToggleButton based controls (SwitchButton,
CheckBox)".
- `RemoveInputBorder(wxWindow*)` is "for TextCtrl based controls (TextInput, ComboBox, SpinInput..)".
Both return immediately if `GetHandle()` is null, so call them after the control is created; the
widgets do it in their constructors.
- **GTK3** (and the GTK4 branch): a `GtkCssProvider` is added to the widget's own style context at
`GTK_STYLE_PROVIDER_PRIORITY_USER`, then released with `g_object_unref` (the context keeps its
reference). The CSS covers `button, button:hover, button:active, button:focus`, or
`entry, entry text, entry undershoot` for inputs, and zeroes `border`, `outline`, `box-shadow`,
`padding`, `margin`, `min-height` and `min-width`, with `background: none`.
- **GTK2**: `gtk_rc_parse_string` installs a **global** rc style keyed by widget path or class
(`"*.GtkBitmapToggleButton"`, class `"GtkEntry"`). The first call changes every matching widget in
the process, not just the one passed in.
**Callers:** `CheckBox::CheckBox`, `SwitchButton`, `RadioBox`, `ScalableButton`, `ObjColorDialog` and
`PresetComboBoxes` (`RemoveButtonBorder`); the inner `wxTextCtrl` of `TextInput` and `SpinInput`
(`RemoveInputBorder`). A new owner-drawn control built on a native GTK widget needs the same call.
**Sizing a bitmap button on GTK.** A `wxBitmapToggleButton`/`wxButton`-based widget sized to exactly its
bitmap leaves no room for the theme's CSS padding, and GTK logs "negative content width" criticals.
Either strip the button CSS with `RemoveButtonBorder` and size to the bitmap (`CheckBox::Rescale`), or
size to `GetBestSize()` grown to the bitmap (`IncTo`; `RadioBox::Rescale` and `SwitchButton::Rescale`
do both). Cite: 6148ba16b3, 988b500f33.
**Pitfalls**
- **Rule:** To strip native GTK control chrome (entry and button borders, padding), attach a
`GtkCssProvider` at `GTK_STYLE_PROVIDER_PRIORITY_USER` to the widget's style context, and include the
pseudo-class states (`button, button:hover, button:active, button:focus { ... }`), plus the inner
subnodes for entries (`entry, entry text, entry undershoot`). Guard the code with `#ifdef __WXGTK__`,
branch GTK2/3/4 with `GTK_CHECK_VERSION`, and keep the `.hpp` declaration guard identical to the
`.cpp` definition guard.
**Why:** wx border-style flags don't remove GTK theme borders or padding, which show up as black
borders on Linux. CSS without `:hover` / `:focus` lets the border come back on interaction. Guards
that differ between header and implementation (`__WXGTK3__` vs `__WXGTK__`) break the build on other
GTK versions.
```cpp
// Wrong: wx flags alone, border reappears / theme padding stays
text_ctrl = new wxTextCtrl(this, wxID_ANY, text, pos, size, style | wxBORDER_NONE);
// Right
text_ctrl = new wxTextCtrl(this, wxID_ANY, text, pos, size, style | wxBORDER_NONE);
#ifdef __WXGTK__
Slic3r::GUI::RemoveInputBorder(text_ctrl);
#endif
```
Cite: 477208a969 (`GUI_Utils.cpp` `RemoveButtonBorder` / `RemoveInputBorder`, `GUI_Utils.hpp`).
- **Rule:** Guard direct `gtk_window_resize()` calls (and similar GTK size calls) so they run only with
strictly positive width and height.
**Why:** GTK checks the arguments (`gtk_window_resize: assertion 'width > 0'`) when a popup is
measured before it has content. This spams assertion errors on Linux and aborts when GTK criticals
are made fatal. `DropDown` resizes the GTK wrapper window itself (source comment: "Gtk has a
wrapper window for popup widget"), so its size can be zero before the items exist.
```cpp
// Wrong
gtk_window_resize(GTK_WINDOW(m_widget), szContent.x, szContent.y);
// Right
if (szContent.x > 0 && szContent.y > 0)
gtk_window_resize(GTK_WINDOW(m_widget), szContent.x, szContent.y);
```
Cite: dc12126b78 (`DropDown::messureSize`, `Widgets/DropDown.cpp`).
## wx 3.3 migration notes
Orca moved from **3.1.5** to 3.3.2 in 8248b06337 ("Updated wxWidgets to 3.3.2", #12941; build system
in 2d7e26292b), so the 3.1.6–3.2.0 incompatible changes (`docs/changes_32.txt`) apply as well as
`docs/changes.txt`. The version digest, every migration commit as a rule for new code, and the
post-upgrade regressions to watch are owned by `references/wx-33-changes.md` (§8). The
platform-specific ones are described in this file: the `MainFrame` `WS_CAPTION` handling
([MSW title bar](#msw-the-mainframe-custom-title-bar)), the macOS `kAEGetURL` re-registration and the
`SidePopup` anchoring ([ifdef landscape](#the-ifdef-landscape)), and the GTK criticals filter
([GTK3 summary](#gtk3-x11-and-wayland)).
## Cross-platform testing checklist
Because wx asserts are compiled out, a platform bug usually shows up as wrong pixels, a dropped
event or a frozen interaction, not a dialog. Exercise the change; a successful build proves little.
Say in the PR which platforms you exercised.
**Build**
- [ ] Every toolkit branch compiles: `__WXMSW__`, `__WXOSX__`, `__WXGTK__`. GTK code also compiles with
the GTK2 branch of any `GTK_CHECK_VERSION`, and header/implementation guards match.
- [ ] New Cocoa code is in a `.mm` file registered in the `APPLE` block, and new sources are added to
`src/slic3r/CMakeLists.txt`.
- [ ] No `FromSVG*`, no new wx private header without the matching port macro, no reliance on a wx
assert.
**Windows**
- [ ] 100 %, 125 %, 150 % and 175 % scaling, plus moving the window between monitors with different
scaling (PMv2 `wxEVT_DPI_CHANGED`, `DPIAware` rescale, `on_dpi_changed`).
- [ ] Light and dark, including switching dark mode at runtime from Preferences.
- [ ] `MainFrame` maximise/restore, maximise with an auto-hide taskbar, Snap, drag from the top bar,
resize from the top edge.
- [ ] Popups: open, then click elsewhere, Alt+Tab, minimise the main window; the popup must close
exactly once.
- [ ] Live window resize with the 3D view visible (no blank canvas); custom-painted widgets don't
flicker.
**macOS**
- [ ] A Retina and a non-Retina display, and moving windows between them.
- [ ] Switching system appearance while the app runs.
- [ ] Menu-bar shortcuts vs text fields (typing in a field must not trigger a menu shortcut), and
Cmd+H / Cmd+M / Cmd+Q / Cmd+Ctrl+F.
- [ ] Popups and dropdown menus: move the cursor from the anchor into the menu without it
dismissing; nothing stays captured after a drag (the UI must stay clickable).
- [ ] `ObjectList` editing and keyboard shortcuts (native data view).
**Linux, GTK3 on X11** (`GDK_BACKEND=x11` on a Wayland session, or an X11 session)
- [ ] Scale 1 and 2 (`GDK_SCALE=2`), light and dark GTK themes, large font settings (text-driven
sizes, `em_unit`).
- [ ] No native borders or padding around custom widgets; dialogs open at their fitted size and don't
collapse after minimising the main window.
- [ ] The GL views render (GLX path), and WebView pages load.
**Linux, GTK3 on native Wayland**: at least GNOME (mutter) and one wlroots compositor (Sway or
Hyprland); KDE is worth a run for decoration differences.
- [ ] Moving and resizing the main window through the top bar and the edges; maximise/restore; no
second title bar; the splash has no title bar.
- [ ] Popups, chained dropdown submenus and search dropdowns open in the right place, track hover and
dismiss correctly.
- [ ] Nothing depends on `wxGetMousePosition()`, `SetPosition()` on a top-level window, or
`wxClientDC` drawing. No CPU spin with the window on an inactive workspace.
- [ ] GL views render after startup (EGL post-init retry), and overlay icons are not translucent.
- [ ] Dock panes cannot float, and a layout saved on X11 loads.
**Packaging**
- [ ] The Flatpak build (shared wx, GTK3, sandbox) if the change touches wx options, file dialogs,
WebView or desktop integration.
**Debugging aids**
- [ ] In a Debug or RelWithDebInfo build, Ctrl+Shift+I (Cmd+Shift+I on macOS) on any `DPIAware` window
opens wxInspector to check the window tree, sizes and styles per platform.
- [ ] On Linux, remember that `GUI_App::on_init_inner` filters some GTK criticals. Check the terminal
output for the rest.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,822 @@
# Sizers and layout
How wx 3.3.2 computes window sizes and lays out children, and how OrcaSlicer builds layouts on top of
that: sizers and flags, best/min size, the fitting functions (`SetSizerAndFit`, `SetSizeHints`, `Fit`,
`Layout`, `FitInside`) per platform, show/hide relayout, Freeze/Thaw, scrolled windows, `wxStaticText`
wrapping, layout on DPI change, and Orca's layout idioms. Read it when building or reviewing any dialog
or panel layout, or when debugging a window that is collapsed, clipped, too large or not re-laid out.
Contents: [Rules](#rules) · [Build facts](#build-facts-that-change-how-layout-bugs-present) ·
[Size model](#the-size-model-best-min-effective-min-initial-virtual) ·
[Fitting functions](#fitting-functions-setsizer-setsizerandfit-setsizehints-fit-layout) ·
[Re-layout](#re-layout-after-content-or-visibility-changes) · [Adding items](#adding-items-proportion-flags-wxsizerflags) ·
[Ownership](#ownership-and-removal) · [Specific sizers](#specific-sizers) ·
[Size events](#wxevt_size-handlers) · [Freeze/Thaw](#freeze--thaw) ·
[Scrolled windows](#scrolled-windows) · [Static text wrapping](#wxstatictext-wrapping-and-ellipsizing) ·
[Layout on DPI change](#layout-on-dpi-change) · [Platform summary](#platform-summary) ·
[Orca idioms](#orcaslicer-layout-idioms-and-spacing-conventions)
## Rules
1. On a top-level window, attach the finished sizer with `SetSizerAndFit(sizer)`. If `SetSizer` must
come before the content exists, call `GetSizer()->SetSizeHints(this)` once the content is built, and
again after rebuilding content. Never rely on `Fit()` alone. → [Fitting](#fitting-functions-setsizer-setsizerandfit-setsizehints-fit-layout)
2. Child panels use plain `SetSizer`; never `SetSizerAndFit` or `sizer->SetSizeHints(panel)` on a
non-top-level window (it pins the panel's min size). → [Fitting](#fitting-functions-setsizer-setsizerandfit-setsizehints-fit-layout)
3. Keep a dialog's content within any `SetMaxSize` (cap a scrolled region), or the min > max hints are
silently dropped. → [Fitting](#fitting-functions-setsizer-setsizerandfit-setsizehints-fit-layout)
4. Proportion is the second argument: `Add(w, 0, wxEXPAND | wxALL, FromDIP(n))`, never `Add(w, wxEXPAND)`.
→ [Adding items](#adding-items-proportion-flags-wxsizerflags)
5. In a box sizer, alignment and `wxEXPAND` act only across the sizer's direction, and `wxEXPAND`
overrides alignment; contradictory flags are silently ignored in Orca. → [Adding items](#adding-items-proportion-flags-wxsizerflags)
6. Give `proportion > 0` only to items that must stretch; proportions inflate the sizer's min size.
→ [Adding items](#adding-items-proportion-flags-wxsizerflags)
7. A window managed by a sizer is a child of the sizer's containing window (or of the `wxStaticBox` of a
`wxStaticBoxSizer`), and sits in exactly one sizer; `Detach` before re-adding.
→ [Adding items](#adding-items-proportion-flags-wxsizerflags), [Ownership](#ownership-and-removal)
8. When rebuilding content, destroy the old windows; deleting, clearing or replacing a sizer leaves them
alive and visible. → [Ownership](#ownership-and-removal)
9. After changing content or visibility, `Layout()` the nearest ancestor whose allocation must change;
resize a top-level window with `GetSizer()->SetSizeHints(tlw)`. `Hide()` is always followed by a
`Layout()`. → [Re-layout](#re-layout-after-content-or-visibility-changes)
10. A `wxEVT_SIZE` handler calls `Skip()` and never `SetSize`s its own window. → [Size events](#wxevt_size-handlers)
11. `Freeze()`/`Thaw()` must balance on every path; use `wxWindowUpdateLocker`. → [Freeze/Thaw](#freeze--thaw)
12. A scrolled window needs a non-zero `SetScrollRate`, `FitInside()` after its content changes, and an
explicit min size or proportion + `wxEXPAND` in its parent. → [Scrolled windows](#scrolled-windows)
13. To re-wrap a `wxStaticText` after `SetLabel`, call `Wrap(-1); Wrap(w);`, or use `Label` with
`LB_AUTO_WRAP`; use `Label` for CJK text. → [Wrapping](#wxstatictext-wrapping-and-ellipsizing)
14. Never compute or commit a size from a width that has not been laid out yet. → [Wrapping](#wxstatictext-wrapping-and-ellipsizing)
15. Give wrapping labels a fixed width and `-1` height, never a fixed height. → [Wrapping](#wxstatictext-wrapping-and-ellipsizing)
16. In `on_dpi_changed`, re-apply what wx does not rescale, then resize with
`GetSizer()->SetSizeHints(this)`; never multiply existing min sizes or borders by a DPI ratio.
→ [Layout on DPI change](#layout-on-dpi-change)
17. A custom widget reports its size through its min size (Orca widgets) or `DoGetBestClientSize()`, and
invalidates it when content, label or font change. → [Size model](#the-size-model-best-min-effective-min-initial-virtual)
18. Pixel values are `FromDIP(n)` or `n * em_unit()`; borders are explicit `FromDIP(n)`; dialog button
rows are `DialogButtons`. → [Orca idioms](#orcaslicer-layout-idioms-and-spacing-conventions)
## Build facts that change how layout bugs present
- **Every wx layout assert is silent in Orca.** wx is built with `wxBUILD_DEBUG_LEVEL=0` and
`libslic3r_gui` with `wxDEBUG_LEVEL=0`, so `wxASSERT`/`wxFAIL` compile to nothing and `wxCHECK_*` return
early without a message (`include/wx/debug.h:314-324, 342-382`). The checks that would flag layout
mistakes in a debug wx therefore do nothing, and the review has to catch them by reading the code:
- flag consistency in box sizers (`wxBoxSizer::DoInsert`, `src/common/sizer.cpp:2295`);
- "window managed by the sizer must have the containing window as parent" (`wxSizer::DoInsert`,
`sizer.cpp:904, 947`);
- mixed parents in one `wxStaticBoxSizer` (`wxStaticBoxSizer::RepositionChildren`, `sizer.cpp:2888`);
- duplicate or out-of-range `AddGrowableCol/Row` (`sizer.cpp:2235, 2250`);
- `Thaw()` without `Freeze()` (`src/common/wincmn.cpp:1247`);
- a window added to a second sizer (`wxWindowBase::SetContainingSizer`, `wincmn.cpp:2421`): the
`wxCHECK_RET` refuses the bookkeeping, but the item is still inserted;
- top-level `SetSizeHints` with min > max (`wxWindowBase::DoSetSizeHints`, `wincmn.cpp:1041`): the
whole call is dropped.
- **Linux is GTK3** (`DEP_WX_GTK3` ON, `SLIC3R_GTK` "3"); GTK2 is an opt-out build. GTK-only facts below
are GTK3 unless marked.
- **DIP model.** `wxHAS_DPI_INDEPENDENT_PIXELS` is defined for wxGTK3 and wxOSX
(`include/wx/features.h:115`): logical pixels are DIPs and `FromDIP` is the identity. On MSW `FromDIP`
scales by the window's DPI (`wincmn.cpp` `wxWindowBase::FromDIP`); GTK2 takes the same conversion path,
but its display PPI is always 96, so `FromDIP` is the identity there too **[source]**. DIP conversion
itself: see `references/dpi-bitmaps-fonts.md`.
## The size model: best, min, effective min, initial, virtual
**Contract** (`docs/doxygen/overviews/windowsizing.h:23-100`):
- *Best size* is derived from content. *Min size* is "normally explicitly set by the programmer"; most
controls also take it from a non-default ctor size. *Initial size* is the ctor size; a partly specified
size such as `wxSize(150, -1)` is completed from the best size. *Virtual size* is the scrollable extent.
- `GetEffectiveMinSize()` merges the best size into the min size: "This is the value used by sizers to
determine the appropriate amount of space to allocate for the widget" (`interface/wx/window.h:1393-1402`). It is the min size with unspecified components filled
from the best size (`wincmn.cpp:868`). Once `SetMinSize(wxSize(w, h))` sets both components, the
content no longer affects the sizer allocation; pass `-1` for the component that must follow content.
- "The best size respects the minimal and maximal size explicitly set for the window" (`window.h:1342-1350`;
`wincmn.cpp:879`: raised to min, lowered to max). It is **cached only when the window has no sizer**
**[source]** (`wincmn.cpp:881`). Containers with sizers recompute every time; leaf controls and custom
widgets return the cache until `InvalidateBestSize()` (`window.h:1645-1651`; the cache is documented for
`DoGetBestClientSize`, `window.h:4432-4435`).
- `InvalidateBestSize()` also invalidates the parent chain, stopping at a top-level window **[source]**
(`wincmn.cpp:636`). It does not lay anything out (see [Re-layout](#re-layout-after-content-or-visibility-changes)).
- The default `DoGetBestSize()` uses the sizer's min size; without a sizer, the bounding box of visible
children; with no children, the min size or (1,1) (`wincmn.cpp:649`).
- `SetInitialSize(size)` sets the min size to `size`, merges it with the best size and resizes
(`window.h:1742-1757`, `wincmn.cpp:937`). A ctor size therefore becomes the min size: a fixed height
pins the minimum height.
- `SetMinSize` "doesn't prevent the program from making the window explicitly smaller … by calling
SetSize(), it just ensures that it won't become smaller than this size during the automatic layout"
(`window.h:1808-1815`). Top-level windows are the documented exception: their size hints also stop the
program's own `SetSize()` (`interface/wx/toplevel.h:576-603`). **[source]** On GTK, top-level windows and
`wxPopupWindow` clamp `SetSize` to min/max (`src/gtk/toplevel.cpp:1365` `ConstrainSize`,
`src/gtk/popupwin.cpp:171`).
- On a top-level window, `SetMinSize`/`SetMaxSize` go through `SetSizeHints(min, max)`
(`src/common/toplvcmn.cpp:197-205`), so a min larger than the current max (or the reverse) is dropped
silently.
- `wxWindow::SetSizeHints` on a non-top-level window "is discouraged. Please use SetMinSize() and
SetMaxSize() instead" (`window.h:1885-1891`).
- **Height-for-width (3.3.2).** `GetMinSizeFromKnownDirection(direction, size, availableOtherDir)`
(`window.h:1404-1444`) lets a control report its min size once the layout fixes one dimension; box and
flex-grid sizers feed the known width to their items during layout, and `wxSizer::CalcMinSizeFromKnownDirection`
is the sizer side (`interface/wx/sizer.h:339-377`). `InformFirstDirection` is the deprecated
compatibility path. `wxST_WRAP` and `wxWrapSizer` rely on this negotiation. `DoGetBestClientHeight()`/
`DoGetBestClientWidth()` are "not used by wxWidgets yet" (`window.h:4453-4455`): overriding them changes
no sizer layout.
**Writing a custom control (wx way).** Override `DoGetBestClientSize()` and let `DoGetBestSize()` add the
borders (`windowsizing.h:46-51`, `window.h:4421-4441`); the default returns `wxDefaultSize` and the best
size is then arbitrary. Call `SetInitialSize()` at the end of `Create()`, and `InvalidateBestSize()`
whenever content, label or font change.
**OrcaSlicer.** The `StaticBox`-based widgets (`Button`, `TextInput`, `SpinInput`, `ComboBox`) do not
override `DoGetBestClientSize`. Each has (or inherits) a `messureSize()` that measures its content and calls
`wxWindow::SetMinSize(...)`, and they override `SetMinSize` to merge a caller's request with the content
size: `Button::SetMinSize` stores the request (its height overrides, its width is a floor), and
`TextInput::SetMinSize` fills a `-1` height from the current size. Re-measuring happens inside their
`SetLabel`/`SetFont`/`Rescale` overrides, so callers only re-lay out the parent. A min size is honoured
identically by every sizer and has no cache to invalidate, so this design never needs `InvalidateBestSize()`.
Writing a new Orca widget: see `references/painting-custom-widgets.md`.
For owner-drawn text whose height depends on width, follow `WikiLabel` (`src/slic3r/GUI/Preferences.cpp`):
re-wrap on width change, then `SetMinSize(wxSize(-1, totalH))` + `InvalidateBestSize()`; override
`DoGetBestSize()`; guard `GetCharHeight()` against 0 before the window is realized on GTK (Orca comment).
## Fitting functions: SetSizer, SetSizerAndFit, SetSizeHints, Fit, Layout
**Contract.**
- `SetSizer(s, deleteOld = true)`: "The window will then own the object, and will take care of its
deletion"; `deleteOld` deletes a previous sizer (pass `false` only if you delete it yourself). It "will
also call SetAutoLayout() implicitly with true … so that the sizer will be effectively used to layout
the window children whenever it is resized" (`window.h:3693-3715`).
- `SetSizerAndFit(s)` "calls SetSizer() and then wxSizer::SetSizeHints() which sets the initial window
size to the size needed to accommodate all sizer elements and sets the minimal size to the same size,
this preventing the user from resizing this window to be less than this minimal size (if it's a
top-level window …)" (`window.h:3717-3728`; `wincmn.cpp:2414`).
- `wxSizer::SetSizeHints(win)` is documented as "first calls Fit() and then
wxTopLevelWindow::SetSizeHints() … It does nothing in normal windows or controls", with the idiom of
calling the panel's sizer's `SetSizeHints(frame)` to size the frame to fit the panel
(`interface/wx/sizer.h:937-970`). **[source]** The doc is outdated: it calls
`WXSetInitialFittingClientSize(wxSIZE_SET_CURRENT | wxSIZE_SET_MIN)` (`sizer.cpp:1284`), which calls
`SetMinClientSize()` then `SetClientSize()` on **any** window (`wincmn.cpp:974`). On a child panel it
freezes the panel's min size at its current content.
- `wxSizer::Fit(win)` resizes the window so its client area matches the sizer's min size
(`sizer.h:454-463`; `sizer.cpp:1243`: `wxSIZE_SET_CURRENT` only).
- `wxWindow::Fit()` "only changes the current window size and doesn't change its minimal size"
(`window.h:1066-1077`); it is `SetSize(GetBestSize())` (`wincmn.cpp:625`), except that a top-level window
without a sizer and with exactly one child sets its client size to that child's best size
(`toplvcmn.cpp:508`).
- `Layout()` "doesn't do anything" without a sizer unless the window is top-level; it "is called
automatically when the window size changes if it has the associated sizer" (`window.h:3753-3769`). It
positions children inside the current virtual size (`wincmn.cpp:2469`). A top-level window without a
sizer and with exactly one child resizes that child to fill the client area (`toplvcmn.cpp:475`).
| Call | Sets current size | Sets min size | Clamped to display (TLW) | GTK3: replayed at `Show()` if called while hidden |
|---|---|---|---|---|
| `win->SetSizer(s)` | no | no | – | – |
| `win->SetSizerAndFit(s)` | yes (client) | yes (client) | yes | yes |
| `s->SetSizeHints(win)` | yes | yes | yes | yes (with the window's own sizer) |
| `s->Fit(win)` | yes | **no** | yes | yes |
| `win->Fit()` | yes | **no** | **no** | **no** |
| `win->Layout()` | no (positions children) | no | – | – |
**[source]** `ComputeFittingClientSize` (`sizer.cpp:1194`) clamps a **top-level** window only to the
display client area; the doc's "maximum window size if previously set" applies to child windows only. If
a dialog's `SetMaxSize` is smaller than its content, `SetSizeHints` computes min > max,
`DoSetSizeHints` drops the hints, and on GTK the following `SetClientSize` is clamped to the max: the
dialog ends up with no enforced minimum.
**GTK3 hidden-window replay [source]** (`src/gtk/toplevel.cpp`). `wxTopLevelWindowGTK::WXSetInitialFittingClientSize`
(`:1726`) applies the fit at once and, if the window is still hidden, stores the flags because the "GTK
style cache hasn't been updated yet"; `Show()` (`:1262-1266`) and `GTKDoAfterShow()` (`:1687`) replay them
through `GTKUpdateClientSizeIfNecessary()` (`:1702`), which re-fits with the window's **own** sizer (a
`panel_sizer->SetSizeHints(frame)` on a sizer-less frame is not replayed). An explicit `SetMinSize()`
cancels the pending minimum (`:1715-1723`); an explicit size change cancels the pending current size but
keeps the pending minimum (`:1384-1396`). `wxWindow::Fit()` never takes this path, so a hint-less GTK3
dialog keeps whatever was measured with the stale style cache.
**GTK size hints [source].** WM min/max geometry hints are set only for `wxRESIZE_BORDER` windows; a
non-resizable dialog is sized through `gtk_widget_set_size_request` in `DoSetSize`
(`toplevel.cpp:1485-1503, 1399-1407`). Either way the min comes from `SetSizeHints`.
**Usage — the three canonical shapes:**
```cpp
// (a) content built before the sizer is attached
SetSizerAndFit(main_sizer); // size + min, display-clamped, GTK3-safe
CenterOnParent();
// (b) sizer attached early, content added later (MsgDialog::finalize)
SetSizer(main_sizer); /* ... add content ... */
GetSizer()->SetSizeHints(this); Layout(); CenterOnParent();
// (c) content changed after the dialog exists (PrinterPartsDialog::Show)
/* ... show/hide/add rows ... */
GetSizer()->SetSizeHints(this); // not Fit(): Fit() leaves the old minimum
```
`SetSizeHints` already sets the current size, so a `Fit()` before it is redundant, and a `Fit()` after it
only re-applies the best size without the display clamp.
**OrcaSlicer.** The project rule (`AGENTS.md`): "Always use `SetSizerAndFit(sizer)` instead of
`SetSizer(sizer)` on top level window. Unless `SetSizer` must be called before the full layout is built,
call `sizer->SetSizeHints(window)` afterwards in this case." Models: `CloneDialog` ctor (shape a);
`MsgDialog` (its ctor calls `SetSizer(main_sizer)`, subclasses add content, `MsgDialog::finalize` runs
`GetSizer()->SetSizeHints(this); Layout(); Fit(); CenterOnParent(); wxGetApp().UpdateDlgDarkUI(this);`);
`NetworkPluginDownloadDialog` ctor (`main_sizer->SetSizeHints(this)` after building the mode-specific UI);
`PrinterPartsDialog::Show` (shows/hides its panels and rows, then `GetSizer()->SetSizeHints(this)` before
`DPIDialog::Show`); the calibration dialogs in `calib_dlg.cpp` (`Layout(); Fit(); v_sizer->SetSizeHints(this);`).
**Pitfalls**
- **Rule:** Top-level dialogs get their minimum from `SetSizerAndFit` or `sizer->SetSizeHints(this)`,
never from `Fit()` alone.
**Why:** `Fit()` sets no minimum, is not display-clamped and is not replayed at GTK3 show; a dialog
whose minimum was never propagated from its children renders collapsed or mis-sized on GTK3 and can be
shrunk below its content anywhere.
```cpp
// Wrong
SetSizer(main_sizer); Layout(); main_sizer->Fit(this);
// Right (layout fully built)
SetSizerAndFit(main_sizer); Layout();
// Right (sizer set early, content built later, e.g. MsgDialog::finalize; its trailing Fit() is redundant)
GetSizer()->SetSizeHints(this); Layout(); Fit();
```
Cite: 5ede9711f5 (`MsgDialog::finalize`, `UnsavedChangesDialog::build`, `PrinterPartsDialog::Show`,
`CloneDialog` ctor and other dialog ctors; added the `AGENTS.md` rule).
- **Rule:** Lock in the minimum in the constructor, before the dialog can receive iconize, refresh or DPI
events.
**Why:** Per f760f4e462's message, on wxGTK iconizing the main window while a hint-less dialog is open
re-ran `Fit()` with transient zero-sized children and collapsed the dialog (OK button clipped); the
WM then honoured the small geometry, so the user could not resize it back. The trigger chain is not
visible in wx source; what is verified is that `Fit()` never updates the minimum and that a minimized
top-level window reports a (0,0) client size (`window.h:1375-1376`; GTK `toplevel.cpp:1463`).
```cpp
Layout(); Fit(); // Wrong: no enforced minimum
Layout(); Fit(); v_sizer->SetSizeHints(this); // Right (the Fit() is redundant)
```
Cite: f760f4e462 (`FlowRateCalibrationDialog` ctor, `calib_dlg.cpp`).
- **Rule:** Child panels use `SetSizer`, not `SetSizerAndFit`.
**Why:** `SetSizeHints` pins the panel's min client size at its current content **[source]**. A fully
specified min size replaces the best size in `GetEffectiveMinSize()`, so the parent sizer stops tracking
the panel's content: it no longer shrinks when content is removed or translations get shorter, nor grows
when content is added.
```cpp
panel->SetSizerAndFit(s); // Wrong
panel->SetSizer(s); // Right
```
Cite: `wincmn.cpp:974` `WXSetInitialFittingClientSize`.
- **Rule:** After content added to a shown dialog, call `GetSizer()->SetSizeHints(this)`, not `Fit()`.
**Why:** `Fit()` grows the window but keeps the old minimum, so the WM can shrink it below the new
content; after removing content `Fit()` cannot shrink below the stale minimum either.
```cpp
add_extra_row(); Fit(); // Wrong: stale minimum
add_extra_row(); GetSizer()->SetSizeHints(this); // Right
```
Cite: `PrinterPartsDialog::Show` (re-hints after showing/hiding its rows).
- **Rule:** Keep content within a dialog's `SetMaxSize` by capping a scrolled region.
**Why:** min > max hints are silently rejected (`wincmn.cpp:1041`) and the dialog has no minimum.
Cite: `MsgDialog` (`MSG_DLG_MAX_SIZE` caps height only, "ban setting the maximum width value") with
`add_msg_content` capping the scrolled text.
## Re-layout after content or visibility changes
**Contract.**
- `wxSizer::Layout()` recomputes min sizes and repositions items inside the sizer's **current**
rectangle (`sizer.h:705-710`, `sizer.cpp:1272`); `wxWindow::Layout()` does the same within the window's
size. Neither propagates upward. If a change alters a container's min size, `Layout()` the nearest
ancestor whose allocation must change; if the top-level window itself must grow or shrink, call
`GetSizer()->SetSizeHints(tlw)`. Manual `Layout()` is needed only after a content change that does not
come with a size change; a resize lays out automatically.
- "To make a sizer item disappear, use Hide() followed by Layout()" (`sizer.h:569-603`). `wxWindow::Show`
only flips the flag; the ports do not re-lay out the parent (`wincmn.cpp:1128`). A window item is shown
exactly when the window `IsShown()` (`sizer.cpp:863`) unless it carries `wxRESERVE_SPACE_EVEN_IF_HIDDEN`
(next item), so `win->Hide()` and `sizer->Hide(win)` are
equivalent for layout. `sizer->Show(win, …)` with `recursive = false` returns false and does nothing when
`win` sits in a nested sizer. Hiding is honoured only by `wxBoxSizer` and `wxFlexGridSizer`
(`docs/doxygen/overviews/sizer.h:134`).
- `wxRESERVE_SPACE_EVEN_IF_HIDDEN` (`wxSizerFlags::ReserveSpaceEvenIfHidden()`) makes `wxSizerItem::IsShown()`
return true whatever the window's state (`sizer.cpp:863-865`; doc `interface/wx/sizer.h:94-99, 1343-1345`).
The hidden item keeps its min size and position, since `wxBoxSizer::CalcMin` and `RepositionChildren` skip
only `!IsShown()` items (`sizer.cpp:2745, 2428`), and its `wxFlexGridSizer` row or column does not collapse
(`sizer.cpp:1957`): showing or hiding it moves no neighbour and resizes no parent. `sizer->IsShown(win)`
then reports true for a hidden window (`sizer.cpp:1562`); ask `win->IsShown()`. On a sizer item the flag
reserves only the nested sizer's min size, which still skips that sizer's hidden children
(`wxSizerItem::CalcMin`, `sizer.cpp:680-682`), so a nested sizer whose children are all hidden collapses to
the item's border (or the nested sizer's `SetMinSize`); put the flag on the window items.
- `wxStaticText::SetLabel` resizes the control itself (unless `wxST_NO_AUTORESIZE`) but never re-lays out
the parent (`src/common/stattextcmn.cpp` `AutoResizeIfNecessary`).
- `SendSizeEvent()`: "if the frame is using either sizers or constraints … it is enough to call
wxWindow::Layout() directly and this function should not be used in this case" (`window.h:1668-1686`).
`PostSizeEvent()` queues it instead (`window.h:1653-1658`), which defers a relayout past the current
handler and the GTK allocation.
**Usage.**
```cpp
row->Show(enabled); // or sizer->Show(row_sizer, enabled)
GetParent()->Layout(); // or the ancestor whose min size changed
// top-level window must change size too:
GetSizer()->SetSizeHints(this);
```
**OrcaSlicer.** Page switching by sizer visibility: the `wxEVT_TAB_SEL_CHANGED` handler in
`PreferencesDialog` runs `Freeze(); f_sizers[i]->Show(i == selection); Layout(); Thaw();`.
`PreferencesDialog::UpdateSidebarLayout` re-lays out the sidebar inside `Freeze()/Thaw()` and then calls
`plater->PostSizeEvent()` so the plater re-lays out after GTK has allocated. Show/hide driven by hover
must not run inside enter/leave handlers (it re-fires them); see `references/mouse-keyboard-focus.md`.
Reserved space: `KBShortcutsDialog::create_page` adds each editable row's reset button with
`wxALIGN_CENTRE_VERTICAL | wxRESERVE_SPACE_EVEN_IF_HIDDEN`, so `KBShortcutsDialog::apply_bindings` toggles
`reset->Show(is_customized)` without the buttons column changing width.
## Adding items: proportion, flags, wxSizerFlags
**Contract.**
- `Add(win, int proportion = 0, int flag = 0, int border = 0, userData = nullptr)`
(`interface/wx/sizer.h:186`). Proportion is the **second** argument; `Add(w, wxEXPAND)` passes
`0x2000` as a proportion.
- Proportion acts only along the sizer's direction; `wxEXPAND` and alignment act only across it. Default
alignment is left/top.
- **Proportion inflates the min size [source].** `wxBoxSizer::CalcMin` (`sizer.cpp:2728`) sizes the main
direction as `max_i(min_i / prop_i) × Σprop + Σ(fixed items)`. Two `proportion = 1` items with min
widths 100 and 300 make the sizer at least 600 wide, which also widens a `SetSizerAndFit` dialog. The
overview's "half the extra space each" (`overviews/sizer.h:114`) is outdated: 3.x distributes the
total space by proportion with min-size floors (`wxBoxSizer::RepositionChildren`, `sizer.cpp:2402`).
- Box-sizer flag rules, checked only by compiled-out asserts in `wxBoxSizer::DoInsert` (`sizer.cpp:2295`):
a vertical box ignores `wxALIGN_BOTTOM` and `wxALIGN_CENTRE_VERTICAL` (unless combined with
`wxALIGN_CENTRE_HORIZONTAL`, i.e. `wxALIGN_CENTRE`); a horizontal box ignores `wxALIGN_RIGHT` and
`wxALIGN_CENTRE_HORIZONTAL` (unless with `wxALIGN_CENTRE_VERTICAL`); `wxEXPAND` without `wxSHAPED`
overrides every alignment. In `wxGridSizer`, `wxEXPAND | wxALIGN_CENTRE_VERTICAL` means "expand
horizontally, centre vertically". `DisableConsistencyChecks()` (`sizer.h:1609`) is irrelevant in Orca.
- Parent rule: windows managed by a sizer must be children of the sizer's containing window, or of a
`wxStaticBox` inside it (`sizer.cpp` `CheckExpectedParentIs`); otherwise they are positioned in the
wrong coordinate space, silently.
- `wxSizerFlags` (`include/wx/sizer.h:40-240`): `Align()`/`Centre()` **replace** all alignment bits,
while `Left/Right/Top/Bottom/CentreHorizontal/CentreVertical` set one axis (`:60-75`).
`Border(dir, px)` takes raw pixels and the doc prefers the default border "to avoid too small borders
… with high DPI" (`interface/wx/sizer.h:1502-1519`). The default border is 6 on GTK and 5 on macOS (not
scaled); on MSW it is `5 × GetDPIScaleFactor()` of **`wxApp::GetMainTopWindow()`**, not of the window
being laid out (`include/wx/sizer.h:125-141`, `sizer.cpp:172` **[source]**; doc `sizer.h:1652-1661`).
`FixedMinSize()` (`wxFIXED_MINSIZE`) copies the window's current size into its min size when added
(`sizer.cpp:395-409`). `Shaped()` keeps the aspect ratio. `ReserveSpaceEvenIfHidden()` keeps a hidden
item's space (`wxRESERVE_SPACE_EVEN_IF_HIDDEN`, `sizer.h:1641-1650`; see
[Re-layout](#re-layout-after-content-or-visibility-changes)).
- `AddSpacer(n)`: in `wxSizer` it adds `n × n` (a whole cell in grid sizers); in `wxBoxSizer` only along
the main direction. `AddStretchSpacer(p)` is `Add(0, 0, p)` (`sizer.h:300-331`).
**OrcaSlicer.** Orca uses int flags with explicit DIP borders,
`Add(w, 0, wxEXPAND | wxALL, FromDIP(10))`, rather than `wxSizerFlags::Border()` defaults, so spacing is
identical on every port instead of 5/6 px (and main-window DPI on MSW).
**Pitfalls**
- **Rule:** Pass the proportion before the flags.
```cpp
sizer->Add(ctrl, wxEXPAND); // Wrong: proportion 0x2000, no flags
sizer->Add(ctrl, 0, wxEXPAND); // Right
sizer->Add(ctrl, wxSizerFlags(1).Expand().Border(wxALL, FromDIP(5))); // Right
```
- **Rule:** Do not combine `wxEXPAND` with an alignment in a box sizer, and align only across the sizer.
**Why:** EXPAND wins; a main-direction alignment is ignored. The assert that names the conflict is
compiled out, so the flag silently does nothing.
```cpp
vbox->Add(x, 0, wxEXPAND | wxALIGN_CENTER_VERTICAL); // Wrong: alignment ignored
vbox->Add(x, 0, wxALIGN_CENTER_VERTICAL); // Wrong: main-direction alignment
vbox->Add(x, 0, wxALIGN_CENTER_HORIZONTAL); // Right
```
- **Rule:** A fixed-size neighbour of a stretching item gets proportion 0.
**Why:** proportions inflate `CalcMin`, widening the fitted dialog.
## Ownership and removal
**Contract.**
- "Sizers, like child windows, are owned by the library and will be deleted by it which implies that
they must be allocated on the heap. However if you create a sizer and do not add it to another sizer or
window, the library wouldn't be able to delete such an orphan sizer and in this, and only this, case it
should be deleted explicitly" (`interface/wx/sizer.h:45-49`). Sizers own child sizers and spacers, not
child windows; a sizer has exactly one owner: a parent sizer or one `SetSizer`.
- `Detach(win|sizer|index)` never destroys and "does not cause any layout or resizing to take place, call
Layout() to update the layout 'on screen'" (`sizer.h:416-451`). `Remove(wxSizer*)`/`Remove(index)`
destroy sizers and spacers; `Remove(wxWindow*)` is deprecated and does **not** destroy the window
despite its name (`sizer.h:789-835`). `Clear(delete_windows = false)` always deletes child sizers and
destroys child windows (via `Destroy()`) only with `true` (`sizer.h:378-389`, `sizer.cpp:1169`).
`Replace(oldwin, newwin)` neither hides nor destroys `oldwin`, which stays on screen at its last
position; `Replace(oldsizer, newsizer)` deletes the old sizer (`sizer.h:836-882`).
- A destroyed window detaches itself from its containing sizer (`wincmn.cpp:510`). Deleting or replacing
a sizer, including `SetSizer(new)` with `deleteOld`, only clears its windows' containing-sizer pointers
(`sizer.cpp:519` `wxSizerItem::Free`): the old controls stay alive and visible as unmanaged ghosts.
- A window belongs to one sizer: "Adding a window already in a sizer, detach it first!"
(`wincmn.cpp:2421`). In Orca the check is silent and the item is still appended, so the second sizer
holds a dangling pointer once the window dies.
- `wxStaticBoxSizer` owns its box; its destructor destroys the box with `WXDestroyWithoutChildren`, which
reparents the box's children to the box's parent instead of destroying them (`sizer.cpp:2822`).
**Pitfalls**
- **Rule:** Destroy old windows when rebuilding a panel.
```cpp
panel->SetSizer(build_rows()); // Wrong: old rows stay as ghosts
panel->DestroyChildren(); panel->SetSizer(build_rows()); panel->Layout(); // Right
// or: old_sizer->Clear(true) before refilling it
```
- **Rule:** `Detach` a window before adding it to another sizer.
**Why:** the silent `wxCHECK` leaves both sizers pointing at it.
- **Rule:** After `Replace(old, neu)`, `old->Destroy()` (or `Hide()`) and `Layout()`.
**Why:** `Replace` leaves `old` drawn at its last position.
## Specific sizers
**`wxStaticBoxSizer`** — "strongly encouraged to create the windows which are added … as children of
wxStaticBox itself … creating them using the static box parent as parent still works too (but note that
items using different parents can't be used inside the same sizer" (`interface/wx/sizer.h:2036-2045`).
Use `sz->GetStaticBox()` as the parent. **[source]** Box-children are positioned box-relative: (0,0) on
GTK, a fixed 10 px inset on macOS, the static borders on MSW (`wxStaticBoxSizer::RepositionChildren`,
`sizer.cpp:2888`); mixing the two parents mispositions one group. Orca: `LabeledStaticBox`
(`Widgets/LabeledStaticBox.hpp`, a `wxStaticBox`) is the box to use (`new wxStaticBoxSizer(stb, wxVERTICAL)`
in `OptionsGroup` and `calib_dlg.cpp`); those layouts parent all items to the dialog, which is valid as long
as no item in that sizer is parented to the box.
**`wxGridSizer`** — every cell gets the size of the largest item; `cols` alone lets rows grow; with both
`rows` and `cols` given, at most `rows × cols` items are allowed (`sizer.h:1897-1944`). **[source]** Adding
more is an assert in a debug wx; in Orca `wxGridSizer::DoInsert` silently forgets the row count and lets rows
grow (`sizer.cpp:1642-1670`). Hidden items still occupy their cells (`wxGridSizer::RepositionChildren`,
`sizer.cpp:1711`, has no `IsShown` check).
**`wxFlexGridSizer`** — per-row heights and per-column widths; growables via `AddGrowableCol(idx, prop)`;
if all proportions are 0, all growables share equally; re-adding an index requires `RemoveGrowableCol`
first (`sizer.h:1770-1792`). Indices are checked only against fixed ctor counts (`sizer.cpp:2235-2262`).
`SetFlexibleDirection`/`SetNonFlexibleGrowMode` "do not trigger relayout" (`sizer.h:1859-1878`). **[source]**
Hidden items keep their cell; a row or column (gap included) collapses only when all its items are hidden
(`wxFlexGridSizer::FindWidthsAndHeights`, `SumArraySizes`). A growable
column grows the cell, not the control: the item also needs `wxEXPAND`.
```cpp
if (!flex->IsColGrowable(1)) flex->AddGrowableCol(1, 1); // rebuild-safe
flex->Add(value_ctrl, 0, wxEXPAND); // fill the grown cell
```
**`wxGridBagSizer`** — `Add(win, wxGBPosition, wxGBSpan, flag, border)` returns `nullptr` when the cell is
occupied (`interface/wx/gbsizer.h:82-95`) and the window stays unmanaged: check the result.
`SetEmptyCellSize` sets the size of empty rows and columns (`gbsizer.h:195`).
**`wxWrapSizer`** — lays items out along the primary direction and wraps to new lines;
`wxEXTEND_LAST_ON_EACH_LINE | wxREMOVE_LEADING_SPACES` is the default (`interface/wx/wrapsizer.h`).
**[source]** Before it has been given a width its min size is the largest single item
(`wxWrapSizer::CalcMin` → `CalcMaxSingleItemSize`, `src/common/wrapsizer.cpp:182, 243`), so a dialog fitted
before the first layout is sized for one item per line. Put it where it receives a definite width
(`wxEXPAND` in a vertical box under a container with a known width) and re-fit after the first `Layout()`.
**`wxStdDialogButtonSizer`** — `AddButton` accepts only the stock ids (`wxID_OK/YES/SAVE/APPLY/CLOSE/NO/
CANCEL/HELP/CONTEXT_HELP`); other ids go through `SetAffirmativeButton`/`SetNegativeButton`/`SetCancelButton`;
`Realize()` must be called to order and space the buttons; order follows the platform, and on macOS a
`wxID_SAVE` button is relabelled "Save" and `wxID_NO` "Don't Save" (`sizer.h:1030-1151`). Orca uses
`DialogButtons` instead
([Orca idioms](#orcaslicer-layout-idioms-and-spacing-conventions)).
**Books and splitters** (other API: `references/controls-dataview.md`). **[source]** A book control's best
size is the **max over all pages** unless the protected `SetFitToCurrentPage(true)` was called
(`wxBookCtrlBase::DoGetBestSize`, `src/common/bookctrl.cpp:125-148`), so a large hidden page enlarges a fitted
dialog. `wxSplitterWindow::SetMinimumPaneSize` takes pixels: pass `FromDIP(n)`.
## wxEVT_SIZE handlers
**Contract.** "Sizers rely on size events … in a sizer-based layout, do not forget to call Skip on all
size events you catch" (`interface/wx/event.h:5058-5060`). Automatic layout is the static-table handler
`wxWindowBase::InternalOnSize` (`wincmn.cpp:124, 2489`) and `wxTopLevelWindowBase::OnSize`
(`toplvcmn.cpp:40`). `Bind()` handlers run before static tables, so a bound handler that does not
`Skip()` suppresses auto-layout (handler order: `references/events.md`). **[source]** `wxScrolled<>` always
runs its own size handling after user handlers, skipped or not (`src/generic/scrlwing.cpp:203-214`).
**Pitfalls**
- **Rule:** Skip, don't `Layout()` (it runs anyway) and never `SetSize` the same window inside its size
handler; defer other relayouts with `CallAfter` or `PostSizeEvent`.
```cpp
Bind(wxEVT_SIZE, [this](wxSizeEvent&) { recompute(); }); // Wrong: no auto-layout
Bind(wxEVT_SIZE, [this](wxSizeEvent& e) { recompute(); e.Skip(); }); // Right
```
Cite: `Label::OnSize`, `CenteredMultiLinePanel::OnSize` (both skip).
New code binds with `Bind()` and lambdas; no new static event tables (`references/events.md`).
## Freeze / Thaw
**Contract** (`window.h:2203-2232`). `Freeze()` suppresses painting of the window and, recursively, its
non-top-level children; calls nest and must balance; it is "mostly just a hint". **[source]** Children
added while frozen are frozen too, removed ones are thawed (`wxWindowBase::AddChild`/`RemoveChild`).
Freezing does **not** stop layout or size events. RAII: `wxWindowUpdateLocker` (`include/wx/wupdlock.h:19`).
**Platforms [source].** MSW: `Freeze()` on a hidden window only counts; the native redraw lock is skipped
(`wxWindowMSW::DoFreeze`, `src/msw/window.cpp:1659`) and applied by a later `Show()` while still frozen
(`wxWindowMSW::Show`). macOS: `Refresh()` returns early while `IsFrozen()` or not shown
(`src/osx/window_osx.cpp:1340-1346`).
**Pitfalls**
- **Rule:** Balance on every path; prefer `wxWindowUpdateLocker`.
**Why:** an unmatched `Freeze()` leaves the window and its children unpainted. An unmatched `Thaw()`
is worse: `m_freezeCount` is `unsigned` (`include/wx/window.h:2063`) and the "Thaw() without matching
Freeze()" assert is compiled out, so the count wraps to `UINT_MAX`, `IsFrozen()` stays true from then on
(a later balanced pair only drops it to 0 between its `Freeze()` and `Thaw()`), and on macOS the window
stops repainting.
```cpp
Freeze(); if (!rebuild()) return; Thaw(); // Wrong: early return leaves it frozen
wxWindowUpdateLocker lock(this); if (!rebuild()) return; // Right
```
**OrcaSlicer.** `wxWindowUpdateLocker` brackets bulk rebuilds in `Tab.cpp`, `ParamsPanel.cpp`,
`PresetComboBoxes.cpp`, `GUI_ObjectSettings.cpp`; `DPIAware::rescale` wraps `on_dpi_changed` in
`Freeze()`/`Thaw()`.
## Scrolled windows
**Contract** (`interface/wx/scrolwin.h`).
- `wxScrolledWindow` (`wxScrolled<wxPanel>`) hosts child controls; `wxScrolledCanvas`
(`wxScrolled<wxWindow>`) is for drawn content; `wxScrolled<wxControl>` is not advised (`:24-40`).
- **Scrolling is off until a rate is set**: "scrolling is only enabled in orientations with a non-zero
increment" (`:57-66`); both rates start at 0. Vertical-only: `SetScrollRate(0, FromDIP(n))`.
- With a sizer, the virtual size follows the sizer; "if you add or remove any elements to the sizer, you
need to call wxSizer::FitInside() to adjust the virtual size" (`:66-68`). `wxWindow::FitInside()` =
`SetVirtualSize(GetBestVirtualSize())` (`wincmn.cpp:631`); `wxSizer::FitInside(win)` "will not alter the
on screen size" (`sizer.h:465-473`). **[source]** A size event re-derives the virtual size
(`wxScrollHelperBase::HandleOnSize`, `scrlwing.cpp:924`).
- **Best size ignores content in a scrolling direction [source]**: with a sizer, in each direction with a
non-zero rate the best size is `GetMinSize() + scrollbar thickness` (`wxScrolledT_Helper::FilterBestSize`,
`scrlwing.cpp:1594-1634`: "If the app needs some minimal size for its scrolled window, it should set it
and put the window into sizer as expandable"). Without `SetMinSize` and proportion/`wxEXPAND` it
collapses to about the scrollbar width.
- `EnableScrolling(x, y)` toggles **physical** (blit) scrolling; it does not enable or disable a
direction (`:338-356`). Direction is chosen by the scroll rate and the `wxHSCROLL`/`wxVSCROLL` style.
- `ShowScrollbars(horz, vert)` (`wxSHOW_SB_NEVER/DEFAULT/ALWAYS`) works only after creation; the
`wxALWAYS_SHOW_SB` style is the ctor-time equivalent for both directions (`:358-384`).
- Children report physical positions: a child at (10,10) reports (10,-90) after scrolling 100 px
(`:90-96`). `GetViewStart()` is in scroll units; `GetViewStartPixels()` is new in 3.3.2 (`:405-458`).
- `SetTargetWindow(w)` requires overriding `GetSizeAvailableForScrollTarget()` (`:602-617, 719-731`).
- A focused child is scrolled into view; override `ShouldScrollToChildOnFocus` to opt out (`:705-717`).
Mouse-drag autoscroll is configurable with `EnableAutoScrollInside`/`DisableAutoScrollOutside` (new in
3.3.2, `:628-661`).
- Drawing in a scrolled window (`OnDraw`, `DoPrepareDC`, `DoPrepareReadOnlyDC`): see
`references/painting-custom-widgets.md §DC coordinates and scale under DPI`.
**Usage — "shrink to content, scroll beyond a cap":**
```cpp
auto sw = new wxScrolledWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL);
sw->SetScrollRate(0, FromDIP(20));
sw->SetSizer(content);
// after every content change:
int h = std::min(content->GetMinSize().y, cap);
sw->SetMinSize(wxSize(-1, h));
sw->FitInside();
parent->Layout();
```
Orca: `Sidebar::update_filaments_area_height` (`SetMaxSize` from a preferred row count, then
`SetMinSize({-1, min(sizer min, max)})` on `m_panel_filament_content`, with `FitInside()` after rebuilds);
`add_msg_content` in `MsgDialog.cpp` (a `wxScrolledWindow(wxVSCROLL)` with `SetScrollRate(0, FromDIP(20))`, a
`Label(…, LB_AUTO_WRAP)` with min = max width, the window's min = max size set to
`(info_width, min(content, 48 * em))`, then `FitInside()`), which keeps the message within the dialog max.
**Orca `ScrolledWindow`** (`Widgets/ScrolledWindow.hpp`) — a `wxScrolled<wxWindow>` that hides the native
bars (`ShowScrollbars(NEVER, NEVER)`) and draws slim `MyScrollbar`s in a margin strip:
- content goes on `GetPanel()`, the `SetTargetWindow` target (`GetSizeAvailableForScrollTarget` is not
overridden);
- the ctor builds its inner windows from the passed `size`, so pass a real size;
- the virtual size is set manually in pixels: `SetScrollbars(1, 1, w, h)` or its own `SetVirtualSize`,
which only **hides** the non-virtual `wxWindow::SetVirtualSize` — `FitInside()` and calls through a
`wxWindow*` bypass the custom bars;
- its size handler resizes the inner panels and calls `Layout(); AdjustScrollbars();`;
- use it with `wxVSCROLL` only: `SetBackgroundColour` dereferences the vertical-only members.
```cpp
// SearchDialog::SearchDialog (Search.cpp)
auto sw = new ScrolledWindow(parent, wxID_ANY, wxDefaultPosition, wxSize(W, H), wxVSCROLL, 6, 6);
auto list = new wxWindow(sw->GetPanel(), wxID_ANY);
list->SetSizer(s); list->Fit();
sw->SetScrollbars(1, 1, 0, list->GetSize().GetHeight());
```
Ordinary dialogs and the sidebar use plain `wxScrolledWindow`.
**Pitfalls**
- **Rule:** A scrolled area that "doesn't scroll" lacks `SetScrollRate` or a `FitInside()` after the
content changed; one that collapses lacks a min size or proportion + `wxEXPAND`.
- **Rule:** Forbid horizontal scrolling with `SetScrollRate(0, y)` and/or a `wxVSCROLL`-only style.
```cpp
sw->EnableScrolling(false, true); // Wrong: only disables blit scrolling
sw->SetScrollRate(0, FromDIP(20)); // Right
```
## wxStaticText wrapping and ellipsizing
**Contract.**
- `Wrap(width)` breaks lines at word boundaries and **modifies the label** (`interface/wx/stattext.h:119-130`,
`include/wx/stattext.h:37-40`). `width < 0` means no wrapping; the width is not exact because of borders.
`Wrap` is not virtual.
- **3.3.2 wrap cache [source].** `wxStaticTextBase::Wrap` returns at once when `width == m_currentWrap`
(`src/common/stattextcmn.cpp:259-263`); `SetLabel` → `UpdateLabelOrig` clears the saved unwrapped
label but **not** `m_currentWrap` (`:354-365`). So `SetLabel(new); Wrap(sameWidth);` leaves the new
text unwrapped. `Wrap(-1); Wrap(w);` resets the cache.
- wx breaks only at whitespace and outputs an unbreakable run whole (`stattextcmn.cpp:184-215`): CJK text
without spaces never wraps with `wxStaticText::Wrap`.
- `wxST_WRAP` (new in 3.3.2): "Wrap label text on multiple lines if necessary, using the available
horizontal space. This style only works when the control is used inside a sizer"
(`interface/wx/stattext.h:46-49`; `docs/changes.txt:280`). It is opt-in; labels without it are
unaffected. It is implemented with `GetMinSizeFromKnownDirection` (`stattextcmn.cpp:285-312`).
**[source]** The initial `CalcMin` still uses the unwrapped best size, so a fitted dialog grows to the
full one-line width and nothing wraps; constrain the width another way (a fixed or min width on the
container, `SetMaxSize`).
- `SetLabel` resizes the control to its best size unless `wxST_NO_AUTORESIZE`, which right- or
centre-aligned labels whose width the sizer sets need (`stattext.h:30-36`); it never re-lays out the
parent; it does nothing when the text is unchanged (`stattext.h:107-117`).
- `wxST_ELLIPSIZE_*` ellipsize only when the control is narrower than its text (`stattext.h:37-45`); the
best size is always the full text, so the sizer allots the full width unless something limits it: a
width cap (`SetMaxSize(wxSize(w, -1))`), or an explicit min width plus `wxST_NO_AUTORESIZE`. **[source]**
The generic ellipsizer does nothing while the client width or height is < 2 px
(`wxStaticTextBase::Ellipsize`).
- Mnemonics, markup and `SetLabelText`: `references/controls-dataview.md`.
**Platforms [source].** GTK: `GtkLabel` always has line wrap on, so a `wxStaticText` given less width
than its text wraps natively, while MSW and macOS clip it; best width adds 1 px to avoid spurious wraps
(`src/gtk/stattext.cpp:126, 239-296`). `SetFont` on a hidden label makes wx measure the text itself
because the GTK style cache is stale (`gtk/stattext.cpp:178-190`). GTK2 only: a centre/right-aligned label
silently gets `ELLIPSIZE_MIDDLE`/`START` (`gtk/stattext.cpp:63-90`). MSW uses native end-ellipsis only for
single-line `wxST_ELLIPSIZE_END` labels; other modes are generic (`src/msw/stattext.cpp:233-256`).
**OrcaSlicer `Label`** (`Widgets/Label.hpp`, `: wxStaticText`):
- keeps the original text in `m_text`; `Label::Wrap(int)` **hides** the non-virtual `wxStaticText::Wrap`,
wraps `m_text` with Orca's `wxTextWrapper2` (breaks between ideographs above U+4E00 and hard-breaks
space-less runs) and sets the result through the base `SetLabel` with `m_skip_size_evt` set; it has no
width cache;
- `LB_AUTO_WRAP` binds `wxEVT_SIZE` (with `Skip()`) and re-wraps at the new width; `Label::SetLabel`
re-wraps at `GetSize().x` under `LB_AUTO_WRAP` and returns early when the text is unchanged;
- the label needs a real width: a ctor size `wxSize(FromDIP(w), -1)`, min = max width, or `wxEXPAND` in a
vertical sizer whose container width is fixed;
- `Label::split_lines(dc, width, text, out, max_count)` exposes the same wrapper for owner-drawn text.
Orca's wrapping is preferred over `wxST_WRAP` for CJK text.
**Pitfalls**
- **Rule:** Re-wrapping after `SetLabel` must defeat the wrap cache.
```cpp
st->SetLabel(msg); st->Wrap(w); // Wrong when w is unchanged: stays unwrapped
st->SetLabel(msg); st->Wrap(-1); st->Wrap(w); // Right
// or: Label with LB_AUTO_WRAP / label->Wrap(w) through a Label*
```
- **Rule:** Call `Wrap` through a `Label*`, never through a `wxStaticText*` that points at a `Label`.
**Why:** the base `Wrap` sets the wrapped text through the virtual `Label::SetLabel`, which overwrites
`m_text` with the wrapped copy (and re-wraps it under `LB_AUTO_WRAP`).
- **Rule:** Never compute or commit a size or wrap from a width that has not been laid out; guard with a
sanity check on the client width.
**Why:** a plain `wxWindow`/`wxPanel` child created with `wxDefaultSize` is wx's 20×20 placeholder on
every port until the first sizer layout **[source]** (`include/wx/window.h:1914-1915` `WidthDefault`/
`HeightDefault`; native controls instead take their best size through `SetInitialSize` in `Create()`),
and a minimized top-level window reports a (0,0) client size. Wrapping against that width commits a garbage min size, which `SetSizeHints` then locks in.
```cpp
void UpdateMinSize() {
int cWidth = GetClientSize().GetWidth();
if (cWidth < 50) return; // not laid out yet: don't commit a size
/* wrap at cWidth, SetMinSize(wxSize(-1, h)), GetParent()->Layout() */
}
```
Cite: 5ede9711f5 (`CenteredMultiLinePanel::UpdateMinSize` and `::OnPaint` in `TroubleshootDialog.hpp`).
- **Rule:** Give a wrapping label a fixed width and `-1` height, then wrap.
**Why:** the ctor size becomes the min size (`SetInitialSize`), so a fixed height clips the text when a
translation is longer.
```cpp
new wxStaticText(this, wxID_ANY, txt, wxDefaultPosition, wxSize(FromDIP(490), FromDIP(40))); // Wrong
m_action_line = new wxStaticText(this, wxID_ANY, wxEmptyString, wxDefaultPosition,
wxSize(FromDIP(490), -1)); // Right
m_action_line->Wrap(width);
```
Cite: 5ede9711f5 (`UnsavedChangesDialog`, `UNSAVE_CHANGE_DIALOG_ACTION_LINE_SIZE`, `m_action_line`).
- **Rule:** An ellipsized label needs a width limit.
**Why:** its best size is the full text, so the sizer gives it the full width and nothing ellipsizes.
## Layout on DPI change
**What wx does per platform.**
| Platform | When `DPIAware::on_dpi_changed` runs | What wx already rescaled |
|---|---|---|
| MSW (per-monitor v2 manifest) | `wxEVT_DPI_CHANGED`; also `wxEVT_MOVE_END` when the scale differs | **[source]** before the event, `MSWUpdateOnDPIChange` rescales every window's min/max size, the fonts of non-top-level windows (the TLW keeps its font), every sizer item's border, spacer and nested-sizer min sizes, and invalidates best sizes (`src/msw/window.cpp:5002-5085`; detail in `references/dpi-bitmaps-fonts.md` §wxEVT_DPI_CHANGED) |
| GTK3 | `wxEVT_DPI_CHANGED` when the integer GDK scale changes (GTK ≥ 3.10, `src/gtk/toplevel.cpp:336-350`) | nothing; logical pixels are DIPs, so DIP sizes stay valid |
| macOS | never: wx does send `wxEVT_DPI_CHANGED` on backing-scale changes **[source]** (`src/osx/cocoa/nonownedwnd.mm` `windowDidChangeBackingProperties`), but `DPIAware` binds it only `#ifndef __WXOSX__` | nothing needed; Cocoa scales |
A top-level window's default `wxEVT_DPI_CHANGED` handler resizes it, and a handler that does not `Skip()`
suppresses that (`interface/wx/event.h:3572-3584`). `DPIAware`'s handler does not `Skip()`, so on MSW wx
skips its own resize of the top-level window (`src/msw/nonownedwnd.cpp:284-318` `HandleDPIChange`): on MSW
the dialog must resize itself in `on_dpi_changed`. `DPIAware::rescale` (`src/slic3r/GUI/GUI_Utils.hpp`) runs
`Freeze(); update_em_unit(); on_dpi_changed(rect); Layout(); Thaw();`. The em machinery: see
`references/dpi-bitmaps-fonts.md`.
`em_unit` per platform (`DPIAware::update_em_unit`): MSW `max(10, 10 × dpi/96)`; macOS always 10 (Orca's
`get_dpi_for_window` returns 96 there); GTK `max(10, GetTextExtent("m").x − 1)`, i.e. from the font. So
on GTK an `n * em` size tracks the system font size while `FromDIP(n)` does not; pick one consistently
for related sizes.
**What `on_dpi_changed` re-applies.** Everything wx does not own:
- sizes set with `SetSize` or computed into members (cached metrics, wrap widths);
- `wxDataViewCtrl::SetRowHeight`, column widths and other setter-held values (`references/controls-dataview.md`);
- bitmaps and `ScalableBitmap`s (`references/dpi-bitmaps-fonts.md`);
- each Orca widget's `Rescale()` (and `msw_rescale()` where that is its name);
- min sizes expressed in em, recomputed from the new em (a ratio-free recompute is safe on MSW even
though wx already rescaled the stored value);
- then the top-level resize.
**Usage — the reconciled body:**
```cpp
void MyDialog::on_dpi_changed(const wxRect&) // MyDialog : DPIDialog; members illustrative
{
const int em = em_unit();
msw_buttons_rescale(this, em, {wxID_OK, wxID_CANCEL}); // raw wxButtons only: min height 2.5 em (omit with DialogButtons, below)
m_apply_btn->Rescale(); // Button / TextInput / ComboBox / SpinInput
m_logo.msw_rescale(); // ScalableBitmap
m_logo_ctrl->SetBitmap(m_logo.bmp()); // the control holds its own copy
m_list->SetMinSize(wxSize(40 * em, -1)); // recompute from em, never multiply
GetSizer()->SetSizeHints(this); // resize + new minimum
Refresh();
} // DPIAware::rescale then calls Layout() and Thaw()
```
`SetSizeHints` both resizes and re-derives the minimum, which a bare `Fit()` does not. A `Fit()` is
acceptable only when a correct minimum already exists (`PurgeModeDialog::on_dpi_changed` sets
`SetMinSize(wxSize(70 * em, 32 * em))` and then `Fit(); Refresh();`). **[source]** A `Refresh()`-only
body (`FlowRateCalibrationDialog::on_dpi_changed`) leaves the dialog at its old pixel size on MSW, so
content is clipped until the user resizes it; on GTK3 it is harmless. An empty override (`CloneDialog`)
is accepted only for trivially simple dialogs, with the same MSW caveat. Code placed only in
`on_dpi_changed` never runs on macOS.
Larger dialogs walk their widgets: `PreferencesDialog::on_dpi_changed` `dynamic_cast`s each child and
calls `Rescale()` on every `Button`, `TextInput`, `ComboBox`, `SpinInput` and `WikiLabel`, then re-runs its
tab switch. In such a walk, qualify `::CheckBox`: inside `Slic3r::GUI` an unqualified `CheckBox` names the
`Field` subclass from `Field.hpp`, which is not a `wxWindow`, so the cast never matches (namespaces:
`references/orca-widgets.md`). `DialogButtons` rescales itself: it binds its parent's `wxEVT_DPI_CHANGED`,
rebuilds its row and `Skip()`s. `msw_buttons_rescale` (`wxExtensions.cpp`) calls
`SetMinSize(wxSize(-1, 2.5 * em))` on whatever window has each id; `DialogButtons` gives its Orca
`Button`s stock ids, so on a dialog with a `DialogButtons` row it would override their style height
through `Button::SetMinSize`; leave it out there.
**Pitfalls**
- **Rule:** Every dialog has an enforced minimum before any DPI/refresh path can resize it, and
`on_dpi_changed` resizes with `GetSizer()->SetSizeHints(this)` rather than an unconditional bare `Fit()`.
**Why:** a `Fit()` on a dialog without a minimum is the f760f4e462 collapse; a `Fit()` with a stale
minimum keeps the old floor; and on MSW a body that does not resize leaves content clipped because
`DPIAware` suppresses wx's own resize.
```cpp
// Wrong
Layout(); Fit(); // ctor: no minimum
void on_dpi_changed(const wxRect&) override { Refresh(); Fit(); }
// Right
Layout(); v_sizer->SetSizeHints(this); // ctor
void on_dpi_changed(const wxRect&) override { /* rescale content */ GetSizer()->SetSizeHints(this); Refresh(); }
```
Cite: f760f4e462 (`calib_dlg.cpp`), 5ede9711f5; `src/msw/nonownedwnd.cpp` `HandleDPIChange`.
- **Rule:** Re-apply sizes that wx does not rescale with the same expression as the constructor;
never scale an existing value by a DPI ratio.
**Why:** setter-held values (row heights, column widths, `SetSize` sizes) never rescale on any port,
and stale pixels clip or overflow content (the object-list filament badge no longer fitting its row).
On MSW, wx already rescaled stored min/max sizes and sizer borders, so `SetMinSize(GetMinSize() * ratio)`
scales twice. On GTK3 and macOS, DIP values need no rescale at all.
```cpp
// ObjectList::create_objects_ctrl and again in ObjectList::msw_rescale
SetRowHeight(2 * em + FromDIP(2)); // same expression, new em
SetMinSize(GetMinSize() * new_scale / old); // Wrong: double scaling on MSW
```
Cite: d5638273c6 (`ObjectList::create_objects_ctrl`, `ObjectList::msw_rescale`).
## Platform summary
- **MSW.** DPI change rescales min/max sizes, non-top-level fonts, sizer borders, spacer and nested-sizer min sizes before
`wxEVT_DPI_CHANGED`; Orca must resize the top-level window itself. The default `wxSizerFlags` border
follows the main window's DPI. `Freeze()` on a hidden window skips the native redraw lock until the window
is shown. A minimized top-level window reports client size (0,0).
- **macOS.** `FromDIP` is the identity and `em_unit` is 10. Default sizer border 5. `wxStaticBoxSizer`
box-children sit at a 10 px inset. `Refresh()` is a no-op while frozen or not shown.
- **GTK3.** Sizer-fitting calls on a hidden top-level window are replayed at `Show()`; `wxWindow::Fit()` is
not. **[source]** `SetFont` before the top-level window is shown queues best-size revalidation at show
(`GTKSizeRevalidate`, `src/gtk/window.cpp:6651-6725`), so best sizes measured in a ctor can be wrong
until then; re-measure on show or DPI change, or let sizers do it. Top-level and popup `SetSize` are
clamped to min/max. WM geometry hints only for `wxRESIZE_BORDER`. `GtkLabel` wraps natively when given
less width. Default sizer border 6. A minimized top-level window reports client size (0,0)
(`toplevel.cpp:1463`). On X11 the first `Show()` of a top-level window may be deferred until
`_NET_FRAME_EXTENTS` arrives (`toplevel.cpp:1140-1240`), so geometry is not final right after `Show()`
(window showing: `references/windows-dialogs.md`). em follows the font.
- **GTK2** (opt-out build). `FromDIP` is the identity in effect (fixed 96 PPI); centre/right-aligned labels
are implicitly ellipsized.
- **Wayland.** `wxWindow::Update()` "doesn't do anything in wxGTK port when using Wayland"
(`window.h:2405-2407`): `Layout(); Update();` does not force a synchronous repaint. No deferred first show;
with client-side decorations the decoration size counts as 0 for hints (`toplevel.cpp:1519-1523`).
## OrcaSlicer layout idioms and spacing conventions
- **Units.** Every pixel value is `FromDIP(n)` or `n * em_unit()` (`em_unit()` on a `DPIDialog`/`DPIFrame`;
the free `em_unit(wxWindow*)` in `wxExtensions.cpp` returns the enclosing `DPIDialog`/`DPIFrame`'s em,
else `wxGetApp().em_unit()`). Never raw ints, never `wxSizerFlags::Border()` defaults.
- **Spacing conventions.** Outer dialog padding `FromDIP(10)`–`FromDIP(20)` with `wxEXPAND | wxALL`;
inter-widget gaps `FromDIP(4)`–`FromDIP(12)`; `FromDIP(1)` separators; `sizer->AddSpacer(FromDIP(n))`
for vertical rhythm; `AddStretchSpacer()` to push a group to the far side.
- **Separators.** A 1 px `wxPanel` with a light grey background (`FilamentPickerDialog::CreateSeparatorLine`)
or the `StaticLine` widget; avoid `wxStaticLine`.
- **Button rows.** `DialogButtons` (`Widgets/DialogButtons.hpp`, `Slic3r::GUI`) is a `wxPanel` with its own
horizontal `wxBoxSizer`, not a `wxStdDialogButtonSizer`; add it with `main_sizer->Add(dlg_btns, 0, wxEXPAND)`.
`UpdateButtons()` rebuilds the row with `m_sizer->Clear()` (the buttons are the panel's children and
survive), adds a leading gap when no button is left-aligned, a stretch spacer before the right-aligned
group, each button with
`wxRIGHT`/`wxLEFT | wxTOP | wxBOTTOM | wxALIGN_CENTER_VERTICAL` and a `FromDIP(ButtonProps::ChoiceButtonGap())`
border, then `Layout(); Fit();`. The order is the same on every platform, deliberately unlike wx's
per-platform ordering. Older dialogs hand-roll the row with `Button` + `AddStretchSpacer()`;
`DialogButtons` is the form for new code. Construction and labels: `references/orca-widgets.md §DialogButtons`;
its place in a dialog: `references/windows-dialogs.md §8`.
- **Long static text.** Fixed-width `wxStaticText` + `Wrap(FromDIP(w))` once, or `Label` with
`LB_AUTO_WRAP` when the text changes or must wrap CJK ([Wrapping](#wxstatictext-wrapping-and-ellipsizing)).
- **Bulk rebuilds.** `wxWindowUpdateLocker` (or a balanced `Freeze()`/`Thaw()`), rebuild, `Layout()` the
owning ancestor, `SetSizeHints` if the top-level size must change; `PostSizeEvent()` on the plater when
the sidebar's height changed.
- **Size clamps.** Dialogs may clamp with `SetMinSize/SetMaxSize(FromDIP(…))`; keep content within the
max ([Fitting](#fitting-functions-setsizer-setsizerandfit-setsizehints-fit-layout)).
- **Exemplars.** `CloneDialog.cpp` (minimal `SetSizerAndFit` dialog), `MsgDialog.cpp` (late content,
capped scrolled text), `PrintOptionsDialog.cpp` `PrinterPartsDialog::Show` (show/hide rows + re-hint),
`Preferences.cpp` (sizer-visibility pages, height-for-width `WikiLabel`), `Plater.cpp`
`Sidebar::update_filaments_area_height` (capped scrolled list).
@@ -0,0 +1,883 @@
# Strings, translation, files and app services
How text moves between UTF-8 `std::string` and `wxString` under Orca's build flags, how to format
and translate it, and the wx services that carry text or files in and out of the app: file and
directory dialogs, paths, browser/app launching, clipboard, drag and drop, logging, native message
boxes, settings storage, secrets and single-instance handling. Read it before touching any
user-visible string, any file path or any of those services, and when debugging mojibake, empty
strings, untranslated text or a lost `&`.
Contents: [Rules](#rules) · [Build facts](#build-facts-that-decide-string-behaviour) ·
[wxString conversions](#wxstring-encodings-and-conversions) · [Formatting](#formatting) ·
[Translation](#translation) · [Language switching](#language-switching-and-the-translation-lifecycle) ·
[Labels & mnemonics](#labels-mnemonics-and-markup) · [Paths](#paths-and-standard-locations) ·
[File & dir dialogs](#file-and-directory-dialogs) · [Launching](#launching-the-browser-files-and-programs) ·
[Clipboard](#clipboard) · [Drag & drop](#drag-and-drop) · [Logging](#logging-wxlog-vs-boost-log) ·
[Message boxes](#native-message-boxes) · [AppConfig](#settings-appconfig-not-wxconfig) ·
[Secrets](#secrets-wxsecretstore) · [Single instance](#single-instance)
## Rules
1. `std::string` is UTF-8 everywhere outside wx; `wxString` exists only at the wx boundary. Convert
with `from_u8`/`into_u8`, paths with `from_path`/`into_path`. → §wxString conversions
2. Never let a UTF-8 `std::string`/`const char*` reach a `wxString` parameter implicitly (default dir
of a file dialog, `SetLabel`, `wxString::Format("%s", …)`): it is decoded with the C-locale
encoding, the ANSI code page on Windows. → §wxString conversions
3. Never take narrow text out of a `wxString` with `ToStdString()`, `mb_str()` or `c_str()`; use
`into_u8(w)` or `w.utf8_string()`. → §wxString conversions
4. Never keep the pointer from `ToUTF8().data()`, `utf8_str()`, `mb_str()` or `c_str()` beyond the
full-expression; own a `std::string`. → §Pointer lifetime
5. Non-ASCII text in source only through `_L(...)`, `wxString::FromUTF8(u8"…")` or `L"…"`, never
`wxString("°C")`. → §Source literals
6. Build user-visible strings with `format_wxstr`/`GUI::format` and `%1%` placeholders; pass
`std::string` arguments as they are; write a literal percent as `%%`. → §Formatting
7. With `wxString::Format`/`Printf`, match every specifier to its argument type yourself (`%zu`,
casts); Orca compiles the check out. → §Formatting
8. Serialise numbers locale-independently; only display goes through the UI locale. → §Numbers
9. Mark every user-visible literal with an extracted keyword (`L`, `_L`, `_u8L`, `L_CONTEXT`,
`_L_CONTEXT`, `_u8L_CONTEXT`, `_L_PLURAL`); never `_()`, `_utf8()`, `_CHB()` for a new string.
→ §Extraction
10. A new source file with translatable strings is added to `localization/i18n/list.txt`.
→ §Extraction
11. Translate at construction/display time; never cache translated text in a static, namespace-scope
variable or long-lived singleton without a re-localise hook. → §Language switching
12. One complete sentence per msgid, placeholders instead of concatenated fragments, the number
placeholder in both plural forms, `// TRN` for ambiguous strings, a context for homonyms.
→ §Plurals and contexts
13. A wrapper around `wxGetTranslation` returns `wxString` by value. → §Translation
14. User-supplied text (preset, filament, file, printer names) in a mnemonic-interpreting label
goes through `SetLabelText` or `wxControl::EscapeMnemonics`. → §Labels
15. File dialogs: filters from `file_wildcards(FT_*)`, default dir `from_u8(app_config->get_last_dir())`,
results through `GetPaths`/`GetPath` + `into_path`, never `wxFD_CHANGE_DIR`/`wxDD_CHANGE_DIR`,
a real parent window. → §File and directory dialogs
16. Pass only the dialog's own style flags; `wxDD_NEW_DIR_BUTTON` is 0, use `wxDD_DEFAULT_STYLE`.
→ §File and directory dialogs
17. Folders open through `desktop_open_any_folder`/`desktop_open_datadir_folder`; a file that came
from a project is launched only after `is_safe_to_open_file_name`. → §Launching
18. Clipboard access goes through a checked `wxClipboardLocker`; use `SetText`/`wxTextDataObject(text)`,
not `wxTextDataObject::SetData`. → §Clipboard
19. Start `DoDragDrop` from the mouse handler; never replace a drop target from inside its own
callbacks. → §Drag and drop
20. wxLog never reaches the user in Orca; tell the user with `show_error` or a MsgDialog, log with
`BOOST_LOG_TRIVIAL` and stream UTF-8. → §Logging
21. `wxMessageBox` returns `wxYES/wxNO/wxCANCEL/wxOK/wxHELP`, `ShowModal` returns `wxID_*`; UI code
uses the MsgDialog family. → §Native message boxes
22. AppConfig values are set with a `std::string`, never a bare `const char*` third argument, and only
on the main thread; no wxConfig. → §AppConfig
23. Never touch `wxSecretStore` from a timer, poll or other per-tick UI path. → §Secrets
24. Cross-instance requests go through `instance_check` and `OtherInstanceMessageHandler`; do not add
another checker or a wxIPC server. → §Single instance
## Build facts that decide string behaviour
Generic wx advice is often wrong for Orca because of these settings. The installed `setup.h` is
`<wx install>/lib/wx/include/<port>-unicode-static-3.3/wx/setup.h`.
| Setting | Value in Orca | Where |
|---|---|---|
| `wxString` storage | `std::wstring` (`wxUSE_UNICODE_UTF8 0`, `wxUSE_UTF8_LOCALE_ONLY 0`), deep copy, never copy-on-write | installed setup.h; `include/wx/string.h:121-132` |
| implicit `wxString` → `const char*` / `const void*` | **off**: the app defines `wxNO_UNSAFE_WXSTRING_CONV` | top-level `CMakeLists.txt` (`add_definitions(-DwxNO_UNSAFE_WXSTRING_CONV)`, comment "This implicit conversion breaks the UTF-8 encoding quite often"); `include/wx/string.h:1631-1637` |
| implicit `wxString` → `std::string` | **off** (`wxUSE_STD_STRING_CONV_IN_WXSTRING 0`) | installed setup.h; `include/wx/string.h:1376-1385` |
| implicit `std::string` / `const char*` → `wxString` | **on**, decoded with the current locale (`wxNO_IMPLICIT_WXSTRING_ENCODING` is not defined) | `include/wx/string.h:1215-1217, 1323-1325` |
| wx's own `_()` macro | not defined (`-DWXINTL_NO_GETTEXT_MACRO`); Orca defines its own | `CMakeLists.txt`; `include/wx/translation.h:47-49`; `src/slic3r/GUI/I18N.hpp` |
| debug level | wx built with `wxBUILD_DEBUG_LEVEL=0`; `libslic3r_gui` gets `wxDEBUG_LEVEL=0` under `SLIC3R_STATIC` (default ON): `wxASSERT`, `Format` type checks, `wxLogDebug`, `wxLogTrace` compile out; `wxCHECK*` still return, silently | `deps/wxWidgets/wxWidgets.cmake`, `src/slic3r/CMakeLists.txt`; `include/wx/log.h:62-77` |
| printf positional parameters | on (`wxUSE_PRINTF_POS_PARAMS 1`) | installed setup.h |
| MSVC source charset | `/utf-8`, so narrow literals hold UTF-8 bytes | `CMakeLists.txt` (`add_compile_options(... /utf-8)`) |
| clipboard, DnD, secret store, config, single-instance checker, IPC | compiled in (all `wxUSE_* 1`); Orca uses its own config and messaging, and the wx checker only on Windows | installed setup.h |
Because asserts are compiled out, every misuse described below fails silently in Orca: wrong or
empty text, a dropped call, a leaked object. wx would assert in a debug build; Orca never shows it.
## wxString encodings and conversions
**Contract.** A narrow `char*`, `std::string` or `std::string_view` given to wxString "supposes that
the string contains data in the current locale encoding, use FromUTF8() if the string contains
UTF-8-encoded data instead"; the implicit constructors are "dangerous … the resulting string will be
empty if the conversion from the current locale encoding fails" (`interface/wx/string.h:58-89`).
In the other direction `c_str()`/`mb_str()` are "potentially destructive … an empty string is
returned if the conversion fails", and `ToStdString()` loses data unless given `wxConvUTF8`; use
`utf8_string()` (`interface/wx/string.h:108-120, 843-863`). `FromUTF8`: "If s is not a valid UTF-8
string, an empty string is returned" (`interface/wx/string.h:2037`). The Unicode overview calls
`FromUTF8()` followed by `c_str()` "a recipe for disaster … may work perfectly well during testing on
Unix systems using UTF-8 locale but completely fail under Windows" (`docs/doxygen/overviews/unicode.h:314-320`).
**What "current locale encoding" is** (`wxConvLibc`, `src/common/strconv.cpp:3403-3409`) [source]:
| Platform | Narrow ↔ wide conversion | Consequence for UTF-8 data |
|---|---|---|
| MSW | `wxMBConv_win32` with `CP_ACP`, the system ANSI code page (`src/common/strconv.cpp:2600-2607`), independent of the UI language Orca picks; Orca's manifest does not opt into a UTF-8 active code page | non-ASCII text becomes mojibake (single-byte code pages) or an empty string (DBCS code pages). A developer machine with Windows' system-wide "Use Unicode UTF-8" option hides the bug |
| macOS | `wxMBConvLibc` (`mbstowcs`, follows `LC_CTYPE`) | the C runtime stays in the `"C"` locale until `GUI_App::load_language` calls `wxLocale::Init`; in `"C"` macOS maps each byte to one character (mojibake) [tested]; region locales afterwards decode UTF-8 [tested] |
| GTK | `wxMBConvLibc` | wxGTK calls `gtk_disable_setlocale()` (`src/gtk/app.cpp:526-537`), so the locale is `"C"` until `load_language`; `wxUILocale` then prefers a UTF-8 codeset (`src/unix/uilocale.cpp` `TryCreateLocaleWithUTF8`, `wxSetlocaleTryUTF8`) |
The implicit conversions therefore work on macOS and Linux after startup and break on Windows, so a
bug of this class survives testing on a Mac.
**What compiles in Orca and what it does:**
| Expression | Compiles? | Encoding |
|---|---|---|
| `wxString w = s;` or `f(const wxString&)` called with `std::string`/`const char*` | yes | locale (CP_ACP on MSW): mojibake or empty for UTF-8 |
| `wxString w(sv)` from `std::string_view` | only explicitly: the ctor is `explicit` (`include/wx/string.h:1327-1328`) although `interface/wx/string.h:71` calls it implicit | locale |
| `std::string s = w;`, `const char* p = w;` | **no** | — |
| `w.ToStdString()`, `w.mb_str()`, `(const char*)w.c_str()` | yes | locale; `""` on failure (`wxCStrData::AsChar`, `include/wx/string.h:4292-4303`) |
| `w.utf8_string()`, `w.ToUTF8()`/`w.utf8_str()`, `into_u8(w)` | yes | UTF-8, never fails |
| `wxString::FromUTF8(s)`, `from_u8(s)` | yes | UTF-8; `""` on invalid input |
| `w.ToStdWstring()`, `w.wc_str()`, `wxString(L"…")` | yes | lossless |
**OrcaSlicer helpers** (`src/slic3r/GUI/GUI.hpp`/`GUI.cpp`):
| Helper | Does | Note |
|---|---|---|
| `from_u8(const std::string&)` | `wxString::FromUTF8(str.c_str())` | stops at an embedded NUL, `""` on invalid UTF-8; for binary-safe input use `wxString::FromUTF8(s)` (the `std::string` overload passes the length, `include/wx/string.h:1782-1783`) |
| `into_u8(const wxString&)` | `std::string(str.utf8_str().data())` | owning copy |
| `from_path(const boost::filesystem::path&)` | `wstring` on Windows, `from_u8(path.string())` elsewhere | |
| `into_path(const wxString&)` | `boost::filesystem::path(str.wx_str())` | wide on every platform |
| `file_url_from_path(path)` | `wxFileSystem::FileNameToURL(wxFileName(from_path(path)))` | |
| `I18N::translate*`, `L_str(std::string)` | decode their narrow input with `wxConvUTF8` | UTF-8 msgids are safe |
ImGui takes UTF-8 `const char*`: `ImGuiWrapper::text(const wxString&)` converts with `into_u8`; raw
`ImGui::` calls take `_u8L(...)`/`into_u8(...)` (see `references/webview-gl-aui-media.md` for the
ImGui layer).
### Source literals
MSVC compiles with `/utf-8`, so `"°C"` is a UTF-8 byte string on every compiler, and
`wxString("°C")` decodes it with the locale like any other narrow string ("never use 8-bit
characters directly in the program source", `docs/doxygen/overviews/unicode.h:145-160`). Non-ASCII
text goes through `_L("…")` (decodes UTF-8), `wxString::FromUTF8(u8"\u2103")` (the
`AMSDryControl.cpp` style for `℃`) or a wide literal `L"…"`. In C++20 `u8"…"` becomes `char8_t`,
which `FromUTF8` does not accept; Orca builds C++17. Mixing plain ASCII literals with a `wxString`
(`_L("Version") + " " + v`) is fine.
### Pointer lifetime
`utf8_str()`/`ToUTF8()` return a `wxScopedCharBuffer` and `mb_str()` a `wxCharBuffer` by value
(`interface/wx/string.h:745-755, 884-886`); each dies at the end of the full-expression. `c_str()`
returns a `wxCStrData` proxy whose narrow pointer points into a conversion buffer owned by the
`wxString` itself (`m_convertedToChar`, `src/common/string.cpp` `wxString::AsChar`) [source]: it
stays valid only until that string is modified, converted again or destroyed, so `_L("…").c_str()`
dangles at the end of the statement. Note that `wxCStrData` still converts implicitly to
`const char*` under `wxNO_UNSAFE_WXSTRING_CONV` (`include/wx/string.h:210-217`), so
`const char* p = w.c_str();` compiles.
- **Rule:** never keep a pointer into a temporary conversion buffer beyond the statement that
created it.
**Why:** the pointer dangles once the buffer is destroyed; the read returns garbage or crashes
later, intermittently, on every platform.
```cpp
// Wrong:
const char* p = name.ToUTF8().data();
use(p);
// Right:
std::string s = into_u8(name); // or name.utf8_string()
use(s.c_str());
```
Cite: `ImGuiWrapper::clipboard_get` keeps the converted text in the member
`m_clipboard_text` so the `const char*` it returns stays valid.
Passing a temporary such as `_L("…") + dots` straight into a `const wxString&` parameter is safe,
even if the callee yields or repaints: the temporary lives until the end of the full-expression and
`wxString` is a deep-copy `std::wstring`. (The Linux splash crash once blamed on such a temporary
was the splash screen's event filter, fixed in 4088a36095; see `references/threads-timers-app.md`.)
### Other traps
- `s[n]` returns a `wxUniCharRef` proxy: it cannot be `switch`ed on, and `auto c = s[0]; c = 'x';`
**modifies the string**. Use `s[n].GetValue()` or an explicit `int`/`wchar_t` type
(`interface/wx/string.h:171-226`).
- Since 3.3 `wxstr = {"Hello", 2}` is ambiguous (the `string_view` constructor); write
`wxString{"Hello", 2}` (`docs/changes.txt:191-194`).
- Never pass a `wxString`, `c_str()` or `mb_str()` to a real C vararg function (`printf`); use
`wxString::Format`/`wxPrintf` or convert explicitly (`interface/wx/string.h:262-300`).
- `wxUSE_STL` no longer exists; wx 3.3 re-enables implicit `wxString` → `std::string` only through
`wxUSE_STD_STRING_CONV_IN_WXSTRING=1` (`docs/changes.txt:163-166`). Orca keeps it 0 and adds
`wxNO_UNSAFE_WXSTRING_CONV`; do not enable either conversion, the compile error is the guard.
### Pitfalls
- **Rule:** wrap every UTF-8 `std::string` in `from_u8()` (or pass it to a helper that takes
`std::string`) before it reaches a wx API.
**Why:** the implicit constructor decodes with CP_ACP on MSW, so a non-ASCII preset name, path or
translated `_u8L` string turns into mojibake or `""`; macOS/Linux look correct.
```cpp
// Wrong:
label->SetLabel(preset.name);
wxFileDialog dlg(this, title, app_config->get_last_dir(), "", file_wildcards(FT_3MF), wxFD_OPEN);
// Right:
label->SetLabel(from_u8(preset.name));
wxFileDialog dlg(this, title, from_u8(app_config->get_last_dir()), "", file_wildcards(FT_3MF), wxFD_OPEN);
```
Cite: `GUI_App::import_model` (model opener with `from_u8` default dir); `CMakeLists.txt`
`wxNO_UNSAFE_WXSTRING_CONV` comment.
- **Rule:** get UTF-8 out of a `wxString` with `into_u8`/`utf8_string()`.
**Why:** `ToStdString()`, `mb_str()` and `c_str()` use `wxConvLibc` and return `""` when a character
is not representable in the ANSI code page (`include/wx/string.h:4292-4303`).
```cpp
// Wrong:
std::string path = dlg.GetPath().ToStdString();
// Right:
boost::filesystem::path path = into_path(dlg.GetPath()); // or into_u8(dlg.GetPath())
```
## Formatting
**`wxString::Format`/`Printf` contract.** Variadic templates that normalise each argument; positional
`%2$d %1$d` is supported because `wxUSE_PRINTF_POS_PARAMS` is 1 (`interface/wx/string.h:1533-1546`).
The specifier/argument type check is a `wxASSERT_MSG` (`include/wx/strvararg.h:336-347`), so at
debug level 0 nothing checks it: `%d` with `size_t`, `%s` with an `int` or a missing argument is
silent undefined behaviour. `const char*`, `std::string` and `std::string_view` arguments are
decoded with `wxConvLibc` (`wxArgNormalizerWchar<const char*>`, `include/wx/strvararg.h:612-624,
772-781`), so UTF-8 data garbles on MSW exactly as in the table above.
**OrcaSlicer: `format_wxstr` and `GUI::format`** (`src/slic3r/GUI/format.hpp`, on top of
`Slic3r::format` in `src/libslic3r/format.hpp`) wrap `boost::format`:
| Helper | Returns | Arguments |
|---|---|---|
| `format_wxstr(fmt, args...)` | `wxString`, built with `wxString::FromUTF8(result)` | `fmt` as `const char*`, `std::string` (UTF-8) or `wxString`; args of any streamable type |
| `GUI::format(fmt, args...)` | UTF-8 `std::string` | same |
| `Slic3r::format(fmt, args...)` (libslic3r) | UTF-8 `std::string` | narrow only |
- Placeholders: boost's `%1%` (preferred: position-independent, checked by `xgettext --boost`),
printf-style `%s`/`%d` and positional `%1$s`/`%1$d` all work.
- `std::string`/`const char*` arguments are inserted as raw bytes, i.e. as UTF-8; pass them directly
instead of converting to `wxString` first.
- `boost::format` throws `boost::io::too_many_args`/`too_few_args` on a placeholder/argument count
mismatch, and a stray `%` throws too (Orca's helpers keep boost's default "all errors throw"):
`bad_format_string` when it cannot start a directive (`"50%"`, `"5%/s"`), `too_few_args` when it
happens to parse as one (`"50% done"` reads `% d`, a space-flag `%d`) [tested]. A literal percent
is `%%`. A string that never reaches a formatter but contains `%` needs
`// xgettext:no-c-format, no-boost-format` above it, otherwise `msgfmt --check-format` fails
(AGENTS.md; e.g. `ConfigManipulation.cpp`).
- `wxString` arguments [source + tested]: the `cook(const wxString&)` overloads that convert to
UTF-8 live in `Slic3r::internal::format` in `slic3r/GUI/format.hpp`, declared after the
`format_recursive` template in `libslic3r/format.hpp`. Under two-phase lookup (clang, GCC) a
dependent call only finds later overloads through ADL, and `wxString`'s namespace is the global
one, so the generic `cook` is chosen and the `wxString` reaches boost through wx's
`operator<<(std::ostream&, const wxString&)` → `wxConvWhateverWorks` (C locale first, UTF-8
fallback; `src/common/string.cpp:158-170`, `src/common/strconv.cpp:3354-3363`). That is correct
while the C locale is UTF-8 (macOS and Linux after `load_language`); MSVC without `/permissive-`
finds the overloads. `into_u8(w)` as the argument is exact everywhere.
**Choosing.** New user-visible strings use `format_wxstr(_L("… %1% …"), args)` or
`GUI::format(_u8L(...), args)`: translators can reorder `%1%`/`%2%`, while plain `%s %d` order is
fixed (a reordered c-format translation fails `msgfmt --check-format`; never reorder positional
arguments in a c-format string). `wxString::Format` with printf specifiers is accepted in existing
code; plural strings often use `%1$d` with `GUI::format` (`NotificationManager.cpp`).
```cpp
wxString msg = format_wxstr(_L("The file %1% was loaded"), filename); // filename: UTF-8 std::string
wxString pl = wxString::Format(_L_PLURAL("%d object", "%d objects", n), n); // n: int/unsigned, not size_t
```
### Numbers
`wxLocale::Init` "changes the application locale … this will affect many of standard C library
functions such as printf()" (`interface/wx/intl.h:584-590`). Orca deliberately leaves `LC_NUMERIC`
localised (the `wxSetlocale(LC_NUMERIC, "C")` in `GUI_App::load_language` is commented out), so in
the GUI thread `wxString::Format("%.2f")`, `wxString::ToDouble` and `std::to_string` use the UI
language's decimal separator; `wxString::ToCDouble`/`FromCDouble` do not.
- Display: `double_to_string(value, precision)` (`Field.cpp`, `wxNumberFormatter` plus the locale
separator from `is_decimal_separator_point()`); parse user input by replacing the other separator
with the locale's and then calling the locale-aware `wxString::ToDouble` (`Field::get_value_by_opt_type`
style in `Field.cpp`), not `ToCDouble`.
- Data (config values, G-code, project files, URLs): `CNumericLocalesSetter` (RAII `LC_NUMERIC="C"`),
`float_to_string_decimal_point`, `string_to_double_decimal_point` (`libslic3r/LocalesUtils.hpp`).
TBB worker threads set `"C"` per thread (`libslic3r/Thread.cpp`); the GUI thread does not.
### Pitfalls
- **Rule:** pass UTF-8 `std::string` values to `format_wxstr`, or `from_u8` them for
`wxString::Format`.
**Why:** a `std::string` `%s` argument to `wxString::Format` is decoded with CP_ACP on MSW.
```cpp
// Wrong:
wxString::Format(_L("Preset %s not found"), preset_name); // std::string
// Right:
format_wxstr(_L("Preset %1% not found"), preset_name);
```
Cite: `include/wx/strvararg.h:772-781`.
- **Rule:** escape literal percent signs in strings that go through `format_wxstr`/`GUI::format`.
**Why:** a stray `%` throws at runtime (`boost::io::too_few_args` here, because `% d` parses as a
directive; `bad_format_string` for `"50%"` at the end), in whatever handler builds the message.
```cpp
// Wrong:
format_wxstr(_L("Progress: 50% done"));
// Right:
format_wxstr(_L("Progress: 50%% done"));
```
- **Rule:** use `%zu` or cast for `size_t` in `wxString::Format`.
**Why:** the type check is compiled out; `%d` with a 64-bit `size_t` is undefined behaviour that
truncates the value or misreads the following arguments, depending on the ABI.
```cpp
// Wrong:
wxString::Format("%d items", vec.size());
// Right:
wxString::Format("%d items", static_cast<int>(vec.size()));
```
## Translation
**wx contract** (`interface/wx/translation.h:577-640`): `wxGetTranslation(string, domain = "",
context = "")` returns the original string when no catalog has it; a non-empty context needs a
matching `msgctxt` in the catalog; the plural overload returns `string` for `n == 1` and `plural`
otherwise when no catalog is found; "This function is thread-safe". Since 3.3 it returns
**`wxString` by value**, not a const reference: "please change the return type of the function to
wxString" (`docs/changes.txt:139-142`). Orca's `I18N::translate` overloads already return by value;
keep any new wrapper that way, a `const wxString&` return now dangles.
**OrcaSlicer macros** (`src/slic3r/GUI/I18N.hpp`):
| Macro | Returns | Extracted by xgettext? |
|---|---|---|
| `_L(s)` | translated `wxString` | yes |
| `_u8L(s)` | translated UTF-8 `std::string` | yes |
| `_L_CONTEXT(s, ctx)` / `_u8L_CONTEXT(s, ctx)` | `msgctxt`-disambiguated `wxString` / `std::string` | yes (`1,2c`) |
| `_L_PLURAL(s, plural, n)` | plural-aware `wxString` (`n` is `unsigned int`) | yes (`1,2`) |
| `L(s)` / `L_CONTEXT(s, ctx)` | the literal unchanged: a **marker** for xgettext, translated later at display time | yes |
| `_(s)`, `_utf8(s)` | same as `_L`/`_u8L` | **no** |
| `_CHB(s)` | translated `wxScopedCharBuffer` (a temporary, see §Pointer lifetime) | **no** |
| `_devL(s)` | `wxString(s)`, untranslated and locale-decoded | **no** |
| `_omitL(s)` | `""` | **no** |
There is no `_CTX` macro and no plural-with-context macro. All overloads decode `const char*` and
`std::string` input with `wxConvUTF8`, and accept `wxString`/`std::wstring` too, so `_L(var)` works
for a runtime string, but only finds a translation if that exact string was marked with `L()`
somewhere. `L_str(std::string)` (`I18N.cpp`) is the function form of `_L` for a UTF-8 string.
libslic3r has its own `src/libslic3r/I18N.hpp`: `L`/`L_CONTEXT` markers (functions returning the
literal) and `_u8L`, which calls a callback that `GUI_App` installs
(`Slic3r::I18N::set_translate_callback(libslic3r_translate_callback)`); `#error` guards stop either
header being included in the other module.
### Extraction
`scripts/run_gettext.sh --full` (and `.bat`) run xgettext with exactly these keywords
`--keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c
--keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2`, plus `--add-comments=TRN --from-code=UTF-8 --boost`
(among other flags), over the files listed in `localization/i18n/list.txt`, then `scripts/HintsToPot.py` appends the
strings of `resources/data/hints.ini`, and `msgfmt --check-format` compiles each catalog into
`resources/i18n/<lang>/OrcaSlicer.mo`. Consequences:
- A string inside `_()`/`_utf8()`/`_CHB()`/`_devL()` never reaches the `.pot`, so it stays English.
`_()` is fine only around a value that was already marked with `L()`.
- xgettext extracts only string literals (wx states the same for its own `_()`,
`interface/wx/translation.h:590-592`); a variable inside `_L()` is translated at runtime only if its
value was marked elsewhere.
- A file missing from `list.txt` is not scanned at all.
- `// TRN …` immediately above the line carries a translator comment into the catalog
(`//TRN To be shown in the main menu View->Top` in `MainFrame.cpp`, `// TRN %1% = file path` in
`DownloaderFileGet.cpp`).
- `--boost` marks `%1%` strings as boost-format, and `msgfmt --check-format` then rejects a
translation that drops or changes a placeholder.
- Only `OrcaSlicer.mo` ships; there is no `wxstd` catalog, so wx's own internal strings (standard
dialog buttons it creates, wx error messages) stay English.
### Deferred translation
Static tables, option definitions and enum labels hold `L("…")`-marked literals and are translated
where they are shown: `file_wildcards_by_type` stores `L("STL files")` titles and `file_wildcards()`
calls `I18N::translate(data.title_id)`; option labels marked `L_CONTEXT("Top", "Layers")` are shown
with `_L_CONTEXT(option.label, "Layers")` (`OG_CustomCtrl.cpp`) — only those hard-coded `"Top"`/`"Bottom"`
labels: every other def string is translated without context, so an `L_CONTEXT` in a def is ignored at
display. The settings side of this pattern is in `references/orca-settings-ui.md` §Localization of option
definitions.
### Plurals and contexts
```cpp
wxString s = wxString::Format(_L_PLURAL("%d object", "%d objects", n), n);
text += GUI::format(_L_PLURAL("%1$d Object has custom supports.", "%1$d Objects have custom supports.", cnt), cnt);
wxString top = _L_CONTEXT("Top", "Camera View"); // msgctxt "Camera View" (MainFrame.cpp)
```
- **Rule:** keep the number placeholder in both forms and give the full sentence to one msgid.
**Why:** the catalog's `Plural-Forms` decides which form applies (Russian uses the "singular" form
for 21, 31, …; ja/ko/zh have one form), so "One object" without `%d` is wrong for 21 objects;
glued fragments (`_L("Delete") + " " + _L("object")`) cannot be reordered or inflected.
- **Rule:** disambiguate homonyms with a context in the source (`_L_CONTEXT`/`_u8L_CONTEXT`), never
by tweaking a translation; the context string must match exactly at the marker and the call site.
## Language switching and the translation lifecycle
**OrcaSlicer design** (`GUI_App::load_language(wxString language, bool initial)`):
1. Initial call only: `wxFileTranslationsLoader::AddCatalogLookupPathPrefix(from_u8(localization_dir()))`
(a static wx list), then the language from AppConfig key `language`, else the system language
(MSW: `LCIDToLocaleName`), else `wxTranslations::GetBestTranslation(SLIC3R_APP_KEY, wxLANGUAGE_ENGLISH)`.
RTL languages are not supported and fall back.
2. The dictionary language and the C-runtime locale are chosen separately: Slovak uses the Czech
dictionary; when the locale is not available the code tries `linux_get_existing_locale_language`
(Linux), the base language (`en` from `en_IL`), then a fallback chain (current, system, best,
en_US, en_GB) while keeping the requested dictionary. If nothing is available it shows a
`wxMessageBox` and, on the initial call, exits.
3. `m_wxLocale.release()` (deliberate leak: "wxWidgets cause havoc if the current locale is
deleted"), `m_wxLocale = make_unique<wxLocale>()`, `m_wxLocale->Init(lang)`,
`wxTranslations::Get()->SetLanguage(language_dict)`, `m_wxLocale->AddCatalog(SLIC3R_APP_KEY)`,
`m_imgui->set_language(...)`, then rebuilds two caches: `Preset::update_suffix_modified(...)` and
`HintDatabase::get_instance().reinit()`.
Changing the language at runtime (`GUI_App::open_preferences` with a pending language) calls
`load_language(..., false)`, rebuilds the action-registry titles (`m_action_registry.relocalize_builtins()`)
and then `GUI_App::recreate_GUI`, which destroys and rebuilds `MainFrame` (and drops the cached
Speed Dial dialog). Everything constructed under the new `MainFrame` re-translates by construction;
anything that outlives it does not.
**wx caveats.** On macOS "it is impossible to change the application UI locale after launching it …
using this class doesn't affect the native controls and dialogs", and on macOS 11.0–12.2 changing the
C locale can break the menus (`interface/wx/intl.h:282-292`): native file dialogs, the app menu and
standard buttons follow the system language. `wxLocale::IsAvailable` builds a region tag and asks
`wxUILocale(...).IsSupported()` (`src/common/intl.cpp` `wxLocale::IsAvailable`), which on Unix no
longer falls back to another region of the same language (`docs/changes.txt:75-79`); keep the
fallbacks in `load_language`.
- **Rule:** translate at use; never store a translated string in a namespace-scope or function-local
`static`, or in a singleton/registry, unless it has a re-localise hook wired into the language
switch.
**Why:** a namespace-scope static is initialised before `load_language`, so it is English forever;
a function-local static captures the language of its first call and survives `recreate_GUI`.
```cpp
// Wrong:
static const wxString NA_STR = _L("N/A");
// Right:
m_label->SetLabel(_L("N/A")); // or keep L("N/A") in the table and translate when shown
```
Cite: `ActionRegistry::relocalize_builtins`, `HintDatabase::reinit`, `Preset::update_suffix_modified`
are the existing hooks.
## Labels, mnemonics and markup
**Contract** (`interface/wx/control.h:173-195, 380-398`): in `SetLabel` "All "&" characters … indicate
that the following character is a mnemonic … To insert a literal ampersand character, you need to
double it"; `SetLabelText` shows the text exactly (implemented as `SetLabel(EscapeMnemonics(text))`,
`include/wx/control.h:63-67`); `EscapeMnemonics()` is for a label combining program mnemonics with
user text. Constructor labels are interpreted like `SetLabel` (the ports' `wxStaticText::Create`
call `SetLabel(label)`) [source]. `SetLabelMarkup` also treats an
unescaped `&` as a mnemonic, needs `&amp;`/`&lt;` for literal characters, strips the markup where it
is unsupported, and leaves the label unchanged (returns false) when the string is not well-formed
(`interface/wx/control.h:200-360`); user text inside markup must be XML-escaped.
Since 3.3 wxListbook/wxChoicebook also interpret mnemonics in page titles (`docs/changes.txt:128-130`);
Orca uses neither, but the rule is the same for every book control.
**OrcaSlicer.** `Label` (`Widgets/Label.cpp`) derives from `wxStaticText`; `Label::SetLabel` stores
the text and goes through `wxStaticText::SetLabel` (or `Wrap` for `LB_AUTO_WRAP`, `SetLabelMarkup` for
`LB_HYPERLINK` on macOS), so `&` is a mnemonic in every `Label` too. The inherited `SetLabelText`
escapes and then calls `Label::SetLabel`, so it works for `Label`. Menu labels use `&File`-style
mnemonics plus `"\t" + accelerator`, and translations must keep the `&`. A plain-text MsgDialog
message is rendered by a `Label` (mnemonics interpreted); a message with a link, `is_marked_msg`,
code excerpts or a `<tr>` table goes to `wxHtmlWindow` through `xml_escape` instead (see
`references/windows-dialogs.md` §MsgDialog family).
- **Rule:** user-supplied text goes through `SetLabelText` or `wxControl::EscapeMnemonics`.
**Why:** the `&` disappears and the next character becomes a mnemonic: `PLA & PETG` shows as
`PLA PETG` on every port.
```cpp
// Wrong:
label->SetLabel(from_u8(preset.name));
menu->Append(id, from_u8(printer_name));
// Right:
label->SetLabelText(from_u8(preset.name));
menu->Append(id, wxControl::EscapeMnemonics(from_u8(printer_name)));
```
## Paths and standard locations
**OrcaSlicer path model.** Paths live as `boost::filesystem::path` or UTF-8 `std::string`.
`OrcaSlicer.cpp` calls `boost::nowide::nowide_filesystem()` at startup, which imbues boost paths with a
UTF-8 codecvt, so on Windows `boost::filesystem::path(utf8_string)` and `path.string()` are UTF-8.
`std::filesystem::path` is not imbued: on Windows a narrow `std::string` is read in the ANSI code page,
so build it from the wide form (`into_path(w).wstring()`) or stay with boost. At the wx boundary use
`from_path`/`into_path`.
**`wxFileName`** (`interface/wx/filename.h`): `Normalize()` without flags is deprecated
(`include/wx/filename.h:358-364`) because `wxPATH_NORM_ALL` includes `wxPATH_NORM_ENV_VARS` and
expands `$VAR`/`%VAR%` inside file names (`interface/wx/filename.h:90-103`); use `MakeAbsolute()` or
explicit `wxPATH_NORM_DOTS | wxPATH_NORM_ABSOLUTE`. 3.3 adds `IsMSWExtendedLengthPath()` for `\\?\`
paths "avoiding the 260 character path length restriction" (`interface/wx/filename.h:1040-1051`,
`docs/changes.txt:551`). `wxFileSystem::FileNameToURL`/`URLToFileName` convert to and from `file:`
URLs (`interface/wx/filesys.h:96-103, 175-180`).
**`wxStandardPaths`** (`interface/wx/stdpaths.h`): the directories "may or may not exist"
(`:39`); `GetUserDataDir()` is `~/.appinfo` on Unix, `%APPDATA%\appinfo` on Windows and
`~/Library/Application Support/appinfo` on macOS, and on Unix ignores `FileLayout_XDG` (`:406-420`);
`GetUserDir()` always follows XDG on Unix (`:422-433`).
**OrcaSlicer data dir** (`GUI_App::init_app_config`): a `data_dir` folder next to the executable if it
exists (portable mode), else `GetUserDataDir()` on MSW/macOS, or `$XDG_CONFIG_HOME/OrcaSlicer`
(default `~/.config/OrcaSlicer`) built by hand on Linux; then the process **chdirs to
`data_dir()/log`**. Relative paths therefore resolve into the log folder, and anything that changes the
working directory (`wxFD_CHANGE_DIR`, `wxDD_CHANGE_DIR`, `chdir`) breaks code that relies on it. Use
absolute paths built from `data_dir()`, `resources_dir()`, `localization_dir()`.
## File and directory dialogs
**Usage:**
```cpp
wxFileDialog dlg(this, _L("Choose one or more files (3MF/STEP/STL/SVG/OBJ/AMF):"),
from_u8(wxGetApp().app_config->get_last_dir()), wxEmptyString,
file_wildcards(FT_MODEL), wxFD_OPEN | wxFD_MULTIPLE | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK)
return;
wxArrayString paths;
dlg.GetPaths(paths); // GetPath() returns "" with wxFD_MULTIPLE
for (const wxString& p : paths)
load(into_path(p));
```
**Contract** (`interface/wx/filedlg.h`):
- `wxFD_OPEN` and `wxFD_SAVE` are exclusive; `wxFD_MULTIPLE`/`wxFD_FILE_MUST_EXIST` are open-only,
`wxFD_OVERWRITE_PROMPT` save-only (`:163-200`). Contradictory styles are only asserted
(`src/common/fldlgcmn.cpp:776-787`), i.e. ignored silently in Orca.
- Style bits are reused between classes: `wxPD_APP_MODAL` (0x0002) equals `wxFD_SAVE`
(`include/wx/progdlg.h:21`, `include/wx/filedlg.h:45-46`), so a foreign flag can change the dialog
type. Pass only `wxFD_*`.
- `GetPath()`/`GetFilename()` "can't be used with dialogs which have the `wxFD_MULTIPLE` style"
(`:343-347, 380-384`); they return `""` there (`docs/changes_32.txt:119-120`). Use
`GetPaths()`/`GetFilenames()`.
- Wildcard format `"Desc (*.a;*.b)|*.a;*.b|Desc2 (*.c)|*.c"` (`:106-115`); the default wildcard is
`"*.*"` on MSW and `"*"` elsewhere (`:31-36`).
- `SetFilename()` in wxGTK has "little effect unless a default directory has previously been set"
(`:452-456`).
- `SetExtraControlCreator()` forces old XP-style dialogs on MSW; `SetCustomizeHook()` is native
(`:131-155`). New-style MSW dialogs need a single-threaded COM apartment (`:157-161`).
- `wxDirDialog`: `wxDD_NEW_DIR_BUTTON` is `0` ("deprecated, on by default now",
`interface/wx/dirdlg.h:14`); the "Create new directory" button is shown exactly when
`wxDD_DIR_MUST_EXIST` is absent (`interface/wx/dirdlg.h:42-46`); on macOS 10.11+ there is no title
bar, the `message` argument is what the user sees (`interface/wx/dirdlg.h:59-62`).
**Platforms:**
| | Behaviour |
|---|---|
| MSW | New-style `IFileDialog`: wx calls `SetDefaultExtension` with the selected filter's first extension and no longer calls `AppendExtension` itself (`src/msw/filedlg.cpp:1644-1656, 1738-1740`; `docs/changes.txt:556`). `SetExtraControlCreator` → XP-style dialog. |
| macOS | Open dialogs show **no filter choice** and apply all wildcards at once unless `wxSystemOptions::SetOption(wxOSX_FILEDIALOG_ALWAYS_SHOW_TYPES, 1)`, and even then non-matching files are only greyed (`interface/wx/filedlg.h:117-128`). Matching compares the lower-cased last path extension (`wxOpenSavePanelDelegate panel:shouldEnableURL:`, `src/osx/cocoa/filedlg.mm:58-88`) [source]: case-insensitive, and a multi-dot pattern such as `*.gcode.3mf` or `*.zip.amf` never matches by itself. `wxFD_OVERWRITE_PROMPT` is always on (`:172-175`); `wxFD_OPEN` always behaves as `wxFD_FILE_MUST_EXIST` (`:184-189`). The save panel replaces the initial file name's extension with the first one in the wildcard (Orca's comment on `file_wildcards`). The native panel runs `runModal` and does not re-raise the parent dialog afterwards (`src/osx/cocoa/filedlg.mm` `ShowModal`); see `references/windows-dialogs.md` for the deferred re-raise. |
| GTK3 | `GtkFileChooserNative` (portal-capable, e.g. Flatpak) when GTK ≥ 3.20 at runtime and neither `wxFD_PREVIEW` nor an extra control/customize hook is used (`src/gtk/filedlg.cpp:265-274, 437-443`; `src/gtk/dirdlg.cpp:122-130`; `docs/changes.txt:393`). It runs through `gtk_native_dialog_run`: there is no wx window, so size/position calls do nothing (`wxFileDialog::DoSetSize` is empty). Patterns go to `gtk_file_filter_add_pattern` per token and are **case-sensitive** (`src/gtk/filectrl.cpp:169`). The chooser is transient for the parent left after `GetParentForModalDialog`, which replaces a null (or hidden, dying or `wxWS_EX_TRANSIENT`) parent with the active top-level window, else the app's main top window (`src/gtk/filedlg.cpp:206, 223-225`; `src/common/dlgcmn.cpp:180-203` `DoGetParentForDialog`) [source]. `wxFD_PREVIEW` is GTK-only (`interface/wx/filedlg.h:195-197`). |
| GTK2 (opt-out build) | never uses the native chooser; patterns case-sensitive. |
**OrcaSlicer:**
- `file_wildcards(FileType, custom_extension)` (`GUI_App.cpp`) builds every filter from
`file_wildcards_by_type`: translated title, and an upper-case twin of every extension
(`*.stl;*.STL`) because GTK patterns are case-sensitive. A `custom_extension` is put first because
the macOS save panel substitutes the first extension into the initial file name.
- Openers to copy: `GUI_App::import_model`, `GUI_App::import_zip` (default dir `from_u8(...)`).
Last directories come from `AppConfig::get_last_dir()`/`get_last_output_dir()`; the app stores
them itself (`update_config_dir`, `update_last_output_dir`) instead of using `wxFD_CHANGE_DIR`.
- `Plater::priv::get_export_file` appends the expected extension on `__WXMSW__` when the returned name
lacks it and then asks its own overwrite question, because the native overwrite prompt only checked
the name the user typed.
- `CheckboxFileDialog` (`GUI_Utils.hpp/.cpp`) uses `SetExtraControlCreator`, which costs the native
dialog on MSW and GTK3; prefer `SetCustomizeHook` for new extra controls.
**Pitfalls:**
- **Rule:** give file and directory dialogs the top-level window they belong to as parent.
**Why:** with `nullptr`, wxGTK (`wxFileDialog::Create`, `wxDirDialog::Create`) and wxMSW
(`wxFileDialog::ShowModal`) substitute whatever top-level window is active at that moment, else
the app's main top window (`wxDialogBase::DoGetParentForDialog`, `src/common/dlgcmn.cpp:180-203`)
[source], so the chooser becomes transient for and modal over an arbitrary window (a modeless
dialog or web window that happened to be active), or gets no parent when none qualifies. Gizmo
code has no `this` window to hand, which is where `nullptr` creeps in.
```cpp
// Wrong:
wxFileDialog dialog(nullptr, _L("Choose SVG file"), ...);
// Right:
wxFileDialog dialog(wxGetApp().mainframe, _L("Choose SVG file"), ...); // or GetTopWindow()
```
Cite: `src/gtk/filedlg.cpp` `wxFileDialog::Create` (`GetParentForModalDialog`, then `gtk_parent`).
- **Rule:** use `wxDD_DEFAULT_STYLE` for directory dialogs; add `wxDD_DIR_MUST_EXIST` only when the
user must not create a folder.
**Why:** `wxDD_NEW_DIR_BUTTON` is 0, so passing it alone means style 0: no
`wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER` (the generic dialog loses its frame). It does not control
the new-folder button either: that appears whenever `wxDD_DIR_MUST_EXIST` is absent, so adding
`wxDD_DIR_MUST_EXIST` removes it (and restricts the choice to existing folders).
```cpp
// Wrong:
wxDirDialog dlg(this, msg, path, wxDD_NEW_DIR_BUTTON);
// Right:
wxDirDialog dlg(this, msg, path, wxDD_DEFAULT_STYLE); // | wxDD_DIR_MUST_EXIST: existing folders only, no new-folder button
```
Cite: `include/wx/dirdlg.h:45-47`; `interface/wx/dirdlg.h:42-46`.
- **Rule:** never pass `wxFD_CHANGE_DIR`/`wxDD_CHANGE_DIR`.
**Why:** Orca's working directory is `data_dir()/log`; the native GTK path even `chdir`s directly
(`src/gtk/filedlg.cpp:449-458`).
## Launching the browser, files and programs
**Contract** (`interface/wx/utils.h`):
- `wxLaunchDefaultBrowser(url, flags)`: `wxBROWSER_NEW_WINDOW` is honoured only on Windows; a URL
without a scheme is tested as a local file/dir (then prefixed with `file:`), otherwise `http:` is
prepended; returns false on failure (`:468-490`). wxGTK tries `gtk_show_uri` and then `xdg-open`
(`src/unix/utilsx11.cpp:2674-2737`).
- `wxLaunchDefaultApplication(document, flags)` opens the file in its associated application; `flags`
is unused (`:454-462`).
- `wxExecute`: `wxEXEC_ASYNC` returns the pid; `wxEXEC_SYNC` "will call wxYield()" and disables all
windows unless `wxEXEC_NODISABLE` (`:1196-1212`), so a synchronous call re-enters your handlers;
main thread only (`:1250-1252`).
**OrcaSlicer:**
- Links: `wxGetApp().open_browser_with_warning_dialog(url, flags)` is the app-level entry point (it
forwards to `wxLaunchDefaultBrowser`, so a direct `wxLaunchDefaultBrowser` call behaves the same).
- Folders: `desktop_open_any_folder(path)` / `desktop_open_datadir_folder()` (`GUI.cpp`): `explorer`
on Windows (`explorer /select,<path>` for `desktop_open_any_folder`), `openFolderForFile`
(any folder) or `open` (data dir) on macOS, `xdg-open` on Linux (of the containing folder when
`path` is a file) with the AppImage variables (`APPIMAGE`, `APPDIR`, `LD_LIBRARY_PATH`,
`LD_PRELOAD`, `UNION_PRELOAD`) removed and `OWD` as the working directory. A bare `wxExecute("xdg-open …")` or
`wxLaunchDefaultApplication` from an AppImage passes Orca's bundled libraries to the file manager.
- Project attachments: `desktop_open_project_attachment` accepts only a regular file inside the
project's auxiliary temp folder (`is_absolute_path_within_root`) and asks before opening anything
`is_safe_to_open_file_name` (`libslic3r/utils.cpp`) does not allow-list, because the desktop would
run a script or executable without a download warning. Reuse it for any file that arrived inside a
project or from the network.
## Clipboard
**Contract** (`interface/wx/clipbrd.h`): `Open()` "should be tested"; keep the clipboard open "only
momentarily" (`:24-29, 145-155`). `SetData` replaces any previously set object, so several formats
need one composite object; "After this function has been called, the clipboard owns the data"
(`:157-170`). `Flush()` keeps the data after exit; implemented on MSW and GTK only, on GTK only for
the CLIPBOARD selection and with a clipboard manager running (`:56-61, 100-115`).
`UsePrimarySelection(true)` makes every operation fail on platforms without a PRIMARY selection
(`:172-187`). `wxClipboardLocker` (`include/wx/clipbrd.h:167-191`) opens in its ctor, closes in its
dtor, and `!lock` tests `IsOpened()`.
**Platforms** [source]:
| | Behaviour |
|---|---|
| MSW | `SetData`/`GetData` do not check `Open()` (`src/msw/clipbrd.cpp` `wxClipboard::SetData`), so code that forgets it works here only. `wxTextDataObject::SetData()` size must now include the 2-byte NUL (`docs/changes.txt:62-66`); use `SetText()` or the ctor. |
| macOS | without `Open()`, `SetData`/`AddData`/`GetData` return false through `wxCHECK_MSG(m_open, …)` without taking the object (`src/osx/carbon/clipbrd.cpp:84-105, 140-147`); a successful write is flushed to the pasteboard immediately (`:110-114`). |
| GTK | same `wxCHECK_MSG(m_open, …)` early return (`src/gtk/clipbrd.cpp:643-647, 775`). `GetData`/`IsSupported` are asynchronous underneath: `wxClipboardSync` spins `YieldFor(wxEVT_CATEGORY_CLIPBOARD)` until GTK answers and forbids re-entrancy (`src/gtk/clipbrd.cpp:68-92`). PRIMARY selection exists. Under Wayland, Wayland MIME types are advertised next to the X11 atoms (`src/gtk/clipbrd.cpp:655-700`). |
```cpp
wxClipboardLocker lock;
if (!lock)
return;
wxTheClipboard->SetData(new wxTextDataObject(text)); // clipboard owns it on success
```
**OrcaSlicer.** Models: the copy button in `TroubleshootDialog` (`wxClipboardLocker`) and
`ImGuiWrapper::clipboard_set`/`clipboard_get` (tested `Open()`, UTF-8 via `wxString::FromUTF8`/
`into_u8`). The 3D scene's copy/paste is an internal clipboard (`Selection::Clipboard`,
`Selection::copy_to_clipboard`/`paste_from_clipboard`), not the wx one.
- **Rule:** open the clipboard through a checked `wxClipboardLocker` before `SetData`/`GetData`, and
test the result.
**Why:** on GTK and macOS an unopened clipboard makes `SetData` return false without taking the
object (leak, nothing copied) and `GetData` return false (nothing pasted); MSW does not check, so
the bug passes Windows testing.
```cpp
// Wrong:
wxTheClipboard->Open();
wxTheClipboard->SetData(new wxTextDataObject(t));
wxTheClipboard->Close();
// Right:
wxClipboardLocker lock;
if (lock)
wxTheClipboard->SetData(new wxTextDataObject(t));
```
## Drag and drop
**Contract.** `SetDropTarget`: "If the window already has a drop target, it is deleted"
(`interface/wx/window.h:3651-3657`); the window also deletes its target in its destructor
(`src/common/wincmn.cpp:515`), so the window owns it. `DragAcceptFiles` "Cannot be used together
with SetDropTarget() on non-Windows platforms" (`interface/wx/window.h:3659-3672`). The
`wxDropTarget` destructor deletes its data object and `SetDataObject` deletes the previous one
(`interface/wx/dnd.h:59-62, 138-146`). Call sequence: `OnEnter` → `OnDragOver`* → `OnDrop` (return
false to refuse) → `OnData` (`interface/wx/dnd.h:44-45, 75-127`). `wxFileDropTarget::OnDropFiles(x, y,
filenames)` returns true to accept (`interface/wx/dnd.h:395-422`). `wxDropSource::SetData` "will not
delete any previously associated data", i.e. the source does not own it (`interface/wx/dnd.h:335-338`).
`DoDragDrop(flags)` "blocks the program until the user releases the mouse button", and the target
cannot change the result code the source gets (`docs/doxygen/overviews/dnd.h:44-50, 84-90`).
`wxDragMove` is reported on MSW only (`interface/wx/dnd.h:26`).
**Platforms** [source]:
| | Behaviour |
|---|---|
| MSW | `SetDropTarget` revokes and deletes the old target immediately (`src/msw/window.cpp:1753-1761`). `wxDropTarget::MSWUpdateDragImageOnLeave()` (undocumented, `include/wx/msw/ole/droptgt.h:73`) hides the shell drag image. |
| GTK | `SetDropTarget` unregisters and deletes immediately (`src/gtk/window.cpp:6605-6616`). `DoDragDrop` returns `wxDragNone` unless a mouse button is down after a mouse event (`src/gtk/dnd.cpp:835-850`), then runs a nested `gtk_main_iteration` loop with `g_blockEventsOnDrag` set (`:906-916`). Under Wayland it also hooks button/motion events because "drag-end" may never arrive (`:945-955`). |
| macOS | `SetDropTarget` deletes the old target immediately (`src/osx/window_osx.cpp:616-622`). |
**wxDataViewCtrl DnD** (`interface/wx/dataview.h`): `EnableDropTargets` is fully implemented in the
generic and native macOS versions, wxGTK uses only the first format (`:1376-1386`); `SetDragFlags` is
honoured only by the generic control (MSW), not native GTK/macOS (`:4010-4024`); `GetDropEffect`
returns `wxDragNone` on native GTK and macOS (`:4026-4041`); `GetProposedDropIndex` works from
`ITEM_DROP` everywhere and from `ITEM_DROP_POSSIBLE` except native GTK (`:4053-4064`). The control
itself is in `references/controls-dataview.md`.
**OrcaSlicer:**
- Model-file drops: `PlaterDropTarget : wxFileDropTarget` (`Plater.cpp`), installed with
`q->SetDropTarget(new PlaterDropTarget(...))` (the window takes ownership); `SetDefaultAction(wxDragCopy)`.
`OnDropFiles` calls `MSWUpdateDragImageOnLeave()` under `WIN32` before showing UI, raises the main
frame, switches to the Prepare tab, routes a single `.svg` to the SVG gizmo (`GLGizmoSVG::create_volume`
at the drop point) and otherwise calls `Plater::load_files`. `Preview::set_drop_target(target)` is
the helper for putting a target on the preview window.
- Custom payload: `DragDropPanel.cpp` (filament-group dragging). `ColorDataObject : wxCustomDataObject`
with `wxDataFormat("application/customize_format")` and fixed-size POD data; `ColorDropSource` keeps
the data object as a member and is constructed on the stack in `DragDropPanel::DoDragDrop`, which
`ColorPanel::OnLeftDown` calls (satisfying the GTK button-down rule); `ColorDropTarget` owns its
`ColorDataObject` through `SetDataObject`.
- ObjectList row reordering (`GUI_ObjectList.cpp`): `ObjectList::OnBeginDrag` keeps the real payload in
`m_dragged_data`, gives wx a dummy `wxTextDataObject` with non-empty text ("needed for GTK") and
`SetDragFlags(wxDrag_DefaultMove)`; `m_prevent_list_events` suppresses the selection events GTK fires
because it drops *between* rows where MSW/macOS drop *on* a row.
**Pitfalls:**
- **Rule:** call `wxDropSource::DoDragDrop` synchronously from the mouse-down/drag handler.
**Why:** wxGTK refuses (returns `wxDragNone`) without a current button press after a mouse event; a
`CallAfter` or timer loses it.
```cpp
// Wrong:
CallAfter([this] { wxDropSource src(this); src.SetData(m_obj); src.DoDragDrop(); });
// Right:
void ColorPanel::OnLeftDown(wxMouseEvent&) { m_parent->DoDragDrop(this, ...); }
```
- **Rule:** never call `SetDropTarget` on a window from inside that window's current target
(`OnDrop`/`OnData`/`OnDropFiles`); defer it with `CallAfter`.
**Why:** every port deletes the old target immediately, so the callback returns into a freed object.
## Logging: wxLog vs boost log
**Contract** (`docs/doxygen/overviews/log.h`): with the default `wxLogGui`, `wxLogError`/`wxLogWarning`/
`wxLogMessage` pop up a message box (`:31-38`); messages from other threads are buffered until the main
thread flushes (`:228-240`); `wxLogNull`/`EnableLogging(false)` affect only the current thread
(`:242-244`). `wxLog::SetActiveTarget` takes ownership of the new target and the caller must delete the
returned old one (`interface/wx/log.h:326-340`). `wxLogNull` suppresses **all** messages, not only the
one you expect (`interface/wx/log.h:1026-1035`).
**OrcaSlicer.** `GUI_App::on_init_inner` first calls `wxLog::SetActiveTarget(new wxBoostLog())`;
`wxBoostLog::DoLogText` writes every wx message to `BOOST_LOG_TRIVIAL(warning)` as UTF-8, and its
destructor flushes pending thread messages into itself so they never reach a `wxLogGui`. Release builds
(`BBL_RELEASE_TO_PUBLIC`) also `wxLog::SetLogLevel(wxLOG_Message)`. `wxLogDebug`/`wxLogTrace` compile to
nothing at debug level 0 (`include/wx/log.h:62-77`). So **wxLog never shows UI in Orca**, including
the errors wx itself logs (failed file operations, image loading), which end up only in the log file.
Use `wxLogNull` around a probing call whose failure wx would log (`OpenGLManager.cpp`), and still check
the return value.
Write diagnostics with `BOOST_LOG_TRIVIAL(level) << __FUNCTION__ << …` (or
`boost::format("%1%: …") % …`) and stream UTF-8: `into_u8(w)` or `w.ToUTF8().data()` inside the
statement. Streaming a `wxString` directly goes through `wxConvWhateverWorks` (locale first;
`src/common/string.cpp:158-170`) and writes ANSI bytes into the UTF-8 log on MSW.
User-facing errors go through `show_error(parent, msg)` (`GUI.hpp`), which is **asynchronous**: it
queues an `ErrorDialog` with `wxGetApp().CallAfter` and captures the raw `parent`, so pass a parent that
outlives the call. `show_info` and `warning_catcher` are synchronous MsgDialogs; the `const char*`/
`std::string` overloads decode UTF-8. The dialogs themselves are in `references/windows-dialogs.md`
§MsgDialog family.
- **Rule:** never use `wxLogError`/`wxLogWarning`/`wxLogMessage` to inform the user.
**Why:** `wxBoostLog` sends them to the log file only; the user sees nothing.
```cpp
// Wrong:
wxLogError(_L("Export failed"));
// Right:
show_error(this, _L("Export failed"));
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": export failed: " << into_u8(path);
```
## Native message boxes
**Contract** (`interface/wx/msgdlg.h`): `wxCANCEL` "Must be combined with either wxOK or wxYES_NO"
(`:30`); `wxYES_NO` without `wxCANCEL` has no close button on MSW (`:31-35`); `wxHELP` is unsupported
from a non-main thread on wxOSX (`:39-40`); `wxCANCEL_DEFAULT` is ignored on wxOSX (`:44-46`);
`wxSTAY_ON_TOP` works only on MSW and GTK (`:89-91`). `wxMessageDialog::ShowModal()` returns
`wxID_OK/wxID_CANCEL/wxID_YES/wxID_NO/wxID_HELP` (`:269-275`), but **`wxMessageBox()` returns
`wxYES/wxNO/wxCANCEL/wxOK/wxHELP`** (`:308-312`). `wxRichMessageDialog` is native only on MSW and
generic elsewhere (`interface/wx/richmsgdlg.h:18-23`). On MSW every `TaskDialog`-based dialog
(`wxMessageBox`, `wxMessageDialog`, `wxRichMessageDialog`, `wxProgressDialog`) ignores dark mode
(`interface/wx/app.h:1436-1440`).
**OrcaSlicer.** UI code uses the MsgDialog family (`MessageDialog`, `RichMessageDialog`,
`WarningDialog`, `ErrorDialog`, `InfoDialog`; `references/windows-dialogs.md` §MsgDialog family) for
dark mode, DPI and a consistent look; its `ShowModal()` returns `wxID_*`. Native boxes are for code that
runs before the GUI exists (`wxMessageBox` in `GUI_App::load_language`, `MessageBoxA` in `OrcaSlicer.cpp`).
- **Rule:** compare each API's result with its own constants.
**Why:** `wxYES` (0x2) is not `wxID_YES` (5103); the comparison is always false and the "Yes" branch
never runs.
```cpp
// Wrong:
if (wxMessageBox(msg, title, wxYES_NO) == wxID_YES) ...
// Right:
if (MessageDialog(this, msg, title, wxYES_NO).ShowModal() == wxID_YES) ... // or wxMessageBox(...) == wxYES
```
## Settings: AppConfig, not wxConfig
Orca does not use `wxConfig`/`wxFileConfig` (so the 3.3 change of the Unix default location to XDG,
`docs/changes.txt:25-29`, does not affect it). Settings live in `AppConfig`
(`src/libslic3r/AppConfig.hpp`), a JSON file `OrcaSlicer.conf` under `data_dir()`
(`AppConfig::config_path`), reached through `wxGetApp().app_config` (the object map is in
`references/orca-architecture.md`).
- String-typed API: `get(key)`/`get(section, key)` return `std::string` (UTF-8) and `""` when absent;
`set(key, value)`, `set(section, key, value)`, `set_str(section, key, value)`, `set_bool(key, bool)`;
`has`, `erase`. `get_bool(section, key)` is `get(section, key) == "true" || get(key) == "1"` (the
`"1"` test reads the `app` section). Some keys hold `"true"`/`"false"` (what `set(section, key, bool)`
writes), others `"1"`/`"0"`; check how a key is written before testing it.
- `set` marks the config dirty when the value changes; `GUI_App`'s idle handler saves a dirty config
after post-init. Call `app_config->save()` explicitly only when the value must be on disk before control
returns to the event loop (before a restart, exit or launching another instance); Preferences rows also
`save()` at once by convention (`references/orca-architecture.md` §Preferences).
- `AppConfig::save()` throws `CriticalException` off the main thread, and nothing in `AppConfig` is
locked: read and write it on the main thread only.
- Values are UTF-8: `from_u8(app_config->get(...))` at the wx boundary, `into_u8(w)` going back.
- **Rule:** pass a `std::string` (or use `set_str`) when setting a string value with a section.
**Why:** for `set("app", "key", "value")` the `const char*` → `bool` conversion is a standard
conversion and beats the user-defined `std::string` one, so `set(section, key, bool)` wins and stores
`"true"` [tested with clang].
```cpp
// Wrong:
app_config->set("app", "theme", "dark"); // stores "true"
// Right:
app_config->set_str("app", "theme", "dark"); // or std::string("dark"), or set("theme", "dark")
```
Cite: `AppConfig::set` overloads in `AppConfig.hpp`.
## Secrets: wxSecretStore
**Contract** (`interface/wx/secretstore.h`): on Unix it needs libsecret and a running secret service, so
always check `IsOk(&errmsg)` (`:193-210, 257`); libsecret is no longer required at run time
(`docs/changes.txt:533`), so a missing library or service shows up only as `IsOk()` false.
`GetDefault()` "may show a dialog to the user under some platforms, so it can take an arbitrarily long
time to return" (`:243-246`). One username per service (`:260-268`).
**OrcaSlicer** (`OrcaCloudServiceAgent`): AppConfig `SETTING_USE_ENCRYPTED_TOKEN_FILE` selects either an
AES-GCM encrypted token file in the data dir or the system store; `secret_stored` records whether this
process read or wrote a secret, and `clear_user_secret` touches the store only then (or on an explicit
all-backends logout).
- **Rule:** never call `wxSecretStore::GetDefault()`, `Load`, `Save` or `Delete` from a timer, poll or
repeated UI path; touch the store on explicit login/logout and cache the state.
**Why:** each call is a blocking keychain/D-Bus round trip on the UI thread; an unresponsive keychain
froze the UI for 25 s per poll, and the delete also removed a login another instance had just saved.
Cite: 1a5bc8982d (`OrcaCloudServiceAgent::clear_user_secret`).
## Single instance
**Contract** (`interface/wx/snglinst.h`): `wxSingleInstanceChecker::Create(name, path)`: `name` "is used
as the mutex name under Win32 and the lock file name under Unix", and `path` "is ignored under Win32"
(`:97-103`); the default name includes the user id, so different users may run concurrently (`:49-53`).
**OrcaSlicer** (`instance_check(argc, argv, app_config_single_instance)`, `InstanceCheck.cpp`), run at
startup from `GUI_Init.cpp` before the GUI: the executable path (the AppImage file on Linux) is hashed into a lock name.
Windows checks it with a `wxSingleInstanceChecker` (`GUI_App::init_single_instance_checker`, name
`<hash>.lock`); macOS and Linux use Orca's own lock file `data_dir()/cache/<hash>.lock`
(`instance_check_internal::get_lock`). When another instance holds the lock and single-instance mode
applies (command line, else AppConfig), the command line is forwarded and this process exits: Windows
`WM_COPYDATA` to the other instance's window, macOS `NSDistributedNotificationCenter`
(`InstanceCheckMac.mm`, `send_message_mac`), Linux D-Bus. No wxIPC is involved. The receiving
`OtherInstanceMessageHandler` turns messages into `EVT_LOAD_MODEL_OTHER_INSTANCE`,
`EVT_START_DOWNLOAD_OTHER_INSTANCE` and `EVT_INSTANCE_GO_TO_FRONT` for the main frame. A new kind of
cross-instance request extends this message path on all three transports rather than adding a second
checker or a wxIPC server.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,991 @@
# Windows, dialogs and window lifetime
How windows are created, parented, closed and destroyed; how to tell whether a window is still
alive; how top-level windows show, raise and persist; how modal and modeless dialogs work on each
port; what Orca's `DPIDialog`/`DPIFrame` add; the Orca dialog recipe; and the `MsgDialog` family.
Read it before writing or reviewing any dialog, frame, close handler, `Destroy()`/`delete`, or code
that keeps a pointer to a window across an event, a `CallAfter` or a modal loop.
wx cites are relative to the pinned wx 3.3.2 tree (`find deps -maxdepth 5 -type d -path
'*dep_wxWidgets-prefix/src/dep_wxWidgets'`). wx is built with `wxBUILD_DEBUG_LEVEL=0` and
`libslic3r_gui` with `wxDEBUG_LEVEL=0`: every wx assert quoted below is compiled out, so misuse
fails silently (dropped call, stuck loop, freed memory), never with an assert dialog. "GTK" below
means wxGTK as Orca builds it on Linux: GTK3 by default (X11 or Wayland); GTK2 is only an opt-out.
Contents: [Rules](#rules) · [1 Creating and parenting](#1-creating-and-parenting-windows) ·
[2 Destroy, delete, Close](#2-destroying-windows-destroy-delete-close-and-the-close-event) ·
[3 Liveness](#3-liveness-is-this-window-still-alive) ·
[4 Top-level windows](#4-top-level-windows-show-raise-enable-state-geometry) ·
[5 Window styles](#5-window-styles) · [6 Dialogs and modality](#6-dialogs-and-modality) ·
[7 DPIDialog and DPIFrame](#7-dpidialog-and-dpiframe) ·
[8 The Orca dialog recipe](#8-the-orca-dialog-recipe) ·
[9 MsgDialog family](#9-message-boxes-the-msgdialog-family) ·
[10 Overlay frames](#10-overlay-frames-basetransparentdpiframe)
## Rules
1. Free heap-allocated windows with `Destroy()`, never `delete`; a modal dialog on the stack is freed by its scope and must never be `Destroy()`ed. §2
2. Never `Destroy()` a child window (or an ancestor of it) from inside that child's own event handler: child `Destroy()` is a synchronous `delete this`. Defer with `wxTheApp->ScheduleForDestruction(w)` or a guarded `CallAfter`. §2
3. A close handler either vetoes or destroys/ends the window; prompts and vetoes only when `CanVeto()`; when `!CanVeto()` it must not veto. §2
4. Do irreversible teardown in a close handler only once every veto is decided — the default top-level handler still vetoes a vetoable close while any modal dialog is open. §2
5. Give every dialog and frame a live top-level parent (`parent ? parent : wxGetApp().mainframe`); a parentless window that only hides on close keeps the process alive. §1, §2
6. Never construct a window to reach computation; make the logic `static` or free. §1
7. To ask "is this top-level window still alive", check `wxTheApp->IsScheduledForDestruction(w)` as well as `IsBeingDeleted()`; `wxWeakRef` nulls only at the very end of destruction; cross-thread code uses a `std::shared_ptr<std::atomic<bool>>` alive flag. Re-check after every nested event loop. §3
8. A `wxEVT_DESTROY` handler on a parent fires for every descendant: compare `GetEventObject()` and `Skip()`. For a top-level window it runs after the derived destructor — do derived cleanup in the destructor. §3
9. Accessors reachable from teardown-time events return `nullptr` instead of dereferencing a pimpl or child; the real fix is still to stop the events first. §3
10. Bring a top-level window forward with `if (!w->IsShown()) w->Show(); w->Raise();` — `Raise()` never shows, and a redundant `Show()` is not inert on GTK3. §4
11. On macOS, after a native modal (file/dir dialog, native message box) or a generic progress dialog opened from a secondary top-level window, re-raise that window with a deferred, liveness-guarded `Raise()`. §4
12. Remove style bits with `& ~flag`, never `!flag`. §5
13. Always pass the style to a `DPIDialog`: the `DPIAware` default is `wxDEFAULT_FRAME_STYLE` (resizable, min/max boxes). §5, §7
14. A `wxFRAME_FLOAT_ON_PARENT` frame takes a non-null top-level parent (parent to `mainframe`, not to a panel). §5
15. End a modal dialog with `EndModal(wxID_*)` (or `EndDialog` from inside the class); never with `Hide()`, `Show(false)` or `Destroy()` while its loop runs. A heap modal is `Destroy()`ed after `EndModal()` — normally by the caller once `ShowModal()` returns. §6
16. Return codes are `wxID_*` ids, never the `wxOK/wxCANCEL/wxYES/wxNO/wxCLOSE` style bits (the `MsgDialog` "Go to" button's `wxFORWARD` is the one deliberate exception). §6, §9
17. Nested modal dialogs end innermost first; `DPIDialog::EndModal` refuses (logs, leaves the dialog open) on a dialog that is not the innermost `DPIDialog`. §6, §7
18. Never call `EndModal()` on a modeless dialog; use `Hide()`, `Close()`, `Destroy()`, or `EndDialog(rc)` inside the class. §6
19. Never show a modal dialog directly from a mouse-button or motion handler; defer with `CallAfter`. §6
20. Run cancel cleanup on every close path: ESC and the title-bar close box go through `Close()` and `wxID_CANCEL`, never through an Orca `Button`'s handler. §6, §8
21. Dialogs derive `DPIDialog`, implement `on_dpi_changed`, call `CenterOnParent()` after fitting, and call `wxGetApp().UpdateDlgDarkUI(this)` last; an override of `ShowModal()` calls `DPIDialog::ShowModal()`. §7, §8
22. The bottom row is `DialogButtons` with untranslated labels; bind every button whose default wx handling is not what you want — Yes/No/Confirm/custom buttons never close the dialog by themselves. §8
23. Messages to the user go through the `MsgDialog` family (`MessageDialog`, `RichMessageDialog`, `WarningDialog`, `ErrorDialog`, `InfoDialog`, `show_error`/`show_info`), never `wxMessageBox`/`wxMessageDialog` once the GUI exists; treat `wxID_CANCEL` (ESC/close box) as "no". §9
24. `show_error` is asynchronous and captures its parent pointer; pass a long-lived parent or `nullptr`. §9
---
## 1 Creating and parenting windows
**Contract.**
- Child windows are created shown; top-level windows (frames, dialogs) are created hidden "to allow
you to create their contents without flicker" (`interface/wx/window.h:3140-3158`).
- "Child windows are deleted from within the parent destructor. This includes any children that are
themselves frames or dialogs, so you may wish to close these child frame or dialog windows explicitly
from within the parent close handler" (`docs/doxygen/overviews/windowdeletion.h:96-100`).
- `wxDialog` with a NULL parent "will be owned by the application's top window, if any. Use
`wxDIALOG_NO_PARENT` style to really make dialog not owned by any window" (`interface/wx/dialog.h:166-172`).
The style doc adds: orphan dialogs are "not recommended for modal dialogs" (`interface/wx/dialog.h:122-126`).
- **[source]** Only the *native* owner is substituted; `GetParent()` stays NULL. Owner resolution
(`wxDialogBase::DoGetParentForDialog`, `src/common/dlgcmn.cpp:180-204`): the given parent's
top-level parent → the active window's top-level parent → `wxApp::GetMainTopWindow()`, each
rejected if it is pending delete, being deleted, has `wxWS_EX_TRANSIENT`, or — for modal use — is
not `IsShownOnScreen()` (`CheckIfCanBeUsedAsParent`, `src/common/dlgcmn.cpp:130-178`).
**Two-step creation.** "the underlying window must be created exactly once… if you use the default
constructor… you *must* call Create() before using the window and if you use the non-default
constructor, you can *not* call Create()" (`interface/wx/window.h:411-417`). Methods may be called
between the C++ constructor and `Create()` — `Hide()` to build invisibly, `Enable(false)`,
`SetExtraStyle()` for create-time extra styles (`interface/wx/window.h:419-428, 3120-3126`; `interface/wx/dialog.h:127-133`).
```cpp
auto* panel = new wxPanel(); // C++ object only
panel->Hide(); // legal before Create
panel->Create(parent, wxID_ANY); // native window, still hidden
/* build children */ panel->Show();
```
**[source]** `Destroy()` on a never-created window skips `wxEVT_DESTROY` (`src/common/wincmn.cpp:559-573`),
and a never-created top-level window is deleted immediately (`src/common/toplvcmn.cpp:102-112`; not on
macOS, whose `Destroy()` always defers, §2 table).
Orca's widgets (`StaticBox`, `Button`, `TextInput`, `SpinInput`) follow the layering:
default constructor + `Create(...)`, the convenience constructor delegates to both, and each
`Create` calls its base `Create` first, then attaches `state_handler`. `ComboBox` has only the
convenience constructor, which default-constructs its `TextInput` base and calls `TextInput::Create`.
Subclasses keep that layering.
**Reparent.** `Reparent(newParent)` moves the window between children lists (and between
`wxTopLevelWindows` and a parent); "you need to explicitly call wxNotebook::RemovePage() before
reparenting a notebook page" (`interface/wx/window.h:730-742`). **[source]** It does not touch
sizers (`src/common/wincmn.cpp:1339-1377`); GTK hides the widget and re-shows it on idle if the new parent is
visible (`src/gtk/window.cpp:5195-5230`); MSW just calls `::SetParent`.
**Platforms.**
- MSW fixes a dialog's native owner at `Create` time (`wxTopLevelWindowMSW::CreateDialog` →
`GetParentForModalDialog`, `src/msw/toplevel.cpp:337-352`). A dialog created while its parent is
not on screen (e.g. inside the parent frame's constructor) is owned by the active window, the main
top window, or nothing — wrong z-order and taskbar grouping.
- GTK sets `transient_for` for modal dialogs in `ShowModal()` (`src/gtk/dialog.cpp:139-144`), and for
dialogs/float-on-parent/stay-on-top frames with a top-level parent in `Create`
(`src/gtk/toplevel.cpp:774-782`).
- Wayland: the compositor places top-level windows (see §4); dialog placement comes from
`transient_for`, so a real parent is the only positioning control there is.
**OrcaSlicer.** Dialogs take `parent ? parent : wxGetApp().mainframe` — never orphan a dialog (the
`MsgDialog` constructor does this substitution itself). Passing a child panel as parent is legal:
the dialog dies with that panel (§2) and `CenterOnParent()` centres on the panel's screen rect
(`wxTopLevelWindowBase::DoCentre`, `src/common/toplvcmn.cpp:239-279`); with no wx parent it centres on the display.
**Pitfalls.**
- **Rule:** Never construct a window — especially one hosting a `wxWebView` — just to call one of
its computation methods; make the computation `static` or a free function.
**Why:** window construction has heavy native side effects (native handles, webview processes,
event bindings). `is_flush_config_modified()` built a full `WipingDialog` (with a WebView) only to
call `CalcFlushingVolumes()`; it runs on the UI-rebuild path (`Sidebar::msw_rescale`,
`Sidebar::sys_color_changed`), and on macOS the language switch froze the app.
```cpp
// Wrong
WipingDialog dlg(wxGetApp().mainframe, extra_flush_volumes);
auto m = dlg.CalcFlushingVolumes(i);
// Right
static VolumeMatrix CalcFlushingVolumes(int extruder_id); // no window needed
auto m = WipingDialog::CalcFlushingVolumes(i);
```
Cite: 80f4a7a40f (`WipingDialog::CalcFlushingVolumes`/`CalcFlushingVolume` in `src/slic3r/GUI/WipeTowerDialog.hpp`).
- **Rule:** Create a dialog lazily, after its parent is on screen, when MSW ownership matters.
**Why:** the native owner is chosen at `Create` and a hidden parent is rejected (above).
- **Rule:** `Reparent()` does not move sizer membership.
```cpp
// Wrong: w->Reparent(p);
// Right:
old_sizer->Detach(w); w->Reparent(p); new_sizer->Add(w, 0, wxEXPAND); p->Layout();
```
- **Rule:** Call `Create()` exactly once; never on an object built with the non-default constructor.
---
## 2 Destroying windows: Destroy, delete, Close and the close event
**Contract.**
- `Destroy()`: "Use this function instead of the delete operator, since different window classes can
be destroyed differently. Frames and dialogs are not destroyed immediately when this function is
called -- they are added to a list of windows to be deleted on idle time, when all the window's
events have been processed. This prevents problems with events being sent to non-existent windows."
Returns true "if the window has either been successfully deleted, or it has been added to the list of
windows pending real deletion" (`interface/wx/window.h:3607-3618`).
- Destructor: "Deletes all sub-windows, then deletes itself. Instead of using the delete operator
explicitly, you should normally use Destroy() so that wxWidgets can delete a window only when it is
safe to do so, in idle time" (`interface/wx/window.h:389-394`). `DestroyChildren()` is called automatically by the
destructor (`interface/wx/window.h:626`).
- "Windows with parents, such as controls, don't have delayed destruction… For consistency, continue
to use the wxWindow::Destroy function instead of the delete operator when deleting these kinds of
windows explicitly" (`docs/doxygen/overviews/windowdeletion.h:103-109`).
- `Close(force)` "simply generates a wxCloseEvent whose handler usually tries to close the window. It
doesn't close the window itself"; `force=true` means the handler cannot veto; returns "true if the
event was handled and not vetoed" — a handler that hides instead of destroying still makes it return
true. "Calling Close does not guarantee that the window will be destroyed… To guarantee that the
window will be destroyed, call wxWindow::Destroy instead" (`interface/wx/window.h:3574-3605`).
- Close handler: "If [CanVeto()] is false, you *must* destroy the window using wxWindow::Destroy… If you
don't destroy the window, you should call wxCloseEvent::Veto" (`interface/wx/event.h:4708-4717`).
"The wxCloseEvent handler should only call wxWindow::Destroy to delete the window, and not use the
delete operator" (`docs/doxygen/overviews/windowdeletion.h:38-42`).
- Defaults (`docs/doxygen/overviews/windowdeletion.h:63-73`): wxDialog's close handler simulates `wxID_CANCEL`; the cancel
handler hides a modeless dialog or `EndModal(wxID_CANCEL)`s a modal one — "the dialog *is not*
destroyed (it might have been created on the stack)". wxFrame's default close handler calls `Destroy()`.
**[source]** The top-level default (`wxTopLevelWindowBase::OnCloseWindow`, `src/common/toplvcmn.cpp:535-546`)
**vetoes** a vetoable close while `wxModalDialogHook::GetOpenCount() > 0` (any app-modal wx or native
dialog open), otherwise calls `Destroy()`.
- Deleting from inside a handler: "it may be unsafe for an event handler to delete the object which
generated the event because more events may be still pending for the same object. In this case the
handler may call ScheduleForDestruction() instead" (`interface/wx/app.h:191-212`); without an event
loop it deletes immediately.
**When does `Destroy()` actually free the window?** **[source]**
| Window | `Destroy()` does | Cite |
|---|---|---|
| Child (control, panel) | sends `wxEVT_DESTROY`, then `delete this` **synchronously**; the window detaches from its containing sizer in `~wxWindowBase` | `src/common/wincmn.cpp:559-573` |
| Top-level, normal case | appends to `wxPendingDelete`, hides unless it is the last visible top-level window; deleted by `DeletePendingObjects()` at the next idle — which also runs inside full yields and modal loops (`wxYield`, `ShowModal`), not inside a masked `YieldFor` (progress-dialog `Update`) | `src/common/toplvcmn.cpp:102-142`; `src/common/appbase.cpp:643-662` |
| Top-level, parent already being deleted, or never created (not macOS) | deleted immediately | `src/common/toplvcmn.cpp:102-112` |
| Top-level, macOS | always deferred and always `Hide()`n | `src/osx/toplevel_osx.cpp:96-105` |
| Top-level, MSW | base behaviour plus `wxWakeUpIdle()` so iconized windows still get deleted | `src/msw/toplevel.cpp:771-784` |
| Any child of a dying parent, top-level children included | `DestroyChildren` calls the non-virtual `wxWindowBase::Destroy()`: deleted **immediately**, not queued | `src/common/wincmn.cpp:586-607` |
| `wxPopupTransientWindow` | deferred to idle (not hidden); a second `Destroy()` fails a `wxCHECK` — see `references/popups-menus.md` | `src/common/popupcmn.cpp` `wxPopupTransientWindowBase::Destroy` |
**Close-handler shapes** (pick one per window, never mix; `confirm_discard`/`cleanup` stand for your code):
```cpp
// destroy-on-close (a frame, or a modeless dialog the user owns)
Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& e) {
if (e.CanVeto() && !confirm_discard()) { e.Veto(); return; }
cleanup(); // members still alive here
e.Skip(); // frame: default Destroy() (vetoed while a modal is open, Rule 4)
// modeless dialog: call Destroy() instead -- its default only EndDialog(wxID_CANCEL)s = hides it
});
// hide-on-close (a reusable window someone else owns) — TextureProjectorFrame's shape
Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& e) {
if (e.CanVeto()) { e.Veto(); Hide(); } else e.Skip(); // a forced close must still destroy
});
// usable both modal and modeless — WebDialog::on_close_window's shape
void on_close(wxCloseEvent&) { if (IsModal()) EndModal(wxID_CANCEL); else Destroy(); }
```
A dialog's `e.Skip()` reaches `wxDialogBase::OnCloseWindow`, which **ends** the dialog
(`EndDialog(wxID_CANCEL)` = `EndModal` or `Hide`) but never destroys it: a modeless dialog closed
with the close box is only hidden (`src/common/dlgcmn.cpp:525-559`).
**Application exit.** The app exits when the last top-level window is destroyed
(`docs/doxygen/overviews/windowdeletion.h:89-93`; `SetExitOnFrameDelete(false)` disables it, `interface/wx/app.h:1195-1207`). "By default,
the application stays alive as long as there are any open top level windows" — hidden ones count;
override `ShouldPreventAppExit()` to return false for unimportant windows (`interface/wx/toplevel.h:650-657`).
**[source]** `IsLastBeforeExit` runs from `~wxTopLevelWindowBase` (`src/common/toplvcmn.cpp:93-97, 144-189`);
`GetTopWindow()` skips windows pending delete (`src/common/appcmn.cpp:185-208`).
**OrcaSlicer — shapes to follow.**
- *Modeless singleton owned by `GUI_App`* (`GUI_App::open_terminal_dialog`, `GUI_App::open_speed_dial`):
```cpp
if (!m_dlg) {
m_dlg = new TerminalDialog(mainframe, wxID_ANY, _L("Plugin Terminal"));
m_dlg->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& e) {
if (e.GetEventObject() == m_dlg) m_dlg = nullptr; // the event also arrives from children
e.Skip();
});
}
if (!m_dlg->IsShown()) m_dlg->Show();
m_dlg->Raise();
```
These dialogs bind no close handler, so the close box only hides them (the wx default for a modeless
dialog, below) and the reopen path re-shows the same window; they die with their parent `mainframe`
(or an explicit `Destroy()`, as `GUI_App::recreate_GUI` does for the speed dial), and the
`wxEVT_DESTROY` reset clears the pointer then. A reset that does not compare `GetEventObject()` clears
the pointer when any child of the dialog is destroyed. `open_terminal_dialog` also wraps all of this in
`CallAfter` because it is reached from a webview script message (§6).
- *Dual-mode dialog* (`WebDialog`): close handler `IsModal() ? EndModal(wxID_CANCEL) : Destroy()`;
forced teardown (`WebDialog::destroy_silently`) calls `EndModal` first, then `Destroy()`, because
destroying a modal "can leave ShowModal() running". Registry cleanup runs in `~WebDialog`, explicitly
not in a `wxEVT_DESTROY` handler (comment there: the event comes from the base `~wxDialog`, after the
members died).
- *Hide-on-close reusable frame* (`TextureProjectorFrame`): veto + `Hide()` when `CanVeto()`, else
`Skip()`. The owner (the texture gizmo) holds the pointer; it is parented to `mainframe` because
`wxFRAME_FLOAT_ON_PARENT` needs a real top-level parent.
- *Pseudo-modal modeless dialog* (`ParamsDialog`): see §6 "Modality helpers".
- *By-value top-level member*: `MainFrame::m_settings_dialog` is a `SettingsDialog` (a `DPIDialog`)
stored by value with a NULL parent; its close handler only `Hide()`s. Such a window must never be
`Destroy()`ed or `delete`d — it dies in member destruction, before the frame's base destructors.
Prefer a heap child for new code.
- *Main-frame replacement* (`GUI_App::recreate_GUI`): `mainframe->shutdown()`, create the new
`MainFrame`, `SetTopWindow(new_frame)`, then `old->Destroy()` — new frame first, so wx never sees
"last top-level window gone" and exits.
- *Main-frame close* (`MainFrame` constructor's `wxEVT_CLOSE_WINDOW` lambda): every prompt and veto is
gated by `event.CanVeto()` (gizmo editing, `Plater::close_with_confirm`, print-host queue); then
`set_closing(true)`, `m_plater->reset()`, `shutdown()`, `event.Skip()` → default `Destroy()`. Cmd+Q on
macOS posts a vetoable close (`wxPostEvent(this, wxCloseEvent(wxEVT_CLOSE_WINDOW))`); updater paths use
`Close(true)` to force. Its teardown runs before `Skip()`, so a vetoable close that arrives while a
modal is open (the `wxEVT_QUERY_END_SESSION` handler in `GUI_App::on_init_inner` sends exactly that) is
vetoed by the default handler after teardown — the hazard of Rule 4; don't copy that ordering into new
handlers. Sequence detail: `references/orca-architecture.md`.
- *Lifetime-tied cleanup*: `GUI_App::recreate_GUI` attaches a `wxClientData` subclass with
`SetClientObject`; its destructor runs in `~wxEvtHandler`, after all children are gone.
- *Lazily built window* (`Lazy<T>` / `LazyInstance<T>` in `Lazy.hpp`, e.g. `MainFrame::m_diff_dialog`, a
`Lazy<DiffPresetDialog>`): the holder builds the window on demand or at idle, but the window's parent
owns it; code that only needs an already-built instance asks `T::if_built()` instead of forcing a
build. Design: `docs/HLSD/deferred-page-construction.md`, `references/orca-architecture.md`.
**Pitfalls.**
- **Rule:** Destroy heap-allocated top-level windows with `Destroy()`, never `delete`; stack-allocated
modal dialogs are destroyed by scope.
**Why:** `delete` skips the pending-delete queue, and for a top-level window `wxEVT_DESTROY` is then
sent only from the base destructor. `Destroy()` *delays* deletion; it does not stop events: queued
events and `CallAfter`s of a window sitting in `wxPendingDelete` still run (§3), so deferred code
still needs liveness checks.
```cpp
// Wrong: auto* dlg = new MyDialog(this); dlg->ShowModal(); delete dlg;
// Right: auto* dlg = new MyDialog(this); dlg->ShowModal(); dlg->Destroy();
// Right: MyDialog dlg(this); dlg.ShowModal();
```
Cite: 0a0d59b76b (`detail::run_off_thread_with_progress` in `src/slic3r/GUI/PluginsDialog.hpp`: the
worker body in `try/catch`, then one main-thread `CallAfter` that does `timer->Stop(); delete timer;`
and `progress->Destroy()` only while the host's alive flag is set, or when the caller passed no flag —
the progress dialog is a child of the host and has already died with it otherwise, §2 table). Older code that `delete`s a dialog
after `ShowModal()` is legacy; don't extend it.
- **Rule:** Never `Destroy()` a control (or its ancestor panel) from inside that control's own handler.
```cpp
// Wrong: m_btn->Bind(wxEVT_BUTTON, [this](auto&) { m_panel->Destroy(); }); // m_btn is inside m_panel
// Right:
m_btn->Bind(wxEVT_BUTTON, [this](auto&) { wxTheApp->ScheduleForDestruction(m_panel); m_panel = nullptr; });
```
**Why:** child `Destroy()` deletes synchronously; the dispatcher then returns into freed memory.
- **Rule:** A close handler that neither destroys nor vetoes, or that vetoes without checking
`CanVeto()`, is wrong.
**Why:** `Close(true)`, session end and the default top-level handler rely on a non-vetoable close
destroying the window; a hide-only handler leaks the window, and a parentless leaked window keeps the
process alive after the main frame is gone. Parent it to `mainframe`, `Destroy()` it, or override
`ShouldPreventAppExit()`.
- **Rule:** Do irreversible teardown only when the close is certain.
**Why:** a handler that tears down and then `Skip()`s can still be vetoed by
`wxTopLevelWindowBase::OnCloseWindow` when the close is vetoable and any modal dialog is open (for
example a vetoable close sent while a dialog is up, as the `wxEVT_QUERY_END_SESSION` path does): the
window survives, half torn down.
```cpp
// Wrong
Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& e) { shutdown(); e.Skip(); });
// Right
Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& e) {
if (e.CanVeto() && wxModalDialogHook::GetOpenCount() > 0) { e.Veto(); return; }
shutdown(); e.Skip();
});
```
- **Rule:** A `wxTimer` must not outlive the handler that receives its events — make it a member
(`wxTimer m_timer{this}`) or delete it before the owner dies. Detail in `references/threads-timers-app.md` §wxTimer.
---
## 3 Liveness: is this window still alive?
**`IsBeingDeleted()`.** Doc: true "if this window, or one of its parent windows, is scheduled for
destruction and can be useful to avoid manipulating it as it's usually useless to do something with a
window which is at the point of disappearing anyhow" (`interface/wx/window.h:3620-3633`). **[source]** Wrong for top-level windows: the flag is
set only by `SendDestroyEvent()`, i.e. when deletion actually starts (`src/common/wincmn.cpp:541-557`);
`wxTopLevelWindowBase::Destroy()` only queues and hides, so **after `tlw->Destroy()` returns,
`tlw->IsBeingDeleted()` is false until idle**. The parent walk also stops at a top-level window
(`m_isBeingDeleted || (!IsTopLevel() && m_parent->IsBeingDeleted())`, `src/common/wincmn.cpp:535-539`): a dialog
does not report its parent frame's deletion.
**`wxApp::IsScheduledForDestruction(obj)` / `ScheduleForDestruction(obj)`** (`interface/wx/app.h:191-221`):
the first answers "has `Destroy()` (or `ScheduleForDestruction`) already been called"; both share
`wxPendingDelete` (`src/common/appbase.cpp:624-641`). `ScheduleForDestruction` defers deletion of *any*
`wxObject`, children included; **[source]** it deletes with plain `delete` at idle (`src/common/appbase.cpp:643-662`).
| After… | `IsBeingDeleted()` | `IsScheduledForDestruction()` | `wxWeakRef` |
|---|---|---|---|
| `child->Destroy()` returned | object gone | object gone | null |
| `tlw->Destroy()` returned, before idle | **false** | true | **non-null** |
| inside `wxEVT_DESTROY` handlers and the wx base destructors | true | false (already removed) | **non-null** until `~wxTrackable` |
| inside a top-level window's own derived destructor (deleted at idle or by `delete`) | **false** — `SendDestroyEvent()` runs later, in `~wxFrameBase` or the port's top-level/dialog destructor (`src/common/framecmn.cpp:200`, `src/msw/toplevel.cpp:522`, `src/gtk/toplevel.cpp:975`, `src/osx/dialog_osx.cpp:90`) | false | non-null |
**`wxWeakRef<T>`** auto-resets "when the object pointed is destroyed"; works for `wxEvtHandler`/`wxWindow`
(`interface/wx/weakref.h:40-99`). **[source]** The reset happens in `~wxTrackable`, the last base
destructor (`include/wx/tracker.h`) — non-null throughout the destroy sequence and while a top-level window
sits in the pending list. The tracker list is unlocked: main thread only. Orca uses it for the splash
(`wxWeakRef<SplashScreen> scrn` in `GUI_App::on_init_inner`), `g_delay_webviews` (`Widgets/WebView.cpp`),
`DockPanel`, `GuideFrame`.
**`wxWindowPtr<T>`** is a shared pointer that calls `Destroy()` at refcount 0
(`interface/wx/windowptr.h:10-26`). It does not track: if the window dies another way (parent deletion,
default frame close) the last release calls `Destroy()` on freed memory. Use it only for parentless,
self-owned top-level windows — the documented `ShowWindowModalThenDo` idiom (§6).
**`wxEVT_DESTROY`** (`wxWindowDestroyEvent`, `interface/wx/event.h:4525-4553`): for top-level windows it
is sent "by wxFrame or wxDialog destructor, i.e. after the destructor of the derived class was executed";
for children "just before deleting the window from wxWindow::Destroy()… or from the window destructor if
operator delete was used directly". **[source]** It derives from `wxCommandEvent` and **propagates to the
parent** unless the parent is being deleted (`wxWindowBase::TryAfter`, `src/common/wincmn.cpp:3499-3522`), so a
parent's handler fires for every descendant destroyed before it. Derived-class code that must run at
destruction goes in the derived destructor (or call `SendDestroyEvent()` there).
**Pending events and nested loops.** **[source]** `ProcessPendingEvents` runs queued events and
`CallAfter`s of a top-level window that is already in `wxPendingDelete` (`src/common/appbase.cpp:561-601`); only the
actual deletion discards them (`~wxEvtHandler` → `DeletePendingEvents`, `src/common/event.cpp`). Idle
events are skipped for pending-delete windows (`src/common/appcmn.cpp:405-428`). "Deferred" does not mean "after the
current handler returns": `ShowModal()`, `wxYield()` and `wxSafeYield()` run pending events *and*
`DeletePendingObjects()` (`src/common/evtloopcmn.cpp:172-192`), so a raw pointer to a `Destroy()`ed
window dies across any of them, and a lambda queued before a modal opens can run while the caller is
still inside `ShowModal()`. A masked `YieldFor` — `wxProgressDialog::Update`/`Pulse` — runs no idle pass
and only the queued events its categories allow (none on GTK): `references/threads-timers-app.md`
§Event categories and yields. `CallAfter` liveness mechanics (self-queued calls are dropped
with their handler; `wxGetApp().CallAfter` calls are not): `references/events.md` §CallAfter. Yields:
`references/threads-timers-app.md`.
**OrcaSlicer.**
- Alive flag for anything that may run after the window died, including worker threads:
`std::shared_ptr<std::atomic<bool>> m_alive = std::make_shared<std::atomic<bool>>(true);`, set false in the
destructor (`PluginsDialog::~PluginsDialog`), captured by value, checked inside the lambda. Unlike
`wxWeakRef` it flips at the *start* of the derived destructor and is thread-safe.
- App shutdown gate: `wxGetApp().is_closing()`; background-to-UI callbacks check it before posting and
again inside the `CallAfter` lambda (`references/threads-timers-app.md`).
**Pitfalls.**
- **Rule:** For a top-level window that something may have `Destroy()`ed, test both flags.
```cpp
// Wrong: if (!tlw->IsBeingDeleted()) use(tlw);
// Right: if (!wxTheApp->IsScheduledForDestruction(tlw) && !tlw->IsBeingDeleted()) use(tlw);
```
- **Rule:** Don't trust `wxWeakRef` inside teardown code; combine it with `IsBeingDeleted()` /
`IsScheduledForDestruction()` or an alive flag.
- **Rule:** In a `wxEVT_DESTROY` handler bound on a parent, compare the event object and `Skip()`.
```cpp
// Wrong: parent->Bind(wxEVT_DESTROY, [this](auto&) { m_child = nullptr; });
// Right:
parent->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& e) {
if (e.GetEventObject() == m_child) m_child = nullptr;
e.Skip();
});
```
- **Rule:** After `ShowModal()` returns, re-validate anything the modal loop could have destroyed (a
popup, a panel rebuilt on a language or preset change) — hold a `wxWeakRef` or alive flag and re-check.
- **Rule:** Make accessors that are reachable from teardown-time events null-safe: guard the pimpl or
child pointer and return `nullptr` instead of dereferencing.
**Why:** on macOS, close and shutdown still deliver events (render, idle, focus) that call back into
widget accessors after internals are gone; `Plater::get_view3D_canvas3D()` crashed on app close until it
checked its pimpl. `Plater::~Plater() = default` destroys `std::unique_ptr<priv> p` *before* the base
`wxWindow` destructor runs `DestroyChildren()` (`src/osx/window_osx.cpp:248`, `src/gtk/window.cpp:3105`,
`src/msw/window.cpp:421`; `src/common/wincmn.cpp:586-609`), so events raised while children die see a dead pimpl.
The null check works only because libc++'s `~unique_ptr` resets before deleting; the standard does not
require that, and touching a member after its destructor ran is UB. Treat the guard as a last line of
defence; the real fix is to stop the events (unbind, `is_closing()`) before teardown.
```cpp
return p->view3D->get_canvas3d(); // Wrong: crash on close
return p ? p->view3D->get_canvas3d() : nullptr; // Right
```
Cite: a162e3f031 (`Plater::get_view3D_canvas3D` in `src/slic3r/GUI/Plater.cpp`).
---
## 4 Top-level windows: show, raise, enable, state, geometry
**Show / Hide.** `Show()` returns false when nothing changed (`interface/wx/window.h:3140-3158`).
`IsShownOnScreen()` = shown and every parent up to the top-level window shown (`interface/wx/window.h:3100-3106`).
**[source]** GTK3: `wxTopLevelWindowGTK::Show` calls `GTKSendSizeEventIfNeeded()` even when nothing
changes, so a redundant `Show(true)` can flush a pending size event into layout handlers
(`src/gtk/toplevel.cpp:1258-1268`). X11 (GTK2/GTK3 without client-side decorations): the first `Show()`
may be deferred until `_NET_FRAME_EXTENTS` arrives — `IsShown()` is already true but the window is not
mapped (`src/gtk/toplevel.cpp:1141-1243`).
**Raise.** "only requests the window manager to raise this window… If the window is currently hidden,
this function does *not* show it", top-level windows only (`interface/wx/window.h:3013-3033`); true on all ports
since 3.3 (`docs/changes.txt:144-146`). **[source]** MSW = `::SetForegroundWindow`, subject to the
foreground lock — Windows may only flash the taskbar button (`src/msw/toplevel.cpp:650-655`); GTK =
`gtk_window_present` only if shown (`src/gtk/toplevel.cpp:1301-1310`; during a deferred X11 first show it
already counts as shown); macOS = `makeKeyAndOrderFront` only if shown (`src/osx/nonownedwnd_osx.cpp:289-295`, `src/osx/cocoa/nonownedwnd.mm:896-899`).
**Enable.** `Enable(false)` on a parent disables children logically: `IsEnabled()` reflects ancestors,
`IsThisEnabled()` the window's own flag (`interface/wx/window.h:3060-3070, 3116-3138`). **[source]** On MSW/macOS wx
propagates through `NotifyWindowOnEnableChange` → `DoEnable` on non-top-level children — not through the
virtual `Enable()` — and skips children entirely when a top-level window is disabled, so a modal dialog
does not grey the frame (`src/common/wincmn.cpp:1150-1191`); GTK relies on native sensitivity. Orca widgets update
their painted state only from their own `Enable()` override, so disabling an ancestor leaves them
looking enabled: `references/orca-widgets.md`.
**State and geometry** (`interface/wx/toplevel.h`):
| API | Contract / platform note |
|---|---|
| `Iconize()`, `Maximize()` | on wxGTK "the change… is not immediate" (`:260-274, 335-346`); **[source]** MSW on a hidden window only records the state for the next show (`src/msw/toplevel.cpp:661-735`) |
| `Restore()` | on wxGTK does not unmaximize — call `Maximize(false)` (`:394-404`) |
| `ShowFullScreen(show, style)` | also shows a hidden window (`:730-750`) |
| `EnableFullScreenView()` | wxOSX only; then `ShowFullScreen` uses the native Spaces API and only `wxFULLSCREEN_NOTOOLBAR|NOMENUBAR` apply (`:697-728`); `wxEVT_FULLSCREEN` is macOS-only, only with it, and not generated by `ShowFullScreen()` (`interface/wx/event.h:2391-2412`) |
| `RequestUserAttention()` | documented for Win32 (taskbar flash) and wxGTK (`:375-392`); **[source]** macOS bounces the dock icon (`src/osx/cocoa/nonownedwnd.mm:1306`) |
| `SetIcon/SetIcons` | MSW needs a 16×16 or 32×32 icon; no effect on Wayland — ship a `.desktop` file (`:506-546`); **[source]** no macOS override: stored, never shown |
| `SetSizeHints/SetMinSize/SetMaxSize` | on a top-level window they also constrain programmatic `SetSize()` (`:576-603`) |
| `SetTransparent()` | on wxGTK call it before the first show (`:632-648`) |
| `EnableMaximizeButton/EnableMinimizeButton` | MSW and macOS only (`:172-203`) |
| `EnableCloseButton` | all ports, but its result is unreliable on X11, GTK included (`:160-170`) |
| `wxEVT_MOVE_START/END` | wxMSW only (`:80-87`) |
| `wxEVT_SHOW` | not sent for iconize/restore on MSW (`interface/wx/event.h:4905-4914`) |
| `SaveGeometry/RestoreToGeometry`, `wxPersistentTLW` | serializer-based persistence (`:406-492`; `interface/wx/persist/toplevel.h`) — not used by Orca |
**Wayland.** `SetIcon(s)` do nothing (`interface/wx/toplevel.h:518-521, 539-542`); the app id comes from
`SetClassName` with GTK ≥ 3.24.22 (`interface/wx/app.h:765-771`). `SetPosition()`/`Move()` on a
top-level window is a no-op (the compositor places windows), so `CentreOnParent` and saved positions are
ignored (observed; recorded in `GUI_App::window_pos_restore`, not documented by wx). Detection: `Slic3r::GUI::is_running_on_wayland()`; see `references/platforms.md`.
**OrcaSlicer geometry persistence.** Orca uses `AppConfig`, not `wxPersistentTLW`:
`GUI_App::window_pos_save` / `window_pos_restore` / `window_pos_sanitize` / `window_pos_center` (key
`window_<name>`, a `WindowMetrics` = screen rect + maximized; restore skips `SetPosition` on Wayland), and
`on_window_geometry(tlw, callback)` (`GUI_Utils.cpp`) to run the callback when geometry is real — MSW
immediately (no `wxEVT_SHOW` for windows created maximized), Linux on `wxEVT_SHOW` + `CallAfter`, macOS on
`wxEVT_SHOW`. `GUI_App::persist_window_geometry(window, default_maximized)` saves on
`wxEVT_CLOSE_WINDOW` (then `Skip()`) but always uses the key `window_mainframe`, whatever the window's
name — for any other window call `window_pos_save/restore` with its own name.
**Pitfalls.**
- **Rule:** When restoring a top-level window (the main frame after hiding a popup or overlay frame, or a
singleton dialog being re-opened), call `Show()` only if `!IsShown()`, and call `Raise()` unconditionally.
**Why:** `Raise()` never shows a hidden window (3.3 on all ports); on GTK3 a redundant `Show(true)` runs
`GTKSendSizeEventIfNeeded()`, flushing a pending size event into layout handlers — in Orca this froze the
app permanently after hiding the filament-sync popup.
```cpp
// Wrong
mainframe->Show(); mainframe->Raise();
// Right
if (!mainframe->IsShown()) mainframe->Show();
mainframe->Raise();
```
Cite: dd8cb89f6d (`BaseTransparentDPIFrame::on_hide`); the same guard in `GUI_App::open_terminal_dialog`.
- **Rule:** On macOS, after any native modal (`wxFileDialog`/`wxDirDialog`, `wxMessageBox`/`wxMessageDialog`)
or a generic `wxProgressDialog` opened from a secondary top-level dialog, re-raise that dialog with a
deferred, liveness-guarded `Raise()` (`CallAfter` + alive flag + `IsShown()`).
**Why:** when the native panel closes, macOS re-activates the app's main window instead of the dialog
that opened it, burying the dialog behind the main frame (observed; not documented by wx). wx compensates
only for its own `wxDialog` modals — `EndModal` raises the parent (`src/osx/dialog_osx.cpp:191-202`) —
while `wxFileDialog::ShowModal` runs `[panel runModal]` with no such step (`src/osx/cocoa/filedlg.mm:597-620`),
and on macOS `wxProgressDialog` is the generic dialog (only MSW has a native one,
`include/wx/progdlg.h:30-37`), normally shown modeless behind a disabler and destroyed, never ended
through `EndModal`. The `Raise()` is deferred to run after the modal has fully torn down, and guarded
because the dialog may be destroyed while queued.
```cpp
void PluginsDialog::restore_z_order()
{
wxGetApp().CallAfter([this, alive = m_alive]() {
if (alive->load(std::memory_order_acquire) && IsShown())
Raise();
});
}
```
Cite: 0a0d59b76b (`PluginsDialog::restore_z_order`; also passed as the `restore` callback of
`detail::run_off_thread_with_progress`).
- **Rule:** Never position windows by absolute coordinates on Wayland, and never expect `SetIcon` to show on
macOS or Wayland.
---
## 5 Window styles
**Contract.**
- `wxDEFAULT_FRAME_STYLE` = `wxSYSTEM_MENU | wxRESIZE_BORDER | wxMINIMIZE_BOX | wxMAXIMIZE_BOX |
wxCLOSE_BOX | wxCAPTION | wxCLIP_CHILDREN` (`interface/wx/toplevel.h:55-61`); `wxDEFAULT_DIALOG_STYLE`
= `wxCAPTION | wxSYSTEM_MENU | wxCLOSE_BOX`, `wxSYSTEM_MENU` unused under Unix (`interface/wx/dialog.h:20, 99-101`).
Non-resizable frame: `wxDEFAULT_FRAME_STYLE & ~(wxRESIZE_BORDER | wxMAXIMIZE_BOX)`.
- `wxMINIMIZE_BOX`, `wxMAXIMIZE_BOX`, `wxCLOSE_BOX` implicitly enable `wxCAPTION` "on most systems"
(`interface/wx/dialog.h:94-114`, `interface/wx/frame.h:61-80`). `wxMAXIMIZE_BOX` is ignored on wxGTK without
`wxRESIZE_BORDER` (`interface/wx/frame.h:75-78`). The `wxMAXIMIZE` style works on Windows and GTK only; `wxICONIZE`/
`wxMINIMIZE` on Windows only (`interface/wx/frame.h:59-74`).
- `wxSTAY_ON_TOP`: above all other windows. `wxFRAME_FLOAT_ON_PARENT`: above its parent only, "must have a
non-null parent". `wxFRAME_NO_TASKBAR`: no taskbar button on Windows/GTK (GTK only with
`_NET_WM_STATE_SKIP_TASKBAR` support). `wxFRAME_TOOL_WINDOW`: small title bar, no taskbar button
(`interface/wx/frame.h:82-106`).
- Borders: `wxBORDER_NONE/SIMPLE/SUNKEN/RAISED/STATIC(MSW)/THEME`; `wxTRANSPARENT_WINDOW` is obsolete and
does nothing (`interface/wx/window.h:191-220`).
- Extra styles (`SetExtraStyle`, some must precede two-step `Create`): `wxWS_EX_BLOCK_EVENTS`,
`wxWS_EX_TRANSIENT` ("Don't use this window as an implicit parent… risk of creating a dialog/frame with
this window as a parent, which would lead to a crash"), `wxWS_EX_PROCESS_IDLE`,
`wxWS_EX_PROCESS_UI_UPDATES` (`interface/wx/window.h:262-290`). Dialogs set `wxWS_EX_BLOCK_EVENTS` by default
(`src/common/dlgcmn.cpp:124-127`): command events from inside a dialog never reach its parent frame;
frames do not block.
**Platforms. [source]**
| Style | MSW | macOS | GTK |
|---|---|---|---|
| MIN/MAX/CLOSE_BOX | force `WS_CAPTION` (`src/msw/toplevel.cpp:132-137`) — a custom title bar must strip it | — | — |
| frame with a parent, no `FLOAT_ON_PARENT` | unowned; gets its own taskbar button (`WS_EX_APPWINDOW`) unless `wxFRAME_NO_TASKBAR` (`src/msw/toplevel.cpp:193-244`) | — | — |
| `wxFRAME_FLOAT_ON_PARENT` | native owner = `GetHwndOf(parent)` as given (`MSWGetParent`, `src/msw/toplevel.cpp:212-240`) — pass a top-level window | `NSFloatingWindowLevel`, a *global* level: floats above other apps' windows too (`src/osx/cocoa/nonownedwnd.mm:835-876`) | `transient_for` the parent's top-level window, set only in `Create` (`src/gtk/toplevel.cpp:774-782`) |
| `wxFRAME_TOOL_WINDOW` | small caption, no taskbar | `NSFloatingWindowLevel` | no taskbar |
| `wxSTAY_ON_TOP` | `WS_EX_TOPMOST` | `NSModalPanelWindowLevel` | keep-above + `transient_for` in `Create` (`src/gtk/toplevel.cpp:774-791`); runtime change honoured |
| runtime `SetWindowStyleFlag` | — | re-levels the window (`src/osx/cocoa/nonownedwnd.mm:1038-1056`) | updates only `STAY_ON_TOP` and `NO_TASKBAR` (`src/gtk/toplevel.cpp:1943-1968`) |
macOS dialogs are not attached as Cocoa child windows (no `addChildWindow`, `src/osx/cocoa/nonownedwnd.mm:947-991`),
so they do not move with their parent; non-tool windows get `setHidesOnDeactivate:NO`.
**OrcaSlicer.**
- `DPIAware`'s constructor defaults `style = wxDEFAULT_FRAME_STYLE` and `name = wxFrameNameStr` for
dialogs too: `DPIDialog(parent, id, title)` without a style is resizable with min/max boxes. Pass
`wxCAPTION | wxCLOSE_BOX` or `wxDEFAULT_DIALOG_STYLE` (§7).
- `MainFrame` uses `BORDERLESS_FRAME_STYLE` (`MainFrame.cpp`: min/max/close boxes, plus `wxRESIZE_BORDER`
off Apple, no `wxCAPTION`) and paints its own title bar: MSW strips the `WS_CAPTION` that wx adds,
macOS calls `set_miniaturizable`, GTK adds `ResizeEdgePanel`s. Custom titlebar rules:
`references/platforms.md`.
- `TextureProjectorFrame` is the model for a tool frame floating above the main window:
`wxCAPTION | wxRESIZE_BORDER | wxCLOSE_BOX | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT`, parented to
`mainframe`, `SetTransparent` before the first show.
**Pitfalls.**
- **Rule:** Remove style bits with `& ~flag`; `!flag` is 0.
```cpp
// Wrong: !wxCAPTION | !wxCLOSE_BOX | wxBORDER_NONE // == wxBORDER_NONE by accident
// Right: wxBORDER_NONE // or: wxDEFAULT_FRAME_STYLE & ~wxCAPTION
```
- **Rule:** Parent a `wxFRAME_FLOAT_ON_PARENT` frame to a top-level window, never to a panel or NULL.
**Why:** with NULL wx asserts (silently in Orca) and ignores the flag (`wxTopLevelWindowMSW::MSWGetParent`).
With a panel, the ports disagree on the owner — MSW passes the panel's own HWND (`GetHwndOf(parent)`),
GTK resolves `wxGetTopLevelParent(parent)` (`src/gtk/toplevel.cpp:774-782`) — and the frame is deleted
with the panel (§2). A top-level parent gives every port the same owner and lifetime.
- **Rule:** Don't use `wxSTAY_ON_TOP` or `wxFRAME_FLOAT_ON_PARENT` to keep a tool window "above the app" on
macOS without accepting that it also floats above other applications.
---
## 6 Dialogs and modality
**Contract** (`interface/wx/dialog.h`).
- Stack allocation is the sanctioned form for a modal dialog: "the modal dialog is one of the very few
examples of wxWindow-derived objects which may be created on the stack… no need to call Destroy()";
heap form `ShowModal()` then `dlg->Destroy()` (`interface/wx/dialog.h:61-88`).
- `ShowModal()`: "Program flow does not return until the dialog has been dismissed with EndModal()…
ShowModal() can't be called twice without intervening EndModal() calls… creates a temporary event loop…
also results in a call to wxApp::ProcessPendingEvents()" (`interface/wx/dialog.h:597-618`). Timers, `CallAfter`s,
idle-time deletion, socket and worker events all run inside it.
- `EndModal(retCode)` sets the value `ShowModal()` returns (`interface/wx/dialog.h:340-349`). `Show(false)`: "The preferred
way of dismissing a modal dialog is to use EndModal()" (`interface/wx/dialog.h:586-595`).
- `ShowWindowModal()` is "only fully implemented in wxOSX… under the other platforms it behaves like
ShowModal()" (`interface/wx/dialog.h:620-638`). `ShowWindowModalThenDo(functor)`: the dialog must outlive the functor —
hold it in a `wxWindowPtr` captured by value (`interface/wx/dialog.h:640-678`).
- "you shouldn't show a modal dialog from a mouse click event handler as this would break the mouse capture
state" — defer with `CallAfter` (`interface/wx/event.h:490-497`). **[source]** GTK's `ShowModal` releases
any mouse capture first (`GTKReleaseMouseAndNotify`, `src/gtk/dialog.cpp:137`). Capture rules:
`references/mouse-keyboard-focus.md`.
**[source] facts.**
- `wxDialogBase::EndDialog(rc)` (protected, `src/common/dlgcmn.cpp:361-367`) = `IsModal() ? EndModal(rc) : Hide()`:
ends either kind from inside the class.
- Modal loops nest and unwind LIFO on every port: MSW `wxEventLoopManual::DoStop` only wakes the loop
(`src/common/evtloopcmn.cpp:388-401`), GTK re-enters `gtk_main()` until its own `m_shouldExit`
(`src/gtk/evtloop.cpp:58-90`), macOS keeps a LIFO `s_modalStack` (`src/osx/dialog_osx.cpp:47-60`) and
stops via `[NSApp abortModal]`, which hits the innermost session (`src/osx/cocoa/evtloop.mm:453-456`).
Every port's `EndModal` stops its loop through `wxEventLoopBase::Exit()`, whose
`wxCHECK_RET(IsRunning())` returns silently unless that loop is the active (innermost) one
(`src/common/evtloopcmn.cpp:91-96`; MSW via `Hide()` → `wxDialogModalData::ExitLoop`,
`src/msw/dialog.cpp:64-67, 199-207, 261-268`; macOS `src/osx/dialog_osx.cpp:191-194`; GTK's `EndModal` tests
`IsRunning()` itself, `src/gtk/dialog.cpp:199-202`). Ending a lower dialog first therefore hides it
but never tells its loop to exit: its `ShowModal()` does not return even after every dialog above it
has ended. On MSW its `wxWindowDisabler` (owned by the generic `wxModalEventLoop` that MSW's
`ShowModal` runs, deleted only in its `OnExit()`, `include/wx/evtloop.h:377-396`) stays alive too,
so the other top-level windows remain disabled. GTK uses no disabler: modality is each dialog's own
`gtk_window_set_modal` grab (`src/gtk/dialog.cpp:158`). The same silent `Exit()` no-op applies to
nested `wxEventLoop`s: `references/threads-timers-app.md` §Nested event loops.
- Return codes are ids: `wxID_OK = 5100`, `wxID_CANCEL`, `wxID_APPLY`, `wxID_YES`, `wxID_NO`, …
(`include/wx/defs.h:1847`). `wxYES 0x2`, `wxOK 0x4`, `wxNO 0x8`, `wxCANCEL 0x10`, `wxAPPLY 0x20`,
`wxCLOSE 0x40` are style bits (`include/wx/defs.h:1665-1672`) — `EndModal(wxCANCEL)` makes `ShowModal() == wxID_CANCEL` false.
- Whichever `EndModal` runs last before `ShowModal()` returns sets the result: every port's `EndModal`
calls `SetReturnCode` unconditionally and `ShowModal()` returns `GetReturnCode()`.
**Per-port modal behaviour. [source]**
| | MSW (`src/msw/dialog.cpp:195-268`) | macOS (`src/osx/dialog_osx.cpp:106-202`) | GTK (`src/gtk/dialog.cpp:60-205`) |
|---|---|---|---|
| `Hide()`/`Show(false)` on a modal dialog | exits the loop; `ShowModal()` returns the current code (0 if none set) | **does not exit**: clears the modality, orders the window out; `ShowModal()` stays blocked behind an invisible dialog, and wx's `EndDialog` path then takes the `Hide()` branch | calls the virtual `EndModal(wxID_CANCEL)`, overwriting any code |
| `EndModal()` on a modeless dialog | sets the code and hides (assert compiled out) | sets the code, hides, raises the parent | sets the code, then `wxFAIL` + return: the dialog stays visible |
| `IsModal()` between `EndModal()` and the return of `ShowModal()` | true (`m_modalData`, `include/wx/msw/dialog.h:48`) | false | false |
| `EndModal()` raises the parent | no | yes ("Prevent app frame from taking z-order precedence") | no |
| native owner / transient | fixed at `Create` (§1) | none (not a child window) | `transient_for` set in `ShowModal()` |
| how other windows are blocked | `wxWindowDisabler` in a generic `wxModalEventLoop` (`src/msw/dialog.cpp:70`) | Cocoa modal session (`src/osx/cocoa/evtloop.mm:424-456`) | `gtk_window_set_modal` grab; mouse capture released first |
**Buttons, ESC and the close box. [source]**
- `SetAffirmativeId(id)` (default `wxID_OK`): that button runs `Validate()` + `TransferDataFromWindow()` and
closes with the id (`interface/wx/dialog.h:480-494`). `SetEscapeId(id)`: default `wxID_ANY` = the `wxID_CANCEL`
button if present, else the affirmative one; `wxID_NONE` = ignore ESC; native dialogs cannot be
customized (`interface/wx/dialog.h:496-515`). `CreateStdDialogButtonSizer` makes `wxButton`s and sets the affirmative
id (`interface/wx/dialog.h:291-304`) — Orca uses `DialogButtons` instead (§8).
- Routing is by **id**, not type: `wxDialogBase::OnButton` (static table, `src/common/dlgcmn.cpp:455-480`) handles any
`wxEVT_BUTTON` that propagates to the dialog — affirmative id → `AcceptAndClose()` (`Validate()` +
`TransferDataFromWindow()` then `EndDialog(id)`), `wxID_APPLY` → validate + transfer, no close, escape id
or `wxID_CANCEL` → `EndDialog(wxID_CANCEL)`, anything else skipped. An Orca `Button` with `wxID_OK` or
`wxID_CANCEL` and no handler (or a handler that `Skip()`s) closes the dialog by itself; a handler bound
on the button that does not `Skip()` suppresses it.
- ESC → `wxDialogBase::OnCharHook` → `SendCloseButtonClickEvent()`: tries the escape id (`wxID_CANCEL`), then
the affirmative id, through `EmulateButtonClickIfPresent`, which does
`wxDynamicCast(FindWindow(id), wxButton)` and requires the button enabled and shown
(`src/common/dlgcmn.cpp:387-453`). Orca's `Button` derives from `StaticBox : wxWindow`, so emulation finds nothing:
in a plain `wxDialog` with Orca buttons ESC does nothing; in a `DPIDialog`, `DPIAware`'s own char hook
turns ESC into `Close()` first (§7).
- Close box (and `Close()`) → `wxDialogBase::OnCloseWindow` (`src/common/dlgcmn.cpp:525-559`): if shown, try
`SendCloseButtonClickEvent()`; when that finds no `wxButton`, `EndDialog(wxID_CANCEL)`. The Cancel
button's own handler never runs on this path.
**Modeless dialogs.** `Show()`; the close box only hides them (above). Long-lived modeless windows (monitor
pages, progress dialogs, plugin dialogs) either destroy themselves in a close handler or are deliberately
hide-on-close and reused (§2 shapes).
**Modality helpers.**
- `wxWindowDisabler(winToSkip, winToSkip2)` disables all *shown and enabled* top-level windows except the
skipped ones and re-enables them in its destructor (`interface/wx/utils.h:58-111`). MSW: a skipped window
that appears in the taskbar lets the user close the whole app from the taskbar (`interface/wx/utils.h:93-99`).
**[source]** The destructor re-enables every top-level window not recorded as skipped — including ones
created after the disabler (`src/common/utilscmn.cpp:1527-1546`); on macOS the constructor begins a Cocoa
modal session and **shows** `winToSkip` if it is not on screen (`src/osx/cocoa/evtloop.mm:458-514, 573-584`).
Keep disablers scoped and strictly nested.
- `wxModalDialogHook::GetOpenCount()` (since 3.3.0, `interface/wx/modalhook.h:118-126`) counts every open
modal — generic and native message/file/colour/font/print dialogs all use `WX_HOOK_MODAL_DIALOG`. Use it
for "is any modal open" in new code.
- `wxFrame::SetWindowModality(wxWindowMode)` (new in 3.3.2): call before showing; `AppModal`/`WindowModal`
(`interface/wx/frame.h:318-329`). **[source]** Applies immediately in the call, ends at the first hide, adds
`wxFRAME_NO_TASKBAR` and removes `wxMINIMIZE_BOX` (`src/common/framecmn.cpp:159-195, 304-339`); it does not
block — the caller keeps running.
- Orca's pseudo-modal `ParamsDialog` (filament/printer settings): shown modeless with `Popup()` →
`Show()`, creates a heap `wxWindowDisabler(this)` in its `wxEVT_SHOW` handler and deletes it on hide; its
close handler validates (vetoes when validation fails and `CanVeto()`), hides, and never destroys, so the
hosted tabs stay reusable; `ParamsDialog::Popup()` calls `Reparent(mainframe)` on Windows before showing.
**Pitfalls.**
- **Rule:** Use `wxID_*` codes with `EndModal`.
```cpp
// Wrong: EndModal(wxCANCEL); EndModal(wxCLOSE); EndModal(wxOK);
// Right: EndModal(wxID_CANCEL); EndModal(wxID_CLOSE); EndModal(wxID_OK);
```
- **Rule:** Dismiss a modal dialog with `EndModal(rc)`, not `Hide()`/`Show(false)`.
**Why:** macOS keeps `ShowModal()` blocked behind an invisible dialog; GTK forces `wxID_CANCEL`; MSW
returns whatever code was last set.
- **Rule:** `EndModal()` only on a modal dialog; a modeless one is hidden/closed/destroyed (or `EndDialog`).
**Why:** GTK ignores `EndModal` on a modeless dialog — it stays on screen there and only there.
- **Rule:** Never `Destroy()` a modal dialog whose loop is still running; `EndModal(rc)` first, then
`Destroy()` (heap) — from the caller once `ShowModal()` has returned, or straight after `EndModal()`
as forced teardown does, relying on a top-level `Destroy()` only queuing the deletion. Never `Destroy()`
a stack dialog.
Cite: `WebDialog::destroy_silently` (`EndModal(wxID_CANCEL)` then `Destroy()`).
- **Rule:** Close nested modal dialogs innermost-first.
**Why:** ending a lower one hides it but leaves its `ShowModal()` stranded (and, on MSW, its disabler
alive) on every port — see the LIFO bullet above.
- **Rule:** Show a modal from a mouse handler only through `CallAfter`.
```cpp
// Wrong: m_btn->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { MyDialog dlg(this); dlg.ShowModal(); });
// Right:
m_btn->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) {
e.Skip();
CallAfter([this] { MyDialog dlg(this); dlg.ShowModal(); });
});
```
The same applies to `EndModal` bound to `wxEVT_LEFT_DOWN`; bind `wxEVT_BUTTON` on an Orca `Button` instead.
- **Rule:** No window work (create, show, raise, destroy, modal dialogs) on the stack of a
`wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED` handler — WebKitGTK and WKWebView deliver it synchronously. Detail
and the `WebViewHostDialog` contract: `references/webview-gl-aui-media.md`.
- **Rule:** Scope a `wxWindowDisabler` on the stack or tie it strictly to show/hide; never let heap
disablers outlive their window or end out of order (on macOS each one is a modal session).
---
## 7 DPIDialog and DPIFrame
`template<class P> class DPIAware : public P, public wxInspector::wxInspectable` (`GUI_Utils.hpp`);
`typedef DPIAware<wxFrame> DPIFrame;` `class DPIDialog : public DPIAware<wxDialog>`. New Orca dialogs and
frames derive one of them. Public API: `scale_factor()`, `prev_scale_factor()`, `em_unit()`,
`normal_font()`, `enable_force_rescale()`; on Windows `force_color_changed()`. Subclasses implement the pure
virtual `on_dpi_changed(const wxRect&)` and may override `on_sys_color_changed()`.
**What the constructor does** (one-step construction only; signature
`(parent, id, title, pos = wxDefaultPosition, size = wxDefaultSize, style = wxDEFAULT_FRAME_STYLE,
name = wxFrameNameStr)`):
| Step | Detail | Owner of the topic |
|---|---|---|
| scale factor, normal font | from `get_dpi_for_window(this)`; `SetFont(m_normal_font)` **except on macOS** (`#ifndef __WXOSX__`, avoids name cutting in `ObjectList`) | `references/dpi-bitmaps-fonts.md` |
| `CenterOnParent()` | runs **before any content exists** — re-centre after fitting | §8 |
| `SetupInspectorAccelerator(this)` | wxInspector toggle (Ctrl+Shift+I) in builds without `WXINSPECTOR_DISABLE`; a later `SetAcceleratorTable()` on the window replaces it | `references/orca-widgets.md` |
| MSW `update_dark_ui(this)` | no-op (its body only reads the dark flag) | `references/colours-dark-mode.md` |
| `update_em_unit()` | non-GTK `max(10, 10·scale)`; GTK `max(10, GetTextExtent("m").x - 1)` | `references/dpi-bitmaps-fonts.md` |
| bind `wxEVT_DPI_CHANGED` (not macOS) | rescales (`Freeze` → font/em → `on_dpi_changed` → `Layout` → `Thaw`) when the scale changed and no monitor drag is in progress, and does **not** `Skip()` — so wx's default top-level handler (scale the window size by the DPI ratio) never runs (`interface/wx/event.h:3572-3583`) | `references/dpi-bitmaps-fonts.md` |
| bind `wxEVT_MOVE_START/END` (MSW-only events) | defer rescale while the window is dragged between monitors | `references/dpi-bitmaps-fonts.md` |
| bind `wxEVT_SYS_COLOUR_CHANGED` | non-Windows: `update_dark_config()` + `on_sys_color_changed()` + `Skip()`; Windows: swallowed | `references/colours-dark-mode.md` |
| dialogs only: bind `wxEVT_CHAR_HOOK` | `WXK_ESCAPE` → `this->Close()`, never skipped; other keys skipped | below |
**ESC in a `DPIDialog`.** ESC = the close box: your `wxEVT_CLOSE_WINDOW` handler if any, else
`wxDialogBase::OnCloseWindow` → `EndDialog(wxID_CANCEL)` (modal: `ShowModal()` returns `wxID_CANCEL`;
modeless: hidden). The dynamic `DPIAware` hook runs before wx's static `wxDialogBase::OnCharHook`, so
`SetEscapeId()` has no effect on ESC. A `MessageDialog` with only Yes/No buttons therefore returns
`wxID_CANCEL` on ESC or the close box — callers must treat anything but `wxID_YES` as "no".
**`dialogStack` and the `EndModal` guard.** `DPIAware::ShowModal()` (same signature as the virtual
`wxDialog::ShowModal`, so it overrides it) pushes `this` on the global `std::deque<wxDialog*> dialogStack`
(`GUI_Utils.cpp`) and pops it after the loop. `DPIDialog::EndModal(retCode)` **refuses** — logs
"DPIAware::EndModal Error…", returns, the dialog stays open and modal — when the stack is non-empty and
`this` is not `dialogStack.front()`. It is a guard for the LIFO rule of §6, not a fix for a wx bug: wx
cannot end a lower loop first on any port (the dialog would be hidden with its `ShowModal()` stranded),
and the guard turns that into a no-op. It only knows
`DPIDialog`s that went through `DPIAware::ShowModal()`; native and plain-wx modals are not on the stack.
Consumers: `GUI_App::ShowDownNetPluginDlg` (searches the stack to avoid a second instance) and the
`wxEVT_QUERY_END_SESSION` handler in `GUI_App::on_init_inner` (sends a vetoable close to `mainframe`, then
`EndModal(wxID_ABORT)` on every stacked dialog — with the guard only the innermost actually ends). For "is
any modal open" in new code prefer `wxModalDialogHook::GetOpenCount()`.
There is no `Destroy()` override: heap `DPIDialog`s follow the stock `ShowModal(); Destroy();` or the
stack form.
**Pitfalls.**
- **Rule:** Pass the style explicitly.
```cpp
// Wrong: MyDialog(wxWindow* p) : DPIDialog(p, wxID_ANY, _L("Title")) {} // resizable, min/max boxes
// Right: MyDialog(wxWindow* p) : DPIDialog(p, wxID_ANY, _L("Title"), wxDefaultPosition,
// wxDefaultSize, wxCAPTION | wxCLOSE_BOX) {}
```
- **Rule:** Call `CenterOnParent()` again after `SetSizerAndFit()` (`MsgDialog::finalize` does).
**Why:** the `DPIAware` constructor centred the empty, default-sized window.
- **Rule:** An override of `ShowModal()` must call `DPIDialog::ShowModal()` (as
`RichMessageDialog::ShowModal` → `MsgDialog::ShowModal()`, `UnsavedChangesDialog`, `TextureImportDialog`
do), never `wxDialog::ShowModal()`.
**Why:** a dialog that bypasses the push is not on `dialogStack`; when it is opened above another
`DPIDialog`, its own `EndModal` is refused and it cannot close.
- **Rule:** To keep ESC from closing a `DPIDialog`, veto in the close handler or bind your own
`wxEVT_CHAR_HOOK` that swallows `WXK_ESCAPE`; `SetEscapeId(wxID_NONE)` does nothing here.
```cpp
// Right: bound in the subclass constructor, i.e. after DPIAware's hook, so it runs first
Bind(wxEVT_CHAR_HOOK, [](wxKeyEvent& e) { if (e.GetKeyCode() != WXK_ESCAPE) e.Skip(); });
```
- **Rule:** A child that binds the dialog's `wxEVT_DPI_CHANGED` must be bound after `DPIAware` (any child
created in the subclass constructor is) and must `Skip()`.
**Why:** dynamic handlers run most-recently-bound first (`interface/wx/event.h:592-593`) and `DPIAware`'s
handler does not skip; `DialogButtons` binds its parent's event in its constructor and calls `Skip()`,
so it runs and then `DPIAware` rescales. It unbinds in its destructor — the model for any widget that
binds an event on another window.
---
## 8 The Orca dialog recipe
Exemplars: `src/slic3r/GUI/CloneDialog.cpp` (minimal; `DialogButtons` with a left-aligned extra button,
an Enter-key `wxEVT_CHAR_HOOK` that synthesizes the OK `wxEVT_BUTTON`; its OK handler's `wxYield()` loop
inside a frozen plater is not a pattern to copy), `PurgeModeDialog.cpp` (custom-painted clickable card
panels; OK/Cancel `Button`s that close purely by id through `wxDialogBase::OnButton`;
`on_dpi_changed` with min size + `Fit()`/`Refresh()`; its `msw_buttons_rescale` call also resizes those
Orca `Button`s, so leave it out when copying), `FilamentPickerDialog.cpp`
(larger; helper `Create*()` methods returning sizers; positions itself next to the sidebar).
```cpp
class MyDialog : public DPIDialog
{
public:
explicit MyDialog(wxWindow* parent)
: DPIDialog(parent ? parent : static_cast<wxWindow*>(wxGetApp().mainframe), wxID_ANY,
_L("My Dialog"), wxDefaultPosition, wxDefaultSize,
wxCAPTION | wxCLOSE_BOX) // or wxDEFAULT_DIALOG_STYLE; never omit
{
SetBackgroundColour(*wxWHITE); // light design colour, dark-mapped at the end
SetFont(Label::Body_14);
auto* sizer = new wxBoxSizer(wxVERTICAL);
// ... children: Orca widgets, sizes via FromDIP(n), labels via _L() ...
sizer->Add(content_sizer, 1, wxEXPAND | wxALL, FromDIP(10));
auto* dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); // NOT pre-translated
dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { /* apply */ EndModal(wxID_OK); });
dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { /* cancel work */ EndModal(wxID_CANCEL); });
Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& e) { /* same cancel work */ e.Skip(); }); // ESC, close box
sizer->Add(dlg_btns, 0, wxEXPAND);
SetSizerAndFit(sizer);
CenterOnParent(); // DPIAware centred the empty window
wxGetApp().UpdateDlgDarkUI(this); // always last, after all children exist
}
protected:
void on_dpi_changed(const wxRect&) override; // rescale bitmaps/widgets, min sizes, GetSizer()->SetSizeHints(this), Refresh()
};
// caller
MyDialog dlg(this);
if (dlg.ShowModal() == wxID_OK) { /* read results */ }
```
**Conventions and why.**
- `DPIDialog` supplies `scale_factor()`, `em_unit()`, DPI-change handling and ESC handling, and requires
`on_dpi_changed(const wxRect&)`. Typical body: `Rescale()` on Orca widgets, `msw_rescale()` (+
`SetBitmap()` on the displaying control) on `ScalableBitmap`s, min sizes reset in em units or `FromDIP`,
then `GetSizer()->SetSizeHints(this)` (or a dialog `SetMinSize` + `Fit()`) and `Refresh()`.
`msw_buttons_rescale(this, em_unit(), ids)` (min height 2.5 em on the windows with those ids) is for raw
`wxButton`s only: it also overrides the style height of Orca `Button`s carrying those ids, including the
`DialogButtons` OK/Cancel. An empty override is acceptable only for trivially simple dialogs
(`CloneDialog`; on MSW it then keeps its old pixel size). Detail: `references/dpi-bitmaps-fonts.md`
§DPIAware rescale path, `references/sizers-layout.md` §Layout on DPI change.
- Parent fallback `parent ? parent : wxGetApp().mainframe` — never orphan a dialog (§1).
- Title and labels through `_L()` (`references/strings-i18n-files.md`).
- `SetBackgroundColour(*wxWHITE)` and `SetFont(Label::Body_14)` at the top. Setting a font on a dialog is
fine on every platform; the macOS "no `SetFont`" concern is `DPIAware`'s per-DPI default font
(ObjectList name cutting), which `DPIAware` already skips there.
- Light design colours everywhere; `wxGetApp().UpdateDlgDarkUI(this)` as the last line maps them for dark
mode and themes the native parts on Windows; child panels built later use `UpdateDarkUIWin`. Hand-picked
colours go through `StateColor::darkModeColorFor(wxColour("#..."))`. Detail: `references/colours-dark-mode.md`.
- `SetSizerAndFit(sizer)` on the dialog (AGENTS.md rule); where `SetSizer` must come first, follow with
`sizer->SetSizeHints(this)` (`MsgDialog::finalize` does `GetSizer()->SetSizeHints(this)`). Child panels use
plain `SetSizer`. Detail: `references/sizers-layout.md`.
- `CenterOnParent()` after sizing. Dialogs may set the app icon
(`SetIcon(wxIcon(encode_path(icon_path.c_str()), wxBITMAP_TYPE_ICO))` with
`resources_dir()/images/OrcaSlicerTitle.ico`, as `PurgeModeDialog` does — no effect on macOS or
Wayland, §4) and
clamp their size with `SetMinSize/SetMaxSize(FromDIP(...))`.
- Modality: construct on the caller's stack, `ShowModal()`, read the `wxID_*` result. Heap form:
`auto* dlg = new MyDialog(this); dlg->ShowModal(); dlg->Destroy();`. `Show()` is for modeless,
long-lived windows (monitor pages, progress dialogs, plugin dialogs), with a close shape from §2.
**`DialogButtons` and how its buttons close the dialog.** `Slic3r::GUI::DialogButtons(parent,
non_translated_labels, primary_btn_translated_label = "", left_aligned_buttons_count = 0)` is a `wxPanel`
of Orca `Button`s. It calls `_L()` on each label itself and assigns a stock id by matching the lower-cased
label (catalogue: `references/orca-widgets.md`).
| Label | Id | Closes the dialog with no handler bound? |
|---|---|---|
| OK | `wxID_OK` | yes — `AcceptAndClose()` → `EndDialog(wxID_OK)` (affirmative id) |
| Cancel | `wxID_CANCEL` | yes — `EndDialog(wxID_CANCEL)` |
| Apply, Confirm | `wxID_APPLY` (both) | no — `Validate()` + `TransferDataFromWindow()` only |
| Yes, No, Save, Delete, … | `wxID_YES`, `wxID_NO`, `wxID_SAVE`, `wxID_DELETE`, … | no |
| anything unknown | auto id | no — fetch with `GetButtonFromLabel(_L("…"))` or `GetButtonFromIndex(i)` |
Getters (`GetOK`, `GetCANCEL`, …), the full label → id map, and how the primary (Confirm-styled) and alert
buttons are chosen — in numeric id order, so `{"Save", "OK"}` makes Save primary — are in
`references/orca-widgets.md §DialogButtons`.
**Pitfalls.**
- **Rule:** Run cancel cleanup on every close path — the Cancel handler *and* `wxEVT_CLOSE_WINDOW` (or after
`ShowModal()` returns, where every path ends).
**Why:** ESC (via `DPIAware` → `Close()`) and the close box go through `wxDialogBase::OnCloseWindow` →
`EndDialog(wxID_CANCEL)`; wx's button emulation needs a real `wxButton`, so an Orca Cancel button's
handler never runs on those paths (§6).
- **Rule:** Bind every button whose default handling is not what you want; Yes/No/Apply/Confirm/custom
buttons never close by themselves, and an OK handler that does real work ends the dialog itself
(`EndModal(wxID_OK)`) or `Skip()`s to the default `AcceptAndClose()`.
- **Rule:** Pass untranslated labels to `DialogButtons`.
```cpp
// Wrong: new DialogButtons(this, {_L("OK"), _L("Cancel")}); // double translation; no stock ids in non-English UIs
// Right: new DialogButtons(this, {"OK", "Cancel"});
```
- **Rule:** `UpdateDlgDarkUI(this)` runs once, after every child exists; children added later are themed with
`UpdateDarkUIWin(child)`.
- **Rule:** Don't create dialogs in a constructor of their parent or before the main frame is shown when MSW
ownership matters (§1); don't keep `CloneDialog`'s `wxYield()` loop pattern — long work goes to a job
(`references/threads-timers-app.md`).
---
## 9 Message boxes: the MsgDialog family
**Rule.** Never `wxMessageBox`/`wxMessageDialog`/`wxRichMessageDialog` once the GUI exists; use the themed
replacements in `src/slic3r/GUI/MsgDialog.hpp` (`Slic3r::GUI`), rooted in `MsgDialog : DPIDialog` (logo on the
left, content on the right, `Button` row underneath, dark-mode and DPI aware). Why: on MSW the TaskDialog-based
native boxes (`wxMessageBox`, `wxMessageDialog`, `wxRichMessageDialog`, `wxProgressDialog`) ignore dark mode
(`interface/wx/app.h:1436-1446`), and native boxes cannot match Orca's look. `wxMessageBox` remains only for
failures before the GUI exists (e.g. `GUI_App::load_language`). Native message-box style limits:
`references/strings-i18n-files.md`. Return-value trap when reading old code: `wxMessageBox()` returns
`wxYES/wxNO/wxCANCEL/wxOK/wxHELP`, while `ShowModal()` returns `wxID_YES/…` (`interface/wx/msgdlg.h:269-276` vs `309-311`).
| Class | Constructor | Notes |
|---|---|---|
| `MessageDialog` | `(parent, message, caption = "", style = wxOK, forward_str = "", link_text = "", link_callback = nullptr)` | default choice; first four parameters match `wxMessageDialog` (style `wxOK`, `wxCANCEL`, `wxYES_NO`, `wxICON_*`); empty caption → "<app> info" |
| `RichMessageDialog` | `(parent, message, caption = "", style = wxOK)` | adds `ShowCheckBox(text, checked)` / `IsCheckBoxChecked()` (a "Don't show again" check box added in its `ShowModal()`); its `SetYesNoLabels`/`SetYesNoCancelLabels`/`SetOKLabel`/`SetOKCancelLabels`/`SetHelpLabel` only store strings and never relabel a button |
| `WarningDialog` | `(parent, message, caption = "", style = wxOK)` | empty caption → "<app> warning" |
| `ErrorDialog` | `(parent, msg, has_code_excerpts)` | caption "<app> error"; `has_code_excerpts` renders source/caret line pairs monospaced (placeholder-parser errors) |
| `InfoDialog` | `(parent, title, msg, is_marked = false, style = wxOK | wxICON_INFORMATION)` | caption is always "<app> information"; `title` is passed as the base's headline, which is not displayed |
| `DeleteConfirmDialog` | `(parent, title, msg)` | a plain `DPIDialog`, not a `MsgDialog`: Delete → `wxID_OK`, Cancel → `wxID_CANCEL` |
`DownloadDialog` and `FilamentWarningDialog` are single-purpose `MsgDialog` subclasses; read their
constructors before reusing them.
**Behaviour** (`MsgDialog.cpp`):
- Window style is always `wxDEFAULT_DIALOG_STYLE`; the `style` argument only selects buttons and icon. A NULL
parent becomes `wxGetApp().mainframe`.
- `MsgDialog::apply_style`: `wxOK` → OK, `wxYES` → Yes, `wxNO` → No, `wxCANCEL` → Cancel; Orca's use of
`wxFORWARD` adds a "Go to <forward_str>" button and turns OK into "Later" (`wxID_CANCEL`). Every button's
handler is `EndModal(btn_id)`, so compare with `wxID_OK/wxID_YES/wxID_NO/wxID_CANCEL` — and with
`wxFORWARD` (the style bit `0x2000`, used as the button id) for "Go to". OK/Yes/Go-to are Confirm-styled and
focused; `wxNO_DEFAULT`, `wxCANCEL_DEFAULT`, `wxHELP` and `wxSTAY_ON_TOP` are ignored.
- Icon from the style: `wxAPPLY` → "completed", `wxICON_WARNING` → "exclamation", `wxICON_INFORMATION` →
"info", `wxICON_QUESTION` → "question", otherwise the app logo; `wxICON_ERROR` greys it.
- ESC and the close box return `wxID_CANCEL` whatever the buttons (§7).
- Content (`add_msg_content`): plain text → a wrapped `Label` in a scrolled window, so `&` is a mnemonic —
escape user text (`references/strings-i18n-files.md`); with `link_text`/`link_callback`, `is_marked`,
code excerpts, or a message containing `<tr>` → a `wxHtmlWindow` with the text `xml_escape`d (`is_marked`
keeps `<`/`>` so markup works) and `\n` → `<br>`.
- Base helpers: `SetButtonLabel(wxID_*, label, set_focus = false)` relabels a created button;
`AddButton(id, label, set_focus = false)` appends a choice button that also ends with `EndModal(id)`;
`show_dsa_button(title = {})` adds a "Don't show again" `CheckBox` that posts `EVT_CHECKBOX_CHANGE`
(int = checked) to the dialog; `get_checkbox_state()`.
- `finalize()` (called by each subclass constructor): `SetSizeHints`, `Layout`, `Fit`, `CenterOnParent`,
`UpdateDlgDarkUI` — the recipe of §8 in one call; a custom `MsgDialog` subclass ends its constructor with it.
**Free helpers** (`GUI.hpp`): `show_error(parent, msg, has_code_excerpts = false)` is **asynchronous** — it
shows an `ErrorDialog` from `wxGetApp().CallAfter`, capturing the raw `parent`; `show_info(parent, msg,
title)` and `warning_catcher(parent, msg)` are synchronous `MessageDialog`s. The `const char*`/`std::string`
overloads decode UTF-8.
**Pitfalls.**
- **Rule:** Relabel buttons with `SetButtonLabel`, not the `RichMessageDialog::Set*Labels` methods.
```cpp
// Wrong: dlg.SetYesNoLabels(_L("Discard"), _L("Keep")); // stored, never shown
// Right: dlg.SetButtonLabel(wxID_YES, _L("Discard")); dlg.SetButtonLabel(wxID_NO, _L("Keep"));
```
- **Rule:** Test for the positive answer; everything else (No, Cancel, ESC, close box) is "no".
```cpp
// Wrong: if (dlg.ShowModal() != wxID_NO) discard();
// Right: if (dlg.ShowModal() == wxID_YES) discard();
```
- **Rule:** Never compare a `MsgDialog` result with `wxYES`/`wxOK` (style bits), except `wxFORWARD` for "Go to".
- **Rule:** Give `show_error` a parent that outlives the deferred call (the main frame, or `nullptr`, which
`MsgDialog` maps to it), not a dialog that may close first; don't rely on the error being visible when
`show_error` returns.
---
## 10 Overlay frames: BaseTransparentDPIFrame
`BaseTransparentDPIFrame : DPIFrame` (`BaseTransparentDPIFrame.hpp/.cpp`) is the base for small
semi-transparent prompt frames (text, OK/Cancel `Button`s, optional timed fade-out via
`DisappearanceMode::TimedDisappearance`). Its lifetime design:
- Parent is always `wxGetApp().mainframe`; borderless.
- `wxEVT_CLOSE_WINDOW` → `on_hide()` on every close, forced or not (it neither vetoes nor destroys): stop
the refresh timer, `Hide()`, then restore the main frame with
`if (!mainframe->IsShown()) mainframe->Show(); mainframe->Raise();` (§4, dd8cb89f6d). The frame is reused;
it is destroyed only through `on_close()` → `Destroy()`, or with the main frame.
- `Show(bool)` override starts/stops the refresh timer; `on_full_screen` adds `wxSTAY_ON_TOP` on macOS so the
overlay stays above a full-screen main window.
When writing a similar overlay: keep the restore rule; give a close handler that respects `CanVeto()` if the
frame can outlive its owner; own the timer as a member (`wxTimer m_timer{this}`) rather than a heap
`new wxTimer()` that is never deleted; write the style as `wxBORDER_NONE` (not `!wxCAPTION | …`, §5).
@@ -0,0 +1,675 @@
# wx 3.1.5 → 3.3.2: changes Orca code can trip on
OrcaSlicer moved from wxWidgets **3.1.5** to **3.3.2** (8248b06337, #12941). This file digests the
changes between those versions that matter to GUI code — the 3.1.6–3.2.0 incompatible changes, the
3.3 incompatible changes, and the notable 3.3.0/3.3.1/3.3.2 changes — each with what it means for
Orca, plus the migration already done in Orca as rules for new code. Read it when code written
from older wx knowledge behaves oddly, when a wx doc says "since 3.3", or when reviewing code near
the workarounds in §8. Where this file and prior wx knowledge disagree, this file wins.
Contents: [Rules](#rules) · [1 Reading the change logs](#1-reading-the-change-logs) ·
[2 3.1.6 → 3.2.0](#2-changes-from-316-to-320) · [3 3.3 behaviour changes](#3-33-behaviour-changes-that-compile) ·
[4 3.3 build changes](#4-33-changes-that-break-the-build) · [5 3.3.0](#5-330-notable-changes) ·
[6 3.3.1](#6-331-notable-fixes) · [7 3.3.2](#7-332-notable-changes) ·
[8 Migration done in Orca](#8-migration-already-done-in-orca)
All `docs/`, `interface/`, `include/`, `src/`, `build/` cites are relative to the pinned wx tree
(`find deps -maxdepth 5 -type d -path '*dep_wxWidgets-prefix/src/dep_wxWidgets'`), except paths
explicitly called Orca's (`deps/…`, Orca's `src/CMakeLists.txt`) and bare Orca file + symbol cites.
## Rules
1. Read every "now asserts" in the change logs as "now silently returns or does nothing" in Orca:
wx asserts are compiled out. Validate arguments yourself. → [§1](#1-reading-the-change-logs)
2. Target 3.3.2 only. Do not add `wxCHECK_VERSION` / `wxVERSION_NUMBER` /
`wxVERSION_EQUAL_OR_GREATER_THAN` branches for older wx. → [§8](#8-migration-already-done-in-orca)
3. Mark every override of a wx virtual `override`. 3.2/3.3 changed parameter types (`wxBitmapBundle`,
`wxReadOnlyDC`, `wxWindowBase*`, `wxVersionContext`); a stale signature must fail to compile, not
become a silent overload. → [§4](#4-33-changes-that-break-the-build)
4. Call `Show()` before `Raise()` on a window that may be hidden, guarded by `!IsShown()` (a redundant
`Show()` is not inert on GTK3). → [§3](#3-33-behaviour-changes-that-compile)
5. Never call `SetLabel`/`SetLabelText` on a `wxTextCtrl`; use `ChangeValue` (quiet) or `SetValue`.
→ [§3](#3-33-behaviour-changes-that-compile)
6. Do not use `wxTRANSPARENT_WINDOW` (it is `0`). Give the panel an explicit background colour, or set
`wxBG_STYLE_TRANSPARENT` before `Create()`. → [§3](#3-33-behaviour-changes-that-compile)
7. Decide Orca's theme with `GUI_App::dark_mode()`, never `wxSystemAppearance::IsDark()` or
`SelectLightDark()` (on MSW they report the OS app mode); ask about the OS with `AreAppsDark()` /
`IsSystemDark()`. → [§3](#3-33-behaviour-changes-that-compile)
8. Request multisampling explicitly (`.SampleBuffers(1).Samplers(4)` or `WX_GL_SAMPLE_BUFFERS` +
`WX_GL_SAMPLES`); `wxGLAttributes::Defaults()` no longer includes it. → [§3](#3-33-behaviour-changes-that-compile)
9. Size a `wxImageList` in physical pixels (the bitmaps' `GetSize()`), or use the `wxBitmapBundle`
image APIs. → [§3](#3-33-behaviour-changes-that-compile)
10. Return translated strings as `wxString` by value. → [§3](#3-33-behaviour-changes-that-compile)
11. Escape `&` in user text used as a control label or book page title. → [§3](#3-33-behaviour-changes-that-compile)
12. `wxDynamicCast` only on a pointer whose static type derives from `wxObject`; use `dynamic_cast`
for mixin interfaces (`wxComboPopup`, `wxItemContainer`, `wxTextEntry`). → [§4](#4-33-changes-that-break-the-build)
13. Build a `wxArrayString` with `Add()` or an initializer list, walk wx lists with
`compatibility_iterator` or range-for, and make a `wxString` the first operand of a mixed
`+` chain. → [§4](#4-33-changes-that-break-the-build)
14. MSW windows are not double-buffered by default in 3.3.2; a custom-painted control buffers itself.
→ [§5](#5-330-notable-changes)
15. `wxEVT_DPI_CHANGED` now fires on GTK3 too; such handlers must be correct on GTK, and every handler
you bind calls `Skip()` (`DPIAware`'s own is the one exception). → [§5](#5-330-notable-changes)
16. wx MSW dark mode cannot be switched at runtime; do not replace Orca's NppDarkMode with it without
keeping live theme switching. → [§5](#5-330-notable-changes)
17. After `wxStaticText::SetLabel`, re-wrap with `Wrap(-1); Wrap(w);` (or use `Label`); `wxST_WRAP`
is opt-in and works only inside a sizer. → [§7](#7-332-notable-changes)
18. On Linux/X11 call `wxGLCanvas::PreferGLX()` before any GL use; on Unix set a swap interval explicitly
if frame pacing matters (wx forces 0). → [§7](#7-332-notable-changes)
19. Compare `wxGrid::GetSelectedBlocks()` `begin()` with `end()` before dereferencing.
→ [§8](#8-migration-already-done-in-orca)
20. Keep the post-upgrade workarounds (MainFrame `WS_CAPTION` masking, macOS deep-link handler,
`MSWEnableDarkMode` ordering, `SidePopup` anchoring, MSW `GLCanvas3D::on_paint` render) when
touching that code. → [§8](#8-migration-already-done-in-orca)
## 1 Reading the change logs
**Sources.** `docs/changes.txt` lists changes since 3.2: incompatible behaviour changes :11-146,
build-breaking changes :149-252, 3.3.2 :255-330, 3.3.1 :333-377, 3.3.0 :380-622.
`docs/changes_32.txt` covers 3.x → 3.2.0: the cumulative "INCOMPATIBLE CHANGES SINCE 3.0.x" list
:9-233, 3.2.0 :235-276, 3.1.7 :279-340, 3.1.6 :343-444. wxQt, wxiOS, wxUniv, wxMotif and wxGTK1
items are left out here.
**Coverage gaps.**
- The "since 3.0.x" list in `changes_32.txt` is cumulative over all of 3.1.x. Most of it was already
in force in 3.1.5; §2.1 lists only what was added after 3.1.5, §2.3 the older items worth knowing.
- `changes_32.txt` in this tree stops at 3.2.0. The 3.2.1–3.2.10 maintenance fixes are not listed
anywhere in the tree.
- 3.3.0's list is relative to 3.2.8 (`changes.txt:383`). 3.3.2's list is relative to 3.2.10, and the
full set of changes since 3.3.1 *also includes* the 3.2.9 and 3.2.10 fixes, which are **not**
listed in this file (`changes.txt:258-259`).
**"Asserts" mean silence in Orca.** wx is built with `-DwxBUILD_DEBUG_LEVEL=0`
(`deps/wxWidgets/wxWidgets.cmake` → `build/cmake/init.cmake:245-246`), and `libslic3r_gui` gets
`-DwxDEBUG_LEVEL=0` when `SLIC3R_STATIC` (Orca's `src/slic3r/CMakeLists.txt`). At level 0 `wxASSERT`,
`wxASSERT_MSG`, `wxFAIL` and `wxFAIL_MSG` expand to nothing (`include/wx/debug.h:314-324`), while
`wxCHECK*` still test the condition and return early, but silently (`include/wx/debug.h:342-368`).
Every "now asserts" item below is, in Orca, either a silent early return (`wxCHECK`) or carrying on
with bad state (`wxASSERT`). Nothing ever shows a dialog.
**Build configuration that decides which changes bite** (generated `wx/setup.h` in the wx build
directory, `dep_wxWidgets-build/lib/wx/include/<toolkit>-unicode-static-3.3/wx/setup.h`, plus
`deps/wxWidgets/wxWidgets.cmake`):
| Setting | Orca value | Consequence |
|---|---|---|
| Linux toolkit | GTK3 (`option(DEP_WX_GTK3 … ON)` in Orca's `deps/CMakeLists.txt`, `SLIC3R_GTK` "3"; Flatpak gtk3) | GTK3 items apply; GTK2 is only the `-DDEP_WX_GTK3=OFF` opt-out |
| `WXWIN_COMPATIBILITY_3_0` / `_3_2` | `0` / `1` | 3.0-deprecated API is gone, 3.2-deprecated still compiles |
| `wxUSE_STD_CONTAINERS` | `1` | wx containers are std-like; `wxList::Node` and `wxArrayString(n, s)` are gone (§4) |
| `wxUSE_STD_STRING_CONV_IN_WXSTRING` / `wxUSE_UNSAFE_WXSTRING_CONV` | `0` / `1` | no implicit `wxString` → `std::string`; use `into_u8` / `ToStdString` |
| `wxUSE_NANOSVG` / `wxUSE_LUNASVG` | `0` / `0` | `wxHAS_SVG` undefined: no `wxBitmapBundle::FromSVG*`; Orca rasterises SVG itself |
| `wxUSE_STC` | `OFF` | Scintilla items do not apply |
| `wxUSE_LIBWEBP` | `builtin` (Flatpak: `sys`) | WebP decodes through `wxImage` (§5) |
| `wxUSE_WEBVIEW_EDGE` | MSVC only, IE off | Edge items apply on Windows only; Chromium backend not built |
| `wxUSE_GLCANVAS_EGL` | `ON` | effective only on GTK3 with EGL found |
## 2 Changes from 3.1.6 to 3.2.0
### 2.1 Incompatible changes new since 3.1.5
These entries of `changes_32.txt` "INCOMPATIBLE CHANGES SINCE 3.0.x" were not in 3.1.5's list, plus
the 3.1.7-specific section.
| Change | Cite (`changes_32.txt`) | Orca relevance |
|---|---|---|
| `wxRegEx` uses PCRE; `wxRE_ADVANCED` syntax is now PCRE syntax, `wxRE_BASIC` is deprecated, POSIX classes `[:XXXX:]` fail to compile | :15-17; `interface/wx/regex.h:95, 148-201` | Orca uses `wxRegEx` for simple patterns (e.g. `TroubleshootDialog`); write new patterns in PCRE syntax |
| `wxSpinCtrlDouble::SetValue(wxString)` with invalid text resets to `GetMin()` | :126-127 | raw spin controls only; Orca's `SpinInput` is custom |
| `wxSpinCtrl::SetValue(wxString)` sends no events on MSW (as documented) | :129-130 | do not rely on setter events (`controls-dataview.md`) |
| `wxButton::GetBitmap{Current,Disabled,Focus,Pressed}()` return a valid bitmap on MSW only if set | :132-134 | check `IsOk()` |
| `wxFileName::GetVolume()` returns `\\share` for UNC paths and `\\?\Volume{GUID}` for GUID paths | :136-140 | path code comparing volumes |
| `wxBitmapComboBoxBase::SetItemBitmap()` takes `wxBitmapBundle` | :152-153 | the bundle change also retyped `OnAddBitmap` (5f365b5c6b, §8) |
| MSW also links `oleacc` (3.1.5 already needed `shlwapi`, `uxtheme`, `version`) | :158-163 | automatic with MSVC |
| Xcode projects drop i386, add arm64 | :205-207 | none (CMake build) |
| `wxImage` ctor from XPM data is `explicit` | :225-226 | write `wxImage(xpm)` |
| `wxWindow::DoGetBorderSize()` removed | :228-229 | use `GetWindowBorderSize()` |
| MSVC 7 unsupported | :231-232 | none |
| 3.1.7: `wxImageFileProperty` members and internal `wxPropertyGridPageState` functions removed | :282-290 | no propgrid in Orca's own code (only the `wxInspector` dev dependency links it, on MSW/macOS) |
### 2.2 New APIs and behaviour in 3.1.6–3.2.0
| Change | Cite | Orca relevance |
|---|---|---|
| `wxBitmapBundle` added and used "throughout the entire API" (3.1.6) | `changes_32.txt:366` | setters take bundles (`wxBitmap` converts implicitly); **overrides** of virtuals that took `wxBitmap` break (5f365b5c6b). Bundles and sizing: `dpi-bitmaps-fonts.md` |
| Bitmap logical/DIP API: `CreateWithDIPSize` (3.1.6), `CreateWithLogicalSize` (3.3.0), `GetLogicalSize`; `CreateScaled` and `GetScaledSize/Width/Height` are "older synonyms … use the new function in the new code" | `interface/wx/bitmap.h:514, 551, 563-579, 706, 770-787` | new code uses the new names; Orca's older call sites (`BitmapCache`, `ScalableBitmap`) still compile |
| `wxDC::GetContentScaleFactor()` returns the effective DPI factor for window DCs (e.g. 1.5 on MSW), unlike `wxWindow::GetContentScaleFactor()` (always 1 on MSW), "since wxWidgets 3.1.6" | `interface/wx/dc.h:153-168` | do not divide bitmap sizes by the DC scale (`painting-custom-widgets.md`) |
| `wxDPIChangedEvent::Scale()` (3.1.6) | `changes_32.txt:372` | convenience for rescaling stored sizes |
| MSW: TLW resizing on DPI change improved and overridable — a handler that sizes the TLW itself does not `Skip()` (3.2.0) | `changes_32.txt:269`; `interface/wx/event.h` (`wxDPIChangedEvent`) | Orca's `DPIAware` handler does not `Skip()` (`dpi-bitmaps-fonts.md`) |
| `wxUILocale` (3.1.6); `wxLocale::IsAvailable` is now implemented through it | `changes_32.txt:348`; `src/common/intl.cpp:740-781` | see `wxUILocale::IsSupported` in §3 |
| `wxWebView::RunScriptAsync()` (3.1.6) | `changes_32.txt:382` | `webview-gl-aui-media.md` |
| wxOSX: "Allow user input in `wxPopupTransientWindow`" (3.1.7) — the popup's child holds mouse capture while the cursor is outside it and releases it inside, toggled from `OnIdle` [source] | `changes_32.txt:334`; `src/common/popupcmn.cpp:438-471` | how wxOSX transient popups track the mouse; the macOS gap-dismissal worked around in `SidePopup::Popup` is not traced to a specific wx change (§8; `popups-menus.md`) |
| wxGTK: Wayland fixes (3.1.6), no GDK errors from `PopupMenu()` on Wayland (3.1.7), `wxCURSOR_SIZING` on Wayland (3.2.0) | `changes_32.txt:397, 317, 261` | `platforms.md` |
| MSW: all native modal dialogs are app-modal (3.1.6) | `changes_32.txt:409` | `windows-dialogs.md` |
| Also new: `wxKeyEvent::IsAutoRepeat()`, `wxSpinCtrl::GetTextValue()/SetIncrement()`, `wxTopLevelWindow::SetContentProtection()`, `wxEVT_SPLITTER_SASH_POS_RESIZE`, OSX full-screen view options, OSX `wxEVT_CHAR` from `wxDataViewCtrl` | `changes_32.txt:364-436` | available API |
### 2.3 Older 3.x changes that still trip prior knowledge
These were already in force in 3.1.5, so the upgrade did not change them, but code written from
3.0-era knowledge gets them wrong (`changes_32.txt` line):
| Contract | Line |
|---|---|
| MSW `wxYield()` generates `wxEVT_IDLE` (idle handlers can run inside a yield) | :25-27 |
| A 0-width or 0-height `wxBitmap` fails on every port | :29-30 |
| Invalid sizer flags assert (silent in Orca; `sizers-layout.md`) | :32-37 |
| `Validate()`/`TransferData*Window()` recurse into children by default (`wxWS_EX_VALIDATE_RECURSIVELY`) | :39-41 |
| MSW: call `Skip()` in `wxEVT_KEY_DOWN`/`wxEVT_CHAR` handlers, or system keys such as Alt+Space and Alt+F4 stop working | :54-57 |
| MSW dotted/dashed pens are high quality and slow; `wxPenInfo::LowQuality()` | :59-62 |
| `wxEVT_AUINOTEBOOK_PAGE_CHANGED` comes after the change | :68-69 |
| Generic `wxDataViewCtrl` stretches its last column | :76-77 |
| GTK `wxNotebook::AddPage()` sends no event for the first page; GTK `wxTextCtrl` sends no `wxEVT_TEXT` at creation | :79-83 |
| `wxDC::GetTextExtent("")` height is 0 on every port | :85-86 |
| `wxTE_PROCESS_ENTER` is required for `wxEVT_TEXT_ENTER`, even multi-line | :96-99 |
| `wxGLCanvas` uses physical pixels on GTK3/macOS: multiply `GetSize()` by `GetContentScaleFactor()` (Orca: `RetinaHelper::get_scale_factor`, GTK3 variant in `GLCanvas3D.cpp`) | :101-104 |
| `wxSizer::RecalcSizes()` is not to be called; call `Layout()` | :116-117 |
| `wxFileDialog::GetPath()/GetFilename()` assert and return empty with `wxFD_MULTIPLE` | :119-120 |
| `wxChoice::GetString()` asserts on an invalid index | :124 |
| Application code cannot construct `wxPaintEvent` | :165-167 |
## 3 3.3 behaviour changes that compile
`changes.txt` "Changes in behaviour not resulting in compilation errors" (:11-146). Each row: the
change, its line, and what it means for Orca.
| Change | Line | Orca relevance |
|---|---|---|
| wxMSW needs Windows 7+ | :14-15 | drop XP/Vista assumptions (they need wx 3.2) |
| Fatal-error exit code is 255 everywhere (was 127 with MSVC); `wxApp::SetErrorExitCode()` (virtual, `interface/wx/app.h:886`), static `SetFatalErrorExitCode()` (:922) | :17-19 | update anything (CI, crash reporting, wrapper scripts) that matches the old code |
| `wxGLCanvas` no longer multisamples by default | :21-23 | pitfall below |
| `wxFileConfig` on Unix defaults to XDG `~/.config/appname.conf` (old file still used; `wxCONFIG_USE_XDG`/`wxCONFIG_USE_HOME`, `MigrateLocalFile()`) | :25-29 | none: Orca uses `AppConfig`, no `wxConfig` (`strings-i18n-files.md`) |
| `wxColourDatabase` uses CSS values; `UseScheme()` restores the old ones (`interface/wx/gdicmn.h:845, 973-996`) | :31-33 | none: stock colours (`*wxGREEN`, …) are fixed RGB (`src/common/gdicmn.cpp:789-830`) and Orca builds colours from hex/RGB. Do not build colours from names |
| `wxAuiNotebook` default art is the new flat art; `wxAuiNativeTabArt` (or `"native"` in XRC) keeps the old look | :35-37 | none: no `wxAuiNotebook` in Orca |
| `wxTHREAD_WAIT_DEFAULT` is `wxTHREAD_WAIT_BLOCK`: `wxThread::Delete()/Wait()` no longer pump events | :39-41 | Orca uses std/boost threads (`wxThread` only for `IsMain()`); a new `wxThread` whose exit needs the main loop would deadlock |
| `wxDocument::OnCloseDocument()` runs once, after the views are destroyed (it used to run twice when the document was closed from the menu); an override must not rely on any view existing | :43-46 | none: no doc/view |
| `wxGrid::FreezeTo()` asserts on out-of-range counts, and freezes even when the grid is too small | :48-51 | silent `false` in Orca; it also refuses reordered or drag-movable rows/columns [source] (`src/generic/grid.cpp:5742-5750`). Clamp arguments (`controls-dataview.md`) |
| Invalid `wxImageList` calls assert | :53-56 | silent in Orca; `wxCHECK` returns (generic `Add` → -1). Create the list with a valid size before use |
| `wxTRANSPARENT_WINDOW` does nothing (`#define wxTRANSPARENT_WINDOW 0`, `include/wx/defs.h:1449`); MSW code that needs it can set `WS_EX_TRANSPARENT` | :58-60 | pitfall below; 026b105dcb |
| MSW `wxTextDataObject::SetData()` size includes the 2-byte NUL (consistent with `GetDataSize()`); an old-style size chops the last character | :62-66 | none: Orca uses the `wxTextDataObject(text)` ctor; use `SetText()`, never `SetData()` |
| `wxListCtrl::EditLabel()` asserts without `wxLC_EDIT_LABELS` | :68-69 | silent no-op in Orca; add the style where editing is intended |
| MSW `wxSystemAppearance::IsDark()` reports the app's own mode; `AreAppsDark()`/`IsSystemDark()` report the OS | :71-73 | pitfall below |
| Unix `wxUILocale::IsSupported()` no longer falls back to another region of the same language; pass just `"fr"` to accept any `fr_XX` | :75-79 | `wxLocale::IsAvailable` builds the region-qualified tag (`GetCanonicalWithRegion()`) and calls `IsSupported()` [source] (`src/common/intl.cpp:740-781`); `GUI_App::load_language` relies on it, so on Linux a language whose canonical locale (e.g. `fr_FR`) is not installed reports unavailable. Keep the fallbacks in `load_language` |
| Deprecated `wxPGCellRenderer::DrawCaptionSelectionRect()` overload not called; override the overload taking `wxWindow*`, or enable 3.0 compatibility | :81-83 | none: no propgrid in Orca's own code |
| `wxImageList` size is in physical pixels | :85-88 | pitfall below |
| Mac `wxWebRequest` no longer uses persistent storage. The entry names `wxWebRequest::EnablePersistentStorage()`; the real API is `wxWebSession::EnablePersistentStorage(bool)` (`interface/wx/webrequest.h:1612-1629`; also `wxWebSessionSync`, :1835), macOS-only, before the first request | :90-92 | none: Orca uses `wxWebRequest` only for image downloads (`wxWebSession::GetDefault().CreateRequest` in `StatusPanel`, `SliceInfoPanel`, `ReleaseNote`, `DeviceErrorDialog`); network and login agents use libcurl (`Slic3r::Http`) |
| MSW `wxBitmap::Create(size, dc)` no longer multiplies by the DC's content scale; the size is physical | :94-96 | remove compensating scaling; use `CreateWithLogicalSize` for logical sizes |
| `wxIMAGE_QUALITY_NEAREST` has a new value and is no longer `wxIMAGE_QUALITY_NORMAL`; `NORMAL` (the `Scale`/`Rescale` default) is bilinear + box average (`interface/wx/image.h:31-67`) | :98-99 | never store or compare the enum numerically; default-quality thumbnails and icons look smoother than under 3.1.5; pass `wxIMAGE_QUALITY_NEAREST` for pixel-exact scaling |
| `wxTextCtrl::{Save,Load}File()` treat `.rtf` as RTF | :101-104 | pass `wxTEXT_TYPE_PLAIN` to keep plain text |
| `wxClientDC`/`wxPaintDC` offset their origin by a `wxFrame` toolbar on every port | :106-109 | none: Orca frames have no native toolbar (`BBLTopbar` is a child `wxAuiToolBar`) |
| `wxTextCtrl::SetLabel()` does nothing and asserts on every port (MSW used to act as `SetValue`) | :111-113 | pitfall below |
| `wxAuiGenericTabArt` subclasses (also via `wxAuiMSWTabArt`) override `DrawPageTab()`/`GetPageTabSize()` instead of `DrawTab()`/`GetTabSize()`; direct `wxAuiTabArt` subclasses still work | :115-120 | none: no Orca tab art |
| `wxAuiNotebook` page index is logical (reorder-independent); `GetPagePosition()` gives the screen position | :122-126 | none; index math under `wxAUI_NB_TAB_MOVE` is what breaks |
| `wxListbook`/`wxChoicebook` interpret mnemonics in page titles "just as the other wx*book classes already did" | :128-130 | Orca uses neither (`BedShapeDialog` uses `wxSimplebook` + a combo); every book interprets `&` (`interface/wx/bookctrl.h:141-147`) → rule 11 |
| `wxAUI_MGR_HINT_FADE` is not in the default `wxAuiManager` style | :132-133 | Plater's `AuiMgr` keeps the default flags (minus `wxAUI_MGR_ALLOW_FLOATING` on Wayland, `Plater::priv::priv`), so the docking hint no longer fades; add the flag if wanted |
| `wxPrintDialogData::SetAllPages(false)`/`SetSelection(false)` changed meaning | :135-137 | none |
| `wxGetTranslation()` returns `wxString` by value | :139-142 | pitfall below |
| `wxWindow::Raise()` no longer shows a hidden window on any port | :144-146 | pitfall below; ba867cc534 |
**Pitfalls**
- **Rule:** Request multisampling explicitly; never rely on `wxGLAttributes::Defaults()` or a null
attribute list for MSAA.
**Why:** 3.1.5's `Defaults()` was RGBA, double buffer, depth 16 plus `SampleBuffers(1).Samplers(4)`
on every port; 3.3's is `RGBA().Depth(16).DoubleBuffer()` (`include/wx/glcanvas.h:175-178`), also used for a null
attribute list (`src/common/glcmn.cpp:159-165`). The change-log line has the arguments swapped:
`SampleBuffers(n)` is "number of sample buffers, usually 1" and `Samplers(n)` the samples per pixel
(`interface/wx/glcanvas.h:233-246`). The plater canvas is unaffected:
`OpenGLManager::create_wxglcanvas` passes `WX_GL_SAMPLE_BUFFERS`/`WX_GL_SAMPLES` explicitly (from
the anti-aliasing setting). A canvas built with `.Defaults()` — the `SkipPartCanvas` that
`PartSkipDialog` creates — renders without MSAA.
```cpp
// Wrong: attrs.PlatformDefaults().Defaults().Stencil(8).EndList(); // no MSAA in 3.3
// Right: attrs.PlatformDefaults().Defaults().SampleBuffers(1).Samplers(4).Stencil(8).EndList();
```
Cite: `docs/changes.txt:21-23`; `OpenGLManager::create_wxglcanvas`.
- **Rule:** Use `GUI_App::dark_mode()` for "is Orca dark", and `AreAppsDark()`/`IsSystemDark()` for
"is the OS dark"; never `GetAppearance().IsDark()` in GUI code (it breaks on MSW, see below;
`colours-dark-mode.md` rule 1 forbids it on every port).
**Why:** `IsDark()` returns true whenever wx's own dark mode is active, else it falls back to
`IsUsingDarkBackground()` (`src/msw/settings.cpp:431-441`). Orca calls
`MSWEnableDarkMode(DarkMode_Auto)` (`DarkMode_Auto = 0`, `include/wx/msw/app.h:48`), which puts wx
in `AppMode_AllowDark` (`src/msw/darkmode.cpp:235-252`); from then on `IsDark()` follows the OS
apps setting (`ShouldUseDarkMode()`, :205-227), whatever theme the user picked in Orca.
`AreAppsDark()` reads `AppsUseLightTheme`, `IsSystemDark()` reads `SystemUsesLightTheme`
(`src/msw/settings.cpp:444-452`; `interface/wx/settings.h:307-358`). On other ports the three agree.
`GUI_App::dark_mode()` honours the `dark_color_mode` app-config value first and only then calls
`check_dark_mode()`, which still uses `IsDark()`.
```cpp
// Wrong: bool dark = wxSystemSettings::GetAppearance().IsDark(); // OS apps setting on MSW
// Right: bool dark = wxGetApp().dark_mode();
```
Cite: 8248b06337; `GUI_App::dark_mode`, `check_dark_mode` (`GUI_Utils.cpp`); `colours-dark-mode.md`.
- **Rule:** `Show()` before `Raise()`, and `Show()` only if the window is hidden.
**Why:** "If the window is currently hidden, this function does *not* show it automatically"
(`interface/wx/window.h:3015-3033`). MSW already behaved so; GTK and macOS used to show it, so a
bring-to-front path that only raises leaves a hidden frame hidden on those ports. On GTK3 a
redundant `Show(true)` still runs `GTKSendSizeEventIfNeeded()` [source]
(`src/gtk/toplevel.cpp:1259-1269`), which froze Orca once (dd8cb89f6d), so guard it.
```cpp
// Wrong: wxGetApp().mainframe->Raise();
// Right: auto* mf = wxGetApp().mainframe; if (!mf->IsShown()) mf->Show(); mf->Raise();
```
Cite: ba867cc534 (the other-instance handlers in `Plater::priv::priv`,
`Plater::priv::bring_instance_forward`); `windows-dialogs.md` §4.
- **Rule:** Do not use `wxTRANSPARENT_WINDOW`; give the panel the background it must blend with, or
set `wxBG_STYLE_TRANSPARENT` **before** `Create()`.
**Why:** the flag is `0`. Under 3.1.5 it set `WS_EX_TRANSPARENT` on MSW; now the panel paints its
own background. A `SetBackgroundStyle(wxBG_STYLE_TRANSPARENT)` call after the window exists is a
`wxCHECK_MSG` that returns `false` silently (`src/common/wincmn.cpp:1616-1625`), so it is no
replacement for the flag — 026b105dcb assumed it was.
```cpp
// Wrong: new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTRANSPARENT_WINDOW);
// Right: auto p = new wxPanel(this, wxID_ANY);
// p->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#3B4446")));
```
Cite: 026b105dcb, 8248b06337 (`MainFrame::create_side_tools`, `MainFrame::update_side_button_style`
re-apply the colour on theme change); `painting-custom-widgets.md`.
- **Rule:** Never `SetLabel`/`SetLabelText`/`GetLabel` on a `wxTextCtrl` to show or read its value.
**Why:** `wxTextCtrlBase::SetLabel` is only `wxFAIL_MSG("Use SetValue() or ChangeValue()
instead.")` (`src/common/textcmn.cpp:933-936`); `SetLabelText` calls the virtual `SetLabel`
(`include/wx/control.h:64-67`), and `GetLabel()` returns `m_labelOrig`, not the text
(`include/wx/control.h:61`). Orca compiles the assert out, so the call is a complete no-op: the
field never updates. Under 3.1.5 MSW it acted as `SetValue`, so code written then looked fine on
Windows.
```cpp
// Wrong: m_input_ip->GetTextCtrl()->SetLabelText(m_obj->get_dev_ip());
// Right: m_input_ip->GetTextCtrl()->ChangeValue(m_obj->get_dev_ip()); // no wxEVT_TEXT
```
Cite: `docs/changes.txt:111-113`; `controls-dataview.md`.
- **Rule:** Size a `wxImageList` from the bitmaps' physical size, or use
`SetImages(std::vector<wxBitmapBundle>)`.
**Why:** "the size is specified in physical pixels and must correspond to the size of bitmaps, in
pixels" (`interface/wx/imaglist.h:62-63`). An Orca icon from `create_scaled_bitmap(name, win, 16)`
is larger than 16 px on HiDPI MSW, where `Add()` hands it to the native `ImageList_Add`
(`src/msw/imaglist.cpp:299-313`), which splits a wider bitmap into list-width images. The generic
list (GTK, macOS) keeps a bitmap whose scale factor is not 1 intact, but returns -1 for a narrower
1×-scale bitmap and chops a wider one into several list-width images [source]
(`src/generic/imaglist.cpp:126-160`).
```cpp
// Wrong: m_images = new wxImageList(16, 16); m_images->Add(create_scaled_bitmap("icon", this, 16));
// Right: wxBitmap bmp = create_scaled_bitmap("icon", this, 16);
// m_images = new wxImageList(bmp.GetWidth(), bmp.GetHeight(), false);
```
Cite: `docs/changes.txt:85-88`; `Tab` builds its list from `bmp().GetWidth()/GetHeight()`;
`dpi-bitmaps-fonts.md`.
- **Rule:** A function that returns a translation returns `wxString` by value.
**Why:** `wxGetTranslation()` returns by value now (`include/wx/translation.h:278-321`); returning
it as `const wxString&` dangles. Orca's `I18N::translate` overloads and `_L` already return by
value.
```cpp
// Wrong: const wxString& title() { return _L("Printer"); }
// Right: wxString title() { return _L("Printer"); }
```
Cite: `docs/changes.txt:139-142`; `I18N.hpp`.
- **Rule:** Escape `&` in user data (preset, filament, printer, file names) shown as a control label or
book page title.
**Why:** labels and page titles interpret `&` as a mnemonic; an unescaped `&` disappears or
underlines the next character. 3.3 extended this to `wxListbook`/`wxChoicebook`.
```cpp
// Wrong: book->AddPage(page, preset_name);
// Right: book->AddPage(page, wxControl::EscapeMnemonics(preset_name)); // or label->SetLabelText(name)
```
Cite: `docs/changes.txt:128-130`; `interface/wx/bookctrl.h:141-147`; `controls-dataview.md`.
## 4 3.3 changes that break the build
`changes.txt` "Changes in behaviour which may result in build errors" (:149-252).
| Change | Line | Orca relevance |
|---|---|---|
| 3.0-deprecated symbols disabled by default (`WXWIN_COMPATIBILITY_3_0=1` at wx build time re-enables them), 2.8-deprecated removed | :152-154 | Orca builds `WXWIN_COMPATIBILITY_3_0 0`, `_3_2 1`: port off 3.0-deprecated API |
| `wxUSE_UNICODE=0` unsupported | :156 | none |
| `wxUSE_STD_CONTAINERS=1` by default ("Container Classes" overview); building wx with 0 keeps the old containers | :158-161 | Orca builds with 1 → pitfall below |
| `wxUSE_STL` gone; implicit `wxString` → `std::[w]string` only with `wxUSE_STD_STRING_CONV_IN_WXSTRING=1` at wx build time | :163-166 | Orca builds 0: convert explicitly (`into_u8`, `ToStdString`, `ToUTF8()`) |
| MSW links `gdiplus.lib`, `msimg32.lib` | :168-172 | automatic with MSVC/wx-config; only static non-MSVC builds add them |
| wxMotif, wxGTK1 removed | :174-175 | none |
| Private containers (e.g. `wxSimpleDataObjectList`) removed; object arrays (`wxImageArray`) compare values in `Index()` | :177-183 | use `std::vector`/`std::list` |
| Operators on wx types are hidden (not global) | :185-189 | pitfall below |
| `wxString` from `std::string_view` makes `wxstr = {"Hello", 2}` ambiguous | :191-194 | write `wxString{"Hello", 2}` |
| Generic `wxSearchCtrl` lost multi-line-only methods | :196-197 | none |
| Wide-filename `wxOnAssert()` overload removed | :199-200 | none |
| 64-bit DLLs carry an `x64` suffix in all build systems | :202-204 | packaging scripts matching DLL names; Orca links wx statically (Flatpak builds it shared in its own manifest) |
| CMake config installs to `lib/cmake/wxWidgets-3.3`; plain `find_package(wxWidgets)` is unaffected, hard-coded paths break | :206-211 | Orca: `find_package(wxWidgets 3.3 CONFIG …)` on Windows/macOS, `wx-config --toolkit=gtk${SLIC3R_GTK}` on Linux (Orca's `src/CMakeLists.txt`) |
| Memory-tracing options removed | :213-216 | use ASan |
| `wxTEST_DIALOG()` needs a trailing `;` | :218-219 | none |
| `wxWindow::GetDefaultBorderForControl()` not virtual | :221-223 | do not override; use `wxBORDER_THEME` |
| GTK `wxDirButton::Create()` lost `wildcard` | :225-226 | none |
| Several virtuals take `wxReadOnlyDC` | :228-231 | pitfall below |
| `wxSizer::Detach()` takes `wxWindowBase*` | :233-236 | only custom sizer subclasses |
| `wx/cursor.h` no longer includes `wx/utils.h` | :238-240 | include `<wx/utils.h>` explicitly (8248b06337 added it to `GLCanvas3D.cpp`) |
| `wxStyledTextCtrl::AddSelection()` returns void | :242-244 | none: `wxUSE_STC=OFF` |
| `wxColour` from `bool` no longer compiles | :246-248 | use the RGB or string ctor explicitly |
| `wxGLCanvas::CreateSurface()` removed from EGL builds | :250-252 | none: wx chooses EGL or GLX itself (§7) |
**Pitfalls**
- **Rule:** Override wx measuring virtuals with `wxReadOnlyDC&` and mark them `override`.
**Why:** these virtuals now take `wxReadOnlyDC&` (the non-drawing base of `wxDC`, since 3.3.0,
`interface/wx/dc.h:115-127`): `wxRendererNative::GetCollapseButtonSize`
(`interface/wx/renderer.h:477`), the AUI tab and toolbar art size getters (`GetTabSize`,
`GetLabelSize`, `GetToolSize`, …; `interface/wx/aui/auibook.h:1186+`, `interface/wx/aui/auibar.h:648-664, 887-903`),
the `wxGridCellRenderer::GetPreferred{Size,Height,Width}`/`GetMaxSize` virtuals that 3.3 added
beside the old `wxDC&` `GetBestSize` family (`include/wx/generic/grid.h:203-251`), the new `wxScrolled::PrepareReadOnlyDC`
(`interface/wx/scrolwin.h:519`), and richtext/ribbon art. Nothing near `DoGetBestSize`, which takes
no DC. Without `override` an old `wxDC&` signature silently becomes an overload that is never
called; with `override` it fails to compile, which is what you want. Orca's `Widgets/` override
none of them; `BBLTopbarArt` overrides only `DrawBackground`/`DrawButton` (still `wxDC&`); the
`ObjectTable` grid renderers override the compatibility `GetBestSize(…, wxDC&, …)`, which "are the
ones actually called by wxGrid" (`include/wx/generic/grid.h:253-271`). Callers are unaffected; a
helper taking `wxDC&` cannot accept a `wxInfoDC`.
```cpp
// Wrong: wxSize GetToolSize(wxDC& dc, wxWindow* w, const wxAuiToolBarItem& it); // never called
// Right: wxSize GetToolSize(wxReadOnlyDC& dc, wxWindow* w, const wxAuiToolBarItem& it) override;
```
Cite: `docs/changes.txt:228-231`; `painting-custom-widgets.md`.
- **Rule:** With `wxUSE_STD_CONTAINERS=1`, build a `wxArrayString` with `Add()` or an initializer
list, and walk `WX_DECLARE_LIST` lists with `compatibility_iterator` or range-for.
**Why:** the std-container `wxArrayString` has no `(count, value)` ctor
(`include/wx/arrstr.h:64-84`), and `List::Node` no longer exists. Convert Orca's UTF-8
`std::string` explicitly: the implicit `wxString(const std::string&)` uses the current locale
(`include/wx/string.h:1323-1326`).
```cpp
// Wrong: load_files(wxArrayString(1, wxString::FromUTF8(target_path.string())));
// AmsRadioSelectorList::Node* node = m_radio_group.GetFirst();
// Right: wxArrayString arr; arr.Add(wxString::FromUTF8(target_path.string())); load_files(arr);
// for (AmsRadioSelector* rs : m_radio_group) { ... } // or ::compatibility_iterator
```
Cite: 1765d296a8 (`Plater::import_model_id`, `SendMultiMachinePage::request_params`); `strings-i18n-files.md`.
- **Rule:** In a concatenation, make a `wxString` the first operand when the others are `char`,
`wchar_t`, `std::string` or `std::wstring`.
**Why:** wx operators are hidden friends now, found only by argument-dependent lookup on a wx type;
"preventing them from implicitly being used with types convertible to wx types"
(`changes.txt:185-189`). `char + std::wstring + …` compiled under 3.1.5 through the global
`operator+(char, const wxString&)`, converting the `std::wstring` implicitly; under 3.3 that
operator is a hidden friend (`include/wx/string.h:2150`) and no operand is a `wxString`, so it
does not.
```cpp
// Wrong: return marker_by_type(opt.type, printer_technology) + opt.category_local + sep + opt.label_local;
// Right: return wxString(marker_by_type(opt.type, printer_technology)) + opt.category_local + sep + opt.label_local;
```
(`marker_by_type` returns `char`, the `*_local` labels are `std::wstring`.) Making only `sep` a
`wxString` (1765d296a8) did not help, because the leading `char + std::wstring` is evaluated first.
Cite: 1765d296a8, 2b3328c2b2 (`OptionsSearcher::search` `get_tooltip`, `Search.cpp`).
- **Rule:** Use `wxDynamicCast` only on a pointer whose static type derives from `wxObject`; for
mixin interfaces use `dynamic_cast`.
**Why:** 3.3's macro casts its argument straight to `const wxObject*` [source]
(`include/wx/object.h:118-121`); 3.1.5 first `static_cast` it to the target class, so a
`wxComboPopup*` → `wxCheckListBoxComboPopup` cast used to compile. `wxComboPopup` has no `wxObject`
base (`include/wx/combo.h:748`), so the 3.3 cast does not compile. The same holds for
`wxItemContainer`, `wxTextEntry` and other non-`wxObject` mixins.
```cpp
// Wrong: auto* p = wxDynamicCast(combo->GetPopupControl(), wxCheckListBoxComboPopup);
// Right: auto* p = dynamic_cast<wxCheckListBoxComboPopup*>(combo->GetPopupControl());
```
Cite: 7ea69199fd (`combochecklist_get_flags`, `combochecklist_set_flags`, `GUI.cpp`).
## 5 3.3.0 notable changes
`changes.txt:380-592` (relative to 3.2.8).
**Major changes** (:385-394)
| Change | Orca relevance |
|---|---|
| Experimental MSW dark mode (#23028), with `wxApp::SetAppearance()` (#24461) | pitfall below; Orca kept NppDarkMode and only calls `MSWEnableDarkMode(DarkMode_Auto)` |
| Chromium `wxWebView` backend (#706) and `wxEVT_WEBVIEW_CREATED` | Chromium is not built; `wxEVT_WEBVIEW_CREATED` is the documented ready signal for Edge (`webview-gl-aui-media.md`) |
| WebP images (#25205) | Orca builds `wxUSE_LIBWEBP=builtin` and calls `wxInitAllImageHandlers()` (`GUI_App::on_init_inner`), so `wxImage` decodes WebP (e.g. downloaded thumbnails) |
| Pinned and multi-row AUI tabs (#25187, #25076) | none: no `wxAuiNotebook` |
| Synchronous `wxWebRequest` (#24760) | available (`wxWebSessionSync`); "must not be used from the main thread of GUI applications" (`interface/wx/webrequest.h:614-615`) |
| Raw touch events (#17077); wxGrid accessibility (#24368) | available |
| Unix power events and blockers (#22396, #23717) | could keep a Linux system awake during long work; Linux covers only `wxPOWER_RESOURCE_SYSTEM` and needs systemd ≥ 183 (`interface/wx/power.h:163-171`); not used by Orca |
| Native GTK file dialogs when possible (#24486, #25104) | the portal dialog is used only with GTK ≥ 3.20 at runtime, without `wxFD_PREVIEW` and without an extra control [source] (`src/gtk/filedlg.cpp:265-273, 438-443`); Orca's `CheckboxFileDialog` (`SetExtraControlCreator`, `GUI_Utils.hpp`) therefore gets the non-native GTK dialog |
| Native `wxTextCtrl` contents / RTF (#24626, #24912) | available (`GetRTFValue`, `SearchText`) |
**All** (:398-442)
| Change | Orca relevance |
|---|---|
| `wxWebRequest`: base URL (#24769), proxy (#24762), repeated headers (#24878); `wxWebSession::EnablePersistentStorage()` (#23743) | see §3 for storage |
| `wxString`: move operations (#23215), `std::string_view` ctor (#23711), faster and more robust `To/FromCDouble()` (#23287), `errno` preserved (#23113), `wc_string()` (#23463), `wxWARN_UNUSED` on the class (#24833, unused-variable warnings for `wxString` locals) | settings fields do **not** parse through `ToCDouble`: `Field` normalises the decimal separator and calls `wxString::ToDouble`, and `double_to_string` uses `wxNumberFormatter::ToString`; `ToCDouble`/`FromCDouble` appear only in a few helpers (e.g. `PreferencesDialog::create_camera_orbit_mult_input`). The `wxNumberFormatter` changes (3.3.1, 3.3.2) matter more for `Field` |
| Lambdas with `Bind()` without RTTI (#14850); move-only `wxMessageQueue` (#25026); `wxLogXXX(string)` safe with a single string (#25414) | available |
| Environment variables use UTF-8 (#25101) | check round-tripping of non-ASCII paths through `wxGetEnv`/`wxSetEnv` |
| Improved locale matching (#24855); thread-safe `wxPlatformInfo::Get()` (#25459); `wxXmlParseError` from `wxXmlDocument::Load()` (#24215); customisable error exit code (#24770) | available |
**All (GUI)** (:444-500)
| Change | Orca relevance |
|---|---|
| High-DPI wave: `wxCursorBundle` (#25374), animations (#23817), generic `wxListCtrl` (#22916), print preview (#24666), AUI dock art after DPI change (#23420), `wxBusyInfo` bitmaps (#23813) | AUI dock-art size metrics are DIP-like now: `wxAuiManager` reads them through `GetMetricForWindow()` (since 3.3.0), which scales them by the window DPI. The docs exempt `wxAUI_DOCKART_SASH_SIZE` and `wxAUI_DOCKART_PANE_BORDER_SIZE` (`interface/wx/aui/dockart.h:274-294`), but the default implementation exempts only `wxAUI_DOCKART_PANE_BORDER_SIZE` (and the non-pixel gradient type) and does scale the sash size [source] (`src/aui/dockart.cpp:199-226`). Pass DIP values to `SetMetric` (Plater's `SetMetric(wxAUI_DOCKART_CAPTION_SIZE, 18)`), never `FromDIP(…)` |
| Dark-mode colours in XRC (#23571); CSS colour names (#23518) | none: no XRC, no colour names |
| `wxTextCtrl::SearchText()` (#24756) and RTF (#24626); locale-aware date/time pickers (#23965, display format changes); `wxSearchCtrl` derives from `wxTextEntry` on all ports (#23686) | available |
| Scintilla 5.0 / Lexilla 5.3 (#23117, #24369); nanosvg crash fixes (#24213) | none: `wxUSE_STC=OFF`, `wxUSE_NANOSVG=OFF` |
| Better `wxImage` resizing (#25252) | default-quality scaling output differs from 3.1.5 (§3) |
| `wxAuiManager::{Save,Load}Layout()` (#24235); `wxAuiNotebook` layout save/restore (#24950) | Orca persists docking with `SavePerspective`/`LoadPerspective` (Plater, `AuiPaneLayout`) |
| Non-live resize restored in wxAUI and `wxSplitterWindow` (#24193) | `wxAUI_MGR_LIVE_RESIZE` is in `wxAUI_MGR_DEFAULT` since 3.3.0 (`interface/wx/aui/framemanager.h:59-66`). The style table's "always enabled in wxGTK3 and wxOSX ports as non-live resizing is not implemented in them" (:199-206) is as stale as the `AlwaysUsesLiveResize()` note: that function "always returns false" as of 3.3.0 (:345; `src/aui/framemanager.cpp:711-714`), and the flag decides on every port [source] (`HasLiveResize`, :716-719). See `webview-gl-aui-media.md` §AUI docking |
| `wxInfoBar::ShowCheckBox()` (#25394); printing multiple page ranges (#25030); `wxGrid::CopySelection()` (#24124); new default flat AUI tab art (#25316); `wxApp::SetAppearance()` (#24461) | available |
**All (WebView)** (:510-525) — `wxWebViewConfiguration` + `GetNativeConfiguration()`, `SetProxy()`,
`ShowDevTools()`, `EnablePersistentStorage()`, `EnableBrowserAcceleratorKeys()`, clearing browsing
data, advanced requests, child-window handling, `IsTargetMainFrame()`, Edge user agent settable after
creation, and **Edge events queued** (#22744, #19075): Edge handlers run later than on the other
backends. Edge posts its events with `AddPendingEvent` (script messages: `src/msw/webview_edge.cpp:811`)
except the vetoable ones, `wxEVT_WEBVIEW_NAVIGATING` (:599) and `wxEVT_WEBVIEW_NEWWINDOW` with its
`NEWWINDOW_FEATURES` follow-up (:723, :737), which stay synchronous; WebKit and
WebKit2GTK deliver script messages synchronously (`src/osx/webview_webkit.mm:1360`,
`src/gtk/webview_webkit2.cpp:408`) [source]. `webview-gl-aui-media.md` owns the details.
**wxGTK** (:527-547)
| Change | Orca relevance |
|---|---|
| `wxDPIChangedEvent` generated (#19290, #24040), GTK ≥ 3.10 (`interface/wx/event.h:3592-3593`) | pitfall below |
| `wxGLCanvas` scale fixed with EGL/Wayland in high DPI (#23733) | Orca has no compensating hack: GTK3 `RetinaHelper::get_scale_factor` returns `GetContentScaleFactor()` |
| `wxKeyEvent::GetKeyCode()` fixed for non-US layouts (#23379) | shortcuts match through `KeyChord::from_event` (`mouse-keyboard-focus.md`) |
| Missing enter/leave events fixed (#24339); mouse event generation fixes (#24931-#24933) | hover logic (`StateHandler`) gets enter/leave reliably |
| Total window size with GNOME on X11 (#25348) | TLW geometry |
| `libwebkit2gtk-4.1` support (#23633) | wx's CMake build prefers 4.1 and falls back to 4.0 (`build/cmake/init.cmake:571-577`) |
| `libsecret` not required at runtime (#25355) | `wxSecretStore` loads it on demand: always check `IsOk()` (`interface/wx/secretstore.h:197-201`), as `OrcaCloudServiceAgent` does |
| Multi-line `wxTextCtrl` max length (#24751); `wxRB_SINGLE` (#23652); `GTKSetPangoMarkup()` (#24912) | `controls-dataview.md` |
| `wxDC::DrawRoundedRectangle()` radius limited to half the smaller side (#24327) | the clamp is in the GTK2 GDK DC (`src/gtk/dcclient.cpp:874-875`, built only in the GTK2 opt-out, `GTK2_LOWLEVEL_SRC` in `build/files`) and also in the common `wxGraphicsPathData::AddRoundedRectangle` (`src/common/graphcmn.cpp:439-440`, absent in 3.1.5) that `wxGraphicsContext::DrawRoundedRectangle` uses, so every `wxGCDC` clamps: GTK3 (Cairo) and macOS window DCs, and Orca's memory-DC + `wxGCDC` paint paths on all ports [source]. A radius larger than half the smaller side is now clamped instead of drawing overlapping arcs |
| Read-only `wxBitmapComboBox` height fixed (#25468) | `Slic3r::GUI::BitmapComboBox` on GTK |
**wxMSW** (:549-583)
| Change | Orca relevance |
|---|---|
| "Enable double buffering for all windows" (#22851) | **reverted in 3.3.2** (#25808) → pitfall below |
| `wxOverlay` reimplemented with layered windows (#23261) | none: no `wxOverlay` |
| `wxBG_STYLE_TRANSPARENT` implemented (#23412) | as `WS_EX_TRANSPARENT` on non-TLW children [source] (`src/msw/window.cpp:1581-1582`); set before `Create()` (`painting-custom-widgets.md`) |
| `wxCAPTION` turned on when min/max/close boxes are set (#23575) | `WS_CAPTION` is added to the style (`src/msw/toplevel.cpp:132-135`); origin of the MainFrame workaround (§8) |
| `wxButton` default size larger in high DPI (#25297) | raw `wxButton` layouts may grow; Orca dialogs use `Button`/`DialogButtons` |
| Markup in `wxStaticText` (#25000); `wxHyperlinkCtrl` colour changeable (#23549) | `SetLabelMarkup` now works on MSW (single line) |
| Extended-length paths (#25033); `wxFileDialog` no unwanted extension (#24949); UTF-8 build fixes (#23313); non-BMP strings (#25128) | available |
| `wxDisplay` invalidated on display change (#25396); TLW with one child resized on DPI change (#22983); Aero-snapped geometry saved | multi-monitor geometry |
| Modern default `wxTreeCtrl` look (#23844); RTL fixes for `wxOverlay`/`wxScrolled` (#25413) and GDI+ (#25431); `wxBitmap::UseAlpha()` returns `bool` (#23919) | available |
**wxOSX** (:585-592) — `wxEventLoop::OnExit()` is always called (#25409); TLW cursor setting fixed
(#25131); Cmd-C no longer activates a "Close" button (#25346); `wxCursor` loadable from resources
(#24374); `wxUIActionSimulator` works (#23692), usable for GUI tests.
**Pitfalls**
- **Rule:** Do not replace Orca's MSW dark mode with wx's (`SetAppearance`/`MSWEnableDarkMode`)
unless live theme switching is kept.
**Why:** wx's MSW dark mode cannot change once any top-level window exists or a mode was chosen:
`SetAppearance` returns `CannotChange` (`src/msw/darkmode.cpp:263-270`;
`interface/wx/app.h:1166-1173`). TaskDialog-based dialogs (`wxMessageDialog`, `wxProgressDialog`,
simple `wxAboutBox`), the common dialogs (colour, find/replace, font, page setup, print) and the
date/time/calendar controls stay light (`interface/wx/app.h:1434-1448`). Orca switches themes at
runtime, which is why 8248b06337 kept NppDarkMode and only informs wx with
`MSWEnableDarkMode(DarkMode_Auto)` (§8).
Cite: 8248b06337 (PR text); `colours-dark-mode.md`.
- **Rule:** On MSW, a custom-painted control buffers its own drawing (`wxAutoBufferedPaintDC`/
`wxBufferedPaintDC` with `wxBG_STYLE_PAINT`, or the Orca memory-DC + `wxGCDC` path), or calls
`SetDoubleBuffered(true)` after creation.
**Why:** 3.3.0's global `WS_EX_COMPOSITED` was reverted in 3.3.2 (`changes.txt:308`). In 3.3.2
nothing sets it except an explicit `SetDoubleBuffered(true)` [source] (`src/msw/window.cpp:4704-4721`;
the only other use is `MSWDisableComposited`, :1643-1652), so MSW windows are not double-buffered by
default, exactly as in 3.1.5. `wxAutoBufferedPaintDC` is a `wxBufferedPaintDC` on MSW at compile
time (`include/wx/dcbuffer.h:18-23, 215-221`). Nothing tuned against 3.3.0/3.3.1 applies.
Cite: `docs/changes.txt:308, 557`; `painting-custom-widgets.md`.
- **Rule:** A `wxEVT_DPI_CHANGED` handler must be correct on GTK3, and every handler you bind — on a
child, a control or a `DPIDialog`/`DPIFrame` — calls `Skip()`.
**Why:** wxGTK3 now emits the event from `wxTopLevelWindowGTK::GTKConfigureEvent` when the integer
content scale changes, with DPI = 96 × scale [source] (`src/gtk/toplevel.cpp:336-351`;
`src/common/wincmn.cpp:2828-2830`). The event reaches each top-level window and its children
recursively, and the docs say handlers "should almost always call `event.Skip()`"
(`interface/wx/event.h:3564-3582`). Orca's `DPIAware` binds it on every non-macOS port and does
not `Skip()`; it sets `m_scale_factor` from that DPI although GTK3 pixels are already DIPs (the
ctor starts at 1 because `get_dpi_for_window` returns 96 on Linux). Double scaling when a window moves between monitors of
different scale is a risk that has not been verified at runtime; test DPI changes on GTK when
touching `DPIAware::rescale` paths.
Cite: `docs/changes.txt:541`; `dpi-bitmaps-fonts.md` §wxEVT_DPI_CHANGED.
## 6 3.3.1 notable fixes
`changes.txt:333-377`.
| Port | Change | Orca relevance |
|---|---|---|
| All | Persistence for `wxCheckBox` (#25515) and `wxRadioButton` groups (#25530); `wxAuiPaneInfo::FloatingClientSize()` (#25483); PNG "Description" chunk (#25556) | available |
| All | Settable app id (#25548): `wxAppConsole::SetClassName()` — the Windows AppUserModelID and the Wayland `app_id` (wxGTK ≥ 3.24.22), unused elsewhere; call it before any TLW, typically in the app ctor; on Windows it also changes shell behaviour (shift-middle-click new instance, shell MRU) (`interface/wx/app.h:760-812`) | Orca calls only `SetAppName`; setting a class name would change Windows taskbar grouping and jump lists |
| All | `wxDataViewCtrl::Collapse()` safe from event handlers (#25631); no `wxEVT_GRID_SELECT_CELL` at `wxGrid` creation (#25498); empty `wxGridSizer` no longer asserts (#25641); `wxPropertyGrid` compatibility (#25627); `wxNumberFormatter` (#25614, #25635); reproducible static Unix builds (#25502) | `ObjectList`/`ObjectGrid` event handlers; `Field` number formatting |
| wxGTK | Crash sorting a `wxDataViewCtrl` with a single leaf (#25625); `wxListCtrl` contents lost after `AppendColumn()` (#25519) | native GTK `wxDataViewCtrl` |
| wxMSW | Dark-mode fixes: disabled `wxButton` bitmaps (#25575), disabled `wxStaticText` (#25574), `wxComboCtrl` (#23766), `wxTE_RICH` `wxTextCtrl` (#25602), selected toolbar buttons (#25616), `wxStaticBitmap` in `wxNotebook` crash (#25499), notebook background in high-contrast (#25542) | apply only where wx's own dark mode is active (`wxMSWDarkMode::IsActive()`; with Orca's `DarkMode_Auto`, whenever `ShouldAppsUseDarkMode()` reports dark, `src/msw/darkmode.cpp:205-227, 414-417`) |
| wxMSW | `wxDataViewCtrl` border in light mode (#25532); `wxTreeCtrl::EnsureVisible()` while frozen (#18435); preferred-languages buffer overrun (#25612); `wxAcceleratorTable` with 0 entries (#25517); date/time pickers on non-English Windows (#25511); per-window menu MDI crash (#25522) | available |
| wxOSX | Border look of `wxDataViewCtrl`, `wxListBox`, `wxTextCtrl` (#25570); startup crash with Farsi system language (#25561) | native macOS controls |
## 7 3.3.2 notable changes
`changes.txt:255-318`, relative to 3.2.10 (see §1 for the unlisted 3.2.9/3.2.10 fixes).
| Port | Change | Orca relevance |
|---|---|---|
| All | `wxWebRequestDebugLogger` (#26086); configurable `wxWebRequest` timeouts (#25673); number/currency formatting (#25765); 3rd-party libraries updated (#26010); `wxSOCKET_NOWAIT_READ\|wxSOCKET_WAITALL_WRITE` (#17114) | image downloads; `Field` formatting |
| All | `wxDC::DrawLabel()` bitmap position fixed in high DPI (#25888) | available |
| GUI | `wxGLContext::ClearCurrent()` (#25958) and `wxGLContext::GetProcAddress()` (#9215) — static members of `wxGLContext`, not `wxGLCanvas` as the change log says; `GetProcAddress` "is currently not implemented under macOS and always returns NULL" (`interface/wx/glcanvas.h:550-599`) | Orca loads GL with GLAD (through `eglGetProcAddress` on Wayland, `OpenGLManager::init_gl`) |
| GUI | `wxGLCanvas::SetSwapInterval()` (#25449) returning `SwapInterval::{NotSet, Set, NonAdaptive}`, `GetSwapInterval()`, `DefaultSwapInterval` (`interface/wx/glcanvas.h:866-893, 1036-1154`) | pitfall below |
| GUI | Automatic `wxStaticText` wrapping (#25753) | pitfall below |
| GUI | `wxDisplay::GetRawPPI()` (#26082); configurable `wxScrolled<>` autoscroll (#25978, `EnableAutoScrollInside`/`DisableAutoScrollOutside`); `wxScrolled::GetViewStartPixels()`; `wxWindow::GetMinSizeFromKnownDirection()` | `sizers-layout.md` |
| GUI | `wxPersistentDVC` restores column positions (#26222); safer `wxTipWindow` close detection (#26070); wxDC-derived objects movable (#25726) | available |
| GUI | AUI: pane minimising (#23986), crash on hover after closing a notebook tab (#25959), notebook splitting (#26081) | Plater docking |
| GUI | LunaSVG option (#25902); `wxSVGFileDC` improvements (#25723); generic `wxCalendarCtrl` DPI-aware (#25713); `wxTextEntryDialog::SetHint()` (#26176); `wxStyledTextCtrlMiniMap` (#25887) | LunaSVG and STC are off in Orca's build |
| GUI | Many RTL layout fixes in wxMSW and wxGTK (#25426) | RTL languages |
| wxGTK | GLX and EGL in the same program (#26023): wx defaults to EGL even on X11 unless `PreferGLX()` or `wx_opengl_egl=0`; Wayland is always EGL [source] (`src/unix/glcanvas.cpp:218-245`; `interface/wx/glcanvas.h:1082-1102`) | pitfall below |
| wxGTK | `WarpPointer()` on Wayland compositors with the pointer-warp protocol (#23778); mutter moves the pointer only while a button is pressed (`interface/wx/window.h:3895-3914`) | Orca does not call `WarpPointer` |
| wxGTK | Gesture handling fixes (#26241); `wxDIRP_DIR_MUST_EXIST` | `GLCanvas3D::bind_event_handlers` binds `wxEVT_GESTURE_PAN/ZOOM/ROTATE` |
| wxMSW | `WS_EX_COMPOSITED` use from earlier 3.3 reverted (#25808) | §5 pitfall |
| wxMSW | Dark-mode rendering of several controls (#25835), toolbar (#25892), menus (#26182); accessibility: `wxCheckBox` in dark mode (#26184), full `wxCheckListBox` (#25948) and `wxStyledTextCtrl` (#25956), basic `wxRichTextCtrl` (#26202) | wx dark mode only |
| wxMSW | `wxNO_WIN32_W` (#25965); MSVS 2026 project files (#26131); `wxString` debug visualiser (#25684) | none |
| wxOSX | Visual fixes for macOS 26 Tahoe (#25766, #25743, #25767); dark-mode grid lines (#25783); nested markup attributes (#25864); suspend/resume events (#25778); threaded `wxGA_SMOOTH` animation (#25906); more joystick axes (#26216) | native look on Tahoe |
| wxOSX | Click events consistent with wxMSW (#25886) | pitfall below |
**Pitfalls**
- **Rule:** After `SetLabel` on a `wxStaticText`, re-wrap with `Wrap(-1); Wrap(w);`, or use Orca's
`Label` (`LB_AUTO_WRAP`, `Label::Wrap`); use `wxST_WRAP` only inside a sizer that constrains the
width.
**Why:** "Automatic wrapping" is the opt-in `wxST_WRAP` style, which "only works when the control is
used inside a sizer" (`interface/wx/stattext.h:46-49`); labels without it lay out as before, and
Orca uses no `wxST_WRAP`. What does change existing code is the rewritten `Wrap()`: it returns
immediately when `width == m_currentWrap` (`src/common/stattextcmn.cpp:259-263`), and `SetLabel`
clears the saved unwrapped text but not `m_currentWrap` (`UpdateLabelOrig`, :354-365) [source].
So `SetLabel(new); Wrap(sameWidth);` — correct under 3.1.5, whose `Wrap()` always re-wrapped —
leaves the new label unwrapped. `wxST_WRAP` wraps at the width the sizer offers via
`GetMinSizeFromKnownDirection` (:285-316), but the first `CalcMin` still uses the unwrapped best
size, so a fitted dialog grows to the full line [source]; constrain the width another way (a fixed
or max width on the container). `Label::Wrap` re-wraps from its stored text and has no cache.
```cpp
// Wrong: m_static_valid->SetLabel(info_line); m_static_valid->Wrap(FromDIP(300));
// Right: m_static_valid->SetLabel(info_line); m_static_valid->Wrap(-1); m_static_valid->Wrap(FromDIP(300));
```
Cite: `docs/changes.txt:280`; `sizers-layout.md` §wxStaticText wrapping.
- **Rule:** On Linux/X11, call `wxGLCanvas::PreferGLX()` before any GL use, attribute objects
included; set a swap interval explicitly if the canvas needs VSync.
**Why:** `PreferGLX()` called late "will trigger an assert failure and have no other effect" (silent
in Orca) and has no effect on Wayland (`interface/wx/glcanvas.h:1082-1102`). Orca calls it when
`is_running_on_x11()` early in `GUI_App::on_init_inner`. On Unix wx sets the swap interval to **0**
(VSync off) at the first `SwapBuffers` unless `SetSwapInterval` was called, so that
`eglSwapBuffers`/`glXSwapBuffers` never block on an occluded window [source]
(`include/wx/unix/private/glcanvas.h:96`; `src/unix/glegl.cpp:897-915`; `src/unix/glx11.cpp:940-950`).
`SetSwapInterval(DefaultSwapInterval)` keeps the driver's default. macOS and MSW leave the default
unless asked. `OpenGLManager` sets no swap interval, so on Linux the 3D view's buffer swaps are
not synchronised to VSync.
```cpp
// Right (if frame pacing is wanted): canvas->SetSwapInterval(1); // before the first SwapBuffers
```
Cite: `docs/changes.txt:275-276, 292`; `webview-gl-aui-media.md` §EGL vs GLX.
- **Rule:** Do not count clicks from `wxEVT_LEFT_DCLICK` alone on macOS; handle `DOWN` and `DCLICK`
as on MSW.
**Why:** `wxWidgetCocoaImpl::DoHandleMouseEvent` turns every second `DCLICK` back into a `DOWN`
(left and right buttons), so a triple click gives `DOWN, DCLICK, DOWN` as on MSW; previously every
click with `clickCount > 1` was a `DCLICK` [source] (`src/osx/cocoa/window.mm:4103-4150`). Per-platform click-count ifdefs
written for 3.1.5 need rechecking.
Cite: `docs/changes.txt:316`; `mouse-keyboard-focus.md`.
## 8 Migration already done in Orca
The upgrade landed as 8248b06337 ("Updated wxWidgets to 3.3.2", #12941; build system 2d7e26292b).
Its PR kept Orca's own MSW dark mode to avoid broader changes and because wx's needs an app restart to
switch. Each row is a rule for new code; the owning file has the detail.
| Commit | Rule for new code | Where | Owner |
|---|---|---|---|
| 8248b06337 | No version-conditional code for wx < 3.3: the upgrade deleted the pre-3.1.3 DPI-event shim (`DpiChangedEvent`, `EVT_DPI_CHANGED_SLICER`), the luma fallback in `check_dark_mode`, the `wxCHECK_VERSION` guards in `I18N.hpp`/`GUI_App.hpp`, and the macOS 10.9.5 `wxGLContext` hack | `GUI_Utils.hpp` `DPIAware`, `OpenGLManager` | this file |
| 8248b06337 | `MSWEnableDarkMode(DarkMode_Auto)` runs before `NppDarkMode::InitDarkMode()`, so NppDarkMode's `SetPreferredAppMode(ForceDark` or `ForceLight`, per Orca's setting) overrides wx's `AllowDark` at OS level in both directions; `GUI_App::dark_mode()` honours `dark_color_mode` before `check_dark_mode()` | `GUI_App::on_init_inner`, `GUI_App::dark_mode` | `colours-dark-mode.md` |
| 8248b06337 | `wxToolTip::GetToolTipCtrl()` is private in 3.3 (`include/wx/msw/tooltip.h:92`); wx applies dark mode to the tooltip window itself through `wxMSWDarkMode::AllowForWindow`, which follows wx's mode [source] (`src/msw/tooltip.cpp:321`; `src/msw/darkmode.cpp:454-457`) | `GUI_App::force_colors_update` (the `#if wxVERSION_NUMBER < 3300` block is dead under 3.3) | `colours-dark-mode.md` |
| 8248b06337 | Backend webview factories override `GetVersionInfo(wxVersionContext)` without a default argument (`include/wx/msw/webview_edge.h:150`); pass `wxVersionContext::RunTime` when calling through the concrete factory | `WebView::CheckWebViewRuntime` | `webview-gl-aui-media.md` |
| 8248b06337 | The GL canvas gets `wxBG_STYLE_PAINT`; on MSW `GLCanvas3D::on_paint` renders immediately because idle events are not dispatched inside the modal resize loop (c06a0223a7) | `OpenGLManager::create_wxglcanvas`, `GLCanvas3D::on_paint` | `webview-gl-aui-media.md` |
| 8248b06337 | On MSW, do not `SetFocus()` the GL canvas while `wxCurrentPopupWindow` is set: the focus change makes wx call `MSWDismissUnfocusedPopup` and closes the search dropdown. `wxCurrentPopupWindow` is a wx-internal global (`src/msw/popupwin.cpp`) that Orca declares `extern` itself, usable only because wx is linked statically | `GLCanvas3D::on_mouse` (`evt.Entering()` branch) | `popups-menus.md` |
| 6148ba16b3 (in 8248b06337), 988b500f33 | On GTK a `wxBitmapToggleButton`/`wxButton`-based widget sized to exactly its bitmap leaves no room for the theme's CSS padding (GTK "negative content width" criticals). Either strip the native button CSS with `Slic3r::GUI::RemoveButtonBorder` and size to the bitmap (`CheckBox`), or size to `GetBestSize()` grown to the bitmap (`IncTo`; `RadioBox` and `SwitchButton` do both) | `RemoveButtonBorder` (`GUI_Utils.cpp`), `CheckBox::Rescale`, `RadioBox::Rescale`, `SwitchButton::Rescale` | `platforms.md` §GTK native chrome |
| 8248b06337 | The macOS-only vertical text nudges in `Button::render` and `SideButton::dorender` were removed; do not re-add per-OS baseline offsets | `Button::render`, `SideButton::dorender` | `painting-custom-widgets.md` |
| 8248b06337 | `wxEXPAND` combined with `wxALIGN_*` in a box sizer was cleaned up; the combination is ignored | `Sidebar::priv::layout_printer`, `AMSControl::createAmsPanel` | `sizers-layout.md` |
| 8248b06337 | `GUI_App::on_init_inner` filters known-harmless GTK criticals (allocation on hidden widgets, events on unrealised widgets, style-context calls from `SetBackgroundColour` before realisation); check that filter before chasing such a message | `GUI_App::on_init_inner` | `platforms.md` |
| 5f365b5c6b | `wxBitmapComboBox` overrides take `wxBitmapBundle` (`OnAddBitmap(const wxBitmapBundle&)`, `include/wx/bmpcbox.h:89`); `m_bitmaps` became `m_bitmapbundles`; get a bitmap with `GetBitmap(GetDefaultSize())` | `BitmapComboBox::OnAddBitmap`, `BitmapComboBox::OnDrawItem` (both macOS-only overrides) | `dpi-bitmaps-fonts.md` |
| ed88cbe3f5 → d62aa42e61 | 3.3's `wxWebViewWebKit` has neither the default ctor nor the creating `(parent, id, url, …)` ctor of 3.1.5; its only ctor is `explicit wxWebViewWebKit(const wxWebViewConfiguration&, WX_NSObject request = nullptr)` (`include/wx/osx/webview_webkit.h:36`). ed88cbe3f5 switched macOS to `wxWebView::New()`, which bypassed the subclass destructor that calls `RemoveScriptMessageHandler("wx")`; d62aa42e61 restored `WebViewWebKit` via `wxWebView::NewConfiguration(wxWebViewBackendWebKit)`. Only Linux uses `wxWebView::New()` | `WebView::CreateWebView`, `WebViewWebKit` | `webview-gl-aui-media.md` |
| 1765d296a8, 2b3328c2b2 | `wxArrayString` via `Add()`; `wxString` first in mixed concatenation; `compatibility_iterator` for wx lists (§4) | `Plater::import_model_id`, `OptionsSearcher::search`, `SendMultiMachinePage` | this file |
| 7ea69199fd | `dynamic_cast`, not `wxDynamicCast`, on `wxComboPopup` (§4) | `combochecklist_get_flags`/`_set_flags` (`GUI.cpp`) | this file |
| ba867cc534 | `Show()` (only if `!IsShown()`) before `Raise()` (§3) | `Plater::priv::priv` handlers, `Plater::priv::bring_instance_forward` | `windows-dialogs.md` |
| 026b105dcb | No `wxTRANSPARENT_WINDOW` (§3); it is a no-op `0`, so this was cleanup, not a build fix | `MainFrame` (`ResizeEdgePanel`, side-tool panels) | this file |
| eefdabcd98, f70d30bf79 (#13074) | The auto-added `WS_CAPTION` (3.3.0, #23575; eefdabcd98's message says 3.3.2) made `DefWindowProc` subtract a caption from the maximised client area, and on Windows 10 left the native frame visible behind the custom title bar ("double window"). The `MainFrame` ctor strips `WS_CAPTION` from `GWL_STYLE` right after creation (`SetWindowPos(… SWP_FRAMECHANGED)`); `WM_NCCALCSIZE` computes border thickness with `GetWindowLongPtr(hWnd, GWL_STYLE) & ~WS_CAPTION` and strips the border overshoot itself when maximised; f70d30bf79 restored the `wxEVT_MAXIMIZE` handler that clamps the maximised frame to the display client area | `MainFrame::MainFrame`, `MainFrame::MSWWindowProc` (`WM_NCCALCSIZE`), `AdjustWorkingAreaForAutoHide` | `platforms.md` §MSW title bar |
| 46e47cec0a | `wxGrid::GetSelectedBlocks()` (unordered, possibly overlapping, `interface/wx/grid.h:5091-5109`) is an empty range after the user deselects; compare `begin()` with `end()` and fall back to the activating cell | `GridCellSupportEditor::DoActivate` (`GUI_ObjectTable.cpp`) | `controls-dataview.md` |
| 9a053f15eb (#12936) | Since the upgrade, on macOS the Slice/Print split-button's transient popup was dismissed the moment the cursor entered the gap between button and menu; on macOS anchor transient popups flush with (slightly overlapping, 2 px) their button | `SidePopup::Popup` (`__APPLE__` branch) | `popups-menus.md` |
| 1f2ed70288 (#13119) | Orca registers its own `kAEGetURL` handler so `orcaslicer://` links reach `MacOpenURL` | `register_mac_deep_link_handler` (`DeepLinkHandlerMac.mm`), called from `GUI_App::on_init_inner` | this file |
These post-upgrade regressions were fixed: 46e47cec0a (ObjectTable crash on cell deselect),
1f2ed70288 (macOS deep links), d62aa42e61 (WebView script-handler cleanup), eefdabcd98 (maximised
window not filling the desktop), f70d30bf79 (Windows 10 double window frame), c06a0223a7 (blank 3D
canvas during MSW resize). Watch for them to
recur when touching the same code.
**Pitfalls**
- **Rule:** Keep Orca's own `kAEGetURL` registration in `GUI_App::on_init_inner`; do not rely on
wx's handler for `orcaslicer://` links.
**Why:** wx installs its handler in `applicationWillFinishLaunching:`
(`src/osx/cocoa/utils.mm:46-57`); it forwards to `MacOpenURL` only once `OSXInitWasCalled()` is
true and otherwise stores the URL with `OSXStoreOpenURL` (:191-200) [source]. After the upgrade
the wx handler stopped delivering deep links on macOS (#13119, reported on macOS 26): links from
Printables/Thingiverse opened a blank project. `NSAppleEventManager` keeps the last registration, so Orca's later registration wins
and routes straight to `MacOpenURL` → `start_download`. Removing it, or registering before wx does,
brings the regression back.
```cpp
// Right (GUI_App::on_init_inner, __APPLE__): register_mac_deep_link_handler();
```
Cite: 1f2ed70288; `DeepLinkHandlerMac.mm`.
- **Rule:** Do not reintroduce `wxTRANSPARENT_WINDOW` or a post-creation
`SetBackgroundStyle(wxBG_STYLE_TRANSPARENT)` to make a panel blend in; set its background colour,
dark-mode mapped, and re-apply it on theme change.
**Why:** see §3. The side-tool panels lost MSW transparency with the upgrade, and 8248b06337
(b9952b39ad) fixed them by giving them `StateColor::darkModeColorFor(wxColour("#3B4446"))`,
re-applied in `MainFrame::update_side_button_style`.
Cite: 026b105dcb, 8248b06337 (b9952b39ad).
+25 -12
View File
@@ -56,7 +56,9 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Pushes don't cancel a running build, because only a finished branch build
# saves caches every pull request can restore.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
@@ -183,9 +185,11 @@ jobs:
os: ${{ vars.SELF_HOSTED && 'orca-macos-arm64' || 'macos-14' }}
artifact: ${{ github.sha }}-tests-macos-arm64
test-dir: build/arm64/tests
# Slice a two-colour cube through every shipped printer so all custom g-code
# (change_filament_gcode, machine start/end, etc.) is expanded - catches
# slicing regressions the static profile checks and unit tests can't see.
# Slice a two-colour cube through every shipped printer, and through every
# system process/filament whose 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 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
# covers src/engine PRs with the PR-built binary.
slice_check_linux:
@@ -330,12 +334,13 @@ jobs:
# sources are unchanged, so a re-run of the same commit would skip the
# OrcaSlicer module and ship no test asset. A per-run value in that module's
# env keeps it rebuilding; orca_deps stays cached, and the compiler cache
# still serves the rebuild.
- name: Inject commit hash and flatpak-builder cache buster into Flatpak manifest
# still serves the rebuild. run-tests on the same module builds the test
# binaries.
- name: Inject commit hash, run-tests and cache buster into Flatpak manifest
env:
flatpak_builder_cache_buster: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n flatpak_builder_cache_buster: \"$flatpak_builder_cache_buster\"\n git_commit_hash: \"$git_commit_hash\"|}" \
sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n run-tests: true\n build-options:\n env:\n flatpak_builder_cache_buster: \"$flatpak_builder_cache_buster\"\n git_commit_hash: \"$git_commit_hash\"|}" \
scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
shell: bash
# flatpak-builder's --ccache only wraps cc and gcc, and the manifest builds
@@ -381,9 +386,6 @@ jobs:
save-cache: false
arch: ${{ matrix.variant.arch }}
upload-artifact: false
# run-tests fires the module's build-only test-commands; keep-build-dirs
# retains the binaries for the packaging step below.
run-tests: true
keep-build-dirs: true
# The build has just touched everything it can use, so an object untouched
# for a week is dead, usually orphaned by a flag change.
@@ -415,6 +417,14 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/caches"
# The save step reports success even when its tar failed, so keep
# the older entries unless the new one is listed.
if ! curl -sSf -H "Authorization: Bearer $GH_TOKEN" \
"$api?ref=$GITHUB_REF&key=$CCACHE_ENTRY" \
| jq -e --arg entry "$CCACHE_ENTRY" 'any(.actions_caches[]; .key == $entry)' > /dev/null; then
echo "$CCACHE_ENTRY was not saved; keeping the older entries."
exit 0
fi
curl -sSf -H "Authorization: Bearer $GH_TOKEN" \
"$api?ref=$GITHUB_REF&key=ccache-$CCACHE_LEG-&per_page=100" \
| jq -r --arg prefix "ccache-$CCACHE_LEG-" --argjson run "$GITHUB_RUN_ID" \
@@ -449,10 +459,13 @@ jobs:
# the bounds checks are compiled in, so a stripped exe still catches them.
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
# resources/ the shipped profiles (PROFILES_DIR) and the printers/ maps.
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, and
# the icon SVGs (NativeCommands icon names are checked against them).
find "$d" -mindepth 1 -maxdepth 1 -type d \
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers -exec rm -rf {} +
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers ! -name images -exec rm -rf {} +
# Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight.
find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true
tar -cf flatpak-test-asset.tar flatpak_app "$d"
- name: Upload flatpak test asset
uses: actions/upload-artifact@v7
+48 -1
View File
@@ -448,9 +448,26 @@ jobs:
- name: Install nsis
if: runner.os == 'Windows' && !vars.SELF_HOSTED
shell: pwsh
# The Chocolatey community feed intermittently 504s, and `choco install`
# exits 0 when package resolution fails that way. Unchecked, the job then
# builds for ~25 minutes before `cpack -G NSIS` reports a missing makensis.
# Retry the install and verify makensis itself, so a real failure stops here.
run: |
dir "C:/Program Files (x86)/Windows Kits/10/Include"
choco install nsis
$nsisDir = Join-Path ${env:ProgramFiles(x86)} 'NSIS'
$makensis = Join-Path $nsisDir 'makensis.exe'
for ($attempt = 1; $attempt -le 3 -and -not (Test-Path $makensis); $attempt++) {
if ($attempt -gt 1) { Start-Sleep -Seconds (15 * $attempt) }
Write-Host "::group::choco install nsis (attempt $attempt)"
choco install nsis --yes --no-progress
Write-Host "::endgroup::"
}
if (-not (Test-Path $makensis)) {
throw "NSIS install failed: $makensis not found after 3 attempts."
}
& $makensis /VERSION
$nsisDir | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8
- name: Build slicer Win
if: runner.os == 'Windows'
@@ -685,6 +702,18 @@ jobs:
name: OrcaSlicer_profile_validator_Linux_ubuntu_${{ env.ubuntu-ver }}_${{ env.ver }}
path: './build/src/Release/OrcaSlicer_profile_validator'
# generate_system_cache is what scripts/build_preset_cache.sh bakes the
# <vendor>.opc caches with; it was already built by the "Build system
# preset cache (Linux)" step above. The .opc format is 64-bit
# little-endian native, i.e. identical across every platform Orca ships,
# so only the Linux binary is published.
- name: Upload generate_system_cache Ubuntu
if: ${{ ! env.ACT && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
uses: actions/upload-artifact@v7
with:
name: generate_system_cache_Linux_ubuntu_${{ env.ubuntu-ver }}_${{ env.ver }}
path: './build/src/dev-utils/Release/generate_system_cache'
- name: Deploy Ubuntu release
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && env.deploy_nightly == 'true' && runner.os == 'Linux' && !vars.SELF_HOSTED }}
uses: WebFreak001/deploy-nightly@v3.2.0
@@ -715,6 +744,17 @@ jobs:
asset_content_type: application/octet-stream
max_releases: 1
- name: Deploy Ubuntu generate_system_cache release
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label}
release_id: 137995723
asset_path: ./build/src/dev-utils/Release/generate_system_cache
asset_name: generate_system_cache_Linux${{ env.ubuntu-ver-str }}_nightly
asset_content_type: application/octet-stream
max_releases: 1
- name: Deploy orca_custom_preset_tests
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED && inputs.arch != 'aarch64' }}
uses: WebFreak001/deploy-nightly@v3.2.0
@@ -758,6 +798,13 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# The save step reports success even when its tar failed, so keep
# the older entries unless the new one is listed.
if ! gh cache list --ref "$GITHUB_REF" --key "$CCACHE_ENTRY" --json key \
| jq -e --arg entry "$CCACHE_ENTRY" 'any(.[]; .key == $entry)' > /dev/null; then
echo "$CCACHE_ENTRY was not saved; keeping the older entries."
exit 0
fi
gh cache list --ref "$GITHUB_REF" --key "ccache-$CCACHE_LEG-" --limit 100 --json id,key \
| jq -r --arg prefix "ccache-$CCACHE_LEG-" --argjson run "$GITHUB_RUN_ID" \
'.[] | select((.key | ltrimstr($prefix) | split("-")[0] | tonumber?) < $run) | .id' \
+9 -4
View File
@@ -14,6 +14,9 @@ on:
# this workflow.
- 'resources/printers/**'
- '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"
workflow_dispatch:
@@ -71,8 +74,10 @@ jobs:
set +e
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log
exit ${PIPESTATUS[0]}
# Slice a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode)
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks above cannot see.
# Slice a two-colour cube through every printer, and through every system process/filament whose
# 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)
id: validate_slice
continue-on-error: true
@@ -80,8 +85,8 @@ jobs:
set +e
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -s -l 2 2>&1 | tee ${{ runner.temp }}/validate_slice.log
exit ${PIPESTATUS[0]}
# All vendors' filament_id collisions were fixed (see scripts/filament_id_snapshot.json),
# so the duplicate-filament-subtype check runs tree-wide.
# All vendors' filament_id collisions were fixed, so the duplicate-filament-subtype
# check runs tree-wide.
- name: validate filament subtype check
id: validate_filament_subtypes
continue-on-error: true
+199
View File
@@ -0,0 +1,199 @@
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
# 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
# changes to resources/profiles/OrcaFilamentLibrary since that branch's own last successful run. Any
# branch with no changes is skipped; each changed branch gets its own post_merge_profiles.yml dispatch.
#
# OFL has no dedicated FOLDER_MERGERS grant (it isn't merged through the PR merge-bot delegation
# scheme), so post_merge_profiles.yml is dispatched with an explicit `vendor` input, which that
# workflow trusts and uses to bypass the FOLDER_MERGERS check for this trigger. That same explicit-
# 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.
#
# Each run captures a timestamp, dispatches the needed OFL publishers, waits for
# all of them to finish, then clears the pending-publish table once. Changes merged
# after that timestamp remain pending for the next run.
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
actions: write # list this workflow's past runs and dispatch post_merge_profiles.yml
contents: read
env:
VENDOR: OrcaFilamentLibrary
jobs:
daily-job:
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }}
runs-on: ubuntu-24.04
steps:
- name: Capture start timestamp
id: start
shell: bash
run: |
set -euo pipefail
timestamp="$(date -u +%s)"
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v7
with:
# Full history: the per-branch "since last successful run" check below
# needs to look arbitrarily far back if a prior run failed or was skipped.
fetch-depth: 0
- name: Fetch all branches
shell: bash
run: git fetch origin '+refs/heads/*:refs/remotes/origin/*'
- name: Scan branches and publish changed OFL profiles
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SCAN_UNTIL: ${{ steps.start.outputs.timestamp }}
run: |
set -euo pipefail
mapfile -t branches < <(
gh api "repos/${{ github.repository }}/branches" --paginate --jq '.[].name' \
| 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
echo "::group::$branch"
if [ -z "$since" ]; then
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" -- \
resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \
| sed '/^$/d')"
else
changed_files="$(git log --since="$since" --until="$SCAN_UNTIL" --name-only --pretty=format: "origin/$branch" -- \
resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \
| sed '/^$/d')"
fi
if [ -n "$changed_files" ]; then
echo "OFL changed on $branch from ${since:-the beginning} through $SCAN_UNTIL:"
echo "$changed_files"
changed=true
else
echo "No OFL changes on $branch through $SCAN_UNTIL."
changed=false
fi
if [ "$changed" = true ]; then
dispatch_id="${GITHUB_RUN_ID}-${branch//\//-}"
# Record successful dispatches for the barrier step below.
# Branches whose workflow predates workflow_dispatch are skipped
# with a warning, as they were before the barrier was added.
if gh workflow run post_merge_profiles.yml \
--repo "${{ github.repository }}" \
--ref "$branch" \
-f vendor="$VENDOR" -f auto_publish=true \
-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
echo "::endgroup::"
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
+52 -8
View File
@@ -5,8 +5,9 @@
# re-sliced on its own to see whether it changes the G-code
# harness - the GUI-vs-CLI parity harness (metrics only, never fails)
# Both test the latest successful build_all.yml Linux AppImage from main, with
# sources checked out at the commit that build was made from. Nothing here
# gates a build or a PR.
# sources checked out at the commit that build was made from; a manual run can
# name another branch, or pin one build by its run id. Nothing here gates a
# build or a PR.
name: Parity Nightly
on:
@@ -20,9 +21,13 @@ on:
required: false
default: "main"
build_branch:
description: "branch whose latest successful build_all artifact to test"
description: "branch whose newest successful build_all artifact to test (a PR build is the PR merged into its base; sources are checked out at the PR head)"
required: false
default: "main"
build_run_id:
description: "build_all run id to test instead of build_branch's newest (same PR caveat)"
required: false
default: ""
fixtures:
description: "harness fixture ids, space-separated (empty = all)"
required: false
@@ -50,14 +55,53 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
BRANCH: ${{ inputs.build_branch || 'main' }}
RUN_ID: ${{ inputs.build_run_id }}
SCHEDULED: ${{ github.event_name == 'schedule' }}
run: |
set -euo pipefail
gh run list --workflow build_all.yml \
--branch "${{ inputs.build_branch || 'main' }}" \
--status success --limit 1 --json databaseId,headSha \
--jq '"run_id=\(.[0].databaseId)\nhead_sha=\(.[0].headSha)"' \
>> "$GITHUB_OUTPUT"
if [ -n "$RUN_ID" ]; then
[[ $RUN_ID =~ ^[0-9]+$ ]] || { echo "build_run_id must be a numeric run id, got '$RUN_ID'" >&2; exit 1; }
# a pinned build is read directly, not through a search; it must come
# from this repository, because the later jobs check out its commit here
found=$(gh api "repos/$GH_REPO/actions/runs/$RUN_ID" --jq \
'select(.path == ".github/workflows/build_all.yml" and .conclusion == "success"
and .head_repository.full_name == env.GH_REPO)
| "\(.id) \(.head_sha) \(.created_at)"')
[ -n "$found" ] || { echo "run $RUN_ID is not a successful build_all run of $GH_REPO" >&2; exit 1; }
else
# GitHub serves filtered run listings (branch=, status=, head_sha=, ...)
# from a search index that has returned weeks-old results, while the
# unfiltered listing stays current, so list unfiltered and filter here.
# The repository check keeps out fork PRs whose branch has the same
# name. A feature branch is normally built only for its PR, and a PR
# build compiles the PR merged into its base rather than head_sha, so
# a build of the branch itself (push or dispatch) is preferred when
# the same page has one.
pick='([.workflow_runs[] | select(.head_branch == env.BRANCH and .conclusion == "success"
and .head_repository.full_name == env.GH_REPO)]
| map(select(.event != "pull_request"))[0] // .[0])
| select(.) | "\(.id) \(.head_sha) \(.created_at)"'
# a page of 100 runs spans about a day and a half; a manual run may
# target a branch that last built weeks ago
pages=3
if [ "$SCHEDULED" != true ]; then pages=20; fi
found=""
for page in $(seq "$pages"); do
found=$(gh api "repos/$GH_REPO/actions/workflows/build_all.yml/runs?per_page=100&page=$page" --jq "$pick")
if [ -n "$found" ]; then break; fi
done
[ -n "$found" ] || { echo "no successful $BRANCH build among the last $((pages * 100)) build_all runs; pass build_run_id to test an older one" >&2; exit 1; }
fi
read -r run_id head_sha created <<< "$found"
# the nightly fails rather than report on a stale build
if [ "$SCHEDULED" = true ] && [ $(( $(date +%s) - $(date -d "$created" +%s) )) -gt 172800 ]; then
echo "newest $BRANCH build $run_id is from $created, over 48 hours old" >&2
exit 1
fi
printf 'run_id=%s\nhead_sha=%s\n' "$run_id" "$head_sha" >> "$GITHUB_OUTPUT"
cat "$GITHUB_OUTPUT"
echo "Testing build [$run_id](https://github.com/$GH_REPO/actions/runs/$run_id) of \`$head_sha\`, built $created" >> "$GITHUB_STEP_SUMMARY"
effect:
name: Override sweep effect stage (shard ${{ matrix.shard }})
+464
View File
@@ -0,0 +1,464 @@
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
# 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
# a per-Orca-version release of the profiles repo. From there OrcaCloud's OTA
# Manager lists the asset, a maintainer attaches a changelog and hits Publish, and
# only then does it become a live OTA update - this workflow does none of that
# last part (no changelog, no R2, no webhook).
#
# A workflow_dispatch carrying a `vendor` input (e.g. the daily OFL cron - OFL has
# no FOLDER_MERGERS grant, since it isn't merged through the PR merge-bot delegation
# scheme) publishes that vendor directly and skips the FOLDER_MERGERS check below.
# workflow_dispatch is already a trusted, explicit trigger, unlike the automatic
# push-diff path the FOLDER_MERGERS check exists to gate.
#
# Separately, an ordinary push whose diff touches an OrcaFilamentLibrary company
# folder (resources/profiles/OrcaFilamentLibrary/filament/<Company>/**) records
# that PR as pending via POST /api/v1/ota/ofl/pending, regardless of whether
# OrcaFilamentLibrary as a whole is authorized to publish in this same run - a
# partner's OTA Manager dashboard should see a merged PR immediately, well
# before the daily cron actually builds and publishes it.
#
# Asset contract expected by OrcaCloud's release scanner:
# ^(\d+\.\d+\.\d+)_([^_]+)_(\d+(?:\.\d+){3})_(\d{12})\.zip$
# <orca_ver>_<vendor>_<profile_version>_<UTC yyyymmddHHMM>.zip (zip root: <vendor>.opc)
#
# Setup (App + secrets): docs/ota/post-merge-profiles-setup.md
on:
push:
branches:
# once v2.5.0 stable is released, this will be removed, so nightly won't receive OTA updates.
- main
# release/vX.Y.Z point-release branches only, not the release/vX.Y working
# branch profile PRs land on first - "v*.*.*" requires two literal dots,
# which release/vX.Y (one dot) doesn't have.
- release/v*.*.*
paths:
- 'resources/profiles/**'
- '.github/workflows/post_merge_profiles.yml'
workflow_dispatch:
inputs:
vendor:
description: >-
Publish only this vendor, bypassing the FOLDER_MERGERS grant check.
For trusted explicit dispatches only (e.g. the OFL nightly cron).
Leave empty to fall back to diffing the triggering commit.
required: false
type: string
auto_publish:
description: >-
After publishing, also call the OTA auto-publish API to go live
immediately, skipping the human changelog/Publish step. Separate
from `vendor` on purpose: a maintainer can dispatch with just
`vendor` set to rebuild/republish an asset without it going live.
Only the OFL nightly cron should set this to true.
required: false
type: boolean
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:
contents: read
pull-requests: read # commits/{sha}/pulls lookup in the OFL-pending step
# One run per branch; let a run finish rather than cancel it, since it publishes.
concurrency:
group: post-merge-profiles-${{ github.ref }}
cancel-in-progress: false
env:
# generate_system_cache is published to this repo's own nightly-builds release
# by build_orca.yml's Linux leg. The job guard pins github.repository to
# OrcaSlicer/OrcaSlicer, so this resolves there.
TOOL_REPO: ${{ github.repository }}
TOOL_ASSET: generate_system_cache_Linux_Ubuntu2404_nightly
# Where per-vendor ZIP assets are published; OrcaCloud's OTA reads this repo.
PROFILES_OWNER: OrcaSlicer
PROFILES_REPO: orcaslicer-profiles
jobs:
publish_profile_caches:
name: Publish profile caches
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }}
# FOLDER_MERGERS is an environment-scoped variable, shared with the PR
# merge bot. Keep this environment free of protection rules so this
# push-triggered job does not wait for a reviewer.
environment: merge-delegation
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# Enough history to reach github.event.before for the changed-vendor
# diff on a normal push; deeper pushes fall back to HEAD^..HEAD in the
# step below. fetch-depth: 0 would clone all of OrcaSlicer's history.
fetch-depth: 50
- name: Resolve changed vendors
id: vendors
shell: bash
env:
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
DISPATCH_VENDOR: ${{ github.event_name == 'workflow_dispatch' && inputs.vendor || '' }}
run: |
set -euo pipefail
# A vendor has a manifest plus either a preset directory or a version
# field; this drops non-vendor files such as blacklist.json. Shared by
# both the explicit-dispatch path below and the push-diff path further
# down, so the definition of "valid vendor" can't drift between them.
is_valid_vendor() {
local v="$1"
local json="resources/profiles/$v.json"
[ -f "$json" ] && { [ -d "resources/profiles/$v" ] || jq -e '.version' "$json" >/dev/null 2>&1; }
}
# Explicit vendor dispatch (e.g. the OFL cron): trust the caller and
# skip both the git-diff detection and the FOLDER_MERGERS check below.
if [ -n "$DISPATCH_VENDOR" ]; then
v="$DISPATCH_VENDOR"
# Becomes part of the release asset filename and the OTA API's
# payload; keep it to the same charset every real vendor name uses.
if ! [[ "$v" =~ ^[A-Za-z0-9]+$ ]]; then
echo "::error::vendor '$v' must be alphanumeric"
exit 1
fi
if ! is_valid_vendor "$v"; then
echo "::error::vendor '$v' has no resources/profiles/$v.json with a profile directory or version field"
exit 1
fi
echo "vendors=$v" >> "$GITHUB_OUTPUT"
exit 0
fi
base='${{ github.event.before }}'
head='${{ github.sha }}'
# Zero SHA (branch created / force push) or manual dispatch: fall back
# to this commit's own diff.
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$head^"
fi
# Exposed so the OFL-pending step below can reuse this exact diff
# range instead of re-deriving it (and drifting from this logic).
echo "base=$base" >> "$GITHUB_OUTPUT"
echo "head=$head" >> "$GITHUB_OUTPUT"
mapfile -t candidates < <(
git diff --name-only "$base" "$head" -- resources/profiles \
| sed -nE 's#^resources/profiles/([^/]+)/.*#\1#p; s#^resources/profiles/([^/]+)\.json$#\1#p' \
| sort -u
)
vendors=()
for v in "${candidates[@]:-}"; do
[ -n "$v" ] || continue
if is_valid_vendor "$v"; then
vendors+=("$v")
fi
done
if [ "${#vendors[@]}" -eq 0 ]; then
echo "vendors=" >> "$GITHUB_OUTPUT"
exit 0
fi
# A vendor is eligible only when both the profile directory and its
# sibling bundle JSON are covered by at least one FOLDER_MERGERS
# grant. The account part is intentionally ignored here: this is a
# post-merge safety check, not an authorization check for a command.
# An ineligible vendor (e.g. OrcaFilamentLibrary, which has no grant)
# is dropped on its own - it never blocks other vendors in the same
# push from publishing.
grants=()
while IFS= read -r raw_line; do
line="${raw_line#"${raw_line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -n "$line" ] || continue
[[ "$line" == \#* ]] && continue
[[ "$line" == *:* ]] || continue
grant="${line#*:}"
grant="${grant#"${grant%%[![:space:]]*}"}"
grant="${grant%"${grant##*[![:space:]]}"}"
while [[ "$grant" == */ ]]; do grant="${grant%/}"; done
grants+=("$grant")
done <<< "${FOLDER_MERGERS:-}"
is_granted() {
local path="$1"
local grant
for grant in "${grants[@]:-}"; do
if [[ "$path" == "$grant" || "$path" == "$grant/"* ]]; then
return 0
fi
done
return 1
}
authorized=()
unauthorized=()
for v in "${vendors[@]}"; do
if is_granted "resources/profiles/$v" && is_granted "resources/profiles/$v.json"; then
authorized+=("$v")
else
unauthorized+=("$v")
fi
done
if [ "${#unauthorized[@]}" -ne 0 ]; then
echo "::warning::skipping vendor(s) with no FOLDER_MERGERS grant (no asset built or published for them this run): ${unauthorized[*]}"
fi
echo "vendors=${authorized[*]}" >> "$GITHUB_OUTPUT"
- name: Resolve Orca version
id: orca
# Unconditional: needed both by the vendor-publish pipeline below (only
# when vendors is non-empty) and by the OFL-pending step at the end
# (which runs whenever OFL itself changed, even if vendors ends up
# empty because OFL has no FOLDER_MERGERS grant). Cheap and harmless
# to always resolve - version.inc is present on every commit.
shell: bash
run: |
set -euo pipefail
raw="$(sed -nE 's/^set\(SoftFever_VERSION "([^"]+)".*/\1/p' version.inc | head -1)"
[ -n "$raw" ] || { echo "::error::could not read SoftFever_VERSION from version.inc"; exit 1; }
orca_ver="${raw%%-*}"
if ! [[ "$orca_ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Orca version '$orca_ver' (from '$raw') is not X.Y.Z"; exit 1
fi
# release_tag is what the desktop client sends as orca_version and what
# OrcaCloud keys R2 on; orca_ver (X.Y.Z) is the asset-name prefix.
echo "release_tag=$raw" >> "$GITHUB_OUTPUT"
echo "orca_ver=$orca_ver" >> "$GITHUB_OUTPUT"
- name: Validate profile versions
id: pver
if: steps.vendors.outputs.vendors != ''
shell: bash
run: |
set -euo pipefail
: > "$RUNNER_TEMP/pver.tsv"
for v in ${{ steps.vendors.outputs.vendors }}; do
pv="$(jq -r '.version // empty' "resources/profiles/$v.json")"
if ! [[ "$pv" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::vendor $v version '${pv:-<none>}' must be 4 numeric parts (A.B.C.D) for the OTA asset name; fix resources/profiles/$v.json"
exit 1
fi
printf '%s\t%s\n' "$v" "$pv" >> "$RUNNER_TEMP/pver.tsv"
done
- name: Download generate_system_cache
if: steps.vendors.outputs.vendors != ''
shell: bash
env:
# gh (with the default token) rather than an unauthenticated curl: keeps
# working if TOOL_REPO is ever private and avoids anonymous rate limits.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh release download nightly-builds --repo "$TOOL_REPO" \
--pattern "$TOOL_ASSET" --output generate_system_cache --clobber
chmod +x generate_system_cache
- name: Build caches and package assets
id: pkg
if: steps.vendors.outputs.vendors != ''
shell: bash
run: |
set -euo pipefail
# One timestamp for the whole run so a multi-vendor merge groups together.
ts="$(date -u +%Y%m%d%H%M)"
orca_ver='${{ steps.orca.outputs.orca_ver }}'
out="$RUNNER_TEMP/assets"
mkdir -p "$out"
for v in ${{ steps.vendors.outputs.vendors }}; do
./generate_system_cache -p "$GITHUB_WORKSPACE/resources/profiles" -v "$v" -l 2
opc="resources/profiles/$v.opc"
[ -f "$opc" ] || { echo "::error::$opc was not generated"; exit 1; }
pv="$(awk -F'\t' -v v="$v" '$1==v{print $2}' "$RUNNER_TEMP/pver.tsv")"
name="${orca_ver}_${v}_${pv}_${ts}.zip"
( cd resources/profiles && zip -q -j "$out/$name" "$v.opc" )
done
echo "dir=$out" >> "$GITHUB_OUTPUT"
- name: Mint profiles-repo token
id: token
if: steps.vendors.outputs.vendors != ''
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.PROFILES_APP_ID }}
private-key: ${{ secrets.PROFILES_APP_PRIVATE_KEY }}
owner: ${{ env.PROFILES_OWNER }}
repositories: ${{ env.PROFILES_REPO }}
- name: Publish assets to profiles release
if: steps.vendors.outputs.vendors != ''
shell: bash
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
RELEASE_TAG: ${{ steps.orca.outputs.release_tag }}
ASSET_DIR: ${{ steps.pkg.outputs.dir }}
run: |
set -euo pipefail
repo="$PROFILES_OWNER/$PROFILES_REPO"
if ! gh release view "$RELEASE_TAG" --repo "$repo" >/dev/null 2>&1; then
echo "Creating release $RELEASE_TAG on $repo"
gh release create "$RELEASE_TAG" --repo "$repo" \
--title "$RELEASE_TAG" --notes "Profile cache assets for Orca $RELEASE_TAG." \
--latest=false
fi
# Asset names are timestamp-unique; a clash means a bug, so don't --clobber.
gh release upload "$RELEASE_TAG" --repo "$repo" "$ASSET_DIR"/*.zip
{
echo "### Published to \`$repo\` release \`$RELEASE_TAG\`"
for f in "$ASSET_DIR"/*.zip; do echo "- \`$(basename "$f")\`"; done
} >> "$GITHUB_STEP_SUMMARY"
- name: Notify OTA auto-publish
# Gated on auto_publish specifically, not just "vendor was dispatched":
# a maintainer manually dispatching with vendor=OrcaFilamentLibrary (e.g.
# to rebuild/republish an asset while debugging) must not silently go
# live. Only a caller that explicitly opts in with auto_publish=true
# (the OFL nightly cron) skips the human changelog/Publish step.
if: >-
steps.vendors.outputs.vendors != '' && github.event_name == 'workflow_dispatch'
&& (inputs.auto_publish == true || inputs.auto_publish == 'true')
shell: bash
env:
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
ASSET_DIR: ${{ steps.pkg.outputs.dir }}
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; }
mapfile -t zip_files < <(cd "$ASSET_DIR" && ls -1 *.zip)
filenames_json="$(printf '%s\n' "${zip_files[@]}" | jq -R . | jq -s .)"
payload="$(jq -n --argjson filenames "$filenames_json" '{filenames: $filenames}')"
resp_file="$RUNNER_TEMP/ota-auto-publish-response.json"
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
"${OTA_API_BASE_URL%/}/api/v1/ota/auto-publish" \
-H "Authorization: Bearer $OTA_API_KEY" \
-H 'Content-Type: application/json' \
-d "$payload")"
body="$(cat "$resp_file")"
echo "$body"
if [ "$status" != "200" ]; then
echo "::error::OTA auto-publish call failed with HTTP $status"
exit 1
fi
# A 200 can still carry per-file "error" results (e.g. NOT_FOUND); the
# asset is already safely published to the profiles release above, but
# it never went live, so treat that as a failure worth surfacing loudly.
error_count="$(jq '[.results[] | select(.status == "error")] | length' <<< "$body")"
if [ "$error_count" != "0" ]; then
jq -r '.results[] | select(.status == "error") | "::error::\(.filename): \(.code) - \(.message)"' <<< "$body"
exit 1
fi
- name: Record OFL pending changes
# A real merge, never the cron's explicit-vendor dispatch (that's
# automation publishing, not a new merge to report). This covers two
# trigger shapes: an ordinary push, and a vendor-less workflow_dispatch
# - the latter is exactly what pr-merge-bot.yml's re-dispatch after a
# successful /bot merge looks like (a GITHUB_TOKEN-authored merge fires
# no push event at all, which is why that re-dispatch exists). Both
# land in the same diff-fallback path in "Resolve changed vendors", so
# base/head/orca_ver are already correctly populated either way - only
# this condition needs widening.
# Placed last in the job on purpose: a failure here must never block
# the vendor-publish pipeline above, which a step failing earlier in
# the job would do (subsequent steps without always() get skipped).
if: >-
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && !inputs.vendor)
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
run: |
set -euo pipefail
base='${{ steps.vendors.outputs.base }}'
head='${{ steps.vendors.outputs.head }}'
orca_ver='${{ steps.orca.outputs.orca_ver }}'
# Only real vendor subdirectories under filament/, e.g.
# .../filament/Qidi/x.json -> "Qidi". This naturally excludes loose
# top-level files (.../filament/Generic PLA @System.json - no further
# slash to match) and is further filtered below to drop "base", the
# shared @base/@System inheritance folder, not a partner company.
mapfile -t ofl_companies < <(
git diff --name-only "$base" "$head" -- resources/profiles/OrcaFilamentLibrary/filament \
| sed -nE 's#^resources/profiles/OrcaFilamentLibrary/filament/([^/]+)/.*#\1#p' \
| grep -vx 'base' \
| sort -u
)
if [ "${#ofl_companies[@]}" -eq 0 ]; then
echo "No OFL company folders changed in this push."
exit 0
fi
[ -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; }
# The head commit's own merged PR, not a per-commit walk: this
# assumes the ordinary one-PR-per-push shape every other merge path
# in this repo already assumes (pr-merge-bot.yml's re-dispatch logic
# does the same). A merge commit's parents don't matter here - this
# API call works the same regardless of merge strategy.
pr_json="$(gh api "repos/${{ github.repository }}/commits/$head/pulls" \
--jq '[.[] | select(.merged_at != null)] | sort_by(.merged_at) | last // empty')"
if [ -z "$pr_json" ]; then
echo "::warning::push $head touches OFL compan(y/ies) (${ofl_companies[*]}) but has no associated merged PR; skipping pending record(s)"
exit 0
fi
pr_number="$(jq -r '.number' <<< "$pr_json")"
pr_url="$(jq -r '.html_url' <<< "$pr_json")"
pr_title="$(jq -r '.title' <<< "$pr_json")"
for company in "${ofl_companies[@]}"; do
payload="$(jq -n --arg vendor "$company" --arg ver "$orca_ver" --argjson pr "$pr_number" \
--arg url "$pr_url" --arg title "$pr_title" \
'{vendor: $vendor, orcaSlicerVersion: $ver, prNumber: $pr, prUrl: $url, prTitle: $title}')"
resp_file="$RUNNER_TEMP/ofl-pending-$company.json"
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending" \
-H "Authorization: Bearer $OTA_API_KEY" \
-H 'Content-Type: application/json' \
-d "$payload")"
body="$(cat "$resp_file")"
echo "$body"
if [ "$status" != "200" ]; then
echo "::error::OFL pending record failed for vendor=$company (PR #$pr_number): HTTP $status"
exit 1
fi
done
+2
View File
@@ -35,6 +35,7 @@ jobs:
// kind of change
'crash',
'bug-fix',
'SECURITY',
'enhancement',
'QoL',
'optimization',
@@ -193,6 +194,7 @@ jobs:
// kind of change
'crash',
'bug-fix',
'SECURITY',
'enhancement',
'QoL',
'optimization',
+429 -6
View File
@@ -12,6 +12,13 @@ name: PR Merge Bot
# PR targets main or release/*, and CI is green on the head commit. Otherwise it
# comments naming the files that fell outside the grant.
#
# When a PR touching resources/profiles/** is opened, two labels are applied
# independently of the merge command:
# profile every changed path is inside resources/profiles/
# orca profile partner the PR author holds a grant covering every changed
# path, plus a one-time comment explaining /bot merge
# Neither label changes what the merge command checks.
#
# Grants come from the FOLDER_MERGERS variable in the `merge-delegation`
# environment: one per line, `account: path`, `#` comments and blank lines
# allowed. Paths may contain spaces. A vendor takes two grants, the folder and
@@ -32,10 +39,18 @@ on:
issue_comment:
types:
- created
# Labels profile PRs on open, without waiting for a /bot merge command.
pull_request_target:
types:
- opened
paths:
- 'resources/profiles/**'
# One merge attempt per PR at a time, so two quick comments cannot race.
# Labels run under their own group, so a queued label run is not replaced by
# a merge run for the same PR.
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
@@ -43,6 +58,7 @@ jobs:
# Skips the job unless a PR comment mentions the command.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '/bot merge')
permissions:
@@ -53,7 +69,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
# delegated merge would wait for a human reviewer.
# delegated merge and partner label run would wait for a human reviewer.
environment: merge-delegation
steps:
- name: Merge PR on behalf of a folder delegate
@@ -76,7 +92,6 @@ jobs:
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
const MERGE_METHOD = 'squash';
const REQUIRED_CHECK = 'Check profiles'; // job name in check_profiles.yml
const MAX_CHANGED_FILES = 500; // policy cap, well under listFiles' 3000
const LISTFILES_CAP = 3000;
const MAX_REPORTED_FILES = 12;
const MERGEABLE_ATTEMPTS = 5;
@@ -304,9 +319,6 @@ jobs:
'so the file list is truncated and I cannot verify the folder scope. A maintainer must merge this one.'
);
}
if (pr.changed_files > MAX_CHANGED_FILES) {
return refuse(`it changes ${pr.changed_files} files; delegated merges are capped at ${MAX_CHANGED_FILES}.`);
}
const deniedFiles = [];
const outsideFiles = [];
@@ -508,3 +520,414 @@ jobs:
} catch (error) {
core.warning(`Merged successfully, but dispatching build_all.yml failed: ${error.message}`);
}
// ---- re-kick the profile publish ----
// Same reason as above: post_merge_profiles.yml is push-triggered, so a
// GITHUB_TOKEN merge never starts it. workflow_dispatch skips the paths:
// filter, so only dispatch when the PR actually touched profiles.
const touchesProfiles = files.some((file) =>
[file.filename, file.previous_filename]
.filter(Boolean)
.some((p) => p.startsWith('resources/profiles/'))
);
if (touchesProfiles) {
try {
await github.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: 'post_merge_profiles.yml',
ref: pr.base.ref
});
core.info(`Dispatched post_merge_profiles.yml on ${pr.base.ref}.`);
} catch (error) {
core.warning(`Merged successfully, but dispatching post_merge_profiles.yml failed: ${error.message}`);
}
}
label-profile:
# Independent of the merge rules: any PR that changes only files inside
# resources/profiles/ is labeled `profile`.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'pull_request_target'
permissions:
contents: read
pull-requests: write
issues: write
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Label profile-only PRs
uses: actions/github-script@v9
with:
script: |
function isPermissionDenied(error) {
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
}
const PROFILE_ROOT = 'resources/profiles/';
const LABEL = 'profile';
const LISTFILES_CAP = 3000;
const ATTEMPTS = 3;
function profileOnlyProblem(pr, files) {
if (!files.length) {
return 'PR changes no files; not labeling.';
}
// A truncated list, or a count that disagrees with the PR, cannot
// prove "only profile files".
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
return `PR reports ${pr.changed_files} changed files but the API listed ${files.length}; not labeling.`;
}
// Both endpoints of a rename count, so a move out of the profile
// root is not mistaken for a profile-only change.
const paths = files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean));
const outside = paths.filter((path) => !path.startsWith(PROFILE_ROOT));
if (outside.length) {
return `${outside.length} changed path(s) fall outside ${PROFILE_ROOT}; not labeling.`;
}
return null;
}
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
// The event payload is frozen at `opened`; listFiles is not. Read
// fresh PR metadata and retry if either side of the diff changes.
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.state !== 'open') {
core.info(`PR is ${pr.state}; not labeling.`);
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100
});
const problem = profileOnlyProblem(pr, files);
const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (
after.state !== 'open' ||
after.head.sha !== pr.head.sha ||
after.base.ref !== pr.base.ref ||
after.base.sha !== pr.base.sha
) {
core.info('PR changed while listing files; retrying.');
continue;
}
if (problem) {
core.info(problem);
return;
}
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [LABEL]
});
core.info(`Applied the "${LABEL}" label.`);
} catch (error) {
if (isPermissionDenied(error)) {
core.warning(`Cannot add the "${LABEL}" label because the token cannot write.`);
return;
}
throw error;
}
return;
}
core.warning('PR kept changing during verification; not labeling.');
label-profile-partner:
# Labels a profile PR whose author holds a grant covering every changed
# path, and explains the /bot merge command to them once.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'pull_request_target'
permissions:
contents: read # delegatable subtree, for file modes
pull-requests: write
issues: write # label + comment
runs-on: ubuntu-latest
timeout-minutes: 10
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
# qualifying PR open would wait for a human reviewer.
environment: merge-delegation
steps:
- name: Label profile PRs from delegated maintainers
uses: actions/github-script@v9
env:
# Read as an env var, never interpolated into the script body.
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
with:
script: |
function isPermissionDenied(error) {
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
}
// Never prints the grant list: this job posts public comments and
// its logs are public too.
async function bestEffort(call, warning) {
try {
await call();
} catch (error) {
if (isPermissionDenied(error)) {
core.warning(warning);
return;
}
throw error;
}
}
const MARKER = '<!-- profile-partner-bot -->';
const LABEL = 'orca profile partner';
const ATTEMPTS = 3;
// ---- scope rules, mirrored from the merge job above ----
// Change both together: these decide whether a delegate could merge.
const DELEGATABLE_ROOT = 'resources/profiles/';
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
const LISTFILES_CAP = 3000;
const REGULAR_FILE_MODES = new Set(['100644', '100755']);
const DENIED_PATTERNS = [
/^\.github\//,
/(^|\/)\.git(attributes|modules|ignore|config)$/,
/^(?:src|deps|deps_src|tests|tools|cmake|sandboxes|scripts|docs?|localization|bbl)\//,
/(^|\/)cmakelists\.txt$/,
/\.cmake$/,
/^build_[^/]*\.(?:sh|bat)$/,
/^version\.inc$/,
// Executables, including those inside the delegatable root.
/\.(?:sh|bash|bat|cmd|ps1|py|js|mjs|cjs|ts|rb|pl|php)$/
];
function parseGrants(raw) {
// GitHub login: 1-39 chars, alphanumerics with single interior hyphens.
const loginPattern = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
const grantsByLogin = new Map();
const problems = [];
(raw || '').split(/\r?\n/).forEach((rawLine, index) => {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
return;
}
// Splits on the first colon only, so paths may contain ':' and spaces.
const separator = line.indexOf(':');
if (separator === -1) {
problems.push(`line ${index + 1}: expected \`account: path\``);
return;
}
const login = line.slice(0, separator).trim().replace(/^@/, '');
const path = line.slice(separator + 1).trim().replace(/\/+$/, '');
if (!loginPattern.test(login)) {
problems.push(`line ${index + 1}: \`${login}\` is not a valid GitHub account name`);
return;
}
if (/[\\*?\u0000-\u001f\u007f]/.test(path) || path.split('/').includes('..') || path.includes('//')) {
problems.push(`line ${index + 1}: invalid path (no globs, \`..\`, \`//\`, backslashes or control characters)`);
return;
}
// Rejects anything outside the root, and the bare root itself.
if (!path.startsWith(DELEGATABLE_ROOT) || path.length <= DELEGATABLE_ROOT.length) {
problems.push(`line ${index + 1}: \`${path}\` is not inside \`${DELEGATABLE_ROOT}\``);
return;
}
const key = login.toLowerCase();
grantsByLogin.set(key, (grantsByLogin.get(key) || []).concat(path));
});
return { grantsByLogin, problems };
}
function isDenied(path) {
if (/[\\\u0000-\u001f\u007f]/.test(path) || path.startsWith('/') || path.split('/').includes('..')) {
return true;
}
const normalized = path.normalize('NFKC').toLowerCase();
return DENIED_PATTERNS.some((pattern) => pattern.test(normalized));
}
// Byte-exact match on directory boundaries, so a grant of
// `.../Acme` covers neither `.../Acme Labs/x.json` nor `.../Acme.json`.
function isGranted(path, grants) {
return grants.some((grant) => path === grant || path.startsWith(`${grant}/`));
}
// Both endpoints of a rename; both must satisfy the grant.
function pathsFor(file) {
return [file.filename, file.previous_filename].filter(Boolean);
}
// ---- end mirrored rules ----
function scopeProblem(pr, files, grants) {
if (!files.length) {
return 'PR changes no files; not labeling.';
}
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
return `PR reports ${pr.changed_files} changed files but the API listed ${files.length}; not labeling.`;
}
let outsideCount = 0;
for (const file of files) {
for (const path of pathsFor(file)) {
if (isDenied(path) || !isGranted(path, grants)) {
outsideCount += 1;
}
}
}
if (outsideCount) {
return `PR has ${outsideCount} path(s) outside @${author}'s grants; not labeling.`;
}
return null;
}
// ---- file modes: rejects symlinks and submodules ----
function modeProblem(files, tree) {
if (tree.truncated) {
return 'The profile tree is too large to verify file modes; not labeling.';
}
const modesByPath = new Map(tree.tree.map((entry) => [`${DELEGATABLE_ROOT}${entry.path}`, entry.mode]));
const hasIrregularFile = files.some((file) =>
file.status !== 'removed' && !REGULAR_FILE_MODES.has(modesByPath.get(file.filename)));
if (hasIrregularFile) {
return 'PR adds symlinks, submodules or files whose modes cannot be verified; not labeling.';
}
return null;
}
const { owner, repo } = context.repo;
const number = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
const { grantsByLogin, problems } = parseGrants(process.env.FOLDER_MERGERS);
// Only the count: the malformed lines may name grant holders.
if (problems.length) {
core.warning(`FOLDER_MERGERS has ${problems.length} malformed line(s); not labeling.`);
return;
}
const grants = grantsByLogin.get(author.toLowerCase()) || [];
// Says nothing to accounts with no grant, so it cannot be used to spam.
if (!grants.length) {
core.info(`Ignoring PR from @${author}: not listed in FOLDER_MERGERS.`);
return;
}
// Read current PR metadata for the file list and head tree. Retry
// if either side of the diff changes during verification.
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (pr.state !== 'open') {
core.info(`PR is ${pr.state}; not labeling.`);
return;
}
if (!ALLOWED_BASE_BRANCH.test(pr.base.ref)) {
core.info(`PR targets "${pr.base.ref}", not main or release/*; not labeling.`);
return;
}
// Checked before listing files, so a PR too large to list is
// rejected in one call.
if (pr.changed_files >= LISTFILES_CAP) {
core.info(`PR changes ${pr.changed_files} files, more than the API can list; not labeling.`);
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100
});
const scopeIssue = scopeProblem(pr, files, grants);
let modeIssue = null;
if (!scopeIssue) {
const { data: tree } = await github.rest.git.getTree({
owner,
repo,
tree_sha: `${pr.head.sha}:${DELEGATABLE_ROOT.replace(/\/$/, '')}`,
recursive: 'true'
});
modeIssue = modeProblem(files, tree);
}
const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
if (
after.state !== 'open' ||
after.head.sha !== pr.head.sha ||
after.base.ref !== pr.base.ref ||
after.base.sha !== pr.base.sha
) {
core.info('PR changed while verifying; retrying.');
continue;
}
const problem = scopeIssue || modeIssue;
if (problem) {
core.info(problem);
return;
}
// ---- label + one-time comment ----
await bestEffort(
() => github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }),
`Cannot add the "${LABEL}" label because the token cannot write.`);
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100
});
if (comments.some((comment) => (comment.body || '').includes(MARKER))) {
core.info('Partner notice already present; skipping comment.');
return;
}
await bestEffort(
() => github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body:
`${MARKER}\n` +
`Hi @${author}, this profile PR is covered by your delegated merge grant.\n\n` +
`Once it is ready for review and CI is green, you can merge it yourself:\n\n` +
`- \`/bot merge\` - squash-merge into \`main\` or \`release/*\`\n` +
`- \`/bot merge --dry-run\` - report the verdict without merging\n\n` +
`The bot re-checks the scope, the file modes and the \`Check profiles\` check at merge time.`
}),
'Cannot post the partner notice because the token cannot write comments.');
core.info(`Applied the "${LABEL}" label and posted the /bot merge notice.`);
return;
}
core.warning('PR kept changing during verification; not labeling.');
+8
View File
@@ -44,6 +44,14 @@ jobs:
uses: actions/download-artifact@v8
with:
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
with:
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
+2 -1
View File
@@ -53,4 +53,5 @@ __pycache__/
*.pyc
*.opc
/.test/
docs/superpowers/
docs/superpowers/
ctest_results.xml
+84 -58
View File
@@ -56,9 +56,9 @@ You can do this in Environment Variables settings.
endif ()
if (APPLE)
# if CMAKE_OSX_DEPLOYMENT_TARGET is not set, set it to 11.3
# if CMAKE_OSX_DEPLOYMENT_TARGET is not set, set it to 12.0 (the lowest Xcode 27 accepts)
if (NOT CMAKE_OSX_DEPLOYMENT_TARGET)
set(CMAKE_OSX_DEPLOYMENT_TARGET "11.3" CACHE STRING "Minimum OS X deployment version" FORCE)
set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING "Minimum OS X deployment version" FORCE)
endif ()
message(STATUS "CMAKE_OSX_DEPLOYMENT_TARGET: ${CMAKE_OSX_DEPLOYMENT_TARGET}")
endif ()
@@ -70,6 +70,9 @@ if (POLICY CMP0092)
cmake_policy(SET CMP0092 NEW)
endif ()
# project() reads this, so set it first.
set(CMAKE_USER_MAKE_RULES_OVERRIDE "${CMAKE_CURRENT_LIST_DIR}/cmake/modules/ClangClShowIncludes.cmake")
project(OrcaSlicer)
# Backward compatibility for old CMake versions
@@ -107,6 +110,7 @@ endif()
option(SLIC3R_STATIC "Compile OrcaSlicer with static libraries (Boost, TBB)" ${SLIC3R_STATIC_INITIAL})
option(SLIC3R_GUI "Compile OrcaSlicer with GUI components (OpenGL, wxWidgets)" 1)
option(SLIC3R_CAD "Compile OrcaSlicer with the parametric Design/CAD tab (needs OCCT ModelingAlgorithms)" 1)
option(SLIC3R_FHS "Assume OrcaSlicer is to be installed in a FHS directory structure" 0)
option(SLIC3R_PROFILE "Compile OrcaSlicer with an invasive Shiny profiler" 0)
option(SLIC3R_PCH "Use precompiled headers" 1)
@@ -275,6 +279,11 @@ if (APPLE)
endif()
SET(CMAKE_XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.orcaslicer.OrcaSlicer")
# The macOS CI jobs build with Ninja (build_release_macos.sh -x), so the Xcode generator
# is not covered. Xcode adds -Wshorten-64-to-32 by default ("Implicit Conversion to 32 Bit
# Type"); Ninja/-Wall does not, and under -Werror it fails Xcode builds on code CI accepts.
set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION "NO")
message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}")
elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(CMAKE_INSTALL_RPATH "$ORIGIN")
@@ -308,6 +317,10 @@ if (SLIC3R_GUI)
add_definitions(-DSLIC3R_GUI)
endif ()
if (SLIC3R_CAD)
add_definitions(-DSLIC3R_CAD)
endif ()
if(SLIC3R_DESKTOP_INTEGRATION)
add_definitions(-DSLIC3R_DESKTOP_INTEGRATION)
endif ()
@@ -821,6 +834,37 @@ find_package(OpenSSL REQUIRED)
find_package(CURL 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)
target_link_libraries(libcurl INTERFACE CURL::libcurl)
@@ -1076,78 +1120,52 @@ function(orcaslicer_copy_dlls target config postfix output_dlls)
${TOP_LEVEL_PROJECT_DIR}/deps/WebView2/lib/win-${_arch}/WebView2Loader.dll
DESTINATION ${_out_dir})
file(COPY ${CMAKE_PREFIX_PATH}/bin/occt/TKBO.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKBRep.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKCAF.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKCDF.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKernel.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKG2d.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKG3d.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKGeomAlgo.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKGeomBase.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKHLR.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKLCAF.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKMath.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKMesh.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKPrim.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKService.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKShHealing.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKSTEP.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKSTEP209.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKSTEPAttr.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKSTEPBase.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKTopAlgo.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKV3d.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKVCAF.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKXCAF.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKXDESTEP.dll
${CMAKE_PREFIX_PATH}/bin/occt/TKXSBase.dll
# Stage the OCCT toolkits libslic3r links (published as OCCT_LIBS), not whatever the
# deps prefix happens to hold, and fail the configure if one of them is missing.
if (NOT OCCT_LIBS)
message(FATAL_ERROR "OCCT_LIBS is not set; libslic3r must be configured first.")
endif ()
set(_occt_bin "${CMAKE_PREFIX_PATH}/bin/occt")
set(_occt_dlls "")
set(_occt_staged "")
set(_missing_occt "")
foreach (_tk IN LISTS OCCT_LIBS)
if (EXISTS "${_occt_bin}/${_tk}.dll")
list(APPEND _occt_dlls "${_occt_bin}/${_tk}.dll")
list(APPEND _occt_staged "${_out_dir}/${_tk}.dll")
else ()
list(APPEND _missing_occt "${_tk}.dll")
endif ()
endforeach ()
if (_missing_occt)
message(FATAL_ERROR
"OCCT DLLs missing from ${_occt_bin}/: ${_missing_occt}\n"
"Rebuild the dependencies (build_release_vs2022.bat deps) with the same "
"SLIC3R_CAD setting as this project.")
endif ()
file(COPY ${_occt_dlls}
${CMAKE_PREFIX_PATH}/bin/freetype.dll
${CMAKE_PREFIX_PATH}/bin/avformat-61.dll
${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll
${CMAKE_PREFIX_PATH}/bin/swresample-5.dll
${CMAKE_PREFIX_PATH}/bin/swscale-8.dll
${CMAKE_PREFIX_PATH}/bin/avutil-59.dll
DESTINATION ${_out_dir})
set(${output_dlls}
set(_dll_list
${_out_dir}/libgmp-10.dll
${_out_dir}/libmpfr-4.dll
${_out_dir}/WebView2Loader.dll
${_out_dir}/TKBO.dll
${_out_dir}/TKBRep.dll
${_out_dir}/TKCAF.dll
${_out_dir}/TKCDF.dll
${_out_dir}/TKernel.dll
${_out_dir}/TKG2d.dll
${_out_dir}/TKG3d.dll
${_out_dir}/TKGeomAlgo.dll
${_out_dir}/TKGeomBase.dll
${_out_dir}/TKHLR.dll
${_out_dir}/TKLCAF.dll
${_out_dir}/TKMath.dll
${_out_dir}/TKMesh.dll
${_out_dir}/TKPrim.dll
${_out_dir}/TKService.dll
${_out_dir}/TKShHealing.dll
${_out_dir}/TKSTEP.dll
${_out_dir}/TKSTEP209.dll
${_out_dir}/TKSTEPAttr.dll
${_out_dir}/TKSTEPBase.dll
${_out_dir}/TKTopAlgo.dll
${_out_dir}/TKV3d.dll
${_out_dir}/TKVCAF.dll
${_out_dir}/TKXCAF.dll
${_out_dir}/TKXDESTEP.dll
${_out_dir}/TKXSBase.dll
${_out_dir}/freetype.dll
${_out_dir}/avformat-61.dll
${_out_dir}/avcodec-61.dll
${_out_dir}/swresample-5.dll
${_out_dir}/swscale-8.dll
${_out_dir}/avutil-59.dll
PARENT_SCOPE
)
list(APPEND _dll_list ${_occt_staged})
set(${output_dlls} ${_dll_list} PARENT_SCOPE)
endfunction()
@@ -1164,7 +1182,10 @@ function(orcaslicer_copy_sos target config postfix output_sos)
set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}")
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.3.100
${CMAKE_PREFIX_PATH}/lib/libavutil.so
@@ -1179,6 +1200,9 @@ function(orcaslicer_copy_sos target config postfix output_sos)
DESTINATION ${_out_dir})
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.61
${_out_dir}/libavcodec.so.61.3.100
@@ -1308,6 +1332,8 @@ endif ()
if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
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.3.100
${LIBDIR_BIN}/libavutil.so.59
+2 -2
View File
@@ -53,7 +53,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -s: Build slicer only"
echo " -u: Build universal app only (requires existing arm64 and x86_64 app bundles)"
echo " -n: Nightly build"
echo " -t: Specify minimum version of the target platform, default is 11.3"
echo " -t: Specify minimum version of the target platform, default is 12.0"
echo " -x: Use Ninja Multi-Config CMake generator, default is Xcode"
echo " -b: Build without reconfiguring CMake"
echo " -c: Set CMake build configuration, default is Release"
@@ -95,7 +95,7 @@ if [ -z "$DEPS_CMAKE_GENERATOR" ]; then
fi
if [ -z "$OSX_DEPLOYMENT_TARGET" ]; then
export OSX_DEPLOYMENT_TARGET="11.3"
export OSX_DEPLOYMENT_TARGET="12.0"
fi
if [ -z "$CMAKE_IGNORE_PREFIX_PATH" ]; then
+10
View File
@@ -0,0 +1,10 @@
# ccache does not parse the -clang: arguments CMake uses for clang-cl's gcc-style
# depfile, so a cache hit writes the object and no depfile, and Ninja then records
# no headers for that object. ccache reproduces /showIncludes output on a hit.
foreach (_lang C CXX)
if (CMAKE_${_lang}_COMPILER_ID STREQUAL "Clang" AND
CMAKE_${_lang}_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC")
set(CMAKE_DEPFILE_FLAGS_${_lang} "/showIncludes")
set(CMAKE_${_lang}_DEPFILE_FORMAT msvc)
endif ()
endforeach ()
+42 -13
View File
@@ -26,9 +26,9 @@ endif()
cmake_minimum_required(VERSION 3.2)
if (APPLE)
# if CMAKE_OSX_DEPLOYMENT_TARGET is not set, set it to 11.3
# if CMAKE_OSX_DEPLOYMENT_TARGET is not set, set it to 12.0 (the lowest Xcode 27 accepts)
if (NOT CMAKE_OSX_DEPLOYMENT_TARGET)
set(CMAKE_OSX_DEPLOYMENT_TARGET "11.3" CACHE STRING "Minimum OS X deployment version" FORCE)
set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING "Minimum OS X deployment version" FORCE)
endif ()
message(STATUS "CMAKE_OSX_DEPLOYMENT_TARGET: ${CMAKE_OSX_DEPLOYMENT_TARGET}")
@@ -38,6 +38,14 @@ if(POLICY CMP0135) # DOWNLOAD_EXTRACT_TIMESTAMP
cmake_policy(SET CMP0135 NEW)
endif()
# project() reads this, so set it first. scripts/flatpak/make_deps_tar.sh packs deps/
# without cmake/, so the file is missing in a Flatpak build.
set(_rules_override "${CMAKE_CURRENT_LIST_DIR}/../cmake/modules/ClangClShowIncludes.cmake")
if (EXISTS "${_rules_override}")
set(CMAKE_USER_MAKE_RULES_OVERRIDE "${_rules_override}")
endif ()
unset(_rules_override)
project(OrcaSlicer-deps)
# Backward compatibility for old CMake versions
@@ -55,6 +63,7 @@ endif ()
set(DEP_DOWNLOAD_DIR ${CMAKE_CURRENT_SOURCE_DIR}/DL_CACHE CACHE PATH "Path for downloaded source packages.")
set(FLATPAK FALSE CACHE BOOL "Toggles various build settings for flatpak, like /usr/local in DESTDIR or not building wxwidgets")
option(SLIC3R_CAD "Build the SolveSpace solver and OCCT ModelingAlgorithms module the parametric Design/CAD tab needs. Must match the main project's SLIC3R_CAD." ON)
if ("${DESTDIR}" STREQUAL "" OR "${DESTDIR}" STREQUAL "${AUTOGENERATED_DESTDIR}")
if (LINUX AND (NOT DEFINED USE_OLD_DESTDIR_PREV OR USE_OLD_DESTDIR_PREV) AND EXISTS "${CMAKE_BINARY_DIR}/destdir/usr/local" AND NOT EXISTS "${CMAKE_BINARY_DIR}/OrcaSlicer_dep/usr/local")
@@ -155,7 +164,7 @@ if (NOT _is_multi AND NOT CMAKE_BUILD_TYPE)
endif ()
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
# generator. A non-Visual-Studio superbuild passes its own generator down, and with
@@ -201,12 +210,18 @@ function(orcaslicer_add_cmake_project projectname)
set(_build_j "-j${NPROC}")
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)
ExternalProject_Add(
dep_${projectname}
EXCLUDE_FROM_ALL ON
INSTALL_DIR ${DESTDIR}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
${_source_dir_arg}
${_gen}
CMAKE_ARGS
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
@@ -219,6 +234,7 @@ if (NOT IS_CROSS_COMPILE OR NOT APPLE)
-DCMAKE_CXX_COMPILER:STRING=${CMAKE_CXX_COMPILER}
-DCMAKE_C_COMPILER_LAUNCHER:STRING=${CMAKE_C_COMPILER_LAUNCHER}
-DCMAKE_CXX_COMPILER_LAUNCHER:STRING=${CMAKE_CXX_COMPILER_LAUNCHER}
-DCMAKE_USER_MAKE_RULES_OVERRIDE:STRING=${CMAKE_USER_MAKE_RULES_OVERRIDE}
-DCMAKE_TOOLCHAIN_FILE:STRING=${CMAKE_TOOLCHAIN_FILE}
-DCMAKE_EXE_LINKER_FLAGS:STRING=${CMAKE_EXE_LINKER_FLAGS}
-DCMAKE_SHARED_LINKER_FLAGS:STRING=${CMAKE_SHARED_LINKER_FLAGS}
@@ -239,12 +255,14 @@ if (NOT IS_CROSS_COMPILE OR NOT APPLE)
# 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)
# so, as much as I would like to use that, it's not happening
ExternalProject_Add_Step(dep_${projectname} free_download_space
DEPENDEES download # do after download
COMMENT "Freeing Space: Removing source archive"
WORKING_DIRECTORY ${DEP_DOWNLOAD_DIR}
COMMAND ${CMAKE_COMMAND} -E rm -r ${projectname}
)
if (NOT P_ARGS_SOURCE_DIR)
ExternalProject_Add_Step(dep_${projectname} free_download_space
DEPENDEES download # do after download
COMMENT "Freeing Space: Removing source archive"
WORKING_DIRECTORY ${DEP_DOWNLOAD_DIR}
COMMAND ${CMAKE_COMMAND} -E rm -rf ${projectname}
)
endif ()
ExternalProject_Add_Step(dep_${projectname} free_build_space
DEPENDEES install # do after install
COMMENT "Freeing Space: Removing source and build files"
@@ -258,6 +276,7 @@ else()
EXCLUDE_FROM_ALL ON
INSTALL_DIR ${DESTDIR}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
${_source_dir_arg}
${_gen}
CMAKE_ARGS
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
@@ -266,6 +285,7 @@ else()
-DCMAKE_IGNORE_PREFIX_PATH:STRING=${CMAKE_IGNORE_PREFIX_PATH}
-DCMAKE_C_COMPILER_LAUNCHER:STRING=${CMAKE_C_COMPILER_LAUNCHER}
-DCMAKE_CXX_COMPILER_LAUNCHER:STRING=${CMAKE_CXX_COMPILER_LAUNCHER}
-DCMAKE_USER_MAKE_RULES_OVERRIDE:STRING=${CMAKE_USER_MAKE_RULES_OVERRIDE}
-DBUILD_SHARED_LIBS:BOOL=OFF
${_cmake_osx_arch}
"${_configs_line}"
@@ -363,6 +383,11 @@ include(GLEW/GLEW.cmake)
include(GLFW/GLFW.cmake)
include(OpenCSG/OpenCSG.cmake)
set(SLVS_PKG "")
if (SLIC3R_CAD)
include(SLVS/SLVS.cmake)
set(SLVS_PKG dep_SLVS)
endif ()
include(TBB/TBB.cmake)
@@ -380,10 +405,6 @@ include(libnoise/libnoise.cmake)
include(Draco/Draco.cmake)
include(FFMPEG/FFMPEG.cmake)
include(Assimp/Assimp.cmake)
# 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
# a grep across the repo shows it is used for other things
@@ -394,6 +415,12 @@ if(NOT OPENSSL_FOUND)
set(OPENSSL_PKG dep_OpenSSL)
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
# so, just don't even bother
# ...i think this is how it works? change if wrong
@@ -452,6 +479,7 @@ set(_dep_list
dep_NLopt
dep_OpenVDB
dep_OpenCSG
${SLVS_PKG}
dep_OpenCV
dep_Eigen
dep_CGAL
@@ -466,6 +494,7 @@ set(_dep_list
dep_wxInspector
dep_FFMPEG
dep_Assimp
${DATACHANNEL_PKG}
)
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(_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)
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_HASH_arm64 "12f4140279f2f8469885e1b5b2e8be9d788882914c21523cacd56989f3548054")
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_HASH_x64 "e65916020ddb9ef84b2666dfbcbfc9b1d67f69d15b4a66db53754637bf2d498c")
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 "da480cbb39680056de824c57ec4dc3bd577b479ebbc310ff1f9dc55cf014b4c1")
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 "85da19daf198f5548259d8aabb349db84997a3f6e6886d8d7764114add9c6dae")
ExternalProject_Add(dep_FFMPEG
${_ffmpeg_depends}
URL ${PREBUILD_URL_${DEPS_ARCH}}
URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
@@ -21,6 +33,8 @@ if (MSVC)
)
else ()
set(_openssl_cmd --enable-openssl)
if (APPLE)
set(_minos_cmd
"--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}"
@@ -52,10 +66,11 @@ else ()
endif()
ExternalProject_Add(dep_FFMPEG
${_ffmpeg_depends}
URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz
URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
CONFIGURE_COMMAND ${_conf_cmd}
CONFIGURE_COMMAND ${_ffmpeg_configure_command}
${_cross_cmd}
${_pic_cmd}
${_arch_cmd}
@@ -63,20 +78,21 @@ else ()
"--prefix=${DESTDIR}"
${_link_cmd}
${_minos_cmd}
${_openssl_cmd}
--disable-doc
--enable-small
--disable-outdevs
--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*
--disable-protocols
--enable-protocol=file,fd,pipe,rtp,udp
--enable-protocol=file,fd,pipe,http,https,rtp,tcp,udp
--disable-muxers
--enable-muxer=rtp
--disable-encoders
--disable-decoders
--enable-decoder=*aac*,h264*,mp3*,mjpeg,rv*
--disable-demuxers
--enable-demuxer=h264,mp3,mov
--enable-demuxer=h264,mp3,mov,mpjpeg,rtsp,sdp
--disable-zlib
--disable-avdevice
BUILD_IN_SOURCE ON
+3 -3
View File
@@ -5,7 +5,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
+void g(int a,t1 const*b,t1 c,t2 dd,t1 const*e,int ff){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
@@ -17,7 +17,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
+void g(int a,t1 const*b,t1 c,t2 dd,t1 const*e,int ff){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
@@ -26,7 +26,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
+void g(int a,t1 const*b,t1 c,t2 dd,t1 const*e,int ff){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
+16 -1
View File
@@ -11,6 +11,21 @@ else()
set(library_build_type "Static")
endif()
# SLIC3R_CAD (declared in deps/CMakeLists.txt) builds OCCT's ModelingAlgorithms module
# (fillet/offset/loft), whose only consumer is the parametric Design/CAD tab. With it OFF
# the deps prefix matches upstream exactly.
#
# With it ON the delta is THREE toolkits, not two: TKFillet (7.40 MiB archive, used via
# BRepFilletAPI), TKOffset (5.38 MiB, used via BRepOffsetAPI) and TKFeat (4.42 MiB), which
# nothing here references but which the module flag builds anyway -- it is all-or-nothing
# per module. The module's other nine toolkits are built either way, because DataExchange
# (the STEP path upstream already ships) depends on them.
#
# On macOS/Linux OCCT links statically, so an unreferenced toolkit costs build time and no
# shipped bytes. The Windows figure is a real DLL cost and has NOT been measured -- an
# earlier "3.77 MiB, Windows only" note here covered only two of the three toolkits and is
# not a number to quote. See docs/HLSD/design-tab.md.
if (IN_GIT_REPO)
set(OCCT_DIRECTORY_FLAG --directory ${BINARY_DIR_REL}/dep_OCCT-prefix/src/dep_OCCT)
endif ()
@@ -35,7 +50,7 @@ orcaslicer_add_cmake_project(OCCT
#-DBUILD_MODULE_DataExchange=OFF
-DBUILD_MODULE_Draw=OFF
-DBUILD_MODULE_FoundationClasses=OFF
-DBUILD_MODULE_ModelingAlgorithms=OFF
-DBUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD}
-DBUILD_MODULE_ModelingData=OFF
-DBUILD_MODULE_Visualization=OFF
${_occt_compiler_args}
+65
View File
@@ -0,0 +1,65 @@
# Replaces the upstream SolveSpaceLib CMakeLists, which builds a demo executable and
# has no install rules. The sources themselves are used verbatim.
cmake_minimum_required(VERSION 3.13)
project(SLVS VERSION 3.0)
add_library(slvs
libslvs/constrainteq.cpp
libslvs/entity.cpp
libslvs/expr.cpp
libslvs/system.cpp
libslvs/util.cpp
libslvs/platform/unixutil.cpp
libslvs/lib.cpp
libslvs/SolveSpaceSystem.cpp)
target_compile_features(slvs PUBLIC cxx_std_11)
# LIBRARY strips the solver core out of the SolveSpace application it was extracted from.
target_compile_definitions(slvs PRIVATE -DLIBRARY)
if (MSVC)
target_compile_definitions(slvs PRIVATE -D_CRT_SECURE_NO_WARNINGS -D_SCL_SECURE_NO_WARNINGS)
endif ()
target_include_directories(slvs
PUBLIC $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/libslvs/include>
PRIVATE ${PROJECT_SOURCE_DIR}/libslvs)
# libslic3r is linked into shared targets, so this has to be position independent.
set_target_properties(slvs PROPERTIES POSITION_INDEPENDENT_CODE ON)
# 2018 code, predating the project's warning settings; it is not ours to clean up.
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
target_compile_options(slvs PRIVATE -w -fno-strict-aliasing)
endif ()
include(CMakePackageConfigHelpers)
include(GNUInstallDirs)
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY AnyNewerVersion)
install(TARGETS slvs
EXPORT ${PROJECT_NAME}Targets
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
set(ConfigPackageLocation ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})
install(EXPORT ${PROJECT_NAME}Targets
FILE "${PROJECT_NAME}Config.cmake"
NAMESPACE ${PROJECT_NAME}::
DESTINATION ${ConfigPackageLocation})
install(FILES
${PROJECT_SOURCE_DIR}/libslvs/include/slvs.h
${PROJECT_SOURCE_DIR}/libslvs/include/SolveSpaceSystem.h
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake"
DESTINATION ${ConfigPackageLocation})
+13
View File
@@ -0,0 +1,13 @@
# libslvs — the geometric constraint solver behind the Design tab's sketch constraints.
# Extraction of solvespace.com's libslvs, taken verbatim from JacobStoren/SolveSpaceLib;
# only the CMakeLists is ours, because upstream's builds a demo and installs nothing.
# GPLv3, compatible with this fork's licence. Self-contained: no external dependencies.
orcaslicer_add_cmake_project(SLVS
URL https://github.com/JacobStoren/SolveSpaceLib/archive/4d8704523e4bf212fadf5189f92484244f670fea.zip
URL_HASH SHA256=1c4bdde9c3c6ef20ea4b50b73601de56769f2eb131b36927d7c6489f102e6c30
PATCH_COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_LIST_DIR}/CMakeLists.txt.in ./CMakeLists.txt
)
if (MSVC)
add_debug_dep(dep_SLVS)
endif ()
+1
View File
@@ -15,6 +15,7 @@ orcaslicer_add_cmake_project(
-DTBB_BUILD_SHARED=OFF
-DTBB_BUILD_TESTS=OFF
-DTBB_TEST=OFF
-DTBB_DISABLE_HWLOC_AUTOMATIC_SEARCH=ON
-DTBB_ENABLE_IPO=OFF
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
+11 -1
View File
@@ -151,6 +151,12 @@ elseif(APPLE)
# the post-install -add_rpath below.
set(_python_ldflags "${_python_arch_flags} -Wl,-headerpad_max_install_names")
# The macOS 27 SDK declares pipe2() and dup3() as available from macOS 27, so
# configure finds them and CPython 3.12 calls them without a runtime check.
# Below a macOS 27 deployment target they are weak-linked and resolve to NULL
# on older systems, where os.pipe() then segfaults -- in `make install`
# (compileall, ensurepip) and in the shipped app alike. Every configure below
# keeps the pipe()/dup2() fallbacks (python/cpython#153711).
if(IS_CROSS_COMPILE)
set(_python_build_tgt --build=${_python_build_arch}-apple-darwin --host=${_python_host_arch}-apple-darwin)
set(_python_build_arch_flags "-arch ${_python_build_arch_flag} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}")
@@ -174,7 +180,8 @@ elseif(APPLE)
--enable-shared \
--without-static-libpython \
--disable-test-modules \
--build=${_python_build_arch}-apple-darwin && \
--build=${_python_build_arch}-apple-darwin \
ac_cv_func_pipe2=no ac_cv_func_dup3=no && \
make -j${NPROC} python && \
cd '<SOURCE_DIR>' && \
env \
@@ -191,6 +198,7 @@ elseif(APPLE)
--without-static-libpython \
--with-openssl='${DESTDIR}' \
--disable-test-modules \
ac_cv_func_pipe2=no ac_cv_func_dup3=no \
${_python_build_tgt} \
--with-build-python='${_python_build_python}' \
py_cv_module__tkinter=n/a"
@@ -213,6 +221,8 @@ elseif(APPLE)
--with-openssl=${DESTDIR}
--disable-test-modules
${_python_build_tgt}
ac_cv_func_pipe2=no
ac_cv_func_dup3=no
# Tcl/Tk 9.0 (e.g. from Homebrew) is incompatible with CPython 3.12's
# _tkinter; OrcaSlicer's embedded Python does not need tkinter anyway.
py_cv_module__tkinter=n/a
+137
View File
@@ -0,0 +1,137 @@
# Deferred Page Construction: High Level Design
## Why it exists
The main window is a notebook of tabs, and only one of them is on screen when the first
frame appears. Others are opened later in the session, some never, and some only exist
for certain printers. Building every tab before the first frame makes each startup pay for
tabs the user may never open.
This subsystem builds a tab the first time it is shown, and builds the rest in small units
while the user is idle after startup. Startup pays only for what the first frame shows,
the other tabs are usually ready before anyone opens them, and a click that lands in the
middle of the idle build waits for one unit at most. Work that is not a tab, such as a
dialog or the 3D view's GL resources, uses the same machinery.
## The parts
The parts are independent. A holder can hold anything a factory makes, a placeholder page
is a holder with a widget, a staged object can live outside a holder, and the scheduler
knows none of them; it runs tasks, which the main window makes from the holders.
### The holder: `Lazy<T>`
A holder keeps one object and the factory that makes it. The rest of the app reads the
object if it exists, makes sure it exists now when about to show or navigate to it, or runs
something once it exists. A type with one instance in the app gets these as statics
through `LazyInstance<T>`, so callers need no reference to the main window, and all of them
are harmless while no holder exists.
The holder guarantees that callers see the object only once it is completely built. A
build cannot re-enter itself, so a nested request finds nothing yet, and a factory that
returns null or a unit that throws leaves the holder and the scheduler able to carry on.
The holder does not own the object; its wx parent does, as for any window. The holder has
no wx dependency and is unit-tested.
### The placeholder page: `LazyPage<Panel>`
The notebook needs a page object for a tab to exist and for tabs to be inserted and
removed by pointer, and the placeholder is that object. It builds the real panel inside
itself the first time it is shown and forwards showing and hiding afterwards, so a panel's
own show handling stays its activation hook. Nothing builds while the main window is
hidden; the window's first show builds the start page. A page that is out of the book is
not prebuilt. A panel built while its page is hidden stays hidden, and gets the theming
the window applied before the panel existed.
### Staged construction: `StagedBuild`
A constructor too big to be one unit builds a skeleton and queues the rest as steps, which
run one per unit. A child panel's steps can be forwarded to its parent, and the parent is
complete only once the child is. Nothing may use what a step builds before the last step
has run, so staged panels follow these constraints:
- members created in steps start out null, so a partly built panel can be destroyed;
- timers, event handlers and destructors that touch step content check that the panel is
complete first;
- nothing takes focus while off screen, since a unit may run while the user is typing
elsewhere;
- a widget added by a step keeps its place in the sizer through an empty slot the skeleton
creates.
### The scheduler: `IdleScheduler` and `PrebuildQueue`
The queue holds tasks in order, and a slice runs units of the first pending task until
the task finishes, the time budget is spent, or input arrives. It has no wx dependency and
is unit-tested with a fake clock. A task whose work goes away, such as a tab removed from
the book, is skipped, and becomes pending again if the work comes back.
A slice runs only once the user has been idle for a short quiet time, and each slice is its
own timer message, so paint, timers and input queued in between are handled before the
next slice. Posting slices as pending events would not do that, because wx drains every
pending event before the next native message. On GTK a timer that is always due starves
the lower-priority sources that repaint and deliver posted events, so slices are a few
milliseconds apart. On Windows a slice also waits while the native queue holds input,
not counting mouse moves, which Windows synthesizes whenever a window appears under the
cursor. A slice never runs inside a `wxYield()`, where it would build pages in the middle of
the code that yielded. A unit cannot be interrupted, so the largest unit bounds how long a
click can wait.
When nothing is pending the timer stops and the subsystem costs nothing.
The main window owns the scheduler because it owns what the tasks build, and clearing the
queue with the window keeps a task from outliving its object. Each owner provides its own
tasks, such as a tab, a dialog, the Prepare tab's settings page one option group at a
time, the Prepare page's layout at the size the book gives its pages, or the 3D view's GL
resources.
### The 3D view's GL resources
OpenGL is loaded on the Prepare tab's canvas. When the start page is not Prepare, loading
it is an idle task that makes the context current on the hidden canvas, so the start page
paints first and Prepare never appears. Loading it on a shown canvas under `Freeze()` holds
back the start page's paint, and on GTK `Freeze()` cannot hide the canvas, which is a
native child window or a Wayland subsurface drawn outside GTK. A hidden Windows child
window keeps its device context, macOS attaches the context to a hidden view, and GTK
creates the canvas's surface when the widget is realized, so on GTK the task realizes the
canvas first. If the context cannot be made current, the canvas's first render loads the
resources.
## Rules
**Before the first frame.** Only the start page and the Prepare tab's plater are built
before the first frame. Everything else goes through a holder.
**Reaching a lazy object.** Callers use the type's statics. Reading it if built is for
things the object can live without, such as a rescale, a color change or a status refresh.
Making sure it exists is for navigating to it or showing it. Running something once it is
built is for state it would not fetch for itself on construction. A panel that pulls its
state when constructed only ever needs to be read if built.
**Unit size.** A unit should fit in one slice on a fast machine. A constructor over that is
staged, and a single widget over it is accepted unless the widget itself can be split.
**Order.** Tasks run cheapest and most likely to be opened first. Each holder's order is
given where it is created, with gaps so a new task fits between its neighbors. A negative
order is never prebuilt, for something few sessions open that costs more to build unasked
than it saves.
## Adopting it
A lazy tab needs a panel type deriving from `LazyInstance`, a placeholder page the main
window creates once with its order and registers for the idle build, and every use of the
panel outside the main window going through the statics. Its constructor has to cope with
the main window already existing and the user being busy elsewhere, so it takes no focus
while off screen, and it does all of its own setup, since the main window does nothing to a
panel after creating it.
To stage a heavy constructor, keep the skeleton in the constructor, move the rest into
steps in its original order, and follow the staged-construction constraints. Measure the
units; a step that is still one big widget is split inside the widget or accepted.
## Verifying
The log lists the queue when it is registered, reports each completed task at info level
and each slice and unit at debug level, and reports every on-demand build with its units
and time. A task's completion line counts only the slice it finished in. A run from the
configured start page should show every registered task complete in order, with no unit
longer than intended. A click on a tab during the idle build should show an on-demand
build for what was left, with the slices resuming once the user is idle again.
+213
View File
@@ -0,0 +1,213 @@
# Design tab — High Level Design
## Purpose and scope
The Design tab is a parametric CAD environment inside the slicer: sketch, constrain, build
solid features, commit the result to the plate. It exists because the alternative is a round
trip through an external CAD application, and that round trip discards design intent at both
ends — a part edited after slicing comes back as a mesh rather than as the feature history
that produced it. Keeping the model in the project means a dimension can be changed after the
part has been sliced, with the nozzle diameter, the build volume and the material already
known.
Its coupling to the rest of the application is deliberately narrow. It adds no stage to the
slicing pipeline and touches neither the preset system nor `Tab`. It reaches the rest of Orca
in two places: **Commit to Plate**, which hands finished solids to Prepare as ordinary model
objects, and one optional 3MF archive entry that carries the recipe. Everything else is
contained in `src/libslic3r/CAD/` and `src/slic3r/GUI/CAD/`.
The user-facing manual lives in the wiki
([Design Tab](https://www.orcaslicer.com/wiki/design_tab)), not here. This document covers the
parts of the design that the code does not make evident.
## The model is a recipe
`CadDocument` holds an ordered list of `CadFeature` and nothing else that matters. Bodies,
meshes and display geometry are **derived**: `recompute()` replays the feature list from the
start and rebuilds them. Editing a dimension set twenty features ago is therefore an ordinary
edit — everything downstream is rebuilt by the same replay that built it the first time.
Two consequences follow from deriving rather than storing:
- **Undo is a snapshot of `features` alone.** The caller calls `checkpoint()` before the
mutations that make up one user action; undo restores that snapshot and recomputes. Because
everything else is derived, one checkpoint is exactly one `Ctrl+Z` step and the restored
state is exact rather than approximately reconstructed. The tab keeps this stack itself; it
is not Orca's project snapshot system, which operates on `Model` objects the Design tab does
not own until Commit.
- **Face and edge ids are session-scoped.** They are indices into `TopExp::MapShapes`, so a
rebuild invalidates every one of them. `CadDocument::topo_generation` is bumped on every
rebuild so a holder of an id can discover that it is stale instead of silently addressing a
different edge. The counter is deliberately not serialized: an id means nothing outside the
run that produced it.
## Geometry kernel and dependency surface
The kernel is OCCT, which OrcaSlicer **already** links — `Format/STEP.cpp`, `Format/svg.cpp`
and `Shape/TextShape.cpp` use it upstream. The Design tab adds no third-party dependency; it
widens the existing OCCT build by one module flag in `deps/OCCT/OCCT.cmake`:
```cmake
-DBUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD}
```
Most of that module's twelve toolkits were already being built, because `DataExchange` — the
STEP path upstream ships — depends on them. The delta is `TKFillet` (used through
`BRepFilletAPI`), `TKOffset` (`BRepOffsetAPI`) and `TKFeat`, which nothing here references but
which the module flag builds anyway, because OCCT's module flags are all-or-nothing. On macOS
and Linux OCCT links statically, so an unreferenced toolkit costs build time and no shipped
bytes; on Windows OCCT builds shared, so the cost there is real DLL bytes. That Windows figure
has not been measured, and `OCCT.cmake` says so rather than carrying a number that was derived
from an incomplete toolkit list.
On Windows the packaging step asserts that every linked OCCT toolkit has a shipped DLL and
fails the configure with the name of any that is missing, because the alternative failure — a
deps prefix built with a different `SLIC3R_CAD` setting than the app — otherwise surfaces as a
missing DLL at first launch.
## The sketch constraint solver
`src/libslic3r/slvs/` is a vendored subset of SolveSpace's `libslvs`: self-contained, no
external dependencies, **GPL-3.0**, with its `LICENSE` preserved verbatim in the directory.
`SketchSolver.cpp` is its only consumer and drives every sketch constraint in the tab.
OrcaSlicer is AGPL-3.0. GPLv3 §13 permits combining a GPLv3 work with an AGPLv3 work and
AGPLv3 §13 grants the converse, so the combined work is distributable under AGPL-3.0 with the
solver's GPLv3 terms preserved. The solver is vendored rather than fetched as a dependency
because it is a pinned subset with no build system of its own; the cost of that choice is
upstream-sync burden, paid deliberately to keep `deps/` unchanged.
## The SLIC3R_CAD gate
`SLIC3R_CAD` (default ON) compiles the tab and selects the OCCT module flag above. With it OFF
the tab is not built and the deps prefix matches upstream exactly. The gate is cheap because
the hooks the Design tab adds to shared GUI code — chiefly the `m_design_sketch_tool` member
and the render, mouse and key hooks in `GLCanvas3D` — are null-guarded on the path they extend,
so removing the tab removes behaviour rather than requiring the host code to be rewritten.
The flag has to agree between the dependencies and the application; that is what the DLL
assertion above is checking.
## Project persistence
A project stores the recipe as one optional archive entry, `Metadata/orca_cad.bin`, backed by
a single `std::string cad_recipe` on `Model`. The entry is written only when the string is
non-empty, and readers that do not know it ignore it, so projects that contain no CAD model are
byte-identical to what upstream would have written and older readers are unaffected.
The blob is a cereal binary archive whose layout is the field order of `CadFeature`'s
save/load. That makes the format the one irreversible decision in the subsystem, and the rules
that keep it survivable are:
- **Append only, never reorder.** Enums serialize positionally as their underlying integer, so
inserting a value in the middle of `SketchConstraintType` or `CadFeatureType` reinterprets
every constraint in every saved project. New fields go at the end.
- **Features are length-framed.** Since v5 each feature is a length-prefixed, self-contained
cereal stream, so a reader can skip a feature written by a newer build and stop cleanly on an
older one. This is what makes appending a field a non-breaking change from here on. v4 and
earlier still open through the pre-framing flat path; v1 is deliberately not loadable and has
no migration path.
- **A newer stamp is refused, not guessed at.** `deserialize_recipe` rejects a blob whose
version exceeds `ORCA_CAD_RECIPE_VERSION` with a message naming both versions.
- **The rules are held by fixtures, not by discipline.** `tests/data/cad_recipe_v{3,4,5}.bin`
are checked-in blobs from the builds that wrote them, and the tests that load them fail if a
field is reordered — which the in-memory round-trip test cannot detect. A regeneration test
(`[.regen]`, not run by default) produces a fresh fixture when a new version is stamped.
`Import` features embed the imported solid as an OCCT BRep string inside the recipe rather than
referencing the source file, so a project opens without the STEP or mesh it was built from.
The cost is that saved projects are coupled to an OCCT BRep revision.
## The interaction contract
Three inputs carry the whole modelling loop — left click, right click and `Esc` — and the
contract between them is stated in code rather than spread across handlers.
`DesignInteraction.hpp` defines a four-level LIFO stack whose enum value *is* the depth, so
"which level does this press belong to" is a comparison:
| Level | Holds | One `Esc` press |
| --- | --- | --- |
| `Transient` | a value field or a popup menu | closes it; the tool stays armed |
| `Gesture` | an uncommitted delta — an entity being drawn, a body being dragged | reverts it; committed work is untouched |
| `Tool` | a feature card, an armed sketch tool, a constrain session | exits it; drawn entities survive |
| `Idle` | nothing transient | clears the selection; leaves a sketch session only if it is empty |
`cad_escape_level()` is a `constexpr` free function over a POD of four booleans rather than a
method on the panel, so the ordering that is the entire contract is checkable without a window,
a GL context or an event loop — five `static_assert`s in the header do exactly that at compile
time.
**The strict invariant: no level of `Esc` deletes a feature, discards a sketch that holds
geometry, or rolls history back.** Destroying work needs a gesture that says so — `Del` on an
explicit selection, the sketch ribbon's Cancel, which asks first, or `Ctrl+Z`. A sketch
*session* is deliberately not a `Tool` level; it is the environment the `Idle` level lives in,
which makes the destructive path unrepresentable rather than merely unlikely.
Right-click is read at button-up against two independent budgets — 3 px of drift and 200 ms —
because drift alone still popped a menu at the end of a slow, careful orbit. The raycast uses
the press position, not the release. An armed sketch tool that already consumed the right
button (to terminate a chain, say) declines to also open a menu, through a read-and-clear flag.
Past either budget the event is navigation, and navigation does not transition the state
machine.
Entering a sketch changes three things at once so the mode is legible: a banner above the
canvas (a sibling of the canvas, not a child over it — on GTK a child window over a
`wxGLCanvas` is a native window and does not reliably stack over GL), the printer bed muted so
a plate grid is never read as a sketch grid, and `N` to look normal to the plane. Code that
changes any of the three belongs with a change to this section.
## The offer is generated, not hand-written
Right-clicking geometry opens the *offer*: eight families in a fixed order, each verb at a
permanent row index, verbs that do not apply shown disabled **in place with their reason**
rather than removed. The invariant is that a verb's row index is identical in every selection
where it appears and that adding a verb never moves an existing one — the hand learns the
position, so the menu is never re-sorted, compacted or adaptively ordered.
An invariant across 92 verbs and 20 selection kinds does not survive by review, so the map
exists once, as data: `scripts/CAD/tool_atlas.json` carries every verb with its row, key, icon,
accepted selections, preconditions and refusal string, and `scripts/CAD/gen_offer_table.py`
emits `src/slic3r/GUI/CAD/DesignOffer.hpp` from it. The header is checked in and never
hand-edited; `scripts/CAD/run-all-checks.sh` runs the generator with `--check` as its first
rung, which is what makes "GENERATED — DO NOT EDIT" a fact rather than a request. The generator
also refuses an atlas with a duplicate verb id, since `mcp_run_verb` resolves a verb by id and
would make the second one unreachable.
The atlas and its generator sit in `scripts/CAD/` rather than in `docs/`: they are build inputs
for a checked-in header, not documentation.
## Automation surface
`McpControl` exposes the document over JSON-RPC when `ORCA_CAD_MCP` is set in the environment,
with `tools/orca_cad_mcp_bridge.py` as the client side. It describes the scene, queries
topology, measures, and runs the same verbs the offer does — it re-implements nothing, so a
scripted action and a clicked one cannot diverge. It is off unless the variable is set.
## Where the code lives
| Path | Role |
| --- | --- |
| `src/libslic3r/CAD/CadDocument.*` | the feature recipe, its replay, undo and serialization |
| `src/libslic3r/CAD/GeometryEngine.*` | OCCT wrapper — faces, edges, booleans, healing |
| `src/libslic3r/CAD/SketchEngine.*` | profile → wire → solid |
| `src/libslic3r/CAD/SketchSolver.*` | constraint solving, over the vendored solver |
| `src/libslic3r/slvs/` | vendored 2D constraint solver (GPLv3) |
| `src/slic3r/GUI/CAD/DesignPanel.*` | the tab: toolbar, feature cards, tree, key maps |
| `src/slic3r/GUI/CAD/DesignCanvas.*` | viewport integration |
| `src/slic3r/GUI/CAD/DesignSketchTool.*` | in-canvas sketching |
| `src/slic3r/GUI/CAD/DesignInteraction.hpp` | the Esc level contract |
| `src/slic3r/GUI/CAD/DesignOffer.hpp` | generated offer table |
| `scripts/CAD/tool_atlas.json` | source of truth for the offer |
## Verification
The kernel is covered by Catch2 suites in `tests/libslic3r/` (`test_caddocument`,
`test_sketchconstraints`, `test_sketchedit`, `test_sketchimport`, `test_sketchinference`,
`test_sketchprofile`, `test_slvs_constraints`), which need no display;
`scripts/CAD/run-kernel-tests.sh` builds only `libslic3r_tests` and runs them headless.
The GUI half is not covered by CI, which has no OpenGL canvas or synthetic input: the ladders
in `scripts/CAD/` drive a running application in a local rig instead, and
`scripts/CAD/run-all-checks.sh` is the gate that runs all of them. A green kernel run says
nothing about the viewport, so the two are reported separately rather than as one number.
+31 -51
View File
@@ -36,21 +36,17 @@ This page is the rule for authoring `filament_id` in system profiles
> **Never write a `filament_id` value by hand.** A new filament gets its id from
> `python scripts/orca_profile_tool.py generate-id`; one already in the tree has one — inherit it.
## The design, in two pieces
## The design
Because several consumers match **globally by id alone, first hit wins** (see the next
section), any two materials sharing one id feed wrong data somewhere — a wrong tray name, a
wrong support-material flag, a wrong nozzle grouping — and inside one printer a duplicated id
makes AMS spool matching a coin toss. Hand-written ids produce such collisions constantly, so
the system is built to make them impossible:
1. **Deterministic minting.** An id is a pure hash of the product's identity — no registry to
maintain, no next-free-number ceremony, no way for two concurrent PRs to race for the same
number, and no way to get it wrong by hand, because you never write it by hand.
2. **A sanctioned snapshot.** The complete id landscape derived from the tree must equal
`scripts/filament_id_snapshot.json` exactly, so every change to ids, claims (which bundles
ship which id, and for which filament), or product identity surfaces as a reviewable diff to
one file — the maintainer gate.
the system is built to make them impossible: an id is a pure hash of the product's identity —
no registry to maintain, no next-free-number ceremony, no way for two concurrent PRs to race
for the same number, and no way to get it wrong by hand, because you never write it by hand.
CI holds every id in the tree to that rule, so the profiles themselves are the whole record of
which products exist and which bundles ship them.
## Who consumes the id
@@ -160,8 +156,8 @@ key needed). Tuning a generic material → **join the OrcaFilamentLibrary filame
different product by rule 5, so it then needs its own id.
5. **Ids follow the product identity.** The id is a pure function of the product triple
`(filament_vendor, filament_type, filament name)`, so correcting any of them re-mints the id
**by design**, applied by `generate-id` (preview with `--dry-run`, confine with `--vendor`)
and gated by the `update-snapshot` diff; the exact sequence is in the FAQ. Nothing forwards
**by design**, applied by `generate-id` (preview with `--dry-run`, confine with `--vendor`);
the exact sequence is in the FAQ. Nothing forwards
the old value, so anything outside the tree that stored it — a device tray, a calibration
record, a saved project — falls back to matching by filament type until the user re-selects
the filament. Re-mint deliberately, and only to fix a genuinely wrong identity.
@@ -196,8 +192,8 @@ Snapmaker bundles alike; the OFL generic `Generic/PLA/Generic PLA` mints `OFDSrz
by 35 bundles — most by independent declarations converging on the same mint, the rest
purely through inheritance from the OFL preset.
Nothing but the triple feeds the mint — not the rest of the tree, not the snapshot, not what
another preset of the product happens to carry. Determined triple, determined id: one product
Nothing but the triple feeds the mint — not the rest of the tree, not what another preset of
the product happens to carry. Determined triple, determined id: one product
carries one id and there is no second acceptable value for it, so any other value on a preset
is a mismatch `check` reports and `generate-id` pulls back. Two *different* products whose
triples mint the same base62 value would be a collision (a roughly 36-bit id space against a
@@ -214,17 +210,15 @@ Workflow for a new filament:
# 1. Author the filament with NO filament_id key anywhere.
python scripts/orca_profile_tool.py generate-id --dry-run # 2. preview the ids — writes nothing
python scripts/orca_profile_tool.py generate-id # 3. apply them to the profile file(s)
python scripts/orca_profile_tool.py update-snapshot # 4. record the new claims in the snapshot
python scripts/orca_profile_tool.py check # 5. validate — everything CI checks
# 6. Commit the profile edits together with scripts/filament_id_snapshot.json, for review.
python scripts/orca_profile_tool.py check # 4. validate — everything CI checks
```
`generate-id` makes every filament's id equal the mint of its own
`(filament_vendor, filament_type, filament name)` triple: it inserts one where an instantiated
filament resolves none, and re-derives one that does not match. A preset that *inherits* a
mismatching id is the one case left to the author — check 3b names it, and the fix is to inherit
mismatching id is the one case left to the author — check 2b names it, and the fix is to inherit
a preset of the same filament or to give the preset its own key. A declaration is left alone
exactly when it already equals the one id its triple mints, and a collision (check 3d) is
exactly when it already equals the one id its triple mints, and a collision (check 2d) is
reported and left unwritten. The same run assigns
`generate_preset_setting_id(vendor, type, name)` to every instantiated filament, process
and machine preset of every vendor except BBL, which keeps its authoritative `G*` ids, strips
@@ -242,9 +236,7 @@ loudly), and a no-op on a tree that already passes `check`.
- `--dry-run` reports what the run would do and writes nothing, so
`generate-id --dry-run --vendor <Vendor>` previews just that bundle.
- `--profiles DIR` points the tooling at a different profile tree (default
`resources/profiles`). `check` and `update-snapshot` read and write the sanctioned state of
the tree they are given, so pointing them elsewhere needs `--snapshot PATH` for that tree too —
`scripts/filament_id_snapshot.json` describes `resources/profiles` and no other tree.
`resources/profiles`).
The tool's other commands maintain the tree around the ids: `fix` normalises profile files,
`trim` drops files no `<vendor>.json` list references, and `update-index` rebuilds those lists.
@@ -252,13 +244,11 @@ They do not touch ids; `--help` documents them.
**Identity fixes need no separate command.** `generate-id` re-derives an id that no longer matches
its triple exactly the way it fills in a missing one, so a rename or a `filament_vendor` /
`filament_type` correction is just: fix the config, run `generate-id` (confine it with
`--vendor`, preview it with `--dry-run`), then `update-snapshot` and review the diff.
`filament_type` correction is just: fix the config and run `generate-id` (confine it with
`--vendor`, preview it with `--dry-run`).
If you skip the tooling, CI fails and prints the remedy: the expected id for your filament and
the instruction to run `python scripts/orca_profile_tool.py generate-id`; once the id is minted,
the snapshot checks likewise point at `update-snapshot` and tell you to commit the resulting
diff.
the instruction to run `python scripts/orca_profile_tool.py generate-id`.
## Ids other systems compose
@@ -339,7 +329,7 @@ OrcaFilamentLibrary. **135 is the number to expect at every regeneration** — 1
one-off size of the transition and stopped being computable from the tree once the BBL bundle
was re-minted, so do not "fix" the report to print it.
**Check 5** lives in `check_filament_ids`, so profile CI runs it alongside the other four. It
**Check 4** lives in `check_filament_ids`, so profile CI runs it alongside the other three. It
holds the file to its contract: it parses, carries `source` / `bambustudio_commit` /
`generated`, keys only `OF`-format ids, maps each Bambu id at most once, and — for every row
whose key the tree actually claims — agrees with the tree on that id's `(vendor, type, name)`
@@ -428,23 +418,14 @@ map would silently reproduce the bug.
## How CI enforces this
Profile CI (`check_profiles.yml`) runs `check_filament_ids()` tree-wide via
`scripts/orca_profile_tool.py check`. Its ground truth is
**`scripts/filament_id_snapshot.json` — the sanctioned state**: the id state derived from the
tree must equal the snapshot exactly, in both directions. Any change to the id landscape
therefore surfaces as a diff to that file, and **that snapshot diff is what maintainers review
and gate in a PR**. Never edit the snapshot by hand — `update-snapshot` regenerates it
deterministically (running it twice changes nothing). The snapshot holds one map, `ids`: each
entry is the product the id is minted from (`filament_vendor`, `filament_type`, `name`) and the
`filaments` claiming it (`Vendor/Filament`), and it sanctions *state*, never exceptions: no check
consults it to excuse a preset from a rule, and there is no grandfather list of any kind.
`scripts/orca_profile_tool.py check`. Every check judges the tree against the rules on this
page and nothing else — there is no recorded id state to match and no grandfather list of any
kind.
The checks, in brief:
- **Format** — every id occurring in the tree is `OF` + 6 base62 chars. No exceptions: not a
snapshot entry, not BBL.
- **Snapshot equality** — tree claims == snapshot claims **and** each id's declared triple ==
its snapshot entry, both directions: any `filament_vendor`/`filament_type`/name change
surfaces as a snapshot diff.
- **Format** — every id occurring in the tree is `OF` + 6 base62 chars. No exceptions, not
even BBL.
- **Identity** — the id is a function of the triple alone. A declared `OF*` id must equal the
one id its declarer's own triple mints, with no second acceptable value; the id an
instantiated preset *inherits* must equal the mint of *its* own triple, however it inherits
@@ -463,9 +444,8 @@ The checks, in brief:
A profile that declares an id no triple mints — a Bambu catalog id, a composed Qidi one, a
hand-typed value, whatever its vendor — fails the format check. For a Bambu-cataloged product
the catalog map is where the correspondence belongs. New sharing via a *declared* id is caught
by the identity check; sharing through inheritance carries no declaration to check and surfaces
only as a new claim in the snapshot diff — which is exactly why that diff is the gate.
the catalog map is where the correspondence belongs. Two products sharing one id are caught by
the identity check whether the id is declared or inherited.
The same `check` run holds every declared id to the AMS 8-character limit, tree-wide and for
every vendor alike, scoped to the presets a vendor's index actually references (a file the index
@@ -489,19 +469,19 @@ ambiguity check behind structure rule 3.
(or any real filament) for the settings and declare the id of your own filament; run
`python scripts/orca_profile_tool.py generate-id` to mint it. Inheritance never changes the id.
- **I need to fix a filament's `filament_vendor` or `filament_type`.** Fix the config, run
`generate-id --vendor <Vendor>` (preview with `--dry-run`), then `update-snapshot`, and commit
the profile and snapshot diffs together. The id re-derives from the corrected identity, and
`generate-id --vendor <Vendor>` (preview with `--dry-run`), and commit the result. The id
re-derives from the corrected identity, and
nothing forwards the old value, so a tray or record still holding it falls back to matching by
filament type.
- **I need to rename a filament.** Rename the presets (adding `renamed_from`, which keeps the
preset *name* resolving), then `generate-id --vendor <Vendor>` (preview with `--dry-run`), then
`update-snapshot`. The id follows the new filament name; as with any identity fix, the old id
preset *name* resolving), then `generate-id --vendor <Vendor>` (preview with `--dry-run`). The
id follows the new filament name; as with any identity fix, the old id
is not forwarded.
- **Can I reuse a `QD_*` id for a Qidi profile?** No — it is not a mint, so it is not a
`filament_id`. Those values are composed by the box at runtime, and no preset carries one.
Author Qidi filaments like any other vendor's.
- **CI says my filament needs an id.** Run `python scripts/orca_profile_tool.py generate-id`, then
`update-snapshot`, and commit both diffs. Do not type an id by hand.
- **CI says my filament needs an id.** Run `python scripts/orca_profile_tool.py generate-id` and
commit the result. Do not type an id by hand.
For general profile authoring, see the profile development guide on the
[OrcaSlicer wiki](https://www.orcaslicer.com/wiki).
+132
View File
@@ -0,0 +1,132 @@
# Keyboard Shortcuts
## Why it exists
Key events arrive in several windows (the main frame's char hook, the 3D canvases, the
gizmo manager and the object list), and the same keys are shown again in menu labels,
toolbar tooltips, gizmo names and the shortcuts dialog. The registry is the one table all
of them read. Each binding is defined once; dispatchers look key events up there, labels
are derived from it, and a change the user makes updates all of them.
## Data model
`KeyChord` (`src/slic3r/GUI/KeyChord.hpp`) is one key press: the key code as
`wxEVT_KEY_DOWN` reports it, plus the `wxMOD_*` modifiers held with it. It has two
text forms. The canonical one (`Ctrl+Shift+S`) is platform-neutral and doubles as the wx
accelerator string and the config format. The display one uses translated modifier
names and the command and option glyphs on macOS. `KeyChord::from_event()` turns any wx
key event into the same key code and modifiers, so a chord recorded in the dialog is
equal to the chord a dispatcher builds from the key press.
`Shortcut` is the enum of every user-facing binding. `shortcut_table` in
`src/slic3r/GUI/Shortcuts.cpp` gives each one a config key, a description, a context
mask, a default chord, a `repeatable` flag and a `modifier_variants` flag, in the order
the dialog lists them; a `static_assert` keeps the table and the enum in step.
`ShortcutRegistry` overlays the user's overrides on the defaults and keeps a
chord-to-shortcut index for lookups. It reads and writes the `shortcuts` section of
`AppConfig`. Only overrides are stored, so a default can change between releases
without touching anyone's config; `none` records a shortcut the user unbound.
## Contexts
A key press is looked up in the context of the window that received it.
| Context | Dispatcher | Examples |
|--------------|-----------------------------------------------------------|-------------------------------|
| `Global` | `MainFrame`'s `wxEVT_CHAR_HOOK`, before any child sees it | New project, camera views |
| `Plater` | `GLCanvas3D` of the 3D and assembly views | Arrange, gizmo activation |
| `Preview` | `GLCanvas3D` of the G-code preview | One-layer mode, jump to layer |
| `ObjectList` | the object list | Copy, delete, auto drop |
| `Painting` | `GLGizmosManager` while a painting gizmo is open | Circle, sphere, fill tools |
A shortcut can belong to several contexts, which is how copy and paste are a single
binding for the canvas and the object list. Two shortcuts can share a chord when their
contexts do not overlap; `C` is the cut gizmo in the 3D view, the G-code window in the
preview and the circle tool while painting. A Global chord is dispatched before every
other context, so the dialog treats it as conflicting with all of them.
A Global shortcut has to include Ctrl or Alt or use a key that types nothing, since a
bare printable key in the frame hook would swallow that character in every text field.
The dialog refuses such chords and `ShortcutRegistry::load()` drops them from the config.
Space counts as typing. The speed dial's default is the one bare Space, and
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
combo boxes), so it opens the dial from the canvases and the tab strip only.
## Which event a chord matches
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
on the keyboard layout: the key labelled `Q` on an AZERTY keyboard and the key in the
same position under a Cyrillic layout both report `Q`. Numpad keys fold onto their main
keyboard equivalents, so `Ctrl+1` and `Ctrl+Numpad 1` are one binding.
Punctuation matches on `wxEVT_CHAR`, because only the char event knows which character
a key produced under the active layout. `+` is Shift and `=` on a US keyboard and a key
of its own on a German one, and the binding means the character in both cases. The
canvas looks a key up on key-down first and, when nothing matched, once more on the char
event, for punctuation chords only. The dialog records chords the same way: a printable
non-alphanumeric key pressed with nothing but Shift is taken from the char event that
follows.
wxGTK does not report key auto-repeat, so the canvases share one record of the keys
seen going down and swallow the repeats of every shortcut not marked `repeatable`. Zoom
and undo repeat, for example; a toggle such as Tab does not. The record is shared because
a shortcut can move the focus to another canvas while its key is still held; a key
released while no canvas had the focus is dropped on the next press.
A few shortcuts have `modifier_variants`: Shift or Ctrl added to their binding selects a
step of the same action (1 mm and camera-space moves of the selection, five-step slider
moves). Only a binding without Shift or Ctrl of its own has steps, so no two bindings
share one. `ShortcutRegistry::match()` looks the exact chord up first and only then, when
nothing is bound to it, looks for such a shortcut whose binding is the chord minus those
modifiers, reporting which were added; a binding on Ctrl+Shift+key therefore wins over
the combined step. The Shift and Ctrl steps themselves are reserved. `step_owner()` names
the shortcut they belong to, the capture dialog refuses to assign them, and
`conflicts()` reports exact chords only. A binding made before its key became a stepping
key keeps its chord and shadows that one step. A move or rotation of the selection
started from the keyboard runs until the key that started it is released, or the
canvas loses focus, so a held key is one undo step.
## Labels
Menu labels, toolbar tooltips, gizmo names, the context menu and the shortcuts dialog
read the registry, so a rebinding shows up in all of them. Each tracked menu item keeps
its base label; `MainFrame::update_shortcut_labels()` appends the current binding
again after an edit, which also installs the new wx accelerator.
A chord that is unsafe as a menu accelerator, meaning a bare printable key, is appended
to the label as plain text so the menu cannot take it away from text fields. The macOS
edit menu shows its clipboard and undo entries that way, because a system-menu key
equivalent for Cmd+C would run instead of the text field's own copy.
On macOS the object list receives no key events at all, so its bindings are installed as
a `wxAcceleratorTable`, regenerated from the registry after each edit.
## Editing
The shortcuts dialog has a page per context, each opening with a line that says when its
keys apply. A page lists the shortcuts under the headings of `section_table`, with the
fixed keys that cannot change (mouse buttons, the step modifiers, Esc, the digit keys
that pick a filament) sorted into the same sections. The mouse drag rows describe the
camera actions chosen in Preferences; their button opens Preferences > Control with that
option scrolled into view and focused, instead of editing a key.
Editing a row opens a capture dialog that records the next chord, names the shortcuts it
would take the chord from, and on confirmation unbinds those and binds this one.
Resetting a row asks the same question when its default is now held by another
shortcut, so a reset cannot leave two shortcuts on one chord. Each change is written to
the config at once and pushed to the menus, tooltips and accelerator tables through
`GUI_App::on_shortcuts_changed()`. The dialog opens from the Help menu and Preferences >
Control on the Global page, and from the `?` key on the page of the view that received it.
## Adding a shortcut
1. Add the enum value to `Shortcut` and its row to `shortcut_table`, in the position
the dialog should list it; the row's section heading is the `section_table` entry
above it, so a new section needs an entry there too. Pick a default that does not
collide inside its contexts; the `[Shortcuts]` tests check every default against the
others.
2. Handle it in the dispatcher of its context: `MainFrame::handle_global_shortcut`,
`GLCanvas3D::handle_shortcut`, `ObjectList::dispatch_shortcut`, or a gizmo's
`on_tool_shortcut`. A gizmo that opens on a key sets `m_shortcut` in its constructor.
3. Where the UI shows the key, ask the registry (`display()` for tooltips,
`accelerator()` for menu labels); no label holds a literal key name.
+129
View File
@@ -0,0 +1,129 @@
# Multiline infill — High Level Design
## Purpose and scope
`fill_multiline` prints every sparse infill wall as N adjacent lines instead of
one, so a wall is `d1 = N * spacing` thick. Only internal sparse infill uses it.
Each pattern first builds its single-line centerlines at N times the usual line
spacing (so the density holds), and `multiline_fill()` then replaces each
centerline by the lines of that wall: the centerline itself when N is odd, and
closed outlines around it at every `spacing` out to `d1 / 2`. The outlines are
clipped to the fill region contracted by half a line width, then connected like
any other infill.
Outlines of centerlines that cross each other overlap at every crossing, which
over-extrudes the wall intersections. The line-crossing patterns Grid,
Triangles, Tri-hexagon and Cubic therefore build centerlines that never cross
(`FillRectilinear::fill_surface_trapezoidal()`), and so do Adaptive Cubic and
Support Cubic (`FillAdaptive`); the other patterns outline their usual
centerlines.
## Non-crossing centerlines
The crossing lines are resolved into x-monotone paths, the levels of the line
arrangement: walking along x, the k-th path is always the k-th line from the
bottom. At every crossing, the two paths bounce off each other instead of
passing through. Adjacent paths meet only at crossings, so their outlines touch
there and nowhere overlap.
Where two paths meet, each is cut short by a line perpendicular to the bisector
of its bend, `d1 / 2` from the crossing. The two cut segments are parallel and
`d1` apart, so the outermost lines of the two walls sit exactly `spacing` apart,
like the lines inside a wall. Where three lines meet at one point, the middle
path runs straight through and the outer two are cut `d1` from it.
Each pattern builds its rows along x in a rotated frame. Grid lines run at ±45°
there, and its rows are trapezoid waves that transpose on alternate layers. The three families of Triangles, Tri-hexagon and
Cubic run at 0°, 60° and 120°. Those rows rotate by 120° every layer about a
3-fold center of the arrangement, so each family takes every role in turn.
The pattern is phased on fixed positions, so it lines up across layers and
across the regions of one layer. Rounding the corners with
`sparse_infill_smooth_factor` happens before `multiline_fill()`.
Each region builds only the rows over its bounding box in that frame, and
outlines only the centerlines within `d1 / 2` of it, the ones whose outlines
reach it. Every row is monotone along its direction, so each outline is started
on the cap at the first end of its centerline, outside the region, and clipping
to the region cuts it only where it crosses the boundary.
## Cubic
Single-line Cubic draws the three families at the same spacing `h` and shifts
them with z: by `+dx`, `-dx` and `+dx`, `dx = z / sqrt(2)`. The multiline paths
follow the same lines. In the frame where one family is horizontal, the other two
cross in rows `h` apart, alternating by half a period, at height
`tau = -3 * dx (mod h)` above the horizontal line below them. The crossings split
every band between horizontal lines into up-pointing triangles of height `tau`,
down-pointing triangles of height `h - tau`, and hexagons. At `tau = 0` (and `h`)
all three families meet at common points, as in Triangles. At `tau = h / 2` the
triangles are equal, as in Tri-hexagon. The origin of that frame is always a
3-fold center, whatever z is, so the per-layer rotation keeps the lines in place.
Each band holds two paths that touch at its crossings: the upper one takes the
V below the crossing and runs along the top horizontal line, and the lower one
takes the inverted V above it and runs along the bottom line. Both are the same function
of `tau`, the lower one mirrored with `h - tau`. `cubic_upper_level()` builds one
period of the upper path as the lower envelope of five lines, clipped from below:
- the two slanted lines through the crossings,
- the horizontal line, lowered when the triangle above it is less than `1.5 * d1` high,
- the two chamfers where the path turns onto and off the horizontal line, `d1 / 2`
from those crossings,
- the flat cut into the V at the crossing.
The cut height `clamp(tau - d1 / 2, 0, h - d1) + d1` is what makes the pattern
continuous in z. While both triangles are at least `1.5 * d1` high, every
crossing is a pair of bends `d1 / 2` from it, as in Tri-hexagon. When a triangle
is thinner, its three paths stack like a triple crossing. The path through it
flattens toward its base line and lies on it once the triangle is under `d1 / 2`
high, and the paths beside it are pushed `d1` away. The layout thus reaches the
Triangles one where the families meet. Adjacent paths stay at least `d1` apart
at every `tau` and at every density up to 100%.
## Adaptive Cubic
Adaptive Cubic and Support Cubic take their lines from an octree of cubes
standing on a corner. On each layer every cube cuts its three mid-planes into
segments of the same three 60° families as Cubic, but the pattern is not
periodic. Smaller cubes near the surface add finer lines, and a finer line ends
where it meets the wall of its coarser cube, so the lines form crossings and
T-junctions. `FillAdaptive::multiline_paths()` builds the paths from these
segments directly, for each fill region and within `4 * d1` of it.
At a crossing the two paths bounce as in Cubic. At a T-junction the through line
runs straight on and the path of the ending line stops there. Every path still
runs left to right in the frame where one family is horizontal, and that family
rotates with the layer.
Every line of every cube size lies on one fine lattice, so crossings closer than
a few `d1` are the corners of one small triangle of that lattice, as in Cubic.
The cuts follow the Cubic rules without a closed formula:
- The two bends of a crossing are cut `d1` apart, `d1 / 2` each, perpendicular
to their bisector, so their walls touch. A cut goes no further than the path
end, and the other bend takes the rest of `d1`.
- At the tip of a small triangle, between the two slanted families, a cut also
goes no further than the neighbouring bend turning the other way, and the
path beyond that bend is kept a wall away from it. The bends onto the
horizontal family are not limited this way: pushing their paths apart would
open gaps between walls that should touch.
- A cut moves the path only where the cut line lies beyond it, near its bend.
The sharp bends between the two slanted families are cut after the bends onto
the horizontal family, so the tip of a small triangle wins, as in Cubic.
- A path stopping at a T-junction is trimmed until it is `d1` less half a line
spacing from every other path, so that its end overlaps the wall it stops on
by half a line and bonds to it. The paths are trimmed one at a time against
the others as already trimmed, so two ends facing each other meet instead of
both backing off. A path stopping on the line of another is trimmed before
that one, so it gives way and the other still reaches the line it stops on. A
second round trims every path again from its full length, so an end grows
back where the ends it gave way to were trimmed later, and a last round only
shortens them, keeping them that far apart. Paths shorter than `d1` are left
out.
- A line that ends on another less than `2 * d1` past a crossing stops at that
crossing instead, the shorter one where both do. The path along such a stub
would be trimmed away, leaving a hole between the walls that were cut to
touch it.
Short paths enclosed by coarser lines still print as closed outlines, but most
paths run on across several cells.
+239
View File
@@ -0,0 +1,239 @@
# Precise Seam — High Level Design
## Purpose and scope
Precise Seam places the seam where a helper volume intersects the external
wall. The user attaches a mesh to an object as a Precise Seam modifier, and on
every layer the seam placer reads the modifier's slice to decide where the seam
of each external perimeter may, must or must not go. The same mesh keeps
working after the model changes, so the seam does not have to be repainted
after every design revision, and a swept helper body can guide the seam along
any path.
The modifier is non-printing geometry. It does not take part in slicing, region
assignment, filament selection or brim adhesion. It affects only seam
placement, which runs during G-code export.
## Volume types and priority
Precise Seam adds six `ModelVolumeType` values after `SUPPORT_ENFORCER`. The
strong types come first and the weak types follow. `is_precise_seam()`,
`is_precise_seam_strong()` and `is_precise_seam_weak()` are range checks that
depend on this order.
| Type | Group | Effect on the perimeter |
| --- | --- | --- |
| `PRECISE_SEAM_CENTER` | strong | seam at the arc-length midpoint of the intersection |
| `PRECISE_SEAM_LEFT` | strong | seam at the first point of the intersection |
| `PRECISE_SEAM_RIGHT` | strong | seam at the last point of the intersection |
| `PRECISE_SEAM_ENFORCED` | weak | intersection marked as enforced |
| `PRECISE_SEAM_BLOCKED` | weak | intersection marked as blocked |
| `PRECISE_SEAM_NEUTRAL` | weak | intersection reset to neutral |
A strong modifier fixes one point. A weak modifier only changes the
enforced/blocked type of seam candidates, and the configured seam position then
chooses among them. First and last are taken along the perimeter made
counter-clockwise seen from above. On an outer wall seen from outside, Left is
the left end of the intersection. On the wall of a hole seen from inside the
hole, the two ends are swapped.
The order of volumes in the object is the priority order, highest first.
`ModelObject::sort_volumes()` keeps every strong modifier before every weak one
and preserves the user's order within each group. The object list lets the user
drag a modifier only within its own group. A type change that crosses a group
boundary moves the volume to the end of its new group, where it has the lowest
priority. Strong modifiers are tried in this order, and the first one that
yields a seam on a perimeter wins. Weak modifiers are applied from the lowest
priority to the highest, so the highest one overwrites any overlapping zone.
## Model storage and 3MF compatibility
Projects must stay readable by earlier releases, and the modifier must not
change a print there. Both 3MF writers therefore store a Precise Seam volume as
an ordinary parameter modifier: `modifier_part` in the Bambu-format part
subtype, and `ParameterModifier` together with the legacy `modifier` flag in
the Prusa-format volume metadata. The seam mode is written separately under
`precise_seam_type`, using the names from `ModelVolume::type_to_string()`
(`precise_seam_center` and so on).
On load, the mode applies after all other volume metadata, regardless of XML
key order, and only when the base type is a modifier. Missing or unknown modes
leave an ordinary modifier. Seam metadata on any other base type is ignored.
Files that stored the seam mode directly as the volume type still load.
A Precise Seam volume keeps any per-volume settings it had as a part or
modifier, but they are inactive and the object list shows no settings item for
it. The writers prefix these keys with `precise_seam_config:`, so an earlier
reader drops them as unknown options. The volume therefore loads there as a
modifier without settings and has no effect on the print. The current reader
restores the keys only when the volume ends up as a Precise Seam type, so the
settings return when the user changes the type back. Configuration values are
XML-escaped in both writers, for every volume type.
## Print invalidation
`Print::apply()` compares the Precise Seam volumes of each object by type, ID
and transformation. Adding, removing, moving, reordering or retyping one
cancels background processing and invalidates only `psGCodeExport`; the sliced
layers are kept. `model_volume_list_update_supports_and_seams()` then brings
the support and Precise Seam volumes of the print's model copy in line with the
new model in one pass. A volume may switch between the two families, since
neither affects slicing. A conversion to or from a part or ordinary modifier
changes the solid and modifier volume lists and reslices as before.
## Modifier slices
`SeamPlacer::init()` collects the Precise Seam volumes of each object once:
strong ones in priority order and weak ones reversed. It slices each volume
separately with `PrintObject::slice_single_volume()`, which shares
`slice_modifier_volumes()` with support blockers and enforcers but does not
merge volumes, so each keeps its own priority. The result is cached per volume
and indexed by object layer; `Layer::id()` includes raft layers, which are
subtracted. Seam candidates are then gathered in parallel over the layers and
read the cache without locking.
Objects without Precise Seam volumes follow the unchanged seam placement path.
For objects that have them, perimeter extraction also removes consecutive
duplicate points and the repeated closing point of each extrusion loop.
Zero-length edges at path junctions would otherwise prevent point insertion
there. Distinct visits to one point of a self-touching contour are kept.
## Finding the wall segment
The seam placer works on the external perimeter loops of each layer, both
outer contours and holes, each made counter-clockwise. For every modifier
polygon on the layer that overlaps the perimeter's bounding box, the region
enclosed by the perimeter is clipped against the modifier polygon. The boundary
of each intersection polygon alternates between runs that follow the perimeter
and runs that follow the modifier outline. The wall segment is the longest
continuous run of intersection vertices that lie on the perimeter, measured in
vertices.
The fast path first finds an intersection vertex that exactly matches a
perimeter vertex. It then walks forward and backward, expecting the adjacent
perimeter vertex and falling back to projection when Clipper has merged or
split collinear edges. A vertex counts as on the perimeter when its projection
is within about 1.6 nm, which covers Clipper's rounding. If no vertex matches
exactly, or every vertex lies on the perimeter, the general path projects all
vertices. When every vertex is on the perimeter, the edge midpoints are checked
instead: a modifier chord can join two perimeter vertices directly, and the
chords split the vertex ring into runs. If no edge leaves the perimeter, the
perimeter lies entirely inside the modifier.
`Polygon::point_projection()` optionally reports the edge that holds the
projection, and every point of the segment keeps the index of its perimeter
edge. New points are inserted on that edge. A point within 1 µm of an existing
vertex snaps to that vertex instead.
## Strong modifiers
For a strong modifier, the target is the first point, the last point or the
arc-length midpoint of the segment. The midpoint is projected back onto the
original perimeter, because Clipper may have merged several perimeter edges
into one segment edge. The target is inserted into the perimeter, and a helper
point is inserted 1 µm before and after it. Strong modifiers are tried in
priority order, the first valid intersection decides the seam, and weak
modifiers are not processed for that perimeter.
When candidates are built, the inserted point is the only enforced candidate
and becomes the central enforcer; every other candidate is blocked. The seam
position modes then pick that point: Aligned and Aligned Back prefer the central
enforcer, while Back, Random and Nearest rank enforced candidates above blocked
ones. Alignment and random placement can still move the final position along an
edge. After alignment, `restore_precise_seam_positions()` writes the exact point
and its index back into every perimeter that has a strong seam. Inner walls take
their seam from the external seam as usual, including staggering.
## Weak modifiers
Weak modifiers produce one segment per intersection polygon, so one modifier can
mark several zones on one perimeter. All segment boundaries are inserted into
the perimeter in order of decreasing arc length. Each insertion then leaves the
indices of the pending, shorter ones unchanged; a point on the closing edge is
appended rather than inserted at index zero. A helper point is added 1 µm
outside each boundary. Random placement picks a position along the edge that
follows a candidate. These helpers keep that edge 1 µm long at each boundary, so
a zone cannot extend or intrude further than that. Boundaries that coincide
share their helper points.
The zone types are then resolved in priority order, and the edges of enforced
zones are subdivided into steps of at most
`SeamPlacer::enforcer_oversampling_distance` (0.2 mm). The middle candidate of
the longest enforced patch is therefore close to the geometric middle of the
zone. That patch is measured in candidates, across the closing edge, regardless
of where the contour starts; the same rule applies to painted seams.
Candidates first receive their type from seam painting. The weak zones then
overwrite it, lowest priority first. Blocked and Enforced zones therefore take
precedence over painting, and Neutral clears painting inside its zone.
## Unsupported geometry and warnings
Some modifier shapes cannot be resolved to one seam or one zone per crossing.
They are detected cheaply and reported rather than guessed:
- A strong modifier that crosses a perimeter in more than one place uses only
its first valid segment. The other crossings are ignored.
- A modifier that crosses the whole region enclosed by the perimeter is
detected when the modifier outline minus that region leaves more than one
piece, none of them a hole. Its intersection holds two wall runs, and only
one of them is used.
- A modifier whose slice has a hole on a layer, found as a clockwise polygon in
the flattened slice, is skipped on that layer. The flattened slice no longer
records which hole belongs to which contour.
- A perimeter that lies entirely inside a modifier is ignored by that modifier.
The conditions are atomic flags shared by all layers and objects. After all
objects are processed, `SeamPlacer::init()` issues at most one non-critical
warning with the ID `SlicingPreciseSeamWarning`. The warning is a single line
that lists every cause found, because the export warnings dialog shows only the
first line of each warning. Repeated warning events replace this notification
instead of appending text to it.
## User interface
- *Add Precise Seam* in the object menu creates a Center modifier from a
primitive or a loaded mesh. Text and SVG volumes cannot become Precise Seam
modifiers: the menu does not offer them, and `ObjectList::set_volume_type()`
refuses the change.
- *Change Type* has a single *Precise Seam* entry. It converts other volumes to
Center and keeps the mode of volumes that are already Precise Seam. The
*Precise Seam Type* submenu appears only when every selected item is a
Precise Seam volume, including settings rows that resolve to one. It sets the
chosen mode on all selected volumes.
- Each mode has its own icon in the object list and its own color in the 3D
view, at 60% opacity: warm oranges for the strong modes, and green, red and
gray for Enforced, Blocked and Neutral.
- Object list drops map visible rows to volume indices while skipping hidden
cut connectors, and they refresh the row-to-volume map of the object.
- Precise Seam volumes have no filament, block pasting into SLA, and are exposed
to Python plugins as `ModelVolumeType` values plus the `is_precise_seam*()`
methods.
## Implementation and verification
- [PreciseSeam.cpp](../../src/libslic3r/GCode/PreciseSeam.cpp) implements segment
detection, point insertion, weak-zone resolution and position restoration.
[SeamPlacer.cpp](../../src/libslic3r/GCode/SeamPlacer.cpp) integrates it into
candidate gathering and issues the warning.
- [Model.hpp](../../src/libslic3r/Model.hpp) defines the types and their order,
[PrintApply.cpp](../../src/libslic3r/PrintApply.cpp) handles invalidation, and
[PrintObjectSlice.cpp](../../src/libslic3r/PrintObjectSlice.cpp) slices the
modifiers. [bbs_3mf.cpp](../../src/libslic3r/Format/bbs_3mf.cpp) and
[3mf.cpp](../../src/libslic3r/Format/3mf.cpp) store them.
- [GUI_Factories.cpp](../../src/slic3r/GUI/GUI_Factories.cpp) and
[GUI_ObjectList.cpp](../../src/slic3r/GUI/GUI_ObjectList.cpp) provide the menus,
type changes and ordering.
- [Precise Seam tests](../../tests/fff_print/test_precise_seam.cpp) cover the
strong positions, including a midpoint on an existing vertex or the closing
edge. They also cover shared and coincident weak boundaries, every warning,
and the priority order.
- [Seam placer tests](../../tests/fff_print/test_seam_placer.cpp) cover
enforced-patch selection independent of the contour start, fully painted
contours, duplicate removal, and `Print::apply()` synchronization through
type changes and restored model snapshots.
- [3MF tests](../../tests/libslic3r/test_precise_seam_3mf.cpp) cover the round
trip of every mode and of inactive settings, attribute escaping, and which
metadata combinations restore a seam mode.
[Plugin tests](../../tests/slic3rutils/test_precise_seam_plugin.cpp) cover the
Python bindings.
+54 -24
View File
@@ -28,8 +28,8 @@ Per-vendor granularity is what makes the system practical:
- A vendor whose profile is bumped invalidates only its own cache. The other 60-odd
vendors keep theirs — even when the bumped vendor is the shared Orca filament
library everyone else inherits from.
- The setup wizard, which loads vendors one at a time, gets the same speedup as
startup without a second code path.
- The setup wizard loads its vendors through the same routine as startup, so it
gets the same speedup without a second code path.
- A vendor with no cache, or a broken one, costs only that vendor a parse.
A cache holds *system* presets only. User presets, project settings and modified
@@ -76,9 +76,10 @@ and the count of errors the original parse hit.
Each entry is one preset **in source form**: what its JSON sub-file states and nothing
that resolving it derives — the preset's own config diff, the name of the preset it
inherits, and the parse metadata (name, sub-path, description, instantiation, setting
and filament ids, renames). Non-instantiated base presets are stored too; the children
that inherit from them cannot resolve without them.
inherits, the names of the presets it includes, and the parse metadata (name, sub-path,
description, instantiation, setting and filament ids, renames). Non-instantiated base
presets are stored too; the children that inherit from or include them cannot resolve
without them.
**The payload names its own keys.** The dictionary holds the distinct `opt_key`s the
file uses, the `ConfigOptionType` each was written as, and the distinct enum *value
@@ -161,15 +162,20 @@ cache nothing can invalidate is worse than no cache.
Vendors load in a fixed order, because filament inheritance crosses exactly one
boundary: any vendor's filament may inherit from the shared Orca filament library,
and nothing else reaches across vendors. The library therefore goes first, alone;
every other vendor follows in parallel, resolving against it; and the results are
and nothing else reaches across vendors — an `include` is always vendor-local. Only
installing a vendor's presets crosses it; reading the vendor, from its cache or its
JSONs, needs nothing from the library. So every other vendor is read while the library
loads, each is installed against it as soon as both are done, and the results are
merged in a stable order:
```mermaid
flowchart LR
lib["1 · OrcaFilamentLibrary<br/>loaded first, synchronously"] --> par["2 · every other vendor in parallel,<br/>each into its own bundle, filaments<br/>resolving against the loaded library"] --> merge["3 · bundles merged into one,<br/>sequentially, in stable vendor order"]
lib["1 · OrcaFilamentLibrary loaded;<br/>meanwhile every other vendor read<br/>from its cache or its JSONs"] --> par["2 · every other vendor installed<br/>in parallel, each into its own bundle,<br/>filaments resolving against the library"] --> merge["3 · bundles merged into one,<br/>in one pass per collection,<br/>in stable vendor order"]
```
`PresetBundle::load_vendors` runs these steps for startup and for the setup wizard,
which hand it the vendors to load and the directory each is installed in.
Whether a vendor comes from its cache or from a parse changes nothing in that
order — both produce the same bundle, so cached and parsed vendors mix freely in
one startup.
@@ -210,14 +216,28 @@ shipped cache answered first, so the profile in `<data_dir>/system/` was never p
and its cache was never written back.
Serving from a cache is not a memory-image restore. The entries are deserialized and
then installed one by one — inheritance resolved against the presets installed before
them and the currently loaded filament library, configs flattened onto the collection
defaults, validated and registered — by the same function the JSON path calls straight
after parsing a sub-file. The two paths share everything below the parse, which is what
makes a cache-loaded bundle indistinguishable from a JSON-loaded one by construction
rather than by test coverage. Installation also rebuilds each preset's file path from
the local data directory, so a shipped cache never carries the generating machine's
paths.
then installed by `install_vendor`, the routine the JSON path hands the vendor's entries
to once it has parsed the sub-files: inheritance resolved against the presets installed
before them and the currently loaded filament library, includes layered in, configs
flattened onto the collection defaults, validated and registered. An `include` layers
what the included base states, between the parent and the preset's own keys: the base's
diff against the
default, taken when the base itself was installed and before the per-variant padding
`inherits` sees, so only what a template sets reaches the presets including it. The two
paths share everything below the parse, which is what makes a cache-loaded bundle
indistinguishable from a JSON-loaded one by construction rather than by test coverage.
Installation also rebuilds each preset's file path from the local data directory, so a
shipped cache never carries the generating machine's paths.
Installing an entry is split in two. `resolve_vendor_preset` flattens it, reading only
what is registered under the names it inherits and includes, and `commit_vendor_preset`
registers it, the only step that writes anything shared. Entries resolve across threads
in runs and commit in the order the vendor lists them. A run ends before an entry that
inherits or includes one already in it, since that one's commit registers what the
entry resolves against, so no entry in a run reads what another in it registers. An
entry's parse messages are held until it commits. The bundle, the log's parse and
install messages and the error count therefore come out as parsing and installing one
entry at a time would leave them, whatever the listing order.
App upgrades work because a cache normally survives one. Only a deliberate
`CACHE_VERSION` bump makes an installed cache unreadable, and that is handled at
@@ -250,17 +270,20 @@ the wizard caches the *derived JSON*, not another form of the inputs:
open, the wizard computes the current stamps (one version peek per vendor) and, when
they match, serves the catalog from the file — no bundle built, no preset installed.
Caching bundle inputs instead was tried and measured: rebuilding the bundle from
per-vendor caches costs ~2 s of preset installation whatever feeds it, so only
skipping the rebuild entirely wins.
per-vendor caches costs over a second of preset installation whatever feeds it, so
only skipping the rebuild entirely wins.
Any change to the set — a vendor added, removed or updated, or its cache-only
`.opc` replaced by a newer one — changes the stamps and retires the whole file;
the wizard then rebuilds the bundle vendor by vendor (per-vendor caches serving where
they cover) and writes the catalog back. Selections, region and per-open decorations
are applied downstream of the cache either way, so a served catalog is
indistinguishable from a rebuilt one. Nothing ships this file and the updater never
touches it; it is a locally written artifact, re-derived whenever stale, written
through a temp file and rename so half a cache is never readable.
the wizard then rebuilds the bundle with `PresetBundle::load_vendors`, the load
startup uses (per-vendor caches serving where they cover), and writes the catalog
back. When a vendor fails to load, the filament library included, that open falls
back to the wizard's own scan of the vendor JSONs, as when no bundle can be built,
and writes nothing. Selections, region and per-open decorations are applied
downstream of the cache either way, so a served catalog is indistinguishable from a
rebuilt one. Nothing ships this file and the updater never touches it; it is a
locally written artifact, re-derived whenever stale, written through a temp file and
rename so half a cache is never readable.
The cache lives under `<data_dir>/cache/`, not beside the vendors: everything that
scans `<data_dir>/system/` treats any `.opc` there as a vendor, so a non-vendor
@@ -374,6 +397,13 @@ enumerates only `*.json` will find no vendors at all in a packaged build.
the `CachedPreset` field list — written and read by `visit_entry` in
`PresetCacheFormat.cpp`, one list for the save, the load and the name peek alike — or
the cache's own layout or stamps, requires bumping `CACHE_VERSION` by hand.
- **Adding a kind of reference between presets**, as `inherits` and `include` are:
parse the names into `CachedPreset` (a field change, so `CACHE_VERSION` is bumped),
have `install_vendor_entries` end a run before an entry that names one already in it
and retain what the names point at, look them up only in `resolve_vendor_preset`, and
register what they point at only in `commit_vendor_preset`. The listing-order test in
`test_vendor_cache.cpp` fails for a kind the runs do not check once its fixture uses
it.
- **The dictionary indexes with a `uint16`**, so `print_config_def` may hold at most
65535 options and one cache at most 65535 distinct enum value names.
`CacheDictionary::save` throws past that, which surfaces when CI generates the
+197
View File
@@ -0,0 +1,197 @@
# Prime tower sparse layers — High Level Design
## Purpose and scope
A prime tower exists to absorb filament changes, but it is planned on every
object layer below the topmost change, not only on the layers that purge. The
layers in between carry no filament change and print nothing but a block of the
tower's own footprint to keep its top level. They are called sparse layers, and
on a print with few changes they are most of the tower: they cost time, filament
and a travel to the tower on every layer.
Two settings trade that cost against something else. `wipe_tower_no_sparse_layers`
drops them, which sinks the tower below the model. `wipe_tower_sparse_layers_combination`
merges runs of them into fewer, thicker layers, which keeps the tower level with
the model. Both are off by default, and with both off the tower prints one layer
per object layer as it always has.
The decisions belong to tower planning and G-code emission. They do not change
sliced object geometry, but they do change the emitted G-code, the filament and
time estimates, and — for the compacted case — whether a plate is printable at
all. Changing either setting invalidates the tower step.
## What a sparse layer is
`ToolOrdering::fill_wipe_tower_partitions` counts the filament changes per layer
and propagates that count downwards, so every layer below the topmost change is
marked as carrying a tower. It then fills any gap between two tower layers, so
the tower is continuous from the bed to its last purge. `wipe_tower_layer_height`
is the distance from the previous tower layer, which is the object's layer height
whenever the tower prints on every layer.
`Print::_make_wipe_tower` plans one tower layer per such object layer. A layer
whose only call keeps the current filament leaves no toolchange in the plan, and
the layer it generates is a single result whose initial and new tool are equal.
That is what `wipe_tower_layer_is_sparse` recognises, and it is the unit both
settings work on.
The plan stays one entry per tower layer in every case. The G-code emitter walks
`WipeTowerData::tool_changes` by layer index, advancing once per object layer
that carries a tower, so a planner that removed entries would silently shift
every later layer onto the wrong tower geometry. Layers that print nothing are
therefore still planned and still generated; they are marked, and the emitter
drops them.
## Shared rules
Tower planning, G-code emission and the plate validation all have to agree about
which layers print and where. They ask one set of free functions, declared beside
the tower classes, rather than each re-deriving the answer from the raw options:
- `wipe_tower_sparse_layers_skipped` — whether sparse layers are really dropped.
Smooth timelapse and clumping detection park the nozzle on the tower every
layer, so with either of them on no layer is ever dropped and the option reads
as off everywhere.
- `wipe_tower_sparse_layers_combined` — whether runs are really merged. The same
two rule it out, and so does `wipe_tower_no_sparse_layers`: dropping the layers
outright is the stronger answer to the same problem, so the two settings are
exclusive and the GUI greys out the second while the first is on.
- `wipe_tower_layer_is_sparse`, `wipe_tower_layer_is_combined_away` — per-layer
questions the emitter asks about generated results.
- `compute_compacted_wipe_tower_z` — the tower's print z per planned layer when
it is compacted.
- `combine_sparse_wipe_tower_layers` and its `combine_sparse_wipe_tower_plan`
wrapper — the merge rule, applied to either generator's plan.
Both tower generators are driven through these. `WipeTower` (Type 1, the block
tower) and `WipeTower2` (Type 2, the default) keep separate plans with the same
per-layer shape — print z, layer height, toolchanges, and a `combined_away` flag
— so one template covers both.
## Dropping sparse layers
With `wipe_tower_no_sparse_layers`, the tower only grows on layers that carry a
real change. It therefore falls one layer height behind the object for every
sparse layer, and by the top of a tall print it can sit far below the model. The
nozzle has to reach down to it at each purge.
`compute_compacted_wipe_tower_z` derives that z once, from the generated results,
so the emitter and the validator cannot disagree. Emission descends to it, but
only once the nozzle is parked over the tower: descending while still over the
model would drive the nozzle into the print, so a descent that would do that is
deferred until after the travel to the tower. Extrusions emitted without an
explicit z — the nozzle-change wipe in particular — are pulled down to the
compacted z for the same reason.
Reaching down is only safe if nothing tall stands near the tower. `Print.hpp`
carries the clearance rule: a keep-out zone grown from the tower's footprint by
the spiral z-hop envelope, and a per-object limit on how high an object may rise
near it, tiered by the nozzle cone, the head body, the rod and the lid. The same
rule serves the precise check on real extrusions, the pre-slice estimate that
feeds the plater, and the outlines the plater draws while an object is dragged,
so that the ring the user sees touches the object's outline exactly when the
check trips.
## Merging sparse layers
With `wipe_tower_sparse_layers_combination`, no layer is dropped and nothing is
compacted: the tower keeps following the object, and the nozzle never descends.
Instead a run of consecutive sparse layers prints once, on the run's last layer,
at the accumulated height of everything it covers — the same way infill
combination merges sparse infill. The layers below it in the run print nothing.
`combine_sparse_wipe_tower_plan` runs before the tower's depths are planned,
because the heights it rewrites feed the extrusion flow of every later pass. It
raises `height` in place on the layer that prints a run and sets `combined_away`
on the rest; generation then proceeds unchanged, and the flag is copied onto the
results so the emitter can drop them.
Four constraints shape the rule:
- **Whole layers only.** A tower layer is entered at the object's z, so a merged
layer has to end on an object layer boundary. The merged height is therefore a
sum of whole layer heights, never a clamped value.
- **The nozzle's maximum layer height.** A run stops growing as soon as one more
layer would pass `max_layer_height` for the nozzle printing it — three quarters
of the nozzle diameter when that is left at 0, as elsewhere in slicing. The cap
is read through the filament-to-nozzle map, since `max_layer_height` is per
nozzle while the tower indexes filaments. This is what makes the setting inert
at common layer heights: two 0.2 mm layers are 0.4 mm and do not fit under a
0.3 mm maximum, so nothing merges until the layer height is 0.15 mm or below,
or the maximum is raised.
- **A filament change purges at its own z.** A layer with a real change can
neither be merged away nor absorb the run below it, so a run always ends on its
own last sparse layer and the change above it is untouched.
- **The first layer stays on the bed.** It carries the brim and is never merged.
A run holds one filament throughout — that is what makes it sparse — so the cap
is uniform across it, and the tower reserves depth only for the purges above a
layer, so a run has one footprint and the merged layer covers exactly the area
the layers it replaces would have.
## Emission and accounting
`WipeTowerIntegration` drops a layer whose results are marked, for both settings,
through the same `ignore_sparse` path in `tool_change` and
`is_empty_wipe_tower_gcode`. A dropped layer emits no travel to the tower and no
extrusion.
Filament used is accumulated by the generators while they write, so a layer that
will be dropped must not be charged. Type 1 asks `layer_is_printed` at each of
its accumulation points; Type 2 guards the equivalent block in `finish_layer`,
which also stops a merged-away layer from adding height of its own — the layer
that prints the run carries all of it.
A merged layer is the only case where the tower's layer height differs from the
object layer it sits on, and therefore the only case where the height the
exporter already emitted for that layer is wrong for the tower. Both generators
do declare a height, but each hardcodes a tag dialect — the block tower forces
the BBL tag, the other writes the compatible one — while the G-code processor
reads only the tag its printer uses. On a non-BBL printer with a Type 1 tower the
declaration is dropped, and the merged layer is drawn and costed as a thin one.
`WipeTowerIntegration::tower_height_tag` therefore declares it at export time,
where the printer is known, and only when the tower's own G-code does not already
carry the tag that will be read. The object's height returns on the next object
path, because emission forces the processor role to the tower on any layer that
carries one.
## Constraints
A layer that prints nothing prints nothing at all, including any interface work
the tower planner scheduled there. The Type 1 block planner marks a layer as a
contact layer when a filament category stops or starts being used relative to the
layer below, and a sparse layer immediately above a change qualifies. Merging a
run, like dropping its layers, replaces that interface with the run's single
layer. Both settings are off by default for this among other reasons.
Neither setting changes what the tower is for. A plate that needs a tower on
every layer — smooth timelapse, clumping detection — gets one, and the settings
read as off rather than compacting or merging in one place and not another.
## Implementation and verification
- [WipeTower.hpp](../../src/libslic3r/GCode/WipeTower.hpp) declares the shared
rules and the plan-merging template;
[WipeTower.cpp](../../src/libslic3r/GCode/WipeTower.cpp) implements them and
the Type 1 tower, [WipeTower2.cpp](../../src/libslic3r/GCode/WipeTower2.cpp)
the Type 2 tower.
- [ToolOrdering.cpp](../../src/libslic3r/GCode/ToolOrdering.cpp) decides which
layers carry a tower at all, and
[Print.cpp](../../src/libslic3r/Print.cpp) plans it and runs the clearance
check whose rule lives in [Print.hpp](../../src/libslic3r/Print.hpp).
- [GCode.cpp](../../src/libslic3r/GCode.cpp) emits the tower, drops the layers
that print nothing, and declares a merged layer's height;
[PrintConfig.cpp](../../src/libslic3r/PrintConfig.cpp) defines the settings and
[ConfigManipulation.cpp](../../src/slic3r/GUI/ConfigManipulation.cpp) their
mutual exclusion.
- [GLCanvas3D.cpp](../../src/slic3r/GUI/GLCanvas3D.cpp) and
[PartPlate.cpp](../../src/slic3r/GUI/PartPlate.cpp) draw the compacted tower's
keep-out outlines live while the user drags.
- [Rule tests](../../tests/libslic3r/test_wipe_tower.cpp) cover the gating of
both settings, the per-layer predicates, the compacted z, the merge rule's run
flushing, height conservation, the nozzle cap and the first-layer exemption,
and the clearance geometry the plater draws.
- [Slicing tests](../../tests/fff_print/test_wipe_tower.cpp) slice a real print
and check that a run folds, that the tower still covers the object exactly
once, that a run too thin for the cap is left alone, and that a merged layer
declares its height in the tag the printer's processor reads.
+240
View File
@@ -0,0 +1,240 @@
# Printer agents
Printer agents isolate printer-specific communication from the rest of OrcaSlicer. The GUI and
`DeviceManager` operate on a shared set of printer operations and device state; a selected printer
agent implements those operations for a particular printer ecosystem. The agent boundary allows
Bambu, Moonraker-based printers, built-in integrations, and Python-provided integrations to use the
same application workflow without making the GUI understand every printer protocol.
The current boundary is an adapter boundary around the existing application contract. In particular,
some request fields and message payloads still use the Bambu-shaped representation that existing
`MachineObject` and `DeviceManager` code consumes. The printer agent is responsible for translating
that representation into the protocol spoken by its printer. This is an intentional compatibility
constraint of the current design; the interface is not yet a neutral printer protocol.
The v1 dialect migration path is deliberately narrow. `DeviceManager` currently speaks the Bambu JSON
dialect because that is the payload shape already used throughout the command and state workflow. The
v1 `OrcaPrinterAgent` also accepts that Bambu dialect. Its transport path places the small translation
needed for the target printer at `deliver_to_sink`, keeping the compatibility code at the edge rather
than spreading it through `DeviceManager` or the agent interface.
The eventual direction is for `DeviceManager` to produce an Orca JSON dialect. The Bambu agent will then
own the translation from Orca JSON to Bambu's protocol, while `OrcaPrinterAgent` can forward the Orca
payload directly to its sink. The v1 translation at `deliver_to_sink` can then be removed without
changing `DeviceManager`, the command callers, or the rest of the agent workflow.
## Components
The system has four relevant layers:
```text
GUI / DeviceManager / MachineObject
|
NetworkAgent
/ \
IPrinterAgent ICloudServiceAgent
| |
printer protocol authentication and cloud services
```
### `DeviceManager` and `MachineObject`
`DeviceManager` owns the application-facing printer workflow. It maintains `MachineObject` instances,
updates their state, filters devices for the active printer agent, and initiates operations such as
homing, temperature changes, printing, subscriptions, and camera playback.
`MachineObject` remains the shared state model used by the GUI. It does not contain the implementation
of a printer protocol. When a device is discovered or returned by a cloud query, the device is tagged
with the active `printer_agent_id`. Device lists and selected-machine operations use that tag to avoid
sending an operation through an agent that does not own the device.
### `NetworkAgent`
`NetworkAgent` is the façade used by the GUI and `DeviceManager`. It owns:
- the currently selected `IPrinterAgent`;
- the registered cloud-service instances, indexed by provider;
- callbacks shared by the active printer agent and the application;
- the forwarding methods for printer commands and cloud operations.
There is one active printer agent for the currently selected printer preset. Switching the preset
increments the machine-list generation, disconnects the old printer agent, removes its callbacks, and
installs the newly selected agent. The façade then forwards printer operations to that agent.
Cloud operations are selected separately using a provider key. `NetworkAgent` forwards a cloud request
to the matching `ICloudServiceAgent`, and forwards cloud camera operations with a device ID. The
printer agent receives a cloud-agent pointer through `set_cloud_agent()` when it is created, allowing
printer communication to obtain cloud tokens without depending on a concrete cloud implementation.
### `IPrinterAgent`
`IPrinterAgent` is the printer-facing contract. It covers:
- cloud-relay and direct-LAN message delivery;
- LAN connection, discovery, binding, and certificates;
- printer subscriptions and callbacks;
- print operations;
- filament synchronization;
- camera capability and local camera URL reporting;
- printer command methods.
Concrete built-in implementations include the Bambu wrapper, the native Orca/Moonraker path, and
other printer-agent implementations registered by the application. A printer agent may use either
the cloud agent, a direct LAN connection, or both.
### `ICloudServiceAgent`
`ICloudServiceAgent` owns authentication and services provided by a cloud backend. It covers login
state, tokens, user and printer lists, settings synchronization, model services, cloud messages, and
cloud camera operations.
Cloud camera operations are device-scoped:
- `get_camera_url(dev_id, callback)` obtains a stream URL for one device;
- `create_camera_signaling_channel(dev_id)` creates signaling for one device where the provider
supports it.
This is separate from the local camera URL exposed by `IPrinterAgent`, which is currently scoped to
the active printer agent because a normal LAN agent represents one physical printer connection.
## Agent registration and selection
`NetworkAgentFactory` maintains the printer-agent registry. Each registry entry contains an agent ID,
a display name, and a factory function. Built-in agents register during application initialization.
Python printer-agent capabilities register dynamically and contribute an agent ID and factory entry.
The selected printer preset contains the printer-agent choice. If no explicit choice is stored, the
application preserves the existing default behavior: Bambu presets select the Bambu agent and other
presets select the native Orca agent. When a preset is changed, `GUI_App` resolves the effective agent
ID, obtains the corresponding cloud agent, creates the printer agent through the registry, and installs
it in `NetworkAgent`.
The registry rejects conflicting agent IDs. This matters for Python plugins because an agent ID is the
stable identity used by presets and device ownership; two enabled plugin capabilities must not claim
the same ID.
## Message and command flow
There are two low-level message paths:
- `send_message()` publishes a command through the printer's cloud relay;
- `send_message_to_printer()` sends a command directly to the printer over the LAN path.
Both paths accept a JSON string, quality-of-service and flag values, and return the existing network
status code domain. The agent owns the conversion from that JSON contract to its native transport.
The typed `command_*` methods are the application-facing convenience layer. The five generic defaults
currently implemented by `IPrinterAgent` construct the existing JSON dialect and route through the
same message path:
| Method | Default operation |
| --- | --- |
| `command_xyz_abs()` | Send `G90` for absolute positioning |
| `command_auto_leveling()` | Send `G29` for bed leveling |
| `command_go_home()` | Use the supported homing operation or send `G28` |
| `command_set_bed()` | Use the supported bed control or send `M140` |
| `command_set_nozzle()` | Send `M104` for nozzle temperature |
These are compatibility defaults for common printer workflows, not a guarantee that every firmware
implements every command identically. An agent can override a method when its protocol needs another
operation. For example, a Klipper configuration may use `BED_MESH_CALIBRATE` instead of `G29`.
The remaining common command methods default to `ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED` because their
existing behavior is vendor-specific or has no portable implementation:
- AMS RFID refresh;
- AMS calibration;
- AMS tray selection;
- camera start;
- axis control.
The methods remain on the common interface so an agent that supports them can override them explicitly.
`sequence_id` remains part of the command contract because `DeviceManager` creates and tracks it as
the command ID.
## Device ownership and stale responses
Printer-agent ownership is represented by `printer_agent_id` on device records and `MachineObject`
instances. The active agent ID is attached when a device is discovered, returned by a cloud list, or
reused after a preset switch. Local-machine configuration also persists the agent ID so a saved LAN
device is not silently reused by an unrelated agent.
Cloud printer-list responses carry three pieces of request context added by `NetworkAgent`:
```text
provider cloud provider used for the request
agent_id active printer agent when the request was made
generation machine-list generation when the request was made
```
`DeviceManager` accepts the response only when those values still match the current provider, active
agent, and generation. This prevents a slow response from the previous preset or provider from
repopulating the current device list.
The provider mapping is currently selected by `GUI_App`: the Bambu agent maps to the Bambu cloud
provider and other agents map to the Orca cloud provider. The generation check protects that existing
selection from races; it does not make cloud-provider ownership intrinsic to an agent. Cloud-printer
ownership and the broader Orca cloud services are therefore still separate architectural concerns.
## Python printer agents
`PrinterAgentPluginCapability` implements `IPrinterAgent` directly. The live capability object is
registered with `NetworkAgentFactory` and handed out as the printer agent when its agent ID is selected.
The plugin receives the selected `ICloudServiceAgent` through `set_cloud_agent()` just like a built-in
printer agent.
Python plugins must implement the core communication and lifecycle methods required by the interface,
including agent metadata, printer connection, discovery callbacks, and the two message-send methods.
Methods that are meaningful only to a particular printer are optional overrides where the C++ base
class provides a default.
All ten `command_*` methods are available in the Python binding and in the trampoline. Their override
status is intentionally optional:
- the five generic commands use the C++ default when Python does not override them;
- the five vendor-specific commands return `NOT_SUPPORTED` unless Python supplies an implementation;
- a Python implementation can replace either behavior for its own protocol.
The Python camera binding exposes HTTP, HTTPS, RTSP, and HTTP-snapshot modes. WebRTC remains a
built-in C++ camera mode, but is not exposed as a Python mode because the current Python capability
does not provide the corresponding cloud signaling-channel contract.
## Camera playback boundary
The camera stream mode describes how a stream is obtained; it does not by itself define ownership of
the wxWidgets view that renders it. `MediaPlayCtrl` selects and tears down the active backend, while
the wx parent owns the child window or renderer. This is important because a web view, native media
control, and frame-based/WebRTC renderer have different wx window-lifetime requirements.
Cloud URL and signaling requests are routed through `NetworkAgent` to the cloud provider selected for
the device. Local URL requests are routed to the active printer agent. The distinction keeps cloud
account services device-scoped while preserving the current one-LAN-agent/one-printer model.
## Compatibility constraints
The printer-agent boundary intentionally preserves several existing application contracts:
- Bambu-shaped JSON is still the shared command representation;
- existing network status codes are reused, with Orca-specific unsupported/capability errors added
in the Orca-reserved range;
- `MachineObject` remains the shared device-state model;
- preset and local-machine data retain compatibility with the existing agent-selection behavior;
- Python plugins use the existing capability and pybind11 registration system.
The agent abstraction is therefore responsible for containing vendor differences, not for pretending
that all vendor protocols are identical. The planned Orca JSON dialect is the protocol-neutral command
model for the `DeviceManager`/agent boundary. Once it is introduced, Bambu-specific translation remains
inside the Bambu agent and the Orca agent's v1 sink adapter can be removed as a self-contained cleanup.
## Main implementation locations
- [`IPrinterAgent`](../../src/slic3r/Utils/IPrinterAgent.hpp) — printer-agent contract and generic command defaults
- [`ICloudServiceAgent`](../../src/slic3r/Utils/ICloudServiceAgent.hpp) — cloud service and per-device
cloud camera contract
- [`NetworkAgent`](../../src/slic3r/Utils/NetworkAgent.hpp) — façade and dispatch between active agents
- [`NetworkAgentFactory`](../../src/slic3r/Utils/NetworkAgentFactory.hpp) — built-in and Python agent registry
- [`DeviceManager`](../../src/slic3r/GUI/DeviceCore/DevManager.cpp) — device ownership, filtering, and
stale-response checks
- [`PrinterAgentPluginCapability`](../../src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp)
— Python bindings
- [`MediaPlayCtrl`](../../src/slic3r/GUI/MediaPlayCtrl.cpp) — camera backend selection and playback lifecycle
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
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
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
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
+12
View File
@@ -125,6 +125,7 @@ src/slic3r/GUI/ThermalPreconditioningDialog.hpp
src/slic3r/GUI/Jobs/SLAImportJob.cpp
src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp
src/slic3r/GUI/AboutDialog.cpp
src/slic3r/GUI/ActionRegistry.cpp
src/slic3r/GUI/AMSMaterialsSetting.cpp
src/slic3r/GUI/ExtrusionCalibration.cpp
src/slic3r/GUI/AmsMappingPopup.cpp
@@ -159,6 +160,7 @@ src/slic3r/GUI/SelectMachinePop.cpp
src/slic3r/GUI/StatusPanel.cpp
src/slic3r/GUI/Monitor.cpp
src/slic3r/GUI/MsgDialog.cpp
src/slic3r/GUI/NativeCommands.cpp
src/slic3r/GUI/NotificationManager.hpp
src/slic3r/GUI/NotificationManager.cpp
src/slic3r/GUI/ObjectDataViewModel.cpp
@@ -179,6 +181,8 @@ src/slic3r/GUI/PublishDialog.cpp
src/slic3r/GUI/PublishSettingsDialog.cpp
src/slic3r/GUI/SavePresetDialog.cpp
src/slic3r/GUI/Search.cpp
src/slic3r/GUI/SettingsIndex.cpp
src/slic3r/GUI/SpeedDialDialog.cpp
src/slic3r/GUI/Selection.cpp
src/slic3r/GUI/SelectMachine.cpp
src/slic3r/GUI/PrePrintChecker.cpp
@@ -209,6 +213,7 @@ src/slic3r/Utils/Process.cpp
src/libslic3r/GCode.cpp
src/libslic3r/GCodeWriter.cpp
src/libslic3r/GCode/ToolOrdering.cpp
src/libslic3r/GCode/SeamPlacer.cpp
src/libslic3r/ExtrusionEntity.cpp
src/libslic3r/Flow.cpp
src/libslic3r/Format/AMF.cpp
@@ -246,6 +251,7 @@ src/slic3r/GUI/TroubleshootDialog.cpp
src/slic3r/Utils/3DPrinterOS.cpp
src/slic3r/Utils/AstroBox.cpp
src/slic3r/Utils/Duet.cpp
src/slic3r/Utils/UltiMaker.cpp
src/slic3r/Utils/FlashAir.cpp
src/slic3r/Utils/MKS.cpp
src/slic3r/Utils/Moonraker.cpp
@@ -291,3 +297,9 @@ src/slic3r/GUI/PrinterWebViewHandler.cpp
src/slic3r/GUI/AMSDryControl.cpp
src/slic3r/GUI/AMSDryControl.hpp
src/libslic3r/PresetBundle.cpp
src/slic3r/GUI/CAD/DesignPanel.cpp
src/slic3r/GUI/CAD/SketchInlineEditor.cpp
src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp
src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp
src/slic3r/GUI/KeyChord.cpp
src/slic3r/GUI/Shortcuts.cpp
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
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
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
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
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14.5,1.5v12a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1V1.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1Z" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><polyline points="4,5 6.5,7.5 4,10" style="fill:none;stroke:#009688;stroke-linecap:round;stroke-linejoin:round"/><line x1="8" y1="10" x2="11" y2="10" style="fill:none;stroke:#009688;stroke-linecap:round;stroke-linejoin:round"/></svg>

After

Width:  |  Height:  |  Size: 524 B

+14
View File
@@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_23434_47713)">
<circle cx="9.32187" cy="4.2125" r="0.9" fill="#009688"/>
<circle cx="3.65625" cy="8.75" r="0.75" fill="#009688"/>
<path d="M8 0.5C12.0195 0.5 15.2656 3.35278 15.4824 6.74512L15.4932 7.0752C15.4914 9.23822 13.7134 11.0155 11.5498 11.0156H9.95312C9.71275 11.0126 9.4739 11.0574 9.25098 11.1475C9.0257 11.2386 8.82126 11.3741 8.64941 11.5459C8.47744 11.7179 8.34113 11.9229 8.25 12.1484C8.1817 12.3176 8.13909 12.4957 8.12402 12.6768L8.11816 12.8496C8.11817 13.3362 8.27448 13.7495 8.59473 14.0801V14.0811C8.73012 14.2338 8.81831 14.4375 8.81836 14.6494C8.81836 15.1395 8.45218 15.5 8 15.5C3.87614 15.5 0.5 12.1239 0.5 8C0.5 3.87614 3.87614 0.5 8 0.5Z" stroke="#949494" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="5.17031" cy="5.11172" r="1.35" fill="#009688"/>
<circle cx="12.1578" cy="7.10313" r="1.15" fill="#009688"/>
</g>
<defs>
<clipPath id="clip0_23434_47713">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17 A11 11 0 0 1 20 17"/><circle cx="4" cy="17" r="1.5" fill="#b6b6b6" stroke="none"/><circle cx="12" cy="7" r="1.5" fill="#b6b6b6" stroke="none"/><circle cx="20" cy="17" r="1.5" fill="#b6b6b6" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 406 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><path d="M5 18 A13 13 0 0 1 18 5"/><path d="M5 5 5 18M5 5 18 5" stroke-dasharray="2.5 2.5" opacity="0.5"/><circle cx="5" cy="5" r="2" fill="#b6b6b6" stroke="none"/><circle cx="5" cy="18" r="1.5" fill="#b6b6b6" stroke="none"/><circle cx="18" cy="5" r="1.5" fill="#b6b6b6" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 472 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="6" height="6"/><rect x="15" y="3" width="6" height="6"/><rect x="3" y="15" width="6" height="6"/><rect x="15" y="15" width="6" height="6"/></svg>

After

Width:  |  Height:  |  Size: 350 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="6"/>
<circle cx="15" cy="12" r="6"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><path d="M3 19 C 6 5, 12 5, 12 12 S 18 19, 21 5"/><circle cx="3" cy="19" r="1.5" fill="#b6b6b6" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="#b6b6b6" stroke="none"/><circle cx="21" cy="5" r="1.5" fill="#b6b6b6" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 420 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16M4 20 16 6"/><path d="M11 20 A7 7 0 0 0 9.1 15.2"/></svg>

After

Width:  |  Height:  |  Size: 254 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b6b6b6" stroke-width="0.85" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2.2" fill="#b6b6b6" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 279 B

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