Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f05d3060 | ||
|
|
4743d00793 | ||
|
|
2f4758446a | ||
|
|
c023632a7d | ||
|
|
b102ae3aaa | ||
|
|
1046851b50 | ||
|
|
03377a0242 | ||
|
|
e7c0e2cd82 | ||
|
|
d1d14329d9 | ||
|
|
236a8786ef | ||
|
|
92d30fbc55 | ||
|
|
3384daa6bc | ||
|
|
6842d9c778 | ||
|
|
1a5bc8982d | ||
|
|
8aa5b1130a | ||
|
|
ba361d9882 | ||
|
|
2a9cb32c1f | ||
|
|
059e171954 | ||
|
|
d849b30906 | ||
|
|
865e9963c3 | ||
|
|
c278de3b10 | ||
|
|
35a4d941b8 | ||
|
|
e1da47a72b | ||
|
|
c03f4925a9 | ||
|
|
ffbbd62355 | ||
|
|
0028e65763 | ||
|
|
e493289b62 | ||
|
|
a5413efc37 | ||
|
|
8d69fa3e5a | ||
|
|
97700c5ab5 | ||
|
|
ff2f42016a | ||
|
|
bd4306e8f8 | ||
|
|
9abad9619d | ||
|
|
2d1e6d5b96 | ||
|
|
09184ac5a4 | ||
|
|
315df5750f | ||
|
|
1e39a36a25 | ||
|
|
36ebe0cde5 | ||
|
|
7c0a3ab916 | ||
|
|
31e7dc0845 | ||
|
|
94266c2819 | ||
|
|
dc0e269186 | ||
|
|
1e4489eb16 | ||
|
|
5d49423faa | ||
|
|
da0611a301 | ||
|
|
4da478c7ad | ||
|
|
561737f407 | ||
|
|
7d42ad17a4 | ||
|
|
e727caed18 | ||
|
|
d58c3b0d89 | ||
|
|
0eb6e6814e | ||
|
|
9590d71fd9 | ||
|
|
789f848694 | ||
|
|
3a0694dce6 | ||
|
|
f5679ad343 | ||
|
|
2769b12ce7 | ||
|
|
203bc63f35 | ||
|
|
e40030cf81 | ||
|
|
ba468c842d | ||
|
|
afaa94b3bf | ||
|
|
dd9b5dc0d4 | ||
|
|
e68694dbaf | ||
|
|
1504bd7153 | ||
|
|
b5022dd454 | ||
|
|
7cbea5f454 | ||
|
|
a46e29c21e | ||
|
|
ea49b4851e | ||
|
|
d87fe1b290 | ||
|
|
72cfe71b81 | ||
|
|
6fcfe6a675 | ||
|
|
50eea48408 | ||
|
|
ef0c656932 | ||
|
|
6f62fe374c | ||
|
|
75b6175988 | ||
|
|
46fb512690 | ||
|
|
8ffd3e514e | ||
|
|
f6514b798a | ||
|
|
7d8318f275 | ||
|
|
9688f0ae62 | ||
|
|
79afb020db | ||
|
|
b910857c18 | ||
|
|
45bc39a47a | ||
|
|
d35ea27ea5 | ||
|
|
490d134507 | ||
|
|
41eeaf3883 | ||
|
|
11e9e07f20 | ||
|
|
9f34f37c27 | ||
|
|
9d8257d2eb | ||
|
|
185cfe4323 | ||
|
|
78fb4f767a | ||
|
|
05da6bbcc8 | ||
|
|
0a55e6ef83 | ||
|
|
1846407e93 | ||
|
|
52679ea1bc | ||
|
|
d48e63b5e3 | ||
|
|
7a2daaa351 | ||
|
|
08f086daf3 | ||
|
|
293aa3e0ed | ||
|
|
faeb84da72 | ||
|
|
c30c9beb09 | ||
|
|
77f8c64d37 | ||
|
|
8a254eb23f | ||
|
|
00a2c5a087 | ||
|
|
71bb400e46 | ||
|
|
2ccabb5695 | ||
|
|
576cce2f72 | ||
|
|
f3a8f711fd | ||
|
|
00bde9265b | ||
|
|
7707487252 | ||
|
|
3df9c215f8 | ||
|
|
cda1588578 | ||
|
|
ec0d8c225f | ||
|
|
3040ebaac1 | ||
|
|
5298e49dd2 | ||
|
|
2fcf2222b6 | ||
|
|
7ee64d83bb | ||
|
|
d8ca9aa3b3 | ||
|
|
d347a80ef8 | ||
|
|
e340a13c18 | ||
|
|
9e16cdb23b | ||
|
|
58842bab05 | ||
|
|
04204ec0d3 | ||
|
|
d65f97a535 | ||
|
|
ae38570562 | ||
|
|
7766c2e003 | ||
|
|
9ed459e132 | ||
|
|
517286c93d | ||
|
|
14ee1e3d3a | ||
|
|
c4a19bff1d | ||
|
|
4504f315ee | ||
|
|
56092f0367 | ||
|
|
944f01c68a | ||
|
|
b3216b7f6a | ||
|
|
9a2eadf9cc | ||
|
|
3f5faa75a7 | ||
|
|
1b029ff8fa | ||
|
|
0a543644ef | ||
|
|
7fd12ed1ee | ||
|
|
9327e7770b | ||
|
|
c5bbb6e031 | ||
|
|
6e88ad7f52 | ||
|
|
3023ca0d38 | ||
|
|
61b503ca3a | ||
|
|
e46eecc716 | ||
|
|
d3485e8b63 | ||
|
|
5baefd8a4e | ||
|
|
c9dbf6fdbd | ||
|
|
b3963b2a49 | ||
|
|
2f05dbf577 | ||
|
|
cfde702d09 | ||
|
|
211dd7daaa | ||
|
|
303efacec0 | ||
|
|
ae7bf43fde | ||
|
|
8f853c0e22 | ||
|
|
7aef3d1215 | ||
|
|
cef6527f9d | ||
|
|
734dc1f9ac | ||
|
|
533b68ed9f | ||
|
|
30b3666c47 | ||
|
|
19277f44ad | ||
|
|
e9b9a79815 | ||
|
|
3baec9cf2b | ||
|
|
b4aa57fe2c | ||
|
|
2159ab3f6d | ||
|
|
56561242a7 | ||
|
|
b5ae632bd7 | ||
|
|
a0430631b8 | ||
|
|
f3d197c4ca | ||
|
|
78f873a27f | ||
|
|
bc28052245 | ||
|
|
f802208d4c | ||
|
|
45d10341c1 | ||
|
|
8bf80a2a46 | ||
|
|
60c03e706a | ||
|
|
08fa489335 | ||
|
|
4a48fc770d | ||
|
|
2ae1e09dc2 | ||
|
|
ae45d7b78a | ||
|
|
cb4a90402f | ||
|
|
e4c570a7b7 | ||
|
|
ca52c08317 | ||
|
|
3aa48abcb7 | ||
|
|
faa0d9a44a | ||
|
|
8434223f96 | ||
|
|
b753cf2216 | ||
|
|
7b98433dd7 | ||
|
|
0df1424101 | ||
|
|
23b6020344 | ||
|
|
d9f679d154 | ||
|
|
5cae3337a7 | ||
|
|
39bfceca6d | ||
|
|
219cddf40e | ||
|
|
129b612af5 | ||
|
|
61d2d4355a | ||
|
|
ab023f3f6d | ||
|
|
9f1722ed3b | ||
|
|
8247514ae2 | ||
|
|
00f55639d3 | ||
|
|
68d754d946 | ||
|
|
dc5a48bdfd | ||
|
|
05083bb6ab | ||
|
|
a393b21642 | ||
|
|
a7c8dcc58d | ||
|
|
3514249197 | ||
|
|
7f2598d0d6 |
@@ -1,78 +1,127 @@
|
|||||||
---
|
---
|
||||||
name: orca-profiles
|
name: orca-profiles
|
||||||
description: Use when creating, modifying, reviewing or debugging OrcaSlicer FFF system profiles under resources/profiles, including printer/vendor/nozzle/material additions, bundle indexes and versions, preset renames, setting_id and filament_id. Also use for missing presets or vendors, ignored profile settings, ambiguous AMS filament matches, and failures from orca_profile_tool.py, check_profile.sh/.bat, OrcaSlicer_profile_validator or the Check profiles CI job.
|
description: Use when creating, modifying, reviewing or debugging OrcaSlicer FFF system profiles under resources/profiles, including printer/vendor/nozzle/material additions, bundle indexes and versions, preset renames, setting_id and filament_id, and moving settings that sibling presets repeat onto shared bases after fix-variant or while drafting. Also use for missing presets or vendors, ignored profile settings, ambiguous AMS filament matches, and failures from orca_profile_tool.py, check_profile.sh/.bat, OrcaSlicer_profile_validator or the Check profiles CI job.
|
||||||
---
|
---
|
||||||
|
|
||||||
# OrcaSlicer system profiles
|
# OrcaSlicer system profiles
|
||||||
|
|
||||||
A bundle is `resources/profiles/<Vendor>.json` plus `<Vendor>/`. The vendor id is the
|
This skill describes how OrcaSlicer system profiles are drafted and shaped: the rules, equations and
|
||||||
filename stem, not the index's display `name`. The index is the loader's only entry point:
|
patterns a profile follows. Use it to draft new profiles, modify existing ones, fix profile issues and
|
||||||
unindexed presets never load. `OrcaFilamentLibrary` is the shared filament bundle;
|
review profile changes.
|
||||||
`blacklist.json` is data, not a bundle.
|
|
||||||
|
|
||||||
## Choose the reference for the task
|
A bundle is the index `resources/profiles/<Vendor>.json` plus the folder `<Vendor>/`. The vendor id is
|
||||||
|
the filename stem (`BBL`), not the index's display `name` (`Bambulab`). The index is the loader's only
|
||||||
|
entry point: an unindexed preset never loads. `OrcaFilamentLibrary` is the shared filament bundle,
|
||||||
|
loaded first; `blacklist.json` is data, not a bundle.
|
||||||
|
|
||||||
Read the relevant reference before editing; load others only when the task crosses those areas.
|
## References
|
||||||
Paths below are relative to this skill. Commands run from the repository root.
|
|
||||||
|
Read the reference for the task before editing; load others only when the task crosses into them.
|
||||||
|
Paths below are relative to this skill. Commands run from the repository root; on Windows use `py -3`
|
||||||
|
for `python3`.
|
||||||
|
|
||||||
| Task | Read |
|
| Task | Read |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Add or tune a filament, brand or material; fix compatibility / alias shadowing | [filament-profiles.md](references/filament-profiles.md) |
|
| Add or tune a filament, brand or material; fix compatibility, alias shadowing or overlapping coverage | [filament-profiles.md](references/filament-profiles.md) |
|
||||||
| Add a printer or nozzle; change models, variants, assets or extruder vectors | [machine-profiles.md](references/machine-profiles.md) |
|
| Add a printer or nozzle; change models, variants, assets or per-extruder vectors | [machine-profiles.md](references/machine-profiles.md) |
|
||||||
|
| Add or tune extruder variants (`extruder_type` Direct Drive / Bowden × nozzle volume type Standard / High Flow / TPU High Flow / E3D High Flow / Extra High Flow variants) on a printer, process or filament | [extruder-variants.md](references/extruder-variants.md) |
|
||||||
| Add a quality tier or tune a process | [process-profiles.md](references/process-profiles.md) |
|
| Add a quality tier or tune a process | [process-profiles.md](references/process-profiles.md) |
|
||||||
| Create a vendor bundle; diagnose loading or inheritance; migrate preset names | [vendor-bundle.md](references/vendor-bundle.md) |
|
| Draft several presets, or clean up after `fix-variant`: which base each shared value belongs on, when a new base pays off, proving nothing loads differently | [shared-bases.md](references/shared-bases.md) |
|
||||||
| Name a preset; check what a name must equal | [naming.md](references/naming.md) |
|
| Name a preset; check what a name must equal; base names, uniqueness, filenames | [naming.md](references/naming.md) |
|
||||||
|
| Create a vendor bundle; index, `version`, `inherits`, `include`; migrate preset names; diagnose why a bundle fails to load | [vendor-bundle.md](references/vendor-bundle.md) |
|
||||||
| Change ids; diagnose AMS identity | [ids.md](references/ids.md), then `docs/HLSD/filament_id.md` for identity changes |
|
| Change ids; diagnose AMS identity | [ids.md](references/ids.md), then `docs/HLSD/filament_id.md` for identity changes |
|
||||||
| Review a profile diff | [review-checklist.md](references/review-checklist.md) |
|
|
||||||
| Run checks, interpret failures, test another tree or verify in the app | [validation.md](references/validation.md) |
|
| Run checks, interpret failures, test another tree or verify in the app | [validation.md](references/validation.md) |
|
||||||
|
| Review a profile diff | [review-checklist.md](references/review-checklist.md) |
|
||||||
|
|
||||||
## Golden rules
|
## Rules
|
||||||
|
|
||||||
1. **Bump every changed bundle's `version`**, including `OrcaFilamentLibrary.json` when affected.
|
1. **Bump the `version` of every bundle you change**, `OrcaFilamentLibrary.json` included when affected.
|
||||||
Increment the last component; carry `.99` into the third component (`02.04.00.99` →
|
Increment the last component and carry `.99` into the third (`02.04.00.99` → `02.04.01.00`). The
|
||||||
`02.04.01.00`). The updater requires a strictly newer version. CI does not check this.
|
updater installs only a strictly newer version, and CI does not check the bump.
|
||||||
2. **Register every preset, bases included, parents before children.** `update-index` generates
|
2. **Register every preset, bases included, parents before children.** `update-index` writes the four
|
||||||
the four `*_list` arrays; `check` requires its output. Index names must equal file `name` fields.
|
`*_list` arrays from the files on disk; `check` fails unless the index equals its output. Each index
|
||||||
3. **Generate ids; never invent or copy them.** Keep existing ids during ordinary tuning. New
|
entry's `name` must equal the file's `name`.
|
||||||
presets normally omit them until `generate-id`; bases must have no `setting_id`.
|
3. **Generate ids; never invent or copy them.** Keep existing ids during ordinary tuning. New presets
|
||||||
BBL's authoritative `setting_id` and a wrongly inherited `filament_id` need the explicit
|
normally omit them until `generate-id`; bases carry no `setting_id`. BBL's own `setting_id`s and a wrongly
|
||||||
handling in [ids.md](references/ids.md).
|
inherited `filament_id` need the explicit handling in [ids.md](references/ids.md).
|
||||||
4. **Load failures can discard a whole vendor bundle.** Broken `inherits`, missing indexed files,
|
4. **A name is an identity; preserve shipped selectable names.** Every reference (`inherits`,
|
||||||
duplicate names, invalid model/variant references and unresolved filament ids affect more than
|
`compatible_printers`, `default_*`, `printer_model`) is the exact, case-sensitive `name`. Renaming or
|
||||||
the edited preset. Inheritance stays within a bundle, except filaments may inherit the library.
|
deleting a shipped selectable preset, or flipping its `instantiation` from `"true"` to `"false"`,
|
||||||
5. **Preserve shipped selectable names.** Renaming, deleting or changing `instantiation` from
|
needs `renamed_from` (a `;`-separated string) on a selectable successor
|
||||||
`"true"` to `"false"` needs `renamed_from` on a selectable successor. It is a `;`-separated string;
|
([migration rules](references/vendor-bundle.md#renamed_from)); update in-tree references too.
|
||||||
update in-tree references too. See [migration rules](references/vendor-bundle.md#renamed_from).
|
5. **Values are strings or arrays of strings.** `"instantiation": "false"`, never `false`. A
|
||||||
6. **Compatibility uses exact printer variant names.** Every instantiated non-library filament
|
`machine_model`'s `nozzle_diameter` is a `;`-separated string; a `machine`'s is an array. Custom
|
||||||
needs a non-empty `compatible_printers` in its own file. Library fallbacks may omit it;
|
G-code is one string. Wrong types can abort loading of the bundle or of every vendor
|
||||||
library printer-specific tunes use a non-empty list. One variant may be claimed by only one
|
([failure scopes](references/vendor-bundle.md#failure-scopes)).
|
||||||
profile per filament product (`filament_id`); an overlap is resolved by moving the variant to the
|
6. **Unknown keys are dropped silently.** Confirm every new key exists in
|
||||||
most specific preset, which is preferred over deleting a profile. See
|
`src/libslic3r/PrintConfig.cpp`; a key a neighbouring file writes is no evidence it exists. `check` rejects,
|
||||||
[one variant, one profile](references/filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product).
|
and `normalize` removes, known obsolete keys, but neither detects an arbitrary misspelling. A key
|
||||||
7. **Preset values are strings or arrays of strings.** Use `"instantiation": "false"`, not `false`.
|
missing from the definitions may be a legacy name the loader still renames
|
||||||
Model `nozzle_diameter` is a `;`-separated string; machine `nozzle_diameter` is an array.
|
(`tool_change_gcode` → `change_filament_gcode`) or whose value it rewrites (`DirectDrive` →
|
||||||
Wrong types can abort loading; see [failure scopes](references/vendor-bundle.md#failure-modes-ranked-by-blast-radius).
|
`Direct Drive`); check `PrintConfigDef::handle_legacy` before removing one, and write the current name
|
||||||
8. **Verify setting keys against the code.** Unknown keys are silently discarded. Check
|
in new edits.
|
||||||
`PrintConfig.cpp` definitions and `PrintConfigDef::handle_legacy`; neighbours can contain dead
|
7. **Write overrides only.** Inherit the bundle's bases and restate just what differs; follow the
|
||||||
keys. `normalize` removes known obsolete keys, but does not detect arbitrary misspellings.
|
bundle's existing layering and style, except that a new filament prefers the library's bases. A
|
||||||
9. **Run the full profile checks before reporting completion.** A vendor-scoped pass is only a
|
value that every preset of a group shares goes on the group's base
|
||||||
development loop. Review also covers version bumps, assets, non-default processes and hardware
|
([shared bases](references/shared-bases.md)).
|
||||||
tuning that CI cannot establish.
|
8. **One load error can discard a whole vendor bundle**: an unresolved `inherits`, a missing indexed
|
||||||
10. **One all-printer preset per product; color is a runtime property, never a preset.** Never ship
|
file, two selectable presets with one name, an unknown `printer_model` or `printer_variant`, a
|
||||||
presets that differ only by color — CI accepts them, so this is a review call. See
|
filament with no resolvable `filament_id`, `nil` in a non-nullable key. `inherits` and `include`
|
||||||
[color is a runtime property](references/filament-profiles.md#color-is-a-runtime-property).
|
resolve only inside the bundle, except that filaments may inherit from OrcaFilamentLibrary.
|
||||||
|
9. **Filament compatibility names exact printer variants.** Every instantiated filament outside the
|
||||||
|
library writes a non-empty `compatible_printers` in its own file. Library fallbacks may omit it;
|
||||||
|
library printer-specific tunes use a non-empty list. One variant may be claimed by only one preset
|
||||||
|
per filament product (`filament_id`); an overlap is resolved by moving the variant to the most
|
||||||
|
specific preset, which is preferred over deleting a preset
|
||||||
|
([one variant, one profile](references/filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)).
|
||||||
|
10. **One all-printer preset per product; colour is a runtime property, never a preset.** Never ship
|
||||||
|
presets that differ only by colour. CI accepts them, so this is a review call
|
||||||
|
([colour](references/filament-profiles.md#colour-is-a-runtime-property)).
|
||||||
|
11. **Write a variant key at full width or not at all.** A key in the four variant sets holds exactly
|
||||||
|
`N` values in the selectable preset that writes it, `N = S × k` in the
|
||||||
|
[sizing equation](references/extruder-variants.md#sizing-equation): one per variant, a (normal,
|
||||||
|
silent) pair per variant for the `machine_max_*` limits; one value is not "the same for every
|
||||||
|
variant". Profiles must be correct as written: `check` judges each selectable preset by the
|
||||||
|
equation, never by what the loader pads or cuts, and `check --strict` also holds what a preset
|
||||||
|
inherits to its own width, as BBL writes it. A base is never judged on its own: its array widths
|
||||||
|
count, under `--strict`, where they reach a preset, while the id and layout rules judge the
|
||||||
|
composed preset, inherited values included, without it. Declare the variant layout on a
|
||||||
|
multi-extruder printer whose extruders need different values
|
||||||
|
([widths](references/extruder-variants.md#widths)).
|
||||||
|
12. **Run the full checks before reporting completion.** A `--vendor` run is only a development loop.
|
||||||
|
Review also covers version bumps, assets, non-default processes and hardware tuning, which CI cannot
|
||||||
|
establish.
|
||||||
|
|
||||||
|
## Names
|
||||||
|
|
||||||
|
| Type | Shape | What the loader uses |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `machine_model` | `<Model>` (`Bambu Lab X1 Carbon`) | the exact string, named by each variant's `printer_model`; also the `<Model>_cover.png` stem |
|
||||||
|
| `machine` | `<Model> <nozzle> nozzle` | the exact string, named by `compatible_printers`; `printer_variant` holds the nozzle token (`0.4`, [rules](references/machine-profiles.md#printer_model-and-printer_variant)) |
|
||||||
|
| `process` | `<lh>mm <Quality> @<target>` | the exact string when referenced or selected; `@<target>` is a label, compatibility comes from the preset's list or condition |
|
||||||
|
| `filament` | `<Product> @<target>` | text before the first `@` is the alias (shadowing, `filament_id`); the rest is a label, with reserved targets `@base` and `@System` |
|
||||||
|
|
||||||
|
The shapes are convention; `check` enforces only uniqueness. Per-type conventions, base names and
|
||||||
|
filename rules are in [naming.md](references/naming.md).
|
||||||
|
|
||||||
## Creating or modifying a profile
|
## Creating or modifying a profile
|
||||||
|
|
||||||
1. **Inspect the diff and neighbouring presets.** Read their `name`, parent chain and children;
|
1. **Inspect the diff and the neighbouring presets.** Read their `name`, parent chain and children:
|
||||||
edits to a base or a leaf with descendants propagate. Match the bundle's structure and write
|
edits to a base, or to a leaf that others inherit, propagate. Match the bundle's structure and write
|
||||||
only overrides. New files use tab indentation, LF and a trailing newline; preserve unrelated
|
only overrides. New files use tab indentation, LF and a trailing newline; preserve unrelated
|
||||||
formatting in existing files. Match filename case exactly and use cross-platform names.
|
formatting in existing files. Match filename case exactly and use
|
||||||
2. **Author explicit metadata.** Set `type` yourself, especially for `machine` vs `machine_model`.
|
[cross-platform names](references/naming.md#filenames-and-paths).
|
||||||
Use `"from": "system"` and string `instantiation` on config presets. Omit ids on new presets
|
2. **Author explicit metadata.** Set `type` yourself (`machine` vs `machine_model` especially), and use
|
||||||
unless [ids.md](references/ids.md) requires special handling; retain them on existing ones.
|
`"from": "system"` and a string `instantiation` on config presets. Omit ids on new presets unless
|
||||||
Complete compatibility, defaults, assets and any rename migration using the task reference.
|
[ids.md](references/ids.md) requires special handling; retain them on existing ones. Complete
|
||||||
3. **Bump the version**, then run the authoring commands in order for each affected bundle:
|
compatibility, defaults, assets and any rename migration using the task reference.
|
||||||
|
3. **Put shared values on shared bases** when drafting several presets, and after `fix-variant`, which
|
||||||
|
widens an array in every preset that writes it and moves nothing. Each value goes on the base of the
|
||||||
|
level that determines it, a new base only where it pays for itself, and a restructure must leave
|
||||||
|
every selectable preset loading what it loaded: `snapshot` before the edit, `compare` after it
|
||||||
|
([shared-bases.md](references/shared-bases.md)).
|
||||||
|
4. **Bump the version**, then run the authoring commands in order for each affected bundle, reading
|
||||||
|
every diff and resolving every error before moving on:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/orca_profile_tool.py normalize --vendor "<Vendor>"
|
python3 scripts/orca_profile_tool.py normalize --vendor "<Vendor>"
|
||||||
@@ -81,46 +130,59 @@ Paths below are relative to this skill. Commands run from the repository root.
|
|||||||
python3 scripts/orca_profile_tool.py check
|
python3 scripts/orca_profile_tool.py check
|
||||||
```
|
```
|
||||||
|
|
||||||
Writing commands support `--dry-run`. Inspect their diffs: `normalize` changes content and can
|
Writing commands accept `--dry-run`. `normalize` changes content and can reformat entire files;
|
||||||
reformat entire files. Stop and resolve command errors before proceeding.
|
rerun `update-index` after any change to `inherits` or `include`, since it orders by them.
|
||||||
|
**Do not use `trim` in this workflow:** it deletes unindexed files, including one you just added. Do
|
||||||
**Do not use `trim` in this workflow:** it can delete newly authored, unindexed profiles.
|
not use `normalize --force` for routine edits. An error in a bundle you did not touch predates your
|
||||||
Do not use `normalize --force` for routine edits.
|
change: confirm it on a clean checkout and report it rather than fixing it in the same change.
|
||||||
4. **Validate:**
|
5. **Validate:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/check_profile.sh --vendor "<Vendor>" # development loop
|
./scripts/check_profile.sh --vendor "<Vendor>" # development loop
|
||||||
./scripts/check_profile.sh # full tree before the PR
|
./scripts/check_profile.sh # full tree before the PR
|
||||||
```
|
```
|
||||||
|
|
||||||
On Windows use `py -3` instead of `python3`, and `scripts\check_profile.bat -Vendor "<Vendor>"`
|
On Windows use `scripts\check_profile.bat -Vendor "<Vendor>"` / `scripts\check_profile.bat`. Logs
|
||||||
/ `scripts\check_profile.bat`. Logs land in a per-user cache dir (see
|
land in a per-user cache dir ([validation.md](references/validation.md)). Id checks stay tree-wide
|
||||||
[validation.md](references/validation.md)).
|
under `--vendor`, and filament-only bundles skip the default slice check. Under `--vendor` read only
|
||||||
Id checks remain tree-wide under `--vendor`; filament-only bundles skip the default slice check.
|
`profile_tool` and `validate_slice`: the other three checks fail on library presets that name other
|
||||||
See [validation.md](references/validation.md) for flags, coverage and error remedies.
|
vendors' printers ([why](references/validation.md#the-five-checks)).
|
||||||
5. **Verify the changed behavior.** Slice newly added non-default processes explicitly, and
|
6. **Verify the changed behaviour.** Slice newly added non-default processes and filaments
|
||||||
[test in the app](references/validation.md#testing-in-the-app) for selection or UI behavior.
|
[explicitly](references/validation.md#checking-a-copy-of-the-tree), and
|
||||||
Report checks actually run, failures/skips and any hardware tuning still unverified.
|
[test in the app](references/validation.md#testing-in-the-app) for selection or UI behaviour. Report
|
||||||
|
the checks actually run, their failures and skips, and any hardware tuning still unverified.
|
||||||
|
|
||||||
## Symptom → first reference
|
## Symptom → first look
|
||||||
|
|
||||||
| Symptom | Start here |
|
| Symptom | Start here |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| A vendor disappears | Loader log / `validate_system`; [bundle failure scopes](references/vendor-bundle.md#failure-modes-ranked-by-blast-radius) |
|
| A vendor disappears | the app's log or the `validate_system` log; [failure scopes](references/vendor-bundle.md#failure-scopes) |
|
||||||
| A setting has no effect | Key spelling/type, `handle_legacy`, or a config key placed on a `machine_model` |
|
| A setting has no effect | key spelling or a legacy name (`PrintConfigDef::handle_legacy`), value type, or a config key placed on a `machine_model` |
|
||||||
| A preset exists but is not selectable | Index registration, `instantiation`, installation and compatibility |
|
| A preset exists but is not selectable | index registration, `instantiation`, whether it is installed (chosen in the setup wizard, or listed in the model's `default_materials`), compatibility |
|
||||||
| A filament is missing, duplicated, or matches the wrong spool | [Compatibility and alias shadowing](references/filament-profiles.md#compatible_printers); [ids](references/ids.md) |
|
| A filament is missing, duplicated, or matches the wrong spool | [compatibility and alias shadowing](references/filament-profiles.md#compatible_printers), [ids](references/ids.md) |
|
||||||
| Presets differ only by color, or an all-printer library preset lacks `@System` | [Color is a runtime property](references/filament-profiles.md#color-is-a-runtime-property) |
|
| Presets differ only by colour, or an all-printer library preset lacks `@System` | [colour is a runtime property](references/filament-profiles.md#colour-is-a-runtime-property) |
|
||||||
| A bed temperature is ignored | [Plate-specific temperature keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) |
|
| High Flow (or a second extruder) slices with Standard (or extruder 1) values; a variant switch is missing; a variant's tuned values never arrive | [extruder variants](references/extruder-variants.md#variant-strings), [widths](references/extruder-variants.md#widths), [variant names](references/validation.md#variant-names) |
|
||||||
| A change is absent from the running app | Version bump and [installed profile location](references/validation.md#testing-in-the-app) |
|
| Values land on the wrong extruder or mode after a variant was added | [inserting a variant](references/extruder-variants.md#adding-a-variant-inserts-its-values-at-its-variant-index), [padding and composition](references/extruder-variants.md#padding-truncation-and-composition) |
|
||||||
| A check fails | [Error → remedy](references/validation.md#error--remedy) |
|
| A resolved value matches neither the file nor its `inherits` parent; an array is not the width the file wrote, or a child got only a base's first value | [composition order and `include`](references/vendor-bundle.md#inherits-and-include) |
|
||||||
|
| A bed temperature is ignored | [the twelve plate keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) |
|
||||||
|
| A change is absent from the running app | version bump and [installed profile location](references/validation.md#testing-in-the-app) |
|
||||||
|
| A check fails | [error → remedy](references/validation.md#error--remedy) |
|
||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
When guidance and behavior disagree, inspect the current checkout:
|
When this skill and the checkout disagree, the checkout wins: `scripts/orca_profile_tool.py` for the
|
||||||
`scripts/orca_profile_tool.py` for tooling and flags; `src/libslic3r/Preset*.cpp` for loading and
|
tool and its flags; `src/libslic3r/Preset*.cpp` for loading and compatibility;
|
||||||
compatibility; `src/libslic3r/PrintConfig.cpp` for setting types and legacy handling;
|
`src/libslic3r/PrintConfig.cpp` for keys, types, nullable options, the variant key sets and legacy
|
||||||
`src/dev-utils/OrcaSlicer_profile_validator.cpp` and `.github/workflows/check_profiles.yml` for
|
handling; `src/dev-utils/OrcaSlicer_profile_validator.cpp` and `.github/workflows/check_profiles.yml`
|
||||||
validation coverage. `docs/HLSD/filament_id.md` defines filament identity. The
|
for validation coverage; `docs/HLSD/filament_id.md` for filament identity. The wiki's
|
||||||
[profile development guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/how_to_create_profiles.md)
|
[profile guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/how_to_create_profiles.md)
|
||||||
is a tutorial; confirm loader and CLI details against these sources.
|
is a tutorial; confirm loader and CLI details against these sources.
|
||||||
|
|
||||||
|
## Editing this skill
|
||||||
|
|
||||||
|
The skill describes what a profile must be, not what the shipped tree currently is. State rules,
|
||||||
|
equations, patterns and profile shapes; never inventories of shipped defects, counts, dated
|
||||||
|
measurements or lists of which vendors do what. Test each sentence: if editing the profiles alone,
|
||||||
|
with the engine and tool unchanged, could make it false, state the rule behind it or give a generic
|
||||||
|
example instead. Example files named as models to copy, and commit hashes cited as the reason for a
|
||||||
|
rule, are fine.
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
# Extruder variants
|
||||||
|
|
||||||
|
Use this when a printer's hotend or extruder can be in more than one hardware configuration that
|
||||||
|
needs different settings: a Standard and a High Flow nozzle, a TPU High Flow, E3D High Flow or Extra High Flow nozzle, or
|
||||||
|
a Direct Drive and a Bowden extruder on one machine. Variants let one printer, process and filament preset carry a
|
||||||
|
separate value per configuration; the user picks the configuration in the sidebar, and slicing uses
|
||||||
|
the matching values. They are not for nozzle **diameter**: that stays one `machine` preset per
|
||||||
|
`printer_variant`.
|
||||||
|
|
||||||
|
## Variant strings
|
||||||
|
|
||||||
|
- A **variant string** is `"<extruder_type> <nozzle volume type>"`: the extruder's `extruder_type`
|
||||||
|
value, a space, and its nozzle volume type (the project's `nozzle_volume_type`, seeded by the
|
||||||
|
printer preset's `default_nozzle_volume_type`), e.g. `"Direct Drive High Flow"`.
|
||||||
|
- A **variant** is one entry of a preset's variant list, named by its variant string (plus an extruder
|
||||||
|
id for printer and process lists). A variant-aware key holds **one value per variant** (a
|
||||||
|
(normal, silent) pair for the `machine_max_*` limits), and each preset declares its variants in
|
||||||
|
that list. Slicing picks, for each extruder, the variant whose
|
||||||
|
variant string equals the current `extruder_type` + nozzle volume type.
|
||||||
|
- Matching is an **exact string compare** of the whole string (plus the extruder id for printer and
|
||||||
|
process lists) against the preset's own resolved list, in any order. With no match the **first
|
||||||
|
variant** is used, silently: variant index 0 for printer and process keys (extruder 1's first
|
||||||
|
variant, whichever extruder asks), the filament's own first variant for filament keys. Printer and
|
||||||
|
process keys are matched only on a printer with several extruders or whose
|
||||||
|
`extruder_variant_list` offers several variant strings (`support_different_extruders`); on any
|
||||||
|
other printer their variant index 0 is read whatever it names. Nothing rejects a profile for a
|
||||||
|
variant mismatch; mistakes surface only as wrong values in the G-code.
|
||||||
|
- Variant index and array length follow [Widths](#widths).
|
||||||
|
|
||||||
|
The complete enum is `s_keys_map_ExtruderType` and `s_keys_map_NozzleVolumeType` in
|
||||||
|
`src/libslic3r/PrintConfig.cpp` (`grep -A5 s_keys_map_NozzleVolumeType` there to confirm):
|
||||||
|
|
||||||
|
| Part | Values |
|
||||||
|
| --- | --- |
|
||||||
|
| Extruder type | `Direct Drive`, `Bowden` |
|
||||||
|
| Nozzle volume type | `Standard`, `High Flow`, `TPU High Flow`, `E3D High Flow`, `Extra High Flow` (`Hybrid` exists but is runtime-only) |
|
||||||
|
|
||||||
|
So the ten legal variant strings are the two extruder types × the five writable nozzle volume types,
|
||||||
|
and this table is the whole test of legality: a string's presence in a shipped profile is no evidence
|
||||||
|
for it. `Hybrid` (an extruder with several sub-nozzles) is never a variant: a filament on it reads the
|
||||||
|
variant of its own nozzle volume type from the project's `filament_volume_map`, printer and process
|
||||||
|
keys get one variant per nozzle volume type the extruder holds (`extruder_nozzle_stats`), and any
|
||||||
|
other lookup reads `Standard`. Never write it in a variant string. The same per-filament and
|
||||||
|
per-type reading applies on any extruder once `extruder_nozzle_stats` lists more nozzle volume
|
||||||
|
types than there are extruders.
|
||||||
|
|
||||||
|
Every other string (a nozzle volume type name from another slicer, a typo, a variant copied from a
|
||||||
|
shipped profile) is a **dead variant**: nothing selects it, and since lookup is by string it does not
|
||||||
|
shift the variants beside it; it still counts toward the variant length when arrays are sized.
|
||||||
|
`orca_profile_tool.py check` reports it as an error in every bundle, BBL included
|
||||||
|
([variant names](validation.md#variant-names)). Legacy names are errors too: in the four variant
|
||||||
|
lists, `default_nozzle_volume_type` and `nozzle_volume_type`, the loader still rewrites `Normal` →
|
||||||
|
`Standard` and `Big Traffic` → `High Flow`, so a ported `Direct Drive Normal` variant would load, but
|
||||||
|
`check` rejects the spelling and names the enum name to write. `DirectDrive` is rewritten only in
|
||||||
|
`extruder_type`, so `DirectDrive Standard` in a variant list is dead. Write the enum names;
|
||||||
|
`normalize` does not convert them.
|
||||||
|
|
||||||
|
### A nozzle the enum does not name
|
||||||
|
|
||||||
|
The nozzle volume types are fixed by the engine, and a profile cannot add one. A nozzle the table does
|
||||||
|
not name needs the type added in code first, which is outside profile work; until then any string
|
||||||
|
for it is a dead variant. Once the engine has the type, its variant string is the enum name after
|
||||||
|
the extruder type, e.g. `"Direct Drive <name>"`, and `check` accepts it with no tool change, since it
|
||||||
|
reads the enum from `PrintConfig.cpp`. Existing arrays are unaffected
|
||||||
|
([slice time](#slice-time-and-existing-users)).
|
||||||
|
|
||||||
|
## When to use variants
|
||||||
|
|
||||||
|
| Hardware | Do |
|
||||||
|
| --- | --- |
|
||||||
|
| One extruder, one nozzle type | Nothing. Without a variant list the printer has one variant ([variant length](#widths)) and no printer-key lookup takes place; a filament with several variants still gets the one for the printer's variant string. |
|
||||||
|
| Bowden-only printer | Nothing either. A list-less printer matches no string, so every printer-key lookup reads variant index 0 whether that variant is called `"Bowden Standard"` or `"Direct Drive Standard"`; naming it is cosmetic while it is the printer's only variant. A multi-extruder Bowden printer that declares the layout writes `"Bowden Standard"` variants: `"Direct Drive Standard"` entries match none of its extruders, so every extruder reads variant index 0 (and `check` reports them under [Printer rule 3](#printer-machine)). A filament with a `"Bowden Standard"` variant does get that variant there. |
|
||||||
|
| Nozzle types the user swaps (Standard / High Flow / TPU High Flow / E3D High Flow / Extra High Flow) | The printer lists them as variants; tune the keys that really differ per nozzle volume type. |
|
||||||
|
| Extruders of different types on one machine | One `extruder_type` per extruder, each extruder listing its own variants. |
|
||||||
|
| Several extruders that need different values in a variant key (retraction, z-hop, `nozzle_volume`, the `machine_max_*` limits) | Declare the layout even with a single nozzle volume type: `extruder_variant_list` with one `"<type> Standard"` per extruder, and the flattened pair. Without it the arrays still hold one value per extruder ([variant length](#widths)), but the loader keeps only extruder 1's value of a list-less printer's arrays, so every extruder prints with it. |
|
||||||
|
|
||||||
|
A preset without variant keys keeps working on a variant printer: its single variant is applied to
|
||||||
|
every extruder. So adding variants to a printer does not break existing processes or library
|
||||||
|
filaments; it only makes per-variant tuning possible.
|
||||||
|
|
||||||
|
## Printer (`machine`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
"extruder_type": ["Direct Drive", "Direct Drive"],
|
||||||
|
"extruder_variant_list": ["Direct Drive Standard,Direct Drive High Flow",
|
||||||
|
"Direct Drive Standard,Direct Drive High Flow,Direct Drive TPU High Flow"],
|
||||||
|
"printer_extruder_id": ["1", "1", "2", "2", "2"],
|
||||||
|
"printer_extruder_variant": ["Direct Drive Standard", "Direct Drive High Flow",
|
||||||
|
"Direct Drive Standard", "Direct Drive High Flow", "Direct Drive TPU High Flow"],
|
||||||
|
"default_nozzle_volume_type": ["Standard", "Standard"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. `extruder_variant_list` has **one entry per extruder**; each entry is the `,`-joined variants that
|
||||||
|
extruder supports. It is the per-extruder menu the sidebar offers. It is in no
|
||||||
|
[variant set](#the-four-key-sets), so the variant-length resize leaves it alone. Extruder 1's
|
||||||
|
first variant, variant index 0 of the flattened pair, is the fallback of every extruder whose
|
||||||
|
variant is missing when the arrays are collapsed for slicing
|
||||||
|
([slice time](#slice-time-and-existing-users)); it is not necessarily the configuration the user
|
||||||
|
sees, which is `default_nozzle_volume_type` (rule 4).
|
||||||
|
2. `printer_extruder_variant` is that list **flattened** extruder-major, one entry per variant, and
|
||||||
|
`printer_extruder_id` gives each entry its 1-based extruder. These two size and address every
|
||||||
|
variant. Write all three keys and keep them in agreement. At load with
|
||||||
|
`single_extruder_multi_material` off, and in the app when the printer tab loads a printer with a
|
||||||
|
different number of extruders, the pair is rebuilt from `extruder_variant_list` (one
|
||||||
|
`Direct Drive Standard` per extruder when the list is absent) and the variant arrays are resized to
|
||||||
|
the rebuilt pair, padded with their first value or cut. The resize skips the `machine_max_*` limits
|
||||||
|
and `hotend_heating_rate` / `hotend_cooling_rate`: they keep their width, and an extruder beyond it
|
||||||
|
reads their first value, so extruder 2 and up of a list-less printer take extruder 1's normal limit
|
||||||
|
as their silent one too. With the three in agreement that changes nothing; a pair written without
|
||||||
|
the list is replaced. A listed variant the pair lacks is a menu choice that reads variant index 0.
|
||||||
|
- The pair without `extruder_variant_list` slices, but the sidebar offers no variant switch and
|
||||||
|
the app cannot add variants to a list-less process: nothing is lost while every extruder
|
||||||
|
has exactly one variant, and every further variant is unreachable.
|
||||||
|
- A missing or one-value `printer_extruder_id` is extruder 1 at every index
|
||||||
|
([the id trap](#padding-truncation-and-composition)) unless the load-time rebuild above
|
||||||
|
replaces the pair (`single_extruder_multi_material` off).
|
||||||
|
- `check` reports against this rule ([variant arrays](validation.md#variant-arrays)). Errors: a
|
||||||
|
pair that is not the flattening, an id array that does not give each entry its extruder, a list
|
||||||
|
of more than one variant without the pair, and a pair without the list that puts several variants
|
||||||
|
on one extruder. A pair without the list and one variant per extruder is a warning where the
|
||||||
|
load-time rebuild would replace it (`single_extruder_multi_material` off, and a pair other than
|
||||||
|
one `Direct Drive Standard` per extruder), and passes otherwise.
|
||||||
|
3. Every variant string in an entry must start with that extruder's `extruder_type`.
|
||||||
|
4. `default_nozzle_volume_type` has one value per extruder and must name a nozzle volume type that
|
||||||
|
extruder's variants list; it seeds the sidebar. The live choice is `nozzle_volume_type` in the
|
||||||
|
project config, never a preset key.
|
||||||
|
5. Size every array by the set its key belongs to (the three sets are listed in full in
|
||||||
|
[The four key sets](#the-four-key-sets); the lengths are worked through in the
|
||||||
|
[sizing equation](#sizing-equation)): exactly the length below in each selectable preset that
|
||||||
|
writes it, or leave the key out and the preset takes what reaches it, the default or its base's
|
||||||
|
array. One value is no
|
||||||
|
shorthand for "the same for every variant"; write the value for every variant:
|
||||||
|
|
||||||
|
| Set | Length | Keys |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `printer_extruder_options` | extruders (`E`) | the 8 per-extruder keys outside the variant scheme (`extruder_type`, `nozzle_diameter`, `default_nozzle_volume_type`, …) |
|
||||||
|
| `printer_options_with_variant_1` | variant length (`S`) | the full list below; not guessable from names |
|
||||||
|
| `printer_options_with_variant_2` | 2 × variant length | the 16 `machine_max_*` limits, at [stride 2](#widths): a (normal, silent) pair per variant |
|
||||||
|
|
||||||
|
Put the variant layout on the shared base of all printers that share the hardware, and let the
|
||||||
|
nozzle-diameter siblings inherit it, restating only the variant arrays whose values change. `check`
|
||||||
|
does not judge a base on its own; its arrays count where they reach a selectable preset, under
|
||||||
|
`check --strict`. The loader stores a base at its **own** `printer_extruder_variant`, one variant
|
||||||
|
when it writes none whatever its extruder count, and cuts a wider array to its first values before
|
||||||
|
any child inherits it; so a multi-extruder base whose extruders need different values declares the
|
||||||
|
layout itself ([composition](#padding-truncation-and-composition)). When a variant array can move from
|
||||||
|
the presets to a base is in [shared-bases.md](shared-bases.md#variant-arrays-on-a-base).
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. `print_extruder_variant` + `print_extruder_id` list the (extruder id, variant string) pairs of the
|
||||||
|
process's variants. A variant is found by that pair, never by position: what is **required** is
|
||||||
|
that every pair a compatible printer (by list or condition) can select is present, in any order. A
|
||||||
|
missing pair reads the process's variant index 0 for that extruder, an extra pair is a variant
|
||||||
|
nothing selects, and neither is reported. The exception is a single-extruder printer whose
|
||||||
|
`extruder_variant_list` offers one variant string: no pair is matched there and variant index 0
|
||||||
|
is read, so a process shared with such a printer lists that printer's pair first. **Mirroring** the printer's `printer_extruder_variant` +
|
||||||
|
`printer_extruder_id` entry for entry is the convention; follow it, so the arrays compare by eye,
|
||||||
|
but a different order with every pair present is a nit, not a defect. A process shared by printers
|
||||||
|
whose pairs differ falls back to variant index 0 on the pairs it lacks; give each layout its own base.
|
||||||
|
2. Every key in `print_options_with_variant` ([the full list](#the-four-key-sets), which is not
|
||||||
|
"every speed") has exactly one value per variant, or is left out. `print_extruder_id` needs its
|
||||||
|
value per variant too: one value pads to extruder 1 everywhere. `check` holds a
|
||||||
|
`print_extruder_id` that reaches the preset, written or inherited, to one entry per variant on any
|
||||||
|
printer, without `--strict`, and warns when it is absent and a variant repeats.
|
||||||
|
3. Only add process variants if speeds or accelerations really differ per nozzle volume type or
|
||||||
|
extruder. Otherwise omit the variant keys: a one-variant process needs no list, because match and
|
||||||
|
no-match both read variant index 0, and that variant is copied to every extruder at slice time.
|
||||||
|
4. Put the lists on the process base for that printer layout, so leaves stay small.
|
||||||
|
|
||||||
|
## Filament
|
||||||
|
|
||||||
|
```json
|
||||||
|
"filament_extruder_variant": ["Direct Drive Standard", "Direct Drive High Flow"],
|
||||||
|
"filament_max_volumetric_speed": ["21", "29"],
|
||||||
|
"filament_flow_ratio": ["0.98", "0.98"],
|
||||||
|
"filament_retraction_length": ["nil", "0.4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `filament_extruder_variant` lists variants **without extruder ids**: a filament's High Flow variant
|
||||||
|
is used on whichever extruder is in High Flow. Its entries must be distinct, since the lookup
|
||||||
|
returns the first equal string and a repeated entry is a variant nothing selects. There is no
|
||||||
|
filament id key: `filament_extruder_id` exists only as a G-code placeholder, not as a filament
|
||||||
|
option (its option and its set entry are commented out in `PrintConfig.cpp`), so a filament file that writes it loses the key as unknown.
|
||||||
|
`filament_extruder_compatibility` is unrelated to variants (it says which extruders the filament
|
||||||
|
may be loaded into).
|
||||||
|
2. Every key in `filament_options_with_variant` ([the full list](#the-four-key-sets)) that reaches the
|
||||||
|
preset, whether written, included or inherited, is resized to its variant count: write it at exactly
|
||||||
|
that width, or leave it out. Keys outside the set
|
||||||
|
(`filament_type`, plate temperatures, `fan_max_speed`, `slow_down_min_speed`, …) are never addressed by variant index; the preset contributes their first value however wide a
|
||||||
|
file writes them.
|
||||||
|
3. Cover every variant the material is meant to print on across its `compatible_printers`. Leave out
|
||||||
|
a variant deliberately when the material should not be tuned for it (e.g. a TPU High Flow variant
|
||||||
|
only on TPU filaments); an extruder reporting that variant string then reads the first variant.
|
||||||
|
4. Order the variants like the printer's, Standard first, so the first-variant fallback is the
|
||||||
|
conservative one.
|
||||||
|
5. Tune what really differs: `filament_max_volumetric_speed` is the usual difference between nozzle
|
||||||
|
volume types, then flow ratio, temperature and retraction. Use measured values; never copy the
|
||||||
|
Standard value into the High Flow variant and call it tuned.
|
||||||
|
6. `nil` is legal per variant in the nullable override keys, occupies one entry like any value, and
|
||||||
|
keeps the printer's value for that variant only.
|
||||||
|
7. Declare a multi-variant list with the arrays it sizes: on the filament, in its own file or in a
|
||||||
|
template it pulls in with [`include`](vendor-bundle.md#inherits-and-include). The filament may be
|
||||||
|
compatible with printers that list fewer variants, or none: each printer takes the filament's variant
|
||||||
|
for its own variant string, else the filament's first variant. A one-variant list on a shared base
|
||||||
|
is harmless, because one variant is the width a list-less preset has anyway; and a list-less base is
|
||||||
|
stored at that width, so a wider array on it reaches its children as its first value only.
|
||||||
|
A key such a base writes reaches a multi-variant child as one value, which the loader spreads over
|
||||||
|
the child's variants; `check --strict` reports it at the child, which restates it at its own width.
|
||||||
|
|
||||||
|
## Widths
|
||||||
|
|
||||||
|
Every variant key is a flat array addressed by **variant index**: index `n` belongs to entry `n` of
|
||||||
|
the preset's own variant list (`printer_extruder_variant`, `print_extruder_variant` or
|
||||||
|
`filament_extruder_variant`). The index is found by exact compare of the string
|
||||||
|
`"<extruder_type> <nozzle volume type>"` (plus the 1-based extruder id for printer and process lists),
|
||||||
|
and the value is read at `index × stride`:
|
||||||
|
|
||||||
|
| Stride | Keys | Layout |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `printer_options_with_variant_1`, `print_options_with_variant`, `filament_options_with_variant` | `[variant 0, variant 1, …]` |
|
||||||
|
| 2 | `printer_options_with_variant_2` (the `machine_max_*` limits) | `[variant 0 normal, variant 0 silent, variant 1 normal, variant 1 silent, …]` |
|
||||||
|
|
||||||
|
So a variant array is `variant length × stride` long. The **variant length** is the number of
|
||||||
|
entries in the preset's variant list, counted on the preset's config **after** `include` and
|
||||||
|
`inherits` are applied, dead variants included. An inherited array arrives already resized to the
|
||||||
|
base's own variant length, an included one at the width its file wrote
|
||||||
|
([composition](#padding-truncation-and-composition)):
|
||||||
|
|
||||||
|
| Preset | Variant length |
|
||||||
|
| --- | --- |
|
||||||
|
| `machine` | `len(printer_extruder_variant)`; without it, the variants `extruder_variant_list` offers; without both, **one per extruder** (`len(nozzle_diameter)`), since the list's default is one `Direct Drive Standard` per extruder. `extruders_count` is a printer-tab field, not a preset key. The loader honours that default only halfway for a system preset: it sizes the composed preset by the one-entry default `printer_extruder_variant`, cutting every variant array to its first value, and only then (with `single_extruder_multi_material` off) rebuilds the pair to one variant per extruder ([Printer rule 2](#printer-machine)) and pads the arrays with that value. The rule's width is still one per extruder; to give extruders different values, declare the layout. |
|
||||||
|
| `process` | `len(print_extruder_variant)`; without one, 1 |
|
||||||
|
| `filament` | `len(filament_extruder_variant)`; without one, 1 |
|
||||||
|
|
||||||
|
The per-extruder keys in `printer_extruder_options` ([listed with the sets](#the-four-key-sets)) are
|
||||||
|
outside this scheme and stay one value per extruder. Keys outside [the four sets](#the-four-key-sets)
|
||||||
|
are never variant-resized or addressed by variant index, however wide a shipped file writes them; a
|
||||||
|
per-extruder vector holds `len(nozzle_diameter)` values, and a shorter one acts as padded with its
|
||||||
|
first value ([machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers)).
|
||||||
|
|
||||||
|
### Sizing equation
|
||||||
|
|
||||||
|
For a key in one of the four variant sets:
|
||||||
|
|
||||||
|
```
|
||||||
|
E = extruders = len(nozzle_diameter) = len(extruder_type)
|
||||||
|
= len(default_nozzle_volume_type) = len(extruder_variant_list)
|
||||||
|
V_i = variants listed for extruder i = ","-separated entries of extruder_variant_list[i],
|
||||||
|
each "<extruder_type[i]> <nozzle volume type>"
|
||||||
|
S = variant length = V_1 + V_2 + … + V_E
|
||||||
|
= len(printer_extruder_variant) = len(printer_extruder_id)
|
||||||
|
k = stride = 2 for printer_options_with_variant_2, else 1
|
||||||
|
|
||||||
|
N = values the key holds, one per variant:
|
||||||
|
machine (printer_options_with_variant_1, _2) = S × k
|
||||||
|
process (print_options_with_variant) = len(print_extruder_variant) = len(print_extruder_id)
|
||||||
|
filament (filament_options_with_variant) = len(filament_extruder_variant)
|
||||||
|
values[s × k + m] = variant index s, mode m (m = 0 normal, m = 1 silent; only m = 0 at stride 1)
|
||||||
|
variant index s = (printer_extruder_id[s], printer_extruder_variant[s])
|
||||||
|
```
|
||||||
|
|
||||||
|
`printer_extruder_variant` is not sized by the equation; it defines `S`: it is `extruder_variant_list`
|
||||||
|
flattened extruder by extruder, and `printer_extruder_id[s]` is the extruder that index `s` came from.
|
||||||
|
A process that mirrors the printer's pairs, as [Process rule 1](#process) asks, has `N = S`; a
|
||||||
|
filament lists each variant string it is tuned for once, with no extruder id, so its `N` is its own
|
||||||
|
and independent of any one printer: it serves every printer in its `compatible_printers`, and a
|
||||||
|
variant string no extruder of a printer reports is simply never read there (a two-extruder printer
|
||||||
|
with `S = 7` serves a filament whose `N` is 3, and the filament keeps its 3 on a printer that offers
|
||||||
|
two variant strings). The
|
||||||
|
pair is what the loader reads, so the equality with the sum holds when the three keys agree, as
|
||||||
|
[Printer rule 2](#printer-machine) requires.
|
||||||
|
|
||||||
|
A selectable preset that writes a variant key writes it at its own `N`; any other width, one value
|
||||||
|
included, is an error. A preset that leaves a key out takes what reaches it, the default or an array
|
||||||
|
it inherits or includes, which the loader resizes to the preset's `N`; `check --strict` holds that
|
||||||
|
array to the preset's `N` too. A base is not judged on its own: its arrays count only where they reach
|
||||||
|
a preset that does not override them. BBL is the model for strict: every printer-specific machine,
|
||||||
|
process and filament declares its layout and restates every variant key at its own `N`, even where all
|
||||||
|
the values are the same, keys Orca added to the sets included.
|
||||||
|
Without the lists, `extruder_variant_list` defaults to one
|
||||||
|
`Direct Drive Standard` per extruder, so a machine has `V_i = 1` and `S = E`, and a process or filament
|
||||||
|
has `N = 1`; the loader cuts a list-less machine to its first variant and, with
|
||||||
|
`single_extruder_multi_material` off, widens it again with that value ([variant length](#widths)). The per-extruder keys outside the sets
|
||||||
|
(`printer_extruder_options` plus `extruder_offset` and `extruder_colour`) and `extruder_variant_list`
|
||||||
|
itself hold `E` values.
|
||||||
|
|
||||||
|
### Sizing examples
|
||||||
|
|
||||||
|
**One extruder, two nozzle volume types**: `E = 1`, `V_1 = 2`, so `S = 2`; stride-1 keys hold 2
|
||||||
|
values, `machine_max_*` hold 4:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"nozzle_diameter": ["0.4"],
|
||||||
|
"extruder_type": ["Direct Drive"],
|
||||||
|
"extruder_variant_list": ["Direct Drive Standard,Direct Drive High Flow"],
|
||||||
|
"printer_extruder_id": ["1", "1"],
|
||||||
|
"printer_extruder_variant": ["Direct Drive Standard", "Direct Drive High Flow"],
|
||||||
|
"default_nozzle_volume_type": ["Standard"],
|
||||||
|
"retraction_length": ["0.8", "1.0"],
|
||||||
|
"machine_max_speed_x": ["500", "200", "600", "250"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching process lists the same two pairs (`print_extruder_id` `["1", "1"]`,
|
||||||
|
`print_extruder_variant` as above) and holds 2 values per key, e.g. `outer_wall_speed`
|
||||||
|
`["200", "260"]`; a filament for it lists `["Direct Drive Standard", "Direct Drive High Flow"]` and
|
||||||
|
holds 2 values per key, e.g. `filament_max_volumetric_speed` `["16", "24"]`.
|
||||||
|
|
||||||
|
**Four extruders, one nozzle volume type each** (a tool changer): `E = 4`, every `V_i = 1`, so
|
||||||
|
`S = 4`; stride-1 keys hold 4 values, `machine_max_*` hold 8:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"nozzle_diameter": ["0.4", "0.4", "0.6", "0.4"],
|
||||||
|
"extruder_type": ["Direct Drive", "Direct Drive", "Direct Drive", "Direct Drive"],
|
||||||
|
"extruder_variant_list": ["Direct Drive Standard", "Direct Drive Standard",
|
||||||
|
"Direct Drive Standard", "Direct Drive Standard"],
|
||||||
|
"printer_extruder_id": ["1", "2", "3", "4"],
|
||||||
|
"printer_extruder_variant": ["Direct Drive Standard", "Direct Drive Standard",
|
||||||
|
"Direct Drive Standard", "Direct Drive Standard"],
|
||||||
|
"default_nozzle_volume_type": ["Standard", "Standard", "Standard", "Standard"],
|
||||||
|
"retraction_length": ["0.8", "0.8", "1.2", "0.8"],
|
||||||
|
"machine_max_speed_x": ["500", "200", "500", "200", "500", "200", "500", "200"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The (normal, silent) pair is repeated per extruder: `["500", "200"]` would be width 2 against
|
||||||
|
`S × k = 8` (the loader pads it to `500, 200, 500, 500, …`) and `["500"]` width 1; both are errors. The
|
||||||
|
process mirrors the four pairs (`print_extruder_id` `["1", "2", "3", "4"]`) with 4 values per key, or
|
||||||
|
omits the variant keys altogether when nothing differs per extruder (then one value per key). A
|
||||||
|
filament for it lists only `["Direct Drive Standard"]`: one entry, so one value per key. Drop the
|
||||||
|
layout from this printer and the widths stay the same, since the default list gives `S = E = 4`; but
|
||||||
|
the loader then keeps only the first variant ([variant length](#widths)): `retraction_length`
|
||||||
|
becomes `0.8` on every extruder and extruder 3 loses its `1.2`.
|
||||||
|
|
||||||
|
### Adding a variant inserts its values at its variant index
|
||||||
|
|
||||||
|
Indexes run extruder-major: extruder 1's variants in `extruder_variant_list` order, then extruder
|
||||||
|
2's. A new variant's values go in at its index, not at the end. Giving extruder 1 of a two-extruder
|
||||||
|
printer a High Flow option, when only extruder 2 had one:
|
||||||
|
|
||||||
|
| Key | Before | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `extruder_variant_list` | `["Direct Drive Standard", "Direct Drive Standard,Direct Drive High Flow"]` | `["Direct Drive Standard,Direct Drive High Flow", "Direct Drive Standard,Direct Drive High Flow"]` |
|
||||||
|
| `printer_extruder_id` | `["1", "2", "2"]` | `["1", "1", "2", "2"]` |
|
||||||
|
| `printer_extruder_variant` | `[Standard, Standard, High Flow]` | `[Standard, High Flow, Standard, High Flow]` (full strings in the file) |
|
||||||
|
| `retraction_length` (stride 1) | `["0.8", "1.0", "1.2"]` | `["0.8", "?", "1.0", "1.2"]`: one value at index 1 |
|
||||||
|
| `machine_max_speed_x` (stride 2) | `["500", "200", "600", "250", "700", "300"]` | `["500", "200", "?", "?", "600", "250", "700", "300"]`: a (normal, silent) pair at position 2 |
|
||||||
|
| `print_extruder_id` / `print_extruder_variant` / `outer_wall_speed` | mirror the printer | the same insertion at index 1 |
|
||||||
|
| `filament_extruder_variant` `[Standard, High Flow]` | — | unchanged: filament variants carry no extruder id, so the existing High Flow variant now serves both extruders |
|
||||||
|
|
||||||
|
Every `?` is a measured value for that nozzle, on the machine limits as much as on retraction.
|
||||||
|
Removing or renaming a variant shifts the later values the same way in reverse; a variant string that
|
||||||
|
no longer matches the enum is simply a variant nothing selects.
|
||||||
|
|
||||||
|
### Padding, truncation and composition
|
||||||
|
|
||||||
|
**Every key in the set widens, whether or not a file restates it.** At load each variant key of the
|
||||||
|
composed config is resized to the length of the preset's own `*_extruder_variant` (the one-entry
|
||||||
|
default when it writes none) × stride: a short array is **padded by repeating its first value**, a
|
||||||
|
long one is truncated to its first values, without a word from the loader or the validator. The rule
|
||||||
|
does not follow from this padding: a file writes a variant key at **exactly `variant length × stride`**
|
||||||
|
or not at all. Any other length, one value included (which the loader spreads over every variant, at
|
||||||
|
stride 2 over normal *and* silent alike), is a mistake the loader hides and
|
||||||
|
`orca_profile_tool.py check` reports as an error in the selectable preset that writes it, even when
|
||||||
|
every value is the same; `check --strict` also reports an array that reaches a selectable preset at
|
||||||
|
another width
|
||||||
|
([variant arrays](validation.md#variant-arrays)).
|
||||||
|
|
||||||
|
The loader sizes a list-less preset of any type to one variant at this step, a list-less machine
|
||||||
|
included: a two-extruder machine without a layout that writes `retraction_length` `["0.8", "0.9"]`, the
|
||||||
|
width the equation asks for, stores `["0.8"]`, which the pair rebuild and the slice-time collapse hand
|
||||||
|
to both extruders. A 3-value array on a preset of variant length 2 keeps its first two; at length 4 it
|
||||||
|
becomes `[a, b, c, a]`.
|
||||||
|
|
||||||
|
Composition hands down widths in two ways:
|
||||||
|
|
||||||
|
- **`inherits` hands down the parent's resized arrays.** A base is stored after its own resize, at
|
||||||
|
the length of its own `*_extruder_variant` (one variant for a base of any type that writes none,
|
||||||
|
whatever its extruder count), so an array wider than the base's list is cut to its first values
|
||||||
|
before any child sees it, and a child that adds variants gets those first values padded. Widen an
|
||||||
|
array only on a preset whose own resolved list already has the entries. `check` judges selectable
|
||||||
|
presets only, at the width each file wrote: an array a base's own resize cuts is not seen (a review
|
||||||
|
item), and an inherited array of another width than a child's list is left to the loader's resize
|
||||||
|
unless `check --strict`, which asks the child to restate it at its own width.
|
||||||
|
- **`include` hands down the template's diff at its pre-resize width.** The template contributes every
|
||||||
|
key where its composed config differs from the built-in defaults, taken before its own resize, so the
|
||||||
|
arrays it writes arrive at the width its file wrote, and the includer's own list sizes them. A key the
|
||||||
|
template sets to the built-in default is not passed on
|
||||||
|
([`include`](vendor-bundle.md#inherits-and-include)).
|
||||||
|
|
||||||
|
The id keys are the trap in this padding: `printer_extruder_id` and `print_extruder_id` are members
|
||||||
|
with default `[1]`, so a missing or one-value id array beside a longer variant list is padded to
|
||||||
|
extruder 1 at every index. That is right on a single-extruder printer and wrong on a multi-extruder
|
||||||
|
one, where every variant is then addressed as extruder 1's. A machine escapes it only where the
|
||||||
|
load-time pair rebuild of [Printer rule 2](#printer-machine) runs (`single_extruder_multi_material`
|
||||||
|
off); a process's `print_extruder_id` is never rebuilt at load. `check`
|
||||||
|
holds an id array that reaches a selectable preset, written or inherited, to one entry per variant on
|
||||||
|
any printer; `fix-variant` never pads an id array or a variant list, since those address the
|
||||||
|
variants rather than fill them.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- A base that gains a variant silently pads every descendant that restates a variant array at the old
|
||||||
|
width, and a short `machine_max_*` array copies variant 0's *normal* limit into the silent entries
|
||||||
|
too; a descendant that restates nothing inherits the widened array and needs no edit. Extend, in one
|
||||||
|
change: the printer base and each nozzle-diameter sibling that restates a variant array, the process
|
||||||
|
bases that mirror the printer's variants, and the filaments that should cover the variant.
|
||||||
|
- A one-value override is reported, and the loader spreads it over **all** variants, overwriting the
|
||||||
|
ones that should differ.
|
||||||
|
- A process or filament that omits the new variant is not padded; the variant resolves to its index 0.
|
||||||
|
|
||||||
|
### Slice time and existing users
|
||||||
|
|
||||||
|
**Slice time collapses variants to extruders.** When printer, process and filaments are combined on a
|
||||||
|
printer with several extruders or several variant strings, each printer and process variant key is
|
||||||
|
re-gathered to one entry per extruder (× stride) in extruder order, using each extruder's live nozzle
|
||||||
|
volume type, or to one entry per nozzle volume type for an extruder that holds several (Hybrid, or
|
||||||
|
`extruder_nozzle_stats` listing more types than there are extruders); on any other printer they are
|
||||||
|
not re-gathered and variant index 0 is read. Filament keys are re-gathered to one entry per filament,
|
||||||
|
on a printer with a single variant too once a filament has several variants, and under a dynamic
|
||||||
|
nozzle map to one entry per variant each filament prints through. Custom G-code and the
|
||||||
|
`machine_max_*` limits therefore index by extruder or filament, never by variant index; variant order
|
||||||
|
matters only inside the preset. An extruder with no matching variant reads variant index 0, extruder
|
||||||
|
1's first variant, whichever extruder it is; under layered nozzle grouping, `get_config_index_base`
|
||||||
|
reads the collapsed arrays and falls back to the same extruder's first entry instead.
|
||||||
|
|
||||||
|
**Existing user presets and projects follow the variant string, not the position.** A user preset
|
||||||
|
stores every variant array it changed (nullable keys as per-variant diffs, `nil` where equal to the
|
||||||
|
parent). On load each parent variant takes the child's value for the variant with the same extruder id
|
||||||
|
and variant string; variants the parent gained keep the parent's value, and a child whose arrays the
|
||||||
|
parent cannot map keeps its own. Per-object process overrides are remapped when a printer change
|
||||||
|
alters the extruder count or the length of `printer_extruder_variant`, by variant string alone (no
|
||||||
|
extruder id; of several matching values the smallest wins), and a single-value override applies to
|
||||||
|
every variant. Adding or reordering variants in a shipped preset is therefore safe for existing
|
||||||
|
users; renaming a variant, or moving it to another extruder id, loses their values for it.
|
||||||
|
|
||||||
|
**A new nozzle volume type changes no array.** Adding one to the code widens nothing until a profile
|
||||||
|
lists the new variant string; until then every existing array keeps its length and meaning.
|
||||||
|
|
||||||
|
## The four key sets
|
||||||
|
|
||||||
|
Membership is literal (four `std::set<std::string>` initializers in `src/libslic3r/PrintConfig.cpp`)
|
||||||
|
and **not guessable from names**: `ironing_speed`, `skirt_speed`, `wipe_speed`, `scarf_joint_speed`,
|
||||||
|
`small_support_perimeter_speed` and `wipe_tower_max_purge_speed` are process speeds outside the set,
|
||||||
|
while every process `*_acceleration` and `*_jerk` key is inside, and so are
|
||||||
|
`small_perimeter_threshold`, `top_solid_infill_flow_ratio` and `slowdown_for_curled_perimeters`;
|
||||||
|
`filament_flush_temp` is in and `filament_flush_temp_fast` out; `use_firmware_retraction` is out and
|
||||||
|
`travel_slope` and `retract_lift_enforce` in. The three variant-list keys and the two id
|
||||||
|
keys are members of their own set (the list sizes itself, a no-op; the id keys are padded like any
|
||||||
|
other member, [the id trap](#padding-truncation-and-composition)), while `extruder_variant_list` is in
|
||||||
|
no set: the variant-length resize leaves it alone, and only the pair rebuild of
|
||||||
|
[Printer rule 2](#printer-machine) pads it. The per-extruder `printer_extruder_options` is listed
|
||||||
|
last for contrast; it is not a variant set.
|
||||||
|
|
||||||
|
`check` and `fix-variant` read the four sets from `PrintConfig.cpp` on every run, so they follow the
|
||||||
|
engine. The lists below are from the 2026-09-29 checkout; regenerate them from the repository root
|
||||||
|
before relying on them (the recipe strips comments, since an initializer can carry a commented-out entry):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import re
|
||||||
|
src = open('src/libslic3r/PrintConfig.cpp', encoding='utf-8', errors='replace').read()
|
||||||
|
for name in ['printer_options_with_variant_1', 'printer_options_with_variant_2',
|
||||||
|
'print_options_with_variant', 'filament_options_with_variant',
|
||||||
|
'printer_extruder_options']:
|
||||||
|
body = re.search(r'std::set<std::string>\s+' + name + r'\s*=\s*\{(.*?)\};', src, re.S).group(1)
|
||||||
|
body = re.sub(r'/\*.*?\*/', '', body, flags=re.S)
|
||||||
|
body = re.sub(r'//[^\n]*', '', body)
|
||||||
|
print(name, sorted(set(re.findall(r'"([^"]+)"', body))))
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**`printer_options_with_variant_1`**, machine, stride 1 (27): `deretraction_speed`, `hotend_cooling_rate`, `hotend_heating_rate`, `long_retractions_when_cut`, `nozzle_flush_dataset`, `nozzle_type`, `nozzle_volume`, `printer_extruder_id`, `printer_extruder_variant`, `retract_after_wipe`, `retract_before_wipe`, `retract_length_toolchange`, `retract_lift_above`, `retract_lift_below`, `retract_lift_enforce`, `retract_restart_extra`, `retract_restart_extra_toolchange`, `retract_when_changing_layer`, `retraction_distances_when_cut`, `retraction_length`, `retraction_minimum_travel`, `retraction_speed`, `travel_slope`, `wipe`, `wipe_distance`, `z_hop`, `z_hop_types`
|
||||||
|
|
||||||
|
**`printer_options_with_variant_2`**, machine, stride 2 (16): `machine_max_acceleration_e`, `machine_max_acceleration_extruding`, `machine_max_acceleration_retracting`, `machine_max_acceleration_travel`, `machine_max_acceleration_x`, `machine_max_acceleration_y`, `machine_max_acceleration_z`, `machine_max_jerk_e`, `machine_max_jerk_x`, `machine_max_jerk_y`, `machine_max_jerk_z`, `machine_max_junction_deviation`, `machine_max_speed_e`, `machine_max_speed_x`, `machine_max_speed_y`, `machine_max_speed_z`
|
||||||
|
|
||||||
|
**`print_options_with_variant`**, process, stride 1 (45): `bridge_acceleration`, `bridge_speed`, `default_acceleration`, `default_jerk`, `default_junction_deviation`, `enable_overhang_speed`, `gap_infill_speed`, `infill_jerk`, `initial_layer_acceleration`, `initial_layer_infill_speed`, `initial_layer_jerk`, `initial_layer_speed`, `initial_layer_travel_acceleration`, `initial_layer_travel_jerk`, `initial_layer_travel_speed`, `inner_wall_acceleration`, `inner_wall_jerk`, `inner_wall_speed`, `internal_bridge_speed`, `internal_solid_infill_acceleration`, `internal_solid_infill_speed`, `outer_wall_acceleration`, `outer_wall_jerk`, `outer_wall_speed`, `overhang_1_4_speed`, `overhang_2_4_speed`, `overhang_3_4_speed`, `overhang_4_4_speed`, `print_extruder_id`, `print_extruder_variant`, `slowdown_for_curled_perimeters`, `small_perimeter_speed`, `small_perimeter_threshold`, `sparse_infill_acceleration`, `sparse_infill_speed`, `support_interface_speed`, `support_speed`, `top_solid_infill_flow_ratio`, `top_surface_acceleration`, `top_surface_jerk`, `top_surface_speed`, `travel_acceleration`, `travel_jerk`, `travel_speed`, `travel_speed_z`
|
||||||
|
|
||||||
|
**`filament_options_with_variant`**, filament, stride 1 (54): `activate_air_filtration`, `activate_air_filtration_during_print`, `activate_air_filtration_on_completion`, `adaptive_pressure_advance`, `adaptive_pressure_advance_bridges`, `adaptive_pressure_advance_model`, `adaptive_pressure_advance_overhangs`, `complete_print_exhaust_fan_speed`, `during_print_exhaust_fan_speed`, `enable_pressure_advance`, `filament_adaptive_volumetric_speed`, `filament_cooling_before_tower`, `filament_deretraction_speed`, `filament_extruder_variant`, `filament_flow_ratio`, `filament_flush_temp`, `filament_flush_volumetric_speed`, `filament_ironing_flow`, `filament_ironing_inset`, `filament_ironing_spacing`, `filament_ironing_speed`, `filament_long_retractions_when_cut`, `filament_max_volumetric_speed`, `filament_pre_cooling_temperature`, `filament_pre_cooling_temperature_nc`, `filament_preheat_temperature_delta`, `filament_ramming_travel_time`, `filament_ramming_travel_time_nc`, `filament_ramming_volumetric_speed`, `filament_ramming_volumetric_speed_nc`, `filament_retract_after_wipe`, `filament_retract_before_wipe`, `filament_retract_length_nc`, `filament_retract_length_toolchange`, `filament_retract_lift_above`, `filament_retract_lift_below`, `filament_retract_lift_enforce`, `filament_retract_restart_extra`, `filament_retract_restart_extra_toolchange`, `filament_retract_when_changing_layer`, `filament_retraction_distances_when_cut`, `filament_retraction_length`, `filament_retraction_minimum_travel`, `filament_retraction_speed`, `filament_wipe`, `filament_wipe_distance`, `filament_z_hop`, `filament_z_hop_types`, `long_retractions_when_ec`, `nozzle_temperature`, `nozzle_temperature_initial_layer`, `pressure_advance`, `retraction_distances_when_ec`, `volumetric_speed_coefficients`
|
||||||
|
|
||||||
|
**`printer_extruder_options`**, machine, one value per extruder, not a variant set (8):
|
||||||
|
`default_nozzle_volume_type`, `extruder_max_nozzle_count`, `extruder_printable_area`,
|
||||||
|
`extruder_printable_height`, `extruder_type`, `max_layer_height`, `min_layer_height`,
|
||||||
|
`nozzle_diameter`. These are never addressed by variant index; `extruder_offset`, `extruder_colour` and
|
||||||
|
`extruder_variant_list` are per extruder too. A shorter array acts as padded with its first value, and
|
||||||
|
entries beyond the extruder count are never read
|
||||||
|
([per-extruder vectors](machine-profiles.md#multi-extruder-idex-and-tool-changers)).
|
||||||
|
|
||||||
|
## Checking and testing
|
||||||
|
|
||||||
|
Checklist for a new variant profile set:
|
||||||
|
|
||||||
|
1. Decide the variants per extruder from the real hardware; pick legal strings only.
|
||||||
|
2. Printer base: `extruder_type`, `extruder_variant_list`, flattened `printer_extruder_variant` +
|
||||||
|
`printer_extruder_id`, `default_nozzle_volume_type`; every variant array at variant length, every
|
||||||
|
`machine_max_*` at 2 × variant length as (normal, silent) pairs, even where the values are the
|
||||||
|
same, values in variant order.
|
||||||
|
3. A process base per variant layout, or no variant keys at all.
|
||||||
|
4. Filaments for the printer: a variant list covering the intended variants, every variant key at that
|
||||||
|
width, measured values per nozzle volume type.
|
||||||
|
5. Run the usual authoring commands and full checks. `check` holds each array of steps 2–4 to the
|
||||||
|
width of the selectable preset that writes it, `check --strict` also to every selectable preset it
|
||||||
|
reaches, and
|
||||||
|
the printer's layout keys through every selectable preset
|
||||||
|
([variant arrays](validation.md#variant-arrays)); `check_variant_names` holds every variant string,
|
||||||
|
`extruder_type`, `nozzle_volume_type` and `default_nozzle_volume_type` to the enums
|
||||||
|
([variant names](validation.md#variant-names)). The choice of variants, the variant order and
|
||||||
|
the measured values are not checked.
|
||||||
|
6. In the app, for each nozzle volume type in the sidebar combo: slice and confirm the G-code uses that
|
||||||
|
variant's values (e.g. volumetric speed limit, retraction). `validate_slice` only slices the default
|
||||||
|
nozzle volume type.
|
||||||
|
|
||||||
|
To review rather than author, run the same list against the diff. `check` catches a wrong array length,
|
||||||
|
a layout key out of step, and a variant string the enums cannot build. The failures no check catches: a
|
||||||
|
new variant appended instead of inserted at its index, an array widened on a base whose list is
|
||||||
|
shorter (cut before any child inherits it), a process lacking a pair its printer can select, and a High
|
||||||
|
Flow variant copied from Standard.
|
||||||
|
|
||||||
|
### UI facts to design around
|
||||||
|
|
||||||
|
- The single-extruder sidebar shows a **nozzle volume type combo** (tooltip `Flow`, in place of the
|
||||||
|
nozzle-diameter selector) only when `extruder_variant_list` offers more than one distinct variant
|
||||||
|
string (`support_different_extruders`); four extruders listing `Direct Drive Standard` each show
|
||||||
|
none.
|
||||||
|
- The two-extruder sidebar's per-extruder nozzle volume type combos are shown for BBL printers only.
|
||||||
|
Another vendor's multi-extruder printer falls back to the single-extruder layout: one combo (for the
|
||||||
|
first extruder) when the variants differ, otherwise the nozzle-diameter selector, so the other
|
||||||
|
extruders' nozzle volume type stays at `default_nozzle_volume_type`.
|
||||||
|
- High Flow is hidden from the combo when `printer_variant` is `0.2` or the printer model is
|
||||||
|
`Bambu Lab X1E`, and E3D High Flow unless `printer_variant` is `0.4` or `0.6`. A variant listed on
|
||||||
|
another nozzle diameter is never selectable there.
|
||||||
|
- The filament tab shows a variant switch built from the filament's own `filament_extruder_variant`;
|
||||||
|
a multi-variant filament whose tab shows none did not resolve its variant list through `include` or
|
||||||
|
`inherits`. The printer and process tabs show one entry per extruder instead, labelled with that
|
||||||
|
extruder's live nozzle volume type (two for Hybrid), and only on two-extruder printers whose
|
||||||
|
`extruder_variant_list` offers more than one variant string; elsewhere they edit the variant of the
|
||||||
|
nozzle volume type selected in the sidebar (an X1C shows no switch).
|
||||||
|
|
||||||
|
### Worked examples in the tree
|
||||||
|
|
||||||
|
`BBL/machine/fdm_bbl_3dp_001_common.json` (one extruder), `fdm_bbl_3dp_002_common.json` (two
|
||||||
|
extruders), `Bambu Lab X1 Carbon 0.4 nozzle.json` (Standard + High Flow), `Bambu Lab H2D 0.4
|
||||||
|
nozzle.json` (extruders with different variant sets), `Bambu Lab X2D 0.4 nozzle.json` (Direct Drive +
|
||||||
|
Bowden), with their `@BBL` processes and filaments. The H2D and X2D examples also carry
|
||||||
|
`E3D High Flow` variants. BBL's multi-variant filament lists come from `fdm_filament_template_*`
|
||||||
|
presets that the printer-specific filaments pull in with `include`, not from their `inherits` chain.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Filament profiles and OrcaFilamentLibrary
|
# Filament profiles and OrcaFilamentLibrary
|
||||||
|
|
||||||
`OrcaFilamentLibrary` is the filament-only bundle the loader reads **first**; its config map
|
`OrcaFilamentLibrary` is the filament-only bundle the loader reads **first**, so any vendor's filament may
|
||||||
becomes the base bundle, so any vendor may inherit a library preset by name. It is the only cross-bundle
|
inherit a library preset by name. It is the only cross-bundle parent: vendor-to-vendor inheritance
|
||||||
parent — vendor-to-vendor inheritance always fails.
|
always fails.
|
||||||
|
|
||||||
## Where a filament goes
|
## Where a filament goes
|
||||||
|
|
||||||
@@ -10,63 +10,65 @@ parent — vendor-to-vendor inheritance always fails.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Generic material for all printers | `OrcaFilamentLibrary/filament/Generic <mat> @System.json` |
|
| Generic material for all printers | `OrcaFilamentLibrary/filament/Generic <mat> @System.json` |
|
||||||
| A brand's product, all printers | `OrcaFilamentLibrary/filament/<Brand>/` |
|
| A brand's product, all printers | `OrcaFilamentLibrary/filament/<Brand>/` |
|
||||||
| A brand's tune for one printer | `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/` — recommended; `<PrinterVendor>/filament/<Brand>/` also works |
|
| A brand's tune for one printer vendor | `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/` (recommended); `<PrinterVendor>/filament/<Brand>/` also works |
|
||||||
| A printer vendor's tune of a generic or its own product | `<Vendor>/filament/` |
|
| A printer vendor's tune of a generic, or its own product | `<PrinterVendor>/filament/` |
|
||||||
|
|
||||||
Both locations for the last-but-one row are supported: `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/<Name>.json`
|
Both locations in the third row are supported: `OrcaFilamentLibrary/filament/<Brand>/<PrinterVendor>/<Name>.json`
|
||||||
(the shape the wiki shows) and `<PrinterVendor>/filament/<Brand>/`. The library path is the one a
|
(the shape the wiki shows) and `<PrinterVendor>/filament/<Brand>/`. The library path is the one a
|
||||||
filament vendor should contribute to — `OrcaFilamentLibrary/filament/<Brand>/` is the brand's own
|
filament brand should contribute to: `OrcaFilamentLibrary/filament/<Brand>/` is the brand's own folder,
|
||||||
folder, while a printer vendor's folder belongs to that printer vendor. Brand tunes do ship under
|
while a printer vendor's folder belongs to that printer vendor.
|
||||||
printer vendors' folders today (Polymaker and SUNLU among others).
|
|
||||||
|
|
||||||
Library layout: `filament/base/fdm_filament_*.json` type roots, root-level `Generic <mat> @System.json`
|
Library layout: `filament/base/fdm_filament_*.json` material roots, root-level
|
||||||
generics, and one subfolder per brand, which may nest printer-specific tunes one level deeper. Adding
|
`Generic <mat> @System.json` generics, and one subfolder per brand, which may nest printer-specific
|
||||||
a brand means adding a folder here; the folder name is a directory label only — `filament_vendor` inside the JSON is the real vendor string.
|
tunes one level deeper. Adding a brand means adding a folder here; the folder name is a directory label
|
||||||
|
only, and `filament_vendor` inside the JSON is the real vendor string.
|
||||||
|
|
||||||
## The three-part shape
|
## The three-part shape
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// Fiberon PA6-CF @base.json — the product root, holds identity + material values
|
// OrcaFilamentLibrary/filament/Polymaker/Fiberon PA6-CF @base.json — the product root: identity + material values
|
||||||
{ "type": "filament", "name": "Fiberon PA6-CF @base", "from": "system",
|
{ "type": "filament", "name": "Fiberon PA6-CF @base", "from": "system",
|
||||||
"instantiation": "false", "inherits": "fdm_filament_pa",
|
"instantiation": "false", "inherits": "fdm_filament_pa",
|
||||||
"filament_id": "OFkOviHk", // generated here; variants inherit it
|
"filament_id": "OFkOviHk", // minted here by generate-id; every child inherits it
|
||||||
"filament_vendor": ["Polymaker"], "filament_type": ["PA6-CF"], /* … */ }
|
"filament_vendor": ["Polymaker"], "filament_type": ["PA6-CF"], /* … */ }
|
||||||
|
|
||||||
// Fiberon PA6-CF @System.json — the selectable shim, 7 keys
|
// OrcaFilamentLibrary/filament/Polymaker/Fiberon PA6-CF @System.json — the selectable all-printer shim, 7 keys
|
||||||
{ "type": "filament", "name": "Fiberon PA6-CF @System", "from": "system",
|
{ "type": "filament", "name": "Fiberon PA6-CF @System", "from": "system",
|
||||||
"instantiation": "true", "inherits": "Fiberon PA6-CF @base",
|
"instantiation": "true", "inherits": "Fiberon PA6-CF @base",
|
||||||
"setting_id": "…", "compatible_printers": [] }
|
"setting_id": "…", "compatible_printers": [] }
|
||||||
|
|
||||||
// <PrinterVendor>/filament/Polymaker/Fiberon PA6-CF @BBL X1C.json — a printer tune
|
// BBL/filament/Polymaker/Fiberon PA6-CF @BBL X1C.json — a printer tune (BBL keeps its own copy of the @base)
|
||||||
{ … "inherits": "Fiberon PA6-CF @base", "filament_max_volumetric_speed": ["14"],
|
{ …, "inherits": "Fiberon PA6-CF @base", "filament_max_volumetric_speed": ["14"],
|
||||||
"compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", …] }
|
"compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", …] }
|
||||||
```
|
```
|
||||||
|
|
||||||
- `@base` is the convention for a root. A base carries **no** `setting_id`, no `compatible_printers`, no
|
- `@base` is the convention for a root; a root is really `instantiation: "false"`. A base carries
|
||||||
`filament_settings_id`. Only the `setting_id` half is enforced, and nothing violates it; the other two
|
**no** `setting_id`, no `compatible_printers` and no `filament_settings_id`. Only the `setting_id`
|
||||||
are unchecked and plenty of bases still carry them. Do not copy that from a neighbouring file.
|
half is enforced; the other two are unchecked, so a neighbouring base that carries them is no model.
|
||||||
- Every `@System` must be `"instantiation": "true"`. DREMC ships `@System` presets set to `"false"`,
|
- Every `@System` shim must be `"instantiation": "true"`; one set to `"false"` would ship but could
|
||||||
which therefore ship but can never be selected; no check catches it.
|
never be selected, and no check catches it. The shim exists only for products in the library.
|
||||||
- A duplicated brand `@base` across bundles is normal and intentional (`Fiberon PA6-CF @base` exists in
|
- `filament_cost`, `filament_density`, `filament_type` and `filament_vendor` belong on the root and
|
||||||
both the library and BBL with the same id, differing only in MVS) — bases never enter the preset
|
should not appear in a printer tune.
|
||||||
collection, so there is no duplicate-name error.
|
- A brand `@base` duplicated across bundles is legal (a vendor bundle may keep its own copy of a
|
||||||
- You may inherit from an instantiated preset as well as from a base; it is common.
|
library product root, with the same id): bases never become selectable presets and the
|
||||||
|
duplicate-name error covers only those, so there is none.
|
||||||
|
- You may inherit from an instantiated preset as well as from a base.
|
||||||
|
|
||||||
## Color is a runtime property
|
## Colour is a runtime property
|
||||||
|
|
||||||
`filament_id` identifies a product, not a color; filament sync/AMS reads the color from the spool at
|
`filament_id` identifies a product, not a colour; filament sync and AMS read the colour from the spool
|
||||||
runtime. A product ships one all-printer preset and the color is chosen at runtime — never a sibling
|
at runtime. A product ships one all-printer preset and the colour is chosen at runtime, never a sibling
|
||||||
preset that differs only by color. A material family (PLA vs PLA Matte vs PLA Silk) is a new product; a
|
preset that differs only by colour. A material family (PLA vs PLA Matte vs PLA Silk) is a new product;
|
||||||
color is not. A printer tune keeps the product alias and does not multiply per color either.
|
a colour is not. A printer tune keeps the product alias and does not multiply per colour either.
|
||||||
|
|
||||||
CI does not catch this — per-color presets pass `check` — so it is a review call.
|
CI does not catch this (per-colour presets pass `check`), so it is a review call.
|
||||||
|
|
||||||
## The two most common contributions
|
## The two most common contributions
|
||||||
|
|
||||||
**A printer vendor tuning a generic.** Keep the `Generic X` base name so the alias shadows the library
|
**A printer vendor tuning a generic.** Keep the `Generic X` alias so it shadows the library preset on
|
||||||
preset on your printers, inherit `Generic X @System`, declare **no** `filament_id` (inheriting the
|
your printers, inherit `Generic X @System`, declare **no** `filament_id` (inheriting the library's is
|
||||||
library's is correct — the product really is the library's generic), and give it a non-empty
|
correct: the product really is the library's generic), and give it a non-empty `compatible_printers` in
|
||||||
`compatible_printers` in its own body:
|
its own file:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// <Vendor>/filament/Generic PETG @Acme One 0.4 nozzle.json
|
// <Vendor>/filament/Generic PETG @Acme One 0.4 nozzle.json
|
||||||
@@ -76,13 +78,14 @@ library's is correct — the product really is the library's generic), and give
|
|||||||
"compatible_printers": ["Acme One 0.4 nozzle"] }
|
"compatible_printers": ["Acme One 0.4 nozzle"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
**A printer vendor's own branded product.** Give it a `@base` root so `generate-id` can mint the id (see
|
**A printer vendor's own branded product.** Give it a `@base` root on a material base so `generate-id`
|
||||||
[ids.md](ids.md) — inheriting `Generic X @System` directly makes the id unfixable by the tool), then one
|
can mint the id, then one instantiated leaf per printer in the same bundle. Inheriting
|
||||||
instantiated leaf per printer in the same bundle. No `@System` shim: that is only for a product entering
|
`Generic X @System` directly gives the product the generic's id, which the tool cannot fix
|
||||||
OrcaFilamentLibrary.
|
([ids.md](ids.md#what-generate-id-does-and-does-not-fix)). No `@System` shim: that is only for a product
|
||||||
|
entering OrcaFilamentLibrary.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// <Vendor>/filament/Acme Aura PETG @base.json — instantiation false, no setting_id
|
// <Vendor>/filament/Acme Aura PETG @base.json — instantiation false, no setting_id
|
||||||
{ "type": "filament", "name": "Acme Aura PETG @base", "from": "system",
|
{ "type": "filament", "name": "Acme Aura PETG @base", "from": "system",
|
||||||
"instantiation": "false", "inherits": "fdm_filament_pet",
|
"instantiation": "false", "inherits": "fdm_filament_pet",
|
||||||
"filament_vendor": ["Acme"], "filament_type": ["PETG"] } // filament_id minted here
|
"filament_vendor": ["Acme"], "filament_type": ["PETG"] } // filament_id minted here
|
||||||
@@ -94,174 +97,190 @@ OrcaFilamentLibrary.
|
|||||||
"compatible_printers": ["Acme One 0.4 nozzle"] }
|
"compatible_printers": ["Acme One 0.4 nozzle"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Omit `filament_settings_id` from new presets — it is runtime bookkeeping the app rewrites to the preset
|
Omit `filament_settings_id` from new presets: it is runtime bookkeeping the app rewrites to the preset
|
||||||
name.
|
name.
|
||||||
|
|
||||||
|
To offer either kind by default, add its name to each model's `default_materials`; put it first in the
|
||||||
|
machine's `default_filament_profile` only if it should be the preselected filament
|
||||||
|
([machine keys](machine-profiles.md#other-keys)).
|
||||||
|
|
||||||
## `compatible_printers`
|
## `compatible_printers`
|
||||||
|
|
||||||
- **Library fallbacks:** empty `[]` or absent, so they are offered on all printers except where
|
- **Library fallbacks** (`@System`): empty `[]` or absent, so they are offered on all printers except
|
||||||
[alias shadowing](#alias-shadowing) supplies a printer-specific tune.
|
where [alias shadowing](#alias-shadowing) supplies a printer-specific tune.
|
||||||
- **Library printer-specific tunes:** non-empty, listing exact printer **variant** names. These can
|
- **Library printer-specific tunes**: non-empty, listing exact printer **variant** names. These
|
||||||
supersede a same-alias fallback just like a tune in a printer vendor's bundle.
|
supersede a same-alias fallback just like a tune in a printer vendor's bundle.
|
||||||
- **Instantiated filaments in every other vendor:** non-empty, listing exact printer **variant** names.
|
- **Instantiated filaments in every other vendor**: non-empty, listing exact printer **variant** names.
|
||||||
Enforced twice but not identically: the C++ `has_errors` reads the *flattened* config, so an inherited list satisfies it,
|
Enforced twice, but not identically: `validate_system` reads the resolved config, so an inherited list
|
||||||
while the Python check reads the file's **own** key. Write the list in the file itself. This is the
|
satisfies it, while `check` reads the file's **own** key. Write the list in the file itself. This is
|
||||||
most common filament CI failure.
|
the most common filament CI failure.
|
||||||
- Emptying it to "make it apply everywhere" fails that check *and* creates a duplicate-`filament_id`
|
- Emptying it to "make it apply everywhere" fails that check *and* collides with the library generic's
|
||||||
collision against the library generic on every printer.
|
`filament_id` on every printer.
|
||||||
- Copying a base's full printer list onto a nozzle-specific variant produces duplicate combobox entries —
|
- Copying a base's full printer list onto a nozzle-specific tune produces duplicate combobox entries: a
|
||||||
a real shipped bug twice over.
|
real shipped bug twice over.
|
||||||
|
|
||||||
## Overlapping coverage: one variant, one profile per product
|
## Overlapping coverage: one variant, one profile per product
|
||||||
|
|
||||||
`filament_id` is the **product** key, not the preset key — every variant of one product shares it
|
`filament_id` is the **product** key, not the preset key: every preset of one product shares it
|
||||||
(`<filament_vendor>/<filament_type>/<name-before-@>`). So if one printer variant appears in the
|
(`<filament_vendor>/<filament_type>/<alias>`). So if one printer variant appears in the
|
||||||
`compatible_printers` of two presets of that product, the slicer cannot tell them apart at AMS match time.
|
`compatible_printers` of two presets of that product, the slicer cannot tell them apart at AMS match
|
||||||
The C++ validator reports `Ambiguous AMS filament match: N presets share filament_id "X" … printer "Y"`.
|
time. `validate_system` reports `Ambiguous AMS filament match: N filament presets share filament_id "X"
|
||||||
`orca_profile_tool.py check` does **not** see it and passes. Resolve the overlap by **specificity**: keep
|
and are all compatible with printer "Y"`; `orca_profile_tool.py check` does **not** see it and passes.
|
||||||
the variant on the most specific profile and remove it from every more general one. Deleting a profile is
|
Resolve the overlap by **specificity**: keep the variant on the most specific profile and remove it from
|
||||||
the least preferred fix — moving coverage keeps the tune that users rely on.
|
every more general one. Deleting a profile is the least preferred fix: moving coverage keeps the tune
|
||||||
|
that users rely on.
|
||||||
|
|
||||||
Judge specificity from the profile's `compatible_printers` — how many variants it actually covers — and
|
Judge specificity from the profile's `compatible_printers` (how many variants it actually covers) and
|
||||||
use the name only as a secondary, easily-vague hint; decide by the lists, with a best judgement call on
|
use the name only as a secondary, often vague hint; decide by the lists, with a judgement call on the
|
||||||
the name. Naming conventions differ by vendor: BBL's is the reference (`@<Vendor> <Model>` for a whole
|
name. Naming conventions differ by vendor: BBL's is the reference (`@<Vendor> <Model>` for a whole
|
||||||
model, `@<Vendor> <Model> <nozzle> nozzle` for one variant, `@<Vendor>` for a vendor-wide generic), but
|
model, `@<Vendor> <Model> <nozzle> nozzle` for one variant, `@<Vendor>` for a vendor-wide generic), but
|
||||||
others vary (`@<printer model>`, a printer serial, or Creality's `@<Model>-all`). A name never overrides
|
others vary (`@<printer model>`, a printer serial, or Creality's `@<Model>-all`). A name never overrides
|
||||||
the list — see [preset naming](naming.md) for the shapes.
|
the list; see [preset naming](naming.md#filament) for the shapes.
|
||||||
|
|
||||||
Specificity, most to least:
|
Specificity, most to least:
|
||||||
|
|
||||||
1. **Variant-specialized** — lists a single printer variant (BBL-style
|
1. **Variant-specialized**: lists a single printer variant (BBL-style
|
||||||
`... @<Vendor> <Model> <nozzle> nozzle`).
|
`… @<Vendor> <Model> <nozzle> nozzle`).
|
||||||
2. **Model-specialized** — lists the variants of one printer model (BBL-style `... @<Vendor> <Model>`).
|
2. **Model-specialized**: lists the variants of one printer model (BBL-style `… @<Vendor> <Model>`). It
|
||||||
It should cover every variant of its model, not only the nozzle it was authored for.
|
should cover every variant of its model, not only the nozzle it was authored for.
|
||||||
3. **Family / series** — lists variants spanning a printer family or series.
|
3. **Family / series**: lists variants spanning a printer family or series.
|
||||||
4. **Generic / catch-all** — vendor-wide, covering many unrelated models (often the bare
|
4. **Generic / catch-all**: vendor-wide, covering many unrelated models (often the bare
|
||||||
`Generic <mat> @<Vendor>`).
|
`Generic <mat> @<Vendor>`).
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- A model-specialized profile is extended to **all** variants of its model, and each variant it thereby
|
- A model-specialized profile is extended to **all** variants of its model, and each variant it thereby
|
||||||
starts covering is removed from the family and generic profiles that also listed it — including variants
|
starts covering is removed from the family and generic profiles that also listed it, including
|
||||||
that had no overlap before. Apply it per nozzle, not just 0.4.
|
variants that had no overlap before. Apply it per nozzle, not just 0.4.
|
||||||
- Apply it **per product**: trim only the material that has a specialized profile from the generic; a
|
- Apply it **per product**: trim only the material that has a specialized profile from the generic; a
|
||||||
material whose product has no specialized profile keeps the variant in the generic.
|
material whose product has no specialized profile keeps the variant in the generic.
|
||||||
- Never strip coverage a variant has nowhere else to get. If a variant has no variant-level specialized
|
- Never strip coverage a variant has nowhere else to get. If a variant has no variant-level specialized
|
||||||
profile, the next level down keeps it; when the model has specialized profiles, the model-level one wins
|
profile, the next level down keeps it; when the model has specialized profiles, the model-level one
|
||||||
over the family/generic.
|
wins over the family and generic ones.
|
||||||
- Moving coverage is preferred over deleting. If a profile must be deleted, remove the more general
|
- Moving coverage is preferred over deleting. If a profile must be deleted, remove the more general one,
|
||||||
one, not the specialized profile that carries the tune.
|
not the specialized profile that carries the tune.
|
||||||
- After moving coverage, repoint the affected `default_filament_profile` (machine) and clean the model's
|
- After moving coverage, repoint the affected machine's `default_filament_profile` and clean the model's
|
||||||
`default_materials`: they should name the most specific profile that covers the variant, and should not
|
`default_materials`: they should name the most specific profile that covers the variant, and should
|
||||||
keep generic entries that no longer cover the model. This rule applies equally when adding or fixing
|
not keep generic entries that no longer cover the model. This rule applies equally when adding or
|
||||||
defaults.
|
fixing defaults.
|
||||||
|
|
||||||
Multiple profiles of one product with **disjoint** `compatible_printers` is the intended end state.
|
Several profiles of one product with **disjoint** `compatible_printers` is the intended end state.
|
||||||
Adding coverage to the specialized profile and removing it from the generic is the preferred direction.
|
Adding coverage to the specialized profile and removing it from the generic is the preferred direction.
|
||||||
|
|
||||||
**Detection caveat:** `orca_profile_tool.py check` is blind to this; only the C++ validator behind the full
|
**Detection caveat:** `orca_profile_tool.py check` is blind to this; only the validator behind the full
|
||||||
`./scripts/check_profile.sh` reports it (`validate_system`). Always confirm with that, not the vendor-scoped
|
`./scripts/check_profile.sh` reports it (`validate_system`). Always confirm with that, not the
|
||||||
loop.
|
vendor-scoped loop.
|
||||||
|
|
||||||
## Alias shadowing
|
## Alias shadowing
|
||||||
|
|
||||||
A printer-specific filament in either the library or a vendor bundle supersedes the library fallback
|
A printer-specific filament in either the library or a vendor bundle supersedes the library fallback on
|
||||||
on the printers it lists. The matching key is the **alias**: the preset name up to the **first** `@`,
|
the printers it lists. The matching key is the **alias**: the preset name up to the **first** `@`,
|
||||||
right-trimmed (no `@` → the whole name). So
|
right-trimmed (no `@` → the whole name). So `QIDI ABS-GF@Q2-Series` aliases to `QIDI ABS-GF`.
|
||||||
`QIDI ABS-GF@Q2-Series` aliases to `QIDI ABS-GF`.
|
|
||||||
|
|
||||||
A library preset with an empty `compatible_printers` collects, into `m_excluded_from`, every printer named
|
A library preset with an empty `compatible_printers` is hidden on every printer that a same-alias preset
|
||||||
by any same-alias preset that *has* a non-empty list, and is then hidden on those printers.
|
lists in a non-empty `compatible_printers`, whether that preset is in the library or in a vendor bundle
|
||||||
|
(a printer matches by its own name or its parent's).
|
||||||
|
|
||||||
Two consequences:
|
Two consequences:
|
||||||
|
|
||||||
- **Only an unrestricted library fallback can be shadowed.** Two printer-specific presets sharing
|
- **Only an unrestricted library fallback can be shadowed.** Two printer-specific presets sharing an
|
||||||
an alias do not exclude each other — overlapping lists for the same product trip the
|
alias do not hide each other; overlapping lists for the same product trip the ambiguous-match error
|
||||||
duplicate-`filament_id` check instead.
|
above instead.
|
||||||
- This is why adding `Generic PLA @<printer>` to a vendor silently removes the library `Generic PLA`
|
- This is why adding `Generic PLA @<printer>` to a vendor silently removes the library
|
||||||
from that printer. Intended — and the reason a vendor tuning a generic must **keep the `Generic X`
|
`Generic PLA @System` from that printer. That is intended, and the reason a vendor tuning a generic
|
||||||
base name**.
|
must **keep the `Generic X` alias**.
|
||||||
|
|
||||||
The literal spelling `Generic <mat> @System` is load-bearing beyond shadowing: `find_preset2` rewrites an
|
The literal spelling `Generic <mat> @System` is load-bearing beyond shadowing: when a user preset, an
|
||||||
unresolved name containing "Generic" into that form and retries against the library, which is how 3MF
|
imported preset or a 3MF project names a parent that no longer resolves and contains `Generic`, the
|
||||||
and project recovery works.
|
loader rewrites the name into `Generic <mat> @System` and retries. Only the library ships those names,
|
||||||
|
so keep them.
|
||||||
|
|
||||||
## `filament_id`, `filament_vendor`, `filament_type`
|
## `filament_id`, `filament_vendor`, `filament_type`
|
||||||
|
|
||||||
`filament_id` is minted from the triple `(filament_vendor, filament_type, name-before-first-@)`.
|
`filament_id` is minted from the triple `(filament_vendor, filament_type, alias)`. `filament_vendor` and
|
||||||
`filament_vendor` and `filament_type` are therefore **identity, not decoration** — editing either
|
`filament_type` are therefore **identity, not decoration**: editing either, or the alias, re-mints the
|
||||||
re-mints the id. Read `docs/HLSD/filament_id.md` before changing any of them, and see
|
id. Read `docs/HLSD/filament_id.md` before changing any of them, and see [ids.md](ids.md) for the
|
||||||
[ids.md](ids.md) for the tooling.
|
tooling.
|
||||||
|
|
||||||
A filament with no resolvable `filament_id` anywhere in its `inherits` chain is a **hard load error** that
|
A filament with no resolvable `filament_id` anywhere in its `inherits` chain is a **hard load error**
|
||||||
discards the vendor bundle. The id inherits across bundles, so a vendor's `Generic ABS @X` inheriting
|
that discards the vendor bundle. The id inherits across bundles, so a vendor's `Generic ABS @X`
|
||||||
`Generic ABS @System` gets the library's id for free; a vendor's own product must resolve its own.
|
inheriting `Generic ABS @System` gets the library's id for free; a vendor's own product must resolve its
|
||||||
|
own.
|
||||||
|
|
||||||
- `filament_type` **must be a JSON array** — the one vector key the Python check enforces. A scalar
|
- `filament_type` **must be a JSON array**: the one vector key `check` rejects as a scalar outright. A
|
||||||
`"PP"` once hung the filament/printer selection UI.
|
scalar `"PP"` once hung the filament and printer selection UI.
|
||||||
- It is an **open** enum: an unlisted value is accepted silently and falls back to 190–300 °C defaults
|
- It is an **open** enum: an unlisted value is accepted silently and falls back to 190–300 °C defaults
|
||||||
and adhesion 1.0. Off-list values do ship. Prefer a value from `MaterialType::all()` in
|
and adhesion 1.0. Prefer a value from `MaterialType::all()` in
|
||||||
`src/libslic3r/MaterialType.cpp`, or add a row there.
|
`src/libslic3r/MaterialType.cpp`, or add a row there.
|
||||||
- Generics use `filament_vendor: ["Generic"]`, which `fdm_filament_common` already defaults to.
|
- Generics use `filament_vendor: ["Generic"]`, which `fdm_filament_common` already defaults to.
|
||||||
|
|
||||||
## `"nil"`
|
## `"nil"`
|
||||||
|
|
||||||
Legal in any key whose `ConfigOptionDef` is `nullable`. In a filament preset that is most of the
|
`"nil"` is legal only in an option defined as nullable (`add_nullable`, or `nullable = true`, in
|
||||||
`filament_*` family, plus `long_retractions_when_ec` and `retraction_distances_when_ec`. About half are
|
`src/libslic3r/PrintConfig.cpp`). Anywhere else it fails the file, and with it the **whole bundle**
|
||||||
the extruder overrides (`filament_retraction_length`, `filament_z_hop`, `filament_wipe`,
|
(`Failed loading configuration file`, after `Deserializing nil into a non-nullable object` or
|
||||||
`filament_retract_*`, `filament_retraction_speed`, `filament_deretraction_speed`,
|
`Invalid value provided for parameter <key>: nil`). To leave a non-nullable key unset, omit it; do not
|
||||||
`filament_retraction_minimum_travel`, `filament_wipe_distance`, `filament_long_retractions_when_cut`,
|
write `nil`.
|
||||||
`filament_retraction_distances_when_cut`, …), where `nil` means *keep the printer/extruder's own value*.
|
|
||||||
The rest are ordinary nullable options (`filament_flow_ratio`, `filament_flush_temp`,
|
|
||||||
`filament_adaptive_volumetric_speed`, …) where it means *unset*.
|
|
||||||
|
|
||||||
Anywhere else it throws `Deserializing nil into a non-nullable object`. To not set a non-nullable key,
|
In filament presets a minority of the `filament_*` keys are nullable, plus `long_retractions_when_ec`
|
||||||
omit it — do not write `nil`.
|
and `retraction_distances_when_ec`. About half of them are the extruder overrides (`filament_retraction_length`,
|
||||||
|
`filament_z_hop`, `filament_wipe`, `filament_retract_*`, `filament_retraction_speed`,
|
||||||
|
`filament_deretraction_speed`, `filament_retraction_minimum_travel`, `filament_wipe_distance`,
|
||||||
|
`filament_long_retractions_when_cut`, `filament_retraction_distances_when_cut`, …), where `nil` means
|
||||||
|
*keep the printer's or extruder's own value*. The rest are ordinary nullable options
|
||||||
|
(`filament_flow_ratio`, `filament_flush_temp`, `filament_adaptive_volumetric_speed`, …), where it means
|
||||||
|
*unset*. Check the option's definition before writing `nil` anywhere else.
|
||||||
|
|
||||||
## What to review per nozzle
|
## Tuning per nozzle and per variant
|
||||||
|
|
||||||
Across `@X` / `@X 0.N nozzle` sibling pairs the keys that differ, most often first, are
|
Between a product's `@X` and `@X 0.N nozzle` tunes the keys that usually differ, most often first, are
|
||||||
`filament_max_volumetric_speed`, `filament_retraction_length`, `slow_down_min_speed`,
|
`filament_max_volumetric_speed`, `filament_retraction_length`, `slow_down_min_speed`,
|
||||||
`filament_flow_ratio`, `slow_down_layer_time`, `nozzle_temperature` and `pressure_advance`.
|
`filament_flow_ratio`, `slow_down_layer_time`, `nozzle_temperature` and `pressure_advance` (switched on
|
||||||
`filament_cost`, `filament_density`, `filament_type` and `filament_vendor` belong on the `@base` and
|
by `enable_pressure_advance`).
|
||||||
should not appear in a printer tune.
|
|
||||||
|
|
||||||
Use measured values for the material, hotend, extruder and nozzle combination. Neither maximum
|
Use measured values for the material, hotend, extruder and nozzle combination. Neither maximum
|
||||||
volumetric speed nor pressure advance has a universal nozzle-only lookup table. When cloning a
|
volumetric speed nor pressure advance has a universal nozzle-only lookup table. When cloning a 0.4
|
||||||
0.4 preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure-advance value
|
preset for a 0.2 nozzle, explicitly revisit flow limits; do not infer a pressure-advance value, or a
|
||||||
or a required direction of change from diameter alone.
|
required direction of change, from diameter alone.
|
||||||
|
|
||||||
## Style
|
On a printer with extruder variants, a filament tunes these per variant too:
|
||||||
|
`filament_max_volumetric_speed`, `filament_flow_ratio`, `nozzle_temperature`, pressure advance and the
|
||||||
Overrides, not full copies: a typical instantiated filament preset carries around a dozen non-meta keys,
|
retraction overrides carry one value per variant of `filament_extruder_variant` (Standard, High Flow, …). The
|
||||||
and a library leaf two or three. Presets that restate fifty-plus keys from their parent do still ship —
|
exact key set is [`filament_options_with_variant`](extruder-variants.md#the-four-key-sets);
|
||||||
Phrozen's single filament preset is that style — but they are the pattern to move away from, not to
|
`slow_down_min_speed` and `fan_max_speed` are not in it. Keep every such array at exactly that width, even where the
|
||||||
copy. Commit `6943b6ddc3` is the stated model (flip true bases to `instantiation: "false"`, strip
|
setting does not differ per variant, and measure the High Flow variant rather than copying Standard
|
||||||
`compatible_printers`/`setting_id`/`filament_settings_id`, add `renamed_from` on the survivor).
|
([extruder-variants.md](extruder-variants.md#filament)).
|
||||||
|
|
||||||
Prefer the library's `fdm_filament_*` bases over a vendor-local copy. Phrozen's local
|
|
||||||
`fdm_filament_common` has drifted from the library's.
|
|
||||||
|
|
||||||
Canonical key order, written by `orca_profile_tool.py normalize` when it rewrites a file: `type`, `name`,
|
|
||||||
`renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`, `instantiation`, then everything else in
|
|
||||||
the order you wrote it. Not enforced — a file that leads with `compatible_printers` passes `check`.
|
|
||||||
|
|
||||||
**Every vector-typed (`co*s`) key must be a JSON array.** Only `filament_type` is an outright error, but
|
|
||||||
`normalize` silently arrayifies five more (`filament_cost`, `filament_density`,
|
|
||||||
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`) and `check` fails when
|
|
||||||
it would. Every other vector key is on you — including `filament_start_gcode`, `filament_end_gcode`,
|
|
||||||
`filament_extruder_variant`, `compatible_printers` and the plate temperatures.
|
|
||||||
|
|
||||||
## Bed temperature is twelve keys, not one
|
## Bed temperature is twelve keys, not one
|
||||||
|
|
||||||
There is no single "bed temperature". Which plate key applies depends on `curr_bed_type`, whose six
|
There is no single "bed temperature". The plate type selected for the printer (`Cool Plate`,
|
||||||
selectable values (`btPC`, `btEP`, `btPEI`, `btPTE`, `btPCT`, `btSuperTack`; `btDefault` maps to no key)
|
`Engineering Plate`, `High Temp Plate`, `Textured PEI Plate`, `Textured Cool Plate`, `Supertack Plate`)
|
||||||
`get_bed_temp_key()` turns into `cool_plate_temp`, `eng_plate_temp`, `hot_plate_temp`,
|
picks one of six keys, each with an `_initial_layer` twin: `cool_plate_temp`, `eng_plate_temp`,
|
||||||
`textured_plate_temp`, `textured_cool_plate_temp` and `supertack_plate_temp` — each with an
|
`hot_plate_temp`, `textured_plate_temp`, `textured_cool_plate_temp` and `supertack_plate_temp`.
|
||||||
`*_initial_layer` twin.
|
|
||||||
|
|
||||||
`textured_cool_plate_temp` is the one most often forgotten. A printer with `support_multi_bed_types` off
|
`textured_cool_plate_temp` is the one most often forgotten. A non-BBL printer with
|
||||||
hides the selector, and the printer preset's
|
`support_multi_bed_types` off hides the plate selector and uses the printer preset's `default_bed_type`
|
||||||
`default_bed_type` decides which plate is selected for it, but `curr_bed_type` can still hold a stale
|
(High Temp Plate, `hot_plate_temp`, when unset or invalid), but a loaded project or a CLI config can
|
||||||
value carried over from another printer — so set every plate the printer plausibly has, as the sibling
|
still carry another plate. So set every plate the printer plausibly has, as the sibling presets in the
|
||||||
presets in the bundle do.
|
bundle do.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Overrides, not full copies: an instantiated filament preset carries around a dozen non-meta keys,
|
||||||
|
and a library `@System` shim two or three. A preset that restates fifty-plus keys from its parent is
|
||||||
|
the pattern to move away from, not to copy. Commit `6943b6ddc3` is the stated model for converting such presets: flip true
|
||||||
|
bases to `instantiation: "false"`, strip their `compatible_printers`, `setting_id` and
|
||||||
|
`filament_settings_id`, and add `renamed_from` on the surviving selectable preset. A value every
|
||||||
|
printer tune of a product shares goes on the product's `@base`
|
||||||
|
([shared bases](shared-bases.md#levels)).
|
||||||
|
- Prefer the library's `fdm_filament_*` bases over a vendor-local copy; for a new preset, even in a
|
||||||
|
bundle whose older presets use one: a local copy drifts from the library's.
|
||||||
|
- Canonical key order, written by `orca_profile_tool.py normalize` when it rewrites a file: `type`,
|
||||||
|
`name`, `renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`, `instantiation`, then
|
||||||
|
everything else in the order you wrote it. Not enforced on its own: a file that leads with
|
||||||
|
`compatible_printers` passes `check`.
|
||||||
|
- **Every vector-typed (`co…s`) key must be a JSON array.** Only a scalar `filament_type` is an outright
|
||||||
|
error; `normalize` silently arrayifies five more (`filament_cost`, `filament_density`,
|
||||||
|
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`), and `check` fails
|
||||||
|
when it would. Every other vector key is on you, including `filament_start_gcode`,
|
||||||
|
`filament_end_gcode`, `filament_extruder_variant`, `compatible_printers` and the plate temperatures.
|
||||||
|
|||||||
@@ -1,76 +1,78 @@
|
|||||||
# `setting_id` and `filament_id`
|
# `setting_id` and `filament_id`
|
||||||
|
|
||||||
Orca-generated ids are deterministic hashes of identity. **Never invent an id or copy a sibling's
|
Orca-generated ids are deterministic hashes of identity. **Never invent an id or copy a sibling's
|
||||||
`setting_id`.** Use `scripts/orca_profile_tool.py`; the two special cases are
|
`setting_id`.**
|
||||||
|
Use `scripts/orca_profile_tool.py`; the two special cases are
|
||||||
[a wrongly inherited filament id](#what-generate-id-does-and-does-not-fix) and
|
[a wrongly inherited filament id](#what-generate-id-does-and-does-not-fix) and
|
||||||
[BBL's authoritative setting ids](#bbls-exception-precisely).
|
[BBL's authoritative setting ids](#bbls-exception-precisely).
|
||||||
|
|
||||||
`docs/HLSD/filament_id.md` is the authoritative design document for `filament_id` — the id landscape, the
|
`docs/HLSD/filament_id.md` is the authoritative design document for `filament_id`: the id landscape,
|
||||||
checks CI runs, and the Bambu catalog map. This page is the tooling half.
|
the checks CI runs, and the Bambu catalog map. This page is the tooling half.
|
||||||
|
|
||||||
| | `setting_id` | `filament_id` |
|
| | `setting_id` | `filament_id` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Identifies | one selectable preset | one filament **product** |
|
| Identifies | one selectable preset | one filament **product** |
|
||||||
| Key hashed | `<vendor folder>/<type>/<name>` | `filament_product/<filament_vendor>/<filament_type>/<name-before-@>` |
|
| Key hashed | `<vendor folder>/<type>/<name>` | `filament_product/<filament_vendor>/<filament_type>/<alias>`, using the resolved (inherited) first values and the name up to the first `@`, right-trimmed |
|
||||||
| Shape | 16 base62 chars | `OF` + 6 base62 chars |
|
| Shape | 16 base62 characters | `OF` + 6 base62 characters |
|
||||||
| Required on | every `instantiation: "true"` preset | every **instantiated** filament, own or inherited |
|
| Required on | every `instantiation: "true"` preset | every **instantiated** filament, own or inherited |
|
||||||
| Forbidden on | bases (`instantiation != "true"`) | — (a base is exactly where it belongs) |
|
| Forbidden on | bases (`instantiation` not `"true"`) | — (a product's root base is exactly where it belongs) |
|
||||||
| Scope | globally unique across the tree | shared by every variant of the product, in every bundle |
|
| Scope | unique across the whole tree | shared by every preset of the product, in every bundle |
|
||||||
|
|
||||||
`<type>` is `machine` / `process` / `filament` — the vendor is the **folder** name (`BBL`), not the
|
`<type>` is `machine`, `process` or `filament`, and the vendor is the **folder** name (`BBL`), not the
|
||||||
display name (`Bambulab`). Renaming a preset changes its `setting_id`; renaming a filament, or editing
|
display name (`Bambulab`). Renaming a preset changes its `setting_id`; renaming a filament's alias, or
|
||||||
its `filament_vendor` or `filament_type`, also changes its `filament_id`.
|
editing its `filament_vendor` or `filament_type`, also changes its `filament_id`, and the old id is not
|
||||||
|
forwarded.
|
||||||
|
|
||||||
## The tool
|
## The tool
|
||||||
|
|
||||||
Use `scripts/orca_profile_tool.py` with a subcommand:
|
`scripts/orca_profile_tool.py` takes a subcommand:
|
||||||
|
|
||||||
| Command | Does |
|
| Command | Does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `check` | everything CI's `profile_tool` step runs — see [validation.md](validation.md) |
|
| `check` | everything CI's `profile_tool` step runs; see [validation.md](validation.md#orca_profile_toolpy-check) |
|
||||||
| `generate-id` | writes `setting_id` and `filament_id` |
|
| `generate-id` | writes `setting_id` and `filament_id` |
|
||||||
| `normalize` | rewrites profile files into their canonical shape |
|
| `normalize` | rewrites profile files into their canonical shape |
|
||||||
| `trim` | deletes profile files no `<vendor>.json` list references |
|
| `trim` | deletes profile files no `<Vendor>.json` list references |
|
||||||
| `update-index` | rebuilds the `*_list` sections from the files on disk |
|
| `update-index` | rebuilds the `*_list` sections from the files on disk |
|
||||||
|
|
||||||
The order after adding, renaming or deleting files — each step feeds the next, so it is not
|
The order after adding, renaming or deleting files is `normalize` → `update-index` → `generate-id` →
|
||||||
interchangeable — is `normalize` → `update-index` → `generate-id` → `check`.
|
`check`. Each step feeds the next, so it is not interchangeable. The
|
||||||
The [authoring workflow](../SKILL.md#creating-or-modifying-a-profile) has the commands.
|
[authoring workflow](../SKILL.md#creating-or-modifying-a-profile) has the commands.
|
||||||
|
|
||||||
> **`trim` deletes.** It removes every profile file the index does not list — including the one you just
|
> **`trim` deletes.** It removes every profile file the index does not list, including the one you just
|
||||||
> added and have not registered yet. Register first, or skip `trim` entirely; it is a cleanup sweep, not
|
> added and have not registered yet. Register first, or skip `trim` entirely: it is a cleanup sweep, not
|
||||||
> part of landing a profile. Preview with `--dry-run`.
|
> part of landing a profile. Preview with `--dry-run`.
|
||||||
|
|
||||||
**Register, then mint.** The `filament_id` pass reads `<Vendor>.json`'s `filament_list`, not the
|
**Register, then mint.** The `filament_id` pass reads `<Vendor>.json`'s `filament_list`, not the
|
||||||
filesystem (the `setting_id` pass walks the filesystem, so a bundle whose index has not landed yet is
|
filesystem (the `setting_id` pass walks the filesystem, so a bundle whose index has not landed yet is
|
||||||
still assignable). A new filament file is therefore invisible to `generate-id`'s filament_id pass until
|
still assignable). A new filament file is therefore invisible to `generate-id`'s `filament_id` pass
|
||||||
it is registered — its `setting_id` is written regardless.
|
until it is registered; its `setting_id` is written regardless.
|
||||||
|
|
||||||
- `--dry-run` works on every writing command (`generate-id`, `normalize`, `trim`, `update-index`)
|
- `--dry-run` works on every writing command (`generate-id`, `normalize`, `trim`, `update-index`) and
|
||||||
and writes nothing.
|
writes nothing.
|
||||||
- `--filament-id` / `--setting-id` narrow `generate-id`; they exclude each other, and passing neither
|
- `--filament-id` / `--setting-id` narrow `generate-id` to one pass; they exclude each other, and
|
||||||
writes both.
|
passing neither writes both.
|
||||||
- `--vendor` is repeatable and narrows **only what is written** — the id is a function of the triple
|
- `--vendor` is repeatable and narrows **only what is written**: an id is a function of its own key
|
||||||
alone, so a narrowed run writes exactly what a full run would. An unknown vendor exits 1 before any
|
alone, so a narrowed run writes exactly what a full run would. An unknown vendor exits 1 before any
|
||||||
write. `--vendor` on `check` narrows the per-vendor checks only; the `setting_id` and `filament_id`
|
write. `--vendor` on `check` narrows the per-vendor checks only; the `setting_id` and `filament_id`
|
||||||
passes stay tree-wide.
|
passes stay tree-wide.
|
||||||
- `--profiles DIR` points any command at another tree — see
|
- `--profiles DIR` points any command at another tree; see
|
||||||
[Checking a copy of the tree](validation.md#checking-a-copy-of-the-tree).
|
[Checking a copy of the tree](validation.md#checking-a-copy-of-the-tree).
|
||||||
- `--profile-type` narrows `normalize`, `trim` and `update-index` to `machine_model`, `process`,
|
- `--profile-type` narrows `normalize`, `trim` and `update-index` to `machine_model`, `process`,
|
||||||
`filament` or `machine`.
|
`filament` or `machine`.
|
||||||
- Exit codes: 0 clean, 1 errors found (`generate-id` still writes what it could), 2 argparse misuse.
|
- Exit codes: 0 clean, 1 errors found (`generate-id` still writes what it could), 2 argparse misuse.
|
||||||
- Output is ANSI-coloured; searching for the literal `[ERROR]` still works.
|
- Output is ANSI-coloured; searching for the literal `[ERROR]` still works.
|
||||||
|
|
||||||
`generate-id` is **idempotent and byte-preserving** — BOM and CRLF kept, one key line touched per pass.
|
`generate-id` is **idempotent and byte-preserving**: BOM and CRLF are kept, and each pass touches only
|
||||||
A legitimate `generate-id` diff is one or two changed lines per file: a new instantiated filament gets
|
its one key line. A legitimate `generate-id` diff is one or two changed lines per file: a new instantiated
|
||||||
both a `filament_id` and a `setting_id`, and a BBL file with a misspelled `settings_id` has that line
|
filament gets both a `filament_id` and a `setting_id`. `normalize` is the opposite by design (it
|
||||||
dropped and its value restored under the right key. `normalize` is the opposite by design — it rewrites
|
rewrites whole files into canonical shape), which is why `check` demands it already be a no-op. A file
|
||||||
whole files into canonical shape — which is why `check` demands it already be a no-op. Some bundles have
|
committed with CRLF line endings changes on every line under `normalize`; read the diff before
|
||||||
CRLF committed (OrcaFilamentLibrary, Anycubic and RH3D among them), so a `normalize` pass there rewrites
|
committing it.
|
||||||
every line — read the diff before committing it.
|
|
||||||
|
|
||||||
On a clean tree `check` and `generate-id --dry-run` both exit 0 with zero findings. That is the
|
Exit 1 from `generate-id` does not mean nothing was written: it writes every id it can and reports the
|
||||||
baseline to restore before opening a PR.
|
rest, so read the diff before rerunning. On a clean tree `check` and `generate-id --dry-run` both exit 0
|
||||||
|
with zero findings; that is the baseline to restore before opening a PR.
|
||||||
|
|
||||||
## What `generate-id` does and does not fix
|
## What `generate-id` does and does not fix
|
||||||
|
|
||||||
@@ -78,35 +80,37 @@ Writes:
|
|||||||
|
|
||||||
- a `setting_id` into any instantiated preset that lacks one, or whose value does not match the formula;
|
- a `setting_id` into any instantiated preset that lacks one, or whose value does not match the formula;
|
||||||
- strips a `setting_id` from a base;
|
- strips a `setting_id` from a base;
|
||||||
- deletes the misspelled `settings_id` key;
|
- deletes the misspelled key `settings_id`, moving its value to `setting_id` only on an instantiated BBL
|
||||||
|
preset that lacks one (everywhere else the old value is discarded and a fresh id minted);
|
||||||
- a `filament_id` into the id-less **root(s)** of an instantiated filament that resolves none;
|
- a `filament_id` into the id-less **root(s)** of an instantiated filament that resolves none;
|
||||||
- rewrites a **declared** `filament_id` that is not the mint of its own triple.
|
- rewrites a **declared** `filament_id` that is not the mint of its own triple.
|
||||||
|
|
||||||
Refuses to write (reports only): a base62 collision between two products, an empty `filament_vendor` or
|
Refuses to write (reports only): a base62 collision between two products, an empty `filament_vendor` or
|
||||||
`filament_type`, a broken `inherits` chain, roots of one filament resolving divergent `(vendor, type)`
|
`filament_type`, a broken `inherits` chain, and roots of one filament resolving different
|
||||||
pairs.
|
`(filament_vendor, filament_type)` pairs.
|
||||||
|
|
||||||
**Does not fix: a preset that *inherits* a wrong `filament_id`.** This is check 2b, and it is the trap
|
**Does not fix: a preset that *inherits* a wrong `filament_id`.** This is check 2b (the label `docs/HLSD/filament_id.md` and the tool use), and it is the trap most likely to bite.
|
||||||
most likely to bite. It happens when a branded filament inherits a generic for its settings:
|
It happens when a branded filament inherits a generic for its settings:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "name": "Phrozen Aura PETG @Phrozen Arco 0.4 nozzle",
|
{ "name": "Phrozen Aura PETG @Phrozen Arco 0.4 nozzle",
|
||||||
"inherits": "Generic PETG @System" } // resolves the OFL generic's id — wrong product
|
"inherits": "Generic PETG @System" } // resolves the library generic's id: wrong product
|
||||||
```
|
```
|
||||||
|
|
||||||
The preset resolves *an* id, so `generate-id` neither inserts nor rewrites, and `check` fails with
|
The preset resolves *an* id, so `generate-id` neither inserts nor rewrites one, and `check` fails with
|
||||||
`inherits filament_id "X" but its own triple "V/T/N" mints "Y"`.
|
`inherits filament_id "X" but its own triple "V/T/N" mints "Y"`.
|
||||||
|
|
||||||
Two fixes, in order of preference:
|
Two fixes, in order of preference:
|
||||||
|
|
||||||
1. **Give the product a `@base` root** inheriting a material base (`fdm_filament_pet`,
|
1. **Give the product a `@base` root** inheriting a material base (`fdm_filament_pet`,
|
||||||
`fdm_filament_pla`, …). No `fdm_filament_*` base carries a `filament_id`, so the filament now resolves
|
`fdm_filament_pla`, …) with its own `filament_vendor` and `filament_type`. No `fdm_filament_*` base
|
||||||
none and `generate-id` mints it for you. This is also the shape the rest of the tree uses.
|
carries a `filament_id`, so the filament now resolves none and `generate-id` mints it on the root.
|
||||||
2. **Declare the tool-computed key on the preset itself.** Use the expected value reported by `check`
|
This is the product-root shape ([the three-part shape](filament-profiles.md#the-three-part-shape)).
|
||||||
or compute it with the function below; this is not a manually chosen id. Make sure the preset
|
2. **Declare the tool-computed id on the preset itself.** Use the expected value `check` reports, or
|
||||||
resolves the right `filament_vendor` and `filament_type` first — with
|
compute it with the function below; this is not a manually chosen id. First make sure the preset
|
||||||
neither set, the triple resolves through the generic parent and the branded product is minted
|
resolves the right `filament_vendor` and `filament_type`: with neither set, the triple resolves
|
||||||
under vendor `Generic`. If you need the id before the file exists:
|
through the generic parent and the branded product is minted under vendor `Generic`. If you need the
|
||||||
|
id before the file exists:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -c "import sys; sys.path.insert(0,'scripts'); from orca_profile_tool import generate_filament_id as g; print(g('Polymaker','PLA','PolyLite PLA'))"
|
python3 -c "import sys; sys.path.insert(0,'scripts'); from orca_profile_tool import generate_filament_id as g; print(g('Polymaker','PLA','PolyLite PLA'))"
|
||||||
@@ -114,36 +118,39 @@ Two fixes, in order of preference:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The quoting works unchanged in cmd and PowerShell; only swap `python3` for `py -3`.
|
The quoting works unchanged in cmd and PowerShell; only swap `python3` for `py -3`.
|
||||||
|
`generate_preset_setting_id('<vendor folder>', '<type>', '<name>')` is the `setting_id` equivalent.
|
||||||
|
|
||||||
The `setting_id` equivalent is `generate_preset_setting_id('<vendor folder>', '<type>', '<name>')`.
|
A vendor's tune of a generic that keeps the `Generic X` alias is not this case: inheriting the
|
||||||
|
generic's id is correct there, because the product really is the library generic
|
||||||
|
([filament-profiles.md](filament-profiles.md#the-two-most-common-contributions)).
|
||||||
|
|
||||||
## BBL's exception, precisely
|
## BBL's exception, precisely
|
||||||
|
|
||||||
`RESERVED_VENDORS = {"BBL"}` covers **`setting_id` assignment only**, keyed on the *folder* name:
|
The exception covers **`setting_id` assignment only**, keyed on the *folder* name `BBL`:
|
||||||
|
|
||||||
- The tool never mints or replaces a BBL `setting_id`. A new instantiated BBL preset with no
|
- The tool never mints or replaces a `setting_id` in `BBL/`: those are Bambu's own ids. A new
|
||||||
`setting_id` therefore **cannot be fixed by the tool**, yet the presence rule still applies to it —
|
instantiated BBL preset with no `setting_id` therefore **cannot be fixed by the tool**, yet the
|
||||||
carry over Bambu's authoritative id by hand.
|
presence rule still applies to it: carry over Bambu's authoritative id by hand.
|
||||||
- BBL is not exempt from anything else: bases still get their `setting_id` stripped, ids must still be
|
- BBL is not exempt from anything else: bases still get their `setting_id` stripped, ids must still be
|
||||||
globally unique, and BBL `filament_id`s are minted like everyone else's — every one of them is an
|
unique across the tree, and BBL `filament_id`s are minted like everyone else's, as `OF…` ids.
|
||||||
`OF*`.
|
|
||||||
|
|
||||||
## Ids other systems compose
|
## Ids other systems compose
|
||||||
|
|
||||||
No id from another system is the mint of a triple, so `check` rejects it like any other bad id — same
|
No id from another system is the mint of a triple, so `check` rejects one used as a `filament_id` like
|
||||||
error, same remedy, whoever wrote it. Three such spaces exist near the tree; recognise them so you do
|
any other bad id: same error, same remedy, whoever wrote it. Three such spaces exist near the tree;
|
||||||
not copy one into a profile:
|
recognise them so you do not copy one into a profile:
|
||||||
|
|
||||||
- **Bambu's `GF*` catalog** — external and opaque, correlated to Orca's ids by the generated
|
- **Bambu's `GF…` catalog**: external and opaque, correlated to Orca's ids by the generated
|
||||||
`resources/printers/bambu_filament_ids.json`. `GF` is a *prefix*, not a spelling the tree avoids: most
|
`resources/printers/bambu_filament_ids.json`. `blacklist.json` and
|
||||||
BBL `setting_id`s start with `G`, and `blacklist.json` and
|
`BBL/filament/filaments_color_codes.json` reference Bambu catalog ids by design. The rule is about
|
||||||
`BBL/filament/filaments_color_codes.json` both reference Bambu catalog ids by design. The rule is
|
`filament_id` and nothing else: every BBL `setting_id` starts with `G`, and that is Bambu's own
|
||||||
about `filament_id` and nothing else.
|
preset id, not a leaked catalog id.
|
||||||
- **Qidi's `QD_*`** — composed at runtime by the box (`QD_<series>_<vendor>_<typeidx>`), not a preset id.
|
- **Qidi's `QD_…`**: composed at runtime by the printer's filament box
|
||||||
- **`P` + 7 hex, and `"null"`** — what `CreatePresetsDialog.cpp` gives a *user*-created filament.
|
(`QD_<series>_<vendor>_<typeidx>`), not a preset id.
|
||||||
|
- **`P` + 7 hex digits, and `"null"`**: what the app gives a *user*-created filament.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`python3 -m unittest discover -s scripts/tests -t scripts` (`py -3 -m …` on Windows). Note the
|
`python3 -m unittest discover -s scripts/tests -t scripts` (`py -3 -m …` on Windows) runs the tool's
|
||||||
`-t scripts` argument; without it the imports fail. CI runs them as the first, non-`continue-on-error`
|
unit tests. Note the `-t scripts` argument; without it the imports fail. CI runs them as the first,
|
||||||
step of the profile job — see [validation.md](validation.md#ci).
|
non-`continue-on-error` step of the profile job; see [validation.md](validation.md#ci).
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
# Printer models and variants
|
# Printer models and variants
|
||||||
|
|
||||||
Both live in `resources/profiles/<Vendor>/machine/*.json`; models go in `machine_model_list`, variants
|
Both live in `resources/profiles/<Vendor>/machine/`; models go in `machine_model_list`, variants and
|
||||||
and shared bases in `machine_list`. Every one of them is registered. Some vendors (Elegoo, Eryone,
|
shared bases in `machine_list`. Every one of them is registered. A bundle may nest further subfolders
|
||||||
InfiMech, FlyingBear) nest a further subfolder under `machine/`, so recurse rather than globbing
|
under `machine/`, so recurse rather than globbing `machine/*.json`.
|
||||||
`machine/*.json`.
|
|
||||||
|
|
||||||
## A `machine_model` is not a config preset
|
## `machine_model`: a record, not a config preset
|
||||||
|
|
||||||
It is parsed by a hand-written key switch, and only these keys are stored (`version` and `url` are
|
The loader reads a fixed set of keys from a `machine_model` and stores only these (`version` and `url`
|
||||||
matched and discarded):
|
are recognised and discarded):
|
||||||
|
|
||||||
`name`, `model_id`, `nozzle_diameter`, `machine_tech`, `family`, `bed_model`, `bed_texture`,
|
`name`, `model_id`, `nozzle_diameter`, `machine_tech`, `family`, `bed_model`, `bed_texture`,
|
||||||
`hotend_model`, `default_materials`, `not_support_bed_type`, `image_bed_type`,
|
`hotend_model`, `default_materials`, `not_support_bed_type`, `image_bed_type`,
|
||||||
`bottom_texture_end_name`, `bottom_texture_rect`, `bottom_texture_rect_longer`, `middle_texture_rect`,
|
`bottom_texture_end_name`, `bottom_texture_rect`, `bottom_texture_rect_longer`, `middle_texture_rect`,
|
||||||
`use_double_extruder_default_texture`.
|
`use_double_extruder_default_texture`.
|
||||||
|
|
||||||
**Everything else is silently dropped.** Only `name` and `nozzle_diameter` are required. Dead keys ship
|
**Everything else is silently dropped**, a printer config key such as `default_bed_type` or a
|
||||||
on real models today — `url`, `default_bed_type`, even a `desciption` typo — so a neighbour carrying a
|
misspelling included, so a neighbour carrying a key is no evidence it does anything. Printer config options belong on the `machine` preset, never here. The loader drops a model
|
||||||
key is no evidence it does anything. Printer config options belong on the `machine` preset, never here.
|
silently if its index entry has no name or its `nozzle_diameter` yields no sizes; `check` also requires
|
||||||
|
the file's own `name`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -36,28 +36,28 @@ key is no evidence it does anything. Printer config options belong on the `machi
|
|||||||
|
|
||||||
| Field | Notes |
|
| Field | Notes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| identity | **the `name` of the `machine_model_list` entry**, which is what a variant's `printer_model` must equal. `check_name_consistency` forces it to equal the file's `name`, so they coincide. |
|
| identity | **the `name` of the `machine_model_list` entry**, which is what a variant's `printer_model` must equal. `check` forces it to equal the file's `name`, so they coincide. |
|
||||||
| `model_id` | a *separate* cloud/device printer type. Optional, and not required to be unique. Not the model's identity. Changing it changes device matching. |
|
| `model_id` | a *separate* cloud/device printer type. Optional, and not required to be unique. Not the model's identity; changing it changes device matching. |
|
||||||
| `machine_tech` | only `starts_with("SL")` means SLA; everything else is FFF. Write `FFF`; a few models write `FGF`, which is a label with no effect. |
|
| `machine_tech` | only a value starting with `SL` means SLA; everything else is FFF. Write `FFF`; `FGF` behaves as FFF. |
|
||||||
| `nozzle_diameter` | `;`-separated string, one token per available size. Order is free (Qidi writes `0.4;0.2;0.6;0.8` to put the default first). This list is the authoritative set of legal `printer_variant` values. |
|
| `nozzle_diameter` | `;`-separated string, one token per available size. Order is free (`0.4;0.2;0.6;0.8` puts the default first). This list is the authoritative set of legal `printer_variant` values. |
|
||||||
| `default_materials` | `;`-separated filament **preset names**. Used to preselect in the wizard *and* by `PresetBundle::load_installed_filaments` to auto-install a printer's filaments on first run, so a dangling entry costs a real user a filament. Not `,`; case-sensitive (`@System`). `check` fails on a dangling name here or in `default_filament_profile`. |
|
| `default_materials` | `;`-separated filament **preset names**, not `,`; case-sensitive (`@System`); order is ignored. Used to preselect filaments in the setup wizard *and* to install a printer's filaments on first run, so a dangling entry costs a real user a filament. Every name must exist (`check` fails on a name matching no filament file, here or in `default_filament_profile`), and every variant of the model needs at least one entry compatible with it (`validate_system`). |
|
||||||
| `family` | a wizard grouping label only; give every model one. |
|
| `family` | a wizard grouping label only; give every model one. |
|
||||||
|
|
||||||
### Assets
|
### Assets
|
||||||
|
|
||||||
`bed_model`, `bed_texture` and `hotend_model` are paths relative to the **vendor folder** (by id).
|
`bed_model`, `bed_texture` and `hotend_model` are paths relative to the **vendor folder** (named by the
|
||||||
Majority convention: `<Model>_buildplate_model.stl` and `<Model>_buildplate_texture.svg`. An empty string
|
vendor id). Convention: `<Model>_buildplate_model.stl` and `<Model>_buildplate_texture.svg`. An
|
||||||
is the legal "none", and is the norm for `hotend_model`.
|
empty string is the legal "none", and is the norm for `hotend_model`.
|
||||||
|
|
||||||
**Nothing checks that the file exists.** A missing `hotend_model` falls back to
|
**Nothing checks that the files exist.** A missing `hotend_model` falls back to
|
||||||
`resources/profiles/hotend.stl`; a missing `bed_model`/`bed_texture` just renders nothing. Broken
|
`resources/profiles/hotend.stl`; a missing `bed_model` makes the bed render as a generic custom bed, and a missing
|
||||||
references already ship. Verify by hand.
|
`bed_texture` renders no texture.
|
||||||
|
Verify by hand, in exact case.
|
||||||
|
|
||||||
Every model also has a `<Model>_cover.png` in the vendor folder — treat it as required, not optional.
|
Every model also has a `<Model>_cover.png` in the vendor folder; treat it as required, not optional.
|
||||||
240×240 is the cap `scripts/optimize_cover_images.py` enforces and the size most covers already use.
|
240×240 is the cap `scripts/optimize_cover_images.py` enforces. A missing cover degrades to a placeholder in both the wizard and the sidebar.
|
||||||
A missing cover degrades to a placeholder in both the wizard and the sidebar.
|
|
||||||
|
|
||||||
## The `machine` variant
|
## `machine`: the variant
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -79,116 +79,154 @@ A missing cover degrades to a placeholder in both the wizard and the sidebar.
|
|||||||
|
|
||||||
Minimum viable key set: `type`, `name`, `from`, `instantiation`, `setting_id`, `inherits`,
|
Minimum viable key set: `type`, `name`, `from`, `instantiation`, `setting_id`, `inherits`,
|
||||||
`printer_model`, `printer_variant`, `nozzle_diameter`, `printable_area`, `printable_height`,
|
`printer_model`, `printer_variant`, `nozzle_diameter`, `printable_area`, `printable_height`,
|
||||||
`default_print_profile`. The four keys without which the preset will not load at all are `name`,
|
`default_print_profile`. `default_filament_profile` is optional; when written it
|
||||||
`instantiation`, `printer_model` and `printer_variant`; `default_filament_profile` is an array
|
is an array (`["Generic PLA @System"]`), while the model's `default_materials` is a `;`-separated
|
||||||
(`["Generic PLA @System"]`) and the model's `default_materials` a `;`-separated string. Unlike a
|
string. Unlike a `machine_model`, a `machine` **is** a config preset, so a key belonging to another
|
||||||
`machine_model`, a `machine` **is** config-loaded, so a key belonging to another preset type is a
|
preset type is a reported error and is removed; a misspelled key is still dropped silently.
|
||||||
reported error (a misspelled key is still silent).
|
|
||||||
|
|
||||||
### `printer_variant` — three hard rules
|
### `printer_model` and `printer_variant`
|
||||||
|
|
||||||
1. Non-empty, and an exact member of the model's `;`-separated `nozzle_diameter` list.
|
1. `printer_model` is non-empty and names a model of this bundle exactly.
|
||||||
2. `printer_model` non-empty and naming a model of this vendor.
|
2. `printer_variant` is non-empty and an exact token of that model's `;`-separated `nozzle_diameter`
|
||||||
3. In validation mode, for instantiated presets only: split `printer_variant` on `+`, each token must
|
list.
|
||||||
start with a number (a trailing non-numeric suffix such as `HF` is ignored), and the resulting **set**
|
3. For instantiated presets, when validating: split `printer_variant` on `+`; each token must start
|
||||||
must equal `set(nozzle_diameter)`.
|
with a number (a trailing non-numeric suffix such as `HF` is ignored), and the resulting **set** must
|
||||||
|
equal the set of `nozzle_diameter` values.
|
||||||
|
|
||||||
Rules 1 and 2 are loader-enforced — failing either drops the preset *and* the whole bundle. Rule 3 only
|
Rules 1 and 2 are loader-enforced: failing either discards the whole bundle. Rule 3 only raises a
|
||||||
raises a validation error: the preset still loads, but the validator exits non-zero.
|
validation error: the preset still loads, but the validator exits non-zero.
|
||||||
|
|
||||||
`nozzle_diameter` lists one entry **per physical nozzle**; `printer_variant` lists the **distinct**
|
`nozzle_diameter` lists one entry **per extruder**; `printer_variant` lists the **distinct** diameters
|
||||||
diameters joined with `+`. Snapmaker U1 is the worked case: `["0.4","0.4","0.6","0.6"]` against
|
joined with `+`: `["0.4","0.4","0.6","0.6"]` against `"0.4+0.6"` passes because the comparison is on
|
||||||
`"0.4+0.6"` — it passes because the comparison is on sets.
|
sets.
|
||||||
|
|
||||||
The conventional values are `0.2`, `0.25`, `0.4`, `0.5`, `0.6`, `0.8` and `1.0`. Suffixed forms
|
Write `printer_variant` as a bare diameter matching the model's list, with no unit. The conventional
|
||||||
(`0.4HF`, `0.6HF`, `0.8HF`, `0.4HS`) are Flashforge-only and the `+` form is rare. A variant is **not**
|
values are `0.2`, `0.25`, `0.4`, `0.5`, `0.6`, `0.8` and `1.0`; a suffixed form (`0.4HF`, `0.4HS`) or
|
||||||
required to be unique within a model — Volumic ships `EXO42 IDRE`, `… COPY MODE` and `… MIRROR MODE` all
|
the `+` form is legal under rule 3. A `printer_variant` is **not** required to be unique within a
|
||||||
at `0.4` under the one model `EXO42 IDRE`.
|
model: an IDEX model's normal, `COPY MODE` and `MIRROR MODE` presets can all be `0.4`.
|
||||||
|
|
||||||
The converse is **unchecked**: a nozzle size in the model's list with no matching variant is offered in
|
The converse is **unchecked**: a nozzle size in the model's list with no matching variant is offered in
|
||||||
the wizard and resolves to nothing. `Wanhao France`'s `D12 500 PRO M2 DIRECT` ships that bug today.
|
the wizard and resolves to nothing. A variant whose `printer_model` names a sibling model by mistake
|
||||||
|
leaves its own model's size in exactly that state.
|
||||||
|
|
||||||
### Other fields worth knowing
|
### Other keys
|
||||||
|
|
||||||
- `default_print_profile` is a **scalar**, matched by exact preset name. Not a `;` list. The named
|
- `default_print_profile` is a **scalar**, matched by exact preset name; not a `;` list. The named
|
||||||
process must be compatible with this printer through its resolved list or condition.
|
process must be compatible with this printer through its resolved list or condition.
|
||||||
`validate_slice` attempts to select it and rejects generic Default fallbacks, but compatibility
|
`validate_slice` attempts to select it and rejects generic Default fallbacks, but compatibility
|
||||||
updates can choose another compatible preset. Check the exact default reference yourself.
|
updates can choose another compatible preset, and `check` does not resolve the name. Check the exact
|
||||||
- `default_filament_profile` is an **array**, one name per element.
|
default reference yourself.
|
||||||
- `printable_area` is an array of `"XxY"` strings — four points for a rectangle, one per segment for a
|
- `default_filament_profile` is an **array**, one name per element. Entry 0 is the filament preselected
|
||||||
delta or circular bed.
|
when the printer is chosen; entry *i* is the preferred replacement when filament *i* is incompatible,
|
||||||
- `gcode_flavor` is usually set once in the base; `klipper`, `marlin`, `marlin2` and `reprapfirmware`
|
and any listed name outranks an unlisted one. The validator checks every entry. The list of a
|
||||||
cover nearly every shipped printer.
|
printer's filaments is the model's `default_materials`: a new filament goes into `default_materials`;
|
||||||
- `printer_settings_id` is junk — most files carrying it disagree with their own name. Do not copy it
|
put it first in `default_filament_profile` only if it should become the preselected one.
|
||||||
when cloning a bundle.
|
- `printable_area` is an array of `"XxY"` strings: four points for a rectangle; a delta or other circular bed
|
||||||
- `min_layer_height` / `max_layer_height` are **machine** keys (per extruder), never process keys.
|
is a polygon with one point per segment.
|
||||||
|
- A non-BBL printer shows the plate selector only with `support_multi_bed_types` `"1"`; otherwise it
|
||||||
|
uses its `default_bed_type` (a plate name such as `"Textured PEI Plate"`; High Temp Plate when unset).
|
||||||
|
Filaments still set every plate
|
||||||
|
([twelve keys](filament-profiles.md#bed-temperature-is-twelve-keys-not-one)).
|
||||||
|
- `gcode_flavor` is usually set once in the base; the common values are `klipper`, `marlin`, `marlin2`
|
||||||
|
and `reprapfirmware`.
|
||||||
|
- `printer_settings_id` does nothing in a preset file: the app replaces it with the selected preset's
|
||||||
|
name before slicing. Omit it, and do not copy it when cloning a bundle.
|
||||||
|
- `min_layer_height` / `max_layer_height` are **machine** keys (one per extruder), never process keys.
|
||||||
|
|
||||||
## Bases
|
## Bases
|
||||||
|
|
||||||
Nearly every machine-bearing vendor registers a base literally named `fdm_machine_common`, and Klipper
|
The conventional machine root is a base named `fdm_machine_common`, with `fdm_klipper_common` on top
|
||||||
vendors add `fdm_klipper_common` on top of it. Two levels is the usual depth.
|
of it for Klipper printers. Which values a hardware
|
||||||
|
family or model base holds, and when adding one pays off, is in [shared-bases.md](shared-bases.md).
|
||||||
|
|
||||||
**There is no leading-underscore convention for bases.**
|
**There is no leading-underscore convention for bases.**
|
||||||
|
|
||||||
## Adding a printer to an existing bundle
|
## Adding a printer to an existing bundle
|
||||||
|
|
||||||
1. Choose the names first — model, variant(s), process(es); everything else references them.
|
1. Choose the names first: model, variant(s), process(es); everything else references them
|
||||||
|
([naming.md](naming.md)).
|
||||||
2. Add the model (`machine_model_list`) and one `machine` variant per nozzle; the minimum key sets are
|
2. Add the model (`machine_model_list`) and one `machine` variant per nozzle; the minimum key sets are
|
||||||
above. Bed assets and `<Model>_cover.png` go directly in `<Vendor>/`.
|
above. Bed assets and `<Model>_cover.png` go directly in `<Vendor>/`.
|
||||||
3. Add at least one process per variant naming it in `compatible_printers`
|
3. Add at least one process per variant naming it in `compatible_printers`
|
||||||
([process-profiles.md](process-profiles.md#adding-a-quality-tier-or-a-nozzles-processes)).
|
([process-profiles.md](process-profiles.md#adding-a-quality-tier-or-a-nozzles-processes)).
|
||||||
4. Register everything (or run `update-index`), bump the version, run the id tool, validate.
|
4. Add the variants to the filaments they should offer, and set the model's `default_materials` so every
|
||||||
|
variant has a compatible entry.
|
||||||
|
5. Register everything (`update-index`), bump the version, run the id tool, validate: the
|
||||||
|
[authoring workflow](../SKILL.md#creating-or-modifying-a-profile).
|
||||||
|
|
||||||
## Adding a nozzle variant
|
## Adding a nozzle variant
|
||||||
|
|
||||||
1. Extend the model's `nozzle_diameter` (`"0.4"` → `"0.4;0.6"`).
|
1. Extend the model's `nozzle_diameter` (`"0.4"` → `"0.4;0.6"`).
|
||||||
2. Add the variant preset. Either inherit the shared base (the usual choice) or the 0.4 sibling (Elegoo,
|
2. Add the variant preset. Either inherit the shared base (the usual choice), or the 0.4 sibling
|
||||||
BBL, Prusa and Qidi do this — smaller diff, but the sibling's edits now reach this file too).
|
(a smaller diff, but the sibling's edits now reach this file too). Follow the bundle.
|
||||||
3. Override what actually changes with nozzle: `nozzle_diameter`, `printer_variant`,
|
3. Override what actually changes with the nozzle: `nozzle_diameter`, `printer_variant`,
|
||||||
`default_print_profile`, `default_filament_profile`, `min_layer_height`/`max_layer_height`, and
|
`default_print_profile`, `default_filament_profile`, `min_layer_height` / `max_layer_height`, and
|
||||||
retraction if the vendor tunes it.
|
retraction if the vendor tunes it.
|
||||||
4. Add at least one process for the new nozzle — see [process-profiles.md](process-profiles.md).
|
4. Add at least one process for the new nozzle (see [process-profiles.md](process-profiles.md)), and
|
||||||
|
extend the filaments' `compatible_printers` so at least one `default_materials` entry covers the new
|
||||||
|
variant.
|
||||||
5. Register both, bump the version, run the id tool, validate.
|
5. Register both, bump the version, run the id tool, validate.
|
||||||
|
|
||||||
## Multi-extruder, IDEX and tool-changers
|
## Multi-extruder, IDEX and tool changers
|
||||||
|
|
||||||
Per-extruder vectors are **silently resized** to the nozzle count, with no error. Padding repeats the
|
Per-extruder vectors hold one value per extruder (`len(nozzle_diameter)`), and a wrong length raises no
|
||||||
**first** value, not the last — `["0.4","0.6"]` on a 4-nozzle machine becomes `0.4, 0.6, 0.4, 0.4`.
|
error: a short vector acts as padded with its **first** value, not the last (`extruder_offset`
|
||||||
Longer vectors are truncated.
|
`["0x0","50x0"]` on a 4-extruder machine reads as `0x0, 50x0, 0x0, 0x0`), and entries beyond the
|
||||||
|
extruder count are never read.
|
||||||
|
|
||||||
Note the two sizing families: the plain per-extruder keys (`extruder_offset`, `extruder_colour`,
|
Note the two sizing families. The plain per-extruder keys (`printer_extruder_options`: `extruder_type`,
|
||||||
`extruder_printable_height`, `min_layer_height`, `max_layer_height`, `nozzle_diameter`) are sized to the
|
`nozzle_diameter`, `default_nozzle_volume_type`, `extruder_printable_height`, `min_layer_height`,
|
||||||
extruder count, while `printer_options_with_variant_1` (`retraction_length`, `z_hop`, `wipe`,
|
`max_layer_height`, …, given in full with the [key sets](extruder-variants.md#the-four-key-sets), plus
|
||||||
`nozzle_type`, the rest of the retraction family) is sized to `printer_extruder_variant` instead.
|
`extruder_offset` and `extruder_colour`) hold one value per extruder. The variant sets
|
||||||
|
(`retraction_length`, `z_hop`, `wipe`, `nozzle_type`, the `machine_max_*` limits at stride 2;
|
||||||
|
[the full lists](extruder-variants.md#the-four-key-sets)) are sized to the variant length:
|
||||||
|
`len(printer_extruder_variant)`, or one variant per extruder when the resolved preset writes no layout,
|
||||||
|
since `extruder_variant_list` defaults to one `Direct Drive Standard` per extruder
|
||||||
|
([widths](extruder-variants.md#widths)). `extruders_count` is a printer-tab field, not a preset key; the
|
||||||
|
loader drops it.
|
||||||
|
|
||||||
- Give **one entry per extruder** for ordinary per-extruder vectors such as `extruder_offset`,
|
- Give **one entry per extruder** for ordinary per-extruder vectors such as `extruder_offset`,
|
||||||
`extruder_colour`, `min_layer_height` and `max_layer_height`; size the variant-dependent family
|
`extruder_colour`, `min_layer_height` and `max_layer_height`. Size the variant sets to the variant
|
||||||
to `printer_extruder_variant` instead.
|
length × stride, one value per variant (per extruder when there is no layout; a (normal, silent)
|
||||||
A single `["0x0"]` `extruder_offset` on a dual or multi-tool machine — which already ships — pads every
|
pair for the `machine_max_*` limits), even where the values are the same; the loader keeps only the first value of a list-less printer's variant arrays, so declare
|
||||||
toolhead to the same offset, so the offset never applies.
|
the layout when the extruders differ. A single `["0x0"]` `extruder_offset` on a dual or
|
||||||
- Overriding `nozzle_diameter` to a different count without re-stating every per-extruder vector is the
|
multi-extruder machine pads every extruder to the same offset, so the offset never applies.
|
||||||
other half of the trap — `Snapmaker U1 (0.4+0.6 nozzle)` inherits 5-entry vectors against 4 nozzles.
|
- Overriding `nozzle_diameter` to a different count without restating every per-extruder vector is the
|
||||||
|
other half of the trap: a 4-extruder preset on a 5-extruder base inherits 5-entry vectors against 4
|
||||||
|
extruders.
|
||||||
|
The reverse is silent too: a base is stored resized to its own `printer_extruder_variant` (one variant
|
||||||
|
when it writes none, whatever its extruder count), so a wider variant array on it reaches the children
|
||||||
|
as its first value padded ([composition](extruder-variants.md#padding-truncation-and-composition)).
|
||||||
|
`check` does not judge the base on its own; where its extruders need different values, declare the
|
||||||
|
layout on the base.
|
||||||
|
|
||||||
Copy targets: `Custom/machine/fdm_toolchanger_common.json` + `Custom/machine/MyToolChanger 0.4
|
Structure to copy: `Custom/machine/fdm_toolchanger_common.json` + `Custom/machine/MyToolChanger 0.4
|
||||||
nozzle.json` (a clean minimal variant on a base that gives every vector five entries), and
|
nozzle.json` (a minimal variant on a base that gives the per-extruder vectors five entries), and
|
||||||
`Ratrig/machine/RatRig V-Core 4 IDEX 300 0.4 nozzle.json` for IDEX. The BBL extruder-variant machinery
|
`Ratrig/machine/RatRig V-Core 4 IDEX 300 0.4 nozzle.json` for IDEX. Take the structure from them and
|
||||||
(`extruder_variant_list`, `printer_extruder_id`, `default_nozzle_volume_type`) is used by a handful of
|
the widths from the [sizing equation](extruder-variants.md#sizing-equation). Both are list-less, so
|
||||||
vendors — do not copy it into a new bundle (`nozzle_volume_type` itself is not a machine-preset key).
|
every variant array holds one value per extruder and the loader keeps only the first: fine while every
|
||||||
|
extruder shares the same retraction and limits. Add the extruder-variant layout
|
||||||
|
(`extruder_variant_list`, `printer_extruder_variant` / `printer_extruder_id`,
|
||||||
|
`default_nozzle_volume_type`) when the hardware has swappable nozzle volume types or mixed extruder
|
||||||
|
types, or as soon as one extruder needs its own value in a variant key; how to author it, and the
|
||||||
|
matching process and filament variants, is in [extruder-variants.md](extruder-variants.md).
|
||||||
|
(`nozzle_volume_type` itself is not a machine-preset key.)
|
||||||
|
|
||||||
## Custom G-code
|
## Custom G-code
|
||||||
|
|
||||||
The keys are `machine_start_gcode`, `machine_end_gcode`, `change_filament_gcode`,
|
The keys are `machine_start_gcode`, `machine_end_gcode`, `change_filament_gcode`,
|
||||||
`machine_pause_gcode`, `before_layer_change_gcode` and `layer_change_gcode`. Both a single string with
|
`machine_pause_gcode`, `before_layer_change_gcode` and `layer_change_gcode`. Each is one string with
|
||||||
embedded `\n` and a JSON array of lines are legal and both are in use — do not convert one into the
|
embedded `\n`. Never split G-code into a JSON array of lines: the loader joins array elements with `,`
|
||||||
other. Conditionals are `{if …}` / `{elsif …}` / `{else}` / `{endif}`; `{elsif}` is rare but real (Qidi's
|
into a single line (a one-element array is equivalent to the string): a two-element
|
||||||
`layer_change_gcode` uses it).
|
`machine_start_gcode` becomes one line, `PRINT_START …,SET_PRESSURE_ADVANCE ADVANCE=0.046`.
|
||||||
|
Conditionals are `{if …}` / `{elsif …}` / `{else}` / `{endif}`.
|
||||||
|
|
||||||
Placeholder errors only surface when the config is actually expanded, which means `validate_slice`:
|
Placeholder errors only surface when the G-code is actually expanded, which means `validate_slice`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/check_profile.sh --vendor "<Vendor>" validate_slice
|
./scripts/check_profile.sh --vendor "<Vendor>" validate_slice
|
||||||
# Windows: scripts\check_profile.bat -Vendor "<Vendor>" validate_slice
|
# Windows: scripts\check_profile.bat -Vendor "<Vendor>" validate_slice
|
||||||
```
|
```
|
||||||
|
|
||||||
What the sweep covers is in [validation.md](validation.md#validate_slice); no `CP TOOLCHANGE START` in
|
What the sweep covers is in [validation.md](validation.md#validate_slice); a printer whose output has no
|
||||||
the output means `change_filament_gcode` never expanded.
|
`CP TOOLCHANGE START` fails it, because its `change_filament_gcode` never expanded.
|
||||||
|
|||||||
@@ -1,90 +1,94 @@
|
|||||||
# Preset naming
|
# Preset naming
|
||||||
|
|
||||||
A preset's `name` is the loader's key, not decoration. The index registers it; `inherits`,
|
A preset's `name` is its identity, not decoration. The index registers it; `inherits`,
|
||||||
`compatible_printers` and the `default_*` keys reference it by the exact string; `renamed_from` depends
|
`compatible_printers`, `printer_model` and the `default_*` keys reference it by the exact,
|
||||||
on it; and `setting_id` / `filament_id` hash it (see [ids.md](ids.md)). Two presets of one type in a
|
case-sensitive string; `renamed_from` migrates it; `setting_id` and `filament_id` hash it
|
||||||
bundle may not share a name (`check_preset_name_uniqueness`). Treat a name change as an identity change,
|
([ids.md](ids.md)). Treat a name change as an identity change that needs
|
||||||
not a relabel.
|
[migration](vendor-bundle.md#renamed_from), not a relabel. Which part of each name the loader acts on
|
||||||
|
is the table in [SKILL.md](../SKILL.md#names); this page holds the conventions.
|
||||||
Naming is convention only where the loader does not parse it. What the loader actually acts on:
|
|
||||||
|
|
||||||
| Type | Shape | Acted on |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `machine_model` | `<Model>` | the exact string, named by a variant's `printer_model` |
|
|
||||||
| `machine` | `<Model> <nozzle> nozzle` | the exact string, named by `compatible_printers`; `printer_variant` must equal a nozzle diameter |
|
|
||||||
| `process` | `<lh>mm <Quality> @<target>` | the exact string when referenced or selected; the `@<target>` half is a label |
|
|
||||||
| `filament` | `<Product> @<target>` | text before the first `@` is the **alias**, used for shadowing; the rest is a label |
|
|
||||||
|
|
||||||
## `machine_model`
|
## `machine_model`
|
||||||
|
|
||||||
`<Model>` — the vendor-prefixed model name (`Bambu Lab X1 Carbon`, `Creality K1`, `Prusa CORE One`). A
|
`<Model>`, vendor-prefixed: `Bambu Lab X1 Carbon`, `Creality K1`, `Prusa CORE One`. Each variant's
|
||||||
variant names it verbatim in `printer_model`; a mismatch makes the variant invalid. `check_name_consistency`
|
`printer_model` names it verbatim (a mismatch discards the bundle), and its index entry equals the
|
||||||
forces the index entry to equal the file's `name`, and the variant's `printer_model` targets this string
|
file's `name`. It is also the stem of `<Model>_cover.png` and, by convention, of the bed assets
|
||||||
([A `machine_model` is not a config preset](machine-profiles.md#a-machine_model-is-not-a-config-preset)).
|
(`<Model>_buildplate_model.stl`).
|
||||||
It is also the `<Model>_cover.png` and bed-asset stem.
|
|
||||||
|
|
||||||
## `machine` (variant)
|
## `machine` (variant)
|
||||||
|
|
||||||
`<Model> <nozzle> nozzle` is near-universal (`Bambu Lab X1 Carbon 0.4 nozzle`). `printer_variant` holds
|
`<Model> <nozzle> nozzle` is near-universal (`Bambu Lab X1 Carbon 0.4 nozzle`). Casing varies
|
||||||
the bare nozzle (`0.4`) and must be an exact member of the model's `nozzle_diameter` list — the hard
|
(`nozzle` / `Nozzle`): match the bundle, not this page. A variant that is not nozzle-specific (a
|
||||||
rules are in [The `machine` variant](machine-profiles.md#the-machine-variant). Casing varies
|
special toolhead, a multi-material build, IDEX copy and mirror modes such as
|
||||||
(`nozzle` / `Nozzle`): match the bundle, not this page. A variant that is not nozzle-specific (a special
|
`<Model> COPY MODE (0.4 nozzle)`) may drop or reshape the suffix; it is still an exact reference. `printer_variant`
|
||||||
toolhead, a multi-material build) may drop the suffix — still an exact reference. Bases are named
|
holds the nozzle token: `0.4`, a suffixed `0.4HF`, or `0.4+0.6` for mixed nozzles
|
||||||
`fdm_machine_common` / `fdm_<vendor>_common`.
|
([rules](machine-profiles.md#printer_model-and-printer_variant)).
|
||||||
|
|
||||||
## `process`
|
## `process`
|
||||||
|
|
||||||
`<layer height>mm <quality> @<target>` — [process-profiles.md](process-profiles.md#naming) has the
|
`<layer height>mm <quality> @<target>`. The quality word stays before `@` and the printer target after
|
||||||
quality ladder and the `fdm_process_*` base names. The quality label stays before `@` and the printer
|
it: a printer model in the quality position leaves the tier undescribed. The `@<target>` is a label,
|
||||||
target after it: a printer model in the quality slot leaves the tier undescribed. The `@<target>` is a
|
and need not equal any variant name; compatibility comes from `compatible_printers` or the
|
||||||
human label, not a reference: it usually does not equal a real variant, and compatibility comes from the
|
condition. The quality ladder and per-nozzle labels are in
|
||||||
resolved `compatible_printers` list or condition.
|
[process-profiles.md](process-profiles.md#naming).
|
||||||
|
|
||||||
## `filament`
|
## `filament`
|
||||||
|
|
||||||
`<Product> @<target>`. The product half is what `filament_id` hashes and what survives as the **alias** up
|
`<Product> @<target>`. The product half, up to the first `@` and right-trimmed, is the **alias**:
|
||||||
to the first `@`; the target half is a label except for reserved forms:
|
shadowing matches on it and `filament_id` hashes it. The target half is a label, except for the
|
||||||
|
reserved forms:
|
||||||
|
|
||||||
- `@base` — a non-instantiated product root. `@base` is convention; a base is really identified by
|
- `@base`: a non-instantiated product root. Convention only; a base is really
|
||||||
`instantiation: "false"` and no `setting_id` ([the three-part shape](filament-profiles.md#the-three-part-shape)).
|
`instantiation: "false"` without `setting_id`
|
||||||
- `@System` — the OrcaFilamentLibrary selectable shim, and the convention for an all-printer product
|
([the three-part shape](filament-profiles.md#the-three-part-shape)).
|
||||||
(`<Product> @System`, empty `compatible_printers`); not enforced, so a deviation is worth a review
|
- `@System`: the OrcaFilamentLibrary selectable shim, and the convention for an all-printer product
|
||||||
comment. The literal `Generic <mat> @System` is load-bearing for 3MF/project recovery, beyond the
|
(`<Product> @System`, empty `compatible_printers`). Not enforced, so a deviation is worth a review
|
||||||
alias rule ([alias shadowing](filament-profiles.md#alias-shadowing)).
|
comment. The literal `Generic <mat> @System` is also load-bearing for project recovery
|
||||||
- `@<Vendor>`, `@<Vendor> <Model>`, `@<Vendor> <Model> <nozzle> nozzle` — printer tunes, BBL's shape.
|
([alias shadowing](filament-profiles.md#alias-shadowing)).
|
||||||
Other vendors differ (a bare model, a printer serial, Creality's `@<Model>-all`). Specificity is judged
|
- Printer tunes. BBL's shape is the reference: `@<Vendor>` (vendor-wide), `@<Vendor> <Model>` (one
|
||||||
from `compatible_printers`, not the name
|
model), `@<Vendor> <Model> <nozzle> nozzle` (one variant). Other vendors differ: a bare model
|
||||||
|
(`QIDI ABS-GF@Q2-Series`), a printer serial, Creality's `@<Model>-all`. Judge specificity from
|
||||||
|
`compatible_printers`, never from the name
|
||||||
([one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)).
|
([one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)).
|
||||||
- Color is not part of the product name: `<Product> <Color>` presets are not authored; the color is
|
- No colour in the product name: `<Product> <Colour>` presets are not authored; colour is chosen at
|
||||||
chosen at runtime
|
runtime ([colour](filament-profiles.md#colour-is-a-runtime-property)).
|
||||||
([color is a runtime property](filament-profiles.md#color-is-a-runtime-property)).
|
|
||||||
|
|
||||||
## Checking names
|
## Bases
|
||||||
|
|
||||||
Check every newly added profile's `name` against its type and role: model, selectable preset or base.
|
|
||||||
Apply the same checks to an intentional name change. The human-readable naming shapes are not enforced
|
|
||||||
by `check`: inspect the added or renamed profiles in the diff, using neighbouring names as context and
|
|
||||||
following the bundle's established style where the type-specific conventions allow variation.
|
|
||||||
|
|
||||||
For bases (`instantiation: "false"`), use the type-specific conventions:
|
|
||||||
|
|
||||||
| Type | Base names |
|
| Type | Base names |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `machine` | `fdm_machine_common`, `fdm_<vendor>_common`, or an established machine-family base name |
|
| `machine` | `fdm_machine_common`, `fdm_<vendor>_common`, `fdm_klipper_common`, or an established machine-family base |
|
||||||
| `process` | `fdm_process_*`, including shared roots and per-layer-height / per-nozzle bases such as `fdm_process_single_0.20` |
|
| `process` | `fdm_process_*`: shared roots and per-layer-height or per-nozzle bases such as `fdm_process_single_0.20` or `fdm_process_<vendor>_<lh>_nozzle_<n>` |
|
||||||
| `filament` | `fdm_filament_*` material roots or `<Product> @base` product roots |
|
| `filament` | `fdm_filament_*` material roots, `<Product> @base` product roots |
|
||||||
|
|
||||||
Shared base names across bundles are intentional, including product roots such as `Fiberon PA6-CF @base`.
|
There is no leading-underscore convention. Base names repeat across bundles by design:
|
||||||
Investigate a newly authored base that retains an unrelated selectable preset's name from a copy.
|
every bundle may have its own `fdm_process_common`, and a product root such as `Fiberon PA6-CF @base`
|
||||||
|
can exist in both the library and a vendor. Investigate a newly authored base that kept an unrelated
|
||||||
|
selectable preset's name from a copy.
|
||||||
|
|
||||||
**Name uniqueness is checked by CI.** `check_preset_name_uniqueness` checks type + name within each
|
## Uniqueness
|
||||||
bundle. `check_machine_model_name_uniqueness` checks model names across the entire tree, even with
|
|
||||||
`--vendor`: `Preset::get_printer_type` matches `printer_model` against all vendors' models and returns
|
|
||||||
the first match, so a duplicate makes lookup depend on vendor order. Both run as part of
|
|
||||||
`python3 scripts/orca_profile_tool.py check`.
|
|
||||||
|
|
||||||
## Not the same as the filename
|
- Type + name is unique within a bundle, indexed or not; `check` enforces it.
|
||||||
|
- `machine_model` names are unique across the whole tree; `check` enforces it even with `--vendor`.
|
||||||
|
Printer-type lookup matches `printer_model` against every vendor's models and takes the first, so a
|
||||||
|
duplicate makes it depend on vendor order.
|
||||||
|
- At load, two selectable presets with one name discard the bundle, and a duplicate across vendors
|
||||||
|
is a validator error. Two bases with one name, or a base and a selectable preset, load silently and
|
||||||
|
the first in the index wins; an unindexed twin is therefore one `sub_path` edit away from becoming
|
||||||
|
the parent every child resolves to.
|
||||||
|
|
||||||
The loader keys off `name`, and a filename that disagrees usually still loads. Index `name` must equal the
|
## Filenames and paths
|
||||||
file's `name`, and the filename should match `sub_path`; a mismatch that differs only in case breaks
|
|
||||||
another platform ([cross-platform paths](validation.md#cross-platform-paths)).
|
The loader keys off `name`, and a filename that disagrees usually still loads, but keep the filename
|
||||||
|
equal to the `name` and to the index `sub_path`. Match the exact case of every `sub_path` and asset
|
||||||
|
filename: Linux filesystems distinguish case even when a macOS or Windows checkout does not, and
|
||||||
|
preset-name references are case-sensitive on every platform. Avoid Windows-invalid characters
|
||||||
|
(`< > : " | ? *`), reserved device names such as `CON` and `NUL` (with any extension), and trailing
|
||||||
|
spaces or dots in a path component; a space right before `.json` is not a trailing space.
|
||||||
|
|
||||||
|
## Checking names
|
||||||
|
|
||||||
|
Check the `name` of every newly added profile, and every intentional rename, against its type and role
|
||||||
|
(model, selectable preset or base). `check` does not enforce the shapes on this page: inspect the
|
||||||
|
added or renamed presets in the diff, use neighbouring names as context, and follow the bundle's
|
||||||
|
established style where the conventions allow variation. Preserve shipped names during ordinary
|
||||||
|
tuning; renaming a shipped selectable preset needs `renamed_from`.
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# Process profiles
|
# Process profiles
|
||||||
|
|
||||||
Processes live in `resources/profiles/<Vendor>/process/` — selectable leaves and shared bases alike, and
|
Processes live in `resources/profiles/<Vendor>/process/`, selectable leaves and shared bases alike, and
|
||||||
every one of them is registered in `process_list`. There are no global processes shared across vendors.
|
every one of them is registered in `process_list`. There are no global processes shared across vendors.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
`"<layer height>mm <quality> @<target>"` — near-universal, so match it.
|
`"<layer height>mm <quality> @<target>"` is near-universal, so match it: the quality word before `@`,
|
||||||
|
the printer label after it ([naming.md](naming.md#process)).
|
||||||
|
|
||||||
Follow the bundle's existing quality vocabulary. BBL's common ladder relates the quality word to
|
Follow the bundle's existing quality vocabulary. BBL's common ladder relates the quality word to the
|
||||||
the layer-height / nozzle ratio; it is a naming convention, not a loader constraint:
|
layer height / nozzle ratio; it is a naming convention, not a loader constraint:
|
||||||
|
|
||||||
| Quality | Ratio | 0.2 nozzle | 0.4 | 0.6 | 0.8 |
|
| Quality | Ratio | 0.2 nozzle | 0.4 | 0.6 | 0.8 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
@@ -20,49 +21,50 @@ the layer-height / nozzle ratio; it is a naming convention, not a loader constra
|
|||||||
| Extra Draft | 0.7× | 0.14 | 0.28 | 0.42 | 0.56 |
|
| Extra Draft | 0.7× | 0.14 | 0.28 | 0.42 | 0.56 |
|
||||||
|
|
||||||
This is the `fdm_process_single_<lh>_nozzle_<n>` ladder; 0.4 is commonly the unsuffixed nozzle default.
|
This is the `fdm_process_single_<lh>_nozzle_<n>` ladder; 0.4 is commonly the unsuffixed nozzle default.
|
||||||
Match neighbouring names rather than renaming shipped tiers to fit the table.
|
Newer BBL printers add High Quality, Balanced Quality and Strength tiers. Match neighbouring names rather
|
||||||
|
than renaming shipped tiers to fit the table. On a model with several nozzles, processes for the other
|
||||||
|
nozzles usually carry the nozzle in the label (`0.30mm Standard @BBL X1C 0.6 nozzle`); follow the bundle.
|
||||||
|
|
||||||
The `@target` is a human label, not a reference: most do not equal any real printer variant name.
|
The `@target` is a human label, not a reference: it need not equal any printer variant name.
|
||||||
Compatibility comes from the resolved list or condition, not this label.
|
Compatibility comes from the resolved list or condition, not this label.
|
||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
A selectable leaf's only truly universal keys are `type`, `setting_id`, `name` and `instantiation`;
|
A selectable leaf has `type`, `setting_id`, `name` and `instantiation`, normally `inherits` and
|
||||||
`inherits` and `from` are near-universal — plus compatibility. No slicing key is universal; even
|
`from`, plus compatibility; its slicing keys, `layer_height` included, normally come from its bases. A
|
||||||
`layer_height` is more often inherited than restated. A base has `type`, `name`, `instantiation`, almost
|
base has `type`, `name`, `instantiation`, `from`, and **no** `setting_id`.
|
||||||
always `from`, and **no** `setting_id`.
|
|
||||||
|
|
||||||
**Target shape: a 7-key leaf.** `OrcaArena` is the cleanest model —
|
**Target shape: a 7-key leaf.** `OrcaArena` is the cleanest model:
|
||||||
`fdm_process_common` → `fdm_process_arena_common` → `fdm_process_arena_<lh>_nozzle_<n>` → leaf, where the
|
`fdm_process_common` → `fdm_process_arena_common` → `fdm_process_arena_<lh>_nozzle_<n>` → leaf, where the
|
||||||
leaf carries only `type`, `name`, `inherits`, `from`, `setting_id`, `instantiation`,
|
leaf carries only `type`, `name`, `inherits`, `from`, `setting_id`, `instantiation` and
|
||||||
`compatible_printers`, and the per-nozzle base holds the layer height and all eight line widths.
|
`compatible_printers`, and the per-nozzle base holds the layer height and all eight line widths.
|
||||||
|
[shared-bases.md](shared-bases.md#levels) says which level each process setting belongs to.
|
||||||
|
|
||||||
BBL, WonderMaker and Z-Bolt are uniform in *layering* — every leaf inherits a base, names its printers
|
BBL's *layering* is a model too (every leaf inherits a base, names its printers directly and holds no
|
||||||
directly and holds no layer height of its own — but not in key count. Imitate BBL's layering, not its
|
layer height of its own), but not its content: its leaves carry multi-variant `print_extruder_variant`
|
||||||
content: its leaves carry doubled `print_extruder_variant` arrays that no single-variant vendor needs.
|
arrays that no single-variant vendor needs ([extruder-variants.md](extruder-variants.md#process)).
|
||||||
|
|
||||||
Nearly every vendor ships its own `fdm_process_common` as the inherits-less root. Those files are not
|
A bundle has its own `fdm_process_common` as the inherits-less root, since a process inherits only
|
||||||
identical; copying another vendor's version into a new bundle is normal.
|
inside its bundle; starting a new bundle's from another vendor's copy is fine.
|
||||||
|
|
||||||
Beware leaf-inherits-leaf: Prusa chains several levels deep through sibling leaves, and Elegoo and
|
Beware leaf-inherits-leaf: a bundle may chain selectable processes several levels deep, so editing one
|
||||||
Flashforge do it too, so editing one selectable process silently changes others. Check a leaf's children
|
silently changes others. Check a leaf's children before editing it.
|
||||||
before editing it.
|
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
Most leaves set `compatible_printers` directly; some inherit it from a base, and Prusa's fall through to
|
A leaf sets `compatible_printers` directly, inherits it from a base, or falls through to
|
||||||
`compatible_printers_condition`. After resolving `inherits`, **every selectable process has one or the
|
`compatible_printers_condition`. After resolving `inherits`, **every selectable process has one or the
|
||||||
other** — that is the invariant to review against. Unlike filaments, inheriting `compatible_printers` is
|
other**: that is the invariant to review against. Unlike filaments, inheriting `compatible_printers` is
|
||||||
legitimate for a process, and no check enforces its presence.
|
legitimate for a process, and no check enforces its presence.
|
||||||
|
|
||||||
- A non-empty `compatible_printers` makes `compatible_printers_condition` **dead code**. Use one or
|
- A non-empty `compatible_printers` makes `compatible_printers_condition` **dead**. Use one or the
|
||||||
the other.
|
other.
|
||||||
- A condition that fails to parse means *compatible with everything* — a warning, not an error. A typo
|
- A condition that fails to parse means *compatible with everything*: a warning, not an error. A typo
|
||||||
widens compatibility instead of narrowing it.
|
widens compatibility instead of narrowing it.
|
||||||
- Matching is `boost::regex` **`regex_match`** — a full-string match, which is why every shipped
|
- A regex in a condition must match the **whole** string, so wrap the keyword in `.*`; `.` also spans
|
||||||
condition wraps its keyword in `.*`. Because it is boost rather than `std`, `.` also spans the newlines
|
the newlines inside `printer_notes`.
|
||||||
inside `printer_notes`.
|
- A `printer_notes` keyword that prefixes another model's keyword matches both. Guard it with a
|
||||||
- A `printer_notes` keyword that prefixes another model's keyword matches both. Prusa guards it:
|
character class after the keyword, and combine terms with `and`:
|
||||||
|
|
||||||
```
|
```
|
||||||
printer_notes=~/.*PRINTER_MODEL_COREONE[^_a-zA-Z0-9].*/ and nozzle_diameter[0]==0.4 and printer_notes=~/.*HF_NOZZLE.*/
|
printer_notes=~/.*PRINTER_MODEL_COREONE[^_a-zA-Z0-9].*/ and nozzle_diameter[0]==0.4 and printer_notes=~/.*HF_NOZZLE.*/
|
||||||
@@ -70,76 +72,84 @@ legitimate for a process, and no check enforces its presence.
|
|||||||
|
|
||||||
The `[^_a-zA-Z0-9]` exists because `PRINTER_MODEL_COREONE_L` also contains `PRINTER_MODEL_COREONE`.
|
The `[^_a-zA-Z0-9]` exists because `PRINTER_MODEL_COREONE_L` also contains `PRINTER_MODEL_COREONE`.
|
||||||
|
|
||||||
`compatible_printers` is almost always one element. A leaf listing a whole model family is where a newly
|
A leaf listing a whole model family is where a newly added printer is usually forgotten.
|
||||||
added printer is usually forgotten.
|
|
||||||
|
|
||||||
## What to review per nozzle
|
## Values to review per nozzle
|
||||||
|
|
||||||
| Key group | Review |
|
| Key group | Review |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `line_width` and per-region widths | resolved widths suit the nozzle and layer height |
|
| `line_width` and per-region widths | resolved widths suit the nozzle and layer height |
|
||||||
| `layer_height`, `initial_layer_print_height` | within the printer's limits |
|
| `layer_height`, `initial_layer_print_height` | within the printer's `min_layer_height` / `max_layer_height` |
|
||||||
| print speeds | consistent with flow limits and hardware tuning |
|
| print speeds | consistent with flow limits and hardware tuning |
|
||||||
| shell layers, wall loops, accelerations, support Z distances | preserve the intended thickness, motion and support behavior |
|
| shell layers, wall loops, accelerations, support Z distances | preserve the intended thickness, motion and support behaviour |
|
||||||
|
|
||||||
**A common starting pattern is nozzle + 0.02 mm**: 0.22 / 0.42 / 0.62 / 0.82 / 1.02. In that pattern, at 0.4,
|
**A common starting pattern is line width = nozzle + 0.02 mm**: 0.22 / 0.42 / 0.62 / 0.82 / 1.02. In
|
||||||
`inner_wall_line_width`, `sparse_infill_line_width`, `skin_infill_line_width` and
|
that pattern, at 0.4, `inner_wall_line_width`, `sparse_infill_line_width`, `skin_infill_line_width` and
|
||||||
`skeleton_infill_line_width` widen to 0.45 and `initial_layer_line_width` to 0.5; at 0.2,
|
`skeleton_infill_line_width` widen to 0.45 and `initial_layer_line_width` to 0.5; at 0.2,
|
||||||
`initial_layer_line_width` widens to 0.25. Also derived, and easily missed:
|
`initial_layer_line_width` widens to 0.25. Also derived, and easily missed:
|
||||||
`ironing_inset = line_width / 2` (0.11 / 0.21 / 0.31 / 0.41).
|
`ironing_inset = line_width / 2` (0.11 / 0.21 / 0.31 / 0.41). These are examples, not required values;
|
||||||
These are examples, not required values; preserve intentional vendor tuning and percentage/automatic
|
preserve intentional vendor tuning and percentage or automatic widths, and validate their resolved
|
||||||
widths, and validate their resolved values.
|
values.
|
||||||
|
|
||||||
`min_layer_height` and `max_layer_height` are machine keys — no process file sets them.
|
`min_layer_height` and `max_layer_height` are machine keys; no process file sets them.
|
||||||
|
|
||||||
## Slice-time content checks
|
### Slicing limits
|
||||||
|
|
||||||
`Print::validate()` enforces four rules at slice time:
|
Slicing rejects a process that breaks one of these (the message in italics):
|
||||||
|
|
||||||
1. `initial_layer_print_height` ≤ min `nozzle_diameter`
|
1. `initial_layer_print_height` ≤ the smallest `nozzle_diameter` (with a raft, the nozzle of the raft's
|
||||||
2. `layer_height` ≤ min `nozzle_diameter` — *"Layer height cannot exceed nozzle diameter."*
|
first-layer extruder).
|
||||||
3. `line_width` and the seven per-region widths (inner/outer wall, sparse infill, internal solid infill,
|
2. `layer_height` ≤ the smallest `nozzle_diameter`: *"Layer height cannot exceed nozzle diameter."*
|
||||||
top surface, skin, skeleton) > `layer_height` — *"Line width too small"*. `support_line_width` only
|
3. `line_width` and the seven per-region widths (inner and outer wall, sparse infill, internal solid
|
||||||
when the object has support or a raft; `initial_layer_line_width` is never checked.
|
infill, top surface, skin, skeleton) > `layer_height`: *"Line width too small"*.
|
||||||
4. every width ≤ 5 × max `nozzle_diameter` — *"Line width too large"*
|
`support_line_width` is checked only when the object has support or a raft;
|
||||||
|
`initial_layer_line_width` is never checked. A width that resolves to 0 (automatic) is skipped.
|
||||||
|
4. Every width ≤ 5 × the largest `nozzle_diameter`: *"Line width too large"*.
|
||||||
|
|
||||||
Two further rules cover `bridge_line_width` (≤ nozzle diameter; > `layer_height` unless `thick_bridges`
|
Two further rules cover `bridge_line_width`: it must not exceed the nozzle diameter, and must exceed
|
||||||
and `thick_internal_bridges` are both on). The sweep starts from printer defaults rather than
|
`layer_height` unless `thick_bridges` and `thick_internal_bridges` are both on. The slice sweep starts
|
||||||
enumerating every process. **A new non-default process gets no dedicated slice coverage in CI.**
|
from printer defaults rather than enumerating every process: **a new non-default process gets no
|
||||||
|
dedicated slice coverage in CI.**
|
||||||
|
|
||||||
## What CI checks on a process
|
## What CI checks on a process
|
||||||
|
|
||||||
Structure, not content: `process_list` name consistency **and** index coverage the other way, two files
|
Structure, not content: `process_list` name consistency **and** index coverage the other way, two files
|
||||||
claiming one process name, the `extruder_clearance_radius` / `extruder_clearance_max_radius` conflict
|
claiming one process name, the `extruder_clearance_radius` / `extruder_clearance_max_radius` conflict
|
||||||
pair, duplicate JSON keys, a file `normalize` would rewrite, and the five `setting_id` rules (the fifth
|
pair, duplicate JSON keys, a file `normalize` would rewrite, the five `setting_id` rules (present on
|
||||||
rejects the misspelled key `settings_id`). `compatible_printers` presence is checked for **filaments
|
selectable presets, absent from bases, equal to the formula outside `BBL/`, unique across the tree, and
|
||||||
only**.
|
no misspelled key `settings_id`), and the variant arrays: every array of `print_options_with_variant`
|
||||||
|
exactly `variant length × stride` wide in each selectable process that writes it, and a written
|
||||||
|
`print_extruder_id` one entry per variant
|
||||||
|
([variant arrays](validation.md#variant-arrays)). `compatible_printers` presence is checked for
|
||||||
|
**filaments only**.
|
||||||
|
|
||||||
Note the C++ loader derives a missing `setting_id` on the fly, so the validator will not fail a process
|
The loader derives a missing `setting_id` on the fly, so the validator accepts a process without one;
|
||||||
without one — only `orca_profile_tool.py check` catches it. Running the validator alone gives a false
|
only `orca_profile_tool.py check` catches it.
|
||||||
all-clear.
|
Running the validator alone gives a false all-clear.
|
||||||
|
|
||||||
## Silent failures specific to processes
|
## Silent failures specific to processes
|
||||||
|
|
||||||
- **Unknown or misspelled keys are discarded with no error and no warning.** They ship all over the
|
- **Unknown or misspelled keys are discarded with no error and no warning**, both plain typos
|
||||||
process tree, both plain typos (`inital_layer_height`, `tree_support_bramch_diameter_angle`,
|
(`inital_layer_height`, `tree_support_bramch_diameter_angle`, `sparse_infill_patter`) and keys
|
||||||
`sparse_infill_patter`) and keys copied from other slicers that Orca never defined.
|
copied from other slicers that Orca never defined.
|
||||||
- Keys on the tool's `OBSOLETE_KEYS` list (`adaptive_layer_height`, `overhang_totally_speed`, …) are
|
- Keys on the tool's obsolete list (`adaptive_layer_height`, `overhang_totally_speed`, …) are rejected
|
||||||
rejected by `check`'s normalization pass across preset types; `normalize` removes them.
|
by `check`'s normalization pass across preset types; `normalize` removes them. The additional per-key
|
||||||
The additional per-key obsolete warnings read `filament/` only.
|
obsolete warnings read `filament/` only.
|
||||||
- A dangling `compatible_printers` inside an `instantiation: "false"` base is invisible to
|
- A dangling `compatible_printers` inside an `instantiation: "false"` base is reported only through a
|
||||||
`check_preset_references`: a base never becomes a `Preset` at all (its config goes into `config_maps`
|
selectable child that inherits it unchanged; it goes unreported when every child overrides the list,
|
||||||
and the loader returns early), so it is in no collection for the check to walk.
|
or when the base has no instantiated children.
|
||||||
- Orphan bases that nothing inherits are scattered through the tree — usually the leftover of a
|
- Nothing flags an orphan base that nothing inherits, usually the leftover of a half-finished nozzle
|
||||||
half-finished nozzle addition.
|
addition.
|
||||||
|
|
||||||
## Adding a quality tier or a nozzle's processes
|
## Adding a quality tier or a nozzle's processes
|
||||||
|
|
||||||
1. Choose the layer height and quality label using the vendor's existing ladder.
|
1. Choose the layer height and quality label using the bundle's existing ladder.
|
||||||
2. If the vendor has per-nozzle bases, add one (`fdm_process_<vendor>_<lh>_nozzle_<n>`) with the layer
|
2. If the bundle has per-nozzle bases, add one (`fdm_process_<vendor>_<lh>_nozzle_<n>`) with the layer
|
||||||
height, nozzle-appropriate line widths, `initial_layer_print_height` and `ironing_inset`.
|
height, nozzle-appropriate line widths, `initial_layer_print_height` and `ironing_inset`.
|
||||||
3. Add the leaf: 7 keys, `compatible_printers` naming the exact printer variant(s).
|
3. Add the leaf: 7 keys, `compatible_printers` naming the exact printer variant(s).
|
||||||
4. Register both in `process_list`, parent first. Bump the version, run the id tool, validate.
|
4. Register both in `process_list`, parent first, bump the version, run the id tool and validate: the
|
||||||
5. Slice this process explicitly with its intended printer; the sweep gives non-default tiers no
|
[authoring workflow](../SKILL.md#creating-or-modifying-a-profile).
|
||||||
dedicated coverage. If it is a printer's `default_print_profile`, verify the exact name and
|
5. Slice this process explicitly with its intended printer
|
||||||
resolved compatibility too — the sweep may fall back or select another compatible process.
|
([on a copy of the tree](validation.md#checking-a-copy-of-the-tree)); the sweep gives non-default
|
||||||
|
tiers no dedicated coverage. If it is a printer's `default_print_profile`, verify the exact name and
|
||||||
|
resolved compatibility too: the sweep may fall back or select another compatible process.
|
||||||
|
|||||||
@@ -1,37 +1,43 @@
|
|||||||
# Reviewing a profile change
|
# Reviewing a profile change
|
||||||
|
|
||||||
Start with delivery, identity and backward compatibility, then check the affected preset types.
|
Run `./scripts/check_profile.sh` on the applied diff first ([validation.md](validation.md) says what CI
|
||||||
The table highlights gaps that need human review. What CI *does* run:
|
runs), then work through the items below: delivery, identity and backward compatibility first, then the
|
||||||
[validation.md](validation.md).
|
affected preset types. The table lists the gaps CI cannot see, so only a reviewer catches them.
|
||||||
|
|
||||||
| Not checked by CI | Consequence |
|
| Not checked by CI | Consequence |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| The `version` bump | The change never reaches an upgrading user |
|
| The `version` bump | The change never reaches an upgrading user; an absent `version` hides the vendor from the setup wizard |
|
||||||
| A misspelled setting key | Setting silently has no effect |
|
| A misspelled setting key | The setting silently has no effect |
|
||||||
| A filename Windows cannot check out, or one that differs from its `sub_path` only in case | Works on the author's machine, breaks the bundle on another platform |
|
| A filename Windows cannot check out, or one that differs from its `sub_path` only in case | Works on the author's machine, breaks the bundle on another platform |
|
||||||
| `bed_model` / `bed_texture` / `hotend_model` pointing at a missing asset | Bed renders as Custom, hotend falls back to the generic model |
|
| `bed_model` / `bed_texture` / `hotend_model` / cover pointing at a missing asset | A missing bed model renders a generic custom bed and a missing texture renders none, the hotend falls back to the generic model, the cover shows a placeholder |
|
||||||
| A nozzle size in a model's list with no matching variant | The size is offered and resolves to nothing |
|
| A nozzle size in a model's list with no matching variant | The size is offered and resolves to nothing |
|
||||||
| A non-default process | `validate_slice` gives non-default quality tiers no dedicated coverage |
|
| A non-default process | `validate_slice` gives non-default quality tiers no dedicated coverage |
|
||||||
| Whether the intended default survived compatibility selection | The sweep can select a different compatible preset |
|
| Whether the intended default survived compatibility selection | The sweep can select a different compatible preset |
|
||||||
| A dangling `compatible_printers` inside an `instantiation: "false"` base | A base never becomes a `Preset`, so the reference check never sees it (a bad `inherits` in a base *is* caught) |
|
| A dangling `compatible_printers` inside a base whose children all override it (or that has no instantiated children) | The reference check walks resolved selectable presets, so it reports a base's list only through a child that inherits it unchanged (a bad `inherits` in a base *is* caught) |
|
||||||
|
| A base nothing inherits | Dead weight, usually the leftover of an unfinished nozzle addition |
|
||||||
| A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name |
|
| A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name |
|
||||||
| A preset differentiated only by color, or an all-printer library preset without `@System` | Per-color presets split one product across ids and the selector fills with near-duplicates; CI stays green |
|
| A preset differentiated only by colour, or an all-printer library preset without `@System` | Per-colour presets split one product across several ids and the selector fills with near-duplicates; CI stays green |
|
||||||
| Per-extruder vector length on a multi-nozzle printer | Silently padded (with the **first** value) or truncated |
|
| Plate temperatures for plates the printer has | The user's plate reads an unset or inherited temperature |
|
||||||
| A name that ignores its type's convention — a model in a `process` quality slot, or an unrelated target label left in a copied preset | The selector misrepresents the preset's quality or intended printer |
|
| Per-extruder vector length on a multi-extruder printer | Silently padded (with the **first** value) or truncated |
|
||||||
|
| A new variant appended instead of inserted at its variant index, a variant array widened on a base with a shorter list, or a variant a filament/process lacks | Values shift onto the wrong extruder, are cut before any child inherits them, or resolve to the first variant: High Flow silently gets Standard values |
|
||||||
|
| A name that ignores its type's convention: a printer model in a `process` quality position, or an unrelated target label or base name left in a copied preset | The selector misrepresents the preset's quality or intended printer |
|
||||||
|
| Values: temperatures, speeds, widths, pressure advance | A wrong value prints wrong while CI stays green |
|
||||||
|
|
||||||
## 1. Was the vendor `version` bumped?
|
## 1. Was the vendor `version` bumped?
|
||||||
|
|
||||||
For **every** bundle whose folder the diff touches, `resources/profiles/<Vendor>.json` must have its
|
For **every** bundle whose folder the diff touches, `resources/profiles/<Vendor>.json` must have its
|
||||||
`version` incremented — last component, carrying `.99` into the third component. A library change
|
`version` incremented: last component, carrying `.99` into the third component. A library change means
|
||||||
means bumping `OrcaFilamentLibrary.json`.
|
bumping `OrcaFilamentLibrary.json`.
|
||||||
|
|
||||||
*Why:* nothing in CI checks it, and `PresetUpdater` reinstalls only when `vendor_ver < resource_ver` —
|
*Why:* nothing in CI checks it, and the app reinstalls a bundled profile set only when its version is
|
||||||
without a bump the change reaches neither an upgrading user nor the author's own running app.
|
newer than the installed one: without a bump the change reaches neither an upgrading user nor the
|
||||||
|
author's own running app. Without any `version` the vendor vanishes from the setup wizard, and neither
|
||||||
|
`check` nor the validator reports it.
|
||||||
|
|
||||||
## 2. Was the index rebuilt, and does the diff contain only this change?
|
## 2. Was the index rebuilt, and does the diff contain only this change?
|
||||||
|
|
||||||
`check` now fails on an unregistered file, on an index `update-index` would reorder, and on a file
|
`check` fails on an unregistered file, on an index `update-index` would reorder, and on a file
|
||||||
`normalize` would rewrite — so a PR that skipped them arrives red, and you do not have to spot the
|
`normalize` would rewrite, so a PR that skipped them arrives red and you do not have to spot the
|
||||||
omission yourself. Three things are still yours:
|
omission yourself. Three things are still yours:
|
||||||
|
|
||||||
- **The index diff belongs to this change.** `update-index` rewrites whole `*_list` sections. If the
|
- **The index diff belongs to this change.** `update-index` rewrites whole `*_list` sections. If the
|
||||||
@@ -41,11 +47,15 @@ omission yourself. Three things are still yours:
|
|||||||
registration; `validate_custom` detects the break only for names covered by released fixtures.
|
registration; `validate_custom` detects the break only for names covered by released fixtures.
|
||||||
- **`normalize` edits content, not just layout.** It drops `version` and `is_custom_defined` from preset
|
- **`normalize` edits content, not just layout.** It drops `version` and `is_custom_defined` from preset
|
||||||
files, removes obsolete keys, deletes six print-speed keys from filament profiles, and resolves
|
files, removes obsolete keys, deletes six print-speed keys from filament profiles, and resolves
|
||||||
`extruder_clearance_radius` against `extruder_clearance_max_radius` by keeping the larger.
|
`extruder_clearance_radius` against `extruder_clearance_max_radius` by keeping the larger
|
||||||
Check that the keys it removed were meant to go.
|
([what normalize changes](validation.md#normalize-and-update-index-are-part-of-the-check)). Check that
|
||||||
|
the keys it removed were meant to go.
|
||||||
|
|
||||||
Obsolete keys fail `check`'s normalization pass and should be removed with `normalize`.
|
Index order is dependency order, not alphabetical: parents and include templates before the presets
|
||||||
`check` also reports per-key obsolete warnings for filament profiles in the selected vendors.
|
that use them, then, in name order, the entries that neither depend on nor are depended on by another
|
||||||
|
entry of their own list (such as a leaf filament whose only parent is in the library), and any entry on
|
||||||
|
a dependency cycle. Judge a hand-placed entry only by
|
||||||
|
`update-index --dry-run`: if it reports nothing to rebuild, the position is not a finding.
|
||||||
|
|
||||||
*Why:* the index is the loader's only entry point. Out-of-order entries fail with `can not find inherits`
|
*Why:* the index is the loader's only entry point. Out-of-order entries fail with `can not find inherits`
|
||||||
and take the whole vendor bundle down; an unindexed file gets reviewed, merged and never loads.
|
and take the whole vendor bundle down; an unindexed file gets reviewed, merged and never loads.
|
||||||
@@ -55,8 +65,8 @@ and take the whole vendor bundle down; an unindexed file gets reviewed, merged a
|
|||||||
No hand-typed or copied `setting_id` / `filament_id`. Instantiated presets have a `setting_id`; bases do
|
No hand-typed or copied `setting_id` / `filament_id`. Instantiated presets have a `setting_id`; bases do
|
||||||
not. `check` enforces all of that; what it cannot tell you is whether the identity *should* have moved.
|
not. `check` enforces all of that; what it cannot tell you is whether the identity *should* have moved.
|
||||||
|
|
||||||
A rewritten or removed `filament_id` means a product's identity moved — a rename, or an edited
|
A rewritten or removed `filament_id` means a product's identity moved (a renamed alias, or an edited
|
||||||
`filament_vendor` / `filament_type` — and the old id is not forwarded anywhere. Confirm that was
|
`filament_vendor` / `filament_type`), and the old id is not forwarded anywhere. Confirm that was
|
||||||
intended, and that a new id is not a rename in disguise.
|
intended, and that a new id is not a rename in disguise.
|
||||||
|
|
||||||
*Why:* a duplicate `filament_id` on one printer makes AMS spool matching a coin toss; a copied
|
*Why:* a duplicate `filament_id` on one printer makes AMS spool matching a coin toss; a copied
|
||||||
@@ -64,136 +74,204 @@ intended, and that a new id is not a rename in disguise.
|
|||||||
|
|
||||||
## 4. Does anything disappear for existing users?
|
## 4. Does anything disappear for existing users?
|
||||||
|
|
||||||
A rename, a deletion, or a flip of `"instantiation": "true"` → `"false"` on a shipped preset removes the
|
A rename, a deletion, or a flip of `"instantiation": "true"` → `"false"` on a shipped selectable preset
|
||||||
name from the preset collection. It needs `renamed_from` on a successor — and only one preset may claim a
|
removes the name from the preset collection. It needs `renamed_from` on a selectable successor
|
||||||
given old name. The claimed old name must **not** still be a live preset; the redirect is inert if it is.
|
([renamed_from](vendor-bundle.md#renamed_from)), and only one preset may claim a given old name. The
|
||||||
|
claimed old name must **not** still be a live preset; the redirect is inert if it is.
|
||||||
|
|
||||||
*Why:* user presets inheriting it die with `can not find parent <name> for config <file>!`; 3MF-embedded
|
*Why:* user presets inheriting it die with `can not find parent <name> for config <file>!`; 3MF-embedded
|
||||||
presets are dropped with no error at all. Commit `33923464ae` reverted exactly this for Cubicon;
|
presets are dropped with no error at all. Commit `33923464ae` reverted exactly this for Cubicon;
|
||||||
`6943b6ddc3` redid it correctly. CI's `validate_custom` catches the shipped-name case — but not an inert
|
`6943b6ddc3` redid it correctly with `renamed_from`. CI's `validate_custom` catches the shipped-name
|
||||||
`renamed_from`.
|
case, but not an inert `renamed_from`.
|
||||||
|
|
||||||
## 5. Is `compatible_printers` right?
|
## 5. Is `compatible_printers` right?
|
||||||
|
|
||||||
Exact printer **variant** names, non-empty on every instantiated filament outside OrcaFilamentLibrary
|
Exact printer **variant** names, non-empty on every instantiated filament outside OrcaFilamentLibrary,
|
||||||
and written in the preset's own file — golden rule 6, with the flattened-vs-own-key trap in
|
and written in the preset's own file ([SKILL.md rule 9](../SKILL.md#rules); the resolved-vs-own-key trap
|
||||||
[filament-profiles.md](filament-profiles.md#compatible_printers). Watch for a nozzle-specific variant that
|
is in [filament-profiles.md](filament-profiles.md#compatible_printers)). A `machine_model` name instead
|
||||||
inherited or copied the base's full printer list, and for two presets of one product with overlapping
|
of a variant name is the usual mistake: `check` passes it, the validator reports
|
||||||
lists — duplicate combobox entries and an ambiguous AMS match.
|
`references unknown compatible_printers`. Watch for a nozzle-specific tune that inherited or copied the
|
||||||
|
base's full printer list, and for two presets of one product with overlapping lists: duplicate combobox
|
||||||
|
entries and an ambiguous AMS match.
|
||||||
|
|
||||||
Two presets of one product (`filament_id`) must not share a variant. Resolve it by specificity: move the
|
Two presets of one product (`filament_id`) must not share a variant. Resolve it by specificity: move the
|
||||||
variant to the most specific preset and remove it from the more general ones — preferred over deleting a
|
variant to the most specific preset and remove it from the more general ones, which is preferred over
|
||||||
profile. Then repoint the machine's `default_filament_profile` and the model's `default_materials` at the
|
deleting a profile. Then repoint the machine's `default_filament_profile` and the model's
|
||||||
profile that now covers it. See
|
`default_materials` at the profile that now covers it. See
|
||||||
[one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product).
|
[one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product).
|
||||||
|
|
||||||
*Why:* real shipped bugs twice (`b7b3418baf` "showing up everywhere", `ff83aa41ef` duplicate Flashforge
|
*Why:* real shipped bugs twice (`b7b3418baf` filaments "showing up everywhere", `ff83aa41ef` duplicate
|
||||||
entries). The Python `check` passes on an overlap; only the full `check_profile.sh` (`validate_system`)
|
Flashforge entries). The Python `check` passes on an overlap; only the full `check_profile.sh`
|
||||||
reports `Ambiguous AMS filament match`.
|
(`validate_system`) reports `Ambiguous AMS filament match`.
|
||||||
|
|
||||||
## 6. Model ↔ variant ↔ process consistency
|
## 6. One product, one all-printer preset; colour is not a preset
|
||||||
|
|
||||||
- New nozzle size → the model's `nozzle_diameter` list extended, a variant with a matching
|
No presets that differ only by colour: `filament_id` identifies a product, and the colour comes from the
|
||||||
`printer_variant`, and at least one process listing that variant.
|
spool at runtime. An all-printer library product is a `<Product> @System` shim with an empty
|
||||||
- `default_print_profile` is one exact name (not a `;` list), and that process's resolved
|
`compatible_printers` ([colour](filament-profiles.md#colour-is-a-runtime-property)).
|
||||||
compatibility list or condition includes this printer.
|
|
||||||
|
*Why:* per-colour presets pass every check, so this is a review call.
|
||||||
|
|
||||||
|
## 7. Model ↔ variant ↔ process consistency
|
||||||
|
|
||||||
|
- A new nozzle size → the model's `nozzle_diameter` list extended, a variant with a matching
|
||||||
|
`printer_variant`, and at least one process listing that variant. Every size in the model's list has a
|
||||||
|
variant (unchecked).
|
||||||
|
- `default_print_profile` is one exact name (not a `;` list), and that process's resolved compatibility
|
||||||
|
list or condition includes this printer.
|
||||||
- `default_filament_profile` is an array of names that exist.
|
- `default_filament_profile` is an array of names that exist.
|
||||||
|
- Each variant of the model has at least one compatible entry in the model's `default_materials`.
|
||||||
|
|
||||||
*Why:* an unlisted `printer_variant` is a hard bundle-load failure. Default process selection is
|
*Why:* an unlisted `printer_variant` is a hard bundle-load failure. Default process selection is weaker:
|
||||||
weaker: the sweep attempts the named default, then updates compatibility and rejects generic Default
|
the sweep attempts the named default, then updates compatibility and rejects generic Default fallbacks,
|
||||||
fallbacks. Another compatible process can conceal a bad reference, so inspect it even after a pass.
|
so another compatible process can conceal a bad reference. Inspect it even after a pass.
|
||||||
|
|
||||||
## 7. Types and spellings
|
## 8. Types and spellings
|
||||||
|
|
||||||
Every value a string or an array of strings; `filament_type` an array; `instantiation` the string
|
Every value a string or an array of strings; `filament_type` an array; `instantiation` the string
|
||||||
`"true"`/`"false"` — golden rule 7. Check index metadata and model `nozzle_diameter` especially;
|
`"true"` / `"false"`; custom G-code one string, never an array of lines ([SKILL.md rule 5](../SKILL.md#rules)).
|
||||||
wrong types there can abort loading for **every** vendor.
|
Check index metadata and model `nozzle_diameter` especially: wrong types there can abort loading for
|
||||||
|
**every** vendor.
|
||||||
|
|
||||||
The part only a reviewer can do: check new setting keys against `src/libslic3r/PrintConfig.cpp`. A
|
The part only a reviewer can do: check new setting keys against `src/libslic3r/PrintConfig.cpp`. A
|
||||||
misspelled key is silently discarded (rule 8), the single most common way a profile edit does nothing
|
misspelled key is silently discarded ([rule 6](../SKILL.md#rules)), the single most common way a profile
|
||||||
while CI stays green.
|
edit does nothing while CI stays green.
|
||||||
|
|
||||||
## 8. Blast radius of a base edit
|
## 9. Blast radius of a base edit
|
||||||
|
|
||||||
A change to `fdm_*_common.json` reaches every child at once. Ask which presets it touches — several
|
A change to `fdm_*_common.json` or any other base reaches every child at once. Ask which presets it
|
||||||
reverts in this repo are exactly this (`41d1b0d3c8`, `dc491166a8`). Also check whether the edited leaf has
|
touches: several reverts in this repo are exactly this (`41d1b0d3c8`, `dc491166a8`). Also check whether
|
||||||
children of its own: Prusa, Flashforge and Elegoo all chain leaf-inherits-leaf several levels deep.
|
the edited leaf has children of its own: a bundle may chain leaf-inherits-leaf several levels deep. A
|
||||||
|
newly added base that nothing inherits is dead weight, and a dangling
|
||||||
|
`compatible_printers` inside a base is reported only through a child that inherits it unchanged. A diff
|
||||||
|
that only moves values between presets and bases must leave every selectable preset loading what it
|
||||||
|
loaded: ask for the `compare` result ([shared bases](shared-bases.md#nothing-loads-differently)), and
|
||||||
|
check each new base against the [balance rules](shared-bases.md#balance).
|
||||||
|
|
||||||
## 9. Do the numbers make sense for the nozzle?
|
## 10. Do the numbers make sense for the nozzle and material?
|
||||||
|
|
||||||
Check resolved widths and layer heights against the nozzle, and flow limits / pressure advance
|
Check resolved widths and layer heights against the nozzle, temperatures against the material (PLA
|
||||||
against the actual hardware and material. The patterns in [process-profiles.md](process-profiles.md)
|
values under an ASA name print wrong), and flow limits / pressure advance against the actual hardware
|
||||||
are examples, not mandatory values; [filament-profiles.md](filament-profiles.md) explains what to
|
and material. The patterns in [process-profiles.md](process-profiles.md#values-to-review-per-nozzle)
|
||||||
revisit for a nozzle change. A cloned preset's unchanged MVS needs particular scrutiny.
|
are examples, not mandatory values; [filament-profiles.md](filament-profiles.md#tuning-per-nozzle-and-per-variant)
|
||||||
|
explains what to revisit for a nozzle change. A cloned preset's unchanged volumetric speed needs
|
||||||
|
particular scrutiny.
|
||||||
|
|
||||||
Settings tuned for real hardware cannot be verified by reading the diff. Say so rather than approving
|
Settings tuned for real hardware cannot be verified by reading the diff. Say so rather than approving
|
||||||
numbers nobody measured.
|
numbers nobody measured.
|
||||||
|
|
||||||
## 10. Asset references (not checked anywhere)
|
## 11. Plate temperatures
|
||||||
|
|
||||||
|
A filament sets the plate temperature for every plate the printer plausibly has, as its siblings do;
|
||||||
|
`textured_cool_plate_temp` is the one most often forgotten
|
||||||
|
([twelve keys](filament-profiles.md#bed-temperature-is-twelve-keys-not-one)).
|
||||||
|
|
||||||
|
## 12. Asset references (not checked anywhere)
|
||||||
|
|
||||||
`bed_model`, `bed_texture`, `hotend_model` and `<Model>_cover.png` exist under
|
`bed_model`, `bed_texture`, `hotend_model` and `<Model>_cover.png` exist under
|
||||||
`resources/profiles/<vendor folder>/`. Broken references already ship; nothing checks them.
|
`resources/profiles/<vendor folder>/`, in exact case. Nothing checks them.
|
||||||
|
|
||||||
## 11. `default_materials` (checked by CI)
|
## 13. `default_materials` and `default_filament_profile` (checked by CI)
|
||||||
|
|
||||||
`check` fails on a `default_materials` / `default_filament_profile` name that resolves to no system
|
`check` fails on a `default_materials` / `default_filament_profile` name that matches no filament file,
|
||||||
filament, so a dangling entry no longer reaches review. When compatibility moves between profiles of a
|
and `validate_system` on a variant with no compatible system filament in `default_materials`, so a
|
||||||
product, the machine's `default_filament_profile` and the model's `default_materials` must be repointed at
|
dangling entry no longer reaches review. When compatibility moves between profiles of a product, the
|
||||||
the most specific profile that still covers the variant, dropping generic entries that no longer apply —
|
machine's `default_filament_profile` and the model's `default_materials` must be repointed at the most
|
||||||
the same [specificity rule](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)
|
specific profile that still covers the variant, dropping generic entries that no longer apply; the same
|
||||||
applies when adding or fixing defaults. Scope the run while working on one vendor:
|
[specificity rule](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product) applies
|
||||||
|
when adding or fixing defaults. Scope the run while working on one vendor:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/orca_profile_tool.py check --vendor "<Vendor>" # py -3 on Windows
|
python3 scripts/orca_profile_tool.py check --vendor "<Vendor>" # py -3 on Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Per-extruder vector lengths (not checked)
|
## 14. Per-extruder vectors (not checked) and variant arrays (widths and layout checked)
|
||||||
|
|
||||||
One entry per extruder for the plain per-extruder vectors; the `printer_options_with_variant_1` keys are
|
One entry per extruder for the plain per-extruder vectors; the variant sets are sized to the variant
|
||||||
sized to `printer_extruder_variant` instead. A wrong length is silently padded — repeating the **first**
|
length, `len(printer_extruder_variant)` or one per extruder when the resolved preset has no layout (a
|
||||||
value, not the last — or truncated. The two sizing families and the worked cases are in
|
per-extruder difference in those keys still needs the layout, since the loader keeps only the
|
||||||
|
first value of a list-less printer's arrays). A wrong length is silently padded, repeating the **first**
|
||||||
|
value, not the last, or truncated. The two sizing families and the worked cases are in
|
||||||
[machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers).
|
[machine-profiles.md](machine-profiles.md#multi-extruder-idex-and-tool-changers).
|
||||||
|
|
||||||
## 13. Non-default processes get no slice coverage
|
`check` reports a variant array of any type that is not exactly the `variant length × stride` width
|
||||||
|
of the selectable preset that writes it, one value included, as an error whatever the values;
|
||||||
|
`check --strict` also reports one that reaches a selectable preset at another width. A base is not
|
||||||
|
judged on its own. On a machine it
|
||||||
|
also reports layout keys that disagree: `printer_extruder_variant` / `printer_extruder_id` not the
|
||||||
|
flattening of `extruder_variant_list`, a variant without its extruder's `extruder_type` prefix, a
|
||||||
|
`default_nozzle_volume_type` the extruder does not list, a list of several variants without the pair, a
|
||||||
|
pair without the list that puts several variants on one extruder. On a machine or a process, an id
|
||||||
|
array, written or inherited, whose length differs from its variant list is an error; the id and
|
||||||
|
layout rules judge the composed preset without `--strict`. Two layout findings are warnings: a
|
||||||
|
pair without the list that `single_extruder_multi_material` off would replace at load, and a process
|
||||||
|
without `print_extruder_id` whose variants repeat. The full rules are in
|
||||||
|
[variant arrays](validation.md#variant-arrays). `check_variant_names` separately holds every variant
|
||||||
|
string, `extruder_type`, `nozzle_volume_type` and `default_nozzle_volume_type` to the engine's enums
|
||||||
|
in every bundle, BBL included, failing on a dead variant, a legacy spelling and a variant list that
|
||||||
|
names one variant twice ([variant names](validation.md#variant-names)).
|
||||||
|
|
||||||
|
On a multi-variant printer, still check by hand:
|
||||||
|
|
||||||
|
- that a new variant's values sit at its variant index (machine limits as a (normal, silent) pair at
|
||||||
|
`2 × index`) in every file of the chain that restates the key, `include` templates included;
|
||||||
|
- that each process lists every (extruder id, variant) pair its printers can select;
|
||||||
|
- that no array was widened on a base with a shorter variant list: the loader cuts it before the
|
||||||
|
children inherit it ([composition](extruder-variants.md#padding-truncation-and-composition)), and
|
||||||
|
`check`, even `--strict`, composes without that cut, so it passes;
|
||||||
|
- that only keys in [the four sets](extruder-variants.md#the-four-key-sets) carry per-variant values;
|
||||||
|
- that the High Flow variants carry measured values rather than copies
|
||||||
|
([checking and testing](extruder-variants.md#checking-and-testing)).
|
||||||
|
|
||||||
|
## 15. Non-default processes get no slice coverage
|
||||||
|
|
||||||
`validate_slice` starts from printer defaults; it does not enumerate every process. Slice a new or
|
`validate_slice` starts from printer defaults; it does not enumerate every process. Slice a new or
|
||||||
changed non-default tier explicitly with its intended printer.
|
changed non-default tier explicitly with its intended printer
|
||||||
|
([on a copy of the tree](validation.md#checking-a-copy-of-the-tree)).
|
||||||
## 14. Housekeeping worth a nit, not a block
|
|
||||||
|
|
||||||
`"from"` other than `"system"` (the preset-bundle loader ignores it, though the CLI's config-file loader
|
|
||||||
rejects anything but `system`/`user`/`User`), `printer_settings_id` copied from another
|
|
||||||
vendor, and a filename that disagrees with the preset's `name` (common; the loader keys off `name`).
|
|
||||||
|
|
||||||
## 15. Cross-platform filenames and paths (not checked)
|
|
||||||
|
|
||||||
Check for Windows-invalid characters, reserved device names, trailing path-component spaces/dots,
|
|
||||||
and case mismatches in `sub_path` or asset paths. See [cross-platform paths](validation.md#cross-platform-paths).
|
|
||||||
|
|
||||||
## 16. Do the preset names follow the conventions?
|
## 16. Do the preset names follow the conventions?
|
||||||
|
|
||||||
Check **every newly added profile and intentional name change**, including models and bases,
|
Check **every newly added profile and intentional name change**, including models and bases, against
|
||||||
against [the naming conventions](naming.md#checking-names). Preserve shipped names during
|
[the naming conventions](naming.md#checking-names): no printer model in a process quality position, no
|
||||||
ordinary tuning; renaming a shipped selectable preset requires the migration in item 4.
|
target label or base name left over from a copied preset, the bundle's established style. Preserve
|
||||||
|
shipped names during ordinary tuning; renaming a shipped selectable preset requires the migration in
|
||||||
|
item 4.
|
||||||
|
|
||||||
*Why:* CI checks name uniqueness, but does not enforce the naming conventions. Catch naming
|
*Why:* CI checks name uniqueness, but does not enforce the naming conventions. Catch naming mistakes
|
||||||
mistakes before the names ship and existing projects depend on them.
|
before the names ship and existing projects depend on them.
|
||||||
|
|
||||||
|
## 17. Cross-platform filenames and paths (not checked)
|
||||||
|
|
||||||
|
Check for Windows-invalid characters, reserved device names, trailing path-component spaces or dots,
|
||||||
|
and case mismatches between `sub_path` or asset references and the files on disk. See
|
||||||
|
[filenames and paths](naming.md#filenames-and-paths).
|
||||||
|
|
||||||
|
## 18. Housekeeping worth a nit, not a block
|
||||||
|
|
||||||
|
`"from"` other than `"system"` (the bundle loader ignores it, though loading the file as a CLI config
|
||||||
|
rejects anything but `system` / `user` / `User`), `printer_settings_id` copied from another vendor,
|
||||||
|
redundant overrides that restate the parent's value, siblings that each repeat a value their base could
|
||||||
|
hold ([shared bases](shared-bases.md)), and a filename that disagrees with the preset's
|
||||||
|
`name` (the loader keys off `name`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Reporting the review
|
## Reporting the review
|
||||||
|
|
||||||
A finding is: **one defect**, its file, what breaks at runtime or in CI, and the fix. Split independent
|
A finding is **one defect**: its file (or quoted lines), what breaks at runtime or in CI, and the fix.
|
||||||
defects into separate findings even when they live in one file — five id problems in one bullet get one
|
Split independent defects into separate findings even when they live in one file: five id problems in
|
||||||
fix and four survivors.
|
one bullet get one fix and four survivors. Say which findings `check` or the validator reports and which
|
||||||
|
only a reader catches: a missing version bump, a misspelled key and wrong temperatures pass CI, so a
|
||||||
|
contributor who only reruns the tools fixes what CI flags and resubmits the rest.
|
||||||
|
|
||||||
Severity discriminates only if it is earned:
|
Severity discriminates only if it is earned:
|
||||||
|
|
||||||
| Severity | Means |
|
| Severity | Means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| blocker | the bundle fails to load, or a preset is unreachable at runtime |
|
| blocker | the bundle fails to load, or a preset is unreachable at runtime |
|
||||||
| major | CI fails, or existing users lose a preset |
|
| major | CI fails, existing users lose a preset, or a value prints wrong while CI stays green (PLA temperatures under an ASA name; a misspelled key whose intended value differs from the inherited one) |
|
||||||
| minor | wrong-but-working: dead keys, `from`, naming, redundant overrides |
|
| minor | wrong but working: redundant or dead keys that change nothing, `from`, naming |
|
||||||
|
|
||||||
Compute every number and id (`orca_profile_tool.py`, a scripted count) or omit it — one invented count
|
Compute every number and id (`orca_profile_tool.py`, a scripted count) or omit it: one invented count
|
||||||
makes a reader stop trusting the right ones. Report a command's result only if you ran it.
|
makes a reader stop trusting the right ones. Report a command's result only if you ran it. End with a
|
||||||
|
verdict: can it merge as it stands?
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Shared bases
|
||||||
|
|
||||||
|
Use this when drafting several presets at once, after `fix-variant`, or when sibling presets repeat the
|
||||||
|
same values. Each value is written once, on the base of the group it is true for, and a selectable
|
||||||
|
preset holds its identity and what makes it different. The work has two halves: choosing the groups,
|
||||||
|
which is judgment, and moving the values, which must leave every preset loading exactly what it loaded
|
||||||
|
before.
|
||||||
|
|
||||||
|
## When
|
||||||
|
|
||||||
|
- **Drafting** a printer family, a quality ladder or a product line: place each setting at its
|
||||||
|
[level](#levels) before writing any preset, then write the presets as overrides
|
||||||
|
([Rule 7](../SKILL.md#rules)).
|
||||||
|
- **After `fix-variant`.** It resizes each array in the selectable preset that writes it and never
|
||||||
|
moves or deletes a value ([variant arrays](validation.md#variant-arrays)). A family whose presets all
|
||||||
|
wrote one value now repeats the widened array in every preset, and a preset that restated what it
|
||||||
|
inherits now restates it wider.
|
||||||
|
- **Converting full copies**: a machine that inherits nothing, or a filament that restates fifty-plus
|
||||||
|
keys, is the style to move away from ([filament style](filament-profiles.md#style)).
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- **BBL.** Its profiles are synced from BambuStudio: a restructure is overwritten by the next sync and
|
||||||
|
makes every later sync diff unreadable. Fix BBL values in the file that holds them.
|
||||||
|
- **OrcaFilamentLibrary bases for one vendor's values.** A library base reaches every bundle's
|
||||||
|
filaments. Put a vendor's shared values on a base in its own bundle (a product `@base`, or a vendor
|
||||||
|
base that inherits the library's), and change a library base only for a value true of every filament
|
||||||
|
below it in every bundle.
|
||||||
|
- **Families the change does not touch.** Restructure the presets you are already changing; a
|
||||||
|
bundle-wide pass is a change of its own. Commit a restructure without any value change, so `compare`
|
||||||
|
alone verifies it.
|
||||||
|
|
||||||
|
## Nothing loads differently
|
||||||
|
|
||||||
|
A restructure changes where values are written, never what a selectable preset loads. Presets keep
|
||||||
|
their `name` and `setting_id`, and user presets and projects refer to a system preset by name and store
|
||||||
|
their own changes against what it loads, so an unchanged load changes nothing for users. Prove it with
|
||||||
|
the bundled helper, from the repository root (every subcommand takes `--profiles <dir>` to work on a
|
||||||
|
copy of the tree):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 .claude/skills/orca-profiles/scripts/shared_settings.py snapshot <before.json> # before any edit
|
||||||
|
python3 .claude/skills/orca-profiles/scripts/shared_settings.py compare <before.json> # after: "0 difference(s)", exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`snapshot` records every selectable preset of every bundle as the loader stores it: the parent's stored
|
||||||
|
config, each `include` at the width its file wrote, the preset's own keys, then every variant array
|
||||||
|
resized to the preset's own variant list ([composition](vendor-bundle.md#inherits-and-include)). It
|
||||||
|
models the step `check` does not see: a base is stored after its own resize, so an array wider than a
|
||||||
|
base's list reaches its children cut. It also leaves out what the loader reads from each file and never
|
||||||
|
hands down (`name`, `type`, `from`, `instantiation`, `inherits`, `include`, `setting_id`,
|
||||||
|
`renamed_from`, `description`, `version`, `url`, `is_custom_defined`), so those keys never move to a
|
||||||
|
base. Nor does it record `print_settings_id`, `printer_settings_id` or `filament_settings_id`: the app
|
||||||
|
replaces them with the selected presets' names before slicing, so a file's value never counts. Delete
|
||||||
|
them from the presets you restructure rather than moving them to a base.
|
||||||
|
|
||||||
|
It does not know the built-in defaults, so `compare` lists a key written on one side only (no file of
|
||||||
|
the preset's chain writes it on the other) apart from the differences, and exits 1 for either. A
|
||||||
|
difference is a value the edit changed: undo it, or make it a separate, deliberate change. A one-sided
|
||||||
|
key is no change only when its written value is the option's default in `PrintConfig.cpp`: confirm
|
||||||
|
each, as when a preset that loaded the default by leaving a key out must now write it
|
||||||
|
([Balance 5](#balance)). A value equal to the default needs writing nowhere when no base above writes
|
||||||
|
another: delete it from the presets rather than moving it, and confirm the one-sided keys.
|
||||||
|
|
||||||
|
## Levels
|
||||||
|
|
||||||
|
A base stands for a level of the vendor's catalogue, and a setting lives at the level that determines
|
||||||
|
it. The test for a shared value: if it had to change for one preset of the group, should it change for
|
||||||
|
all of them? If yes, it is the group's and goes on the group's base. A value that is only equal today
|
||||||
|
(two unrelated printers with the same acceleration) stays in each preset: a base built on coincidence
|
||||||
|
is later edited for one preset and silently changes the others. For a default with exceptions
|
||||||
|
([Balance 5](#balance)), ask the question of the presets that inherit the default.
|
||||||
|
|
||||||
|
The tables give each setting's usual level; the test decides for a given bundle: where each toolhead
|
||||||
|
(Bowden or Direct Drive) has its own default filament, `default_filament_profile` follows the
|
||||||
|
toolhead, not the nozzle. Levels run
|
||||||
|
coarse to fine, and a chain need not visit every level: each preset or base inherits the next coarser
|
||||||
|
level that has a base.
|
||||||
|
|
||||||
|
| Machine level | Base | Settings it determines |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vendor | `fdm_machine_common`, `fdm_<vendor>_common` | the vendor's defaults for every printer |
|
||||||
|
| Firmware | `fdm_klipper_common`, `fdm_marlin_common` | `gcode_flavor`, G-code that calls the firmware's macros (layer change, pause, filament change), `host_type`, `print_host`, the thumbnail format |
|
||||||
|
| Hardware family: models that share a frame, motion system, toolhead or extruder layout | `fdm_<vendor>_<family>_common` (`fdm_qidi_x3_common`, `fdm_machine_eryone_ER20_common`) | the `machine_max_*` limits, `extruder_clearance_*`, the toolhead's retraction where every nozzle shares it, `z_hop` and wipe, fitted hardware such as `auxiliary_fan`, the extruder count and per-extruder vectors, the variant layout ([Printer rule 2](extruder-variants.md#printer-machine)) |
|
||||||
|
| Model: one `machine_model`, which for an IDEX printer includes its mode | the default-nozzle preset where the bundle hangs its other nozzle presets off it; otherwise `fdm_<vendor>_<model>_common` | `printable_area`, `printable_height`, `bed_exclude_area`, the model's start G-code, a COPY or MIRROR mode's settings |
|
||||||
|
| Nozzle: the selectable preset | none | `printer_model` and `printer_variant`, which name the preset's model and nozzle and stay in every preset as the tree writes them; `nozzle_diameter`, `min_layer_height`, `max_layer_height`, `default_print_profile`, `default_filament_profile`, retraction the vendor tunes per nozzle |
|
||||||
|
|
||||||
|
A family may split once more, into toolhead or revision groups that exist only within it:
|
||||||
|
`fdm_<vendor>_<family>_common` → `fdm_<vendor>_<family>_mk1_common`. A split that crosses another axis is not a
|
||||||
|
level: when every controller comes with every toolhead, a toolhead base under each controller base
|
||||||
|
repeats the same values in each ([Balance 6](#balance)).
|
||||||
|
|
||||||
|
| Process level | Base | Settings it determines |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vendor | `fdm_process_common`, `fdm_process_<vendor>_common` | strategy: seam, wall order, infill and support patterns |
|
||||||
|
| Printer family or variant layout | `fdm_process_<vendor>_<family>_common` (`fdm_process_arena_common`, BBL's `fdm_process_dual_common`) | speeds, accelerations and jerk of that motion system; the variant layout ([Process rule 4](extruder-variants.md#process)) |
|
||||||
|
| Layer height × nozzle | `fdm_process_<vendor>_<lh>_nozzle_<n>` (BBL's `fdm_process_single_0.20`) | `layer_height`, line widths, shell layers, speeds scaled to the layer |
|
||||||
|
| Quality × printer: the selectable preset | none | `compatible_printers`, and what is unique to that combination |
|
||||||
|
|
||||||
|
| Filament level | Base | Settings it determines |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Material | `fdm_filament_<material>` in OrcaFilamentLibrary, shared by every bundle | material defaults |
|
||||||
|
| Product | `<Product> @base` | `filament_id` (minted here, [ids](ids.md)), `filament_vendor`, `filament_type`, density, cost, the product's temperatures and cooling |
|
||||||
|
| Printer or nozzle tune: the selectable preset | none | `compatible_printers` (always in its own file, [Rule 9](../SKILL.md#rules)), volumetric speed, flow ratio, pressure advance and retraction measured on that printer |
|
||||||
|
|
||||||
|
**Equal where they must differ is a copy.** A key that follows a finer level, such as the layer-height
|
||||||
|
limits and line widths that follow the nozzle diameter or `printable_area` that follows the bed, never
|
||||||
|
moves above that level. When presets that differ in it carry the same value, the value was copied: leave
|
||||||
|
it in the presets and report it (the [worked example](#worked-example-an-idex-family) has one).
|
||||||
|
|
||||||
|
## Balance
|
||||||
|
|
||||||
|
1. **One group, one base; use the existing one first.** A base is the home of the presets below it,
|
||||||
|
whatever its name. In a bundle whose presets are all one family, the vendor base is the family base,
|
||||||
|
so the family's values go there. In a bundle that hangs the other nozzles off the default-nozzle preset, that preset is the model's home.
|
||||||
|
Never create a base whose presets are exactly its parent's. Where two existing bases already serve
|
||||||
|
the same presets (a copied `fdm_machine_common` above the vendor's own base), the finer one is the
|
||||||
|
home, and merging the pair is a change of its own. Moving a key into an existing home adds no file.
|
||||||
|
A base value that no preset below it loads is dead: replace it with the group's value when there is
|
||||||
|
one; otherwise leave it and report it, since a future preset would inherit it.
|
||||||
|
2. **A new base must stand for a level and pay for itself.** Its file costs five metadata keys and an
|
||||||
|
index entry, so create it only when it takes `k` keys off `n` selectable presets with
|
||||||
|
`(n − 1) × k > 6`, where a custom G-code value counts as one key per G-code line: what it removes
|
||||||
|
must outnumber what it adds. A model with two nozzles that share three short keys keeps them in both.
|
||||||
|
3. **No ad-hoc bases:** never a base for presets that merely agree (the ones with 0.8 mm retraction),
|
||||||
|
and never a base with one preset below it.
|
||||||
|
4. **Keep every selectable preset within four ancestors**, selectable parents included. When a level would push a preset past that, fold it into the level above or leave
|
||||||
|
its keys in the presets.
|
||||||
|
5. **A default with exceptions.** A value that only some presets below a shared base load moves to that
|
||||||
|
base, whichever axis it follows, when two conditions hold. More presets load it than any other value
|
||||||
|
(on a tie the key stays in the presets), and `w − a > 1`, where `w` presets drop their copy and `a`
|
||||||
|
presets that take the key from the base and load another value, the built-in default included, must
|
||||||
|
now write theirs. Presets that write another value keep it and are unaffected. The base then holds
|
||||||
|
the group's default, and the exceptions stay visible in their own files.
|
||||||
|
6. **One chain; the other axes stay in the presets.** `inherits` follows one axis. When presets vary
|
||||||
|
along several (bed size × controller × toolhead), first fill the existing homes by
|
||||||
|
rules 1 and 5. Then give new bases to the axis whose bases pay most: sum rule 2's count over its
|
||||||
|
bases, less the keys a re-parented preset must now write because it no longer inherits them from its
|
||||||
|
old parent; on a tie, follow the layering the bundle already has. Leave the other axes' keys in the
|
||||||
|
presets, and never repeat one axis's bases under each group of another. Outside BBL, whose synced
|
||||||
|
presets need theirs, add no `include` template for a second axis: the loader reads `include` only
|
||||||
|
since #15869 (2026-09-25), and an app that predates it ignores the key, so the template's settings
|
||||||
|
never reach the preset.
|
||||||
|
|
||||||
|
Name a new base after its level ([base names](naming.md#bases)). The name must be unique in its bundle
|
||||||
|
and must not equal a selectable preset's: two such presets load silently and the first in the index wins
|
||||||
|
([uniqueness](naming.md#uniqueness)).
|
||||||
|
|
||||||
|
## Restated values
|
||||||
|
|
||||||
|
`candidates` lists every key a file writes that it would load unchanged without writing it. Delete it
|
||||||
|
when the value is what the presets below the base that supplies it share: more of them load it,
|
||||||
|
written or inherited, than any other value. A family that restates machine limits, clearances and
|
||||||
|
G-code every other printer of the bundle loads from `fdm_klipper_common` drops its copies. When most
|
||||||
|
presets below that base load another value, the match is a coincidence: keep the key, and move it to
|
||||||
|
the level of the presets that share it. Keys of the nozzle level stay in the preset in
|
||||||
|
either case.
|
||||||
|
|
||||||
|
After a restructure the report still lists keys that are right where they are: the nozzle-level keys
|
||||||
|
each preset keeps, and arrays a list-less multi-extruder base writes at its presets' width for
|
||||||
|
`check --strict`.
|
||||||
|
|
||||||
|
## Variant arrays on a base
|
||||||
|
|
||||||
|
- The loader stores a base with its variant arrays resized to the base's own variant list, one variant
|
||||||
|
when it writes none, so an array on a narrower base reaches its presets as its first value, padded.
|
||||||
|
Move a variant array to a base only when the base declares the presets' variant list (and, for a
|
||||||
|
machine or process, their ids), moving the layout keys with it as
|
||||||
|
[Printer rule 2](extruder-variants.md#printer-machine) and [Process rule 4](extruder-variants.md#process)
|
||||||
|
ask; or when the presets load its first value anyway: every value is equal, or the presets are
|
||||||
|
list-less machines, which the loader cuts to one variant. `compare` catches a cut.
|
||||||
|
- Write the array on the base at the width of the presets it serves, their `N`
|
||||||
|
([sizing equation](extruder-variants.md#sizing-equation)), so `check --strict` judges the right width
|
||||||
|
where it reaches them. When the presets below a base need different widths (single- and
|
||||||
|
dual-extruder models on one base), leave the array in the presets, or on bases that each serve one
|
||||||
|
width.
|
||||||
|
- On a list-less multi-extruder family base, declare the extruder count: `nozzle_diameter` and the other
|
||||||
|
per-extruder vectors at one entry per extruder. The base then has its presets' width, and
|
||||||
|
`fix-variant --strict` writes an array that reaches the presets at another width into the base once,
|
||||||
|
instead of into every preset.
|
||||||
|
- Deleting a restated variant array leaves the preset on the inherited array. Plain `check` accepts
|
||||||
|
that; `check --strict` reports it when the inherited width differs, which is why a bundle held to
|
||||||
|
`--strict` restates the array at each preset's width.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Snapshot** the tree before any edit, `fix-variant` included. After `fix-variant`, run `compare`: a
|
||||||
|
difference is a value its padding changed (it repeats the last value, the loader the first). Set that
|
||||||
|
value deliberately, then snapshot again as the baseline for the restructure.
|
||||||
|
2. **List the candidates:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 .claude/skills/orca-profiles/scripts/shared_settings.py candidates --vendor "<Vendor>" --type machine
|
||||||
|
python3 .claude/skills/orca-profiles/scripts/shared_settings.py candidates --vendor "<Vendor>" --type machine --group-by printer_model
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints the [restated values](#restated-values), then, per base, the keys every selectable preset
|
||||||
|
below it loads with one value and how many of those presets write it themselves, and under
|
||||||
|
`default with exceptions` the values that pass [Balance 5](#balance), with `w` and `a`. With
|
||||||
|
`--group-by <key>` it groups the selectable presets by that key's value instead and names each
|
||||||
|
group's nearest common base, where a new base would go: `printer_model` for models, `gcode_flavor`
|
||||||
|
for firmware, `extruder_type` or `default_filament_profile` for toolheads, `filament_id` for
|
||||||
|
filament products, `layer_height` for processes. The report is evidence, not a plan: it cannot tell
|
||||||
|
a shared value from a coincidence or a copy.
|
||||||
|
3. **Decide each key** by [Levels](#levels), [Balance](#balance) and
|
||||||
|
[Restated values](#restated-values): delete the restatements of shared values, move group values up
|
||||||
|
to the group's home, and create only the bases that pay.
|
||||||
|
4. **Edit.** Each new base gets `"type"`, `"name"`, `"from": "system"`, `"instantiation": "false"`, no
|
||||||
|
`setting_id`, and `inherits` set to the presets' old parent. Point the presets' `inherits` at it and
|
||||||
|
delete the moved keys from them. Bump the version, then run `normalize`, `update-index` (it orders
|
||||||
|
parents first) and `generate-id --dry-run`, which must write nothing: bases take no id and presets
|
||||||
|
keep theirs.
|
||||||
|
5. **In a bundle held to `--strict`, run `fix-variant --strict` now**, so it writes into the new bases.
|
||||||
|
6. **Verify.** `compare` prints `0 difference(s)`, and every one-sided key it lists is a default.
|
||||||
|
`check` reports no error it did not report before, and neither does `check --strict` where the bundle
|
||||||
|
passes it. Then run the [authoring checks](../SKILL.md#creating-or-modifying-a-profile).
|
||||||
|
7. **Report** each base added (name, level, presets below it, keys it holds), the keys left in presets
|
||||||
|
and why, the copies found, and the `compare` result.
|
||||||
|
|
||||||
|
## Worked example: an IDEX family
|
||||||
|
|
||||||
|
A Klipper bundle's IDEX family has 36 selectable machines (bed size 300, 400 or 500 × normal, COPY or
|
||||||
|
MIRROR mode × 0.4, 0.5, 0.6 or 0.8 nozzle) that all inherit `fdm_klipper_common` directly and write 49
|
||||||
|
keys each. `fix-variant` has widened their retraction arrays to two values and their machine limits
|
||||||
|
to four.
|
||||||
|
|
||||||
|
- **Restated.** All 36 restate 10 values that every other printer of the bundle loads from
|
||||||
|
`fdm_klipper_common`: three machine limits, the three clearances, wipe, `retract_before_wipe`, and the
|
||||||
|
layer-change and pause G-code. Delete them.
|
||||||
|
- **Family.** All 36 load one value for 24 more keys: the other machine limits, `extruder_offset`,
|
||||||
|
retraction, `z_hop`, `single_extruder_multi_material`, `manual_filament_change`, the remaining G-code
|
||||||
|
except the start G-code, and thumbnails. A new family base, `fdm_<vendor>_<family>_idex_common`,
|
||||||
|
holds them, plus `nozzle_diameter` `["0.4", "0.4"]` for the extruder count: at least
|
||||||
|
`(36 − 1) × 24 = 840`.
|
||||||
|
- **Model.** Each `printer_model` (a bed size in one mode) has four nozzle presets that share
|
||||||
|
`printable_area`, `printable_height` and a three-line `machine_start_gcode`: `(4 − 1) × 5 = 15`, so
|
||||||
|
one base per model, nine in all. The family does not hang its other nozzles off a default-nozzle
|
||||||
|
preset, so the model level here is a base.
|
||||||
|
- **A coincidence.** The twelve 500 presets' `printable_height` 500 equals `fdm_klipper_common`'s, but
|
||||||
|
every preset of the bundle writes its own height and 300 is the most common. The 500 is the base's
|
||||||
|
leftover, not a shared value, so it stays on the model bases.
|
||||||
|
- **A copy.** In COPY and MIRROR mode, every nozzle of a model carries the 0.4 nozzle's
|
||||||
|
`min_layer_height`, `max_layer_height` and `retract_lift_below`: 0.06, 0.3 and 0.2 on the 0.8 nozzle,
|
||||||
|
where normal mode has 0.12, 0.5 and 0.3. `candidates --group-by printer_model` lists the first and
|
||||||
|
last as shared by the model, and `max_layer_height` as restated from `fdm_klipper_common`, whose value
|
||||||
|
is also 0.3. They follow the nozzle, so they stay in the presets and are reported for tuning.
|
||||||
|
- **Nozzle.** Each preset keeps `printer_model`, `printer_variant`, `nozzle_diameter`,
|
||||||
|
`min_layer_height`, `max_layer_height` and `retract_lift_below` beside its metadata.
|
||||||
|
- **Result.** 36 full presets become 36 short ones on 10 new bases. `compare` reports 0 differences,
|
||||||
|
`check --vendor "<Vendor>"` passes as before, and `generate-id --dry-run` writes nothing.
|
||||||
|
`check --strict` reports more errors than before, because the deleted variant arrays now reach the
|
||||||
|
presets at `fdm_klipper_common`'s one value. `fix-variant --strict` writes those arrays into the
|
||||||
|
family base alone, after which `check --strict` passes for the family and `compare` still reports 0
|
||||||
|
differences.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Validating profiles
|
# Validating profiles
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/check_profile.sh # everything CI runs
|
./scripts/check_profile.sh # everything CI runs
|
||||||
./scripts/check_profile.sh --vendor "<Vendor>" # fast loop
|
./scripts/check_profile.sh --vendor "<Vendor>" # development loop
|
||||||
./scripts/check_profile.sh profile_tool validate_slice # named checks only
|
./scripts/check_profile.sh profile_tool validate_slice # named checks only
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -12,153 +12,316 @@ scripts\check_profile.bat -Vendor "<Vendor>"
|
|||||||
scripts\check_profile.bat profile_tool validate_slice
|
scripts\check_profile.bat profile_tool validate_slice
|
||||||
```
|
```
|
||||||
|
|
||||||
`check_profile.bat` is a shim around `check_profile.ps1` — same checks, same order, same logs;
|
`check_profile.bat` is a shim around `check_profile.ps1`: same checks, same order, same logs. Its flags
|
||||||
the flags take PowerShell spellings (`-Vendor`, `-ProfilesDir`, `-Validator`, `-Download`, `-Refresh`,
|
take PowerShell spellings (`-Vendor`, `-ProfilesDir`, `-Validator`, `-Download`, `-Refresh`,
|
||||||
`-WorkDir`, `-LogLevel`) and positional check names are unchanged. `-p`, `-v` and `-l` are aliases, so
|
`-WorkDir`, `-LogLevel`), and positional check names are unchanged. `-p`, `-v` and `-l` are aliases,
|
||||||
`-v Elegoo -l 2` reads the same on both platforms. It passes `-ExecutionPolicy Bypass` because a
|
so `-v Elegoo -l 2` reads the same on both platforms. It passes `-ExecutionPolicy Bypass` because a
|
||||||
default Windows client refuses to run a checked-out `.ps1` at all. The `.ps1` finds Python itself,
|
default Windows client refuses to run a checked-out `.ps1` at all. The `.ps1` finds Python itself,
|
||||||
probing `py -3`, then `python`, then `python3`; run the tool by hand with `py -3` for the same reason.
|
probing `py -3`, then `python`, then `python3`; run the tool by hand with `py -3` for the same reason.
|
||||||
|
|
||||||
Every check in the run happens even after an earlier one fails; the script exits non-zero if any did, and writes
|
Every check runs even after an earlier one fails; the script exits non-zero if any failed, and writes
|
||||||
`logs/<check>.log` plus, on failure, `pr_comment.md` under a per-user cache dir — the same report CI posts on the PR.
|
`logs/<check>.log` plus, on failure, `pr_comment.md` (the same report CI posts on the PR) under a
|
||||||
That dir is `~/Library/Caches/orca-profile-check` on macOS, `${XDG_CACHE_HOME:-~/.cache}/orca-profile-check` on Linux
|
per-user cache dir:
|
||||||
and `%LOCALAPPDATA%\orca-profile-check` on Windows; it is named apart from OrcaSlicer's own per-user dirs and sits
|
|
||||||
outside the checkout, so every worktree shares one copy. `--work-dir` / `-WorkDir` overrides it. A stale `.lock`
|
| Platform | Cache dir |
|
||||||
there after a crash must be removed by hand.
|
| --- | --- |
|
||||||
|
| macOS | `~/Library/Caches/orca-profile-check` |
|
||||||
|
| Linux | `${XDG_CACHE_HOME:-~/.cache}/orca-profile-check` |
|
||||||
|
| Windows | `%LOCALAPPDATA%\orca-profile-check` |
|
||||||
|
|
||||||
|
It is named apart from OrcaSlicer's own per-user dirs and sits outside the checkout, so every worktree
|
||||||
|
shares one copy and each run overwrites its `logs/`. `--work-dir` / `-WorkDir` overrides it.
|
||||||
|
|
||||||
|
When other worktrees or agents may run checks too, pass `--work-dir <a dir of your own>` from the start
|
||||||
|
and capture the console output yourself: the shared `logs/` can belong to another run by the time you
|
||||||
|
read them. With `--work-dir`, also pass `--validator` pointing at the cached nightly, so the new dir
|
||||||
|
does not download it again:
|
||||||
|
|
||||||
|
| Platform | Cached validator |
|
||||||
|
| --- | --- |
|
||||||
|
| Linux | `<cache dir>/validator/OrcaSlicer_profile_validator` |
|
||||||
|
| macOS | `<cache dir>/validator/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator` |
|
||||||
|
| Windows | `<cache dir>\validator\OrcaSlicer_profile_validator.exe` |
|
||||||
|
|
||||||
|
Copying the cached `profile-fixtures/` into the new dir reuses the fixture archives; the fixture
|
||||||
|
`manifest.json` is still downloaded on every run, so `validate_custom` needs the network either way.
|
||||||
|
`another run is using <dir>` means a live run holds `<dir>/.lock`: leave it and use your own
|
||||||
|
`--work-dir`. Only a `.lock` with no `check_profile` process alive is a crash leftover; delete it by
|
||||||
|
hand.
|
||||||
|
|
||||||
## The five checks
|
## The five checks
|
||||||
|
|
||||||
| Check | Command it runs | Catches |
|
| Check | Command it runs | Catches |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `profile_tool` | `python3 scripts/orca_profile_tool.py check` | index coverage **both ways**, preset-name collisions, files `normalize`/`update-index` would still rewrite, duplicate JSON keys, filament `compatible_printers`, `filament_type` array, conflict keys, id length, **all `setting_id` and `filament_id` rules** |
|
| `profile_tool` | `python3 scripts/orca_profile_tool.py check` | index coverage **both ways**, preset-name collisions, files `normalize` / `update-index` would still rewrite, duplicate JSON keys, filament `compatible_printers`, `filament_type` array, conflict keys, variant strings and array widths and layout keys, dangling `default_materials`, id length, **all `setting_id` and `filament_id` rules** ([below](#orca_profile_toolpy-check)) |
|
||||||
| `validate_system` | `validator -p resources/profiles -l 2` | load errors, missing filament `compatible_printers`, dangling `inherits`/`compatible_*`, duplicate `filament_id` per printer |
|
| `validate_system` | `validator -p resources/profiles -l 2` | load errors, missing filament `compatible_printers`, dangling `inherits` / `compatible_*`, duplicate `filament_id` per printer (`Ambiguous AMS filament match`), printer defaults that name no compatible system filament |
|
||||||
| `validate_slice` | `validator -p … -s -l 2` | custom G-code expansion, unresolvable printer defaults |
|
| `validate_slice` | `validator -p … -s -l 2` | custom G-code expansion and unresolvable printer defaults, by slicing |
|
||||||
| `validate_filament_subtypes` | `validator -p … -l 2 -f` | nothing extra — see below |
|
| `validate_filament_subtypes` | `validator -p … -l 2 -f` | nothing extra; see below |
|
||||||
| `validate_custom` | `validator -p <tree+fixture> -l 2` | a shipped preset name that a past release offered no longer resolving |
|
| `validate_custom` | `validator -p <tree + fixture> -l 2` | a shipped preset name that a past release offered no longer resolving |
|
||||||
|
|
||||||
**`-f` is a no-op.** It is declared `po::bool_switch()->default_value(true)`, so the duplicate-`filament_id`
|
**`-f` is a no-op.** It defaults to on, so the duplicate-`filament_id` check runs whether or not you
|
||||||
check runs whether or not you pass it — `validate_system` already fails on duplicates. The binary's own
|
pass it, and `validate_system` already fails on duplicates. The binary's own `--help` ("Off unless this
|
||||||
`--help` ("Off unless this flag is present") does not reflect that default.
|
flag is present") does not reflect that default.
|
||||||
|
|
||||||
### `validate_custom` — the backward-compatibility gate
|
**A `--vendor` run reads differently from CI.** The validator's `-v` loads that vendor plus
|
||||||
|
OrcaFilamentLibrary and nothing else, so every library tune whose `compatible_printers` names another
|
||||||
|
vendor's printers fails `validate_system`, `validate_filament_subtypes` and each `validate_custom`
|
||||||
|
fixture with thousands of `references unknown compatible_printers "Bambu Lab …"` lines. Under
|
||||||
|
`--vendor`, `profile_tool` and `validate_slice` are the meaningful results; for the other three, filter
|
||||||
|
the log for your vendor's files and treat only those lines as findings. The unscoped run is the CI
|
||||||
|
result; run it before the PR.
|
||||||
|
|
||||||
Downloads one fixture archive per past release (v1.9.0 onwards) of *generated mock* user presets —
|
### `validate_custom`: the backward-compatibility gate
|
||||||
a `<vendor>_<preset>_orca_test` copy of every system preset that
|
|
||||||
release shipped, cut with the validator's own `-g 1` mode — unpacks each over a copy of the current tree
|
It downloads one fixture archive per past release (v1.9.0 onwards) of *generated mock* user presets: a
|
||||||
and loads it. Each entry holds only `inherits` plus a canned diff, so the one failure it adds over
|
`<vendor>_<preset>_orca_test` copy of every system preset that release shipped, cut with the
|
||||||
`validate_system` is a shipped preset name disappearing. (The whole current tree sits under each fixture,
|
validator's own `-g 1` mode. It unpacks each over a copy of the current tree and loads it. Each entry
|
||||||
so every `validate_system` error fails it too.) This is what makes a rename or an
|
holds only `inherits` plus a canned diff, so the one failure it adds over `validate_system` is a shipped
|
||||||
`instantiation` flip a CI failure rather than just a user complaint, and the reason `renamed_from` is
|
preset name disappearing. This is what makes a rename, a deletion or an `instantiation` flip a CI
|
||||||
mandatory.
|
failure rather than just a user complaint, and the reason `renamed_from` is mandatory.
|
||||||
|
|
||||||
|
The whole current tree sits under each fixture, so every `validate_system` error fails
|
||||||
|
`validate_custom` too: fix `validate_system` first. Under `--vendor` it copies only the top-level index
|
||||||
|
files, `<Vendor>/` and `OrcaFilamentLibrary/`, and picks fixtures by the index's display `name`
|
||||||
|
(`Bambulab` for `BBL`), not the file stem; fixture presets without that prefix are covered only by an
|
||||||
|
unscoped run, and it warns `validate_custom checked nothing` when none match.
|
||||||
|
|
||||||
### `validate_slice`
|
### `validate_slice`
|
||||||
|
|
||||||
Slices a two-colour cube on every instantiable printer in the tree, sequentially, forcing the prime tower.
|
It slices a two-colour cube on every instantiable printer in the tree, sequentially, forcing the prime
|
||||||
It selects `default_print_profile` and the first `default_filament_profile`, then updates compatibility;
|
tower. It selects `default_print_profile` and the first `default_filament_profile`, then updates
|
||||||
that update can select a different compatible preset. Confirm the intended defaults yourself rather
|
compatibility; that update can select a different compatible preset. Confirm the intended defaults
|
||||||
than treating a passing sweep as proof that those exact presets were sliced.
|
yourself rather than treating a passing sweep as proof that those exact presets were sliced.
|
||||||
A printer fails if it cannot be selected, falls back to a Default preset, throws, produces no g-code, or
|
|
||||||
emits no `CP TOOLCHANGE START`. It cannot be scoped to a filament-only vendor
|
A printer fails if it cannot be selected, falls back to a Default preset, throws, produces no G-code,
|
||||||
(`No instantiable printer presets found for vendor OrcaFilamentLibrary`); `check_profile.sh` records it
|
or emits no `CP TOOLCHANGE START` (`change_filament_gcode` never expanded). Non-default processes and
|
||||||
as SKIP for a vendor with no `machine/` folder.
|
filaments get no dedicated coverage; [slice them on a copy](#checking-a-copy-of-the-tree). In the
|
||||||
|
default set, a bundle without a `machine/` folder is recorded as SKIP; naming `validate_slice`
|
||||||
|
explicitly for it fails (`No instantiable printer presets found for vendor OrcaFilamentLibrary`). The
|
||||||
|
validator logs `[error]` lines that do not fail a check (such as `could not found extruder_type`); only
|
||||||
|
each check's PASS or FAIL counts.
|
||||||
|
|
||||||
## `orca_profile_tool.py check`
|
## `orca_profile_tool.py check`
|
||||||
|
|
||||||
`check` is one subcommand of the tool that also owns
|
`check` is one subcommand of the tool that also owns `fix-variant`, `generate-id`, `normalize`,
|
||||||
`generate-id`, `normalize`, `trim` and `update-index`; see [ids.md](ids.md) for the writing half.
|
`trim` and `update-index`; [ids.md](ids.md#the-tool) has the writing half.
|
||||||
|
|
||||||
| Per vendor | Catches |
|
| Catches | Scope | Function in the tool |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| `check_preset_name_uniqueness` | two files in one bundle claiming one type + name — indexed or not |
|
| two files in one bundle claiming one type + name, indexed or not | per vendor | `check_preset_name_uniqueness` |
|
||||||
| `check_index_coverage` | a file on disk that no `*_list` references (**an error, not a warning**) |
|
| a file on disk that no `*_list` references (**an error, not a warning**) | per vendor | `check_index_coverage` |
|
||||||
| `check_name_consistency` | an index entry whose `name` disagrees with the file, or whose `sub_path` is missing |
|
| an index entry whose `name` disagrees with the file, or whose `sub_path` is missing | per vendor | `check_name_consistency` |
|
||||||
| `check_normalized` | a file `normalize` would rewrite, and an index `update-index` would rebuild |
|
| a file `normalize` would rewrite, an index `update-index` would rebuild | per vendor | `check_normalized` |
|
||||||
| `check_filament_compatible_printers` | an instantiated non-library filament with no `compatible_printers` of its own |
|
| duplicate JSON keys in a file | every file read | the JSON loader |
|
||||||
| `check_conflict_keys` | `extruder_clearance_radius` alongside `extruder_clearance_max_radius` |
|
| an instantiated non-library filament with no non-empty `compatible_printers` of its own | per vendor | `check_filament_compatible_printers` |
|
||||||
| `check_vector_type_keys` | a vector option written as a scalar (`"filament_type": "PLA"`) |
|
| `extruder_clearance_radius` alongside `extruder_clearance_max_radius` | per vendor | `check_conflict_keys` |
|
||||||
| `check_filament_id_length` | a declared `filament_id` longer than 8 characters |
|
| a scalar `filament_type` (`"filament_type": "PLA"`); the five other filament vectors `normalize` arrayifies surface as `normalize would convert <field> to an array` | per vendor | `check_vector_type_keys`, `check_normalized` |
|
||||||
| `check_machine_default_materials` | every `default_materials` / `default_filament_profile` name resolves |
|
| a variant string the two enums cannot build (a dead variant, a legacy spelling included), a variant list naming one variant twice, an `extruder_type`, `nozzle_volume_type` or `default_nozzle_volume_type` that is not an enum name ([variant names](#variant-names)) | per vendor | `check_variant_names` |
|
||||||
| `check_obsolete_keys` | per-key warnings for ignored options; **filament files only** |
|
| a variant array not exactly `variant length × stride` wide in a selectable preset that writes it (with `--strict`, also in one it reaches); machine variant layout keys that disagree; a process id array that does not pair each variant ([variant arrays](#variant-arrays)) | per vendor | `check_variant_arrays` |
|
||||||
|
| a declared `filament_id` longer than 8 characters | per vendor | `check_filament_id_length` |
|
||||||
|
| a `default_materials` name, or a `default_filament_profile` name, matching no filament file ([below](#default-material-references)) | per vendor | `check_machine_default_materials` |
|
||||||
|
| per-key warnings for ignored options, **filament files only** | per vendor | `check_obsolete_keys` |
|
||||||
|
| `setting_id` uniqueness, every `filament_id` rule, and `machine_model` names duplicated across bundles | **tree-wide, ignoring `--vendor` entirely** | `check_setting_id_uniqueness`, `check_filament_ids`, `check_machine_model_name_uniqueness` |
|
||||||
|
|
||||||
Tree-wide, **ignoring `--vendor` entirely**: `check_setting_id_uniqueness` and `check_filament_ids`. So a
|
Because the id and model-name checks stay tree-wide, a vendor-scoped run can and does fail on another
|
||||||
vendor-scoped run can and does fail on another vendor's files — and it saves seconds, not minutes.
|
vendor's files, and it saves seconds, not minutes.
|
||||||
|
|
||||||
Unscoped, the per-vendor pass covers every bundle. The only exclusion is the stray `user/` directory
|
Unscoped, the per-vendor pass covers every bundle. The only exclusion is the stray `user/` directory
|
||||||
(see below); `OrcaFilamentLibrary` is held to the same rules as any vendor, its sole exemption being
|
(below); OrcaFilamentLibrary is held to the same rules as any vendor, its sole exemption being that a
|
||||||
that a library filament may leave `compatible_printers` empty — exactly what
|
library filament may leave `compatible_printers` empty. `check_normalized` covers every bundle with an
|
||||||
`check_filament_compatible_printers` allows. `check_normalized` covers every bundle with an index.
|
index.
|
||||||
|
|
||||||
Notes that matter:
|
Notes that matter:
|
||||||
|
|
||||||
- Exit codes: **0** clean, **1** errors found, **2** argparse misuse. Warnings never change the exit code.
|
- Exit codes: **0** clean, **1** errors found, **2** argparse misuse. Warnings never change the exit
|
||||||
- A nonexistent `--vendor` is a hard error — `[ERROR] unknown vendor "<V>" in <dir>`, exit 1.
|
code.
|
||||||
- `--vendor ""` means all vendors; `check_profile.sh` relies on that. `--vendor` is repeatable.
|
- A nonexistent `--vendor` is a hard error: `[ERROR] unknown vendor "<V>" in <dir>`, exit 1.
|
||||||
- A **stray directory** under `resources/profiles/` still gets counted as a vendor by the per-vendor pass
|
- `--vendor ""` means all vendors; `check_profile.sh` relies on that. `--vendor` is repeatable
|
||||||
and warned about (`No profiles found for vendor: <dir> at …/<dir>.json`, and the "Checked vendors" count
|
(`check --vendor A --vendor OrcaFilamentLibrary`); `check_profile.sh` takes one.
|
||||||
goes up by one). The one exception is `user/`, the validator's data dir, which an unscoped `check`
|
- A **stray directory** under `resources/profiles/` still gets counted as a vendor by the per-vendor
|
||||||
skips by name; `--vendor user` still checks and warns about it. Warnings never change the exit code.
|
pass and warned about (`No profiles found for vendor: <dir> at …/<dir>.json`, and the "Checked
|
||||||
`normalize`, `trim` and `update-index` ignore strays too — they define a bundle as *a directory with a
|
vendors" count goes up by one): usually an emptied folder, or a `user/` left by a direct validator
|
||||||
|
run. An unscoped `check` skips `user/` by name; `--vendor user` still checks and warns about it.
|
||||||
|
`normalize`, `trim` and `update-index` ignore strays too: they define a bundle as *a directory with a
|
||||||
matching index file*.
|
matching index file*.
|
||||||
- Each remedy is printed once for the whole run, not once per file, as a `[WARNING]` under the errors
|
- Each remedy is printed once for the whole run, not once per file, as a `[WARNING]` under the errors
|
||||||
("2 unreferenced file(s) above: delete them, or run … update-index"). Read those lines: they name the
|
(`2 unreferenced file(s) above: delete them, or run … update-index`). Read those lines: they name the
|
||||||
command that fixes the batch.
|
command that fixes the batch.
|
||||||
- The trailing summary always suggests `normalize`. That is right for the shape errors and misleading for
|
- When there are errors or warnings, the trailing summary suggests `normalize`. That is right for the
|
||||||
everything else — an id error needs `generate-id`, a dangling `default_materials` needs a human.
|
shape errors and misleading for everything else: an id error needs `generate-id`, a dangling
|
||||||
|
`default_materials` needs a human.
|
||||||
|
- Other options: `--dry-run` on every writing command, `--profiles DIR` to point any command at another
|
||||||
|
tree, `--profile-type` to narrow `normalize`, `trim` and `update-index` to one type
|
||||||
|
([ids.md](ids.md#the-tool)).
|
||||||
- `resources/profiles/check_unused_setting_id.py` is a legacy BBL-only diagnostic, not part of
|
- `resources/profiles/check_unused_setting_id.py` is a legacy BBL-only diagnostic, not part of
|
||||||
profile CI. Use `orca_profile_tool.py check` for current id validation.
|
profile CI. Use `orca_profile_tool.py check` for current id validation.
|
||||||
|
|
||||||
### Obsolete-key diagnostics
|
### Obsolete keys
|
||||||
|
|
||||||
`check` always reports per-key warnings for obsolete options in filament profiles.
|
`check` always reports per-key warnings for obsolete options in filament profiles. Its normalization
|
||||||
The normalization check also rejects obsolete keys across preset types; `normalize` removes them.
|
check also rejects obsolete keys across all preset types (`normalize would remove <key>`); `normalize`
|
||||||
|
removes them.
|
||||||
|
|
||||||
### Default-material references
|
### Default-material references
|
||||||
|
|
||||||
The materials check finds `default_materials` / `default_filament_profile` entries naming a preset
|
The materials check finds `default_materials` / `default_filament_profile` entries naming a preset that
|
||||||
that does not exist. The three authoring errors it surfaces are `,` instead of `;`, wrong case
|
does not exist. It reads each `machine/` file's own key (a model's `default_materials`, a variant's
|
||||||
(`@system`), and a whole `;`-joined string stuffed into one array element.
|
`default_filament_profile`; a file that writes both is checked on `default_materials` only) and accepts
|
||||||
|
any `name` found in the vendor's or OrcaFilamentLibrary's `filament/` files, bases and unindexed files
|
||||||
|
included. The three authoring errors it surfaces are `,` instead of `;`, wrong case (`@system`), and a
|
||||||
|
whole `;`-joined string stuffed into one array element. Only the validator (`validate_system`) requires
|
||||||
|
an instantiated system filament that is compatible with each variant.
|
||||||
|
|
||||||
|
### Variant arrays
|
||||||
|
|
||||||
|
`check_variant_arrays` composes every selectable preset the loader's way (the parent, then each
|
||||||
|
`include` in order, then the file's own keys; a filament's `inherits` may fall through to
|
||||||
|
OrcaFilamentLibrary) and holds each key of [the four variant sets](extruder-variants.md#the-four-key-sets)
|
||||||
|
that the preset writes itself to exactly its `variant length × stride`
|
||||||
|
([widths](extruder-variants.md#widths)). The variant length is the length of the composed preset's
|
||||||
|
own `*_extruder_variant` list; without one, a machine's is the number of variants its
|
||||||
|
`extruder_variant_list` offers, else its extruder count (the list's default is one
|
||||||
|
`Direct Drive Standard` per extruder), and a process's or filament's is 1. Any other width is an
|
||||||
|
error, one value included and even when every value is the same. A key the preset does not write
|
||||||
|
takes what reaches it, the default or an array it inherits or includes, which the loader resizes; it
|
||||||
|
is not checked. A base is not judged on its own: what it writes counts only where it reaches a preset
|
||||||
|
that does not override it.
|
||||||
|
|
||||||
|
`check --strict` also holds every selectable preset to its own width for each key that reaches it,
|
||||||
|
so a preset whose variants differ from those of the file its array comes from restates the array
|
||||||
|
(the error names that file). That is BBL's practice and the target for new printer-specific presets;
|
||||||
|
CI runs `check` without `--strict`.
|
||||||
|
|
||||||
|
An id array that reaches a selectable preset, `printer_extruder_id` or `print_extruder_id`, written or
|
||||||
|
inherited, must have one entry per entry of its variant list on any printer. This rule and the
|
||||||
|
machine layout rules below judge the composed preset without `--strict`, whichever file writes the
|
||||||
|
keys. Beside a written variant list this rule reports it instead of the width
|
||||||
|
rule, so it is reported once. A process that
|
||||||
|
lists variants without `print_extruder_id` gets a **warning** when a variant repeats (every entry then
|
||||||
|
reads as extruder 1, so the repeated variant is unreachable), nothing otherwise.
|
||||||
|
|
||||||
|
On a machine the layout keys are held to [Printer rules 1–4](extruder-variants.md#printer-machine),
|
||||||
|
every failure an error unless marked:
|
||||||
|
|
||||||
|
- With `extruder_variant_list` written: the list has one entry per extruder, as many as
|
||||||
|
`nozzle_diameter`; every variant starts with its extruder's `extruder_type`;
|
||||||
|
`default_nozzle_volume_type` names a nozzle volume type that extruder lists;
|
||||||
|
`printer_extruder_variant` is the list flattened extruder-major and `printer_extruder_id` gives each
|
||||||
|
entry its 1-based extruder (an id array left out reads as extruder 1 everywhere, which passes when
|
||||||
|
those are the flattening's ids). Without the pair, the list may offer one variant in total.
|
||||||
|
- With the pair written and no `extruder_variant_list`: one variant per extruder at most. A pair that
|
||||||
|
`single_extruder_multi_material` off would replace with the default at load is a **warning**; a pair
|
||||||
|
the rebuild would leave as it is passes.
|
||||||
|
|
||||||
|
It does **not** see a base's own resize: it composes at the width each file wrote, so an array wider
|
||||||
|
than a base's list, which the loader cuts before any child inherits it, passes even with `--strict`
|
||||||
|
([composition](extruder-variants.md#padding-truncation-and-composition)). Nor does it see per-extruder
|
||||||
|
vectors outside the sets (`extruder_offset`, `printer_extruder_options`, …), which no variant list
|
||||||
|
sizes. What a variant holds (a High Flow variant copied from Standard, a variant inserted at the wrong
|
||||||
|
index) is review work
|
||||||
|
([item 14](review-checklist.md#14-per-extruder-vectors-not-checked-and-variant-arrays-widths-and-layout-checked));
|
||||||
|
whether it is a name the engine can select at all is `check_variant_names`'.
|
||||||
|
|
||||||
|
`python3 scripts/orca_profile_tool.py fix-variant` resizes every array the width rule rejects in the
|
||||||
|
selectable preset that writes it, leaves bases alone and adds no key: extra values are dropped, missing ones
|
||||||
|
repeat the last value (the last normal/silent pair at stride 2; a lone value fills normal and silent
|
||||||
|
alike). It leaves the variant lists and id arrays to you, since they address the variants rather than
|
||||||
|
fill them. `fix-variant --strict` then also writes each key that reaches a selectable preset at
|
||||||
|
another width: into the most general file on the way down to the preset whose own width is the
|
||||||
|
preset's and whose selectable presets taking it all need that width, else into the preset itself.
|
||||||
|
Presets of every bundle count towards that agreement; `--vendor` limits the files written and
|
||||||
|
`--dry-run` previews. The loader pads with the first value where `fix-variant` repeats the last, so a
|
||||||
|
padded array need not load as before, and
|
||||||
|
trimming deletes values: when the extra values were meant as per-extruder or per-variant values,
|
||||||
|
declare the variant layout instead ([Printer rule 2](extruder-variants.md#printer-machine)) and keep
|
||||||
|
them. `fix-variant` moves no value, so a family whose presets all wrote one value repeats the widened
|
||||||
|
array in each; put it on the family's base afterwards ([shared bases](shared-bases.md)).
|
||||||
|
|
||||||
|
### Variant names
|
||||||
|
|
||||||
|
`check_variant_names` reads the four list keys plus `extruder_type`, `nozzle_volume_type` and
|
||||||
|
`default_nozzle_volume_type` of every preset the bundle's index references, bases included, and holds
|
||||||
|
each entry to the names the engine's two enum maps define (`s_keys_map_ExtruderType`,
|
||||||
|
`s_keys_map_NozzleVolumeType`, read from `PrintConfig.cpp` on every run). The bundle's own files are
|
||||||
|
judged, not the composed config: a bad string is the writing file's error, once. Every finding is an
|
||||||
|
error, and no bundle is exempt: BBL, whose bundle is imported from BambuStudio, is held to OrcaSlicer's
|
||||||
|
enums like any other.
|
||||||
|
|
||||||
|
- A variant string outside `<extruder type> <nozzle volume type>` is a **dead variant**: it still
|
||||||
|
counts toward the variant length the arrays are sized by, so the values written for it silently never
|
||||||
|
reach the G-code. `Hybrid` too, which is runtime-only, and an empty entry. A name BambuStudio's enum
|
||||||
|
has and OrcaSlicer's lacks is dead here as well, and passes with no tool change once the engine gains
|
||||||
|
that nozzle volume type.
|
||||||
|
- A legacy name the loader still rewrites in these keys (`Normal` → `Standard`, `Big Traffic` →
|
||||||
|
`High Flow`) is an error that names the enum name to write; the profile has to spell the enum
|
||||||
|
name. `DirectDrive` is only rewritten in `extruder_type`, so a variant string carrying it is dead.
|
||||||
|
- A variant list naming one variant twice is an error: the lookup returns the first equal string, so
|
||||||
|
the repeat is unreachable and its value sits at an index no extruder reads. A filament list takes
|
||||||
|
strings, `extruder_variant_list` takes them per extruder, and a process takes `(extruder id, variant)`
|
||||||
|
pairs — one string on two extruders is two pairs, not a repeat.
|
||||||
|
- An `extruder_type`, `nozzle_volume_type` or `default_nozzle_volume_type` value that is not an enum
|
||||||
|
name is an error: they are enum options, so an unknown value fails the validator's load of the
|
||||||
|
whole bundle, while the app silently loads the option's default instead. A legacy spelling
|
||||||
|
(`DirectDrive`, `Normal`, `Big Traffic`) and `Hybrid`, an enum value no profile writes, are errors
|
||||||
|
too.
|
||||||
|
|
||||||
|
The variant *order*, the choice of variants, and the values themselves are not checked.
|
||||||
|
|
||||||
### `normalize` and `update-index` are part of the check
|
### `normalize` and `update-index` are part of the check
|
||||||
|
|
||||||
`check` fails when either command would still change something, so they are not optional polish — the
|
`check` fails when either command would still change something, so they are not optional polish: the
|
||||||
file that gets reviewed has to be the file that ships. What `normalize` changes is narrow and fixed:
|
file that gets reviewed has to be the file that ships. What `normalize` changes is narrow and fixed:
|
||||||
adds a missing `type`, deletes a `version` or `is_custom_defined` key from a *preset* file, deletes six
|
|
||||||
print-speed keys from filament profiles (`initial_layer_print_speed`, `outer_wall_speed`,
|
- adds a missing `type`;
|
||||||
`inner_wall_speed`, `infill_speed`, `top_surface_speed`, `travel_speed`), deletes the
|
- deletes a `version` or `is_custom_defined` key from a *preset* file;
|
||||||
obsolete keys in `PrintConfigDef::handle_legacy`'s `ignore` set across preset types, resolves the
|
- deletes six print-speed keys from filament profiles (`initial_layer_print_speed`, `outer_wall_speed`,
|
||||||
`extruder_clearance_*` conflict pair by keeping the larger, arrayifies five filament options besides
|
`inner_wall_speed`, `infill_speed`, `top_surface_speed`, `travel_speed`);
|
||||||
`filament_type`, and hoists `type`, `name`, `renamed_from`, `inherits`, `from`, `setting_id`,
|
- deletes the obsolete keys the loader ignores (the `ignore` set in `PrintConfigDef::handle_legacy`),
|
||||||
`filament_id`, `instantiation` to the front. A file it changes is then rewritten whole — tab-indented,
|
across preset types;
|
||||||
LF, one trailing newline, keys reordered.
|
- resolves the `extruder_clearance_*` conflict pair by keeping the larger;
|
||||||
|
- arrayifies six filament options (`filament_type`, `filament_cost`, `filament_density`,
|
||||||
|
`temperature_vitrification`, `filament_max_volumetric_speed`, `filament_vendor`);
|
||||||
|
- hoists `type`, `name`, `renamed_from`, `inherits`, `from`, `setting_id`, `filament_id`,
|
||||||
|
`instantiation` to the front.
|
||||||
|
|
||||||
|
A file it changes is then rewritten whole: tab-indented, LF, one trailing newline, keys reordered. A
|
||||||
|
file committed with CRLF line endings therefore changes on every line; read the diff before committing
|
||||||
|
it.
|
||||||
|
|
||||||
**Set `type` explicitly when authoring.** For a file in `machine/` without it, normalization guesses
|
**Set `type` explicitly when authoring.** For a file in `machine/` without it, normalization guesses
|
||||||
`machine` only if its name contains `nozzle`, otherwise `machine_model`. That heuristic cannot
|
`machine` only if its name contains `nozzle` (case-insensitive), otherwise `machine_model`. That
|
||||||
reliably classify shared machine bases or unusually named variants.
|
heuristic cannot reliably classify shared machine bases or unusually named variants.
|
||||||
|
|
||||||
The Python obsolete-key set is checked against the C++ source by a unit test. Active options
|
The tool's obsolete-key set is checked against the loader's ignore list by a unit test. Active options
|
||||||
and legacy aliases that the loader migrates (such as `extruder_type` and
|
are preserved, including live keys whose *values* the loader rewrites (`extruder_type`: `DirectDrive` →
|
||||||
`extruder_clearance_max_radius`) are preserved.
|
`Direct Drive`; the variant-string keys: `Normal` / `Big Traffic` → `Standard` / `High Flow`), and so are
|
||||||
|
legacy key names the loader migrates (such as `extruder_clearance_max_radius`).
|
||||||
|
|
||||||
Two things it therefore does **not** enforce:
|
Two things it therefore does **not** enforce:
|
||||||
|
|
||||||
- **Formatting and key order on their own.** A file with none of those problems is skipped entirely, so
|
- **Formatting and key order on their own.** A file with none of those problems is skipped entirely, so
|
||||||
4-space indent, a missing trailing newline, and a file that leads with `compatible_printers` all pass
|
4-space indent, a missing trailing newline, and a file that leads with `compatible_printers` all pass
|
||||||
`check`. They stay latent until something else trips `normalize` and the whole file reformats inside an
|
`check`. They stay latent until something else trips `normalize` and the whole file reformats inside
|
||||||
unrelated diff. (`normalize --force` rewrites every file, which is not something to run on a shipped
|
an unrelated diff. (`normalize --force` rewrites every file; do not run it on a shipped bundle.)
|
||||||
bundle.)
|
|
||||||
- **A misspelled setting key.** `inital_layer_height` and `sparse_infill_densiti` pass `check` cleanly.
|
- **A misspelled setting key.** `inital_layer_height` and `sparse_infill_densiti` pass `check` cleanly.
|
||||||
Verify new keys against `PrintConfig.cpp` and `PrintConfigDef::handle_legacy`.
|
Verify new keys against `src/libslic3r/PrintConfig.cpp` and the loader's legacy handling
|
||||||
|
(`PrintConfigDef::handle_legacy`: renamed keys, rewritten values and ignored keys).
|
||||||
|
|
||||||
|
`check` does not catch a dangling `default_print_profile` either; check that name by hand.
|
||||||
|
|
||||||
## The validator binary
|
## The validator binary
|
||||||
|
|
||||||
Built from `src/dev-utils/OrcaSlicer_profile_validator.cpp` (`-DORCA_TOOLS=ON`).
|
Built from `src/dev-utils/OrcaSlicer_profile_validator.cpp` with `-DORCA_TOOLS=ON`. Both scripts use a
|
||||||
Both scripts find a local build under `build*/` — `check_profile.sh` tries Release, RelWithDebInfo, then
|
local build under `build*/` when one exists, else they download the nightly into the `validator`
|
||||||
Debug, and `check_profile.ps1` adds MinSizeRel — else they download the nightly into the
|
subdirectory of the cache dir. `check_profile.sh` searches Release, then RelWithDebInfo, then Debug
|
||||||
`validator` subdirectory of the per-user cache dir (see above). Pass `--download` / `-Download` to
|
(each under `build*/src/<config>` and `build*/*/src/<config>`), then single-config `build*/src`, and
|
||||||
match CI exactly, since a stale local build is used silently. Windows looks for
|
prefers a host-architecture build tree; `check_profile.ps1` tries Release, RelWithDebInfo, MinSizeRel,
|
||||||
|
then Debug. A stale local build is used silently; `--download` / `-Download` skips local builds and uses the
|
||||||
|
nightly. The download and the fixtures stay cached until `--refresh` / `-Refresh`, so
|
||||||
|
`--download --refresh` (`-Download -Refresh`) matches CI exactly. Windows looks for
|
||||||
`OrcaSlicer_profile_validator.exe`.
|
`OrcaSlicer_profile_validator.exe`.
|
||||||
|
|
||||||
If your build lives somewhere else entirely, point at it with `--validator` / `-Validator`, or set
|
If your build lives somewhere else, point at it with `--validator` / `-Validator`, or set
|
||||||
`ORCA_PROFILE_VALIDATOR` (`$env:ORCA_PROFILE_VALIDATOR` in PowerShell).
|
`ORCA_PROFILE_VALIDATOR` (`$env:ORCA_PROFILE_VALIDATOR` in PowerShell).
|
||||||
|
|
||||||
| Flag | Meaning |
|
| Flag | Meaning |
|
||||||
@@ -167,11 +330,12 @@ If your build lives somewhere else entirely, point at it with `--validator` / `-
|
|||||||
| `-l <n>` | log level; CI uses 2 |
|
| `-l <n>` | log level; CI uses 2 |
|
||||||
| `-v <Vendor>` | load only that vendor **plus** OrcaFilamentLibrary |
|
| `-v <Vendor>` | load only that vendor **plus** OrcaFilamentLibrary |
|
||||||
| `-s` | slice sweep |
|
| `-s` | slice sweep |
|
||||||
|
| `-o <dir>` | with `-s`, save each printer's G-code there |
|
||||||
| `-f` | no-op (see above) |
|
| `-f` | no-op (see above) |
|
||||||
| `-g 1` | regenerate user-preset fixtures; takes a value, and wipes the user preset dir first |
|
| `-g 1` | regenerate user-preset fixtures; takes a value, and wipes the user preset dir first |
|
||||||
|
|
||||||
On ARM64 Linux the nightly is x86-64 only — the script warns and downloads anyway, producing a binary
|
On ARM64 Linux the nightly is x86-64 only: the script warns and downloads anyway, producing a binary
|
||||||
that will not run. Build it locally instead.
|
that will not run. Build it locally with `-DORCA_TOOLS=ON` and pass `--validator`.
|
||||||
|
|
||||||
Running the validator directly uses the profile tree as its data directory and can create `user/`
|
Running the validator directly uses the profile tree as its data directory and can create `user/`
|
||||||
there. Prefer the wrappers, which stash existing user presets and restore them afterward. After a
|
there. Prefer the wrappers, which stash existing user presets and restore them afterward. After a
|
||||||
@@ -185,16 +349,27 @@ Use `--profiles DIR` on the Python tool and `-p DIR` on the validator. The wrapp
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/check_profile.sh --profiles "<tree>"
|
./scripts/check_profile.sh --profiles "<tree>"
|
||||||
|
# Windows: scripts\check_profile.bat -ProfilesDir "<tree>"
|
||||||
```
|
```
|
||||||
|
|
||||||
On Windows use `scripts\check_profile.bat -ProfilesDir "<tree>"`.
|
To slice a process or filament that is not a printer's default, or to read the G-code, work on a copy:
|
||||||
|
copy `resources/profiles` and `resources/info` into one scratch dir (the validator reads `info/` next to
|
||||||
|
the tree), point the printer's `default_print_profile` or first `default_filament_profile` at the preset
|
||||||
|
in the copy, and run the validator with `-o`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<validator> -p <scratch>/profiles -v "<Vendor>" -s -o <gcode dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each printer's G-code is saved as `<vendor name>__<printer>.gcode`; its embedded config
|
||||||
|
(`; print_settings_id = …`, `; filament_settings_id = …`) shows what was actually sliced.
|
||||||
|
|
||||||
## Testing in the app
|
## Testing in the app
|
||||||
|
|
||||||
Editing this checkout's `resources/profiles` does not update a separately installed application.
|
Editing this checkout's `resources/profiles` does not update a separately installed application. Test
|
||||||
Test with a build using the edited resources and a bumped bundle version; the updater installs newer
|
with a build using the edited resources and a bumped bundle version: the updater installs newer bundles
|
||||||
bundles under `<data_dir>/system/`, and the preset cache also depends on the bundle version.
|
under `<data_dir>/system/`, and the preset cache also depends on the bundle version. Use Help ▸ Show
|
||||||
Use Help ▸ Show Configuration Folder to locate the active data directory:
|
Configuration Folder to locate the active data directory:
|
||||||
|
|
||||||
| Platform | Default data directory |
|
| Platform | Default data directory |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -202,58 +377,71 @@ Use Help ▸ Show Configuration Folder to locate the active data directory:
|
|||||||
| Linux | `$XDG_CONFIG_HOME/OrcaSlicer`, or `~/.config/OrcaSlicer` when unset |
|
| Linux | `$XDG_CONFIG_HOME/OrcaSlicer`, or `~/.config/OrcaSlicer` when unset |
|
||||||
| Windows | `%APPDATA%\OrcaSlicer` |
|
| Windows | `%APPDATA%\OrcaSlicer` |
|
||||||
|
|
||||||
A portable `data_dir` next to the executable takes precedence. Use a separate test configuration
|
A portable `data_dir` next to the executable takes precedence. Use a separate test configuration for a
|
||||||
for a clean-install check; preserve the normal configuration and user presets.
|
clean-install check; preserve the normal configuration and user presets.
|
||||||
|
|
||||||
## Cross-platform paths
|
|
||||||
|
|
||||||
Match the exact case of each `sub_path` and asset filename; Linux filesystems commonly distinguish
|
|
||||||
case even when a macOS or Windows checkout does not. Preset-name references are case-sensitive
|
|
||||||
on every platform. Avoid Windows-invalid characters (`< > : " | ? *`), reserved device names
|
|
||||||
such as `CON` / `NUL` (including with extensions), and trailing spaces or dots in path components.
|
|
||||||
Keep stems tidy too, but a space immediately before `.json` is not a trailing path-component space.
|
|
||||||
|
|
||||||
## Error → remedy
|
## Error → remedy
|
||||||
|
|
||||||
| Message | Fix |
|
| Message | Fix |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `can not find inherits <parent> for <preset>` | parent missing, unregistered, or listed **after** the child |
|
| `can not find inherits <parent> for <preset>` | parent missing, unregistered, misspelled, or listed **after** the child |
|
||||||
| `can not find filament_id for <name>` | nothing in the chain declares one — run `generate-id` |
|
| `can not find include` | the template is misspelled, registered after the includer, or selectable |
|
||||||
| `can not find parent <name> for config <user preset>!` | a shipped name disappeared — add `renamed_from` |
|
| `can not find filament_id for <name>` | nothing in the chain declares one: run `generate-id` |
|
||||||
| `Missing instantiation attribute for <name>` | key absent **or** not the string `"true"`/`"false"` |
|
| `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 |
|
| `contains incorrect keys: <keys>, which were removed` | a key valid for a different preset type |
|
||||||
| `defines invalid printer variant "<v>"` | not in the model's `nozzle_diameter` list |
|
| `defines invalid printer variant "<v>"` | not a token of the model's `nozzle_diameter` list |
|
||||||
| `has printer_variant "<v>" that does not match its nozzle_diameter` | the set comparison in [machine-profiles.md](machine-profiles.md) |
|
| `has printer_variant "<v>" that does not match its nozzle_diameter` | [the set comparison](machine-profiles.md#printer_model-and-printer_variant) |
|
||||||
| `references unknown compatible_printers "<p>"` | the printer was renamed or deleted; fix the reference |
|
| `references unknown compatible_printers "<p>"` | the printer was renamed or deleted, or a `machine_model` name was used instead of a variant name: fix the reference. Under `--vendor`, usually another vendor's printer ([why](#the-five-checks)) |
|
||||||
| `references renamed compatible_printers "<old>" (now "<new>")` | in-tree references must name the current preset; `renamed_from` does not excuse them |
|
| `references renamed compatible_printers "<old>" (now "<new>")` | in-tree references must name the current preset; `renamed_from` does not excuse them |
|
||||||
| `Filament preset "<f>" is missing compatible_printers setting` | non-library filaments need a non-empty list in their **own** file — the flattened-vs-own-key trap is in [filament-profiles.md](filament-profiles.md#compatible_printers) |
|
| `Filament preset "<f>" is missing compatible_printers setting` | non-library filaments need a non-empty list in their **own** file; the resolved-vs-own-key trap is in [filament-profiles.md](filament-profiles.md#compatible_printers) |
|
||||||
| `Ambiguous AMS filament match: N presets share filament_id "X" … printer "Y"` | make the lists disjoint by [specificity](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product) — the specialized profile keeps the variant, the general ones drop it; prefer this over deleting a profile — or fix an `inherits` pointing at another material's `@base`. `orca_profile_tool.py check` does not catch this; only `validate_system` here does |
|
| `Ambiguous AMS filament match: N filament presets share filament_id "X" and are all compatible with printer "Y"` | make the lists disjoint by [specificity](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product): the specialized profile keeps the variant, the general ones drop it; prefer this over deleting a profile. Or fix an `inherits` pointing at another product's `@base`. `orca_profile_tool.py check` does not catch this; only `validate_system` does |
|
||||||
| `Layer height cannot exceed nozzle diameter.` / `Line width too small` | `Print::validate()` flow rules |
|
| `Layer height cannot exceed nozzle diameter.` / `Line width too small` / `Line width too large` | the [slicing limits](process-profiles.md#values-to-review-per-nozzle) |
|
||||||
| `[ERROR] … no <V>.json list references it, so it never loads` | `update-index`, or delete the file |
|
| `[ERROR] … no <V>.json list references it, so it never loads` | `update-index`, or delete the file |
|
||||||
| `[ERROR] … references it and it declares no profile type` | set the correct `type` explicitly, then `normalize` and `update-index` |
|
| `[ERROR] … no <V>.json list references it and it declares no profile type` | set the correct `type` explicitly, then `normalize` and `update-index` |
|
||||||
| `[ERROR] … normalize would <change>` / `<V>.json: update-index would rebuild <lists>` | run that command and commit the result |
|
| `[ERROR] … normalize would <change>` / `<V>.json: update-index would rebuild <lists>` | run that command and commit the result |
|
||||||
| `[ERROR] <V> has N <type> profiles named "<name>"` | identify the intended preset and remove or rename the duplicate; use `trim --dry-run` only for deliberate unindexed-file cleanup |
|
| `[ERROR] <V> has N <type> profiles named "<name>"` | identify the intended preset and remove or rename the duplicate; use `trim --dry-run` only for deliberate unindexed-file cleanup |
|
||||||
| `[ERROR] … must not have a setting_id` / `is missing a setting_id` | `generate-id --setting-id` |
|
| `Duplicate key error in <file>: Duplicate key detected: <key>` | a key written twice in one file; keep the intended one |
|
||||||
| `inherits filament_id "X" but its own triple … mints "Y"` | `generate-id` will **not** fix this — see [ids.md](ids.md) |
|
| `… must not have a setting_id` / `… is missing a setting_id` / `setting_id "X" in <file> does not match the expected "Y" …` | `generate-id --setting-id` (by hand in `BBL/`, see [ids.md](ids.md#bbls-exception-precisely)) |
|
||||||
| `vendor <V>'s config version: <s> invalid` | the `version` string is not Semver-parseable |
|
| `filament_id "X" declared by … does not match the mint of its triple …` | `generate-id --filament-id`; if the id should not be declared here at all, remove it so the preset inherits its root's |
|
||||||
| `[json.exception.type_error.302] type must be string` | locate the non-string value in the index or model; see [failure scopes](vendor-bundle.md#failure-modes-ranked-by-blast-radius) |
|
| `inherits filament_id "X" but its own triple "V/T/N" mints "Y"` | `generate-id` will **not** fix this; see [ids.md](ids.md#what-generate-id-does-and-does-not-fix) |
|
||||||
| `Printer "<p>" fell back to a default preset` | final process or filament selection is a generic Default preset; check named defaults, visibility and available compatible presets. An incompatible default may instead be replaced without this error |
|
| `"<key>" has N values for variant length S at stride k, which takes M` (with ` (no <list key>, so …)` after `S`, naming where `S` came from, when the preset writes no list, and ` (it comes from <file>)` at the end under `--strict` for an array the preset does not write) | exactly `S × k` values in variant order, or leave the key out ([widths](extruder-variants.md#widths)); on a list-less multi-extruder printer whose extruders differ, declare the layout; `fix-variant` cuts or pads to that width ([variant arrays](#variant-arrays)); an `S` you did not expect means the variant list did not resolve through `include` or `inherits` |
|
||||||
| `Printer "<p>" sliced but the filament change never fired` | `change_filament_gcode` never expanded |
|
| `printer_extruder_variant […] is not extruder_variant_list flattened extruder-major […]` / `printer_extruder_id […] does not give each entry of printer_extruder_variant its 1-based extruder […]` / `printer_extruder_id has N entries for the M entries of printer_extruder_variant` | write the pair as the flattening of the list, ids in step ([Printer rule 2](extruder-variants.md#printer-machine)) |
|
||||||
|
| `extruder_variant_list has N entries for M extruder(s)` / `extruder_variant_list entry i "…" holds a variant that does not start with extruder i's extruder_type` / `default_nozzle_volume_type "…" is not a nozzle volume type extruder i lists` | [Printer rules 1, 3 and 4](extruder-variants.md#printer-machine) |
|
||||||
|
| `extruder_variant_list offers N variants but the preset writes no printer_extruder_variant/printer_extruder_id` / `printer_extruder_variant lists several variants for extruder N but the preset writes no extruder_variant_list` / `[WARNING] … with single_extruder_multi_material off the loader replaces printer_extruder_variant …` | write all three layout keys ([Printer rule 2](extruder-variants.md#printer-machine)) |
|
||||||
|
| `print_extruder_id has N entries for the M entries of print_extruder_variant` / `[WARNING] … print_extruder_variant repeats a variant but print_extruder_id is absent` | one id per variant entry, mirroring the printer's pairs ([Process rule 1](extruder-variants.md#process)) |
|
||||||
|
| `<key> <where> holds "…", which no extruder can select: "…" is not a nozzle volume type the enum has (…)` / `… it does not start with an extruder type the enum has (…)` / `… is empty` / `… Hybrid names the sub-nozzles of one hybrid extruder at runtime` | write a legal variant string, `<extruder type> <nozzle volume type>` from the two enums ([variant names](#variant-names), [variant strings](extruder-variants.md#variant-strings)); in every bundle, BBL included |
|
||||||
|
| `… holds the legacy variant "…"` / `extruder_type spells the legacy name "…"` / `nozzle_volume_type spells the legacy name "…"` / `default_nozzle_volume_type spells the legacy name "…"` | write the enum name the message gives: the loader still rewrites the legacy one, but `check` rejects it |
|
||||||
|
| `<key> lists "…" N times` / `print_extruder_variant lists the pair (extruder N, "…") N times` | drop the repeat and its value from every variant array: the lookup returns the first equal string |
|
||||||
|
| `extruder_type "…" is not one of (…)` / `nozzle_volume_type "…" is not one of (…)` / `default_nozzle_volume_type "…" is not one of (…)` / `… names "Hybrid", which the engine computes for a hybrid extruder at runtime` | they are enum options, so an unknown value fails the validator's load of the whole bundle and silently becomes the default in the app; use `s_keys_map_ExtruderType` / `s_keys_map_NozzleVolumeType`, `Hybrid` excepted ([variant strings](extruder-variants.md#variant-strings)) |
|
||||||
|
| `[WARNING] No profiles found for vendor: <dir>` | a directory with no matching index (an emptied folder, or a `user/` left by a direct validator run); remove it |
|
||||||
|
| `… has no compatible system filament in its model's "default_materials"` | add a system filament preset compatible with that variant to the model's `default_materials` |
|
||||||
|
| `… names the unknown system filament "<n>" in its "default_materials"` / `… "default_filament_profile"` | name an existing system (not user, not base) filament exactly; `;` separators, exact case |
|
||||||
|
| `Missing filament profile: '<n>' referenced in <file>` | the same, caught by `check`; usual causes are `,` instead of `;`, wrong case (`@system`), or a `;`-joined list packed into one array element |
|
||||||
|
| `machine_model name "<n>" is declared by N bundles` | model names are unique across the tree; rename the new model |
|
||||||
|
| `vendor <V>'s config version: <s> invalid` | the `version` string does not parse; write `MM.mm.pp.bb` |
|
||||||
|
| `[json.exception.type_error.302] type must be string` | locate the non-string value in the index or a model; see [failure scopes](vendor-bundle.md#failure-scopes) |
|
||||||
|
| `Printer "<p>" fell back to a default preset` | the final process or filament selection is a generic Default preset: check the named defaults, their visibility and that compatible presets exist. An incompatible default may instead be replaced without this error |
|
||||||
|
| `Printer "<p>" sliced but the filament change never fired (no CP TOOLCHANGE START)` | `change_filament_gcode` never expanded |
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.github/workflows/check_profiles.yml`, job **"Check profiles"**, on `pull_request` into `main` or
|
`.github/workflows/check_profiles.yml`, job **"Check profiles"**, runs on `pull_request` into `main` or
|
||||||
`release/*`, paths `resources/profiles/**`, `resources/printers/**`, `scripts/**` and the workflow itself.
|
`release/*` touching `resources/profiles/**`, `resources/printers/**`, `scripts/**`,
|
||||||
There is no push trigger — a direct push to main runs no profile validation.
|
`src/libslic3r/PrintConfig.cpp` (where the tool reads the variant key sets) or the workflow itself.
|
||||||
|
There is no push trigger: a direct push to main runs no profile validation.
|
||||||
|
|
||||||
The job opens with `python3 -m unittest discover -s scripts/tests -t scripts`, the tool's own unit
|
The job opens with `python3 -m unittest discover -s scripts/tests -t scripts`, the tool's own unit tests
|
||||||
tests. That step is deliberately **not** `continue-on-error`: a broken tool makes everything it then says
|
(run them locally after changing `scripts/`, with `py -3` on Windows, and keep `-t scripts` or the
|
||||||
about the profiles worthless. Every check after it is `continue-on-error` with a final gate, so one run
|
imports fail). That step is deliberately **not** `continue-on-error`: a broken tool makes everything it
|
||||||
reports all five results. On failure a second workflow posts or replaces a single PR comment marked
|
then says about the profiles worthless. Every check after it is `continue-on-error` with a final gate,
|
||||||
`<!-- profile-validation-comment -->`, with each failing log truncated to 30 KB; it deletes the comment
|
so one run reports all five results. On failure a second workflow posts or replaces a single PR comment
|
||||||
once the run is green.
|
marked `<!-- profile-validation-comment -->`, holding the start of each failing log (30 KB per check,
|
||||||
|
12 KB per `validate_custom` fixture; reproduce locally for the full list); it deletes the comment once
|
||||||
|
the run is green.
|
||||||
|
|
||||||
The job name is also the required check for the delegated-merge bot, which lets a vendor maintainer
|
The job name is also the required check for the delegated-merge bot, which lets a vendor maintainer
|
||||||
self-merge a `resources/profiles/<Their vendor>/` PR with no human review — so whatever CI does not check
|
self-merge a PR limited to their own `resources/profiles/<Vendor>/` folder with no human review, so
|
||||||
is what ships unreviewed. Its denied patterns refuse `^scripts/` and any `.py`, so a PR that touches the
|
whatever CI does not check is what ships unreviewed. Its denied patterns refuse `^scripts/` and any
|
||||||
tooling always needs a maintainer.
|
`.py`, so a PR that touches the tooling always needs a maintainer.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The vendor bundle and the loader
|
# Vendor bundles
|
||||||
|
|
||||||
A bundle is `resources/profiles/<Vendor>.json` (the index) plus `resources/profiles/<Vendor>/`.
|
A bundle is `resources/profiles/<Vendor>.json` (the index) plus `resources/profiles/<Vendor>/`. The
|
||||||
The **vendor id is the filename stem**, not the `name` inside — several differ (`BBL.json` is named
|
**vendor id is the filename stem**, not the `name` inside; the two may differ (`BBL.json` is named
|
||||||
"Bambulab"). Asset paths and the `setting_id` formula use the id; the `validate_custom` fixture prefix
|
"Bambulab"). Asset paths and the `setting_id` formula use the id; the `validate_custom` fixture prefix
|
||||||
uses the `name`.
|
uses the `name`.
|
||||||
|
|
||||||
@@ -13,16 +13,16 @@ uses the `name`.
|
|||||||
"version": "02.04.00.03",
|
"version": "02.04.00.03",
|
||||||
"force_update": "0",
|
"force_update": "0",
|
||||||
"description": "Phrozen configurations",
|
"description": "Phrozen configurations",
|
||||||
"machine_model_list": [ { "name": "...", "sub_path": "machine/....json" } ],
|
"machine_model_list": [ { "name": "Phrozen Arco", "sub_path": "machine/Phrozen Arco.json" } ],
|
||||||
"machine_list": [ ... ],
|
"machine_list": [ … ],
|
||||||
"process_list": [ ... ],
|
"process_list": [ … ],
|
||||||
"filament_list": [ ... ]
|
"filament_list": [ … ]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The loader reads `name`, `version`, `url` and the four `*_list` arrays.
|
The loader reads `name`, `version`, `url` and the four `*_list` arrays. `description` is only logged;
|
||||||
`description` is only logged. `force_update` is read by `PresetUpdater`, never by the loader.
|
`force_update` is read by the profile updater, never by the loader. `sub_path` is relative to the
|
||||||
`sub_path` is relative to the **vendor folder**.
|
**vendor folder**.
|
||||||
|
|
||||||
| List | Holds |
|
| List | Holds |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -35,141 +35,180 @@ The loader reads `name`, `version`, `url` and the four `*_list` arrays.
|
|||||||
|
|
||||||
1. **Everything is registered, bases included.** Every preset file on disk has exactly one entry in the
|
1. **Everything is registered, bases included.** Every preset file on disk has exactly one entry in the
|
||||||
matching list, and no unindexed preset file is left in the tree.
|
matching list, and no unindexed preset file is left in the tree.
|
||||||
2. **Parents before children.** `inherits` resolves against a per-kind map filled as the list is walked
|
2. **Parents before children, includes before includers.** The lists load processes first, then
|
||||||
(`configs.clear()` then process, filaments, printers). A parent listed after its child produces
|
filaments, then printers, each in index order, and `inherits` and `include` resolve only against
|
||||||
`can not find inherits <parent> for <child>` and the bundle is discarded.
|
presets of that type already loaded from it. A parent
|
||||||
3. **The index entry's `name` must equal the `name` inside the sub_path file.** `check_name_consistency`
|
listed after its child produces `can not find inherits <parent> for <child>` and the bundle is
|
||||||
walks the index looking for the files; `check_index_coverage` walks the files looking for them in the
|
discarded; an include listed after its includer is `can not find include`, a counted error that
|
||||||
index. The `renamed_from` escape hatch `check_name_consistency`'s docstring promises is commented out.
|
leaves the includer without those keys.
|
||||||
|
3. **The index entry's `name` equals the `name` inside the `sub_path` file.** `renamed_from` does not
|
||||||
|
excuse a mismatch.
|
||||||
|
|
||||||
All three are `check` errors now, and `update-index` writes an index that satisfies all three from the
|
All three are `check` errors, and `update-index` writes an index that satisfies all three from the
|
||||||
files on disk — including the parents-first ordering, by topological sort. Hand-editing the index is
|
files on disk, including the dependency ordering (parents and templates before the presets that use
|
||||||
|
them, then, in name order, entries that neither depend on nor are depended on by another entry of their own
|
||||||
|
list, and any entry on a dependency cycle). Hand-editing the index is
|
||||||
fine for a one-line addition, but the committed result must equal what `update-index` writes, because
|
fine for a one-line addition, but the committed result must equal what `update-index` writes, because
|
||||||
`check` compares them.
|
`check` compares them.
|
||||||
|
|
||||||
The loader itself reports none of this: an unregistered file, or an entry with a typo'd key
|
The loader itself reports none of this: an unregistered file, or an entry with a misspelled key
|
||||||
(`"subpath"`), is silently dropped. (A typo'd `sub_path` is a `check` error naming the entry.)
|
(`"subpath"`), is silently dropped. (A misspelled `sub_path` value is a `check` error naming the entry.)
|
||||||
|
|
||||||
`BBL/cli_config.json` and `BBL/filament/filaments_color_codes.json` are auxiliary data loaded by path,
|
`BBL/cli_config.json` and, in `BBL/filament/`, `filaments_color_codes.json`, `filament_id_map.json`,
|
||||||
not presets. The tool's `NON_PROFILE_FILES` excludes these basenames from preset maintenance.
|
`filament_name_map.json` and `support_recommended_params.json` are auxiliary data files read by path,
|
||||||
|
not presets. The last three carry a `type` key and look like presets; the tool excludes all five from
|
||||||
|
preset maintenance.
|
||||||
|
|
||||||
## `version`
|
## `version`
|
||||||
|
|
||||||
Parsed by a four-component Semver where the 4th is folded in as `patch = patch*100 + value`. Write it
|
Four components, `MM.mm.pp.bb`, compared as a version number in which the fourth is folded into the
|
||||||
zero-padded, `MM.mm.pp.bb`; a couple of bundles drop a component or the padding, but do not imitate them.
|
third (`patch × 100 + build`). Write all four components, zero-padded.
|
||||||
|
|
||||||
- **Bump the version for every bundle the PR touches.** `PresetUpdater` installs bundled resources
|
- **Bump the version for every bundle the change touches.** The app installs bundled profiles only
|
||||||
only when their version is newer than the installed version; the `.opc` preset cache is also
|
when their version is newer than the installed one, and the `.opc` preset cache is also keyed on
|
||||||
versioned. Nothing in profile CI checks the bump.
|
the version. Nothing in profile CI checks the bump.
|
||||||
- **Keep the last component ≤ 99.** `02.04.00.100` and `02.04.01.00` both parse to `2.4.100`. A bundle
|
- **Keep the last component ≤ 99.** `02.04.00.100` and `02.04.01.00` both read as
|
||||||
that reaches `.99` carries into the third component (`02.03.02.99` → `02.03.03.00`).
|
`2.4.100`. A bundle that
|
||||||
- An **absent** version is worse than a stale one: the validator still passes, but `Semver::valid()`
|
reaches `.99` carries into the third component (`02.03.02.99` → `02.03.03.00`).
|
||||||
excludes `0.0.0`, so the vendor is dropped from the configuration wizard entirely and the preset cache
|
- An **absent** version is worse than a stale one: it reads as `0.0.0`, which is not a valid version.
|
||||||
is disabled for it. An *unparseable* version is not silent — it throws and discards the whole bundle
|
`check` and the validator still pass, but the vendor is dropped from the setup wizard entirely and gets no preset cache. Confirm the key exists. An
|
||||||
(see the failure table below).
|
*unparseable* version is not silent: it discards the whole bundle (`vendor <V>'s config version: <s>
|
||||||
|
invalid`).
|
||||||
|
|
||||||
## Common preset keys
|
## Common preset keys
|
||||||
|
|
||||||
| Key | Value |
|
| Key | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `type` | `machine_model` / `machine` / `process` / `filament` |
|
| `type` | `machine_model`, `machine`, `process` or `filament` |
|
||||||
| `name` | the preset name; the filename is *not* authoritative |
|
| `name` | the preset name, the identity every reference uses; the filename is *not* authoritative |
|
||||||
| `inherits` | the parent's exact `name` — no path, no `.json` |
|
| `inherits` | the parent's exact `name`: no path, no `.json` |
|
||||||
|
| `include` | a template's exact `name`, or an array of them, layered under this preset's own keys ([below](#inherits-and-include)) |
|
||||||
| `instantiation` | the **string** `"true"` (selectable) or `"false"` (base) |
|
| `instantiation` | the **string** `"true"` (selectable) or `"false"` (base) |
|
||||||
| `from` | `"system"` by convention; the vendor loader never reads it |
|
| `from` | `"system"` for shipped presets |
|
||||||
| `setting_id` | required on instantiated presets, forbidden on bases — generated |
|
| `setting_id` | generated; required on instantiated presets, forbidden on bases |
|
||||||
| `renamed_from` | `;`-separated list of old names this preset supersedes |
|
| `renamed_from` | `;`-separated old names this preset supersedes ([below](#renamed_from)) |
|
||||||
|
|
||||||
These are config-preset keys; `machine_model` records have their own
|
These are config-preset keys; `machine_model` records have their own
|
||||||
[schema](machine-profiles.md#a-machine_model-is-not-a-config-preset). Keep `from` as `"system"`
|
[key set](machine-profiles.md#machine_model-a-record-not-a-config-preset). Keep `from` as `"system"`:
|
||||||
for shipped presets. The vendor loader ignores it, but the CLI config-file loader accepts only
|
the bundle loader ignores it, but loading the file as a CLI config accepts only `system`, `user` or
|
||||||
`system`, `user` or `User` and handles their inheritance differently.
|
`User` and handles their inheritance differently.
|
||||||
|
|
||||||
`instantiation` is the one metadata key that is hard-gated: a missing key or any value other than the
|
`instantiation` is the one metadata key the validator gates: a missing key or any value other than the
|
||||||
strings `"true"`/`"false"` is an error (`Missing instantiation attribute for <name>`). A JSON boolean
|
strings `"true"` / `"false"` is a counted error (`Missing instantiation attribute for <name>`) that fails
|
||||||
`true` fails harder — it throws inside `load_from_json` and takes the **whole vendor bundle** down.
|
the validator, though the preset still loads and is treated as selectable. A file with no
|
||||||
|
`instantiation` whose name contains `gcode`, or that has no `name`, silently becomes an include-only
|
||||||
|
template. A JSON boolean `true` fails harder: it takes the **whole vendor bundle** down.
|
||||||
|
|
||||||
### `inherits`
|
## `inherits` and `include`
|
||||||
|
|
||||||
Resolution is an exact-name lookup **within the same bundle**, plus one exception: filaments may inherit
|
`inherits` resolves by exact name **within the same bundle**, plus one exception: filaments may inherit
|
||||||
from `OrcaFilamentLibrary`, which is loaded first and becomes the base bundle. Vendor-to-vendor
|
from OrcaFilamentLibrary, which is loaded first. Vendor-to-vendor inheritance always fails, and an
|
||||||
inheritance always fails. You can inherit from an instantiated preset as well as from a base; it is
|
unresolved `inherits` discards the bundle. You can inherit from an instantiated preset as well as from a
|
||||||
common.
|
base.
|
||||||
|
|
||||||
### `renamed_from`
|
`"include": ["<name>", …]` (or one bare name) pulls in `instantiation: "false"` presets of the same type
|
||||||
|
from the same bundle (never the library), registered before the includer. It shares a block of keys
|
||||||
|
between presets that do not share a parent: a variant layout, a G-code template. Only `"false"` presets
|
||||||
|
can be included, so a name that resolves to nothing (misspelled, registered after the includer, or a
|
||||||
|
selectable preset) is a counted error (`can not find include`) and the preset loads without it.
|
||||||
|
|
||||||
|
**How a preset's config is composed:** start from the parent's stored config (a root starts from the
|
||||||
|
built-in defaults), apply each preset named in `include` in the order listed, then the preset's own
|
||||||
|
keys. Later layers win, so precedence is own keys > later includes > earlier includes > the `inherits`
|
||||||
|
chain. Only then is every variant key of the composed config resized to its variant length
|
||||||
|
([widths](extruder-variants.md#widths)), and keys of another preset type removed. The two routes hand
|
||||||
|
down different widths:
|
||||||
|
|
||||||
|
- **`inherits` hands down the resized config.** A base is stored *after* its own resize, at the length
|
||||||
|
of its own `*_extruder_variant` (one variant for a base of any type that writes none, whatever its
|
||||||
|
extruder count). A child therefore inherits the base's arrays at the base's width: an array wider than
|
||||||
|
that is cut to its first values before any child sees it, and a child that adds variants gets those
|
||||||
|
first values padded. So widen an array only on a preset whose own variant list already has the
|
||||||
|
entries; a wide array on a narrow base is silently lost at load, and `check`, which composes at the
|
||||||
|
width each file wrote and judges selectable presets only, misses that cut.
|
||||||
|
- **`include` hands down the template's diff, at its pre-resize width.** An included preset contributes
|
||||||
|
every key where its own composed config (its parent, its own includes and its own keys) differs from
|
||||||
|
the built-in defaults, taken *before* its resize. So keys the template inherits are passed on too,
|
||||||
|
arrays it writes arrive at the width its file wrote, and a key it sets to the built-in default value
|
||||||
|
is not passed on at all, so it cannot override what the includer inherited. Resizing happens on the
|
||||||
|
includer, not on the template.
|
||||||
|
|
||||||
|
## `renamed_from`
|
||||||
|
|
||||||
One JSON string, `;`-separated for several old names.
|
One JSON string, `;`-separated for several old names.
|
||||||
|
|
||||||
- Write `"A;B"`, never `"A ; B"` — an unquoted item keeps its trailing space and can never match.
|
- Write `"A;B"`, never `"A ; B"`: a space after a `;` is skipped, but a space before it stays part of
|
||||||
|
the name (`"A "`), which can never match.
|
||||||
- When `renamed_from` is **absent** and the name contains `@`, the loader auto-adds the `@`-removed form
|
- When `renamed_from` is **absent** and the name contains `@`, the loader auto-adds the `@`-removed form
|
||||||
(`X @Y` → `X Y`) as a rename alias. Declaring an explicit `renamed_from` **suppresses** that, so a
|
(`X @Y` → `X Y`) as a rename alias. Declaring an explicit `renamed_from` **suppresses** that, so a
|
||||||
preset that needs both the `@`-removed form and a real old name must list both. No shipped profile
|
preset that needs both the `@`-removed form and a real old name must list both; a preset that gains
|
||||||
currently does, which means any preset that gained a `renamed_from` quietly lost its `X Y` alias.
|
a `renamed_from` without it quietly loses its `X Y` alias.
|
||||||
- It rescues names stored **outside** the tree: user presets and 3MF projects. It does **not** rescue
|
- It rescues names stored **outside** the tree: user presets and 3MF projects. It does **not** rescue
|
||||||
in-tree `inherits` (exact lookup), it does **not** satisfy `check_name_consistency`, the validator
|
in-tree `inherits` (exact lookup), it does **not** satisfy the index-name rule, the validator reports
|
||||||
reports an in-tree reference that only resolves through it (`references renamed compatible_printers
|
an in-tree reference that only resolves through it (`references renamed compatible_printers "OLD"
|
||||||
"OLD" (now "NEW")`), and `machine_model` records never read it at all.
|
(now "NEW")`), and `machine_model` records never read it at all.
|
||||||
- Only one preset may claim a given old name — two that do is a counted error
|
- Only one preset may claim a given old name; two that do is a counted error
|
||||||
(`… was marked as renamed from "Y" … as well`). But the redirect is **inert while a live preset still
|
(`… was marked as renamed from "Y" … as well`). But the redirect is **inert while a live preset still
|
||||||
carries that name**, and nothing checks *that*; Z-Bolt ships a folder of such dead entries.
|
carries that name**, and nothing checks *that*, so a neighbour's `renamed_from` is no model.
|
||||||
|
|
||||||
## Failure modes, ranked by blast radius
|
## Failure scopes
|
||||||
|
|
||||||
| Scope | Cause |
|
| Scope | Cause |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **All vendors, zero system profiles** | a non-string `version`, `name` or `url` at the top level of a vendor index (`"version": 2`), or non-string `nozzle_diameter` on a model — `nlohmann::type_error` escapes the per-vendor `std::runtime_error` catch |
|
| **Every vendor except OrcaFilamentLibrary, and all user presets** | a non-string where the index or a `machine_model` expects a string (`"version": 2` at the top level of an index, a numeric `name` or `url`, a non-string `nozzle_diameter` or other model key): `[json.exception.type_error.302] type must be string`, and the validator reports `Validation failed` |
|
||||||
| **The whole vendor bundle** | unparseable `version`; index JSON parse error; a `sub_path` file missing or unparseable; unresolvable `inherits`; duplicate preset name within the vendor; empty/unknown `printer_model` or `printer_variant`; a filament resolving no `filament_id` |
|
| **The whole vendor bundle** | index JSON parse error; unparseable `version`; a listed file missing or unparseable; a value its option cannot take, such as `nil` in a non-nullable key (`Failed loading configuration file`); unresolved `inherits`; two selectable presets with one name; empty or unknown `printer_model` / `printer_variant`; a filament resolving no `filament_id`; a JSON boolean `instantiation` |
|
||||||
| **One preset** | `instantiation` missing or a wrong string; keys belonging to another preset type (`contains incorrect keys: …, which were removed`); a non-string inside a `*_list` entry (`invalid value type for <key>`) |
|
| **A counted error; the preset still loads** | `instantiation` missing or not `"true"` / `"false"`; keys belonging to another preset type (`contains incorrect keys: …, which were removed`); a non-string inside a `*_list` entry (`invalid value type for <key>`); an `include` naming nothing usable (`can not find include`, loads without it) |
|
||||||
| **Logged, not counted** | a raw JSON number in a preset — `invalid json type for <key>`, the value is dropped and the exit code stays 0 |
|
| **The rest of the file, logged only** | an array with a non-string element (`[0.4]`, `invalid json array`): that key and every key after it in the file are dropped, and no error is counted |
|
||||||
| **Nothing reported by the loader** | unregistered file; misspelled setting key; missing bed/hotend asset. Only the first of those is a `check` error; the other two reach users |
|
| **One value, logged only** | a raw JSON number in a preset (`invalid json type for <key>`): the value is dropped and the exit code stays 0 |
|
||||||
|
| **Nothing reported by the loader** | unregistered file; two bases with one name, or a base and a selectable preset with one name (the first in the index wins); misspelled setting key; missing bed, hotend or cover asset. `check` catches the first two; the others reach users |
|
||||||
|
|
||||||
Deleting a file the index still lists surfaces as a *parse error* on line 1, not "file not found" — the
|
Deleting a file the index still lists surfaces as a *parse error* on line 1 (`unexpected end of input`), not "file
|
||||||
loader `ifstream`s the missing path and nlohmann reports `unexpected end of input`.
|
not found".
|
||||||
|
|
||||||
Preset names are a **single global namespace across every vendor**: a duplicate within one vendor is a
|
Selectable preset names are a **single namespace across every vendor**: a duplicate within one vendor
|
||||||
hard bundle failure, a duplicate across vendors is reported as `Found duplicated preset: <name> in
|
is a hard bundle failure, and a duplicate across vendors is reported as `Found duplicated preset: <name>
|
||||||
vendor: <vendor>` and still counts as an error. `check_preset_name_uniqueness` catches the within-bundle
|
in vendor: <vendor>` and still counts as an error. `check` catches the within-bundle case earlier and
|
||||||
case earlier and more precisely — including an *unindexed* twin, which is one `sub_path` edit away from
|
more precisely, bases included, and including an *unindexed* twin, which is one `sub_path` edit away
|
||||||
silently becoming the parent every child resolves to (`std::map::emplace` keeps the first insertion, so
|
from silently becoming the parent every child resolves to (the first registered preset of a name wins,
|
||||||
index order decides). Base names, by contrast, repeat across bundles by design: `fdm_process_common`
|
so index order decides). Base names, by contrast, repeat across bundles by design:
|
||||||
exists in nearly all of them.
|
every bundle may have its own `fdm_process_common` ([uniqueness](naming.md#uniqueness)).
|
||||||
|
|
||||||
## Starting a whole new vendor bundle
|
## Starting a whole new vendor bundle
|
||||||
|
|
||||||
Nothing generates one; copy the smallest bundle that resembles the hardware. **`Voxelab` or `M3D`** are
|
Nothing generates one; copy the smallest bundle that resembles the hardware. **`Voxelab`** is the
|
||||||
the minimal shape — a shared machine base, the model, one variant, a shared process base, two
|
minimal shape: a shared machine base, the model, one variant, a shared process base, two processes, and
|
||||||
processes, and an empty `filament_list` that takes the library generics. Do *not* start from `Phrozen`:
|
an empty `filament_list`, so the printer takes the library generics. Do *not* start from a bundle that
|
||||||
it carries local `fdm_filament_*` copies that have drifted from the library, and a filament preset that
|
carries local `fdm_filament_*` copies, which drift from the library, or filament presets that restate
|
||||||
restates most of its parent — the style this skill advises against.
|
most of their parent, the style this skill advises against.
|
||||||
|
|
||||||
Write the machine files **last**, so you only visit them once:
|
Write the machine files **last**, so you only visit them once:
|
||||||
|
|
||||||
1. **Choose the names first** — model, variant(s), process(es). Everything else references them.
|
1. **Choose the names first**: model, variant(s), process(es). Everything else references them
|
||||||
|
([naming.md](naming.md)).
|
||||||
2. `resources/profiles/<Vendor>.json`: `name`, `version` (`01.00.00.00`), `force_update: "0"`,
|
2. `resources/profiles/<Vendor>.json`: `name`, `version` (`01.00.00.00`), `force_update: "0"`,
|
||||||
`description`, and all four `*_list` arrays (an empty `filament_list` is fine).
|
`description`, and all four `*_list` arrays (empty is fine: `update-index` fills them once the files
|
||||||
3. The shared bases — `<Vendor>/machine/fdm_machine_common.json` and
|
exist, so this step only needs the bundle metadata to be right).
|
||||||
`<Vendor>/process/fdm_process_common.json`, both `"instantiation": "false"` with no `setting_id`.
|
3. The shared bases: `<Vendor>/machine/fdm_machine_common.json` and
|
||||||
For a Klipper printer add your own `<Vendor>/machine/fdm_klipper_common.json` inheriting the machine
|
`<Vendor>/process/fdm_process_common.json`, both `"instantiation": "false"` with no `setting_id`. For
|
||||||
|
a Klipper printer add your own `<Vendor>/machine/fdm_klipper_common.json` inheriting the machine
|
||||||
base; there is no shared one, because a `machine` preset can only inherit inside its own bundle.
|
base; there is no shared one, because a `machine` preset can only inherit inside its own bundle.
|
||||||
4. One selectable process per variant, each naming its variant in `compatible_printers`.
|
4. One selectable process per variant, each naming its variant in `compatible_printers`.
|
||||||
5. Bed assets and `<Model>_cover.png`, all directly in `<Vendor>/`. None of them is needed for the
|
5. Bed assets and `<Model>_cover.png`, all directly in `<Vendor>/`. None of them is needed for the
|
||||||
bundle to load, and nothing in CI checks them — but the bed files are inert unless the `machine_model`
|
bundle to load, and nothing in CI checks them; but the bed files are inert unless the
|
||||||
names them in `bed_model` / `bed_texture`, and the cover is found by convention as
|
`machine_model` names them in `bed_model` / `bed_texture`, and the cover is found by convention as
|
||||||
`<the name you gave the model in machine_model_list>_cover.png`.
|
`<the name you gave the model in machine_model_list>_cover.png`.
|
||||||
6. The `machine_model` record and the `machine` variants, now that every value they reference exists —
|
6. The `machine_model` record and the `machine` variants, now that every value they reference exists;
|
||||||
the minimum key sets and the `default_*` shapes are in
|
the minimum key sets and the `default_*` shapes are in
|
||||||
[machine-profiles.md](machine-profiles.md#the-machine-variant).
|
[machine-profiles.md](machine-profiles.md#machine-the-variant).
|
||||||
7. Run the tool and validate — follow
|
7. Run the tool and validate: follow
|
||||||
[Creating or modifying a profile](../SKILL.md#creating-or-modifying-a-profile). `generate-id` is not
|
[Creating or modifying a profile](../SKILL.md#creating-or-modifying-a-profile). `generate-id` is not
|
||||||
optional for a new bundle: the validator loads presets that have no `setting_id`, but `check` fails
|
optional for a new bundle: the validator loads presets that have no `setting_id`, but `check` fails
|
||||||
every one of them. `update-index` will fill the four `*_list` arrays for you once the files exist, so
|
every one of them.
|
||||||
step 2 only needs the bundle metadata to be right.
|
|
||||||
|
|
||||||
## `resources/profiles_template/`
|
## `resources/profiles_template/`
|
||||||
|
|
||||||
A separate tree (`Template.json` + `Template/`) holding filament and process templates. It is **not** a
|
A separate tree (`Template.json` + `Template/`) holding filament and process templates. It is **not** a
|
||||||
scaffold for shipped profiles — `CreatePresetsDialog.cpp` reads it for the in-app "create a custom
|
scaffold for shipped profiles: the app's "create a custom printer / filament" dialog reads it, so
|
||||||
printer/filament" wizard, so editing it changes what users get when they create a custom preset.
|
editing it changes what users get when they create a custom preset. `check_profile.sh`'s validator
|
||||||
`check_profile.sh`'s validator checks default to `resources/profiles` (redirectable with `-p`), and so
|
checks default to `resources/profiles` (redirectable with `-p`), and so does `orca_profile_tool.py`
|
||||||
does `orca_profile_tool.py` (redirectable with `--profiles`);
|
(redirectable with `--profiles`); neither covers this tree.
|
||||||
neither covers this tree.
|
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find settings to move onto shared bases, and prove a move changed nothing.
|
||||||
|
|
||||||
|
snapshot OUT.json write every selectable preset's config as the loader stores it
|
||||||
|
compare BEFORE.json report every value that differs from the snapshot; exit 1 if any
|
||||||
|
candidates --vendor V restated values; per base, the settings its presets all share and the
|
||||||
|
defaults with exceptions that would pay
|
||||||
|
|
||||||
|
Every subcommand takes --profiles DIR (default resources/profiles).
|
||||||
|
|
||||||
|
Configs are composed the loader's way: the parent's stored config, then each include at
|
||||||
|
the width its file wrote, then the preset's own keys, and every variant key resized to the
|
||||||
|
preset's own variant list (one variant without one), padded with its first value or cut.
|
||||||
|
A base is stored after that resize, so a variant array wider than a base's list reaches its
|
||||||
|
children cut. Not modelled: the built-in defaults. A key no file in a preset's chain writes
|
||||||
|
loads its default, so compare reports a key written on one side only separately: it is no
|
||||||
|
change when the written value is the option's default in PrintConfig.cpp. Nor is it modelled
|
||||||
|
that an include template does not pass on a key equal to the default. Reads
|
||||||
|
scripts/orca_profile_tool.py.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
# The profile tool lives in <repo>/scripts; this file in <repo>/.claude/skills/orca-profiles/scripts.
|
||||||
|
sys.path[:0] = [os.path.join(os.getcwd(), "scripts"),
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), *[os.pardir] * 4, "scripts")]
|
||||||
|
import orca_profile_tool as tool # noqa: E402
|
||||||
|
|
||||||
|
TYPES = ("machine", "process", "filament")
|
||||||
|
# Keys the loader reads from each file as metadata; neither inherits nor include passes them on.
|
||||||
|
PER_FILE = {"type", "name", "from", "instantiation", "setting_id", "renamed_from", "description",
|
||||||
|
"inherits", "include", "version", "url", "is_custom_defined"}
|
||||||
|
# Keys the app replaces with the selected presets' names before slicing: a file's value never counts.
|
||||||
|
REPLACED = {"print_settings_id", "printer_settings_id", "filament_settings_id"}
|
||||||
|
# What a restructure changes by design, or what never reaches a slice.
|
||||||
|
MOVED_BY_DESIGN = {"inherits", "include"} | REPLACED
|
||||||
|
# Keys that stay in their own file: metadata, identity, and each preset's compatibility.
|
||||||
|
NEVER_SHARED = PER_FILE | REPLACED | {"filament_id", "compatible_printers", "compatible_prints",
|
||||||
|
"printer_variant", "printer_model"}
|
||||||
|
|
||||||
|
|
||||||
|
class Tree:
|
||||||
|
def __init__(self, profiles_dir):
|
||||||
|
self.dir = profiles_dir
|
||||||
|
self.scheme = tool._variant_scheme()
|
||||||
|
self.vendors = tool.list_vendor_names(profiles_dir)
|
||||||
|
self.bundles = {v: tool.load_vendor_configs(profiles_dir, v) for v in self.vendors}
|
||||||
|
self.cache = {}
|
||||||
|
|
||||||
|
def lookup(self, vendor, ptype, name, in_ofl=False):
|
||||||
|
"""(vendor the name resolves in, (rel, data)); filaments fall back to the library."""
|
||||||
|
if not in_ofl and name in self.bundles[vendor][ptype]:
|
||||||
|
return vendor, self.bundles[vendor][ptype][name]
|
||||||
|
if ptype == "filament" and tool.OFL in self.bundles and name in self.bundles[tool.OFL][ptype]:
|
||||||
|
return tool.OFL, self.bundles[tool.OFL][ptype][name]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def composed(self, vendor, ptype, name, drop=None, seen=frozenset()):
|
||||||
|
"""Config before the preset's own resize: parent stored, includes, own keys."""
|
||||||
|
owner, found = self.lookup(vendor, ptype, name, vendor == tool.OFL)
|
||||||
|
if found is None or (owner, name) in seen:
|
||||||
|
return {}
|
||||||
|
seen = seen | {(owner, name)}
|
||||||
|
data = found[1]
|
||||||
|
config = {}
|
||||||
|
if data.get("inherits"):
|
||||||
|
config.update(self.stored(owner, ptype, data["inherits"], seen))
|
||||||
|
include = data.get("include") or []
|
||||||
|
for included in [include] if isinstance(include, str) else include:
|
||||||
|
if included in self.bundles[owner][ptype]:
|
||||||
|
config.update(self.composed(owner, ptype, included, seen=seen))
|
||||||
|
config = {k: v for k, v in config.items() if k not in PER_FILE}
|
||||||
|
config.update((k, v) for k, v in data.items() if k != drop)
|
||||||
|
return config
|
||||||
|
|
||||||
|
def stored(self, vendor, ptype, name, seen=frozenset()):
|
||||||
|
key = (vendor, ptype, name)
|
||||||
|
if key not in self.cache:
|
||||||
|
self.cache[key] = self.resize(ptype, self.composed(vendor, ptype, name, seen=seen))
|
||||||
|
return self.cache[key]
|
||||||
|
|
||||||
|
def resize(self, ptype, config):
|
||||||
|
list_key, strides = self.scheme[ptype]
|
||||||
|
length = len(tool._as_list(config[list_key])) if list_key in config else 1
|
||||||
|
out = dict(config)
|
||||||
|
for key, stride in strides.items():
|
||||||
|
if key in out:
|
||||||
|
values = tool._as_list(out[key])
|
||||||
|
need = length * stride
|
||||||
|
out[key] = values[:need] + values[:1] * (need - len(values))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def presets(self, vendors=None, ptypes=TYPES):
|
||||||
|
for vendor in vendors or self.vendors:
|
||||||
|
for ptype in ptypes:
|
||||||
|
for name, (rel, data) in sorted(self.bundles[vendor][ptype].items()):
|
||||||
|
yield vendor, ptype, name, rel, data
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(tree):
|
||||||
|
return {f"{vendor}/{ptype}/{name}": {k: v for k, v in tree.stored(vendor, ptype, name).items()
|
||||||
|
if k not in MOVED_BY_DESIGN}
|
||||||
|
for vendor, ptype, name, _rel, data in tree.presets()
|
||||||
|
if data.get("instantiation") == "true"}
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before, after):
|
||||||
|
changed, one_sided = 0, []
|
||||||
|
for preset in sorted(before.keys() | after.keys()):
|
||||||
|
old, new = before.get(preset), after.get(preset)
|
||||||
|
if old is None or new is None:
|
||||||
|
print(f"{preset}: {'added' if old is None else 'removed'}")
|
||||||
|
changed += 1
|
||||||
|
continue
|
||||||
|
for key in sorted(old.keys() | new.keys()):
|
||||||
|
if key not in old or key not in new:
|
||||||
|
one_sided.append(f"{preset}: {key} "
|
||||||
|
f"{json.dumps(old[key]) if key in old else '(built-in default)'} -> "
|
||||||
|
f"{json.dumps(new[key]) if key in new else '(built-in default)'}")
|
||||||
|
elif old[key] != new[key]:
|
||||||
|
print(f"{preset}: {key} {json.dumps(old[key])} -> {json.dumps(new[key])}")
|
||||||
|
changed += 1
|
||||||
|
for line in one_sided:
|
||||||
|
print(line)
|
||||||
|
print(f"{changed} difference(s)")
|
||||||
|
if one_sided:
|
||||||
|
print(f"{len(one_sided)} key(s) written on one side only: each is a difference unless the "
|
||||||
|
f"written value is the option's default in src/libslic3r/PrintConfig.cpp")
|
||||||
|
return changed + len(one_sided)
|
||||||
|
|
||||||
|
|
||||||
|
def candidates(tree, vendor, ptypes, group_by):
|
||||||
|
for ptype in ptypes:
|
||||||
|
entries = {name: data for _v, _t, name, _rel, data in tree.presets([vendor], (ptype,))}
|
||||||
|
selectable = [n for n, d in entries.items() if d.get("instantiation") == "true"]
|
||||||
|
stored = {n: tree.stored(vendor, ptype, n) for n in entries}
|
||||||
|
list_key = tree.scheme[ptype][0]
|
||||||
|
|
||||||
|
# Restated: a key a file writes that it would inherit unchanged without writing it.
|
||||||
|
restated = {}
|
||||||
|
for name, data in entries.items():
|
||||||
|
restated[name] = sorted(
|
||||||
|
k for k in data if k not in NEVER_SHARED and k != list_key
|
||||||
|
and (data.get("inherits") or data.get("include"))
|
||||||
|
and tree.resize(ptype, tree.composed(vendor, ptype, name, drop=k)).get(k) == stored[name][k])
|
||||||
|
if restated[name]:
|
||||||
|
print(f"{vendor}/{ptype} {name}: restates what it inherits: {', '.join(restated[name])}")
|
||||||
|
|
||||||
|
# Groups: every preset with selectable presets below it, or the --group-by values.
|
||||||
|
chain = {n: [] for n in selectable}
|
||||||
|
for name in selectable:
|
||||||
|
node = entries[name].get("inherits")
|
||||||
|
while node in entries and node not in chain[name]:
|
||||||
|
chain[name].append(node)
|
||||||
|
node = entries[node].get("inherits")
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for name in selectable:
|
||||||
|
if group_by:
|
||||||
|
groups[json.dumps(stored[name].get(group_by))].append(name)
|
||||||
|
else:
|
||||||
|
for base in chain[name]:
|
||||||
|
groups[base].append(name)
|
||||||
|
printed = {}
|
||||||
|
for label, members in sorted(groups.items(), key=lambda g: -len(g[1])):
|
||||||
|
if len(members) < 2 or label == "null":
|
||||||
|
continue
|
||||||
|
if frozenset(members) in printed:
|
||||||
|
print(f"\n{vendor}/{ptype} {label}: the same presets as {printed[frozenset(members)]}")
|
||||||
|
continue
|
||||||
|
common = [b for b in chain[members[0]] if all(b in chain[m] for m in members[1:])]
|
||||||
|
home = common[0] if group_by and common else None if group_by else label
|
||||||
|
|
||||||
|
def below(m):
|
||||||
|
"""m and the files between it and the group's home."""
|
||||||
|
return [m] + chain[m][:chain[m].index(home)] if home in chain[m] else [m]
|
||||||
|
shared, defaults = [], []
|
||||||
|
for key in sorted(set().union(*(entries[m].keys() for m in members)) - NEVER_SHARED):
|
||||||
|
loaded = [json.dumps(stored[m].get(key)) for m in members]
|
||||||
|
counts = Counter(loaded).most_common(2)
|
||||||
|
if len(counts) == 1:
|
||||||
|
writers = sum(key in entries[m] and key not in restated[m] for m in members)
|
||||||
|
if writers >= 2:
|
||||||
|
shared.append(f"{key} ({writers} write it)")
|
||||||
|
continue
|
||||||
|
(top, held), (_, runner_up) = counts
|
||||||
|
if held == runner_up or top == "null" or home is None:
|
||||||
|
continue
|
||||||
|
# Balance 5: w presets drop their copy; a presets that take the key from the home
|
||||||
|
# (no file on their way to it writes it) and load another value must write theirs.
|
||||||
|
w = sum(key in entries[m] and v == top for m, v in zip(members, loaded))
|
||||||
|
a = sum(v != top and not any(key in entries[f] for f in below(m))
|
||||||
|
for m, v in zip(members, loaded))
|
||||||
|
if w - a > 1:
|
||||||
|
shown = top if len(top) <= 40 else top[:37] + "..."
|
||||||
|
defaults.append(f"{key} = {shown}: {held} load it, {w} write it, "
|
||||||
|
f"{a} would have to write their own")
|
||||||
|
if shared or defaults:
|
||||||
|
where = f"; nearest common base {common[0]}" if group_by and common else ""
|
||||||
|
title = f"{group_by} = {label}" if group_by else label
|
||||||
|
print(f"\n{vendor}/{ptype} {title}: {len(members)} presets{where}")
|
||||||
|
printed[frozenset(members)] = title
|
||||||
|
for line in shared:
|
||||||
|
print(f" {line}")
|
||||||
|
if defaults:
|
||||||
|
print(" default with exceptions:")
|
||||||
|
for line in defaults:
|
||||||
|
print(f" {line}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
profiles = argparse.ArgumentParser(add_help=False)
|
||||||
|
profiles.add_argument("--profiles", default=os.path.join("resources", "profiles"),
|
||||||
|
help="profiles directory (default: resources/profiles)")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
sub.add_parser("snapshot", parents=[profiles]).add_argument("out")
|
||||||
|
sub.add_parser("compare", parents=[profiles]).add_argument("before")
|
||||||
|
cand = sub.add_parser("candidates", parents=[profiles])
|
||||||
|
cand.add_argument("--vendor", required=True)
|
||||||
|
cand.add_argument("--type", choices=TYPES, action="append")
|
||||||
|
cand.add_argument("--group-by", help="group selectable presets by this key's value instead of by "
|
||||||
|
"base: printer_model, gcode_flavor, extruder_type, filament_id, layer_height, ...")
|
||||||
|
args = parser.parse_args()
|
||||||
|
tree = Tree(args.profiles)
|
||||||
|
if args.command == "snapshot":
|
||||||
|
presets = snapshot(tree)
|
||||||
|
with open(args.out, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(presets, f, sort_keys=True)
|
||||||
|
print(f"{len(presets)} selectable presets written to {args.out}")
|
||||||
|
elif args.command == "compare":
|
||||||
|
with open(args.before, encoding="utf-8") as f:
|
||||||
|
sys.exit(1 if compare(json.load(f), snapshot(tree)) else 0)
|
||||||
|
else:
|
||||||
|
candidates(tree, args.vendor, args.type or TYPES, args.group_by)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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, ©)); 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 = ¶ms`, 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.
|
||||||
@@ -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.
|
||||||
@@ -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 `&`/`<` 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.
|
||||||
@@ -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).
|
||||||
@@ -45,7 +45,7 @@ on:
|
|||||||
|
|
||||||
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 17 * * *' # run once a day at 1 AM Singapore time (UTC+8)
|
- cron: '15 2 * * *' # 10:15 AM Singapore time (UTC+8), when macOS runners are least busy
|
||||||
|
|
||||||
workflow_dispatch: # allows for manual dispatch
|
workflow_dispatch: # allows for manual dispatch
|
||||||
inputs:
|
inputs:
|
||||||
@@ -56,7 +56,9 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
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:
|
jobs:
|
||||||
@@ -183,9 +185,11 @@ jobs:
|
|||||||
os: ${{ vars.SELF_HOSTED && 'orca-macos-arm64' || 'macos-14' }}
|
os: ${{ vars.SELF_HOSTED && 'orca-macos-arm64' || 'macos-14' }}
|
||||||
artifact: ${{ github.sha }}-tests-macos-arm64
|
artifact: ${{ github.sha }}-tests-macos-arm64
|
||||||
test-dir: build/arm64/tests
|
test-dir: build/arm64/tests
|
||||||
# Slice a two-colour cube through every shipped printer so all custom g-code
|
# Slice a two-colour cube through every shipped printer, and through every
|
||||||
# (change_filament_gcode, machine start/end, etc.) is expanded - catches
|
# system process/filament whose templates no printer's own slice reaches, so
|
||||||
# slicing regressions the static profile checks and unit tests can't see.
|
# every custom g-code and filename_format shipped is expanded (names in {if}
|
||||||
|
# branches not taken included) - catches slicing regressions the static
|
||||||
|
# profile checks and unit tests can't see.
|
||||||
# Profile-only PRs are covered by check_profiles.yml's nightly binary; this
|
# Profile-only PRs are covered by check_profiles.yml's nightly binary; this
|
||||||
# covers src/engine PRs with the PR-built binary.
|
# covers src/engine PRs with the PR-built binary.
|
||||||
slice_check_linux:
|
slice_check_linux:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ on:
|
|||||||
# this workflow.
|
# this workflow.
|
||||||
- 'resources/printers/**'
|
- 'resources/printers/**'
|
||||||
- 'scripts/**'
|
- 'scripts/**'
|
||||||
|
# orca_profile_tool.py reads the variant key sets from PrintConfig.cpp, and its
|
||||||
|
# tests the obsolete keys, so a PR changing either must be checked against the profiles.
|
||||||
|
- 'src/libslic3r/PrintConfig.cpp'
|
||||||
- ".github/workflows/check_profiles.yml"
|
- ".github/workflows/check_profiles.yml"
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -71,8 +74,10 @@ jobs:
|
|||||||
set +e
|
set +e
|
||||||
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log
|
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log
|
||||||
exit ${PIPESTATUS[0]}
|
exit ${PIPESTATUS[0]}
|
||||||
# Slice a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode)
|
# Slice a two-colour cube through every printer, and through every system process/filament whose
|
||||||
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks above cannot see.
|
# templates no printer's own slice reaches, so every custom g-code and filename_format shipped is
|
||||||
|
# expanded (names in {if} branches not taken included) - catches undefined-placeholder /
|
||||||
|
# invalid-flow bugs the static checks above cannot see.
|
||||||
- name: validate slice (expand custom g-code)
|
- name: validate slice (expand custom g-code)
|
||||||
id: validate_slice
|
id: validate_slice
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Reports the newest push build of main that was not cancelled, for the README badge.
|
||||||
|
name: Main build status
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build all"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
status:
|
||||||
|
if: github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
conclusion=$(gh api "repos/${{ github.repository }}/actions/workflows/build_all.yml/runs?branch=main&event=push&status=completed&per_page=100" \
|
||||||
|
--jq '[.workflow_runs[] | select(.conclusion != "cancelled")][0].conclusion') && break
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "Latest finished push build of main: $conclusion"
|
||||||
|
[ "$conclusion" = success ]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
name: Daily OFL OTA Update
|
name: Daily OFL OTA Update
|
||||||
|
|
||||||
|
run-name: Daily OFL OTA Update [OFL barrier]
|
||||||
|
|
||||||
# This workflow is intended for creating and publishing the OrcaFilamentLibrary (OFL) OPC package to
|
# This workflow is intended for creating and publishing the OrcaFilamentLibrary (OFL) OPC package to
|
||||||
# https://github.com/OrcaSlicer/orcaslicer-profiles, which generates an OTA update.
|
# https://github.com/OrcaSlicer/orcaslicer-profiles, which generates an OTA update.
|
||||||
# This cronjob runs daily at 00:00 UTC every day and scans main plus every release/vX.Y.Z branch for
|
# This cronjob runs daily at 00:00 UTC every day and scans main plus every release/vX.Y.Z branch for
|
||||||
@@ -12,9 +14,9 @@ name: Daily OFL OTA Update
|
|||||||
# vendor-dispatch path is also what makes post_merge_profiles.yml call the OTA auto-publish API after
|
# vendor-dispatch path is also what makes post_merge_profiles.yml call the OTA auto-publish API after
|
||||||
# uploading - see post_merge_profiles.yml for both sides of that contract.
|
# uploading - see post_merge_profiles.yml for both sides of that contract.
|
||||||
#
|
#
|
||||||
# At the start of each run, the pending-publish table is cleared up to a captured
|
# Each run captures a timestamp, dispatches the needed OFL publishers, waits for
|
||||||
# timestamp (POST /api/v1/ota/ofl/pending/clear?timestamp=...). Changes merged after
|
# all of them to finish, then clears the pending-publish table once. Changes merged
|
||||||
# that timestamp remain pending for the next run.
|
# after that timestamp remain pending for the next run.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -34,32 +36,14 @@ jobs:
|
|||||||
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }}
|
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Capture start timestamp and clear OFL pending queue
|
- name: Capture start timestamp
|
||||||
id: start
|
id: start
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
|
|
||||||
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
|
|
||||||
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
|
|
||||||
|
|
||||||
timestamp="$(date -u +%s)"
|
timestamp="$(date -u +%s)"
|
||||||
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
|
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
resp_file="$RUNNER_TEMP/ota-pending-clear-response.json"
|
|
||||||
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
|
|
||||||
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear?timestamp=$timestamp" \
|
|
||||||
-H "Authorization: Bearer $OTA_API_KEY")"
|
|
||||||
body="$(cat "$resp_file")"
|
|
||||||
echo "$body"
|
|
||||||
|
|
||||||
if [ "$status" != "200" ]; then
|
|
||||||
echo "::error::OTA pending-clear call failed with HTTP $status"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
@@ -84,27 +68,21 @@ jobs:
|
|||||||
| grep -E '^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$' | sort -u
|
| grep -E '^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$' | sort -u
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The cron run is the checkpoint: a successful run means every
|
||||||
|
# dispatched branch publisher completed and the pending queue was
|
||||||
|
# cleared. Manual or push-triggered post_merge_profiles runs are not
|
||||||
|
# checkpoints for this scan.
|
||||||
|
successful_cron_runs="$(gh api --method GET \
|
||||||
|
"repos/${{ github.repository }}/actions/workflows/ofl-ota-cronjob.yml/runs" \
|
||||||
|
-f status=success -f branch=main -f per_page=100 --paginate \
|
||||||
|
--jq '.workflow_runs[] | select((.display_title // "") | contains("[OFL barrier]"))')"
|
||||||
|
since="$(jq -rs 'sort_by(.run_started_at) | last.run_started_at // empty' <<< "$successful_cron_runs")"
|
||||||
|
|
||||||
for branch in "${branches[@]}"; do
|
for branch in "${branches[@]}"; do
|
||||||
echo "::group::$branch"
|
echo "::group::$branch"
|
||||||
|
|
||||||
# post_merge_profiles.yml's own run history, not this workflow's: this
|
|
||||||
# workflow only ever runs against main (schedule, or workflow_dispatch
|
|
||||||
# --ref main), so its head branch never varies - filtering ITS history
|
|
||||||
# by $branch would never match anything except main. post_merge_profiles.yml
|
|
||||||
# genuinely runs per-branch (this dispatch below sets --ref "$branch"),
|
|
||||||
# so its history is the real per-branch checkpoint. It also means a
|
|
||||||
# failed publish naturally gets retried tomorrow: the checkpoint only
|
|
||||||
# advances on a run that actually succeeded.
|
|
||||||
# --method GET is required, not cosmetic: gh api defaults to POST
|
|
||||||
# whenever -f fields are present unless a method is given
|
|
||||||
# explicitly, and POST on this list-runs endpoint 404s - confirmed
|
|
||||||
# on real Actions infrastructure, not just reasoned about.
|
|
||||||
since="$(gh api --method GET "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \
|
|
||||||
-f status=success -f branch="$branch" -f per_page=1 \
|
|
||||||
--jq '.workflow_runs[0].run_started_at // empty')"
|
|
||||||
|
|
||||||
if [ -z "$since" ]; then
|
if [ -z "$since" ]; then
|
||||||
echo "No prior successful run for $branch; checking OFL changes up to $SCAN_UNTIL."
|
echo "No prior successful OFL cron run; checking $branch through $SCAN_UNTIL."
|
||||||
changed_files="$(git log --until="$SCAN_UNTIL" --name-only --pretty=format: "origin/$branch" -- \
|
changed_files="$(git log --until="$SCAN_UNTIL" --name-only --pretty=format: "origin/$branch" -- \
|
||||||
resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \
|
resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \
|
||||||
| sed '/^$/d')"
|
| sed '/^$/d')"
|
||||||
@@ -124,16 +102,98 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$changed" = true ]; then
|
if [ "$changed" = true ]; then
|
||||||
# Tolerate a per-branch failure (e.g. a pre-existing release branch
|
dispatch_id="${GITHUB_RUN_ID}-${branch//\//-}"
|
||||||
# whose post_merge_profiles.yml predates the vendor/auto_publish
|
# Record successful dispatches for the barrier step below.
|
||||||
# inputs) rather than aborting the whole scan under set -e.
|
# Branches whose workflow predates workflow_dispatch are skipped
|
||||||
if ! gh workflow run post_merge_profiles.yml \
|
# with a warning, as they were before the barrier was added.
|
||||||
|
if gh workflow run post_merge_profiles.yml \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--ref "$branch" \
|
--ref "$branch" \
|
||||||
-f vendor="$VENDOR" -f auto_publish=true; then
|
-f vendor="$VENDOR" -f auto_publish=true \
|
||||||
echo "::warning::failed to dispatch post_merge_profiles.yml for $branch - its post_merge_profiles.yml at this ref may predate the vendor/auto_publish inputs"
|
-f ofl_cron_dispatch_id="$dispatch_id"; then
|
||||||
|
printf '%s\t%s\n' "$branch" "$dispatch_id" >> "$RUNNER_TEMP/ofl-dispatches.tsv"
|
||||||
|
else
|
||||||
|
echo "::warning::skipping $branch because post_merge_profiles.yml could not be dispatched at that ref"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Wait for OFL publishers
|
||||||
|
id: wait
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DISPATCHES_FILE: ${{ runner.temp }}/ofl-dispatches.tsv
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ ! -s "$DISPATCHES_FILE" ]; then
|
||||||
|
echo "No OFL publisher workflows were dispatched; pending queue will not be cleared."
|
||||||
|
echo "publishers_dispatched=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$RUNNER_TEMP/ofl-run-ids.tsv"
|
||||||
|
while IFS=$'\t' read -r branch dispatch_id; do
|
||||||
|
[ -n "$branch" ] || continue
|
||||||
|
echo "Waiting for OFL publisher on $branch ($dispatch_id)"
|
||||||
|
|
||||||
|
run_id=""
|
||||||
|
for _ in {1..120}; do
|
||||||
|
runs_json="$(gh api --method GET \
|
||||||
|
"repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \
|
||||||
|
-f branch="$branch" -f event=workflow_dispatch -f per_page=100)"
|
||||||
|
run_id="$(jq -r --arg marker "[OFL cron $dispatch_id]" \
|
||||||
|
'[.workflow_runs[] | select((.display_title // "") | contains($marker))]
|
||||||
|
| sort_by(.created_at) | last | .id // empty' <<< "$runs_json")"
|
||||||
|
[ -n "$run_id" ] && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$run_id" ]; then
|
||||||
|
echo "::error::could not find dispatched post_merge_profiles run for $branch ($dispatch_id)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\t%s\n' "$branch" "$run_id" >> "$RUNNER_TEMP/ofl-run-ids.tsv"
|
||||||
|
done < "$DISPATCHES_FILE"
|
||||||
|
|
||||||
|
all_success=true
|
||||||
|
while IFS=$'\t' read -r branch run_id; do
|
||||||
|
[ -n "$run_id" ] || continue
|
||||||
|
echo "Watching OFL publisher run $run_id for $branch"
|
||||||
|
if ! gh run watch "$run_id" --repo "${{ github.repository }}" --exit-status; then
|
||||||
|
all_success=false
|
||||||
|
fi
|
||||||
|
done < "$RUNNER_TEMP/ofl-run-ids.tsv"
|
||||||
|
|
||||||
|
if [ "$all_success" != true ]; then
|
||||||
|
echo "::error::one or more OFL publisher workflows failed; pending queue will not be cleared"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "publishers_dispatched=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Clear OFL pending queue
|
||||||
|
if: steps.wait.outputs.publishers_dispatched == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
|
||||||
|
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
|
||||||
|
TIMESTAMP: ${{ steps.start.outputs.timestamp }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
|
||||||
|
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
|
||||||
|
|
||||||
|
resp_file="$RUNNER_TEMP/ota-pending-clear-response.json"
|
||||||
|
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
|
||||||
|
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear?timestamp=$TIMESTAMP" \
|
||||||
|
-H "Authorization: Bearer $OTA_API_KEY")"
|
||||||
|
body="$(cat "$resp_file")"
|
||||||
|
echo "$body"
|
||||||
|
|
||||||
|
if [ "$status" != "200" ]; then
|
||||||
|
echo "::error::OTA pending-clear call failed with HTTP $status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
name: Post-merge profiles
|
name: Post-merge profiles
|
||||||
|
|
||||||
|
run-name: >-
|
||||||
|
Post-merge profiles${{
|
||||||
|
inputs.ofl_cron_dispatch_id != '' &&
|
||||||
|
inputs.vendor == 'OrcaFilamentLibrary' &&
|
||||||
|
(inputs.auto_publish == true || inputs.auto_publish == 'true') &&
|
||||||
|
format(' [OFL cron {0}]', inputs.ofl_cron_dispatch_id) ||
|
||||||
|
''
|
||||||
|
}}
|
||||||
|
|
||||||
# Push-triggered counterpart to check_profiles.yml (which only gates PRs). When a
|
# Push-triggered counterpart to check_profiles.yml (which only gates PRs). When a
|
||||||
# profile change lands on main or a release branch, rebuild the affected vendors'
|
# profile change lands on main or a release branch, rebuild the affected vendors'
|
||||||
# binary preset caches (<vendor>.opc) and publish each as a versioned ZIP asset on
|
# binary preset caches (<vendor>.opc) and publish each as a versioned ZIP asset on
|
||||||
@@ -59,6 +68,12 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
ofl_cron_dispatch_id:
|
||||||
|
description: >-
|
||||||
|
Unique marker supplied by the trusted OFL daily cron so it can find
|
||||||
|
and wait for this dispatched workflow run.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ jobs:
|
|||||||
// kind of change
|
// kind of change
|
||||||
'crash',
|
'crash',
|
||||||
'bug-fix',
|
'bug-fix',
|
||||||
|
'SECURITY',
|
||||||
'enhancement',
|
'enhancement',
|
||||||
'QoL',
|
'QoL',
|
||||||
'optimization',
|
'optimization',
|
||||||
@@ -193,6 +194,7 @@ jobs:
|
|||||||
// kind of change
|
// kind of change
|
||||||
'crash',
|
'crash',
|
||||||
'bug-fix',
|
'bug-fix',
|
||||||
|
'SECURITY',
|
||||||
'enhancement',
|
'enhancement',
|
||||||
'QoL',
|
'QoL',
|
||||||
'optimization',
|
'optimization',
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ jobs:
|
|||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.artifact }}
|
name: ${{ inputs.artifact }}
|
||||||
|
# run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages
|
||||||
|
# beside them; the Windows arm64 build bundles none, so put one on PATH there.
|
||||||
|
- name: Install uv
|
||||||
|
if: runner.os == 'Windows' && runner.arch == 'ARM64'
|
||||||
|
uses: astral-sh/setup-uv@v10.2.0
|
||||||
|
with:
|
||||||
|
version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt
|
||||||
|
enable-cache: false
|
||||||
- uses: lukka/get-cmake@latest
|
- uses: lukka/get-cmake@latest
|
||||||
with:
|
with:
|
||||||
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
|
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
|
||||||
|
|||||||
@@ -52,4 +52,6 @@ internal_docs/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.opc
|
*.opc
|
||||||
docs/superpowers/
|
/.test/
|
||||||
|
docs/superpowers/
|
||||||
|
ctest_results.xml
|
||||||
|
|||||||
@@ -834,6 +834,37 @@ find_package(OpenSSL REQUIRED)
|
|||||||
find_package(CURL REQUIRED)
|
find_package(CURL REQUIRED)
|
||||||
find_package(Freetype REQUIRED)
|
find_package(Freetype REQUIRED)
|
||||||
|
|
||||||
|
if (SLIC3R_GUI)
|
||||||
|
# LibDataChannel's installed export references its bundled dependencies,
|
||||||
|
# but does not install their CMake targets. Recreate those targets from
|
||||||
|
# the same dependency prefix before loading the LibDataChannel config.
|
||||||
|
if (NOT TARGET Usrsctp::usrsctp)
|
||||||
|
find_library(_ORCA_USRSCTP_LIBRARY NAMES usrsctp
|
||||||
|
PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH)
|
||||||
|
if (_ORCA_USRSCTP_LIBRARY)
|
||||||
|
add_library(Usrsctp::usrsctp UNKNOWN IMPORTED GLOBAL)
|
||||||
|
set_target_properties(Usrsctp::usrsctp PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${_ORCA_USRSCTP_LIBRARY}"
|
||||||
|
IMPORTED_LINK_INTERFACE_LANGUAGES C
|
||||||
|
INTERFACE_LINK_LIBRARIES "Threads::Threads")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if (NOT TARGET LibJuice::LibJuice)
|
||||||
|
find_library(_ORCA_LIBJUICE_LIBRARY NAMES juice
|
||||||
|
PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH)
|
||||||
|
if (_ORCA_LIBJUICE_LIBRARY)
|
||||||
|
add_library(LibJuice::LibJuice UNKNOWN IMPORTED GLOBAL)
|
||||||
|
set_target_properties(LibJuice::LibJuice PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${_ORCA_LIBJUICE_LIBRARY}"
|
||||||
|
IMPORTED_LINK_INTERFACE_LANGUAGES C
|
||||||
|
INTERFACE_LINK_LIBRARIES "Threads::Threads")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_package(LibDataChannel CONFIG REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
|
||||||
add_library(libcurl INTERFACE)
|
add_library(libcurl INTERFACE)
|
||||||
target_link_libraries(libcurl INTERFACE CURL::libcurl)
|
target_link_libraries(libcurl INTERFACE CURL::libcurl)
|
||||||
@@ -1114,6 +1145,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls)
|
|||||||
endif ()
|
endif ()
|
||||||
file(COPY ${_occt_dlls}
|
file(COPY ${_occt_dlls}
|
||||||
${CMAKE_PREFIX_PATH}/bin/freetype.dll
|
${CMAKE_PREFIX_PATH}/bin/freetype.dll
|
||||||
|
${CMAKE_PREFIX_PATH}/bin/avformat-61.dll
|
||||||
${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll
|
${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll
|
||||||
${CMAKE_PREFIX_PATH}/bin/swresample-5.dll
|
${CMAKE_PREFIX_PATH}/bin/swresample-5.dll
|
||||||
${CMAKE_PREFIX_PATH}/bin/swscale-8.dll
|
${CMAKE_PREFIX_PATH}/bin/swscale-8.dll
|
||||||
@@ -1126,6 +1158,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls)
|
|||||||
${_out_dir}/WebView2Loader.dll
|
${_out_dir}/WebView2Loader.dll
|
||||||
|
|
||||||
${_out_dir}/freetype.dll
|
${_out_dir}/freetype.dll
|
||||||
|
${_out_dir}/avformat-61.dll
|
||||||
${_out_dir}/avcodec-61.dll
|
${_out_dir}/avcodec-61.dll
|
||||||
${_out_dir}/swresample-5.dll
|
${_out_dir}/swresample-5.dll
|
||||||
${_out_dir}/swscale-8.dll
|
${_out_dir}/swscale-8.dll
|
||||||
@@ -1149,7 +1182,10 @@ function(orcaslicer_copy_sos target config postfix output_sos)
|
|||||||
set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}")
|
set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so
|
file(COPY ${CMAKE_PREFIX_PATH}/lib/libavformat.so
|
||||||
|
${CMAKE_PREFIX_PATH}/lib/libavformat.so.61
|
||||||
|
${CMAKE_PREFIX_PATH}/lib/libavformat.so.61.1.100
|
||||||
|
${CMAKE_PREFIX_PATH}/lib/libavcodec.so
|
||||||
${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61
|
${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61
|
||||||
${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100
|
${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100
|
||||||
${CMAKE_PREFIX_PATH}/lib/libavutil.so
|
${CMAKE_PREFIX_PATH}/lib/libavutil.so
|
||||||
@@ -1164,6 +1200,9 @@ function(orcaslicer_copy_sos target config postfix output_sos)
|
|||||||
DESTINATION ${_out_dir})
|
DESTINATION ${_out_dir})
|
||||||
|
|
||||||
set(${output_sos}
|
set(${output_sos}
|
||||||
|
${_out_dir}/libavformat.so
|
||||||
|
${_out_dir}/libavformat.so.61
|
||||||
|
${_out_dir}/libavformat.so.61.1.100
|
||||||
${_out_dir}/libavcodec.so
|
${_out_dir}/libavcodec.so
|
||||||
${_out_dir}/libavcodec.so.61
|
${_out_dir}/libavcodec.so.61
|
||||||
${_out_dir}/libavcodec.so.61.3.100
|
${_out_dir}/libavcodec.so.61.3.100
|
||||||
@@ -1293,6 +1332,8 @@ endif ()
|
|||||||
|
|
||||||
if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||||
set(LIBRARY_FILES
|
set(LIBRARY_FILES
|
||||||
|
${LIBDIR_BIN}/libavformat.so.61
|
||||||
|
${LIBDIR_BIN}/libavformat.so.61.1.100
|
||||||
${LIBDIR_BIN}/libavcodec.so.61
|
${LIBDIR_BIN}/libavcodec.so.61
|
||||||
${LIBDIR_BIN}/libavcodec.so.61.3.100
|
${LIBDIR_BIN}/libavcodec.so.61.3.100
|
||||||
${LIBDIR_BIN}/libavutil.so.59
|
${LIBDIR_BIN}/libavutil.so.59
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15552" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15552" alt="OrcaSlicer%2FOrcaSlicer | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15552" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15552" alt="OrcaSlicer%2FOrcaSlicer | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
[](https://github.com/OrcaSlicer/OrcaSlicer/stargazers) [](https://github.com/OrcaSlicer/OrcaSlicer/actions/workflows/build_all.yml)
|
[](https://github.com/OrcaSlicer/OrcaSlicer/stargazers) [](https://github.com/OrcaSlicer/OrcaSlicer/actions/workflows/build_all.yml)
|
||||||
|
|
||||||
OrcaSlicer: an open source Next-Gen Slicing Software for Precision 3D Prints.
|
OrcaSlicer: an open source Next-Gen Slicing Software for Precision 3D Prints.
|
||||||
Optimize your prints with ultra-fast slicing, intelligent support generation, and seamless printer compatibility—engineered for perfection.
|
Optimize your prints with ultra-fast slicing, intelligent support generation, and seamless printer compatibility—engineered for perfection.
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ if (NOT _is_multi AND NOT CMAKE_BUILD_TYPE)
|
|||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
function(orcaslicer_add_cmake_project projectname)
|
function(orcaslicer_add_cmake_project projectname)
|
||||||
cmake_parse_arguments(P_ARGS "FORWARD_CONFIG" "INSTALL_DIR;BUILD_COMMAND;INSTALL_COMMAND" "CMAKE_ARGS" ${ARGN})
|
cmake_parse_arguments(P_ARGS "FORWARD_CONFIG" "INSTALL_DIR;BUILD_COMMAND;INSTALL_COMMAND;SOURCE_DIR" "CMAKE_ARGS" ${ARGN})
|
||||||
|
|
||||||
# MSVC is true for clang-cl as well, so the sub-build toolchain has to key on the
|
# MSVC is true for clang-cl as well, so the sub-build toolchain has to key on the
|
||||||
# generator. A non-Visual-Studio superbuild passes its own generator down, and with
|
# generator. A non-Visual-Studio superbuild passes its own generator down, and with
|
||||||
@@ -210,12 +210,18 @@ function(orcaslicer_add_cmake_project projectname)
|
|||||||
set(_build_j "-j${NPROC}")
|
set(_build_j "-j${NPROC}")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
set(_source_dir_arg "")
|
||||||
|
if (P_ARGS_SOURCE_DIR)
|
||||||
|
set(_source_dir_arg SOURCE_DIR ${P_ARGS_SOURCE_DIR})
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (NOT IS_CROSS_COMPILE OR NOT APPLE)
|
if (NOT IS_CROSS_COMPILE OR NOT APPLE)
|
||||||
ExternalProject_Add(
|
ExternalProject_Add(
|
||||||
dep_${projectname}
|
dep_${projectname}
|
||||||
EXCLUDE_FROM_ALL ON
|
EXCLUDE_FROM_ALL ON
|
||||||
INSTALL_DIR ${DESTDIR}
|
INSTALL_DIR ${DESTDIR}
|
||||||
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
|
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
|
||||||
|
${_source_dir_arg}
|
||||||
${_gen}
|
${_gen}
|
||||||
CMAKE_ARGS
|
CMAKE_ARGS
|
||||||
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
|
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
|
||||||
@@ -249,12 +255,14 @@ if (NOT IS_CROSS_COMPILE OR NOT APPLE)
|
|||||||
# note for future devs: shared libs may actually create a size reduction
|
# note for future devs: shared libs may actually create a size reduction
|
||||||
# but orcaslicer_deps tends to get really funny regarding linking after that (notably boost)
|
# but orcaslicer_deps tends to get really funny regarding linking after that (notably boost)
|
||||||
# so, as much as I would like to use that, it's not happening
|
# so, as much as I would like to use that, it's not happening
|
||||||
ExternalProject_Add_Step(dep_${projectname} free_download_space
|
if (NOT P_ARGS_SOURCE_DIR)
|
||||||
DEPENDEES download # do after download
|
ExternalProject_Add_Step(dep_${projectname} free_download_space
|
||||||
COMMENT "Freeing Space: Removing source archive"
|
DEPENDEES download # do after download
|
||||||
WORKING_DIRECTORY ${DEP_DOWNLOAD_DIR}
|
COMMENT "Freeing Space: Removing source archive"
|
||||||
COMMAND ${CMAKE_COMMAND} -E rm -r ${projectname}
|
WORKING_DIRECTORY ${DEP_DOWNLOAD_DIR}
|
||||||
)
|
COMMAND ${CMAKE_COMMAND} -E rm -rf ${projectname}
|
||||||
|
)
|
||||||
|
endif ()
|
||||||
ExternalProject_Add_Step(dep_${projectname} free_build_space
|
ExternalProject_Add_Step(dep_${projectname} free_build_space
|
||||||
DEPENDEES install # do after install
|
DEPENDEES install # do after install
|
||||||
COMMENT "Freeing Space: Removing source and build files"
|
COMMENT "Freeing Space: Removing source and build files"
|
||||||
@@ -268,6 +276,7 @@ else()
|
|||||||
EXCLUDE_FROM_ALL ON
|
EXCLUDE_FROM_ALL ON
|
||||||
INSTALL_DIR ${DESTDIR}
|
INSTALL_DIR ${DESTDIR}
|
||||||
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
|
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/${projectname}
|
||||||
|
${_source_dir_arg}
|
||||||
${_gen}
|
${_gen}
|
||||||
CMAKE_ARGS
|
CMAKE_ARGS
|
||||||
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
|
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
|
||||||
@@ -396,10 +405,6 @@ include(libnoise/libnoise.cmake)
|
|||||||
|
|
||||||
include(Draco/Draco.cmake)
|
include(Draco/Draco.cmake)
|
||||||
|
|
||||||
include(FFMPEG/FFMPEG.cmake)
|
|
||||||
include(Assimp/Assimp.cmake)
|
|
||||||
|
|
||||||
|
|
||||||
# I *think* 1.1 is used for *just* md5 hashing?
|
# I *think* 1.1 is used for *just* md5 hashing?
|
||||||
# 3.1 has everything in the right place, but the md5 funcs used are deprecated
|
# 3.1 has everything in the right place, but the md5 funcs used are deprecated
|
||||||
# a grep across the repo shows it is used for other things
|
# a grep across the repo shows it is used for other things
|
||||||
@@ -410,6 +415,12 @@ if(NOT OPENSSL_FOUND)
|
|||||||
set(OPENSSL_PKG dep_OpenSSL)
|
set(OPENSSL_PKG dep_OpenSSL)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
include(FFMPEG/FFMPEG.cmake)
|
||||||
|
include(Assimp/Assimp.cmake)
|
||||||
|
|
||||||
|
include(DataChannel/DataChannel.cmake)
|
||||||
|
set(DATACHANNEL_PKG dep_DataChannel)
|
||||||
|
|
||||||
# we don't want to load a "wrong" openssl when loading curl
|
# we don't want to load a "wrong" openssl when loading curl
|
||||||
# so, just don't even bother
|
# so, just don't even bother
|
||||||
# ...i think this is how it works? change if wrong
|
# ...i think this is how it works? change if wrong
|
||||||
@@ -483,6 +494,7 @@ set(_dep_list
|
|||||||
dep_wxInspector
|
dep_wxInspector
|
||||||
dep_FFMPEG
|
dep_FFMPEG
|
||||||
dep_Assimp
|
dep_Assimp
|
||||||
|
${DATACHANNEL_PKG}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (MSVC)
|
if (MSVC)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
)
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
set(_conf_cmd ./configure)
|
set(_conf_cmd ./configure)
|
||||||
|
|
||||||
|
set(_ffmpeg_depends)
|
||||||
|
set(_ffmpeg_configure_command ${_conf_cmd})
|
||||||
|
if (TARGET dep_OpenSSL)
|
||||||
|
set(_ffmpeg_depends DEPENDS dep_OpenSSL)
|
||||||
|
set(_ffmpeg_configure_command
|
||||||
|
${CMAKE_COMMAND} -E env
|
||||||
|
"PKG_CONFIG_PATH=${DESTDIR}/lib/pkgconfig:$ENV{PKG_CONFIG_PATH}"
|
||||||
|
${_conf_cmd}
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
if (MSVC)
|
if (MSVC)
|
||||||
set(_source_dir "${CMAKE_BINARY_DIR}/dep_FFMPEG-prefix/src/dep_FFMPEG")
|
set(_source_dir "${CMAKE_BINARY_DIR}/dep_FFMPEG-prefix/src/dep_FFMPEG")
|
||||||
|
|
||||||
set(PREBUILD_URL_arm64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-winarm64-orca-shared-7.0.zip")
|
set(PREBUILD_URL_arm64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-09-18-16-50/ffmpeg-n7.0.3-33-g887d4b4919-winarm64-orca-shared-7.0.zip")
|
||||||
set(PREBUILD_HASH_arm64 "12f4140279f2f8469885e1b5b2e8be9d788882914c21523cacd56989f3548054")
|
set(PREBUILD_HASH_arm64 "da480cbb39680056de824c57ec4dc3bd577b479ebbc310ff1f9dc55cf014b4c1")
|
||||||
set(PREBUILD_URL_x64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-win64-orca-shared-7.0.zip")
|
set(PREBUILD_URL_x64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-09-18-16-50/ffmpeg-n7.0.3-33-g887d4b4919-win64-orca-shared-7.0.zip")
|
||||||
set(PREBUILD_HASH_x64 "e65916020ddb9ef84b2666dfbcbfc9b1d67f69d15b4a66db53754637bf2d498c")
|
set(PREBUILD_HASH_x64 "85da19daf198f5548259d8aabb349db84997a3f6e6886d8d7764114add9c6dae")
|
||||||
|
|
||||||
ExternalProject_Add(dep_FFMPEG
|
ExternalProject_Add(dep_FFMPEG
|
||||||
|
${_ffmpeg_depends}
|
||||||
URL ${PREBUILD_URL_${DEPS_ARCH}}
|
URL ${PREBUILD_URL_${DEPS_ARCH}}
|
||||||
URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}}
|
URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}}
|
||||||
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
|
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
|
||||||
@@ -21,6 +33,8 @@ if (MSVC)
|
|||||||
)
|
)
|
||||||
|
|
||||||
else ()
|
else ()
|
||||||
|
set(_openssl_cmd --enable-openssl)
|
||||||
|
|
||||||
if (APPLE)
|
if (APPLE)
|
||||||
set(_minos_cmd
|
set(_minos_cmd
|
||||||
"--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}"
|
"--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}"
|
||||||
@@ -52,10 +66,11 @@ else ()
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
ExternalProject_Add(dep_FFMPEG
|
ExternalProject_Add(dep_FFMPEG
|
||||||
|
${_ffmpeg_depends}
|
||||||
URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz
|
URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz
|
||||||
URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC
|
URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC
|
||||||
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
|
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG
|
||||||
CONFIGURE_COMMAND ${_conf_cmd}
|
CONFIGURE_COMMAND ${_ffmpeg_configure_command}
|
||||||
${_cross_cmd}
|
${_cross_cmd}
|
||||||
${_pic_cmd}
|
${_pic_cmd}
|
||||||
${_arch_cmd}
|
${_arch_cmd}
|
||||||
@@ -63,20 +78,21 @@ else ()
|
|||||||
"--prefix=${DESTDIR}"
|
"--prefix=${DESTDIR}"
|
||||||
${_link_cmd}
|
${_link_cmd}
|
||||||
${_minos_cmd}
|
${_minos_cmd}
|
||||||
|
${_openssl_cmd}
|
||||||
--disable-doc
|
--disable-doc
|
||||||
--enable-small
|
--enable-small
|
||||||
--disable-outdevs
|
--disable-outdevs
|
||||||
--disable-filters
|
--disable-filters
|
||||||
--enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test*
|
--enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test*
|
||||||
--disable-protocols
|
--disable-protocols
|
||||||
--enable-protocol=file,fd,pipe,rtp,udp
|
--enable-protocol=file,fd,pipe,http,https,rtp,tcp,udp
|
||||||
--disable-muxers
|
--disable-muxers
|
||||||
--enable-muxer=rtp
|
--enable-muxer=rtp
|
||||||
--disable-encoders
|
--disable-encoders
|
||||||
--disable-decoders
|
--disable-decoders
|
||||||
--enable-decoder=*aac*,h264*,mp3*,mjpeg,rv*
|
--enable-decoder=*aac*,h264*,mp3*,mjpeg,rv*
|
||||||
--disable-demuxers
|
--disable-demuxers
|
||||||
--enable-demuxer=h264,mp3,mov
|
--enable-demuxer=h264,mp3,mov,mpjpeg,rtsp,sdp
|
||||||
--disable-zlib
|
--disable-zlib
|
||||||
--disable-avdevice
|
--disable-avdevice
|
||||||
BUILD_IN_SOURCE ON
|
BUILD_IN_SOURCE ON
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ endif()
|
|||||||
# On macOS/Linux OCCT links statically, so an unreferenced toolkit costs build time and no
|
# On macOS/Linux OCCT links statically, so an unreferenced toolkit costs build time and no
|
||||||
# shipped bytes. The Windows figure is a real DLL cost and has NOT been measured -- an
|
# shipped bytes. The Windows figure is a real DLL cost and has NOT been measured -- an
|
||||||
# earlier "3.77 MiB, Windows only" note here covered only two of the three toolkits and is
|
# earlier "3.77 MiB, Windows only" note here covered only two of the three toolkits and is
|
||||||
# not a number to quote. See docs/cad_dependency_weight.md.
|
# not a number to quote. See docs/HLSD/design-tab.md.
|
||||||
|
|
||||||
if (IN_GIT_REPO)
|
if (IN_GIT_REPO)
|
||||||
set(OCCT_DIRECTORY_FLAG --directory ${BINARY_DIR_REL}/dep_OCCT-prefix/src/dep_OCCT)
|
set(OCCT_DIRECTORY_FLAG --directory ${BINARY_DIR_REL}/dep_OCCT-prefix/src/dep_OCCT)
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ elseif(APPLE)
|
|||||||
# the post-install -add_rpath below.
|
# the post-install -add_rpath below.
|
||||||
set(_python_ldflags "${_python_arch_flags} -Wl,-headerpad_max_install_names")
|
set(_python_ldflags "${_python_arch_flags} -Wl,-headerpad_max_install_names")
|
||||||
|
|
||||||
|
# The macOS 27 SDK declares pipe2() and dup3() as available from macOS 27, so
|
||||||
|
# configure finds them and CPython 3.12 calls them without a runtime check.
|
||||||
|
# Below a macOS 27 deployment target they are weak-linked and resolve to NULL
|
||||||
|
# on older systems, where os.pipe() then segfaults -- in `make install`
|
||||||
|
# (compileall, ensurepip) and in the shipped app alike. Every configure below
|
||||||
|
# keeps the pipe()/dup2() fallbacks (python/cpython#153711).
|
||||||
if(IS_CROSS_COMPILE)
|
if(IS_CROSS_COMPILE)
|
||||||
set(_python_build_tgt --build=${_python_build_arch}-apple-darwin --host=${_python_host_arch}-apple-darwin)
|
set(_python_build_tgt --build=${_python_build_arch}-apple-darwin --host=${_python_host_arch}-apple-darwin)
|
||||||
set(_python_build_arch_flags "-arch ${_python_build_arch_flag} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}")
|
set(_python_build_arch_flags "-arch ${_python_build_arch_flag} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}")
|
||||||
@@ -174,7 +180,8 @@ elseif(APPLE)
|
|||||||
--enable-shared \
|
--enable-shared \
|
||||||
--without-static-libpython \
|
--without-static-libpython \
|
||||||
--disable-test-modules \
|
--disable-test-modules \
|
||||||
--build=${_python_build_arch}-apple-darwin && \
|
--build=${_python_build_arch}-apple-darwin \
|
||||||
|
ac_cv_func_pipe2=no ac_cv_func_dup3=no && \
|
||||||
make -j${NPROC} python && \
|
make -j${NPROC} python && \
|
||||||
cd '<SOURCE_DIR>' && \
|
cd '<SOURCE_DIR>' && \
|
||||||
env \
|
env \
|
||||||
@@ -191,6 +198,7 @@ elseif(APPLE)
|
|||||||
--without-static-libpython \
|
--without-static-libpython \
|
||||||
--with-openssl='${DESTDIR}' \
|
--with-openssl='${DESTDIR}' \
|
||||||
--disable-test-modules \
|
--disable-test-modules \
|
||||||
|
ac_cv_func_pipe2=no ac_cv_func_dup3=no \
|
||||||
${_python_build_tgt} \
|
${_python_build_tgt} \
|
||||||
--with-build-python='${_python_build_python}' \
|
--with-build-python='${_python_build_python}' \
|
||||||
py_cv_module__tkinter=n/a"
|
py_cv_module__tkinter=n/a"
|
||||||
@@ -213,6 +221,8 @@ elseif(APPLE)
|
|||||||
--with-openssl=${DESTDIR}
|
--with-openssl=${DESTDIR}
|
||||||
--disable-test-modules
|
--disable-test-modules
|
||||||
${_python_build_tgt}
|
${_python_build_tgt}
|
||||||
|
ac_cv_func_pipe2=no
|
||||||
|
ac_cv_func_dup3=no
|
||||||
# Tcl/Tk 9.0 (e.g. from Homebrew) is incompatible with CPython 3.12's
|
# Tcl/Tk 9.0 (e.g. from Homebrew) is incompatible with CPython 3.12's
|
||||||
# _tkinter; OrcaSlicer's embedded Python does not need tkinter anyway.
|
# _tkinter; OrcaSlicer's embedded Python does not need tkinter anyway.
|
||||||
py_cv_module__tkinter=n/a
|
py_cv_module__tkinter=n/a
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
# Orca-CAD vs Onshape — capability gap analysis
|
|
||||||
|
|
||||||
Generated 2026-07-22 by enumerating the source, not from recollection:
|
|
||||||
`CadFeatureType` and `add_*` in `src/libslic3r/CAD/CadDocument.hpp`, `Tool` in
|
|
||||||
`src/slic3r/GUI/CAD/DesignPanel.hpp`, `Mode` in `src/slic3r/GUI/CAD/DesignSketchTool.hpp`,
|
|
||||||
`SketchConstraintType` + `SketchEntity::Type` in `src/libslic3r/CAD/SketchEngine.hpp`,
|
|
||||||
and the JSON-RPC dispatch in `src/slic3r/GUI/CAD/McpControl.cpp`.
|
|
||||||
|
|
||||||
**Scope note.** Onshape is a cloud PLM platform; Orca is a Design tab inside a
|
|
||||||
slicer. A large share of Onshape's surface (release management, branching, real-time
|
|
||||||
collaboration, FEA, rendering, PDM) is out of scope by construction and is listed
|
|
||||||
separately at the bottom rather than counted as a "missing tool".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What Orca already has
|
|
||||||
|
|
||||||
### 2D sketcher — near parity with Onshape
|
|
||||||
This is the strongest area. Very little is missing.
|
|
||||||
|
|
||||||
| Category | Orca |
|
|
||||||
|---|---|
|
|
||||||
| Entities | Line, Polyline, Arc (3-point / tangent / center), Circle (center / 2-point / 3-point), Point, Ellipse, Elliptical arc, B-spline |
|
|
||||||
| Shapes | Rectangle (corner / center / oblique / rounded), Slot, Arc-slot, Polygon |
|
|
||||||
| Edit ops | Fillet, Chamfer, Offset, Mirror, Trim, Extend |
|
|
||||||
| Transforms | Move, Rotate, Scale, Linear array, Polar array |
|
|
||||||
| Constraints (19) | Fix, Coincident, Horizontal, Vertical, Distance, LockX, LockY, EqualLength, Parallel, Perpendicular, Concentric, Tangent, Midpoint, Symmetric, Angle, Radius, Diameter, PointOnLine, PointOnObject |
|
|
||||||
| Dimensions | Length, Diameter, Radius, Angle, Distance, Distance-to-line |
|
|
||||||
|
|
||||||
Solver: vendored SolveSpace (`libslvs`, GPL-3.0) — the same solver lineage as a
|
|
||||||
commercial-grade sketcher.
|
|
||||||
|
|
||||||
### Part features
|
|
||||||
|
|
||||||
| Present | Notes |
|
|
||||||
|---|---|
|
|
||||||
| Extrude | + up-to-face / up-to-point, taper, flip |
|
|
||||||
| Revolve | angle-arc gizmo |
|
|
||||||
| Sweep | along a path |
|
|
||||||
| Loft | multi-profile |
|
|
||||||
| Fillet / Chamfer | edge-level |
|
|
||||||
| Draft | face taper |
|
|
||||||
| Shell | wall thickness + open face |
|
|
||||||
| Hole / Thread | face-aware placement |
|
|
||||||
| Pattern | linear + circular |
|
|
||||||
| Boolean | New / Add / Cut / Intersect, with face-mating |
|
|
||||||
| Cut | plane-based, signed offset |
|
|
||||||
| Datum plane | offset / 2-face / 2-edge derived |
|
|
||||||
| Import | STEP (B-rep) + mesh→B-rep (native mesh2step port) |
|
|
||||||
| Export | STEP (native B-rep, not tessellated) |
|
|
||||||
| Multi-body | + per-body colour |
|
|
||||||
| Section view | with flip |
|
|
||||||
| Undo/redo | full feature-tree recompute |
|
|
||||||
| 3MF persistence | parametric recipe survives save/load |
|
|
||||||
|
|
||||||
### Automation
|
|
||||||
9 MCP JSON-RPC methods: `describe_tools`, `describe_scene`, `query_topology`,
|
|
||||||
`measure`, `slice_body`, `import_step`, `import_mesh`, `validate_against`, plus
|
|
||||||
build actions `extrude`, `revolve`, `fillet`, `chamfer`, `hole`, `boolean`, `pattern`.
|
|
||||||
Onshape's equivalent is its REST API + FeatureScript.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Missing tools — ranked by impact
|
|
||||||
|
|
||||||
### Tier 1 — structural absences (whole subsystems)
|
|
||||||
|
|
||||||
**1. Assemblies and mates.** Entirely absent. No assembly document, no mate
|
|
||||||
connectors, no fastened / revolute / slider / cylindrical / planar / ball / pin-slot
|
|
||||||
mates, no assembly patterns, no interference detection, no exploded views.
|
|
||||||
`bool_target_face` / `bool_tool_face` do face-to-face *mating* for a boolean, which
|
|
||||||
is geometric alignment, not a kinematic joint.
|
|
||||||
*Impact:* multi-part products cannot be positioned or validated as a mechanism.
|
|
||||||
*Note:* an MCP-side `align_instance_to_face` / `create_*_mate` vocabulary already
|
|
||||||
exists on the Onshape bridge in this workspace, so the target semantics are known.
|
|
||||||
|
|
||||||
**2. Drawings / 2D documentation.** Absent. No drawing sheets, dimensioned views,
|
|
||||||
section/detail views, GD&T, title blocks, or BOM.
|
|
||||||
*Impact:* nothing manufacturable-by-a-third-party leaves the tool. For 3D printing
|
|
||||||
this matters less than for machining, which is the honest reason it is Tier 1 by
|
|
||||||
CAD convention but arguably Tier 3 for this product.
|
|
||||||
|
|
||||||
**3. Variables, equations, configurations.** Absent — no `add_variable`, no
|
|
||||||
expression evaluation, no configuration table. Every dimension is a literal double.
|
|
||||||
*Impact:* this is the biggest *parametric* gap. "Make this bracket for an M4 vs M5
|
|
||||||
bolt" requires re-editing every dependent feature by hand. Onshape's Variable
|
|
||||||
Studio + configurations are a core differentiator, and this is the cheapest Tier 1
|
|
||||||
item to close for the size of the payoff.
|
|
||||||
|
|
||||||
**4. Surface modelling.** Absent. No surface extrude/revolve/loft/sweep, no fill,
|
|
||||||
knit, trim/extend surface, offset surface, or thicken. Orca is solid-only.
|
|
||||||
*Impact:* organic/complex shapes and repair of imported junk geometry are impossible.
|
|
||||||
OCCT already provides all of it (`TKOffset`, `TKBRep`), so the kernel is not the
|
|
||||||
blocker — only UI and feature plumbing.
|
|
||||||
|
|
||||||
**5. Sheet metal.** Absent. No flange, bend, tab, relief, or flat-pattern unfold.
|
|
||||||
*Impact:* arguably out of scope for an FDM slicer; listed for completeness.
|
|
||||||
|
|
||||||
### Tier 2 — individual features with clear demand
|
|
||||||
|
|
||||||
| Missing | Why it matters | Cheap? |
|
|
||||||
|---|---|---|
|
|
||||||
| **Mirror body** (part-level) | Sketch mirror exists; mirroring a *solid* about a plane does not. Extremely common. | Yes — OCCT `gp_Trsf` mirror + fuse |
|
|
||||||
| **Helix / spiral curve** | No helix ⇒ no springs, no custom threads, no spiral vase geometry. Sweep exists but has no helical path to sweep along. | Yes |
|
|
||||||
| **Move / rotate body as a real feature** | `m_body_xform` exists but is **display-only** (memory #1655) — it never enters the B-rep. Export/boolean see the original position. | Medium |
|
|
||||||
| **Split body** | Cut removes material; splitting one body into two independently-usable bodies is absent. Very relevant for print-in-parts. | Medium |
|
|
||||||
| **Thicken** | Solid from a surface/face offset. | Needs surfaces |
|
|
||||||
| **Rib** | Standard structural feature. | Medium |
|
|
||||||
| **Delete face / move face / replace face** | Direct/dumb-solid editing — the main tool for fixing imported STEP. Given Orca imports STEP *and* meshes, its absence is felt. | Medium |
|
|
||||||
| **Datum axis, coordinate system** | Only datum *planes* exist. Axes are needed for revolve/pattern references. | Yes |
|
|
||||||
| **Mass properties** | `GeometryEngine` computes a volume internally, but there is no volume/mass/COM/inertia readout. For print cost/time estimation this is nearly free to expose. | Yes — trivial |
|
|
||||||
| **Measure tool in the GUI** | `measure` exists over MCP but there is no interactive measure in the UI. | Yes |
|
|
||||||
| **Hole standards library** | Hole exists, but no counterbore/countersink/tapped standards (ISO/ANSI) with callouts. | Medium |
|
|
||||||
| **Project / convert edges into a sketch** | Cannot reference existing solid edges as sketch geometry ("Use" in SolidWorks). A significant sketcher gap given everything else is present. | Medium |
|
|
||||||
| **Construction geometry** | Could not confirm a construction/reference-line flag on sketch entities. | Yes if absent |
|
|
||||||
| **Curve tools** | Projected curve, bridging curve, composite curve, 3D fit spline. | Medium |
|
|
||||||
| **Pattern on curve / pattern faces** | Pattern is linear + circular of whole bodies only; no curve-driven pattern, no feature/face pattern. | Medium |
|
|
||||||
| **Wrap / emboss** | Text or sketch wrapped onto a curved face. | Hard |
|
|
||||||
| **Enclose** | Solid from bounded void regions. | Medium |
|
|
||||||
|
|
||||||
### Tier 3 — platform capabilities (out of scope by construction)
|
|
||||||
|
|
||||||
Version control with branching/merging, release management, real-time multi-user
|
|
||||||
collaboration, cloud PDM, FeatureScript custom-feature authoring, simulation/FEA,
|
|
||||||
photorealistic rendering, app store/integrations. These are Onshape-the-platform,
|
|
||||||
not Onshape-the-modeller. Not defects in Orca.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Recommended priority
|
|
||||||
|
|
||||||
If the goal is "credible parametric CAD inside a slicer", the ordering that buys
|
|
||||||
the most capability per unit of work:
|
|
||||||
|
|
||||||
1. **Variables + expressions** — unlocks genuine parametric reuse; no new kernel work.
|
|
||||||
2. **Mass properties + GUI measure** — nearly free, immediately useful for printing.
|
|
||||||
3. **Mirror body, datum axis, helix** — small, self-contained, high-frequency features.
|
|
||||||
4. **Promote move/rotate body from display-only to a real B-rep feature** — closes a
|
|
||||||
correctness gap, not just a missing tool (exports currently disagree with the view).
|
|
||||||
5. **Split body** — high value for print-in-parts workflows.
|
|
||||||
6. **Project edges into sketch** — the sketcher's most conspicuous hole.
|
|
||||||
7. **Surface modelling** — large, but OCCT already ships the algorithms.
|
|
||||||
8. **Assemblies** — largest effort; only worth it if Orca targets multi-part products.
|
|
||||||
|
|
||||||
Deliberately last: drawings and sheet metal — high cost, low relevance to an
|
|
||||||
FDM-oriented tool.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Honest summary
|
|
||||||
|
|
||||||
Orca's **sketcher is at or near Onshape parity**, and its **solid feature set
|
|
||||||
covers the mainstream modelling path** (sketch → extrude/revolve/sweep/loft →
|
|
||||||
dress-up → boolean/pattern). What is absent is *breadth*: assemblies, surfaces,
|
|
||||||
sheet metal, drawings, and — most importantly for a tool calling itself parametric —
|
|
||||||
**variables and configurations**.
|
|
||||||
|
|
||||||
The single most defensible criticism is #3: without variables, the feature tree is
|
|
||||||
parametric in *structure* but not in *value*, so the promise of "change one number
|
|
||||||
and the model updates" is only half delivered.
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Dependency weight of the Design/CAD subsystem
|
|
||||||
|
|
||||||
What the Design tab actually costs a maintainer who merges it. Written to be checkable:
|
|
||||||
every number below is reproducible with the command that produced it, and the places where
|
|
||||||
a number is still missing say so instead of guessing.
|
|
||||||
|
|
||||||
Measured on Linux x86_64, OCCT V7_6_0, in the `snapmaker-deps` build image.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| | Cost |
|
|
||||||
|---|---|
|
|
||||||
| New third-party dependencies | **none** |
|
|
||||||
| OCCT build flag | `BUILD_MODULE_ModelingAlgorithms=ON` |
|
|
||||||
| Extra OCCT toolkits *built* | 3 (TKFillet, TKOffset, TKFeat) |
|
|
||||||
| Extra OCCT toolkits *linked* | 2 (TKFillet, TKOffset) |
|
|
||||||
| Vendored code | `src/libslic3r/slvs`, 9,339 lines, 380 KiB, GPLv3 |
|
|
||||||
| Own object code | 6.79 MiB unstripped `.o` (7.13 MiB with the solver) |
|
|
||||||
|
|
||||||
OCCT is **already** an upstream dependency — Orca uses it for STEP import. The Design tab
|
|
||||||
does not add a library; it turns on one more OCCT module.
|
|
||||||
|
|
||||||
## The OCCT module flag
|
|
||||||
|
|
||||||
`deps/OCCT/OCCT.cmake` gates the module on `SLIC3R_CAD`:
|
|
||||||
|
|
||||||
```cmake
|
|
||||||
-DBUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD} # was hard-coded OFF
|
|
||||||
```
|
|
||||||
|
|
||||||
With `SLIC3R_CAD=OFF` the deps prefix matches upstream exactly.
|
|
||||||
|
|
||||||
`ModelingAlgorithms` contains 12 toolkits, but **most were already being built**, because
|
|
||||||
`DataExchange` — the STEP path upstream already ships — depends on them. The honest delta is
|
|
||||||
only the toolkits that DataExchange's dependency closure does *not* reach:
|
|
||||||
|
|
||||||
```
|
|
||||||
ModelingAlgorithms = TKGeomAlgo TKTopAlgo TKPrim TKBO TKBool TKHLR
|
|
||||||
TKFillet TKOffset TKFeat TKMesh TKXMesh TKShHealing
|
|
||||||
|
|
||||||
already required by DataExchange: TKBO TKBool TKGeomAlgo TKHLR TKMesh
|
|
||||||
TKPrim TKShHealing TKTopAlgo
|
|
||||||
true delta: TKFeat TKFillet TKOffset TKXMesh
|
|
||||||
```
|
|
||||||
|
|
||||||
Reproduce by walking `adm/MODULES` and each toolkit's `src/<TK>/EXTERNLIB` in the OCCT
|
|
||||||
source tree.
|
|
||||||
|
|
||||||
### Sizes of the delta toolkits
|
|
||||||
|
|
||||||
Static archives in the deps prefix. These are *build artifacts*, not shipped bytes — a
|
|
||||||
static link pulls in only the objects it references:
|
|
||||||
|
|
||||||
| Toolkit | Archive | Referenced by the Design tab? |
|
|
||||||
|---|---|---|
|
|
||||||
| TKFillet | 7.40 MiB | yes — `BRepFilletAPI` |
|
|
||||||
| TKOffset | 5.38 MiB | yes — `BRepOffsetAPI`, `BRepOffset_` |
|
|
||||||
| TKFeat | 4.42 MiB | **no** |
|
|
||||||
| TKXMesh | — | not produced at all |
|
|
||||||
|
|
||||||
TKFeat is worth calling out: nothing in the Design tab references it, and it is absent from
|
|
||||||
the `TKFillet`/`TKOffset` dependency closure, so it is built for nothing. OCCT's module flag
|
|
||||||
is all-or-nothing per module, which is why it comes along. It costs build time and zero
|
|
||||||
shipped bytes on any platform that links OCCT statically.
|
|
||||||
|
|
||||||
**A correction to the record.** The comment in `deps/OCCT/OCCT.cmake` and the earlier
|
|
||||||
summary both said the delta was "TKFillet + TKOffset — 3.77 MiB, Windows only". The toolkit
|
|
||||||
list was incomplete: TKFeat is built too. The 3.77 MiB figure covers 2 of the 3 built
|
|
||||||
toolkits and has not been re-derived here — see the gap below.
|
|
||||||
|
|
||||||
## What is not measured yet
|
|
||||||
|
|
||||||
Two numbers a maintainer may reasonably ask for are **not** in this document, because
|
|
||||||
producing them honestly needs a build this machine cannot do:
|
|
||||||
|
|
||||||
1. **Windows DLL delta.** OCCT builds shared on Windows, so the shipped cost there is real
|
|
||||||
DLL bytes rather than linker-selected objects. That needs a Windows build to size —
|
|
||||||
tracked as the cross-platform build proof (`gix`).
|
|
||||||
2. **Clean-build time delta.** Measuring it means building the deps prefix twice, with the
|
|
||||||
flag ON and OFF, on the same machine. The incremental figures from day-to-day work do not
|
|
||||||
answer the question and are not offered as if they did.
|
|
||||||
|
|
||||||
Do not quote a number for either until it has been measured.
|
|
||||||
|
|
||||||
## Vendored solver
|
|
||||||
|
|
||||||
`src/libslic3r/slvs` — the 2D sketch constraint solver extracted from SolveSpace.
|
|
||||||
|
|
||||||
- 19 files: 8 `.cpp`, 11 `.h`, plus `LICENSE`
|
|
||||||
- 9,339 lines, 380 KiB of source, 0.34 MiB of object code
|
|
||||||
- **GPLv3**, `LICENSE` preserved verbatim in the vendored directory
|
|
||||||
|
|
||||||
The fork is **AGPLv3**. GPLv3 code combines into an AGPLv3 work without difficulty: AGPLv3
|
|
||||||
§13 provides explicit compatibility in that direction. No licence question to resolve.
|
|
||||||
|
|
||||||
It is live code, not a carried corpse — `SketchSolver.cpp` is its only consumer and drives
|
|
||||||
every sketch constraint in the Design tab.
|
|
||||||
|
|
||||||
## Own code
|
|
||||||
|
|
||||||
Object sizes from the release build (unstripped, so these include debug information and
|
|
||||||
overstate the shipped contribution):
|
|
||||||
|
|
||||||
| Object | Size |
|
|
||||||
|---|---|
|
|
||||||
| DesignPanel.o | 2.22 MiB |
|
|
||||||
| McpControl.o | 1.69 MiB |
|
|
||||||
| DesignSketchTool.o | 0.88 MiB |
|
|
||||||
| CadDocument.o | 0.76 MiB |
|
|
||||||
| SketchEngine.o | 0.40 MiB |
|
|
||||||
| DesignCanvas.o | 0.37 MiB |
|
|
||||||
| GeometryEngine.o | 0.32 MiB |
|
|
||||||
| SketchSolver.o | 0.15 MiB |
|
|
||||||
| slvs (all objects) | 0.34 MiB |
|
|
||||||
| **total** | **7.13 MiB** |
|
|
||||||
|
|
||||||
For scale, the linked binary is 137.1 MiB.
|
|
||||||
|
|
||||||
## Reproducing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# toolkit membership and dependency closure
|
|
||||||
R=<occt-source>
|
|
||||||
cat $R/adm/MODULES # module -> toolkits
|
|
||||||
cat $R/src/<TK>/EXTERNLIB # toolkit -> its dependencies
|
|
||||||
|
|
||||||
# archive sizes
|
|
||||||
ls -l <deps-prefix>/lib/libTK{Fillet,Offset,Feat}.a
|
|
||||||
|
|
||||||
# what the Design tab actually references
|
|
||||||
grep -rE 'BRepFilletAPI|BRepOffsetAPI|BRepOffset_|BRepFeat' src/libslic3r/
|
|
||||||
|
|
||||||
# vendored solver
|
|
||||||
wc -l src/libslic3r/slvs/*.cpp src/libslic3r/slvs/**/*.h
|
|
||||||
head -3 src/libslic3r/slvs/LICENSE
|
|
||||||
```
|
|
||||||
@@ -1,704 +0,0 @@
|
|||||||
# Orca-CAD — UX guidelines and design charter
|
|
||||||
|
|
||||||
Status: proposed, v1. Owner: design working group. Applies to the Design tab —
|
|
||||||
the parametric CAD environment inside OrcaSlicer.
|
|
||||||
|
|
||||||
This document is a **review instrument**, not an essay. Sections 3–9 are written
|
|
||||||
so that a reviewer can hold a pull request against them and get a yes or a no.
|
|
||||||
If a rule here cannot be failed, it is badly written and should be rewritten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why this exists
|
|
||||||
|
|
||||||
A CAD tool acquires its interface by accretion. Every feature arrives needing
|
|
||||||
"just one more field", the side panel is the cheapest place to put it, and after
|
|
||||||
forty features the product is FreeCAD: complete, respected, and abandoned by
|
|
||||||
almost everyone who opens it once. That end state is not a failure of any single
|
|
||||||
decision. It is the sum of forty locally reasonable ones taken without a written
|
|
||||||
rule to violate.
|
|
||||||
|
|
||||||
So we write the rule down first, and we make additions argue against it.
|
|
||||||
|
|
||||||
## 2. Product thesis
|
|
||||||
|
|
||||||
**Orca-CAD is a modelling space for people who want a part, inside the tool that
|
|
||||||
prints it.**
|
|
||||||
|
|
||||||
Three audiences, one interface:
|
|
||||||
|
|
||||||
- **The fourteen-year-old on a school laptop.** Free software, on the machine
|
|
||||||
they already have, with no account, no subscription, no licence and no
|
|
||||||
tutorial. They open the tab because they want a bracket for a bike light, and
|
|
||||||
an hour later it is printing. This is not the charity case at the bottom of
|
|
||||||
the list — it is the reason the project is worth doing. A CAD tool that only
|
|
||||||
the equipped can run is a tool for people who were already going to design
|
|
||||||
something; this one has to be a creative instrument in the hands of someone
|
|
||||||
who did not yet know they could make things. Everything in §6.1 exists to
|
|
||||||
keep that door open, and nothing gets to close it for the convenience of the
|
|
||||||
other two audiences.
|
|
||||||
- **The maker** who has an idea and a printer, and who has bounced off FreeCAD.
|
|
||||||
They should be modelling something real within ten minutes of first opening
|
|
||||||
the tab, without a tutorial, without knowing the word "constraint".
|
|
||||||
- **The mechanical designer** who needs assemblies, mates, exploded views,
|
|
||||||
variables, and a feature history they can edit six months later. They should
|
|
||||||
not have to leave for SolidWorks the moment the work gets serious.
|
|
||||||
|
|
||||||
The order matters. When a decision helps one audience and hurts another, the
|
|
||||||
earlier one wins unless there is a written argument for why not.
|
|
||||||
|
|
||||||
The reference for *how it feels* is Shapr3D: direct, gestural, quiet, almost no
|
|
||||||
chrome, depth revealed by what you touch rather than by what is on screen. The
|
|
||||||
anti-references are Blender (a modal keyboard language you must learn before the
|
|
||||||
first success) and FreeCAD (a workbench-and-dialog architecture where the
|
|
||||||
geometry is a preview of a form you fill in elsewhere).
|
|
||||||
|
|
||||||
We are not cloning Shapr3D's feature set. We are adopting its *interaction
|
|
||||||
economy*: the smallest number of visible controls that still makes an expert
|
|
||||||
fast.
|
|
||||||
|
|
||||||
**And one thing neither reference has:** Orca-CAD lives inside a slicer. The
|
|
||||||
plate, the nozzle, the material and the print constraints are known to the
|
|
||||||
application at design time. Designing for print is not a plugin here, it is the
|
|
||||||
home advantage. Where a rule below trades generality for print-awareness, it
|
|
||||||
trades in favour of print-awareness.
|
|
||||||
|
|
||||||
## 3. The laws
|
|
||||||
|
|
||||||
Non-negotiable. A change that breaks one of these does not get merged on the
|
|
||||||
grounds that it was easier, that the alternative is more work, or that another
|
|
||||||
CAD does it that way. Each law carries a test — the question a reviewer asks.
|
|
||||||
|
|
||||||
### L1 — Geometry first: you point, then you act
|
|
||||||
|
|
||||||
Controls live **on the geometry**: handles, arrows, points, small circles and
|
|
||||||
boxes, with an inline label tab for typed values. Not in a side panel of combos
|
|
||||||
and spin fields.
|
|
||||||
|
|
||||||
The canonical gesture: **select a face or plane in the viewport, then click the
|
|
||||||
sketch tool.** Never: click the sketch tool, then choose a plane from a list.
|
|
||||||
The tool consumes what you pointed at — and, better still, the thing you pointed
|
|
||||||
at offers the tool itself (§4).
|
|
||||||
|
|
||||||
> **Test.** Can the operation be performed start to finish without the pointer
|
|
||||||
> leaving the viewport, except to press the tool itself? If a control had to be
|
|
||||||
> added to a panel to make it work, the design is not finished.
|
|
||||||
|
|
||||||
This is the law the others serve. It was stated after two proposals in a row
|
|
||||||
reached for a dropdown, and the failure mode it names is real and recurrent: a
|
|
||||||
fix that "adds a row to the plane combo" is the side-panel pattern wearing a
|
|
||||||
different hat.
|
|
||||||
|
|
||||||
### L2 — Everything draggable is typable, and everything typable is draggable
|
|
||||||
|
|
||||||
Any value produced by direct manipulation (a fillet radius, an extrude depth, a
|
|
||||||
pattern spacing, a plane offset) shows a live label on the geometry, and that
|
|
||||||
label is an editable field. Any value entered numerically has a corresponding
|
|
||||||
handle in the viewport.
|
|
||||||
|
|
||||||
Dragging is for finding the answer. Typing is for committing to it. A tool that
|
|
||||||
offers only one of the two is half a tool.
|
|
||||||
|
|
||||||
> **Test.** Point at the number the tool produces. Can you drag it? Can you
|
|
||||||
> click it and type? Both must be yes.
|
|
||||||
|
|
||||||
### L3 — Noun then verb, always the same way round
|
|
||||||
|
|
||||||
Selection precedes action, without exception, across sketch tools, features,
|
|
||||||
dress-up, booleans and mates. There is no tool in the product that is armed
|
|
||||||
first and asks for its input afterwards.
|
|
||||||
|
|
||||||
> **Test.** Does this tool work if the user has already selected the thing they
|
|
||||||
> want it applied to? Does it work *only* that way?
|
|
||||||
|
|
||||||
### L4 — No modal dialog in the modelling loop
|
|
||||||
|
|
||||||
Dialogs belong to document-level actions: open, save, import, export, preferences.
|
|
||||||
Modelling never opens one. A feature that needs three values gets three labels on
|
|
||||||
the geometry, not a form; a feature that needs confirming gets a ghost preview and
|
|
||||||
a confirm/cancel puck in the scene beside it (§4.2) — an object, not a window: the
|
|
||||||
camera still orbits, the values are still editable, nothing is blocked.
|
|
||||||
|
|
||||||
> **Test.** Between starting an operation and seeing its result, does a window
|
|
||||||
> appear that must be dismissed? If yes, redesign.
|
|
||||||
|
|
||||||
### L5 — One click, one visible change
|
|
||||||
|
|
||||||
Every click either changes what is on screen or tells the user why it did not.
|
|
||||||
A click that opens something invisible, arms an invisible state, or requires a
|
|
||||||
second identical click to have any effect is a defect, not a design.
|
|
||||||
|
|
||||||
This law exists because we shipped its violation twice. Sketch-tool family
|
|
||||||
buttons were flyouts whose first click only rendered a pressed state — three
|
|
||||||
separate sessions filed bugs against tools that were working. Solid picking used
|
|
||||||
a click *cycle* (first click selects the body, second refines to the face), so
|
|
||||||
sketching on a face appeared broken to anyone who clicked a face once, the way
|
|
||||||
every human does.
|
|
||||||
|
|
||||||
> **Test.** Perform the gesture exactly once, as a first-time user would. Take a
|
|
||||||
> screenshot. Is the state visibly different, and is the difference the one the
|
|
||||||
> user intended?
|
|
||||||
|
|
||||||
### L6 — The default is the answer four times out of five
|
|
||||||
|
|
||||||
Every option that has a default must have the *common* answer as its default,
|
|
||||||
measured against real parts, not against generality. "New body" as the default
|
|
||||||
result of an extrude is wrong: most extrudes join. Radius as the input for a
|
|
||||||
circle is wrong: drawings give diameter.
|
|
||||||
|
|
||||||
> **Test.** Take ten real parts. In how many is the default correct? Below eight,
|
|
||||||
> change the default or infer it from context.
|
|
||||||
|
|
||||||
### L7 — Errors are caught before the commit, in the user's words
|
|
||||||
|
|
||||||
A self-intersecting profile, a cut that removes no material, a wall thinner than
|
|
||||||
the nozzle: these are reported at the moment they become knowable, on the
|
|
||||||
geometry that is wrong, phrased as what happened and what to do — not as a kernel
|
|
||||||
exception after the fact, and never silently.
|
|
||||||
|
|
||||||
> **Test.** Is the failure detectable before the user commits? Then it must be
|
|
||||||
> reported before the user commits. Read the message aloud: does it name a thing
|
|
||||||
> the user can see and an action they can take?
|
|
||||||
|
|
||||||
### L8 — The camera is the application's job
|
|
||||||
|
|
||||||
Selecting a sketch plane orients the view to it. Committing a feature does not
|
|
||||||
throw the camera away. Zoom-to-fit exists and is one keystroke. The user is never
|
|
||||||
required to fight the view in order to reach the geometry, and orbit is bound to
|
|
||||||
the gesture people actually try.
|
|
||||||
|
|
||||||
> **Test.** Count camera manipulations in a representative modelling session.
|
|
||||||
> Any camera action the application could have performed for the user is a bug.
|
|
||||||
|
|
||||||
### L9 — Accessible by construction, not by retrofit
|
|
||||||
|
|
||||||
The floor, applied to every new interaction (details in §6.2): full keyboard
|
|
||||||
reach, no meaning carried by colour alone, hit targets that survive a shaky hand
|
|
||||||
and a HiDPI screen, legible labels over an arbitrary 3D background, no gesture
|
|
||||||
that depends on timing.
|
|
||||||
|
|
||||||
> **Test.** Drive the whole interaction from the keyboard. Then drive it in
|
|
||||||
> greyscale. Both must work.
|
|
||||||
|
|
||||||
### L10 — Vocabulary from the drawing office
|
|
||||||
|
|
||||||
Names come from the language of people who make parts: fillet, chamfer, boss,
|
|
||||||
rib, counterbore, mate, exploded view. Not from the kernel (no "boolean
|
|
||||||
subtract", no "B-rep"), not from invented product-speak. Where the drawing-office
|
|
||||||
word and the beginner's word differ, use the drawing-office word and make the
|
|
||||||
tooltip teach it — an approachable tool that leaves the user unable to talk to a
|
|
||||||
machinist has failed them.
|
|
||||||
|
|
||||||
> **Test.** Would a shop-floor engineer recognise this word? Would a first-time
|
|
||||||
> user be able to look it up and find a real definition?
|
|
||||||
|
|
||||||
### L11 — The floor is a school laptop, and nothing is behind a door
|
|
||||||
|
|
||||||
The product runs, completely, on a low-end laptop with integrated graphics and a
|
|
||||||
small screen, offline, with no account, no subscription and no feature withheld.
|
|
||||||
No capability in this document is reserved for a paid tier, a cloud service, a
|
|
||||||
plugin, or a machine with a discrete GPU — there is one product and everybody
|
|
||||||
gets all of it.
|
|
||||||
|
|
||||||
> **Test.** On the reference low-end machine (§6.1), at 1366×768, with the
|
|
||||||
> network cable pulled and no account ever created: does this feature work, and
|
|
||||||
> is it usable at an honest frame rate? Any "no" is a defect, not a limitation.
|
|
||||||
|
|
||||||
## 4. Interaction grammar — object-driven
|
|
||||||
|
|
||||||
The rules above compose into one sentence the whole product obeys:
|
|
||||||
|
|
||||||
> **Point at geometry → the geometry offers what can be done to it → choose the
|
|
||||||
> tool → manipulate handles and type exact values → confirm or cancel.**
|
|
||||||
|
|
||||||
The selection does not merely feed the tool. **The selection determines which
|
|
||||||
tools exist.** Pick a planar face and the product shows you the small set of
|
|
||||||
things a planar face can become — sketch on it, extrude it, hole it, shell it,
|
|
||||||
put a datum on it. Pick an edge and that set is fillet, chamfer, and the sketch
|
|
||||||
tools that can use it as a reference. Nothing else is offered, because nothing
|
|
||||||
else is possible.
|
|
||||||
|
|
||||||
This is the single largest thing we can do for a first-time user, and it is
|
|
||||||
worth stating as the reason: a beginner's difficulty is not operating a tool,
|
|
||||||
it is **not knowing which tools apply to what they are looking at**. A palette
|
|
||||||
of sixty icons answers a question they cannot yet ask. A face that offers its
|
|
||||||
own five verbs teaches the model of the product by using it. It also removes an
|
|
||||||
entire class of failure — a tool that silently does nothing because the
|
|
||||||
selection was wrong can no longer be reached.
|
|
||||||
|
|
||||||
### 4.1 The offer, and the one thing that makes it work
|
|
||||||
|
|
||||||
The flow, in full:
|
|
||||||
|
|
||||||
> **left-click the geometry to select it → right-click to open the offer → a
|
|
||||||
> vertical list, always in the same order, each row an icon, a name and its
|
|
||||||
> keyboard shortcut → click.**
|
|
||||||
|
|
||||||
- **Selecting and acting are separate gestures.** Left-click only ever selects,
|
|
||||||
so pointing at things is quiet — nothing pops up while you look around.
|
|
||||||
Right-click on the selection opens the offer, at the pointer, over the
|
|
||||||
geometry it acts on.
|
|
||||||
- **Order is fixed and it is the whole point.** A verb occupies one permanent
|
|
||||||
row, and that row is the same in every selection where the verb appears.
|
|
||||||
Dress-up is the fourth row on an edge, on a face, on a body, on the day the
|
|
||||||
product ships and two years later. The hand learns the position; the eye stops
|
|
||||||
being needed.
|
|
||||||
- **What does not apply is DISABLED IN PLACE, never removed.** This is the
|
|
||||||
single strongest thing the list does, and it is why it beat the radial we
|
|
||||||
drew first: a greyed row still carries its name *and the reason it is grey* —
|
|
||||||
"Create a sketch, or pick a solid face, first", "Create a solid body to
|
|
||||||
pattern first" — in the words the product already ships. On a first-run
|
|
||||||
document the offer is therefore not a mostly-empty control but a map of what
|
|
||||||
the product does and what you have to do first.
|
|
||||||
- **It is an accelerator, not a toll gate.** The toolbar and the single-letter
|
|
||||||
shortcuts keep working exactly as they do now, and pressing a tool directly
|
|
||||||
consumes the same selection (L3). An expert never has to open the offer; a
|
|
||||||
beginner never has to know the toolbar exists. Both routes land in the same
|
|
||||||
place — this is the only way one interface serves §2's three audiences.
|
|
||||||
- **Every row shows its keyboard shortcut**, right-aligned so the keys stack
|
|
||||||
into a column the eye learns without trying, beside the icon and the
|
|
||||||
drawing-office word (L10). This is deliberate: the offer is the path by which
|
|
||||||
a user stops needing the offer. You reach for fillet in its row, the row says
|
|
||||||
"F", and one day your hand types F before the menu has finished opening. A
|
|
||||||
menu that teaches its own shortcut is how a beginner becomes the power user
|
|
||||||
who never opens it — the same interface at two speeds, with no "advanced mode"
|
|
||||||
between them (§7).
|
|
||||||
- **A family with more than one applicable verb opens a submenu** to the side,
|
|
||||||
in its own fixed order. A family with exactly one shows that verb directly, so
|
|
||||||
the common path is never one click longer than it needs to be.
|
|
||||||
- **It never blocks the view of what it acts on**: it opens beside the pick,
|
|
||||||
never over it, with a thin leader back to the point it belongs to, and it
|
|
||||||
dismisses the moment the selection changes.
|
|
||||||
- **The header names what is selected** ("Top face · Body 1"), because a user
|
|
||||||
who mis-picked should find that out before choosing a verb, not after.
|
|
||||||
|
|
||||||
#### Opening the offer on every machine
|
|
||||||
|
|
||||||
Right-click is the primary gesture and every platform must have a first-class
|
|
||||||
equivalent — this is a reach requirement (L11), not a nicety:
|
|
||||||
|
|
||||||
| Input | Gesture |
|
|
||||||
|---|---|
|
|
||||||
| Two-button mouse | right-click |
|
|
||||||
| Trackpad | two-finger tap (the OS-standard secondary click) |
|
|
||||||
| macOS, one-button mouse | **long-press**, and Ctrl-click, which is the platform convention |
|
|
||||||
| Keyboard | the Menu key, or Shift+F10, on the current selection |
|
|
||||||
| Touch / pen | long-press |
|
|
||||||
|
|
||||||
The long-press is an **additional** route, never the only one — §6.2 forbids
|
|
||||||
press-and-hold as a sole path to a function, and it stays forbidden. Every
|
|
||||||
opening gesture is reachable at least two ways on every platform, and the
|
|
||||||
keyboard route exists everywhere. A long-press must show that it is charging
|
|
||||||
(a growing ring under the finger) so a user who holds too briefly learns why
|
|
||||||
nothing happened rather than concluding the product is broken (L5).
|
|
||||||
|
|
||||||
#### The row-constancy invariant
|
|
||||||
|
|
||||||
This is the rule that has to survive every future feature, so it is written as
|
|
||||||
an invariant rather than as advice:
|
|
||||||
|
|
||||||
> **Every verb has exactly one row index in the offer. That index is identical
|
|
||||||
> for every selection type in which the verb appears. Verbs that do not apply to
|
|
||||||
> the current selection are DISABLED IN PLACE, with their reason — the offer is
|
|
||||||
> never compacted, re-sorted or re-ordered. Adding a verb never changes the
|
|
||||||
> index of an existing one.**
|
|
||||||
|
|
||||||
Two consequences the group must accept together with the invariant:
|
|
||||||
|
|
||||||
- **No adaptive ordering. Ever.** Not most-used-first, not recently-used-first,
|
|
||||||
not per-selection frequency. An offer that rearranges itself to be helpful
|
|
||||||
destroys the only thing that made it fast, and it does so precisely for the
|
|
||||||
user who has just started to learn it. (Office 2000's adaptive menus are the
|
|
||||||
textbook case; they were removed.)
|
|
||||||
- **Greyed rows are the price, and they are cheap.** A compacted menu is shorter
|
|
||||||
and unlearnable. A constant one is a few rows longer, teaches while it waits,
|
|
||||||
and is memorised in a week.
|
|
||||||
|
|
||||||
#### The map — RATIFIED 2026-07-31
|
|
||||||
|
|
||||||
The invariant is not negotiable, and as of 2026-07-31 neither is the assignment:
|
|
||||||
the row order below is **ratified**. It was argued once; it is not argued again.
|
|
||||||
Changing an index from here on is a breaking change to every user's muscle
|
|
||||||
memory and needs the group, not a pull request (§9 q12).
|
|
||||||
|
|
||||||
Eight families, ordered so the sequence itself has a logic: material is created,
|
|
||||||
grows, is taken away, is refined, is repeated, is moved, is referred to, is
|
|
||||||
edited.
|
|
||||||
|
|
||||||
| Row | Family | On a face | On an edge | On a body | On text/art |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| **1** | Create | Sketch on it | — | — | Edit text |
|
|
||||||
| **2** | Add material | Extrude, thicken | — | Combine, thicken | Extrude |
|
|
||||||
| **3** | Remove | Hole, shell | Thread | Shell, cut, split | — |
|
|
||||||
| **4** | Dress-up | Draft | Fillet, chamfer | Fillet, chamfer | — |
|
|
||||||
| **5** | Repeat | Pattern | Pattern along it | Pattern, mirror | Pattern |
|
|
||||||
| **6** | Transform | Align to, mate | — | Move, mate | Move, size |
|
|
||||||
| **7** | Reference | Plane, axis, measure | Axis, measure | Project, measure, mass | — |
|
|
||||||
| **8** | Modify | Delete face, edit | — | Edit, colour, delete | Replace art |
|
|
||||||
|
|
||||||
A dash means the row is drawn greyed for that selection, with its reason.
|
|
||||||
|
|
||||||
The authoritative version of this table is **`docs/ux/tool_atlas.json`**, which
|
|
||||||
carries all 52 verbs with their preconditions and their refusal strings, taken
|
|
||||||
from the code rather than from memory. Every state it produces — 20 selection
|
|
||||||
kinds × 2 document states, 40 primary menus and 73 submenus — is rendered by
|
|
||||||
`docs/ux/mockups/gen_offer_mockups.py` into `docs/ux/offer_atlas.html`. Read the
|
|
||||||
atlas before proposing a change to the map; the generator refuses to render an
|
|
||||||
address collision, so the map cannot silently rot.
|
|
||||||
|
|
||||||
#### Rejected: the radial ring
|
|
||||||
|
|
||||||
The first design put the eight families at eight compass points around the pick.
|
|
||||||
It is recorded here because it is a good idea that loses on evidence, and
|
|
||||||
someone will propose it again:
|
|
||||||
|
|
||||||
- an inapplicable slot could only be drawn empty, and **an empty slot says
|
|
||||||
nothing** — the reason text above has nowhere to live;
|
|
||||||
- the measured fill was **3.45 of 8 slots**, so most of the control was blank
|
|
||||||
most of the time, and on a fresh document only two of eight were live;
|
|
||||||
- sketch-mode *Create* needs **nine** addresses; eight forced two primitives
|
|
||||||
behind a "More" slot, and a ninth position costs the 45° spacing that made the
|
|
||||||
ring worth having;
|
|
||||||
- long translated names do not fit around a circle, and screen readers and arrow
|
|
||||||
keys need bespoke handling a list gets for free;
|
|
||||||
- a 380 px disc over the model costs more on a 1366×768 screen than a 324 px
|
|
||||||
list beside it (§6.1).
|
|
||||||
|
|
||||||
What it kept — equidistant targets and a future flick gesture — buys little in a
|
|
||||||
product whose experts live on the keyboard by design.
|
|
||||||
|
|
||||||
### 4.2 Confirm and cancel are objects, not gestures
|
|
||||||
|
|
||||||
The old rule — click empty space to commit — is withdrawn. It was an invisible
|
|
||||||
gesture with a destructive meaning: nothing on screen said it, and a stray click
|
|
||||||
committed a feature the user was still adjusting. That is exactly what L5
|
|
||||||
forbids, and it is hostile to the audience §6.1 exists for.
|
|
||||||
|
|
||||||
- **A pending feature carries a confirm/cancel puck**, attached to the geometry
|
|
||||||
it is editing, next to its handles: ✓ commits, ✗ discards. Enter and Escape
|
|
||||||
mirror them for the keyboard (L9). It is drawn where the user's attention
|
|
||||||
already is, and it is the only thing in the viewport that commits.
|
|
||||||
- **Empty space now means "clear the selection"** — the safe meaning, and the
|
|
||||||
same meaning everywhere.
|
|
||||||
- **This is not a dialog** (L4). It is two objects in the scene, on the
|
|
||||||
geometry, non-modal: the camera still orbits, the tree is still there, the
|
|
||||||
values are still editable while it waits.
|
|
||||||
- **Continuous tools do not ask.** Drawing a line, a rectangle, a circle commits
|
|
||||||
each entity as its own gesture completes — a ✓ per line would destroy the
|
|
||||||
inner loop. The puck belongs to *features* (extrude, fillet, hole, pattern,
|
|
||||||
mate) and to sketch edits that hold a pending state. Enter/Escape end a
|
|
||||||
continuous tool rather than confirming an entity.
|
|
||||||
- **Ambiguity resolves toward keeping work, never toward losing it.** Starting
|
|
||||||
another operation while a valid feature is pending commits it rather than
|
|
||||||
discarding it; if it is not valid, the product says why (L7) and keeps it
|
|
||||||
pending. Since undo reaches everything (§6.1), the recoverable direction is
|
|
||||||
always the right default.
|
|
||||||
|
|
||||||
### 4.3 The rest of the grammar
|
|
||||||
|
|
||||||
- **The status line is one imperative sentence** naming what the tool wants
|
|
||||||
next, and it names the target when the target came from a selection
|
|
||||||
("Circle — click centre, then radius · on the picked face"). It is the
|
|
||||||
authoritative feedback surface for the armed tool; the toolbar is not.
|
|
||||||
- **Hover previews, click commits.** A hover shows the ghost of what a click
|
|
||||||
would do wherever this is cheap to compute.
|
|
||||||
- **Selection is persistent and visible** until consumed or cleared. A tool that
|
|
||||||
consumes a selection clears it, so the next feature cannot silently inherit it.
|
|
||||||
- **Every gesture is undoable**, and the feature tree is editable history, not a
|
|
||||||
log. Re-editing a feature re-enters the same on-geometry interaction that
|
|
||||||
created it — including its offer and its puck.
|
|
||||||
|
|
||||||
## 5. Layout and screen budget
|
|
||||||
|
|
||||||
The viewport is the application. Chrome is a tax on it.
|
|
||||||
|
|
||||||
- **One toolbar**, contextual to the mode (model / sketch). Tools are grouped by
|
|
||||||
what they make, not by which subsystem implements them.
|
|
||||||
- **A left rail for the document, not for parameters**: feature tree, bodies,
|
|
||||||
variables. It answers "what exists", never "what value should this be".
|
|
||||||
- **No parameter panel.** Where one exists today it is technical debt with a
|
|
||||||
scheduled removal (§10).
|
|
||||||
- **Print context is ambient**, not a panel: the plate is visible in the design
|
|
||||||
space, and print-domain warnings appear on the geometry that will fail.
|
|
||||||
- **Nothing is added to permanent chrome without removing something**, or
|
|
||||||
demonstrating that the addition is used in the majority of sessions.
|
|
||||||
- **The budget is set by the smallest screen we serve**, 1366×768 (§6.1) — not
|
|
||||||
by the reviewer's monitor. Chrome that fits a 27-inch display and swallows a
|
|
||||||
laptop's has not fitted, it has just failed somewhere the author cannot see.
|
|
||||||
|
|
||||||
## 6. Accessibility — reach first, then the assistive floor
|
|
||||||
|
|
||||||
"Accessible" means two different things and the product owes both. §6.1 is about
|
|
||||||
**who can get in at all**; §6.2 is about **who can operate it once inside**.
|
|
||||||
Neither is a phase. Both are merge requirements.
|
|
||||||
|
|
||||||
### 6.1 Reach — the door has to be open
|
|
||||||
|
|
||||||
The premise of the whole project: someone with no money, no licence, no account,
|
|
||||||
no fast machine and no teacher can open this and make a real thing. Free
|
|
||||||
software on a school laptop is the only path to a CAD tool that reaches people
|
|
||||||
who were never going to be handed one. If a design decision quietly raises the
|
|
||||||
cost of entry, it has broken the premise, however elegant it is.
|
|
||||||
|
|
||||||
- **The reference machine.** A 5-year-old laptop: dual/quad-core CPU,
|
|
||||||
**integrated graphics**, 8 GB RAM, **1366×768** screen, no discrete GPU. The
|
|
||||||
Design tab must be usable there, and any interaction that needs more is a
|
|
||||||
design failure to be solved, not a requirement to be documented. The GPU path
|
|
||||||
degrades gracefully to software rendering rather than refusing to start; the
|
|
||||||
viewport stays interactive while the kernel thinks.
|
|
||||||
- **1366×768 is the layout target, not the stretch case.** A form-heavy side
|
|
||||||
panel is not merely inelegant on that screen — it takes the model off it.
|
|
||||||
This is the second, independent argument for the whole of L1 and §5.
|
|
||||||
- **No account, no cloud, no connection.** The product works forever with the
|
|
||||||
network unplugged. Nothing is uploaded, no sign-in gates any feature, no
|
|
||||||
telemetry is required to use it. A school network that blocks everything must
|
|
||||||
not be able to block this.
|
|
||||||
- **No tier, no plugin wall, no "pro".** Every feature named in this document is
|
|
||||||
in the product everyone downloads. Assemblies and exploded views are not the
|
|
||||||
paid half.
|
|
||||||
- **Files belong to the user**, on their disk, in a format that outlives the
|
|
||||||
project: the design travels inside the ordinary project file, and the geometry
|
|
||||||
exports to STEP and mesh formats anyone can open.
|
|
||||||
- **Learnable without instruction.** The first solid comes with no
|
|
||||||
documentation, no video and no tutorial mode — from noticing that a face can
|
|
||||||
be clicked. Tooltips teach the vocabulary (L10) at the moment it is needed;
|
|
||||||
nothing is explained in a manual the user will never open.
|
|
||||||
- **Plain language at the entry tier.** The Make tier speaks in words a
|
|
||||||
thirteen-year-old reads without stopping. Precision comes with the tier that
|
|
||||||
needs it, and everything is translated, because "accessible" in English only
|
|
||||||
is not accessible.
|
|
||||||
- **Exploration must be free.** Undo reaches everything, work is never lost to a
|
|
||||||
wrong click, and no dialog ever asks the user to be sure. A tool that punishes
|
|
||||||
experiments teaches people to stop experimenting, which is the one thing this
|
|
||||||
audience cannot afford to learn.
|
|
||||||
- **The product never blames the user.** Failures are stated as what happened
|
|
||||||
and what to do (L7). "Invalid input" is not an acceptable sentence anywhere.
|
|
||||||
|
|
||||||
### 6.2 Assistive floor
|
|
||||||
|
|
||||||
- **Keyboard**: every operation reachable and completable without a pointer.
|
|
||||||
Single-letter shortcuts for sketch tools, shown in the offer itself (§4.1) as
|
|
||||||
well as in the tooltip. The offer opens from the keyboard (Menu key or
|
|
||||||
Shift+F10) and walks by arrow key and by type-ahead, so the row map works for
|
|
||||||
someone who never touches the pointer. A visible focus state on every
|
|
||||||
focusable element. No shortcut that only works while the pointer happens to be
|
|
||||||
over the canvas.
|
|
||||||
- **Colour**: never the sole carrier of meaning. Selection is colour *and*
|
|
||||||
outline; an error is colour *and* an icon *and* text. Verify in greyscale.
|
|
||||||
- **Contrast**: labels over the 3D viewport get a scrim or halo so 4.5:1 holds
|
|
||||||
against any background the model can produce, including a white body under a
|
|
||||||
white plate.
|
|
||||||
- **Targets**: handles and grips no smaller than 32 px at 100 % scale, scaling
|
|
||||||
with the OS factor; the grab tolerance is larger than the drawn glyph.
|
|
||||||
- **Timing**: no double-click-to-mean-something-else, no press-and-hold as the
|
|
||||||
only route to a function, no cycle that depends on repeated clicks
|
|
||||||
(see L5). The long-press that opens the offer on a one-button Mac and on touch
|
|
||||||
(§4.1) is explicitly an *additional* route — Ctrl-click, two-finger tap and
|
|
||||||
the keyboard all reach the same place — and it shows its own progress while
|
|
||||||
charging, so it never fails silently.
|
|
||||||
- **Motion**: animation is functional (showing where a thing went), never
|
|
||||||
decorative, and it respects the reduced-motion preference.
|
|
||||||
- **Text**: no fixed-width assumptions; the UI holds together in German and in
|
|
||||||
Chinese, at 125 % and 200 % scale. Every string routed through the normal
|
|
||||||
translation path.
|
|
||||||
|
|
||||||
## 7. Depth without clutter — the three tiers
|
|
||||||
|
|
||||||
Power for experts is delivered by **progressive disclosure of tools, never by
|
|
||||||
relocation of tools**. A tool that appears in a later tier is in the same place
|
|
||||||
it will always be; it is simply not shown yet.
|
|
||||||
|
|
||||||
| Tier | Who | What appears |
|
|
||||||
|---|---|---|
|
|
||||||
| **Make** | first hour | Sketch, extrude, revolve, hole, fillet/chamfer, move, commit to plate |
|
|
||||||
| **Model** | competent user | Patterns, shell, draft, sweep/loft, booleans, reference geometry, variables, import/export |
|
|
||||||
| **Mechanism** | mechanical designer | Assemblies and mates, exploded views, interference detection, surfaces, feature-level editing of imported solids |
|
|
||||||
|
|
||||||
Rules that keep this honest:
|
|
||||||
|
|
||||||
1. **Tiers are non-modal.** No mode switch, no workbench selector, no "advanced
|
|
||||||
mode" toggle that changes the meaning of anything. The tier only governs what
|
|
||||||
is *offered*.
|
|
||||||
2. **A tier reveals itself by use.** Using a body reveals boolean tools; adding
|
|
||||||
a second body reveals assembly tools. The product notices what you are doing.
|
|
||||||
3. **Nothing moves when a tier appears.** A user who learned where fillet lives
|
|
||||||
finds it in the same place forever.
|
|
||||||
4. **An expert tool obeys the same grammar** as a beginner tool. Mates are
|
|
||||||
picked in 3D like everything else, not configured in a table.
|
|
||||||
5. **Exploded views are a view state**, not a document mode — reversible,
|
|
||||||
draggable along mate axes, and never a separate file.
|
|
||||||
|
|
||||||
## 8. Designing for print — the home advantage
|
|
||||||
|
|
||||||
Design-time knowledge the application already has, and must use:
|
|
||||||
|
|
||||||
- **The plate is present** in the design space, at the real size, with the real
|
|
||||||
origin. Committing a body to the plate is one action and preserves placement.
|
|
||||||
- **Print-domain checks run on the model, on the geometry, before slicing**:
|
|
||||||
walls thinner than the nozzle, unsupported overhangs beyond the material's
|
|
||||||
angle, features smaller than the layer height, a part that does not fit the
|
|
||||||
build volume.
|
|
||||||
- **These are warnings on the geometry, never a report.** The thin wall glows;
|
|
||||||
the tooltip says how thin and what the nozzle is.
|
|
||||||
- **Material and machine context is inherited** from the active slicer profile,
|
|
||||||
not re-entered in the Design tab.
|
|
||||||
- **The round trip is preserved**: editing a design after slicing returns to the
|
|
||||||
feature history, not to a mesh.
|
|
||||||
|
|
||||||
## 9. The review gate
|
|
||||||
|
|
||||||
Every pull request that touches the Design tab UI answers these, in the PR body.
|
|
||||||
A "no" that is not accompanied by an argument is a request for changes.
|
|
||||||
|
|
||||||
1. Which law (L1–L11) does the change most directly serve?
|
|
||||||
2. Can the whole operation be completed without the pointer leaving the
|
|
||||||
viewport? If not, why is this the exception?
|
|
||||||
And: does the relevant selection *offer* this tool (§4.1), or must the user
|
|
||||||
already know it exists?
|
|
||||||
3. Are the values draggable *and* typable?
|
|
||||||
4. Screenshot of the state after **exactly one** click of the new gesture,
|
|
||||||
performed as a first-time user.
|
|
||||||
5. Keyboard-only walkthrough: does it complete?
|
|
||||||
6. Greyscale screenshot: is every state still distinguishable?
|
|
||||||
7. What was **removed**? (Net additions to permanent chrome require an argument.)
|
|
||||||
8. Which tier does it belong to, and does it appear without moving anything else?
|
|
||||||
9. What does it do when the geometry is invalid, and is that reported before the
|
|
||||||
commit?
|
|
||||||
10. Interaction cost: actions required for the canonical task it addresses,
|
|
||||||
before and after.
|
|
||||||
11. Reach (L11): screenshot at 1366×768 with the panel open — is the model still
|
|
||||||
on screen? Does it run on integrated graphics? Does it need the network, an
|
|
||||||
account, or a file the user cannot keep?
|
|
||||||
12. If the change adds or moves a verb in the offer: which row, and is it that
|
|
||||||
verb's row in **every** selection where it appears? Did any existing verb's
|
|
||||||
index change? (If yes, this is not a UI change, it is a breaking change to
|
|
||||||
every user's muscle memory, and it needs the group — see §4.1.) Was
|
|
||||||
`docs/ux/tool_atlas.json` updated and the atlas regenerated?
|
|
||||||
13. If the change adds a pointer gesture: what is its keyboard equivalent, and
|
|
||||||
what does a one-button Mac, a trackpad and a touch screen do (§4.1)?
|
|
||||||
|
|
||||||
## 10. Where we stand today — honest inventory
|
|
||||||
|
|
||||||
Complying with the laws already:
|
|
||||||
|
|
||||||
- Sketch inline editors — draw an entity and its dimension tab opens on the
|
|
||||||
geometry; Tab walks Length → Width → Angle.
|
|
||||||
- Fillet/chamfer draggable radius arrow with an editable value label.
|
|
||||||
- Extrude depth arrow; move-body three-axis arrows.
|
|
||||||
- Datum-plane resize handles and offset arrow; ghost reference planes picked in
|
|
||||||
3D.
|
|
||||||
- Imported-art place/size gizmo.
|
|
||||||
- Sketch plane taken from the picked face, with the target named in the status
|
|
||||||
line, and the sketch-plane dropdown deleted outright.
|
|
||||||
|
|
||||||
Violating them, with removal scheduled:
|
|
||||||
|
|
||||||
- **Every tool card is a two-column form** of combos and spin fields in the left
|
|
||||||
panel. This is the single largest debt in the product and the reason this
|
|
||||||
document exists. Tracked as an epic; each card is replaced by its on-geometry
|
|
||||||
equivalent, not improved in place. It fails L1 and it fails L11 twice over —
|
|
||||||
on a 1366×768 screen the cards leave the model a strip.
|
|
||||||
- Seven remaining plane pickers still populate a combo instead of consuming a
|
|
||||||
viewport selection.
|
|
||||||
- Pattern has no on-geometry spacing arrow or count badge.
|
|
||||||
- Hole is positioned by X/Y fields rather than by a point on a face.
|
|
||||||
- Booleans and cuts pick their operands from lists rather than in 3D.
|
|
||||||
- Fillet/chamfer edge selection still requires the click cycle L5 forbids.
|
|
||||||
- **Selecting geometry offers nothing.** There is no contextual offer (§4.1):
|
|
||||||
the user faces the full toolbar whatever they have picked, and finds out that
|
|
||||||
a tool did not apply by it doing nothing. This is the largest single item of
|
|
||||||
new work the charter asks for. The map and every state of it are already
|
|
||||||
drawn (`docs/ux/offer_atlas.html`); what the group owes itself before the code
|
|
||||||
is ratifying the row order, since every verb built before that lands has to be
|
|
||||||
addressed afterwards anyway.
|
|
||||||
- **Committing is an invisible click in empty space** rather than the
|
|
||||||
confirm/cancel puck of §4.2 — the exact gesture that rule withdraws.
|
|
||||||
|
|
||||||
Nothing on the violating list is defended. The only open question for each is
|
|
||||||
what its on-geometry replacement should be.
|
|
||||||
|
|
||||||
## 11. How the group works
|
|
||||||
|
|
||||||
**Roles.** Product/UX lead (owns this document and casts the tie-break vote on
|
|
||||||
interaction questions); kernel maintainer; GUI maintainer; a print-domain
|
|
||||||
reviewer; a mechanical-design reviewer who uses the product on real work; an
|
|
||||||
accessibility reviewer covering both senses of §6 — reach and assistive — who
|
|
||||||
owns the reference machine and actually runs on it. One person may hold more
|
|
||||||
than one role; the UX lead and the mechanical-design reviewer should not be the
|
|
||||||
same person, and nobody reviews reach from a workstation.
|
|
||||||
|
|
||||||
**The absent audience needs a seat.** The fourteen-year-old is not in the room
|
|
||||||
and cannot file an issue. Someone in the group is accountable for B5 and B6, and
|
|
||||||
the group watches real first-timers use the product on the reference machine at
|
|
||||||
least once a quarter — school, makerspace, or a friend's kid. Everything else in
|
|
||||||
this document can be argued from principle; approachability can only be
|
|
||||||
observed.
|
|
||||||
|
|
||||||
**Cadence.** A short weekly review of open interaction proposals. A monthly pass
|
|
||||||
over the violating inventory in §10 — anything that has not moved in two months
|
|
||||||
is either scheduled or explicitly accepted as permanent, with a reason written
|
|
||||||
into this document.
|
|
||||||
|
|
||||||
**How a change moves.**
|
|
||||||
|
|
||||||
1. *Problem* — a described user difficulty, ideally with an interaction-cost
|
|
||||||
measurement, never a solution in disguise.
|
|
||||||
2. *Sketch* — one or two on-geometry interaction proposals, drawn or described
|
|
||||||
as a gesture sequence. Reviewed against §3 before any code.
|
|
||||||
3. *Prototype* — built behind whatever the smallest safe path is, driven end to
|
|
||||||
end on a real display, and screenshotted at each state.
|
|
||||||
4. *Gate* — §9 answered in the PR.
|
|
||||||
5. *Merge*, then update §10.
|
|
||||||
|
|
||||||
**Decisions are written down.** Any resolution that constrains future work is
|
|
||||||
appended to this document as a numbered law or as an accepted exception with its
|
|
||||||
reasoning. A decision that lives only in a call is not a decision.
|
|
||||||
|
|
||||||
**How disagreements resolve.** Against the laws first. If the laws do not decide
|
|
||||||
it, the tie-break is the interaction cost measured on the canonical tasks in
|
|
||||||
§12; if that does not decide it, the UX lead chooses and records why.
|
|
||||||
|
|
||||||
## 12. Canonical tasks — the benchmark
|
|
||||||
|
|
||||||
The measure of every UX change is the cost of these five tasks. Each is timed and
|
|
||||||
counted (clicks, keystrokes, camera actions, mode switches) on the headless rig
|
|
||||||
and, periodically, with real users who have not seen the product.
|
|
||||||
|
|
||||||
| # | Task | What it exercises |
|
|
||||||
|---|---|---|
|
|
||||||
| **B1** | Bracket: sketch an L, extrude, two holes, fillet the inside corner, send to plate | The inner loop |
|
|
||||||
| **B2** | Change a hole diameter and the plate thickness, six features deep, and rebuild | Parametric editability |
|
|
||||||
| **B3** | Take an imported STEP, delete a boss, close the face, thicken a wall to nozzle width | Direct editing + print awareness |
|
|
||||||
| **B4** | Two parts, one revolute mate, check interference, produce an exploded view | The Mechanism tier |
|
|
||||||
| **B5** | First-run: from opening the Design tab to a print-ready solid, no documentation | Approachability |
|
|
||||||
| **B6** | B1 again, on the reference machine at 1366×768, offline, on a fresh account-less install | Reach (L11) |
|
|
||||||
|
|
||||||
Every task is run on the reference machine of §6.1, not on a workstation — a
|
|
||||||
number measured on a fast desktop describes an experience most of our users will
|
|
||||||
never have. B6 repeats the inner loop under the full entry conditions so that
|
|
||||||
reach is a measured quantity and not an intention.
|
|
||||||
|
|
||||||
Targets are set once each task has been measured on the current build. B5's
|
|
||||||
target is expressed in minutes-to-first-solid **by someone who has never seen a
|
|
||||||
CAD program**, and it is the number this project is ultimately judged by.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Appendix — anti-patterns we have already paid for
|
|
||||||
|
|
||||||
Kept because each cost real time and each is easy to reintroduce.
|
|
||||||
|
|
||||||
- **The dropdown that grew a row.** Fixing "cannot sketch on a face" by adding a
|
|
||||||
"Face of Body 1" entry to a plane combo. It reads as a small fix and it is the
|
|
||||||
side-panel architecture reproducing itself.
|
|
||||||
- **The invisible first click.** Flyout buttons and pick cycles whose first click
|
|
||||||
changes nothing meaningful. Filed as bugs three separate times against working
|
|
||||||
code, and made a real bug look fixed when it was not.
|
|
||||||
- **The fix verified through a path the user will never take.** A face-sketch fix
|
|
||||||
confirmed by double-clicking to reach face level. Users click once. A fix
|
|
||||||
reachable only by an undiscoverable gesture is indistinguishable from no fix.
|
|
||||||
- **The wrong feedback surface.** Measuring an armed tool by the toolbar, which
|
|
||||||
never renders keyboard-armed state. The status line is the surface that
|
|
||||||
answers.
|
|
||||||
- **The silent success.** A cut that removed no material, reported as done. Now
|
|
||||||
an error naming the likely cause.
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# BearConnector.step — examination
|
|
||||||
|
|
||||||
> **Scope.** One file was supplied and it contains **one object: the male.** Everything below is
|
|
||||||
> measured from that single solid. Earlier drafts of this note reasoned about a female pocket and a
|
|
||||||
> mating pair — those objects were never supplied, so any statement about them was speculation and
|
|
||||||
> has been removed. The clearance, the fit, and the pocket's legibility are all **unassessed**.
|
|
||||||
|
|
||||||
Measured, not eyeballed. Imported into the Design tab's own OpenCascade kernel
|
|
||||||
(`import_step` → one valid closed solid), topology queried, geometry checked numerically.
|
|
||||||
Flat drawing: `artifacts/shots/bear-flat.png`. Viewport: `artifacts/shots/bear-02-zoom.png`.
|
|
||||||
|
|
||||||
**File:** AP242 Edition 2, ST-Developer. 1 `MANIFOLD_SOLID_BREP`, 1 `CLOSED_SHELL`.
|
|
||||||
**Size:** 83.06 × 66.69 × 17.27 mm. **Faces:** 30 — 24 planar + 6 cylindrical.
|
|
||||||
**Curves:** 69 lines + 12 circles. **No** splines, spheres, tori or cones.
|
|
||||||
**Relief:** only four Z levels — 0, 3.00, 10.66, 17.27.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What is right, and precisely so
|
|
||||||
|
|
||||||
**The sloping ridge is implemented exactly as briefed.** From (0.00, 18.40, 17.27) to
|
|
||||||
(0.00, 46.72, 10.66): 28.3 mm long, 6.61 mm drop, **13.1° slope**, and both ends sit dead on
|
|
||||||
x = 0.00. It breaks 180° rotation on its own.
|
|
||||||
|
|
||||||
**20.0° uniform draft on all four snout flanks**, identical to within 0.1°:
|
|
||||||
`(0,−0.94,0.342) (0.936,0.08,0.342) (0,0.94,0.342) (−0.936,0.08,0.342)`. That is a real,
|
|
||||||
deliberate lead-in — it self-centres into a matching pocket, and it demoulds and prints.
|
|
||||||
|
|
||||||
**The eyes are exactly symmetric**: Ø9.87 at x = ±16.43, y = 48.01, matching to 0.01 mm.
|
|
||||||
Someone mirrored those on purpose.
|
|
||||||
|
|
||||||
**The mating feature is extremely economical**: only **five edges** exist above the 3 mm plate —
|
|
||||||
the ridge plus two flank edges at each end. Base plate is exactly 3.00 mm.
|
|
||||||
|
|
||||||
The low-poly constraint is honoured. All six cylinders are outline rounds and eye holes; none of
|
|
||||||
them is a mating surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The asymmetry is deliberate, and it is complete
|
|
||||||
|
|
||||||
**Correction.** A first pass read the left/right differences as an unfinished mirror. That was wrong:
|
|
||||||
the asymmetry is intentional. Tested properly — every candidate self-symmetry, in the part's own
|
|
||||||
centred frame, with a generous 0.1 mm tolerance:
|
|
||||||
|
|
||||||
| operation | edges mapped onto the part |
|
|
||||||
|---|---|
|
|
||||||
| identity | 81 / 81 — 100 % |
|
|
||||||
| mirror about x = 0 (left/right) | **0 / 81** |
|
|
||||||
| mirror about y = 0 (top/bottom) | **0 / 81** |
|
|
||||||
| rotate 180° about Z | **0 / 81** |
|
|
||||||
| rotate 90° about Z | **0 / 81** |
|
|
||||||
| mirror about the diagonal | **0 / 81** |
|
|
||||||
|
|
||||||
**The symmetry group is trivial.** No rigid motion or reflection maps this part onto itself, so
|
|
||||||
**every partial view determines the orientation uniquely** — you never need to see the whole face to
|
|
||||||
know which way round it goes. That is the strongest possible result for a keying interface and it is
|
|
||||||
exactly what the earlier abstract glyph work kept failing to achieve: a symmetric shape seen at a
|
|
||||||
grazing angle, or half-occluded, gives an ambiguous read.
|
|
||||||
|
|
||||||
### Does it let you GRASP the orientation? Measured, not asserted.
|
|
||||||
|
|
||||||
Unique-in-principle and graspable-at-a-glance are different claims. The symmetry table proves the
|
|
||||||
first. For the second, the front-on picture (outline + eyes + mouth, filled) was rasterised and
|
|
||||||
compared against its own mirror and its own 180° rotation — the two ways a person can get it wrong.
|
|
||||||
|
|
||||||
**By size** (percentage of pixels that differ):
|
|
||||||
|
|
||||||
| width | vs mirror | vs rotated 180° |
|
|
||||||
|---|---|---|
|
|
||||||
| 16 px | 20.7 % | 26.0 % |
|
|
||||||
| 24 px | 21.9 % | 30.9 % |
|
|
||||||
| 32 px | 23.0 % | 28.1 % |
|
|
||||||
| 48 px | 22.4 % | 30.6 % |
|
|
||||||
| 80 px | 24.7 % | 31.0 % |
|
|
||||||
| 160 px | 23.6 % | 31.0 % |
|
|
||||||
|
|
||||||
**The curve is flat.** The full signal is already there at 16 pixels and more resolution adds
|
|
||||||
nothing. That is the whole result: **the orientation cue lives at low spatial frequency**, carried by
|
|
||||||
the overall shape rather than by any detail. It therefore survives distance, blur, poor light,
|
|
||||||
peripheral vision, a small print and a low-resolution screen. It is the exact opposite of the abstract
|
|
||||||
disc glyph, whose roll cue was a small high-frequency feature and died at a grazing angle.
|
|
||||||
|
|
||||||
**Partial views — a claim I made and then withdrew.** I ran a masked-window test and concluded that
|
|
||||||
a single quarter of the face was enough to read the orientation. **That test was invalid and the
|
|
||||||
conclusion is wrong.** It compared a window of the original against *the same window* of the mirrored
|
|
||||||
and rotated versions — which silently hands the observer the registration. It assumes you already
|
|
||||||
know that the patch you are looking at is the top-left quarter, which is exactly the thing you would
|
|
||||||
not know if you could only see a quarter.
|
|
||||||
|
|
||||||
**You need to see the whole face.** The cues here are *relational*: the big ear only means something
|
|
||||||
next to the small ear, and the mouth offset only means something relative to the centreline. None of
|
|
||||||
them is self-locating. Whole-face is the operating condition, and the design should be judged and
|
|
||||||
used on that basis.
|
|
||||||
|
|
||||||
That does not weaken the size result above, which always used the complete silhouette: the whole face
|
|
||||||
reads at 16 px. Needing all of it, and needing very little resolution of it, are compatible — and for
|
|
||||||
a part held in a hand, seeing all of it is the normal case.
|
|
||||||
|
|
||||||
**The signal is allocated to the right risks.** The strongest cue (up to 41.7 %) guards against
|
|
||||||
inserting it upside down — the mistake people actually make. The weakest (~23 %) guards the mirror
|
|
||||||
case, which needs the part flipped over and which the protrusion already prevents mechanically.
|
|
||||||
|
|
||||||
It also does mechanical work beyond the ridge. The ridge alone breaks 180° rotation; the asymmetric
|
|
||||||
outline additionally defeats the **mirrored-part** case — a mirror-image copy will not fit, so a
|
|
||||||
modelling or printing mirror is caught at assembly rather than three steps later.
|
|
||||||
|
|
||||||
And for children specifically, a symmetric cartoon face reads as a mask; illustrators asymmetrise
|
|
||||||
deliberately so a face reads as a *character*. The asymmetry is earning its keep three ways at once.
|
|
||||||
|
|
||||||
### What is worth keeping in mind anyway
|
|
||||||
|
|
||||||
**The ears differ by 42 %** — left 8.33 mm wide (top y 65.68), right 11.81 mm (top y 66.69). Both
|
|
||||||
start at the same y = 60.79, so they read as a deliberate pair rather than an error. 42 % is well
|
|
||||||
above the perceptual threshold: you see it instantly. Good cue.
|
|
||||||
|
|
||||||
**The mouth is a smirk** — x −21.93 … 0.00, centred at x = −10.96, stopping on the centreline. A
|
|
||||||
classic character device and a strong asymmetry.
|
|
||||||
|
|
||||||
**The rounds are the best cue and the one safety question.** All four are on the left — Ø11.71 at
|
|
||||||
(−40.82, 7.38), Ø11.71 at (−34.76, 0.58), Ø10.00 at (−29.85, 60.83), Ø2.90 at (−26.70, 65.95) — and
|
|
||||||
the right side is entirely sharp. This is the *most locally readable* cue in the design: the ears
|
|
||||||
differ only by comparison (you must see both to know which is which), whereas a rounded corner tells
|
|
||||||
you "this is the left" from that corner alone, by eye **or by fingertip**. For children assembling by
|
|
||||||
feel that is the cue doing the real work.
|
|
||||||
|
|
||||||
The tension is that "sharp" on a children's part is a hazard, and the obvious safety fix — round
|
|
||||||
everything — destroys the cue. The resolution is not round-vs-sharp but **large-vs-small radius**:
|
|
||||||
keep R≈6 on the left and give the right R≈1. R1 still reads and feels sharp locally, so the cue
|
|
||||||
survives, and the actual edge hazard goes away. That is the one recommendation that outlives the
|
|
||||||
correction.
|
|
||||||
|
|
||||||
**One measurement that does not fit the story:** the outline is off-centre by **0.54 mm** (left reach
|
|
||||||
40.99, right reach 42.07). A deliberate cue should be unmissable; 0.54 mm is invisible. It is
|
|
||||||
probably a by-product of the other features rather than intent — worth a look, not a defect.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Two judgement calls, not defects
|
|
||||||
|
|
||||||
**The snout is highest at the nose tip and slopes down toward the brow** — a real bear's muzzle
|
|
||||||
does the opposite. Anatomically it reads more like a beak or a horn than a snout. But mechanically
|
|
||||||
it is the better choice: the nose tip enters the pocket first and does the finding. Keep it if the
|
|
||||||
lead-in matters more than the likeness; flip it if "it must look like a bear" wins.
|
|
||||||
|
|
||||||
**Only the male was supplied**, so the clearance, the fit and the pocket are unassessed. Nothing in
|
|
||||||
this note should be read as a judgement on them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The strategic point, which is the real reason this design is good
|
|
||||||
|
|
||||||
It gives orientation **a name**. "Ears up, nose down" needs no legend, no convention and no
|
|
||||||
documentation. Face recognition is the most robust pattern-matching humans have: it survives low
|
|
||||||
resolution, poor light, partial occlusion and peripheral vision. That is exactly the robustness the
|
|
||||||
abstract ridge key was reaching for, and here it comes for free.
|
|
||||||
|
|
||||||
**One earlier objection does not transfer — noting it only so it is not carried over by mistake.**
|
|
||||||
In §8c of the design doc a female *pocket* measured as visually invisible — flat-shaded, a recess
|
|
||||||
reads as a blank rectangle — and I concluded male/female
|
|
||||||
is the wrong polarity cue. **That was a viewport finding, and it does not apply to a physical part.**
|
|
||||||
Nobody looks into the pocket of a toy; they feel it. For a part in a child's hands, male/female is
|
|
||||||
exactly the right polarity language. The earlier conclusion stands for the on-screen glyph and must
|
|
||||||
not be carried over to this.
|
|
||||||
|
|
||||||
**The one rule to write down now:** the face and the key must never be allowed to disagree. People
|
|
||||||
will trust the face over the mechanics every time. Here they agree — ridge on the centreline, ears
|
|
||||||
up. If the face is ever restyled independently of the key, a user will orient by the bear and be
|
|
||||||
wrong. Tie them permanently, in the model and in whatever generates it.
|
|
||||||
@@ -1,998 +0,0 @@
|
|||||||
ISO-10303-21;
|
|
||||||
HEADER;
|
|
||||||
FILE_DESCRIPTION(('FreeCAD Model'),'2;1');
|
|
||||||
FILE_NAME('Open CASCADE Shape Model','2026-08-05T12:46:26',('FreeCAD'),(
|
|
||||||
'FreeCAD'),'Open CASCADE STEP processor 7.8','FreeCAD','Unknown');
|
|
||||||
FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));
|
|
||||||
ENDSEC;
|
|
||||||
DATA;
|
|
||||||
#1 = APPLICATION_PROTOCOL_DEFINITION('international standard',
|
|
||||||
'automotive_design',2000,#2);
|
|
||||||
#2 = APPLICATION_CONTEXT(
|
|
||||||
'core data for automotive mechanical design processes');
|
|
||||||
#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10);
|
|
||||||
#4 = PRODUCT_DEFINITION_SHAPE('','',#5);
|
|
||||||
#5 = PRODUCT_DEFINITION('design','',#6,#9);
|
|
||||||
#6 = PRODUCT_DEFINITION_FORMATION('','',#7);
|
|
||||||
#7 = PRODUCT('Open CASCADE STEP translator 7.8 1',
|
|
||||||
'Open CASCADE STEP translator 7.8 1','',(#8));
|
|
||||||
#8 = PRODUCT_CONTEXT('',#2,'mechanical');
|
|
||||||
#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design');
|
|
||||||
#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#958);
|
|
||||||
#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14);
|
|
||||||
#12 = CARTESIAN_POINT('',(0.,0.,0.));
|
|
||||||
#13 = DIRECTION('',(0.,0.,1.));
|
|
||||||
#14 = DIRECTION('',(1.,0.,-0.));
|
|
||||||
#15 = MANIFOLD_SOLID_BREP('',#16);
|
|
||||||
#16 = CLOSED_SHELL('',(#17,#229,#260,#497,#514,#531,#548,#565,#582,#599,
|
|
||||||
#616,#633,#650,#667,#684,#701,#718,#735,#747,#770,#794,#810,#822,
|
|
||||||
#839,#856,#878,#895,#912,#929,#946));
|
|
||||||
#17 = ADVANCED_FACE('',(#18,#68,#79,#213),#224,.F.);
|
|
||||||
#18 = FACE_BOUND('',#19,.F.);
|
|
||||||
#19 = EDGE_LOOP('',(#20,#30,#38,#46,#54,#62));
|
|
||||||
#20 = ORIENTED_EDGE('',*,*,#21,.F.);
|
|
||||||
#21 = EDGE_CURVE('',#22,#24,#26,.T.);
|
|
||||||
#22 = VERTEX_POINT('',#23);
|
|
||||||
#23 = CARTESIAN_POINT('',(19.029295926024,-0.2,-17.63009960955));
|
|
||||||
#24 = VERTEX_POINT('',#25);
|
|
||||||
#25 = CARTESIAN_POINT('',(16.626582997737,-0.2,-8.940188245231));
|
|
||||||
#26 = LINE('',#27,#28);
|
|
||||||
#27 = CARTESIAN_POINT('',(19.849519003668,-0.2,-20.59660707692));
|
|
||||||
#28 = VECTOR('',#29,1.);
|
|
||||||
#29 = DIRECTION('',(-0.26649542889,0.,0.963836182336));
|
|
||||||
#30 = ORIENTED_EDGE('',*,*,#31,.F.);
|
|
||||||
#31 = EDGE_CURVE('',#32,#22,#34,.T.);
|
|
||||||
#32 = VERTEX_POINT('',#33);
|
|
||||||
#33 = CARTESIAN_POINT('',(22.059435554995,-0.2,-3.734519760785));
|
|
||||||
#34 = LINE('',#35,#36);
|
|
||||||
#35 = CARTESIAN_POINT('',(17.698510515043,-0.2,-23.73280021221));
|
|
||||||
#36 = VECTOR('',#37,1.);
|
|
||||||
#37 = DIRECTION('',(-0.213058124893,0.,-0.977039526026));
|
|
||||||
#38 = ORIENTED_EDGE('',*,*,#39,.F.);
|
|
||||||
#39 = EDGE_CURVE('',#40,#32,#42,.T.);
|
|
||||||
#40 = VERTEX_POINT('',#41);
|
|
||||||
#41 = CARTESIAN_POINT('',(-21.72552223146,-0.2,-3.734519760785));
|
|
||||||
#42 = LINE('',#43,#44);
|
|
||||||
#43 = CARTESIAN_POINT('',(0.297084840953,-0.2,-3.734519760785));
|
|
||||||
#44 = VECTOR('',#45,1.);
|
|
||||||
#45 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#46 = ORIENTED_EDGE('',*,*,#47,.F.);
|
|
||||||
#47 = EDGE_CURVE('',#48,#40,#50,.T.);
|
|
||||||
#48 = VERTEX_POINT('',#49);
|
|
||||||
#49 = CARTESIAN_POINT('',(-21.72552223146,-0.2,-8.903751135252));
|
|
||||||
#50 = LINE('',#51,#52);
|
|
||||||
#51 = CARTESIAN_POINT('',(-21.72552223146,-0.2,-19.83215600037));
|
|
||||||
#52 = VECTOR('',#53,1.);
|
|
||||||
#53 = DIRECTION('',(0.,0.,1.));
|
|
||||||
#54 = ORIENTED_EDGE('',*,*,#55,.F.);
|
|
||||||
#55 = EDGE_CURVE('',#56,#48,#58,.T.);
|
|
||||||
#56 = VERTEX_POINT('',#57);
|
|
||||||
#57 = CARTESIAN_POINT('',(4.383041634064E-04,-0.2,-8.903751615529));
|
|
||||||
#58 = LINE('',#59,#60);
|
|
||||||
#59 = CARTESIAN_POINT('',(-5.279852300138,-0.2,-8.903751135252));
|
|
||||||
#60 = VECTOR('',#61,1.);
|
|
||||||
#61 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#62 = ORIENTED_EDGE('',*,*,#63,.F.);
|
|
||||||
#63 = EDGE_CURVE('',#24,#56,#64,.T.);
|
|
||||||
#64 = LINE('',#65,#66);
|
|
||||||
#65 = CARTESIAN_POINT('',(4.347099726942,-0.2,-8.913277437397));
|
|
||||||
#66 = VECTOR('',#67,1.);
|
|
||||||
#67 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03));
|
|
||||||
#68 = FACE_BOUND('',#69,.F.);
|
|
||||||
#69 = EDGE_LOOP('',(#70));
|
|
||||||
#70 = ORIENTED_EDGE('',*,*,#71,.F.);
|
|
||||||
#71 = EDGE_CURVE('',#72,#72,#74,.T.);
|
|
||||||
#72 = VERTEX_POINT('',#73);
|
|
||||||
#73 = CARTESIAN_POINT('',(21.163799345768,-0.2,-48.00951684793));
|
|
||||||
#74 = CIRCLE('',#75,4.735522705283);
|
|
||||||
#75 = AXIS2_PLACEMENT_3D('',#76,#77,#78);
|
|
||||||
#76 = CARTESIAN_POINT('',(16.428276640485,-0.2,-48.00951684793));
|
|
||||||
#77 = DIRECTION('',(-0.,1.,0.));
|
|
||||||
#78 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#79 = FACE_BOUND('',#80,.F.);
|
|
||||||
#80 = EDGE_LOOP('',(#81,#91,#100,#108,#116,#125,#133,#142,#150,#158,#166
|
|
||||||
,#174,#182,#190,#198,#206));
|
|
||||||
#81 = ORIENTED_EDGE('',*,*,#82,.T.);
|
|
||||||
#82 = EDGE_CURVE('',#83,#85,#87,.T.);
|
|
||||||
#83 = VERTEX_POINT('',#84);
|
|
||||||
#84 = CARTESIAN_POINT('',(-27.86158468659,-0.2,-65.78852163128));
|
|
||||||
#85 = VERTEX_POINT('',#86);
|
|
||||||
#86 = CARTESIAN_POINT('',(-29.08766684168,-0.2,-64.52156932717));
|
|
||||||
#87 = LINE('',#88,#89);
|
|
||||||
#88 = CARTESIAN_POINT('',(-29.44004200272,-0.2,-64.15744811364));
|
|
||||||
#89 = VECTOR('',#90,1.);
|
|
||||||
#90 = DIRECTION('',(-0.695421216677,0.,0.718602345805));
|
|
||||||
#91 = ORIENTED_EDGE('',*,*,#92,.F.);
|
|
||||||
#92 = EDGE_CURVE('',#93,#85,#95,.T.);
|
|
||||||
#93 = VERTEX_POINT('',#94);
|
|
||||||
#94 = CARTESIAN_POINT('',(-28.96712497021,-0.2,-57.16864680227));
|
|
||||||
#95 = CIRCLE('',#96,5.2);
|
|
||||||
#96 = AXIS2_PLACEMENT_3D('',#97,#98,#99);
|
|
||||||
#97 = CARTESIAN_POINT('',(-25.35093464349,-0.2,-60.90537900045));
|
|
||||||
#98 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#99 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#100 = ORIENTED_EDGE('',*,*,#101,.T.);
|
|
||||||
#101 = EDGE_CURVE('',#93,#102,#104,.T.);
|
|
||||||
#102 = VERTEX_POINT('',#103);
|
|
||||||
#103 = CARTESIAN_POINT('',(-26.93875323652,-0.2,-55.20570756731));
|
|
||||||
#104 = LINE('',#105,#106);
|
|
||||||
#105 = CARTESIAN_POINT('',(-14.90185526362,-0.2,-43.55710346492));
|
|
||||||
#106 = VECTOR('',#107,1.);
|
|
||||||
#107 = DIRECTION('',(0.718602345805,0.,0.695421216677));
|
|
||||||
#108 = ORIENTED_EDGE('',*,*,#109,.T.);
|
|
||||||
#109 = EDGE_CURVE('',#102,#110,#112,.T.);
|
|
||||||
#110 = VERTEX_POINT('',#111);
|
|
||||||
#111 = CARTESIAN_POINT('',(-41.17904151244,-0.2,-10.52828909594));
|
|
||||||
#112 = LINE('',#113,#114);
|
|
||||||
#113 = CARTESIAN_POINT('',(-32.39120436163,-0.2,-38.09921113329));
|
|
||||||
#114 = VECTOR('',#115,1.);
|
|
||||||
#115 = DIRECTION('',(-0.30368282823,0.,0.952773183837));
|
|
||||||
#116 = ORIENTED_EDGE('',*,*,#117,.F.);
|
|
||||||
#117 = EDGE_CURVE('',#118,#110,#120,.T.);
|
|
||||||
#118 = VERTEX_POINT('',#119);
|
|
||||||
#119 = CARTESIAN_POINT('',(-39.69176606491,-0.2,-4.408923352436));
|
|
||||||
#120 = CIRCLE('',#121,6.054044962965);
|
|
||||||
#121 = AXIS2_PLACEMENT_3D('',#122,#123,#124);
|
|
||||||
#122 = CARTESIAN_POINT('',(-35.41090981799,-0.2,-8.689779599357));
|
|
||||||
#123 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#124 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#125 = ORIENTED_EDGE('',*,*,#126,.T.);
|
|
||||||
#126 = EDGE_CURVE('',#118,#127,#129,.T.);
|
|
||||||
#127 = VERTEX_POINT('',#128);
|
|
||||||
#128 = CARTESIAN_POINT('',(-36.85603142851,-0.2,-1.573188716044));
|
|
||||||
#129 = LINE('',#130,#131);
|
|
||||||
#130 = CARTESIAN_POINT('',(-36.19319006079,-0.2,-0.910347348321));
|
|
||||||
#131 = VECTOR('',#132,1.);
|
|
||||||
#132 = DIRECTION('',(0.707106781187,0.,0.707106781187));
|
|
||||||
#133 = ORIENTED_EDGE('',*,*,#134,.F.);
|
|
||||||
#134 = EDGE_CURVE('',#135,#127,#137,.T.);
|
|
||||||
#135 = VERTEX_POINT('',#136);
|
|
||||||
#136 = CARTESIAN_POINT('',(-32.57517518159,-0.2,0.2));
|
|
||||||
#137 = CIRCLE('',#138,6.054044962965);
|
|
||||||
#138 = AXIS2_PLACEMENT_3D('',#139,#140,#141);
|
|
||||||
#139 = CARTESIAN_POINT('',(-32.57517518159,-0.2,-5.854044962965));
|
|
||||||
#140 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#141 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#142 = ORIENTED_EDGE('',*,*,#143,.T.);
|
|
||||||
#143 = EDGE_CURVE('',#135,#144,#146,.T.);
|
|
||||||
#144 = VERTEX_POINT('',#145);
|
|
||||||
#145 = CARTESIAN_POINT('',(35.082842712475,-0.2,0.2));
|
|
||||||
#146 = LINE('',#147,#148);
|
|
||||||
#147 = CARTESIAN_POINT('',(-7.942265537672,-0.2,0.2));
|
|
||||||
#148 = VECTOR('',#149,1.);
|
|
||||||
#149 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#150 = ORIENTED_EDGE('',*,*,#151,.F.);
|
|
||||||
#151 = EDGE_CURVE('',#152,#144,#154,.T.);
|
|
||||||
#152 = VERTEX_POINT('',#153);
|
|
||||||
#153 = CARTESIAN_POINT('',(42.298608189024,-0.2,-7.015765476549));
|
|
||||||
#154 = LINE('',#155,#156);
|
|
||||||
#155 = CARTESIAN_POINT('',(36.596246576251,-0.2,-1.313403863776));
|
|
||||||
#156 = VECTOR('',#157,1.);
|
|
||||||
#157 = DIRECTION('',(-0.707106781187,0.,0.707106781187));
|
|
||||||
#158 = ORIENTED_EDGE('',*,*,#159,.F.);
|
|
||||||
#159 = EDGE_CURVE('',#160,#152,#162,.T.);
|
|
||||||
#160 = VERTEX_POINT('',#161);
|
|
||||||
#161 = CARTESIAN_POINT('',(26.938753236523,-0.2,-55.20570756731));
|
|
||||||
#162 = LINE('',#163,#164);
|
|
||||||
#163 = CARTESIAN_POINT('',(32.699020781567,-0.2,-37.13346923684));
|
|
||||||
#164 = VECTOR('',#165,1.);
|
|
||||||
#165 = DIRECTION('',(0.30368282823,0.,0.952773183837));
|
|
||||||
#166 = ORIENTED_EDGE('',*,*,#167,.F.);
|
|
||||||
#167 = EDGE_CURVE('',#168,#160,#170,.T.);
|
|
||||||
#168 = VERTEX_POINT('',#169);
|
|
||||||
#169 = CARTESIAN_POINT('',(32.703857168398,-0.2,-60.78483712899));
|
|
||||||
#170 = LINE('',#171,#172);
|
|
||||||
#171 = CARTESIAN_POINT('',(16.008242280412,-0.2,-44.62779994931));
|
|
||||||
#172 = VECTOR('',#173,1.);
|
|
||||||
#173 = DIRECTION('',(-0.718602345805,0.,0.695421216677));
|
|
||||||
#174 = ORIENTED_EDGE('',*,*,#175,.F.);
|
|
||||||
#175 = EDGE_CURVE('',#176,#168,#178,.T.);
|
|
||||||
#176 = VERTEX_POINT('',#177);
|
|
||||||
#177 = CARTESIAN_POINT('',(26.715163243538,-0.2,-66.97315781796));
|
|
||||||
#178 = LINE('',#179,#180);
|
|
||||||
#179 = CARTESIAN_POINT('',(30.25240665457,-0.2,-63.31800414721));
|
|
||||||
#180 = VECTOR('',#181,1.);
|
|
||||||
#181 = DIRECTION('',(0.695421216677,0.,0.718602345805));
|
|
||||||
#182 = ORIENTED_EDGE('',*,*,#183,.F.);
|
|
||||||
#183 = EDGE_CURVE('',#184,#176,#186,.T.);
|
|
||||||
#184 = VERTEX_POINT('',#185);
|
|
||||||
#185 = CARTESIAN_POINT('',(20.532019001374,-0.2,-60.98947335481));
|
|
||||||
#186 = LINE('',#187,#188);
|
|
||||||
#187 = CARTESIAN_POINT('',(9.922784884512,-0.2,-50.72247862452));
|
|
||||||
#188 = VECTOR('',#189,1.);
|
|
||||||
#189 = DIRECTION('',(0.718602345805,0.,-0.695421216677));
|
|
||||||
#190 = ORIENTED_EDGE('',*,*,#191,.F.);
|
|
||||||
#191 = EDGE_CURVE('',#192,#184,#194,.T.);
|
|
||||||
#192 = VERTEX_POINT('',#193);
|
|
||||||
#193 = CARTESIAN_POINT('',(-20.53201900137,-0.2,-60.98947335481));
|
|
||||||
#194 = LINE('',#195,#196);
|
|
||||||
#195 = CARTESIAN_POINT('',(5.354765181569,-0.2,-60.98947335481));
|
|
||||||
#196 = VECTOR('',#197,1.);
|
|
||||||
#197 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#198 = ORIENTED_EDGE('',*,*,#199,.T.);
|
|
||||||
#199 = EDGE_CURVE('',#192,#200,#202,.T.);
|
|
||||||
#200 = VERTEX_POINT('',#201);
|
|
||||||
#201 = CARTESIAN_POINT('',(-25.53052705686,-0.2,-65.82673637491));
|
|
||||||
#202 = LINE('',#203,#204);
|
|
||||||
#203 = CARTESIAN_POINT('',(-9.454421870603,-0.2,-50.26922436104));
|
|
||||||
#204 = VECTOR('',#205,1.);
|
|
||||||
#205 = DIRECTION('',(-0.718602345805,0.,-0.695421216677));
|
|
||||||
#206 = ORIENTED_EDGE('',*,*,#207,.F.);
|
|
||||||
#207 = EDGE_CURVE('',#83,#200,#208,.T.);
|
|
||||||
#208 = CIRCLE('',#209,1.648528137424);
|
|
||||||
#209 = AXIS2_PLACEMENT_3D('',#210,#211,#212);
|
|
||||||
#210 = CARTESIAN_POINT('',(-26.67694849991,-0.2,-64.64210018823));
|
|
||||||
#211 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#212 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#213 = FACE_BOUND('',#214,.F.);
|
|
||||||
#214 = EDGE_LOOP('',(#215));
|
|
||||||
#215 = ORIENTED_EDGE('',*,*,#216,.F.);
|
|
||||||
#216 = EDGE_CURVE('',#217,#217,#219,.T.);
|
|
||||||
#217 = VERTEX_POINT('',#218);
|
|
||||||
#218 = CARTESIAN_POINT('',(-11.6927539352,-0.2,-48.00951684793));
|
|
||||||
#219 = CIRCLE('',#220,4.735522705283);
|
|
||||||
#220 = AXIS2_PLACEMENT_3D('',#221,#222,#223);
|
|
||||||
#221 = CARTESIAN_POINT('',(-16.42827664048,-0.2,-48.00951684793));
|
|
||||||
#222 = DIRECTION('',(-0.,1.,0.));
|
|
||||||
#223 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#224 = PLANE('',#225);
|
|
||||||
#225 = AXIS2_PLACEMENT_3D('',#226,#227,#228);
|
|
||||||
#226 = CARTESIAN_POINT('',(0.403056515455,-0.2,-33.34517655273));
|
|
||||||
#227 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#228 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#229 = ADVANCED_FACE('',(#230),#255,.F.);
|
|
||||||
#230 = FACE_BOUND('',#231,.F.);
|
|
||||||
#231 = EDGE_LOOP('',(#232,#240,#241,#249));
|
|
||||||
#232 = ORIENTED_EDGE('',*,*,#233,.T.);
|
|
||||||
#233 = EDGE_CURVE('',#234,#160,#236,.T.);
|
|
||||||
#234 = VERTEX_POINT('',#235);
|
|
||||||
#235 = CARTESIAN_POINT('',(26.938753236523,3.2,-55.20570756731));
|
|
||||||
#236 = LINE('',#237,#238);
|
|
||||||
#237 = CARTESIAN_POINT('',(26.938753236523,3.,-55.20570756731));
|
|
||||||
#238 = VECTOR('',#239,1.);
|
|
||||||
#239 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#240 = ORIENTED_EDGE('',*,*,#159,.T.);
|
|
||||||
#241 = ORIENTED_EDGE('',*,*,#242,.F.);
|
|
||||||
#242 = EDGE_CURVE('',#243,#152,#245,.T.);
|
|
||||||
#243 = VERTEX_POINT('',#244);
|
|
||||||
#244 = CARTESIAN_POINT('',(42.298608189024,3.2,-7.015765476549));
|
|
||||||
#245 = LINE('',#246,#247);
|
|
||||||
#246 = CARTESIAN_POINT('',(42.298608189024,3.,-7.015765476549));
|
|
||||||
#247 = VECTOR('',#248,1.);
|
|
||||||
#248 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#249 = ORIENTED_EDGE('',*,*,#250,.F.);
|
|
||||||
#250 = EDGE_CURVE('',#234,#243,#251,.T.);
|
|
||||||
#251 = LINE('',#252,#253);
|
|
||||||
#252 = CARTESIAN_POINT('',(32.699020781567,3.2,-37.13346923684));
|
|
||||||
#253 = VECTOR('',#254,1.);
|
|
||||||
#254 = DIRECTION('',(0.30368282823,0.,0.952773183837));
|
|
||||||
#255 = PLANE('',#256);
|
|
||||||
#256 = AXIS2_PLACEMENT_3D('',#257,#258,#259);
|
|
||||||
#257 = CARTESIAN_POINT('',(34.581352051556,3.,-31.22785130119));
|
|
||||||
#258 = DIRECTION('',(-0.952773183837,0.,0.30368282823));
|
|
||||||
#259 = DIRECTION('',(0.30368282823,0.,0.952773183837));
|
|
||||||
#260 = ADVANCED_FACE('',(#261,#311,#322,#447,#481),#492,.T.);
|
|
||||||
#261 = FACE_BOUND('',#262,.T.);
|
|
||||||
#262 = EDGE_LOOP('',(#263,#273,#281,#289,#297,#305));
|
|
||||||
#263 = ORIENTED_EDGE('',*,*,#264,.F.);
|
|
||||||
#264 = EDGE_CURVE('',#265,#267,#269,.T.);
|
|
||||||
#265 = VERTEX_POINT('',#266);
|
|
||||||
#266 = CARTESIAN_POINT('',(16.626582997737,3.2,-8.940188245231));
|
|
||||||
#267 = VERTEX_POINT('',#268);
|
|
||||||
#268 = CARTESIAN_POINT('',(4.383041634064E-04,3.2,-8.903751615529));
|
|
||||||
#269 = LINE('',#270,#271);
|
|
||||||
#270 = CARTESIAN_POINT('',(4.347099726942,3.2,-8.913277437397));
|
|
||||||
#271 = VECTOR('',#272,1.);
|
|
||||||
#272 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03));
|
|
||||||
#273 = ORIENTED_EDGE('',*,*,#274,.F.);
|
|
||||||
#274 = EDGE_CURVE('',#275,#265,#277,.T.);
|
|
||||||
#275 = VERTEX_POINT('',#276);
|
|
||||||
#276 = CARTESIAN_POINT('',(19.029295926024,3.2,-17.63009960955));
|
|
||||||
#277 = LINE('',#278,#279);
|
|
||||||
#278 = CARTESIAN_POINT('',(19.849519003668,3.2,-20.59660707692));
|
|
||||||
#279 = VECTOR('',#280,1.);
|
|
||||||
#280 = DIRECTION('',(-0.26649542889,0.,0.963836182336));
|
|
||||||
#281 = ORIENTED_EDGE('',*,*,#282,.F.);
|
|
||||||
#282 = EDGE_CURVE('',#283,#275,#285,.T.);
|
|
||||||
#283 = VERTEX_POINT('',#284);
|
|
||||||
#284 = CARTESIAN_POINT('',(22.059435554995,3.2,-3.734519760785));
|
|
||||||
#285 = LINE('',#286,#287);
|
|
||||||
#286 = CARTESIAN_POINT('',(17.698510515043,3.2,-23.73280021221));
|
|
||||||
#287 = VECTOR('',#288,1.);
|
|
||||||
#288 = DIRECTION('',(-0.213058124893,0.,-0.977039526026));
|
|
||||||
#289 = ORIENTED_EDGE('',*,*,#290,.F.);
|
|
||||||
#290 = EDGE_CURVE('',#291,#283,#293,.T.);
|
|
||||||
#291 = VERTEX_POINT('',#292);
|
|
||||||
#292 = CARTESIAN_POINT('',(-21.72552223146,3.2,-3.734519760785));
|
|
||||||
#293 = LINE('',#294,#295);
|
|
||||||
#294 = CARTESIAN_POINT('',(0.297084840953,3.2,-3.734519760785));
|
|
||||||
#295 = VECTOR('',#296,1.);
|
|
||||||
#296 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#297 = ORIENTED_EDGE('',*,*,#298,.F.);
|
|
||||||
#298 = EDGE_CURVE('',#299,#291,#301,.T.);
|
|
||||||
#299 = VERTEX_POINT('',#300);
|
|
||||||
#300 = CARTESIAN_POINT('',(-21.72552223146,3.2,-8.903751135252));
|
|
||||||
#301 = LINE('',#302,#303);
|
|
||||||
#302 = CARTESIAN_POINT('',(-21.72552223146,3.2,-19.83215600037));
|
|
||||||
#303 = VECTOR('',#304,1.);
|
|
||||||
#304 = DIRECTION('',(0.,0.,1.));
|
|
||||||
#305 = ORIENTED_EDGE('',*,*,#306,.F.);
|
|
||||||
#306 = EDGE_CURVE('',#267,#299,#307,.T.);
|
|
||||||
#307 = LINE('',#308,#309);
|
|
||||||
#308 = CARTESIAN_POINT('',(-5.279852300138,3.2,-8.903751135252));
|
|
||||||
#309 = VECTOR('',#310,1.);
|
|
||||||
#310 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#311 = FACE_BOUND('',#312,.T.);
|
|
||||||
#312 = EDGE_LOOP('',(#313));
|
|
||||||
#313 = ORIENTED_EDGE('',*,*,#314,.F.);
|
|
||||||
#314 = EDGE_CURVE('',#315,#315,#317,.T.);
|
|
||||||
#315 = VERTEX_POINT('',#316);
|
|
||||||
#316 = CARTESIAN_POINT('',(21.163799345768,3.2,-48.00951684793));
|
|
||||||
#317 = CIRCLE('',#318,4.735522705283);
|
|
||||||
#318 = AXIS2_PLACEMENT_3D('',#319,#320,#321);
|
|
||||||
#319 = CARTESIAN_POINT('',(16.428276640485,3.2,-48.00951684793));
|
|
||||||
#320 = DIRECTION('',(-0.,1.,0.));
|
|
||||||
#321 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#322 = FACE_BOUND('',#323,.T.);
|
|
||||||
#323 = EDGE_LOOP('',(#324,#334,#343,#351,#360,#368,#374,#375,#383,#391,
|
|
||||||
#399,#407,#415,#424,#432,#441));
|
|
||||||
#324 = ORIENTED_EDGE('',*,*,#325,.T.);
|
|
||||||
#325 = EDGE_CURVE('',#326,#328,#330,.T.);
|
|
||||||
#326 = VERTEX_POINT('',#327);
|
|
||||||
#327 = CARTESIAN_POINT('',(-26.93875323652,3.2,-55.20570756731));
|
|
||||||
#328 = VERTEX_POINT('',#329);
|
|
||||||
#329 = CARTESIAN_POINT('',(-41.17904151244,3.2,-10.52828909594));
|
|
||||||
#330 = LINE('',#331,#332);
|
|
||||||
#331 = CARTESIAN_POINT('',(-32.39120436163,3.2,-38.09921113329));
|
|
||||||
#332 = VECTOR('',#333,1.);
|
|
||||||
#333 = DIRECTION('',(-0.30368282823,0.,0.952773183837));
|
|
||||||
#334 = ORIENTED_EDGE('',*,*,#335,.F.);
|
|
||||||
#335 = EDGE_CURVE('',#336,#328,#338,.T.);
|
|
||||||
#336 = VERTEX_POINT('',#337);
|
|
||||||
#337 = CARTESIAN_POINT('',(-39.69176606491,3.2,-4.408923352436));
|
|
||||||
#338 = CIRCLE('',#339,6.054044962965);
|
|
||||||
#339 = AXIS2_PLACEMENT_3D('',#340,#341,#342);
|
|
||||||
#340 = CARTESIAN_POINT('',(-35.41090981799,3.2,-8.689779599357));
|
|
||||||
#341 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#342 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#343 = ORIENTED_EDGE('',*,*,#344,.T.);
|
|
||||||
#344 = EDGE_CURVE('',#336,#345,#347,.T.);
|
|
||||||
#345 = VERTEX_POINT('',#346);
|
|
||||||
#346 = CARTESIAN_POINT('',(-36.85603142851,3.2,-1.573188716044));
|
|
||||||
#347 = LINE('',#348,#349);
|
|
||||||
#348 = CARTESIAN_POINT('',(-36.19319006079,3.2,-0.910347348321));
|
|
||||||
#349 = VECTOR('',#350,1.);
|
|
||||||
#350 = DIRECTION('',(0.707106781187,0.,0.707106781187));
|
|
||||||
#351 = ORIENTED_EDGE('',*,*,#352,.F.);
|
|
||||||
#352 = EDGE_CURVE('',#353,#345,#355,.T.);
|
|
||||||
#353 = VERTEX_POINT('',#354);
|
|
||||||
#354 = CARTESIAN_POINT('',(-32.57517518159,3.2,0.2));
|
|
||||||
#355 = CIRCLE('',#356,6.054044962965);
|
|
||||||
#356 = AXIS2_PLACEMENT_3D('',#357,#358,#359);
|
|
||||||
#357 = CARTESIAN_POINT('',(-32.57517518159,3.2,-5.854044962965));
|
|
||||||
#358 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#359 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#360 = ORIENTED_EDGE('',*,*,#361,.T.);
|
|
||||||
#361 = EDGE_CURVE('',#353,#362,#364,.T.);
|
|
||||||
#362 = VERTEX_POINT('',#363);
|
|
||||||
#363 = CARTESIAN_POINT('',(35.082842712475,3.2,0.2));
|
|
||||||
#364 = LINE('',#365,#366);
|
|
||||||
#365 = CARTESIAN_POINT('',(-7.942265537672,3.2,0.2));
|
|
||||||
#366 = VECTOR('',#367,1.);
|
|
||||||
#367 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#368 = ORIENTED_EDGE('',*,*,#369,.F.);
|
|
||||||
#369 = EDGE_CURVE('',#243,#362,#370,.T.);
|
|
||||||
#370 = LINE('',#371,#372);
|
|
||||||
#371 = CARTESIAN_POINT('',(36.596246576251,3.2,-1.313403863776));
|
|
||||||
#372 = VECTOR('',#373,1.);
|
|
||||||
#373 = DIRECTION('',(-0.707106781187,0.,0.707106781187));
|
|
||||||
#374 = ORIENTED_EDGE('',*,*,#250,.F.);
|
|
||||||
#375 = ORIENTED_EDGE('',*,*,#376,.F.);
|
|
||||||
#376 = EDGE_CURVE('',#377,#234,#379,.T.);
|
|
||||||
#377 = VERTEX_POINT('',#378);
|
|
||||||
#378 = CARTESIAN_POINT('',(32.703857168398,3.2,-60.78483712899));
|
|
||||||
#379 = LINE('',#380,#381);
|
|
||||||
#380 = CARTESIAN_POINT('',(16.008242280412,3.2,-44.62779994931));
|
|
||||||
#381 = VECTOR('',#382,1.);
|
|
||||||
#382 = DIRECTION('',(-0.718602345805,0.,0.695421216677));
|
|
||||||
#383 = ORIENTED_EDGE('',*,*,#384,.F.);
|
|
||||||
#384 = EDGE_CURVE('',#385,#377,#387,.T.);
|
|
||||||
#385 = VERTEX_POINT('',#386);
|
|
||||||
#386 = CARTESIAN_POINT('',(26.715163243538,3.2,-66.97315781796));
|
|
||||||
#387 = LINE('',#388,#389);
|
|
||||||
#388 = CARTESIAN_POINT('',(30.25240665457,3.2,-63.31800414721));
|
|
||||||
#389 = VECTOR('',#390,1.);
|
|
||||||
#390 = DIRECTION('',(0.695421216677,0.,0.718602345805));
|
|
||||||
#391 = ORIENTED_EDGE('',*,*,#392,.F.);
|
|
||||||
#392 = EDGE_CURVE('',#393,#385,#395,.T.);
|
|
||||||
#393 = VERTEX_POINT('',#394);
|
|
||||||
#394 = CARTESIAN_POINT('',(20.532019001374,3.2,-60.98947335481));
|
|
||||||
#395 = LINE('',#396,#397);
|
|
||||||
#396 = CARTESIAN_POINT('',(9.922784884512,3.2,-50.72247862452));
|
|
||||||
#397 = VECTOR('',#398,1.);
|
|
||||||
#398 = DIRECTION('',(0.718602345805,0.,-0.695421216677));
|
|
||||||
#399 = ORIENTED_EDGE('',*,*,#400,.F.);
|
|
||||||
#400 = EDGE_CURVE('',#401,#393,#403,.T.);
|
|
||||||
#401 = VERTEX_POINT('',#402);
|
|
||||||
#402 = CARTESIAN_POINT('',(-20.53201900137,3.2,-60.98947335481));
|
|
||||||
#403 = LINE('',#404,#405);
|
|
||||||
#404 = CARTESIAN_POINT('',(5.354765181569,3.2,-60.98947335481));
|
|
||||||
#405 = VECTOR('',#406,1.);
|
|
||||||
#406 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#407 = ORIENTED_EDGE('',*,*,#408,.T.);
|
|
||||||
#408 = EDGE_CURVE('',#401,#409,#411,.T.);
|
|
||||||
#409 = VERTEX_POINT('',#410);
|
|
||||||
#410 = CARTESIAN_POINT('',(-25.53052705686,3.2,-65.82673637491));
|
|
||||||
#411 = LINE('',#412,#413);
|
|
||||||
#412 = CARTESIAN_POINT('',(-9.454421870603,3.2,-50.26922436104));
|
|
||||||
#413 = VECTOR('',#414,1.);
|
|
||||||
#414 = DIRECTION('',(-0.718602345805,0.,-0.695421216677));
|
|
||||||
#415 = ORIENTED_EDGE('',*,*,#416,.F.);
|
|
||||||
#416 = EDGE_CURVE('',#417,#409,#419,.T.);
|
|
||||||
#417 = VERTEX_POINT('',#418);
|
|
||||||
#418 = CARTESIAN_POINT('',(-27.86158468659,3.2,-65.78852163128));
|
|
||||||
#419 = CIRCLE('',#420,1.648528137424);
|
|
||||||
#420 = AXIS2_PLACEMENT_3D('',#421,#422,#423);
|
|
||||||
#421 = CARTESIAN_POINT('',(-26.67694849991,3.2,-64.64210018823));
|
|
||||||
#422 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#423 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#424 = ORIENTED_EDGE('',*,*,#425,.T.);
|
|
||||||
#425 = EDGE_CURVE('',#417,#426,#428,.T.);
|
|
||||||
#426 = VERTEX_POINT('',#427);
|
|
||||||
#427 = CARTESIAN_POINT('',(-29.08766684168,3.2,-64.52156932717));
|
|
||||||
#428 = LINE('',#429,#430);
|
|
||||||
#429 = CARTESIAN_POINT('',(-29.44004200272,3.2,-64.15744811364));
|
|
||||||
#430 = VECTOR('',#431,1.);
|
|
||||||
#431 = DIRECTION('',(-0.695421216677,0.,0.718602345805));
|
|
||||||
#432 = ORIENTED_EDGE('',*,*,#433,.F.);
|
|
||||||
#433 = EDGE_CURVE('',#434,#426,#436,.T.);
|
|
||||||
#434 = VERTEX_POINT('',#435);
|
|
||||||
#435 = CARTESIAN_POINT('',(-28.96712497021,3.2,-57.16864680227));
|
|
||||||
#436 = CIRCLE('',#437,5.2);
|
|
||||||
#437 = AXIS2_PLACEMENT_3D('',#438,#439,#440);
|
|
||||||
#438 = CARTESIAN_POINT('',(-25.35093464349,3.2,-60.90537900045));
|
|
||||||
#439 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#440 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#441 = ORIENTED_EDGE('',*,*,#442,.T.);
|
|
||||||
#442 = EDGE_CURVE('',#434,#326,#443,.T.);
|
|
||||||
#443 = LINE('',#444,#445);
|
|
||||||
#444 = CARTESIAN_POINT('',(-14.90185526362,3.2,-43.55710346492));
|
|
||||||
#445 = VECTOR('',#446,1.);
|
|
||||||
#446 = DIRECTION('',(0.718602345805,0.,0.695421216677));
|
|
||||||
#447 = FACE_BOUND('',#448,.T.);
|
|
||||||
#448 = EDGE_LOOP('',(#449,#459,#467,#475));
|
|
||||||
#449 = ORIENTED_EDGE('',*,*,#450,.F.);
|
|
||||||
#450 = EDGE_CURVE('',#451,#453,#455,.T.);
|
|
||||||
#451 = VERTEX_POINT('',#452);
|
|
||||||
#452 = CARTESIAN_POINT('',(5.809375885494,3.2,-13.06417917474));
|
|
||||||
#453 = VERTEX_POINT('',#454);
|
|
||||||
#454 = CARTESIAN_POINT('',(2.688069798796,3.2,-49.64588621989));
|
|
||||||
#455 = LINE('',#456,#457);
|
|
||||||
#456 = CARTESIAN_POINT('',(4.914157977861,3.2,-23.55613296875));
|
|
||||||
#457 = VECTOR('',#458,1.);
|
|
||||||
#458 = DIRECTION('',(-8.501532861635E-02,0.,-0.996379643459));
|
|
||||||
#459 = ORIENTED_EDGE('',*,*,#460,.F.);
|
|
||||||
#460 = EDGE_CURVE('',#461,#451,#463,.T.);
|
|
||||||
#461 = VERTEX_POINT('',#462);
|
|
||||||
#462 = CARTESIAN_POINT('',(-5.809375885494,3.2,-13.06417917474));
|
|
||||||
#463 = LINE('',#464,#465);
|
|
||||||
#464 = CARTESIAN_POINT('',(1.615747408047,3.2,-13.06417917474));
|
|
||||||
#465 = VECTOR('',#466,1.);
|
|
||||||
#466 = DIRECTION('',(1.,0.,-3.066574716487E-16));
|
|
||||||
#467 = ORIENTED_EDGE('',*,*,#468,.F.);
|
|
||||||
#468 = EDGE_CURVE('',#469,#461,#471,.T.);
|
|
||||||
#469 = VERTEX_POINT('',#470);
|
|
||||||
#470 = CARTESIAN_POINT('',(-2.688069798796,3.2,-49.64588621989));
|
|
||||||
#471 = LINE('',#472,#473);
|
|
||||||
#472 = CARTESIAN_POINT('',(-4.890802006217,3.2,-23.82986495424));
|
|
||||||
#473 = VECTOR('',#474,1.);
|
|
||||||
#474 = DIRECTION('',(-8.501532861635E-02,0.,0.996379643459));
|
|
||||||
#475 = ORIENTED_EDGE('',*,*,#476,.F.);
|
|
||||||
#476 = EDGE_CURVE('',#453,#469,#477,.T.);
|
|
||||||
#477 = LINE('',#478,#479);
|
|
||||||
#478 = CARTESIAN_POINT('',(1.615747408047,3.2,-49.64588621989));
|
|
||||||
#479 = VECTOR('',#480,1.);
|
|
||||||
#480 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#481 = FACE_BOUND('',#482,.T.);
|
|
||||||
#482 = EDGE_LOOP('',(#483));
|
|
||||||
#483 = ORIENTED_EDGE('',*,*,#484,.F.);
|
|
||||||
#484 = EDGE_CURVE('',#485,#485,#487,.T.);
|
|
||||||
#485 = VERTEX_POINT('',#486);
|
|
||||||
#486 = CARTESIAN_POINT('',(-11.6927539352,3.2,-48.00951684793));
|
|
||||||
#487 = CIRCLE('',#488,4.735522705283);
|
|
||||||
#488 = AXIS2_PLACEMENT_3D('',#489,#490,#491);
|
|
||||||
#489 = CARTESIAN_POINT('',(-16.42827664048,3.2,-48.00951684793));
|
|
||||||
#490 = DIRECTION('',(-0.,1.,0.));
|
|
||||||
#491 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#492 = PLANE('',#493);
|
|
||||||
#493 = AXIS2_PLACEMENT_3D('',#494,#495,#496);
|
|
||||||
#494 = CARTESIAN_POINT('',(0.403056515455,3.2,-33.34517655273));
|
|
||||||
#495 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#496 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#497 = ADVANCED_FACE('',(#498),#509,.F.);
|
|
||||||
#498 = FACE_BOUND('',#499,.F.);
|
|
||||||
#499 = EDGE_LOOP('',(#500,#506,#507,#508));
|
|
||||||
#500 = ORIENTED_EDGE('',*,*,#501,.F.);
|
|
||||||
#501 = EDGE_CURVE('',#168,#377,#502,.T.);
|
|
||||||
#502 = LINE('',#503,#504);
|
|
||||||
#503 = CARTESIAN_POINT('',(32.703857168398,3.,-60.78483712899));
|
|
||||||
#504 = VECTOR('',#505,1.);
|
|
||||||
#505 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#506 = ORIENTED_EDGE('',*,*,#167,.T.);
|
|
||||||
#507 = ORIENTED_EDGE('',*,*,#233,.F.);
|
|
||||||
#508 = ORIENTED_EDGE('',*,*,#376,.F.);
|
|
||||||
#509 = PLANE('',#510);
|
|
||||||
#510 = AXIS2_PLACEMENT_3D('',#511,#512,#513);
|
|
||||||
#511 = CARTESIAN_POINT('',(29.704873980143,3.,-57.88259703786));
|
|
||||||
#512 = DIRECTION('',(-0.695421216677,0.,-0.718602345805));
|
|
||||||
#513 = DIRECTION('',(-0.718602345805,0.,0.695421216677));
|
|
||||||
#514 = ADVANCED_FACE('',(#515),#526,.F.);
|
|
||||||
#515 = FACE_BOUND('',#516,.F.);
|
|
||||||
#516 = EDGE_LOOP('',(#517,#523,#524,#525));
|
|
||||||
#517 = ORIENTED_EDGE('',*,*,#518,.F.);
|
|
||||||
#518 = EDGE_CURVE('',#176,#385,#519,.T.);
|
|
||||||
#519 = LINE('',#520,#521);
|
|
||||||
#520 = CARTESIAN_POINT('',(26.715163243538,3.,-66.97315781796));
|
|
||||||
#521 = VECTOR('',#522,1.);
|
|
||||||
#522 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#523 = ORIENTED_EDGE('',*,*,#175,.T.);
|
|
||||||
#524 = ORIENTED_EDGE('',*,*,#501,.T.);
|
|
||||||
#525 = ORIENTED_EDGE('',*,*,#384,.F.);
|
|
||||||
#526 = PLANE('',#527);
|
|
||||||
#527 = AXIS2_PLACEMENT_3D('',#528,#529,#530);
|
|
||||||
#528 = CARTESIAN_POINT('',(29.709510205968,3.,-63.87899747347));
|
|
||||||
#529 = DIRECTION('',(-0.718602345805,0.,0.695421216677));
|
|
||||||
#530 = DIRECTION('',(0.695421216677,0.,0.718602345805));
|
|
||||||
#531 = ADVANCED_FACE('',(#532),#543,.F.);
|
|
||||||
#532 = FACE_BOUND('',#533,.F.);
|
|
||||||
#533 = EDGE_LOOP('',(#534,#540,#541,#542));
|
|
||||||
#534 = ORIENTED_EDGE('',*,*,#535,.T.);
|
|
||||||
#535 = EDGE_CURVE('',#393,#184,#536,.T.);
|
|
||||||
#536 = LINE('',#537,#538);
|
|
||||||
#537 = CARTESIAN_POINT('',(20.532019001374,3.,-60.98947335481));
|
|
||||||
#538 = VECTOR('',#539,1.);
|
|
||||||
#539 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#540 = ORIENTED_EDGE('',*,*,#183,.T.);
|
|
||||||
#541 = ORIENTED_EDGE('',*,*,#518,.T.);
|
|
||||||
#542 = ORIENTED_EDGE('',*,*,#392,.F.);
|
|
||||||
#543 = PLANE('',#544);
|
|
||||||
#544 = AXIS2_PLACEMENT_3D('',#545,#546,#547);
|
|
||||||
#545 = CARTESIAN_POINT('',(23.522653113203,3.,-63.8836336993));
|
|
||||||
#546 = DIRECTION('',(0.695421216677,0.,0.718602345805));
|
|
||||||
#547 = DIRECTION('',(0.718602345805,0.,-0.695421216677));
|
|
||||||
#548 = ADVANCED_FACE('',(#549),#560,.F.);
|
|
||||||
#549 = FACE_BOUND('',#550,.F.);
|
|
||||||
#550 = EDGE_LOOP('',(#551,#557,#558,#559));
|
|
||||||
#551 = ORIENTED_EDGE('',*,*,#552,.F.);
|
|
||||||
#552 = EDGE_CURVE('',#192,#401,#553,.T.);
|
|
||||||
#553 = LINE('',#554,#555);
|
|
||||||
#554 = CARTESIAN_POINT('',(-20.53201900137,3.,-60.98947335481));
|
|
||||||
#555 = VECTOR('',#556,1.);
|
|
||||||
#556 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#557 = ORIENTED_EDGE('',*,*,#191,.T.);
|
|
||||||
#558 = ORIENTED_EDGE('',*,*,#535,.F.);
|
|
||||||
#559 = ORIENTED_EDGE('',*,*,#400,.F.);
|
|
||||||
#560 = PLANE('',#561);
|
|
||||||
#561 = AXIS2_PLACEMENT_3D('',#562,#563,#564);
|
|
||||||
#562 = CARTESIAN_POINT('',(10.306473847682,3.,-60.98947335481));
|
|
||||||
#563 = DIRECTION('',(0.,0.,1.));
|
|
||||||
#564 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#565 = ADVANCED_FACE('',(#566),#577,.T.);
|
|
||||||
#566 = FACE_BOUND('',#567,.T.);
|
|
||||||
#567 = EDGE_LOOP('',(#568,#574,#575,#576));
|
|
||||||
#568 = ORIENTED_EDGE('',*,*,#569,.F.);
|
|
||||||
#569 = EDGE_CURVE('',#409,#200,#570,.T.);
|
|
||||||
#570 = LINE('',#571,#572);
|
|
||||||
#571 = CARTESIAN_POINT('',(-25.53052705686,3.,-65.82673637491));
|
|
||||||
#572 = VECTOR('',#573,1.);
|
|
||||||
#573 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#574 = ORIENTED_EDGE('',*,*,#408,.F.);
|
|
||||||
#575 = ORIENTED_EDGE('',*,*,#552,.F.);
|
|
||||||
#576 = ORIENTED_EDGE('',*,*,#199,.T.);
|
|
||||||
#577 = PLANE('',#578);
|
|
||||||
#578 = AXIS2_PLACEMENT_3D('',#579,#580,#581);
|
|
||||||
#579 = CARTESIAN_POINT('',(-23.00219525444,3.,-63.37996509944));
|
|
||||||
#580 = DIRECTION('',(0.695421216677,0.,-0.718602345805));
|
|
||||||
#581 = DIRECTION('',(-0.718602345805,0.,-0.695421216677));
|
|
||||||
#582 = ADVANCED_FACE('',(#583),#594,.T.);
|
|
||||||
#583 = FACE_BOUND('',#584,.T.);
|
|
||||||
#584 = EDGE_LOOP('',(#585,#591,#592,#593));
|
|
||||||
#585 = ORIENTED_EDGE('',*,*,#586,.F.);
|
|
||||||
#586 = EDGE_CURVE('',#417,#83,#587,.T.);
|
|
||||||
#587 = LINE('',#588,#589);
|
|
||||||
#588 = CARTESIAN_POINT('',(-27.86158468659,3.,-65.78852163128));
|
|
||||||
#589 = VECTOR('',#590,1.);
|
|
||||||
#590 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#591 = ORIENTED_EDGE('',*,*,#416,.T.);
|
|
||||||
#592 = ORIENTED_EDGE('',*,*,#569,.T.);
|
|
||||||
#593 = ORIENTED_EDGE('',*,*,#207,.F.);
|
|
||||||
#594 = CYLINDRICAL_SURFACE('',#595,1.648528137424);
|
|
||||||
#595 = AXIS2_PLACEMENT_3D('',#596,#597,#598);
|
|
||||||
#596 = CARTESIAN_POINT('',(-26.67694849991,3.,-64.64210018823));
|
|
||||||
#597 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#598 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#599 = ADVANCED_FACE('',(#600),#611,.T.);
|
|
||||||
#600 = FACE_BOUND('',#601,.T.);
|
|
||||||
#601 = EDGE_LOOP('',(#602,#608,#609,#610));
|
|
||||||
#602 = ORIENTED_EDGE('',*,*,#603,.F.);
|
|
||||||
#603 = EDGE_CURVE('',#426,#85,#604,.T.);
|
|
||||||
#604 = LINE('',#605,#606);
|
|
||||||
#605 = CARTESIAN_POINT('',(-29.08766684168,3.,-64.52156932717));
|
|
||||||
#606 = VECTOR('',#607,1.);
|
|
||||||
#607 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#608 = ORIENTED_EDGE('',*,*,#425,.F.);
|
|
||||||
#609 = ORIENTED_EDGE('',*,*,#586,.T.);
|
|
||||||
#610 = ORIENTED_EDGE('',*,*,#82,.T.);
|
|
||||||
#611 = PLANE('',#612);
|
|
||||||
#612 = AXIS2_PLACEMENT_3D('',#613,#614,#615);
|
|
||||||
#613 = CARTESIAN_POINT('',(-28.47462576413,3.,-65.15504547923));
|
|
||||||
#614 = DIRECTION('',(-0.718602345805,0.,-0.695421216677));
|
|
||||||
#615 = DIRECTION('',(-0.695421216677,0.,0.718602345805));
|
|
||||||
#616 = ADVANCED_FACE('',(#617),#628,.T.);
|
|
||||||
#617 = FACE_BOUND('',#618,.T.);
|
|
||||||
#618 = EDGE_LOOP('',(#619,#625,#626,#627));
|
|
||||||
#619 = ORIENTED_EDGE('',*,*,#620,.F.);
|
|
||||||
#620 = EDGE_CURVE('',#434,#93,#621,.T.);
|
|
||||||
#621 = LINE('',#622,#623);
|
|
||||||
#622 = CARTESIAN_POINT('',(-28.96712497021,3.,-57.16864680227));
|
|
||||||
#623 = VECTOR('',#624,1.);
|
|
||||||
#624 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#625 = ORIENTED_EDGE('',*,*,#433,.T.);
|
|
||||||
#626 = ORIENTED_EDGE('',*,*,#603,.T.);
|
|
||||||
#627 = ORIENTED_EDGE('',*,*,#92,.F.);
|
|
||||||
#628 = CYLINDRICAL_SURFACE('',#629,5.2);
|
|
||||||
#629 = AXIS2_PLACEMENT_3D('',#630,#631,#632);
|
|
||||||
#630 = CARTESIAN_POINT('',(-25.35093464349,3.,-60.90537900045));
|
|
||||||
#631 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#632 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#633 = ADVANCED_FACE('',(#634),#645,.T.);
|
|
||||||
#634 = FACE_BOUND('',#635,.T.);
|
|
||||||
#635 = EDGE_LOOP('',(#636,#642,#643,#644));
|
|
||||||
#636 = ORIENTED_EDGE('',*,*,#637,.F.);
|
|
||||||
#637 = EDGE_CURVE('',#326,#102,#638,.T.);
|
|
||||||
#638 = LINE('',#639,#640);
|
|
||||||
#639 = CARTESIAN_POINT('',(-26.93875323652,3.,-55.20570756731));
|
|
||||||
#640 = VECTOR('',#641,1.);
|
|
||||||
#641 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#642 = ORIENTED_EDGE('',*,*,#442,.F.);
|
|
||||||
#643 = ORIENTED_EDGE('',*,*,#620,.T.);
|
|
||||||
#644 = ORIENTED_EDGE('',*,*,#101,.T.);
|
|
||||||
#645 = PLANE('',#646);
|
|
||||||
#646 = AXIS2_PLACEMENT_3D('',#647,#648,#649);
|
|
||||||
#647 = CARTESIAN_POINT('',(-27.90836811563,3.,-56.14404399617));
|
|
||||||
#648 = DIRECTION('',(-0.695421216677,0.,0.718602345805));
|
|
||||||
#649 = DIRECTION('',(0.718602345805,0.,0.695421216677));
|
|
||||||
#650 = ADVANCED_FACE('',(#651),#662,.T.);
|
|
||||||
#651 = FACE_BOUND('',#652,.T.);
|
|
||||||
#652 = EDGE_LOOP('',(#653,#659,#660,#661));
|
|
||||||
#653 = ORIENTED_EDGE('',*,*,#654,.F.);
|
|
||||||
#654 = EDGE_CURVE('',#328,#110,#655,.T.);
|
|
||||||
#655 = LINE('',#656,#657);
|
|
||||||
#656 = CARTESIAN_POINT('',(-41.17904151244,3.,-10.52828909594));
|
|
||||||
#657 = VECTOR('',#658,1.);
|
|
||||||
#658 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#659 = ORIENTED_EDGE('',*,*,#325,.F.);
|
|
||||||
#660 = ORIENTED_EDGE('',*,*,#637,.T.);
|
|
||||||
#661 = ORIENTED_EDGE('',*,*,#109,.T.);
|
|
||||||
#662 = PLANE('',#663);
|
|
||||||
#663 = AXIS2_PLACEMENT_3D('',#664,#665,#666);
|
|
||||||
#664 = CARTESIAN_POINT('',(-34.04006158346,3.,-32.92609366041));
|
|
||||||
#665 = DIRECTION('',(-0.952773183837,0.,-0.30368282823));
|
|
||||||
#666 = DIRECTION('',(-0.30368282823,0.,0.952773183837));
|
|
||||||
#667 = ADVANCED_FACE('',(#668),#679,.T.);
|
|
||||||
#668 = FACE_BOUND('',#669,.T.);
|
|
||||||
#669 = EDGE_LOOP('',(#670,#676,#677,#678));
|
|
||||||
#670 = ORIENTED_EDGE('',*,*,#671,.F.);
|
|
||||||
#671 = EDGE_CURVE('',#336,#118,#672,.T.);
|
|
||||||
#672 = LINE('',#673,#674);
|
|
||||||
#673 = CARTESIAN_POINT('',(-39.69176606491,3.,-4.408923352436));
|
|
||||||
#674 = VECTOR('',#675,1.);
|
|
||||||
#675 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#676 = ORIENTED_EDGE('',*,*,#335,.T.);
|
|
||||||
#677 = ORIENTED_EDGE('',*,*,#654,.T.);
|
|
||||||
#678 = ORIENTED_EDGE('',*,*,#117,.F.);
|
|
||||||
#679 = CYLINDRICAL_SURFACE('',#680,6.054044962965);
|
|
||||||
#680 = AXIS2_PLACEMENT_3D('',#681,#682,#683);
|
|
||||||
#681 = CARTESIAN_POINT('',(-35.41090981799,3.,-8.689779599357));
|
|
||||||
#682 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#683 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#684 = ADVANCED_FACE('',(#685),#696,.T.);
|
|
||||||
#685 = FACE_BOUND('',#686,.T.);
|
|
||||||
#686 = EDGE_LOOP('',(#687,#693,#694,#695));
|
|
||||||
#687 = ORIENTED_EDGE('',*,*,#688,.F.);
|
|
||||||
#688 = EDGE_CURVE('',#345,#127,#689,.T.);
|
|
||||||
#689 = LINE('',#690,#691);
|
|
||||||
#690 = CARTESIAN_POINT('',(-36.85603142851,3.,-1.573188716044));
|
|
||||||
#691 = VECTOR('',#692,1.);
|
|
||||||
#692 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#693 = ORIENTED_EDGE('',*,*,#344,.F.);
|
|
||||||
#694 = ORIENTED_EDGE('',*,*,#671,.T.);
|
|
||||||
#695 = ORIENTED_EDGE('',*,*,#126,.T.);
|
|
||||||
#696 = PLANE('',#697);
|
|
||||||
#697 = AXIS2_PLACEMENT_3D('',#698,#699,#700);
|
|
||||||
#698 = CARTESIAN_POINT('',(-38.27389874671,3.,-2.99105603424));
|
|
||||||
#699 = DIRECTION('',(-0.707106781187,0.,0.707106781187));
|
|
||||||
#700 = DIRECTION('',(0.707106781187,0.,0.707106781187));
|
|
||||||
#701 = ADVANCED_FACE('',(#702),#713,.T.);
|
|
||||||
#702 = FACE_BOUND('',#703,.T.);
|
|
||||||
#703 = EDGE_LOOP('',(#704,#710,#711,#712));
|
|
||||||
#704 = ORIENTED_EDGE('',*,*,#705,.F.);
|
|
||||||
#705 = EDGE_CURVE('',#353,#135,#706,.T.);
|
|
||||||
#706 = LINE('',#707,#708);
|
|
||||||
#707 = CARTESIAN_POINT('',(-32.57517518159,3.,0.2));
|
|
||||||
#708 = VECTOR('',#709,1.);
|
|
||||||
#709 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#710 = ORIENTED_EDGE('',*,*,#352,.T.);
|
|
||||||
#711 = ORIENTED_EDGE('',*,*,#688,.T.);
|
|
||||||
#712 = ORIENTED_EDGE('',*,*,#134,.F.);
|
|
||||||
#713 = CYLINDRICAL_SURFACE('',#714,6.054044962965);
|
|
||||||
#714 = AXIS2_PLACEMENT_3D('',#715,#716,#717);
|
|
||||||
#715 = CARTESIAN_POINT('',(-32.57517518159,3.,-5.854044962965));
|
|
||||||
#716 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#717 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#718 = ADVANCED_FACE('',(#719),#730,.T.);
|
|
||||||
#719 = FACE_BOUND('',#720,.T.);
|
|
||||||
#720 = EDGE_LOOP('',(#721,#727,#728,#729));
|
|
||||||
#721 = ORIENTED_EDGE('',*,*,#722,.F.);
|
|
||||||
#722 = EDGE_CURVE('',#362,#144,#723,.T.);
|
|
||||||
#723 = LINE('',#724,#725);
|
|
||||||
#724 = CARTESIAN_POINT('',(35.082842712475,3.,0.2));
|
|
||||||
#725 = VECTOR('',#726,1.);
|
|
||||||
#726 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#727 = ORIENTED_EDGE('',*,*,#361,.F.);
|
|
||||||
#728 = ORIENTED_EDGE('',*,*,#705,.T.);
|
|
||||||
#729 = ORIENTED_EDGE('',*,*,#143,.T.);
|
|
||||||
#730 = PLANE('',#731);
|
|
||||||
#731 = AXIS2_PLACEMENT_3D('',#732,#733,#734);
|
|
||||||
#732 = CARTESIAN_POINT('',(-16.28758759079,3.,0.2));
|
|
||||||
#733 = DIRECTION('',(0.,0.,1.));
|
|
||||||
#734 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#735 = ADVANCED_FACE('',(#736),#742,.F.);
|
|
||||||
#736 = FACE_BOUND('',#737,.F.);
|
|
||||||
#737 = EDGE_LOOP('',(#738,#739,#740,#741));
|
|
||||||
#738 = ORIENTED_EDGE('',*,*,#151,.T.);
|
|
||||||
#739 = ORIENTED_EDGE('',*,*,#722,.F.);
|
|
||||||
#740 = ORIENTED_EDGE('',*,*,#369,.F.);
|
|
||||||
#741 = ORIENTED_EDGE('',*,*,#242,.T.);
|
|
||||||
#742 = PLANE('',#743);
|
|
||||||
#743 = AXIS2_PLACEMENT_3D('',#744,#745,#746);
|
|
||||||
#744 = CARTESIAN_POINT('',(38.67695526217,3.,-3.394112549695));
|
|
||||||
#745 = DIRECTION('',(-0.707106781187,0.,-0.707106781187));
|
|
||||||
#746 = DIRECTION('',(-0.707106781187,0.,0.707106781187));
|
|
||||||
#747 = ADVANCED_FACE('',(#748),#765,.T.);
|
|
||||||
#748 = FACE_BOUND('',#749,.T.);
|
|
||||||
#749 = EDGE_LOOP('',(#750,#758,#764));
|
|
||||||
#750 = ORIENTED_EDGE('',*,*,#751,.T.);
|
|
||||||
#751 = EDGE_CURVE('',#451,#752,#754,.T.);
|
|
||||||
#752 = VERTEX_POINT('',#753);
|
|
||||||
#753 = CARTESIAN_POINT('',(3.256654205567E-15,17.8572529153,
|
|
||||||
-18.39898295202));
|
|
||||||
#754 = LINE('',#755,#756);
|
|
||||||
#755 = CARTESIAN_POINT('',(5.649679875255,3.602918464526,-13.21082950266
|
|
||||||
));
|
|
||||||
#756 = VECTOR('',#757,1.);
|
|
||||||
#757 = DIRECTION('',(-0.349023821871,0.880598971639,-0.320511814002));
|
|
||||||
#758 = ORIENTED_EDGE('',*,*,#759,.T.);
|
|
||||||
#759 = EDGE_CURVE('',#752,#461,#760,.T.);
|
|
||||||
#760 = LINE('',#761,#762);
|
|
||||||
#761 = CARTESIAN_POINT('',(-5.275833888477,4.546144602338,
|
|
||||||
-13.55413574101));
|
|
||||||
#762 = VECTOR('',#763,1.);
|
|
||||||
#763 = DIRECTION('',(-0.349023821871,-0.880598971639,0.320511814002));
|
|
||||||
#764 = ORIENTED_EDGE('',*,*,#460,.T.);
|
|
||||||
#765 = PLANE('',#766);
|
|
||||||
#766 = AXIS2_PLACEMENT_3D('',#767,#768,#769);
|
|
||||||
#767 = CARTESIAN_POINT('',(2.828438300639,3.068404028665,-13.01628215822
|
|
||||||
));
|
|
||||||
#768 = DIRECTION('',(2.881637632171E-16,0.342020143326,0.939692620786));
|
|
||||||
#769 = DIRECTION('',(-1.048830324052E-16,0.939692620786,-0.342020143326)
|
|
||||||
);
|
|
||||||
#770 = ADVANCED_FACE('',(#771),#789,.T.);
|
|
||||||
#771 = FACE_BOUND('',#772,.T.);
|
|
||||||
#772 = EDGE_LOOP('',(#773,#781,#782,#783));
|
|
||||||
#773 = ORIENTED_EDGE('',*,*,#774,.T.);
|
|
||||||
#774 = EDGE_CURVE('',#775,#752,#777,.T.);
|
|
||||||
#775 = VERTEX_POINT('',#776);
|
|
||||||
#776 = CARTESIAN_POINT('',(1.480297366167E-15,11.242400581089,
|
|
||||||
-46.71869179633));
|
|
||||||
#777 = LINE('',#778,#779);
|
|
||||||
#778 = CARTESIAN_POINT('',(2.6645352591E-15,18.133069549222,
|
|
||||||
-17.21814831317));
|
|
||||||
#779 = VECTOR('',#780,1.);
|
|
||||||
#780 = DIRECTION('',(5.275122655166E-17,0.227455280238,0.97378852709));
|
|
||||||
#781 = ORIENTED_EDGE('',*,*,#751,.F.);
|
|
||||||
#782 = ORIENTED_EDGE('',*,*,#450,.T.);
|
|
||||||
#783 = ORIENTED_EDGE('',*,*,#784,.T.);
|
|
||||||
#784 = EDGE_CURVE('',#453,#775,#785,.T.);
|
|
||||||
#785 = LINE('',#786,#787);
|
|
||||||
#786 = CARTESIAN_POINT('',(1.103762571829,7.940067898719,-47.92064259636
|
|
||||||
));
|
|
||||||
#787 = VECTOR('',#788,1.);
|
|
||||||
#788 = DIRECTION('',(-0.299648208284,0.896513522642,0.326304236859));
|
|
||||||
#789 = PLANE('',#790);
|
|
||||||
#790 = AXIS2_PLACEMENT_3D('',#791,#792,#793);
|
|
||||||
#791 = CARTESIAN_POINT('',(5.823691883056,3.068404028665,-13.45978839622
|
|
||||||
));
|
|
||||||
#792 = DIRECTION('',(0.93629059846,0.342020143326,-7.988827695448E-02));
|
|
||||||
#793 = DIRECTION('',(-0.340781908463,0.939692620786,2.907695487824E-02)
|
|
||||||
);
|
|
||||||
#794 = ADVANCED_FACE('',(#795),#805,.T.);
|
|
||||||
#795 = FACE_BOUND('',#796,.T.);
|
|
||||||
#796 = EDGE_LOOP('',(#797,#803,#804));
|
|
||||||
#797 = ORIENTED_EDGE('',*,*,#798,.T.);
|
|
||||||
#798 = EDGE_CURVE('',#469,#775,#799,.T.);
|
|
||||||
#799 = LINE('',#800,#801);
|
|
||||||
#800 = CARTESIAN_POINT('',(-0.871390517001,8.635298785064,
|
|
||||||
-47.66759924779));
|
|
||||||
#801 = VECTOR('',#802,1.);
|
|
||||||
#802 = DIRECTION('',(0.299648208284,0.896513522642,0.326304236859));
|
|
||||||
#803 = ORIENTED_EDGE('',*,*,#784,.F.);
|
|
||||||
#804 = ORIENTED_EDGE('',*,*,#476,.T.);
|
|
||||||
#805 = PLANE('',#806);
|
|
||||||
#806 = AXIS2_PLACEMENT_3D('',#807,#808,#809);
|
|
||||||
#807 = CARTESIAN_POINT('',(2.828438300639,3.068404028665,-49.69378323641
|
|
||||||
));
|
|
||||||
#808 = DIRECTION('',(0.,0.342020143326,-0.939692620786));
|
|
||||||
#809 = DIRECTION('',(0.,0.939692620786,0.342020143326));
|
|
||||||
#810 = ADVANCED_FACE('',(#811),#817,.T.);
|
|
||||||
#811 = FACE_BOUND('',#812,.T.);
|
|
||||||
#812 = EDGE_LOOP('',(#813,#814,#815,#816));
|
|
||||||
#813 = ORIENTED_EDGE('',*,*,#468,.T.);
|
|
||||||
#814 = ORIENTED_EDGE('',*,*,#759,.F.);
|
|
||||||
#815 = ORIENTED_EDGE('',*,*,#774,.F.);
|
|
||||||
#816 = ORIENTED_EDGE('',*,*,#798,.F.);
|
|
||||||
#817 = PLANE('',#818);
|
|
||||||
#818 = AXIS2_PLACEMENT_3D('',#819,#820,#821);
|
|
||||||
#819 = CARTESIAN_POINT('',(-5.782806207227,3.068404028665,
|
|
||||||
-13.93896851312));
|
|
||||||
#820 = DIRECTION('',(-0.93629059846,0.342020143326,-7.988827695448E-02)
|
|
||||||
);
|
|
||||||
#821 = DIRECTION('',(0.340781908463,0.939692620786,2.907695487824E-02));
|
|
||||||
#822 = ADVANCED_FACE('',(#823),#834,.F.);
|
|
||||||
#823 = FACE_BOUND('',#824,.F.);
|
|
||||||
#824 = EDGE_LOOP('',(#825,#831,#832,#833));
|
|
||||||
#825 = ORIENTED_EDGE('',*,*,#826,.F.);
|
|
||||||
#826 = EDGE_CURVE('',#217,#485,#827,.T.);
|
|
||||||
#827 = LINE('',#828,#829);
|
|
||||||
#828 = CARTESIAN_POINT('',(-11.6927539352,-22.,-48.00951684793));
|
|
||||||
#829 = VECTOR('',#830,1.);
|
|
||||||
#830 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#831 = ORIENTED_EDGE('',*,*,#216,.T.);
|
|
||||||
#832 = ORIENTED_EDGE('',*,*,#826,.T.);
|
|
||||||
#833 = ORIENTED_EDGE('',*,*,#484,.F.);
|
|
||||||
#834 = CYLINDRICAL_SURFACE('',#835,4.735522705283);
|
|
||||||
#835 = AXIS2_PLACEMENT_3D('',#836,#837,#838);
|
|
||||||
#836 = CARTESIAN_POINT('',(-16.42827664048,-22.,-48.00951684793));
|
|
||||||
#837 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#838 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#839 = ADVANCED_FACE('',(#840),#851,.F.);
|
|
||||||
#840 = FACE_BOUND('',#841,.F.);
|
|
||||||
#841 = EDGE_LOOP('',(#842,#848,#849,#850));
|
|
||||||
#842 = ORIENTED_EDGE('',*,*,#843,.F.);
|
|
||||||
#843 = EDGE_CURVE('',#72,#315,#844,.T.);
|
|
||||||
#844 = LINE('',#845,#846);
|
|
||||||
#845 = CARTESIAN_POINT('',(21.163799345768,-22.,-48.00951684793));
|
|
||||||
#846 = VECTOR('',#847,1.);
|
|
||||||
#847 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#848 = ORIENTED_EDGE('',*,*,#71,.T.);
|
|
||||||
#849 = ORIENTED_EDGE('',*,*,#843,.T.);
|
|
||||||
#850 = ORIENTED_EDGE('',*,*,#314,.F.);
|
|
||||||
#851 = CYLINDRICAL_SURFACE('',#852,4.735522705283);
|
|
||||||
#852 = AXIS2_PLACEMENT_3D('',#853,#854,#855);
|
|
||||||
#853 = CARTESIAN_POINT('',(16.428276640485,-22.,-48.00951684793));
|
|
||||||
#854 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#855 = DIRECTION('',(1.,0.,0.));
|
|
||||||
#856 = ADVANCED_FACE('',(#857),#873,.F.);
|
|
||||||
#857 = FACE_BOUND('',#858,.F.);
|
|
||||||
#858 = EDGE_LOOP('',(#859,#865,#866,#872));
|
|
||||||
#859 = ORIENTED_EDGE('',*,*,#860,.F.);
|
|
||||||
#860 = EDGE_CURVE('',#24,#265,#861,.T.);
|
|
||||||
#861 = LINE('',#862,#863);
|
|
||||||
#862 = CARTESIAN_POINT('',(16.626582997737,-22.,-8.940188245231));
|
|
||||||
#863 = VECTOR('',#864,1.);
|
|
||||||
#864 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#865 = ORIENTED_EDGE('',*,*,#63,.T.);
|
|
||||||
#866 = ORIENTED_EDGE('',*,*,#867,.T.);
|
|
||||||
#867 = EDGE_CURVE('',#56,#267,#868,.T.);
|
|
||||||
#868 = LINE('',#869,#870);
|
|
||||||
#869 = CARTESIAN_POINT('',(-5.329070518201E-15,-22.,-8.903751135252));
|
|
||||||
#870 = VECTOR('',#871,1.);
|
|
||||||
#871 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#872 = ORIENTED_EDGE('',*,*,#264,.F.);
|
|
||||||
#873 = PLANE('',#874);
|
|
||||||
#874 = AXIS2_PLACEMENT_3D('',#875,#876,#877);
|
|
||||||
#875 = CARTESIAN_POINT('',(8.237581109188,-22.,-8.921803528809));
|
|
||||||
#876 = DIRECTION('',(-2.191520817069E-03,0.,-0.999997598615));
|
|
||||||
#877 = DIRECTION('',(-0.999997598615,0.,2.191520817069E-03));
|
|
||||||
#878 = ADVANCED_FACE('',(#879),#890,.F.);
|
|
||||||
#879 = FACE_BOUND('',#880,.F.);
|
|
||||||
#880 = EDGE_LOOP('',(#881,#887,#888,#889));
|
|
||||||
#881 = ORIENTED_EDGE('',*,*,#882,.T.);
|
|
||||||
#882 = EDGE_CURVE('',#48,#299,#883,.T.);
|
|
||||||
#883 = LINE('',#884,#885);
|
|
||||||
#884 = CARTESIAN_POINT('',(-21.72552223146,-22.,-8.903751135252));
|
|
||||||
#885 = VECTOR('',#886,1.);
|
|
||||||
#886 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#887 = ORIENTED_EDGE('',*,*,#306,.F.);
|
|
||||||
#888 = ORIENTED_EDGE('',*,*,#867,.F.);
|
|
||||||
#889 = ORIENTED_EDGE('',*,*,#55,.T.);
|
|
||||||
#890 = PLANE('',#891);
|
|
||||||
#891 = AXIS2_PLACEMENT_3D('',#892,#893,#894);
|
|
||||||
#892 = CARTESIAN_POINT('',(-10.96276111573,-22.,-8.903751135252));
|
|
||||||
#893 = DIRECTION('',(0.,0.,-1.));
|
|
||||||
#894 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#895 = ADVANCED_FACE('',(#896),#907,.F.);
|
|
||||||
#896 = FACE_BOUND('',#897,.F.);
|
|
||||||
#897 = EDGE_LOOP('',(#898,#904,#905,#906));
|
|
||||||
#898 = ORIENTED_EDGE('',*,*,#899,.T.);
|
|
||||||
#899 = EDGE_CURVE('',#40,#291,#900,.T.);
|
|
||||||
#900 = LINE('',#901,#902);
|
|
||||||
#901 = CARTESIAN_POINT('',(-21.72552223146,-22.,-3.734519760785));
|
|
||||||
#902 = VECTOR('',#903,1.);
|
|
||||||
#903 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#904 = ORIENTED_EDGE('',*,*,#298,.F.);
|
|
||||||
#905 = ORIENTED_EDGE('',*,*,#882,.F.);
|
|
||||||
#906 = ORIENTED_EDGE('',*,*,#47,.T.);
|
|
||||||
#907 = PLANE('',#908);
|
|
||||||
#908 = AXIS2_PLACEMENT_3D('',#909,#910,#911);
|
|
||||||
#909 = CARTESIAN_POINT('',(-21.72552223146,-22.,-6.319135448019));
|
|
||||||
#910 = DIRECTION('',(-1.,0.,0.));
|
|
||||||
#911 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#912 = ADVANCED_FACE('',(#913),#924,.F.);
|
|
||||||
#913 = FACE_BOUND('',#914,.F.);
|
|
||||||
#914 = EDGE_LOOP('',(#915,#921,#922,#923));
|
|
||||||
#915 = ORIENTED_EDGE('',*,*,#916,.T.);
|
|
||||||
#916 = EDGE_CURVE('',#32,#283,#917,.T.);
|
|
||||||
#917 = LINE('',#918,#919);
|
|
||||||
#918 = CARTESIAN_POINT('',(22.059435554995,-22.,-3.734519760785));
|
|
||||||
#919 = VECTOR('',#920,1.);
|
|
||||||
#920 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#921 = ORIENTED_EDGE('',*,*,#290,.F.);
|
|
||||||
#922 = ORIENTED_EDGE('',*,*,#899,.F.);
|
|
||||||
#923 = ORIENTED_EDGE('',*,*,#39,.T.);
|
|
||||||
#924 = PLANE('',#925);
|
|
||||||
#925 = AXIS2_PLACEMENT_3D('',#926,#927,#928);
|
|
||||||
#926 = CARTESIAN_POINT('',(0.19111316645,-22.,-3.734519760785));
|
|
||||||
#927 = DIRECTION('',(0.,0.,1.));
|
|
||||||
#928 = DIRECTION('',(0.,-1.,0.));
|
|
||||||
#929 = ADVANCED_FACE('',(#930),#941,.F.);
|
|
||||||
#930 = FACE_BOUND('',#931,.F.);
|
|
||||||
#931 = EDGE_LOOP('',(#932,#938,#939,#940));
|
|
||||||
#932 = ORIENTED_EDGE('',*,*,#933,.T.);
|
|
||||||
#933 = EDGE_CURVE('',#22,#275,#934,.T.);
|
|
||||||
#934 = LINE('',#935,#936);
|
|
||||||
#935 = CARTESIAN_POINT('',(19.029295926024,-22.,-17.63009960955));
|
|
||||||
#936 = VECTOR('',#937,1.);
|
|
||||||
#937 = DIRECTION('',(0.,1.,0.));
|
|
||||||
#938 = ORIENTED_EDGE('',*,*,#282,.F.);
|
|
||||||
#939 = ORIENTED_EDGE('',*,*,#916,.F.);
|
|
||||||
#940 = ORIENTED_EDGE('',*,*,#31,.T.);
|
|
||||||
#941 = PLANE('',#942);
|
|
||||||
#942 = AXIS2_PLACEMENT_3D('',#943,#944,#945);
|
|
||||||
#943 = CARTESIAN_POINT('',(20.484588228021,-22.,-10.95643672209));
|
|
||||||
#944 = DIRECTION('',(0.977039526026,0.,-0.213058124893));
|
|
||||||
#945 = DIRECTION('',(-0.213058124893,0.,-0.977039526026));
|
|
||||||
#946 = ADVANCED_FACE('',(#947),#953,.F.);
|
|
||||||
#947 = FACE_BOUND('',#948,.F.);
|
|
||||||
#948 = EDGE_LOOP('',(#949,#950,#951,#952));
|
|
||||||
#949 = ORIENTED_EDGE('',*,*,#21,.T.);
|
|
||||||
#950 = ORIENTED_EDGE('',*,*,#860,.T.);
|
|
||||||
#951 = ORIENTED_EDGE('',*,*,#274,.F.);
|
|
||||||
#952 = ORIENTED_EDGE('',*,*,#933,.F.);
|
|
||||||
#953 = PLANE('',#954);
|
|
||||||
#954 = AXIS2_PLACEMENT_3D('',#955,#956,#957);
|
|
||||||
#955 = CARTESIAN_POINT('',(17.956031892536,-22.,-13.7484168618));
|
|
||||||
#956 = DIRECTION('',(-0.963836182336,0.,-0.26649542889));
|
|
||||||
#957 = DIRECTION('',(-0.26649542889,0.,0.963836182336));
|
|
||||||
#958 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3)
|
|
||||||
GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#962)) GLOBAL_UNIT_ASSIGNED_CONTEXT
|
|
||||||
((#959,#960,#961)) REPRESENTATION_CONTEXT('Context #1',
|
|
||||||
'3D Context with UNIT and UNCERTAINTY') );
|
|
||||||
#959 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) );
|
|
||||||
#960 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) );
|
|
||||||
#961 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() );
|
|
||||||
#962 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#959,
|
|
||||||
'distance_accuracy_value','confusion accuracy');
|
|
||||||
#963 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7));
|
|
||||||
ENDSEC;
|
|
||||||
END-ISO-10303-21;
|
|
||||||
@@ -1,922 +0,0 @@
|
|||||||
# Mate connectors: aligning with the mainstream CAD systems
|
|
||||||
|
|
||||||
Research date: 2026-08-05. Written against `orca_cad` / `Snapmaker` at the M8 state
|
|
||||||
(`CadDocument.{hpp,cpp}`, `apply_mate`, `datum_frame`, the `Mate` card in `DesignPanel.cpp`).
|
|
||||||
|
|
||||||
**Brief:** align with the mate-connector concept as the main CAD programs actually implement it,
|
|
||||||
and be simple, unequivocal, unconfusing. Alignment is the organising principle of this document:
|
|
||||||
every recommendation is labelled either **[INDUSTRY]** — do what they all do — or **[DEVIATION]** —
|
|
||||||
we would be departing, here is why and what it costs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. The answer in ten lines
|
|
||||||
|
|
||||||
1. Seven systems surveyed. **Five of the seven use the same model**; two are the old world.
|
|
||||||
2. The model: a joint is defined between **two local coordinate frames**, one rigidly attached to
|
|
||||||
each part, plus **one type** naming which DOF stay free.
|
|
||||||
3. The frame is called a mate connector (Onshape), a **joint origin** (Fusion, Inventor), a joint
|
|
||||||
connector (FreeCAD 1.0). Same object, three names.
|
|
||||||
4. **Every one of them expresses every DOF about the frame's Z axis.** One axis, one convention.
|
|
||||||
5. **Five types appear in every frame-based system with identical names and identical DOF**:
|
|
||||||
Fastened/Rigid, Revolute, Slider, Cylindrical, Planar. Ball is in four of five.
|
|
||||||
6. That is not fashion — those are the classical **lower kinematic pairs**. The vocabulary converged
|
|
||||||
because the mechanics converged.
|
|
||||||
7. Our kernel is already on the right side of the line: frame-based, five types, Z-relative,
|
|
||||||
superimpose-then-relax. **The architecture needs no revisiting.**
|
|
||||||
8. Where we are out of step: connectors that are not attached to a body; an origin that can only be
|
|
||||||
a face centroid; no live preview of the two Z arrows; a mate card of abstract dropdowns.
|
|
||||||
9. Where we would knowingly deviate: refusing a second mate per body (no vendor does this — it is
|
|
||||||
forced on us by having no solver) and possibly inverting the default mate direction.
|
|
||||||
10. Biggest single win for the stated goal, and it costs no kernel work: **draw both frames and
|
|
||||||
ghost the result before Confirm.** The convention stops needing to be remembered.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The two families
|
|
||||||
|
|
||||||
**Constraint-based ("old CAD").** The user states pairwise *geometric relations* between raw
|
|
||||||
topology — this face coincident with that face, this axis concentric with that axis, this plane
|
|
||||||
parallel at 12 mm. Each relation removes some DOF; a numerical solver satisfies all of them at once.
|
|
||||||
Fully positioning one part typically takes **three or more mates**, and the set can be
|
|
||||||
over-constrained, under-constrained, or satisfiable in several configurations.
|
|
||||||
|
|
||||||
**Frame-based ("mate connectors").** The user places a *local coordinate system* on each part and
|
|
||||||
states **one** relation between the two frames. The relation is not "these surfaces touch" but
|
|
||||||
"these frames coincide, except for the following DOF, which stay free."
|
|
||||||
|
|
||||||
Onshape's help page opens by drawing exactly this line:
|
|
||||||
|
|
||||||
> *"Mates in Onshape are different than mates in old CAD systems. Many assemblies require only one
|
|
||||||
> Onshape Mate between any two instances, as the movement (degrees of freedom) between those two
|
|
||||||
> instances is embedded in the Mate."*
|
|
||||||
|
|
||||||
The frame-based model won for three reasons, all of which matter here:
|
|
||||||
|
|
||||||
- **One mate per pair.** No mental arithmetic about which three constraints add up to a hinge.
|
|
||||||
- **The DOF are declared, not deduced.** A revolute mate *is* one rotation. You do not discover the
|
|
||||||
remaining freedom by dragging.
|
|
||||||
- **It needs no simultaneous solver for the common case.** Frame-to-frame alignment is a matrix
|
|
||||||
composition — precisely what `apply_mate` already does.
|
|
||||||
|
|
||||||
> **Caveat — several vendors ship both, and "align with X" is therefore ambiguous.** **Inventor**
|
|
||||||
> kept its legacy constraints *and* added frame-based Joints in 2012; many Inventor users still build
|
|
||||||
> assemblies entirely with the old constraint stack. **Creo** has placement constraints *and*
|
|
||||||
> Mechanism connections. **FreeCAD** had constraint-based Assembly2/3 add-ons before the frame-based
|
|
||||||
> Assembly workbench shipped in 1.0. So copying "what Inventor does" means copying **one of two
|
|
||||||
> coexisting workflows**. **Onshape and Fusion 360 are the only pure frame-based examples**, and they
|
|
||||||
> are the ones to weight most heavily when the evidence conflicts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Field survey — seven systems
|
|
||||||
|
|
||||||
| | Onshape | Fusion 360 | Inventor | FreeCAD 1.0 | Creo | Siemens NX | SOLIDWORKS |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| **Family** | Frame | Frame | Frame (+ legacy constraints) | Frame (+ legacy add-ons) | Both | Constraint | Constraint |
|
|
||||||
| **Frame object** | Mate connector | Joint origin | Joint origin | Joint connector (`Placement1/2`) | CSYS on `Weld`/`6DOF` | — | — (nearest: **mate reference**) |
|
|
||||||
| **Where it lives** | Part Studio **and** Assembly; in the feature list | Component, inside the joint | Component / inside the joint | Inside the Joint object | Part | — | Part (up to 3 named entities) |
|
|
||||||
| **Origin placement** | Inferred family on hover; `Shift` locks | Discrete **snap points**; `Ctrl` cycles | Snap points + explicit origins | Inferred, previewed on hover | Picked CSYS | Picked entities | Picked entities |
|
|
||||||
| **Orientation control** | Primary axis (Z) + secondary axis; flip + 90° reorient | Flip, angle, offsets | Flip, angle, offsets | `Placement1/2` + `Offset1/2` | CSYS + offset | — | — |
|
|
||||||
| **Type inference** | No — explicit | No — explicit | **Yes — "Automatic"** from picked geometry | No | No | No | Partial (mate reference type) |
|
|
||||||
| **Solver** | Yes, simultaneous — *"order won't affect a Mate"* | Yes | Yes | Yes (Ondsel) | Yes | Yes | Yes |
|
|
||||||
| **Reuse across instances** | **Yes** — a Part Studio connector exists on every instance | Weak | Partial | Per-joint | Interfaces | Product Interface | Mate references auto-mate on insert |
|
|
||||||
|
|
||||||
Three observations that shape everything below.
|
|
||||||
|
|
||||||
- **Every frame-based system reduced the type list by an order of magnitude** relative to SOLIDWORKS
|
|
||||||
(7–13 vs ~25) and lost nothing. That is not simplification-by-omission; it is what happens when the
|
|
||||||
DOF live in the mate instead of being assembled from constraints.
|
|
||||||
- **Every one of them defines its types relative to a single axis.** Slider translates along Z,
|
|
||||||
Revolute rotates about Z, Cylindrical does both, Planar translates in X/Y and rotates about Z.
|
|
||||||
One axis carries the whole vocabulary.
|
|
||||||
- **Onshape alone treats the connector as a first-class, reusable, named object** — and that is also
|
|
||||||
where its worst usability complaints come from (§4).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The type vocabulary — cross-system table
|
|
||||||
|
|
||||||
DOF = degrees of freedom left **free**, stated about/along the connector Z.
|
|
||||||
|
|
||||||
| DOF | Onshape | Fusion 360 | Inventor | FreeCAD 1.0 | Creo | **Ours today** |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| 0 | Fastened | Rigid | Rigid | Fixed | Rigid / Weld | **Fastened** ✅ |
|
|
||||||
| 1 — rot Z | Revolute | Revolute | Rotational | Revolute | Pin | **Revolute** ✅ |
|
|
||||||
| 1 — trans Z | Slider | Slider | Slider | Slider | Slider | **Slider** ✅ |
|
|
||||||
| 2 — rot + trans Z | Cylindrical | Cylindrical | Cylindrical | Cylindrical | Cylinder | **Cylindrical** ✅ |
|
|
||||||
| 3 — trans XY + rot Z | Planar | Planar | Planar | *(Parallel+Distance)* | Planar | **Planar** ✅ |
|
|
||||||
| 3 — rot XYZ | Ball | Ball | Ball | Ball | Ball | — |
|
|
||||||
| 2 — different axes | Pin slot | Pin-Slot | — | — | Slot / Bearing | — |
|
|
||||||
| 1 — coupled | Screw | — | — | Screw | — | — |
|
|
||||||
| 4 | Parallel | — | — | Parallel | — | — |
|
|
||||||
| other | Tangent, Width, Group | As-built | Automatic | Perpendicular, Angle, Distance, Gears, Belt, RackPinion | General, 6DOF | — |
|
|
||||||
|
|
||||||
**Five types appear in every frame-based system, with the same name and the same DOF.** Those five
|
|
||||||
are the industry's common denominator, and they are exactly `mate_kind` 0–4 as already implemented.
|
|
||||||
Ball is in four of five. Everything past that is a long tail no two vendors agree on.
|
|
||||||
|
|
||||||
### Why the convergence is a fact, not a fashion
|
|
||||||
|
|
||||||
A rigid-body placement is an element of SE(3). A mate leaves some set of relative motions free. For
|
|
||||||
the mate to behave the same throughout its range — for a hinge to be a hinge at every angle — that
|
|
||||||
free set must be **closed under composition**: two allowed motions must compose to an allowed motion.
|
|
||||||
A closed set of motions is a **subgroup** of SE(3).
|
|
||||||
|
|
||||||
The subgroups corresponding to physical surface-on-surface contact are the classical **six lower
|
|
||||||
pairs** (Reuleaux):
|
|
||||||
|
|
||||||
| Pair | Free motion relative to Z | DOF |
|
|
||||||
|---|---|---|
|
|
||||||
| Revolute (R) | rotation about Z | 1 |
|
|
||||||
| Prismatic / slider (P) | translation along Z | 1 |
|
|
||||||
| Helical / screw (H) | coupled rotation + translation | 1 |
|
|
||||||
| Cylindrical (C) | rotation about **and** translation along Z | 2 |
|
|
||||||
| Planar (E/G) | translation in X,Y + rotation about Z | 3 |
|
|
||||||
| Spherical / ball (S) | rotation about X, Y, Z | 3 |
|
|
||||||
|
|
||||||
Plus the two trivial ends: identity (0 DOF — **fastened**) and all of SE(3) (6 DOF — floating, i.e.
|
|
||||||
no mate). Hervé's Lie-subgroup analysis of the displacement group is the standard reference for
|
|
||||||
treating these as the algebraic building blocks of mechanism synthesis.
|
|
||||||
|
|
||||||
**Consequence.** Anything outside this table is either (a) a *composition* needing a solver, or
|
|
||||||
(b) not a joint at all but a *measurement*:
|
|
||||||
|
|
||||||
- Onshape's **Parallel** (4 DOF), **Tangent**, **Width**, **Pin slot**, and FreeCAD's **Distance /
|
|
||||||
Angle / Perpendicular** are constraints, not pairs — their free set is not a subgroup, so they only
|
|
||||||
make sense alongside a simultaneous solver.
|
|
||||||
- **Gear, Belt, Rack-and-pinion** are *relations between two mates*, a different object entirely.
|
|
||||||
- **Screw (H)** is a legitimate lower pair but needs a pitch parameter and is rare in printed parts.
|
|
||||||
|
|
||||||
So the vendors' shared five, the lower pairs, and our `mate_kind` 0–4 are the same list arrived at
|
|
||||||
three ways. **[INDUSTRY] Stop looking for missing types and spend the budget on the connector.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. What they all agree on — adopt verbatim
|
|
||||||
|
|
||||||
Deviating from any of these makes an experienced user's intuition *wrong*, which is the operational
|
|
||||||
definition of "confusing".
|
|
||||||
|
|
||||||
**A1 [INDUSTRY] — The connector is a full right-handed frame.**
|
|
||||||
Origin + Z (primary) + X (secondary). Onshape and Fusion expose exactly these two axis controls and
|
|
||||||
nothing else. A point cannot express spin; an axis cannot express clocking.
|
|
||||||
*Status: we comply* — `DatumCoordSys` carries origin/x/y and derives Z.
|
|
||||||
|
|
||||||
**A2 [INDUSTRY] — Z is the joint axis; every DOF is about or along Z.**
|
|
||||||
Revolute rotates about Z. Slider translates along Z. Planar's free plane is normal to Z. Offsets run
|
|
||||||
along Z. This single rule is what makes the system learnable: **one axis to look at, and its meaning
|
|
||||||
never changes.**
|
|
||||||
*Status: we comply* — `mate_offset` along A's z, `mate_angle` about A's z.
|
|
||||||
|
|
||||||
**A3 [INDUSTRY] — Mating superimposes the two frames; the type then relaxes specific DOF.**
|
|
||||||
FreeCAD states it most plainly: *"the second connector is superimposed on the first connector by
|
|
||||||
default and may change its position according to the joint type."* Fastened is not a special case —
|
|
||||||
it is the base case with nothing relaxed.
|
|
||||||
*Status: we comply* — `T = M_A · Rz · Tz · F · M_B⁻¹`, looser kinds relaxing from there.
|
|
||||||
|
|
||||||
**A4 [INDUSTRY] — The connector belongs to a part and moves with it.**
|
|
||||||
Onshape: a connector defined in a Part Studio *"is available for reuse on every instance of that part
|
|
||||||
in every assembly in which it is instanced."* It is part geometry, not assembly geometry.
|
|
||||||
*Status: **violated**.* `CoordSysType::PointWorld` is a bare world XYZ with `X = world X` and no
|
|
||||||
`coordsys_body`. Such a connector does not follow its part. See §6 G1.
|
|
||||||
|
|
||||||
**A5 [INDUSTRY] — Selection order is meaningful and must be visible.**
|
|
||||||
One connector is the reference; the other is driven onto it. Onshape spells out that offsets are
|
|
||||||
measured *"from the second Mate connector selected to the first"*, and that reversing the order
|
|
||||||
flips the sign.
|
|
||||||
*Status: complied with in the data model* (`mate_cs_a` fixed, `mate_cs_b` moves) *but not in the UI* —
|
|
||||||
two dropdowns labelled A and B do not tell the user which part is about to jump.
|
|
||||||
|
|
||||||
**A6 [INDUSTRY] — Flip and re-clock live in the mate dialog, always.**
|
|
||||||
Onshape: *"Click the arrow icon to flip the direction of the primary axis. Click the Reorient
|
|
||||||
secondary axis icon to rotate the secondary axis in 90-degree increments."*
|
|
||||||
*Status: partial.* We have `mate_flip` (Z reversal). We have `mate_angle` as a free number — strictly
|
|
||||||
more powerful than 90° steps, and much worse to *use*: the common case is "it came in a quarter turn
|
|
||||||
out", and typing 90 is a worse gesture than pressing a button.
|
|
||||||
|
|
||||||
**A7 [INDUSTRY] — DOF are shown, not inferred by the user.**
|
|
||||||
Onshape animates each mate's remaining DOF on demand; Fusion and Inventor name the DOF in the type
|
|
||||||
list. Our dropdown text already does this in words ("free spin + axial slide"). Keep it.
|
|
||||||
|
|
||||||
**A8 [INDUSTRY] — Free DOF are preserved from the current placement, not zeroed.**
|
|
||||||
Onshape: a Planar mate aligns the frames *"but they are not restricted to this location with respect
|
|
||||||
to their degrees of freedom."*
|
|
||||||
*Status: we comply* — and it must be *said*, because a Planar mate that leaves the part where it was
|
|
||||||
looks like a mate that did nothing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Where they diverge — who to copy, and why
|
|
||||||
|
|
||||||
### D1 — Where the connector's origin comes from
|
|
||||||
|
|
||||||
| | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| **Fusion 360** | Discrete **snap points** only: vertex, edge midpoint, face centre, arc centre. `Ctrl` cycles the candidates under the cursor. A circle icon denotes a vertex, a triangle a midpoint. "Between two faces" is a separate explicit option. |
|
|
||||||
| **Onshape** | Infers a *family* on hover — centroid, every vertex, every edge midpoint, every arc centre, the centroids of interior regions (holes, slots), and the virtual sharps of conical faces. `Shift` locks the current candidate. |
|
|
||||||
| **Inventor** | Snap points, plus explicit joint origins for awkward cases. |
|
|
||||||
| **FreeCAD 1.0** | Hovering previews where the connector will land before you commit. |
|
|
||||||
| **Ours** | Always the **face centroid**. No alternative exists. |
|
|
||||||
|
|
||||||
Onshape's richness has a cost its own documentation admits: *"The suggested locations are based on
|
|
||||||
the underlying geometry of the part and changing the geometry will change the location of the Mate.
|
|
||||||
This can be undesirable in certain situations."* On the forum this shows up as connectors that move
|
|
||||||
or break on edit — the classic topological-naming failure. Fusion's discrete set is poorer and far
|
|
||||||
more predictable.
|
|
||||||
|
|
||||||
> **[INDUSTRY] Copy Fusion's candidate *set*.** A small, closed, enumerable set — **face centroid,
|
|
||||||
> vertex, edge midpoint, arc/circle centre** — each drawn before commit, with the card naming which is
|
|
||||||
> in use ("Origin: edge midpoint"). This is our largest expressiveness gap: a face centroid alone
|
|
||||||
> cannot place a hinge pin on a corner boss. It is also the one place where copying the *simpler*
|
|
||||||
> vendor is clearly right.
|
|
||||||
>
|
|
||||||
> **Open sub-choice — how the candidate is chosen.** Three options, in increasing order of magic:
|
|
||||||
> (1) **explicit dropdown** in the card after picking the face — no hover behaviour at all;
|
|
||||||
> (2) **Fusion's `Ctrl` cycling** through candidates under the cursor; (3) **Onshape's hover
|
|
||||||
> inference**. Kimi's independent review argued for (1) on the grounds that hover is exactly where
|
|
||||||
> both vendors' instability complaints originate, and that a dropdown gets ~90% of the expressiveness
|
|
||||||
> with none of the hover-guess debugging. That is a fair reading and (1) is the cheapest to build and
|
|
||||||
> the easiest to make unequivocal. **Recommendation: build (1) first; if hover is added later, let it
|
|
||||||
> *pre-fill the dropdown* rather than silently create an implicit connector** — which also keeps R2
|
|
||||||
> (one kind of connector) intact.
|
|
||||||
|
|
||||||
### D2 — Explicit type, or inferred from the geometry?
|
|
||||||
|
|
||||||
Inventor is the only surveyed system that infers: *"Rotational is selected if the two selected
|
|
||||||
origins are circular. Cylindrical if the two selected origins are points on a cylinder. Ball if
|
|
||||||
points on a sphere. Rigid for all other origin selections."* Onshape and Fusion require an explicit
|
|
||||||
choice.
|
|
||||||
|
|
||||||
> **[INDUSTRY, Inventor] Do both, in Inventor's order.** Infer a *default* type from what was picked,
|
|
||||||
> then show it in an editable control. Inference is what makes the tool feel like it understands the
|
|
||||||
> geometry; the visible, editable result is what keeps it unequivocal. Pure inference with no visible
|
|
||||||
> type is the confusing option; a pure dropdown with no default is the tedious one. This also fits
|
|
||||||
> the Design tab's geometry-first charter exactly: point at a bore, get Revolute offered.
|
|
||||||
|
|
||||||
### D3 — How the Z-direction ambiguity is resolved
|
|
||||||
|
|
||||||
This is the specific failure the brief is aimed at. A former IT trainer stated it precisely on the
|
|
||||||
Onshape forum:
|
|
||||||
|
|
||||||
> *"There is always the risk that users will build their own conceptual models of how software works
|
|
||||||
> which may not match the designer's concept. The result is usually a poor user experience and many
|
|
||||||
> mistakes… for a good (say) Fixed mate to occur do the Z axes of the two mates have to be pointing
|
|
||||||
> in the same direction… Alternatively, should they be facing each other?"*
|
|
||||||
|
|
||||||
He is asking the right question and **no vendor's documentation answers it.** Onshape's own advice —
|
|
||||||
*"if the behavior is not what you expected, try flipping the primary and/or secondary axis"* — is
|
|
||||||
trial and error. This is a gap in the industry, not a convention to copy.
|
|
||||||
|
|
||||||
> **[INDUSTRY, method] Resolve it with live preview, not documentation.** FreeCAD previews the
|
|
||||||
> connector on hover; Onshape and Fusion both draw the frames. Draw **both** Z arrows the moment the
|
|
||||||
> second connector is picked, and ghost the resulting placement *before* Confirm. The convention then
|
|
||||||
> never has to be remembered because it is on screen.
|
|
||||||
>
|
|
||||||
> **[DEVIATION, optional] Name the two cases in the user's words** rather than in axis-speak:
|
|
||||||
> "the two faces come together" vs "the axes run the same way". No surveyed vendor does this — they
|
|
||||||
> all ship a flip arrow. It is a small, low-risk improvement on the state of the art, and it is
|
|
||||||
> separable from the default-direction question in §8 D1.
|
|
||||||
|
|
||||||
### D4 — Named, reusable connectors on the part
|
|
||||||
|
|
||||||
Onshape: connectors created in the Part Studio are reused on every instance in every assembly.
|
|
||||||
SOLIDWORKS' **mate reference** reaches the same end by another route: up to three named entities
|
|
||||||
(primary/secondary/tertiary) baked into the part so it auto-mates on drag-and-drop — and a *named*
|
|
||||||
mate reference seeks out a matching name on insertion. That naming trick is how a library of
|
|
||||||
fasteners assembles itself.
|
|
||||||
|
|
||||||
> **[INDUSTRY] Out of scope now, but do not preclude it.** Give connectors a stable, user-visible
|
|
||||||
> name at creation. One string today; expensive to add once documents exist in the wild.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Confusion catalogue
|
|
||||||
|
|
||||||
Documented ways real implementations confuse people. Each is a requirement in disguise.
|
|
||||||
|
|
||||||
**C1 — Which way does Z point?** See D3. If a user has to ask once, they will mis-predict a hundred
|
|
||||||
times.
|
|
||||||
|
|
||||||
**C2 — The roll is unspecified.** Aligning Z leaves one rotation about Z undetermined. Something must
|
|
||||||
pin it, and if that something is world-derived, the frame does not rotate with its part. **This
|
|
||||||
codebase shipped exactly this bug** (`en4`): a face-only connector took Z from the face
|
|
||||||
normal but X from `coordsys_x_hint`, a world constant, so Fastened and Slider claimed to lock an
|
|
||||||
orientation the frame could not see. Fixed 2026-07-26 by deriving X from the face's own first usable
|
|
||||||
edge — but note the fix's own caveat: *"replaying an older document whose face-only connector fed a
|
|
||||||
mate can now place that body differently."* Roll conventions are load-bearing, and changing one is a
|
|
||||||
document-format change.
|
|
||||||
|
|
||||||
**C3 — The origin drifts.** See D1.
|
|
||||||
|
|
||||||
**C4 — Implicit and explicit connectors are not the same thing.** On the Onshape forum, implicit
|
|
||||||
connectors are reported to change their query structure when a feature is edited and re-accepted, and
|
|
||||||
are unusable in places explicit ones work. Two things called by one name that behave differently is a
|
|
||||||
permanent tax.
|
|
||||||
|
|
||||||
**C5 — Which part moves?** A frame alignment is asymmetric. If the UI does not say which frame is
|
|
||||||
driven, the user finds out by watching the wrong part jump.
|
|
||||||
|
|
||||||
**C6 — Which direction is a positive offset?** Onshape measures *"from the second Mate connector
|
|
||||||
selected to the first"* — the sign depends on pick order, and swapping the picks flips it. Documented
|
|
||||||
behaviour, documented surprise.
|
|
||||||
|
|
||||||
**C7 — One intent, several mates.** The SOLIDWORKS failure: expressing "this shaft is in this hole,
|
|
||||||
resting on this shoulder" as three constraints, then discovering the solver picked the mirror
|
|
||||||
configuration. Frame-based systems fix this by construction; the requirement is not to reintroduce it.
|
|
||||||
|
|
||||||
**C8 — Degenerate frames.** A circular face has no usable in-plane edge direction; a cylinder seam
|
|
||||||
projects to nothing; a picked edge parallel to Z gives a zero cross product. `datum_frame` handles all
|
|
||||||
three with fallbacks — the requirement is that a fallback be *visible*, because a silent fallback is
|
|
||||||
C2 wearing a different hat.
|
|
||||||
|
|
||||||
**C9 — Order dependence without a solver.** Onshape can say *"Onshape solves Mates simultaneously so
|
|
||||||
order won't affect a Mate."* A system that composes transforms in tree order cannot say that. Two
|
|
||||||
mates driving one body means the second wins and the first is a lie on screen.
|
|
||||||
|
|
||||||
**C10 — Mirrors and patterns.** A mirrored instance has a left-handed frame. Blindly mirroring a
|
|
||||||
connector gives a frame whose Z still points "out" but whose handedness flipped, so every rotation
|
|
||||||
runs backwards. Cheap to handle now, miserable to retrofit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Requirements
|
|
||||||
|
|
||||||
Labelled **[INDUSTRY]** (what the frame-based systems do) or **[DEVIATION]** (we would depart).
|
|
||||||
|
|
||||||
### Definition
|
|
||||||
|
|
||||||
**R1 [INDUSTRY] — A mate connector is a frame attached to exactly one body.** No body, no connector.
|
|
||||||
*Test:* creating a connector without a body is rejected at creation, not at mate time.
|
|
||||||
→ **`CoordSysType::PointWorld` violates this.** It is a datum wearing a connector's name.
|
|
||||||
|
|
||||||
**R2 [INDUSTRY] — One kind of connector, not two.** No "implicit" connector that behaves differently
|
|
||||||
from an explicit one. If hover inference is offered, hovering *creates* an ordinary connector.
|
|
||||||
*Why:* C4. *Test:* everything that accepts a connector accepts any connector.
|
|
||||||
|
|
||||||
**R3 [INDUSTRY] — A mate names exactly one subgroup of free motion.** Fastened (0), Revolute (1),
|
|
||||||
Slider (1), Cylindrical (2), Planar (3), optionally Ball (3). *Why:* §3. *Test:* every type's free
|
|
||||||
set is closed; no type is "A and also B".
|
|
||||||
|
|
||||||
### Orientation
|
|
||||||
|
|
||||||
**R4 [INDUSTRY] — Everything is about Z. Say so once, in the UI.** *Test:* no mate parameter refers
|
|
||||||
to any other axis.
|
|
||||||
|
|
||||||
**R5 [DEVIATION] — Z is the outward material direction, and mates default to FACING.**
|
|
||||||
A mate would drive B's Z onto **−A's Z** by default, so picking two faces that should touch makes
|
|
||||||
them touch with no options changed. *Why:* it is the whole of C1.
|
|
||||||
**Cost and caveat:** this inverts today's default (`mate_flip=false` currently *aligns*), and I could
|
|
||||||
not establish from any vendor's documentation what their default actually is — the forum question in
|
|
||||||
D3 went unanswered precisely because it is undocumented. So this is marked a deviation on the honest
|
|
||||||
grounds that **I cannot prove the industry agrees with it.** If D3's live preview lands first, the
|
|
||||||
default matters much less, because the user sees the outcome before committing. See §9 D1.
|
|
||||||
|
|
||||||
**R6 [DEVIATION] — Name the two directions; do not ship a boolean called "flip".**
|
|
||||||
`Direction: Facing | Aligned`. Every surveyed vendor ships a flip arrow instead. A boolean requires
|
|
||||||
remembering what unticked means; two named values do not. Low risk, small improvement on the state of
|
|
||||||
the art.
|
|
||||||
|
|
||||||
**R7 [INDUSTRY] — Roll is picked, or a stored quarter turn. Never world-derived.**
|
|
||||||
X from a referenced edge or in-plane direction; failing that, a deterministic body-attached seed, with
|
|
||||||
**Rotate 90°** offered as a stored integer 0–3 on top (this is Onshape's "reorient secondary axis",
|
|
||||||
A6). *Why:* C2 and the world-constant bug this project already shipped. *Test:* rotate the parent
|
|
||||||
body by any angle; the connector's X rotates with it — *this test already exists* ("a face-only frame
|
|
||||||
rotates with its body").
|
|
||||||
|
|
||||||
**R8 [INDUSTRY] — A degenerate roll is reported, not absorbed.** *Test:* a connector on a full
|
|
||||||
cylindrical face reports "roll undefined — pick a direction" rather than silently taking a fallback.
|
|
||||||
|
|
||||||
### Placement
|
|
||||||
|
|
||||||
**R9 [INDUSTRY, Fusion] — Origin comes from a small closed set of named candidates.**
|
|
||||||
**Face centroid, arc/circle centre, edge midpoint, vertex.** Four. Each stored as
|
|
||||||
`(kind, topological reference)` and resolved at rebuild. *Why:* D1. *Test:* the stored kind is visible
|
|
||||||
in the card; a rebuild either resolves it or raises an error.
|
|
||||||
|
|
||||||
**R10 [INDUSTRY] — An unresolvable reference is an error, never a silent relocation.**
|
|
||||||
*Test:* delete the referenced face; the mate reports "connector A: face not found" and the body stays
|
|
||||||
where it was.
|
|
||||||
|
|
||||||
### Semantics without a solver
|
|
||||||
|
|
||||||
**R11 [DEVIATION] — A body is driven by at most one mate. The second is refused.**
|
|
||||||
**No surveyed system does this** — they all have solvers and all accept many mates per body. It is
|
|
||||||
forced on us by tree-order composition: a second mate on the same body silently overrides the first
|
|
||||||
and the screen shows a configuration satisfying only one stated intent (C9). *Test:* creating a
|
|
||||||
second mate whose moving body already has one is rejected, naming the existing mate.
|
|
||||||
This is the single largest departure in this document. See §9 D4.
|
|
||||||
|
|
||||||
> **A tempting misreading, checked and rejected.** It is easy to find the claim that Onshape mandates
|
|
||||||
> *"exactly one Mate between any two instances"*, which would make R11 an industry agreement rather
|
|
||||||
> than a deviation. **The Onshape page does not say that.** It says *"**Many assemblies require only**
|
|
||||||
> one Onshape Mate between any two instances"* and then lists, as an explicit remedy, *"**Use more
|
|
||||||
> than one Mate if necessary.**"* One mate per pair is Onshape's *typical case*, not its rule. R11
|
|
||||||
> remains a deviation and must be justified on our own architecture, not on theirs.
|
|
||||||
|
|
||||||
**R11a [DEVIATION] — The refusal list.** With no solver, these are unsupportable and must be refused
|
|
||||||
rather than half-done: a second mate on an already-driven body; cycles (A→B, B→A); closed loops
|
|
||||||
(A→B, A→C, B→C); relations *between* mates (gear, belt, rack-and-pinion, screw coupling); **joint
|
|
||||||
limits**, which nothing can enforce without a solver; and **dragging a body to exercise a free DOF**,
|
|
||||||
which requires keeping the body on the allowed manifold. Motion analysis and animation follow from the
|
|
||||||
same lack. *Requirement:* none of these may appear in the UI as something that half-works.
|
|
||||||
|
|
||||||
**R12 [DEVIATION] — The mate graph is an acyclic forest rooted at fixed bodies.** A body reached by
|
|
||||||
no mate is fixed; cycles are refused. Same root cause as R11. *Test:* A→B, B→A rejected at creation.
|
|
||||||
|
|
||||||
**R13 [INDUSTRY] — Free DOF are preserved from the current placement, and the user is told.**
|
|
||||||
Behaviour already matches Onshape (A8); the telling does not. *Test:* the card for any type with
|
|
||||||
DOF > 0 says which motions remain and that dragging exercises them.
|
|
||||||
|
|
||||||
**R14 [INDUSTRY] — State what mirroring does to a connector.**
|
|
||||||
*Checked in the code:* `datum_frame` ends with a Gram-Schmidt forcing a right-handed frame
|
|
||||||
(`ds.x = Y.cross(Z)`), so a connector resolved on a mirrored body comes out **right-handed, not
|
|
||||||
mirror-imaged**. Z follows the mirrored face's outward normal, X follows a mirrored edge, handedness
|
|
||||||
is re-imposed. Defensible — a mate on the mirrored part still turns the way its type says — but it
|
|
||||||
means a mirrored sub-assembly is *not* the mirror image of the original in its rotation sense.
|
|
||||||
*Requirement:* document it and pin it with a test. *Why:* C10.
|
|
||||||
|
|
||||||
### Feedback — the part that actually removes confusion
|
|
||||||
|
|
||||||
**R15 [INDUSTRY] — Before Confirm, the card answers four questions in words.** Which body moves;
|
|
||||||
which way Z points on each connector; how many DOF remain; what the offset is measured from.
|
|
||||||
|
|
||||||
**R16 [INDUSTRY] — Draw both frames live, with Z distinguishable, and ghost the result.**
|
|
||||||
Two triads with Z rendered differently from X/Y (length, arrowhead, colour). *Why:* D3 — the fastest
|
|
||||||
way to make a convention unequivocal is to show it. *Test:* both Z directions are readable in a
|
|
||||||
screenshot.
|
|
||||||
|
|
||||||
**R17 [INDUSTRY] — Show the DOF budget per body.** "Body 2: 1 of 6 DOF free (rotation about Z)."
|
|
||||||
The most educational readout in any assembly system, and free to compute here — the type *is* the DOF
|
|
||||||
count. *Test:* the number changes when the type changes.
|
|
||||||
|
|
||||||
**R18 [DEVIATION] — Refuse loudly and name the alternative.** Where something is out of scope (a
|
|
||||||
second mate, a tangency, a gear ratio), say what is unsupported and what to do instead. Vendors do not
|
|
||||||
need this because their solvers accept the input. *Test:* no refusal message ends without a suggested
|
|
||||||
next action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Minimal specification, and gap analysis
|
|
||||||
|
|
||||||
### The connector
|
|
||||||
|
|
||||||
```
|
|
||||||
MateConnector
|
|
||||||
body int required, ≥ 0 (R1)
|
|
||||||
origin_kind enum FaceCentroid | ArcCentre | EdgeMidpoint | Vertex (R9)
|
|
||||||
origin_ref topo ref face / edge / vertex index on that body
|
|
||||||
z_source implied by origin_kind: face normal, arc axis, edge tangent
|
|
||||||
roll_ref topo ref optional in-plane edge; else deterministic seed (R7)
|
|
||||||
roll_quarters int 0..3 stored quarter turns on top of the seed (R7, A6)
|
|
||||||
flip_z bool reverse Z at the connector
|
|
||||||
name string stable, user-visible (D4)
|
|
||||||
```
|
|
||||||
|
|
||||||
`flip_z` is a property of the **connector**, chosen once when it is made — not a per-mate
|
|
||||||
afterthought. Keeping connector-flip and mate-direction separate is what stops the "which flip do I
|
|
||||||
tick?" question.
|
|
||||||
|
|
||||||
### The mate
|
|
||||||
|
|
||||||
```
|
|
||||||
Mate
|
|
||||||
kind enum Fastened | Revolute | Slider | Cylindrical | Planar [| Ball] (R3)
|
|
||||||
fixed connector A — its body does not move
|
|
||||||
moving connector B — its body is driven (A5, C5)
|
|
||||||
direction enum Facing | Aligned (R5, R6)
|
|
||||||
offset mm along A's Z, measured A → B — state this in the label (C6)
|
|
||||||
angle deg about A's Z (R4)
|
|
||||||
```
|
|
||||||
|
|
||||||
Within one field of what exists.
|
|
||||||
|
|
||||||
### Gaps against today
|
|
||||||
|
|
||||||
Source of record: `CadDocument.hpp:26,247-252,298-310`; `CadDocument.cpp:1669` (`datum_frame`),
|
|
||||||
`:2961` (`apply_mate`), `:1302` (`add_mate`); `DesignPanel.cpp:2671-2709` (the Mate card).
|
|
||||||
|
|
||||||
| # | Gap | Severity | Ref |
|
|
||||||
|---|---|---|---|
|
|
||||||
| G1 | `PointWorld` connectors are not attached to a body and their X is a world constant | **High — data model** | A4/R1 |
|
|
||||||
| G2 | Origin is always the face centroid; no vertex / edge-midpoint / arc-centre snap | **High — expressiveness** | D1/R9 |
|
|
||||||
| G3 | No live preview of the two Z arrows or of the resulting placement | **High — this is the brief** | D3/R16 |
|
|
||||||
| G4 | Mate card is two abstract dropdowns; nothing says which body moves | High — charter + A5 | R15 |
|
|
||||||
| G5 | No joint-type inference from the picked geometry | Medium — feel | D2 |
|
|
||||||
| G6 | `add_mate` validates nothing — no one-mate-per-body, no cycle check | Medium | R11/R12 |
|
|
||||||
| G7 | No `Ball` type | Low | §3 |
|
|
||||||
| G8 | Re-clocking needs a typed angle; no 90° step control | Low, cheap | A6/R7 |
|
|
||||||
| G9 | Degenerate roll falls back silently | Low | C8/R8 |
|
|
||||||
| G10 | Connectors have no stable user-facing name | Low now, expensive later | D4 |
|
|
||||||
|
|
||||||
**Already aligned — do not "fix" these:** the five types and their DOF; the frame definition (A1);
|
|
||||||
Z as the joint axis (A2); superimpose-then-relax (A3); the fixed/moving asymmetry in the data model
|
|
||||||
(A5); DOF wording in the type list (A7); free-DOF preservation (A8); right-handed frames under mirror
|
|
||||||
(R14); and `en4`'s fix, which put roll derivation on the body where it belongs (C2).
|
|
||||||
|
|
||||||
**The pattern worth naming: the kernel is in good shape and the concept is under-explained.** Half the
|
|
||||||
requirements here are wording and drawing, not geometry. The two real engineering items are R9 (origin
|
|
||||||
candidates) and R11/R12 (the mate-graph rules).
|
|
||||||
|
|
||||||
### Expensive-to-retrofit decisions — get these right in the data model now
|
|
||||||
|
|
||||||
Changing any of these after documents exist in the wild costs a migration, not an edit.
|
|
||||||
|
|
||||||
1. **Topological reference stability.** Storing raw face/edge indices is brittle — editing a body
|
|
||||||
renumbers faces. Either persistent topology IDs, or store the named origin *kind* plus a
|
|
||||||
deterministic search that re-finds the same geometric intent on rebuild. The latter is cheaper and
|
|
||||||
probably sufficient here; it is also what makes R10's "error, never silent relocation" enforceable.
|
|
||||||
2. **Connector ownership** (R1). Remove `PointWorld` or bind it to a body. Do this first.
|
|
||||||
3. **Mate direction semantics** (R5/D1). Inverting the default rewrites the meaning of every saved
|
|
||||||
mate.
|
|
||||||
4. **Roll representation** (R7). "First usable edge" is better than world-X but still fragile. Store
|
|
||||||
an explicit roll reference plus quarter turns.
|
|
||||||
5. **Coordinate convention** — Z = joint axis, X = roll reference. Changing this after release
|
|
||||||
invalidates every mate.
|
|
||||||
6. **Units** — offset in mm, angle in degrees. Never change.
|
|
||||||
7. **Mirror handedness** (R14) — document the decision, do not let it stay an accident.
|
|
||||||
8. **Flat body index vs. a component tree.** Mates currently reference bodies in a flat vector. If
|
|
||||||
**sub-assemblies** are ever in scope, mates must reference nodes in a tree instead. Retrofitting
|
|
||||||
this is painful and it is the one item on this list not already implied elsewhere in the document —
|
|
||||||
**decide now whether nested assemblies are in scope.**
|
|
||||||
9. **Serialization field semantics.** Adding fields is easy; redefining `mate_flip` or
|
|
||||||
`coordsys_x_hint` is not.
|
|
||||||
10. **The one-mate-per-body rule** (R11). Enforce at creation. Relaxing it later by adding a solver is
|
|
||||||
straightforward; allowing many mates now and discovering later that they silently conflict is not.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8b. The visual shape of the connector — polarity and verse
|
|
||||||
|
|
||||||
Researched separately (2026-08-05) by downloading and **looking at** the vendors' own figures, not
|
|
||||||
by reading their prose. Files kept alongside this document in `doc/design/mate-connectors/`.
|
|
||||||
|
|
||||||
### What the systems actually draw
|
|
||||||
|
|
||||||
**Onshape** — verified from `planarfacemateconnectors.png`, `cylindricalmateconnectors.png`,
|
|
||||||
`linearedgemateconnectors.png`, `mateconnector-planarpoints.png`, `matepointiconLG.png`:
|
|
||||||
|
|
||||||
> **A small circle with one quadrant filled, plus three short coloured axis arms (X red, Y green,
|
|
||||||
> Z blue).**
|
|
||||||
|
|
||||||
Three parts, each doing one job:
|
|
||||||
|
|
||||||
| Element | What it says |
|
|
||||||
|---|---|
|
|
||||||
| The **circle** | "I am a frame, and this is my XY plane." |
|
|
||||||
| The **filled quadrant** | **The roll.** The shaded sector is the +X/+Y quadrant. |
|
|
||||||
| The **coloured arms** | The three axis directions, Z distinguished by colour. |
|
|
||||||
|
|
||||||
The quadrant is the cleverest part of the whole design and it is easy to miss. The figure
|
|
||||||
`matepointreorientsecondaryaxis.png` shows three connectors side by side with the quadrant in three
|
|
||||||
different rotations — **it is the live readout of "reorient secondary axis in 90° increments" (A6).**
|
|
||||||
One glyph element makes the otherwise-invisible clocking visible, and makes the 90° button's effect
|
|
||||||
legible before you commit. The toolbar icon `matepointiconLG.png` is that same circle-with-a-quadrant,
|
|
||||||
so the symbol is consistent from toolbar to viewport.
|
|
||||||
|
|
||||||
Candidate snap points, before you choose one, are drawn as **plain small white dots** on the model
|
|
||||||
(clear in `mateconnector-planarpoints.png`: dots at every corner and edge midpoint). Candidate and
|
|
||||||
committed are deliberately different weights — dots propose, the circle-and-triad commits.
|
|
||||||
|
|
||||||
**FreeCAD 1.0** — verbatim from the wiki: *"Connectors are local coordinate systems and are marked by
|
|
||||||
a symbol with three axes (X, Y, Z) and a circle representing the XY-plane."* Same core as Onshape —
|
|
||||||
circle plus triad — **without** the quadrant.
|
|
||||||
|
|
||||||
**Fusion 360** — the joint origin glyph, plus a documented icon language for *candidates*: *"A circle
|
|
||||||
denotes a vertex, and a triangle denotes a midpoint."* Shape encodes what kind of point it is.
|
|
||||||
|
|
||||||
**Convergent core:** *circle for the XY plane + coloured triad*. Onshape alone adds the roll quadrant.
|
|
||||||
|
|
||||||
### What none of them draw — and it is exactly what was asked for
|
|
||||||
|
|
||||||
**Nothing in any vendor's glyph says which connector is the reference and which one is about to
|
|
||||||
move.** Both ends of a mate are drawn identically. That is confusion C5 ("which part moves?") left
|
|
||||||
unsolved in the visual language, and it is why the honest recommendation earlier was a live ghost —
|
|
||||||
the ghost compensates for a glyph that does not carry the information.
|
|
||||||
|
|
||||||
So the two things asked for split cleanly, and only one of them is solved upstream:
|
|
||||||
|
|
||||||
- **Verse** (*verso* — which way it points): **solved**. Z has a colour and a direction.
|
|
||||||
- **Polarity** (which end receives, which end inserts; who is anchored, who travels): **unsolved
|
|
||||||
everywhere.** This is open ground, and getting it right is a genuine improvement rather than a
|
|
||||||
deviation to justify.
|
|
||||||
|
|
||||||
### Our starting point
|
|
||||||
|
|
||||||
**We draw nothing.** `resolve_datum_coordsys()` (`CadDocument.cpp:1749`) has exactly one consumer in
|
|
||||||
the entire tree — `McpControl.cpp:1310`, the agent socket. A mate connector is today visible only to
|
|
||||||
a program. The glyph is unbuilt, so there is no migration cost to designing it properly now.
|
|
||||||
|
|
||||||
### Proposed glyph: the magnet
|
|
||||||
|
|
||||||
Adopt Onshape's proven core, then add the missing polarity with a metaphor that carries its own
|
|
||||||
instructions.
|
|
||||||
|
|
||||||
```
|
|
||||||
▲ solid cone on +Z ONLY ← verse
|
|
||||||
|
|
|
||||||
────●──── ← the disc = XY plane, ● = exact origin
|
|
||||||
▨ quadrant filled ← roll / clocking, steps 90°
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule 1 — verse: draw +Z and never −Z.** A single stem with a cone head, on the positive side only.
|
|
||||||
No stem below the disc. A double-headed axis is the one thing that guarantees the question gets asked;
|
|
||||||
an arrow that exists on one side only cannot be misread. Length is asymmetric on purpose.
|
|
||||||
|
|
||||||
**Rule 2 — roll: keep Onshape's quadrant.** Filled sector = the +X/+Y quadrant. It rotates in 90°
|
|
||||||
steps with the reorient control (A6/R7). This is aligned *and* it is the only in-glyph answer to
|
|
||||||
"where is X?", which matters because Fastened and Slider lock the clocking.
|
|
||||||
|
|
||||||
**Rule 3 — polarity: solid cone travels, open collar receives.**
|
|
||||||
- The **driven** connector (B, on the body that will move) draws a **solid filled cone** — the plug.
|
|
||||||
- The **fixed** connector (A) draws an **open ring / hollow cone outline** — the socket.
|
|
||||||
|
|
||||||
Same silhouette, so they read as a matched pair; opposite fill, so which one is about to jump is
|
|
||||||
answerable at a glance and without a legend. Plug-into-socket is the one mechanical metaphor every
|
|
||||||
user of this tool already has in their hands.
|
|
||||||
|
|
||||||
**Rule 4 — the pair reads as a magnet.** Draw a dashed line joining the two origins the moment both
|
|
||||||
are picked. Two poles, one field line. And because a magnet's north seeks a south, **"facing" becomes
|
|
||||||
the self-evident default** — which quietly settles open decision D1 (§9) on visual grounds rather than
|
|
||||||
on a convention nobody can look up. If the glyph looks like a magnet, nobody has to be told that two
|
|
||||||
faces which touch have opposed normals.
|
|
||||||
|
|
||||||
**Rule 5 — three states, three weights.**
|
|
||||||
|
|
||||||
| State | Drawing |
|
|
||||||
|---|---|
|
|
||||||
| **Candidate** (hover) | small dot only — Onshape's white dots; shape may encode kind, Fusion-style |
|
|
||||||
| **Picked** | full glyph: disc + quadrant + cone |
|
|
||||||
| **Degenerate roll** (C8/R8) | the quadrant is drawn **hollow/hatched** — "roll undefined, pick a direction" |
|
|
||||||
|
|
||||||
That last row is worth the trouble: it turns R8 from a message nobody reads into a mark you cannot
|
|
||||||
miss, and it costs one branch in the renderer.
|
|
||||||
|
|
||||||
**Rule 6 — do not reuse the existing triad.** The bed-centre world triad
|
|
||||||
(`DesignCanvas.cpp:65`, `set_axes_at_bed_center`) and the move gizmo are already three-coloured arrows.
|
|
||||||
The connector must not be a fourth set of RGB arrows or the viewport becomes unreadable. The disc and
|
|
||||||
the quadrant are what distinguish it; keep the arms short, and consider drawing only Z on the
|
|
||||||
committed glyph, with X/Y implied by the quadrant.
|
|
||||||
|
|
||||||
### Built and judged in the viewport, not in a mock
|
|
||||||
|
|
||||||
The browser mock that first accompanied this section was the wrong instrument and its proportions
|
|
||||||
were meaningless: **every gizmo in this codebase is sized in SCREEN PIXELS** via `upp = 1/zoom`
|
|
||||||
(`render_shell_gizmo` uses `15.0 * upp`, `render_hole_gizmo` `9.0 * upp` for its cube). A connector
|
|
||||||
is a symbol, not a part — it must not shrink with the model. Nothing about that is visible in SVG.
|
|
||||||
|
|
||||||
The glyph was therefore implemented and driven on the rig. Screenshots: `g-0*.png`, left in the workspace `artifacts/shots/` and not moved into the repo.
|
|
||||||
Five findings, none of which a mock could have produced:
|
|
||||||
|
|
||||||
**F1 — Three axis arms lose to one.** Rendered side by side (`ORCA_CAD_GLYPH=A` vs default), the
|
|
||||||
Onshape-style RGB trio crowds a 22 px disc: the arrowheads are as large as the disc, they bury the
|
|
||||||
gold quadrant, and at an oblique angle the three heads pile into a coloured smudge. Worse, **it is
|
|
||||||
indistinguishable from the move gizmo and the bed triad**, which are already RGB arrow trios in this
|
|
||||||
viewport. One-sided Z wins on evidence, not taste. (`g-01-zoom.png` vs `g-02-zoom.png`.)
|
|
||||||
|
|
||||||
**F2 — Polarity works, and colour does more of the work than fill.** A filled blue head against an
|
|
||||||
open grey outline head is readable instantly at 22 px (`g-03-zoom.png`). But the fill difference is
|
|
||||||
the *second* cue; the colour split carries it. Keep both — fill survives greyscale and colour-blind
|
|
||||||
palettes, colour survives small size.
|
|
||||||
|
|
||||||
**F3 — Depth off floats, depth on tears.** With `GL_DEPTH_TEST` off, connectors on faces pointing
|
|
||||||
*away* from the camera still drew their discs over the solid, so the part looked covered in frames
|
|
||||||
that were really on its back. Turning depth on fixed that and immediately caused **z-fighting**: the
|
|
||||||
disc is exactly coplanar with its face, and came out as a broken dotted arc. The fix is depth **on**
|
|
||||||
plus a sub-pixel lift along Z (`0.7 * upp`), scaled by `upp` so it never becomes a visible gap on
|
|
||||||
zoom-in. Both failure modes are in the images (`g-03` torn, `g-04` clean).
|
|
||||||
|
|
||||||
**F4 — The quadrant is the first thing to die at a grazing angle.** On a face seen nearly edge-on the
|
|
||||||
disc foreshortens to a sliver and the fan collapses into a blob (`g-01-zoom.png`, lower-right glyph).
|
|
||||||
The roll is exactly the information that is hardest to read when you most need it. Not yet solved —
|
|
||||||
see the open item below.
|
|
||||||
|
|
||||||
**F5 — Roll-undefined in red is too loud.** It works, but it makes the *least* important connector
|
|
||||||
the most eye-catching thing on screen. Amber, or the same grey with a hatched quadrant, is enough.
|
|
||||||
|
|
||||||
Also surfaced while testing, and unrelated to the glyph: `add_mate` accepted a mate between two
|
|
||||||
connectors **on the same body**, which is meaningless, and duly transformed the body relative to
|
|
||||||
itself. Concrete instance of gap G6.
|
|
||||||
|
|
||||||
**Still untested:** a true grazing view (the view-cube click missed), a connector on a curved face,
|
|
||||||
and behaviour when a connector overlaps the move gizmo. F4 is the open design question — the disc may
|
|
||||||
need to billboard its *quadrant* while keeping the disc in-plane, which is a compromise no surveyed
|
|
||||||
vendor makes and which should be tried before being adopted.
|
|
||||||
|
|
||||||
### What this costs
|
|
||||||
|
|
||||||
A renderer for `resolve_datum_coordsys()` — which does not exist and has to be written whatever glyph
|
|
||||||
is chosen — plus one dashed line and three fill states. No kernel work. It is the same piece of work
|
|
||||||
as G3 (live preview), and doing them together is what makes the mate card honest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8c. The "faceted ridge dome" proposal — built, rendered, judged
|
|
||||||
|
|
||||||
A colleague proposed replacing the flat disc with an **asymmetric low-poly solid**: a faceted
|
|
||||||
prismatic wedge with a dominant longitudinal ridge that **slopes** from a tall steep back to a long
|
|
||||||
shallow front, plus a male protrusion / female pocket pair with a 0.2 mm clearance.
|
|
||||||
|
|
||||||
It was built rather than discussed. `faceted_ridge_key.scad` (this folder) (6 vertices, 7 faces),
|
|
||||||
verified as a closed manifold, exported through OpenSCAD, and flat-shaded from five directions with
|
|
||||||
`render_key.py` / `render_stl.py`. Sheets: `rk-sheet.png`, `cmp-sheet.png`.
|
|
||||||
|
|
||||||
### The verdict: the shape is right, the male/female polarity cue is not
|
|
||||||
|
|
||||||
**It solves F4, decisively.** The grazing view — where the flat disc dies, its quadrant collapsing to
|
|
||||||
a blob — is the view where this shape is *most* legible: the tall back and long shallow front are
|
|
||||||
unmistakable in silhouette. At a grazing angle the silhouette IS the information, and this solid's
|
|
||||||
silhouette is maximally informative there. That is a real, evidence-backed win over what is currently
|
|
||||||
in the code.
|
|
||||||
|
|
||||||
**Down the mating axis (+Z) it also reads well**, which matters because that is the natural viewing
|
|
||||||
direction when you are looking at a face you intend to mate.
|
|
||||||
|
|
||||||
**One degenerate view, and it is not the one I predicted.** I expected the ±X views (along the ridge)
|
|
||||||
to be silhouette-ambiguous, resolved only by shading. Wrong: front and back are clearly *different* —
|
|
||||||
the front shows several facets, the back is a **single flat featureless triangle**. So they are not
|
|
||||||
confusable, but the view from directly behind the tall end tells you nothing about roll or slope.
|
|
||||||
A second blind spot remains untested: from below the base, where the protrusion is hidden behind its
|
|
||||||
own face.
|
|
||||||
|
|
||||||
**The female half fails, and much harder than expected.** Rendered with flat shading and no outlines —
|
|
||||||
the honest test, since a viewport draws no black edges — a recessed pocket is *invisible*: iso and
|
|
||||||
grazing show a plain block with a hairline; straight down the axis shows a **completely blank
|
|
||||||
rectangle**. The interior faces are lit almost identically to the top face and are occluded by the rim
|
|
||||||
from most angles. As a polarity cue, male/female therefore works in exactly one direction and returns
|
|
||||||
nothing in the other.
|
|
||||||
|
|
||||||
> **Conclusion: do not overload shape with all three jobs.** Let the solid carry **verse and roll**,
|
|
||||||
> where it is excellent, and carry **polarity on a second channel** — colour plus the filled/open head
|
|
||||||
> that already tested well at 22 px (F2). Drawing the fixed connector as an outline/wireframe of the
|
|
||||||
> same solid is the variant worth trying; drawing it as a pocket is not.
|
|
||||||
|
|
||||||
### Two premises in the brief are wrong
|
|
||||||
|
|
||||||
**"Avoid curved surfaces to optimise rendering computations / rapid mesh processing."** Not a reason
|
|
||||||
for a viewport glyph. There are 2–20 connectors on screen, the renderer pushes `GLModel` triangles
|
|
||||||
directly, and it performs no CSG or mesh processing at all. **The real argument for flat facets is
|
|
||||||
legibility**: hard normals give distinct value steps between adjacent facets, and the renders confirm
|
|
||||||
that is exactly what makes the shape readable from an arbitrary angle. Keep the constraint, fix the
|
|
||||||
justification. (For a *printed* part the original justification is sound for a different reason: flat
|
|
||||||
facets slice without the stair-stepping a tessellated curve produces.)
|
|
||||||
|
|
||||||
**"0.2 mm clearance for smooth mechanical mating."** Meaningless for a glyph. A symbol mates with
|
|
||||||
nothing, and every gizmo here is sized in screen pixels via `upp`, so a millimetre tolerance has no
|
|
||||||
referent. This is the strongest signal that **the brief was written for a physical printed part**,
|
|
||||||
not for a viewport symbol — as are "scannable" and "mechanical mating". See the open question below.
|
|
||||||
|
|
||||||
### Two defects the build caught that discussion would not have
|
|
||||||
|
|
||||||
1. **The flank quads are not planar.** Written as `[0,3,5,4]` and `[1,4,5,2]` the base edge and the
|
|
||||||
ridge edge are skew, so the four corners do not share a plane — my own first draft asserted the
|
|
||||||
opposite in a comment. Left as quads, the tessellator picks the fold direction, the "flat facet"
|
|
||||||
promise is broken by an unspecified crease, and two exporters can disagree about the shape. Fixed
|
|
||||||
by triangulating explicitly (7 faces, Euler 6 − 11 + 7 = 2).
|
|
||||||
2. **The pocket punched through its own plate.** A 4.5 mm key against a 3 mm demo plate gives a
|
|
||||||
through-hole, not a pocket. Minimum stock = height + clearance + pocket depth + a wall.
|
|
||||||
|
|
||||||
Also worth recording: the first female render was misleading because the debug renderer outlined
|
|
||||||
*every* triangle, so a flat top face triangulated by CGAL looked like a faceted dome. The instrument
|
|
||||||
lied before the geometry did. Conclusions were only drawn after outlines were removed.
|
|
||||||
|
|
||||||
### Second opinion, and the one disagreement worth resolving
|
|
||||||
|
|
||||||
Kimi reviewed the proposal independently and **rejected it for the viewport**. It agreed on the two
|
|
||||||
wrong premises, agreed the female pocket is unreadable, and added the useful framing that a
|
|
||||||
screen-constant symbol and a model-constant part feature are two different design spaces that cannot
|
|
||||||
be served by one geometry. It also noted correctly that there is **no single scalar** that removes
|
|
||||||
ambiguity from every view: you need one asymmetry in the base plane (for top-down roll) and one out
|
|
||||||
of plane (the ridge slope, for front/back). Our base is scalene, so it has both.
|
|
||||||
|
|
||||||
Its central objection was numeric and testable: *"at 22 px with 6–8 facets each facet is 3–7 px wide,
|
|
||||||
that is at the aliasing limit … minimum useful size is roughly 32–48 px, which is not compatible with
|
|
||||||
a 22 px screen-constant symbol."* My own renders were ~300 px, so the claim was unaddressed by my
|
|
||||||
evidence and would have killed the concept if true.
|
|
||||||
|
|
||||||
**Rendered at 22, 32 and 48 px (`size-test.png`), it is false for this shape.** At 22 px all three
|
|
||||||
views still read: the grazing view shows the tall back and shallow front unmistakably, and the
|
|
||||||
down-axis view keeps a strong dark/light split. The reason Kimi's arithmetic does not apply is that
|
|
||||||
this solid presents only **four or five large facets with high value contrast**, not eight small ones —
|
|
||||||
the silhouette does most of the work, and silhouettes survive downsampling far better than facet
|
|
||||||
detail does.
|
|
||||||
|
|
||||||
*Honest limit on that result:* the test renderer has no anti-aliasing, no perspective, one directional
|
|
||||||
light, and no background. Readable at 22 px against white is not the same as readable at 22 px on top
|
|
||||||
of a shaded gold part next to the move gizmo. That case still needs the rig.
|
|
||||||
|
|
||||||
**Where I do not follow Kimi:** its recommendation is to **billboard** the existing flat glyph so it
|
|
||||||
never turns edge-on. That kills F4 by construction, but a billboarded frame cannot show the frame's
|
|
||||||
orientation *in place* — which is the entire reason the disc is a disc and not a dot — and it is what
|
|
||||||
no surveyed CAD system does; Onshape, Fusion and FreeCAD all draw the frame in the geometry. Worth
|
|
||||||
prototyping as an option, not worth adopting on argument.
|
|
||||||
|
|
||||||
### Open question for Tommaso
|
|
||||||
|
|
||||||
**Is this a viewport glyph or a printable alignment feature?** The vertex logic is identical either
|
|
||||||
way; only the units and the clearance change, and the `.scad` file states both readings. But the
|
|
||||||
answer decides whether `clr`/`depth` are real millimetres or meaningless, and whether the geometry
|
|
||||||
scales with the model or stays screen-constant. The brief's own language points at "physical", the
|
|
||||||
conversation it arrived in points at "glyph".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Decisions for you
|
|
||||||
|
|
||||||
**D1 — Invert the default direction to Facing?** [DEVIATION, R5]
|
|
||||||
It changes the meaning of every stored document containing a mate. Options: (a) invert and migrate,
|
|
||||||
writing `direction=Aligned` where `mate_flip` was false; (b) invert only for new mates and store
|
|
||||||
`direction` explicitly from now on. (b) is safer and costs one field. Note this project has taken one
|
|
||||||
such semantic hit knowingly before — the `en4` fix — and the golden fixture survived, so the
|
|
||||||
migration path is a known quantity. **If G3 (live preview) lands first, this matters much less.**
|
|
||||||
|
|
||||||
**D2 — How far to take origin candidates?** [R9]
|
|
||||||
Four kinds is the Fusion-aligned recommendation. Two (face centroid + arc centre) would cover "sit on
|
|
||||||
a face" and "go down a hole" — most printed-part assembly — at a third of the work. Where do you want
|
|
||||||
to stop?
|
|
||||||
|
|
||||||
**D3 — Ball mate: in or out?**
|
|
||||||
In four of five frame-based systems, so including it is the aligned choice. Out is defensible for
|
|
||||||
printable mechanical parts. Cheap either way — align origins, leave orientation free. Kimi's review
|
|
||||||
argued **out**: a true ball joint is hard to print and hard to use without a roll reference, and a
|
|
||||||
Fastened connector at the ball centre approximates it.
|
|
||||||
|
|
||||||
**D3a — Should Planar be dropped?** [dissent worth recording]
|
|
||||||
Kimi's independent review recommended **removing Planar** and shipping four types, on the grounds that
|
|
||||||
"slide on a flat surface" is rarely how printed mechanisms work — you usually want a rail or a hinge —
|
|
||||||
and that Planar is the type most likely to confuse a user who expected "put this flat on that" and got
|
|
||||||
a part free to slide. It further ranked the honest minimum as **three**: Fastened, Revolute, Slider,
|
|
||||||
with Cylindrical useful and decomposable.
|
|
||||||
**I do not agree, and the reason is alignment.** Planar appears in every frame-based system surveyed,
|
|
||||||
it is a genuine lower pair, it is already implemented and tested, and removing it is a document-format
|
|
||||||
change made in exchange for nothing. The confusion Kimi names is real but it is a *feedback* problem —
|
|
||||||
it is exactly what R17 (show the DOF budget) and R13 (say that free DOF are preserved) exist to fix.
|
|
||||||
Recorded here because it is a legitimate reading of the same evidence and the call is yours.
|
|
||||||
|
|
||||||
**D4 — Is refusing a second mate per body acceptable?** [DEVIATION, R11 — the big one]
|
|
||||||
It is the honest consequence of having no solver, and it is what makes the tool predictable. But **no
|
|
||||||
mainstream system behaves this way**, so it is the point where an experienced user's intuition will
|
|
||||||
break. It means a part cannot be constrained by two independent relationships — "in this hole *and*
|
|
||||||
resting on this shoulder" must be expressed by placing one connector correctly rather than by two
|
|
||||||
mates. If that trade is unacceptable, the answer is a solver, and the scope of this document changes
|
|
||||||
entirely.
|
|
||||||
|
|
||||||
There is a strong argument that the trade is not merely acceptable but *correct for this product*:
|
|
||||||
the Design tab lives inside a slicer, and most of its users are positioning parts for printing rather
|
|
||||||
than building working mechanisms. For layout-and-export, tree-order composition is genuinely enough,
|
|
||||||
and adding a solver to look like Onshape would buy complexity nobody asked for. The rule to publish is
|
|
||||||
then simple and defensible: **one mate per moving body, acyclic, no relations between mates** — with
|
|
||||||
R18's loud refusals carrying the honesty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
**Onshape** — [Mate Connector](https://cad.onshape.com/help/Content/PartStudio/mate_connector.htm) ·
|
|
||||||
[Mates](https://cad.onshape.com/help/Content/Assembly/mates.htm) ·
|
|
||||||
[Fastened](https://cad.onshape.com/help/Content/Assembly/fastened_mate.htm) ·
|
|
||||||
[Revolute](https://cad.onshape.com/help/Content/Assembly/revolute_mate.htm) ·
|
|
||||||
[Slider](https://cad.onshape.com/help/Content/Assembly/slider_mate.htm) ·
|
|
||||||
[Cylindrical](https://cad.onshape.com/help/Content/Assembly/cylindrical_mate.htm) ·
|
|
||||||
[Planar](https://cad.onshape.com/help/Content/Assembly/planar_mate.htm) ·
|
|
||||||
[Ball](https://cad.onshape.com/help/Content/Assembly/ball_mate.htm) ·
|
|
||||||
[Parallel](https://cad.onshape.com/help/Content/Assembly/parallel_mate.htm) ·
|
|
||||||
[Tangent](https://cad.onshape.com/help/Content/Assembly/tangent_mate.htm) ·
|
|
||||||
[Pin Slot](https://cad.onshape.com/help/Content/Assembly/pin_slot_mate.htm) ·
|
|
||||||
[5 things you can do with mate connectors in Part Studios](https://www.onshape.com/en/resource-center/tech-tips/tech-tip-5-things-you-can-do-with-mate-connectors-in-onshape-part-studios)
|
|
||||||
|
|
||||||
**Onshape forum** — [The concept behind Mates Z Axes](https://forum.onshape.com/discussion/22828/the-concept-behind-mates-z-axes) (C1/D3) ·
|
|
||||||
[Implicit mate connectors act differently than explicit ones](https://forum.onshape.com/discussion/15736/implicit-mate-connectors-act-differently-than-explicit-ones) (C4) ·
|
|
||||||
[Efficiently set mate connectors](https://forum.onshape.com/discussion/13133/efficiently-set-mate-connectors)
|
|
||||||
|
|
||||||
**Fusion 360** — [Joint types](https://help.autodesk.com/cloudhelp/ENU/Fusion-Assemble/files/GUID-8818AE31-958A-4A59-989B-9875A174C67A.htm) ·
|
|
||||||
[Joint origins](https://help.autodesk.com/view/fusion360/ENU/?guid=ASM-JOINT-ORIGIN) ·
|
|
||||||
[Joints vs. Mates in Fusion](https://www.autodesk.com/products/fusion-360/blog/joints-mates-moving-fusion/) ·
|
|
||||||
[Joint tips — snap points and Ctrl cycling](https://mgfx.co.za/blog/engineering-manufacturing-design/fusion-360-joint-tips/)
|
|
||||||
|
|
||||||
**Inventor** — [Create Joints Reference](https://help.autodesk.com/cloudhelp/2026/ENU/Inventor-Help/files/GUID-6AA68E8F-7C97-4806-8483-3941DE915E70.htm) ·
|
|
||||||
[Use Joint to define and manage relationships](https://knowledge.autodesk.com/support/inventor-products/learn-explore/caas/CloudHelp/cloudhelp/2014/ENU/Inventor/files/GUID-21DC3336-5C51-42C1-90FB-4299CD66E0C6-htm.html) (type inference, D2)
|
|
||||||
|
|
||||||
**FreeCAD 1.0** — [Assembly Workbench](https://wiki.freecad.org/Assembly_Workbench) ·
|
|
||||||
[Fixed Joint properties](https://wiki.freecad.org/Assembly_CreateJointFixed)
|
|
||||||
|
|
||||||
**Creo** — [About Predefined Constraint Sets](https://support.ptc.com/help/creo/creo_pma/r12/usascii/assembly/asm/About_Predefined_Constraint_Sets.html)
|
|
||||||
|
|
||||||
**Siemens NX** — [Assembly constraints](https://learnnx.com/lesson/siemens-nx-assemblies-assembly-constraints/)
|
|
||||||
|
|
||||||
**SOLIDWORKS** — [Mate References](https://help.solidworks.com/2025/English/SolidWorks/sldworks/c_Mate_References_Overview_SWassy.htm) ·
|
|
||||||
[Creating and using mate references](https://blogs.solidworks.com/tech/2019/07/creating-and-using-mate-references.html)
|
|
||||||
|
|
||||||
**Theory** — [Hervé, The Lie group of rigid body displacements, a fundamental tool for mechanism design](https://www.sciencedirect.com/science/article/abs/pii/S0094114X98000512) ·
|
|
||||||
[Joint kinematics — the six lower pairs and their DOF](https://erc-bpgc.github.io/handbook/mechanical/Joint%20Kinematics/) ·
|
|
||||||
[ISO 10303-105 — Kinematics (STEP integrated resource)](https://www.iso.org/standard/78589.html)
|
|
||||||
|
|
||||||
**Internal** — `en4` (closed 2026-07-26, fixes C2 here) · `CadDocument.cpp:1669`
|
|
||||||
`datum_frame` · `CadDocument.cpp:2961` `apply_mate` · `CadDocument.cpp:1302` `add_mate`
|
|
||||||
|
|
||||||
**Second opinion** — an independent review by Kimi Code (2026-08-05) contributed the
|
|
||||||
vendors-ship-both caveat (§1), the explicit-dropdown option for origin choice (D1), the expanded
|
|
||||||
refusal list (R11a), the retrofit list (§8), and the dissents recorded at D3/D3a. One of its claims —
|
|
||||||
that Onshape mandates *"exactly one Mate between any two instances"* — **was checked against the
|
|
||||||
source and is wrong**; the correction is recorded at R11 because it is a misreading that would
|
|
||||||
otherwise turn our largest deviation into a false agreement.
|
|
||||||
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,30 +0,0 @@
|
|||||||
// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit.
|
|
||||||
// Normalised to the part's bounding span and centred: the renderer scales by one radius.
|
|
||||||
static const Vec2d kBearOutline[] = { // 12 verts, RDP eps 0.030, CCW
|
|
||||||
{+0.3842, +0.3294}, {+0.3156, +0.4002}, {+0.2424, +0.3294},
|
|
||||||
{-0.2524, +0.3294}, {-0.3377, +0.3877}, {-0.3693, +0.3298},
|
|
||||||
{-0.3256, +0.2631}, {-0.4893, -0.3337}, {-0.3960, -0.4002},
|
|
||||||
{+0.4151, -0.4002}, {+0.5000, -0.3154}, {+0.3156, +0.2631},
|
|
||||||
};
|
|
||||||
static const Vec2d kBearChin[] = { // the CHIN BAR, flat. The muzzle is relief — see kBearCrest.
|
|
||||||
{-0.2682, -0.3578}, {+0.2628, -0.3578}, {+0.2237, -0.1786},
|
|
||||||
};
|
|
||||||
// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (wi3z).
|
|
||||||
static const Vec3d kBearMarks[] = {
|
|
||||||
{-0.1997, +0.1760, +0.0590},
|
|
||||||
{+0.1947, +0.1760, +0.0590},
|
|
||||||
{+0.2797, +0.0760, +0.0380},
|
|
||||||
};
|
|
||||||
// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets.
|
|
||||||
// This is the only feature standing along +Z and the only one still legible edge-on.
|
|
||||||
static const double kBearPlateZ = +0.0360;
|
|
||||||
static const Vec2d kBearSnoutBase[] = { // CCW from the nose end
|
|
||||||
{-0.0727, -0.2417},
|
|
||||||
{+0.0630, -0.2417},
|
|
||||||
{+0.0259, +0.1939},
|
|
||||||
{-0.0356, +0.1939},
|
|
||||||
};
|
|
||||||
static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short)
|
|
||||||
{-0.0048, -0.1793, +0.2073},
|
|
||||||
{-0.0048, +0.1605, +0.1279},
|
|
||||||
};
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,299 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Mate connector glyph — polarity and verse</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ground: #eceef1;
|
|
||||||
--panel: #f8f9fb;
|
|
||||||
--panel-edge: #d3d8df;
|
|
||||||
--ink: #171a1f;
|
|
||||||
--ink-soft: #5a626e;
|
|
||||||
--ink-faint: #8b93a0;
|
|
||||||
--viewport: #9aa0a8; /* the grey a CAD viewport actually is */
|
|
||||||
--viewport-2: #7f858d;
|
|
||||||
--axis-z: #2f6fed;
|
|
||||||
--axis-x: #d94a3d;
|
|
||||||
--axis-y: #3aa757;
|
|
||||||
--quadrant: #e8a317;
|
|
||||||
--anchor: #6b7280;
|
|
||||||
--driven: #2f6fed;
|
|
||||||
--warn: #c2410c;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--ground: #14171c;
|
|
||||||
--panel: #1b1f26;
|
|
||||||
--panel-edge: #2b313a;
|
|
||||||
--ink: #e8eaee;
|
|
||||||
--ink-soft: #a6aeba;
|
|
||||||
--ink-faint: #6e7784;
|
|
||||||
--viewport: #4a5058;
|
|
||||||
--viewport-2: #3a3f46;
|
|
||||||
--axis-z: #6ea2ff;
|
|
||||||
--axis-x: #ff7a6d;
|
|
||||||
--axis-y: #5fd07f;
|
|
||||||
--quadrant: #ffc247;
|
|
||||||
--anchor: #9aa3b0;
|
|
||||||
--driven: #6ea2ff;
|
|
||||||
--warn: #fb923c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
--ground:#14171c; --panel:#1b1f26; --panel-edge:#2b313a; --ink:#e8eaee;
|
|
||||||
--ink-soft:#a6aeba; --ink-faint:#6e7784; --viewport:#4a5058; --viewport-2:#3a3f46;
|
|
||||||
--axis-z:#6ea2ff; --axis-x:#ff7a6d; --axis-y:#5fd07f; --quadrant:#ffc247;
|
|
||||||
--anchor:#9aa3b0; --driven:#6ea2ff; --warn:#fb923c;
|
|
||||||
}
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--ground:#eceef1; --panel:#f8f9fb; --panel-edge:#d3d8df; --ink:#171a1f;
|
|
||||||
--ink-soft:#5a626e; --ink-faint:#8b93a0; --viewport:#9aa0a8; --viewport-2:#7f858d;
|
|
||||||
--axis-z:#2f6fed; --axis-x:#d94a3d; --axis-y:#3aa757; --quadrant:#e8a317;
|
|
||||||
--anchor:#6b7280; --driven:#2f6fed; --warn:#c2410c;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 40px 24px 72px;
|
|
||||||
background: var(--ground); color: var(--ink);
|
|
||||||
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
.wrap { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 28px; }
|
|
||||||
header { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
h1 { font-size: 26px; line-height: 1.25; margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
|
|
||||||
.sub { color: var(--ink-soft); max-width: 62ch; margin: 0; }
|
|
||||||
.eyebrow {
|
|
||||||
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
|
|
||||||
color: var(--ink-faint); font-weight: 600;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase;
|
|
||||||
color: var(--ink-faint); margin: 16px 0 0; font-weight: 600;
|
|
||||||
}
|
|
||||||
.row { display: flex; flex-wrap: wrap; gap: 16px; }
|
|
||||||
.card {
|
|
||||||
background: var(--panel); border: 1px solid var(--panel-edge);
|
|
||||||
border-radius: 10px; padding: 18px; flex: 1 1 220px; min-width: 220px;
|
|
||||||
display: flex; flex-direction: column; gap: 10px;
|
|
||||||
}
|
|
||||||
.card.wide { flex: 1 1 100%; }
|
|
||||||
.stage { display: flex; align-items: center; justify-content: center; padding: 4px 0; }
|
|
||||||
.name { font-weight: 650; font-size: 15px; }
|
|
||||||
.note { color: var(--ink-soft); font-size: 13.5px; margin: 0; }
|
|
||||||
.k { color: var(--ink); font-weight: 600; }
|
|
||||||
table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
|
||||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--panel-edge); vertical-align: top; }
|
|
||||||
th { color: var(--ink-faint); font-weight: 600; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; }
|
|
||||||
code { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink-soft); }
|
|
||||||
.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; color: var(--ink-soft); }
|
|
||||||
.swatch { display: inline-flex; align-items: center; gap: 7px; }
|
|
||||||
.dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div class="eyebrow">Orca Design · assembly</div>
|
|
||||||
<h1>Mate connector glyph — polarity and verse</h1>
|
|
||||||
<p class="sub">
|
|
||||||
Onshape's core (disc + roll quadrant + Z arrow) is adopted unchanged because it is proven and
|
|
||||||
aligned. The addition is <span class="k">polarity</span> — which connector is anchored and
|
|
||||||
which one travels — which no surveyed CAD system encodes in its glyph.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<h2>The three jobs of the glyph</h2>
|
|
||||||
<div class="row">
|
|
||||||
<div class="card">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="150" height="130" viewBox="-75 -95 150 130" aria-label="Disc with origin dot">
|
|
||||||
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--ink-soft)" stroke-width="2.5"/>
|
|
||||||
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="name">Disc — the XY plane</div>
|
|
||||||
<p class="note">Says “I am a frame, and this is the plane I sit in.” The dot is the exact origin.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="150" height="130" viewBox="-75 -95 150 130" aria-label="Disc with one quadrant filled">
|
|
||||||
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.9"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--ink-soft)" stroke-width="2.5"/>
|
|
||||||
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="name">Quadrant — the roll</div>
|
|
||||||
<p class="note">
|
|
||||||
The filled sector is the +X/+Y quadrant. It steps 90° with the reorient control, so the
|
|
||||||
clocking that Fastened and Slider lock is <em>visible</em> before you commit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="150" height="130" viewBox="-75 -95 150 130" aria-label="Z arrow drawn only upward">
|
|
||||||
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.9"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--ink-soft)" stroke-width="2.5"/>
|
|
||||||
<line x1="0" y1="0" x2="0" y2="-58" stroke="var(--axis-z)" stroke-width="3.5" stroke-linecap="round"/>
|
|
||||||
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="var(--axis-z)"/>
|
|
||||||
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="name">Arrow — the verse</div>
|
|
||||||
<p class="note">
|
|
||||||
Drawn on <span class="k">+Z only</span>. Nothing below the disc. A double-headed axis is what
|
|
||||||
makes people ask which way it points; a one-sided arrow cannot be misread.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Polarity — the part nobody else draws</h2>
|
|
||||||
<div class="row">
|
|
||||||
<div class="card">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="170" height="150" viewBox="-85 -105 170 150" aria-label="Fixed connector, open collar">
|
|
||||||
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.55"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--anchor)" stroke-width="2.5"/>
|
|
||||||
<line x1="0" y1="0" x2="0" y2="-56" stroke="var(--anchor)" stroke-width="3" stroke-linecap="round"/>
|
|
||||||
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="none" stroke="var(--anchor)" stroke-width="3" stroke-linejoin="round"/>
|
|
||||||
<ellipse cx="0" cy="-56" rx="9.5" ry="3.6" fill="none" stroke="var(--anchor)" stroke-width="2.2"/>
|
|
||||||
<circle cx="0" cy="0" r="3.6" fill="var(--anchor)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="name">Fixed — the socket</div>
|
|
||||||
<p class="note">
|
|
||||||
Hollow head, muted colour. This body <span class="k">does not move</span>. It receives.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="170" height="150" viewBox="-85 -105 170 150" aria-label="Driven connector, solid cone">
|
|
||||||
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="var(--quadrant)" opacity="0.95"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--driven)" stroke-width="2.5"/>
|
|
||||||
<line x1="0" y1="0" x2="0" y2="-58" stroke="var(--driven)" stroke-width="3.5" stroke-linecap="round"/>
|
|
||||||
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="var(--driven)"/>
|
|
||||||
<circle cx="0" cy="0" r="3.6" fill="var(--driven)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="name">Driven — the plug</div>
|
|
||||||
<p class="note">
|
|
||||||
Solid head, active colour. This body <span class="k">is the one that jumps</span>. It inserts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="170" height="150" viewBox="-85 -105 170 150" aria-label="Degenerate roll, hatched quadrant">
|
|
||||||
<defs>
|
|
||||||
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
||||||
<line x1="0" y1="0" x2="0" y2="6" stroke="var(--warn)" stroke-width="2"/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<path d="M0,0 L42,0 A42,17 0 0 1 0,17 Z" fill="url(#hatch)" opacity="0.85"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="42" ry="17" fill="none" stroke="var(--warn)" stroke-width="2.5" stroke-dasharray="5 4"/>
|
|
||||||
<line x1="0" y1="0" x2="0" y2="-58" stroke="var(--axis-z)" stroke-width="3.5" stroke-linecap="round"/>
|
|
||||||
<polygon points="0,-80 -9.5,-56 9.5,-56" fill="var(--axis-z)"/>
|
|
||||||
<circle cx="0" cy="0" r="3.6" fill="var(--ink)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="name">Roll undefined</div>
|
|
||||||
<p class="note">
|
|
||||||
Hatched quadrant, dashed disc: a circular face or a seam gave no usable direction. Says
|
|
||||||
“pick a direction” without a dialog.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>The pair reads as a magnet</h2>
|
|
||||||
<div class="card wide">
|
|
||||||
<div class="stage">
|
|
||||||
<svg width="620" height="230" viewBox="-310 -120 620 230" aria-label="Two connectors facing each other on two plates">
|
|
||||||
<!-- lower plate (fixed) -->
|
|
||||||
<path d="M-260,52 L-60,10 L60,44 L-140,86 Z" fill="var(--viewport)" stroke="var(--viewport-2)" stroke-width="1.5"/>
|
|
||||||
<!-- upper plate (driven) -->
|
|
||||||
<path d="M-60,-96 L140,-138 L260,-104 L60,-62 Z" fill="var(--viewport)" stroke="var(--viewport-2)" stroke-width="1.5" opacity="0.55"/>
|
|
||||||
|
|
||||||
<!-- dashed field line between origins -->
|
|
||||||
<line x1="-100" y1="48" x2="100" y2="-79" stroke="var(--ink-faint)" stroke-width="2" stroke-dasharray="7 6"/>
|
|
||||||
|
|
||||||
<!-- FIXED connector, pointing up (+Z out of the lower plate) -->
|
|
||||||
<g transform="translate(-100,48)">
|
|
||||||
<path d="M0,0 L38,0 A38,15 0 0 1 0,15 Z" fill="var(--quadrant)" opacity="0.5"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="38" ry="15" fill="none" stroke="var(--anchor)" stroke-width="2.4"/>
|
|
||||||
<line x1="0" y1="0" x2="0" y2="-48" stroke="var(--anchor)" stroke-width="3" stroke-linecap="round"/>
|
|
||||||
<polygon points="0,-70 -9,-48 9,-48" fill="none" stroke="var(--anchor)" stroke-width="3" stroke-linejoin="round"/>
|
|
||||||
<ellipse cx="0" cy="-48" rx="9" ry="3.4" fill="none" stroke="var(--anchor)" stroke-width="2"/>
|
|
||||||
<circle cx="0" cy="0" r="3.4" fill="var(--anchor)"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- DRIVEN connector, pointing down (+Z out of the upper plate's underside) -->
|
|
||||||
<g transform="translate(100,-79) rotate(180)">
|
|
||||||
<path d="M0,0 L38,0 A38,15 0 0 1 0,15 Z" fill="var(--quadrant)" opacity="0.9"/>
|
|
||||||
<ellipse cx="0" cy="0" rx="38" ry="15" fill="none" stroke="var(--driven)" stroke-width="2.4"/>
|
|
||||||
<line x1="0" y1="0" x2="0" y2="-50" stroke="var(--driven)" stroke-width="3.4" stroke-linecap="round"/>
|
|
||||||
<polygon points="0,-70 -9,-48 9,-48" fill="var(--driven)"/>
|
|
||||||
<circle cx="0" cy="0" r="3.4" fill="var(--driven)"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text x="-100" y="102" text-anchor="middle" font-size="13" fill="var(--ink-soft)">fixed · receives</text>
|
|
||||||
<text x="100" y="-100" text-anchor="middle" font-size="13" fill="var(--ink-soft)">driven · inserts</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="note">
|
|
||||||
Two arrows nose to nose. Because a magnet's north seeks a south, <span class="k">“facing” is the
|
|
||||||
self-evident default</span> — which settles open decision D1 on visual grounds instead of a
|
|
||||||
convention nobody can look up. Nothing has to be remembered: the picture is the rule.
|
|
||||||
The dashed line is what makes the two glyphs read as one object.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>States</h2>
|
|
||||||
<div class="card wide">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>State</th><th>Drawing</th><th>Why</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><span class="k">Candidate</span> (hover)</td>
|
|
||||||
<td>small dot only</td>
|
|
||||||
<td>Onshape draws plain white dots at every corner and midpoint. Dots propose; the full glyph commits.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="k">Picked</span></td>
|
|
||||||
<td>disc + quadrant + cone</td>
|
|
||||||
<td>The committed frame, with roll and verse both readable.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="k">Roll undefined</span></td>
|
|
||||||
<td>hatched quadrant, dashed disc</td>
|
|
||||||
<td>Turns requirement R8 from a message nobody reads into a mark you cannot miss.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Constraints on the drawing</h2>
|
|
||||||
<div class="card wide">
|
|
||||||
<p class="note">
|
|
||||||
<span class="k">Do not make it a fourth RGB triad.</span> The bed-centre world triad
|
|
||||||
(<code>DesignCanvas.cpp:65</code>) and the move gizmo are already three coloured arrows. The disc
|
|
||||||
and the quadrant are what tell a connector apart from those — keep the arms short, and consider
|
|
||||||
drawing only Z on the committed glyph, with X and Y implied by the quadrant.
|
|
||||||
</p>
|
|
||||||
<div class="legend">
|
|
||||||
<span class="swatch"><i class="dot" style="background:var(--quadrant)"></i> roll quadrant</span>
|
|
||||||
<span class="swatch"><i class="dot" style="background:var(--axis-z)"></i> Z / driven</span>
|
|
||||||
<span class="swatch"><i class="dot" style="background:var(--anchor)"></i> fixed</span>
|
|
||||||
<span class="swatch"><i class="dot" style="background:var(--warn)"></i> roll undefined</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Does the connector pair let two hosts sit COPLANAR, or does it hold them apart?
|
|
||||||
#
|
|
||||||
# The male's flat back is the plane Y=0 and all its relief rises to +Y. So Y=0 is the natural
|
|
||||||
# mating datum: everything the male adds lives on one side of it. The test below builds two dummy
|
|
||||||
# host plates that meet on that plane -- one with the male FUSED on, one with the cavity CUT in --
|
|
||||||
# and measures whether they touch, interfere, or stand apart.
|
|
||||||
#
|
|
||||||
# It also emits the artifact that makes this work in practice: a CUTTER solid (the male grown by
|
|
||||||
# the clearance) that you subtract from any host. A standalone female block cannot keep two hosts
|
|
||||||
# coplanar, because its own floor material stands between them; a cavity can.
|
|
||||||
#
|
|
||||||
# Run: /snap/bin/freecad.cmd coplanar_test.py
|
|
||||||
|
|
||||||
import os
|
|
||||||
import FreeCAD as App
|
|
||||||
import Part
|
|
||||||
from FreeCAD import Vector
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
MALE = os.path.join(HERE, "bear.step")
|
|
||||||
CLEAR = 0.20
|
|
||||||
|
|
||||||
male = Part.Shape(); male.read(MALE); male = male.Solids[0]
|
|
||||||
bb = male.BoundBox
|
|
||||||
print(f"male relief: Y {bb.YMin:.3f} .. {bb.YMax:.3f} -> datum plane Y=0, all relief on +Y")
|
|
||||||
|
|
||||||
# the flat back face, and proof it is the whole silhouette sitting on Y=0
|
|
||||||
back = max((f for f in male.Faces
|
|
||||||
if abs(f.CenterOfMass.y) < 1e-6 and abs(abs(f.normalAt(0, 0).y) - 1) < 1e-6),
|
|
||||||
key=lambda f: f.Area)
|
|
||||||
print(f"back face : {back.Area:.1f} mm2 on Y=0 -- this is the contact surface")
|
|
||||||
|
|
||||||
# ---- the cutter: the male grown by the clearance, poking 0.2 mm proud so the boolean is clean
|
|
||||||
cutter = male.makeOffsetShape(CLEAR, 1e-6, False, False, 0, 2, False).Solids[0]
|
|
||||||
cb = cutter.BoundBox
|
|
||||||
print(f"cutter : Y {cb.YMin:.3f} .. {cb.YMax:.3f}, {cutter.Volume/1000:.2f} cm3")
|
|
||||||
|
|
||||||
# ---- two dummy hosts meeting on Y = 0
|
|
||||||
W, H = 120.0, 100.0
|
|
||||||
hostA = Part.makeBox(W, 10.0, H, Vector(-W/2, -10.0, -15.0)) # occupies Y -10..0
|
|
||||||
hostB = Part.makeBox(W, 30.0, H, Vector(-W/2, 0.0, -15.0)) # occupies Y 0..30
|
|
||||||
|
|
||||||
partA = hostA.fuse(male) # male stands proud of A's face
|
|
||||||
partB = hostB.cut(cutter) # cavity sunk into B from its face
|
|
||||||
|
|
||||||
print(f"\npart A (host + male) : {partA.Volume/1000:.2f} cm3")
|
|
||||||
print(f"part B (host - cutter) : {partB.Volume/1000:.2f} cm3")
|
|
||||||
|
|
||||||
# ---- the question ------------------------------------------------------------------
|
|
||||||
inter = partA.common(partB)
|
|
||||||
iv = inter.Volume if inter.Solids else 0.0
|
|
||||||
gap = partA.distToShape(partB)[0]
|
|
||||||
print(f"\nRESULT interference A vs B : {iv:.6f} mm3 (0 = they do not collide)")
|
|
||||||
print(f"RESULT closest approach : {gap:.4f} mm (0 = the host faces are touching)")
|
|
||||||
|
|
||||||
# are the two host faces actually on the same plane?
|
|
||||||
fa = [f for f in partA.Faces if abs(f.CenterOfMass.y) < 1e-9 and abs(abs(f.normalAt(0,0).y)-1) < 1e-6]
|
|
||||||
fb = [f for f in partB.Faces if abs(f.CenterOfMass.y) < 1e-9 and abs(abs(f.normalAt(0,0).y)-1) < 1e-6]
|
|
||||||
print(f"RESULT A has {len(fa)} face(s) lying exactly on Y=0, total {sum(f.Area for f in fa):.1f} mm2")
|
|
||||||
print(f"RESULT B has {len(fb)} face(s) lying exactly on Y=0, total {sum(f.Area for f in fb):.1f} mm2")
|
|
||||||
print("RESULT -> the hosts meet on Y=0: COPLANAR" if fa and fb and iv < 1e-3
|
|
||||||
else "RESULT -> NOT coplanar")
|
|
||||||
|
|
||||||
doc = App.newDocument("Cutter")
|
|
||||||
o = doc.addObject("Part::Feature", "BearConnector_Cutter"); o.Shape = cutter
|
|
||||||
doc.recompute()
|
|
||||||
Part.export([o], os.path.join(HERE, "BearConnector_Cutter.step"))
|
|
||||||
print(f"\nwrote BearConnector_Cutter.step -- subtract this from any host to get the socket")
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,140 +0,0 @@
|
|||||||
// Faceted ridge key — asymmetric male/female alignment feature, flat facets only.
|
|
||||||
//
|
|
||||||
// 6 vertices, 7 faces, one closed manifold. Euler check: V - E + F = 6 - 11 + 7 = 2.
|
|
||||||
// No spheres, no cylinders, no splines, no fillets.
|
|
||||||
//
|
|
||||||
// THE FLANKS ARE TRIANGULATED EXPLICITLY, and that is not cosmetic. Written as quads
|
|
||||||
// [0,3,5,4] and [1,4,5,2] they are NOT planar — the base edge and the ridge edge are
|
|
||||||
// skew, so the four corners do not share a plane. A checker caught this after the first
|
|
||||||
// draft claimed the opposite. Left as quads, the tessellator picks the fold direction for
|
|
||||||
// you, which means the "flat facet" promise is broken by an unspecified crease and two
|
|
||||||
// exporters can disagree about the shape. Splitting them here fixes the crease at
|
|
||||||
// back-bottom -> front-ridge, which keeps the rear peak's triangle large and clean.
|
|
||||||
//
|
|
||||||
// FRAME CONVENTION (matches the CAD mate connector it is derived from):
|
|
||||||
// +Z the mating axis — the feature protrudes along it
|
|
||||||
// +X the roll reference — the ridge runs along it, low end forward
|
|
||||||
// +Y completes the right-handed frame
|
|
||||||
//
|
|
||||||
// WHAT BREAKS WHICH SYMMETRY
|
|
||||||
// rotational about Z ....... the ridge (elongation along X)
|
|
||||||
// 180 deg about Z .......... the ridge SLOPE: tall steep back, long shallow front
|
|
||||||
// mirror across XZ ......... deliberately NOT broken. Handedness is fixed by convention,
|
|
||||||
// so +Y is implied once Z and X are known. Breaking it would
|
|
||||||
// add a facet and buy nothing.
|
|
||||||
//
|
|
||||||
// KNOWN AMBIGUITY, stated rather than hidden: viewed exactly ALONG the ridge (+/-X,
|
|
||||||
// orthographic), the silhouette is the same isoceles triangle from front and back. Front
|
|
||||||
// and back are then distinguished by SHADING only — the long shallow front face catches
|
|
||||||
// light differently from the steep back face. If the target renderer is flat-shaded with a
|
|
||||||
// single headlight, verify this case before committing to the shape.
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- parameters
|
|
||||||
L = 12.0; // overall length along the ridge (X)
|
|
||||||
W = 4.0; // half-width at the BACK
|
|
||||||
tf = 0.45; // front taper: front half-width = W * tf
|
|
||||||
H = 4.5; // peak height at the rear <-- the single dimension controlling asymmetry
|
|
||||||
pr = 0.22; // rear ridge position, fraction of L from the back
|
|
||||||
pf = 0.62; // front ridge position, fraction of L from the back
|
|
||||||
hf = 0.35; // front ridge height, fraction of H
|
|
||||||
|
|
||||||
// Clearance is a PHYSICAL quantity and only means anything if this is a printed part.
|
|
||||||
// See the note at the bottom: for a viewport glyph it is meaningless.
|
|
||||||
clr = 0.20; // per-face clearance, mm
|
|
||||||
depth = 0.40; // extra pocket depth so the male never bottoms out before it seats
|
|
||||||
|
|
||||||
Wf = W * tf;
|
|
||||||
xr0 = -L/2 + L * pr;
|
|
||||||
xr1 = -L/2 + L * pf;
|
|
||||||
Hf = H * hf;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- geometry
|
|
||||||
// Vertex order is fixed and referenced by the face table; do not reorder.
|
|
||||||
// 0 back-left 1 back-right 2 front-right 3 front-left
|
|
||||||
// 4 REAR PEAK (tall) 5 front ridge (low)
|
|
||||||
function ridge_pts(l, w, wf, h, hfr, x0, x1) = [
|
|
||||||
[-l/2, -w, 0 ], // 0
|
|
||||||
[-l/2, w, 0 ], // 1
|
|
||||||
[ l/2, wf, 0 ], // 2
|
|
||||||
[ l/2, -wf, 0 ], // 3
|
|
||||||
[ x0, 0, h ], // 4 rear peak
|
|
||||||
[ x1, 0, hfr] // 5 front ridge, low
|
|
||||||
];
|
|
||||||
|
|
||||||
// OpenSCAD wants each face wound CLOCKWISE seen from OUTSIDE. The right-hand-rule
|
|
||||||
// outward-normal (CCW) form is given in the comment for anyone porting to STL/OCC,
|
|
||||||
// where the opposite convention is the usual one.
|
|
||||||
RIDGE_FACES = [
|
|
||||||
[3, 2, 1, 0], // base (CCW-outward: [0,1,2,3]) planar, all z=0
|
|
||||||
[1, 4, 0], // back (CCW-outward: [0,4,1]) steep
|
|
||||||
[5, 3, 0], // flank -Y a (CCW-outward: [0,3,5])
|
|
||||||
[4, 5, 0], // flank -Y b (CCW-outward: [0,5,4])
|
|
||||||
[5, 4, 1], // flank +Y a (CCW-outward: [1,4,5])
|
|
||||||
[2, 5, 1], // flank +Y b (CCW-outward: [1,5,2])
|
|
||||||
[5, 2, 3] // front (CCW-outward: [3,2,5]) long, shallow
|
|
||||||
];
|
|
||||||
|
|
||||||
module ridge_key(l = L, w = W, wf = Wf, h = H, hfr = Hf, x0 = xr0, x1 = xr1) {
|
|
||||||
polyhedron(points = ridge_pts(l, w, wf, h, hfr, x0, x1),
|
|
||||||
faces = RIDGE_FACES,
|
|
||||||
convexity = 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MALE: the protrusion, nominal size.
|
|
||||||
module ridge_key_male() { ridge_key(); }
|
|
||||||
|
|
||||||
// FEMALE: the pocket. Grown by `clr` on every side and sunk `depth` deeper.
|
|
||||||
//
|
|
||||||
// HONEST LIMITATION: this grows the key by scaling its defining dimensions, which is NOT a
|
|
||||||
// true uniform surface offset — on the shallow front face the normal clearance comes out
|
|
||||||
// smaller than `clr`, because that face is far from perpendicular to every axis it is
|
|
||||||
// scaled along. A true offset needs minkowski() with a small cube, which is exact and slow,
|
|
||||||
// or an explicit per-face plane push, which is exact and fiddly. For a keying feature whose
|
|
||||||
// job is angular registration rather than a press fit, the approximation is the right trade
|
|
||||||
// — but do not quote this pocket as holding 0.2 mm everywhere, because it does not.
|
|
||||||
module ridge_key_female() {
|
|
||||||
translate([0, 0, -depth])
|
|
||||||
ridge_key(l = L + 2*clr,
|
|
||||||
w = W + clr,
|
|
||||||
wf = Wf + clr,
|
|
||||||
h = H + clr + depth,
|
|
||||||
hfr = Hf + clr + depth,
|
|
||||||
x0 = xr0,
|
|
||||||
x1 = xr1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- demo
|
|
||||||
// Left: the male key on its plate. Right: the plate with the pocket cut.
|
|
||||||
PLATE = [30, 18, 3];
|
|
||||||
|
|
||||||
module plate_with_male() {
|
|
||||||
translate([-PLATE[0]/2, -PLATE[1]/2, -PLATE[2]]) cube(PLATE);
|
|
||||||
ridge_key_male();
|
|
||||||
}
|
|
||||||
|
|
||||||
module plate_with_female() {
|
|
||||||
difference() {
|
|
||||||
translate([-PLATE[0]/2, -PLATE[1]/2, -PLATE[2]]) cube(PLATE);
|
|
||||||
ridge_key_female();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
translate([-20, 0, 0]) plate_with_male();
|
|
||||||
translate([ 20, 0, 0]) plate_with_female();
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- note on the two readings
|
|
||||||
// This file is written for the PHYSICAL reading: a printable alignment key, where `clr` and
|
|
||||||
// `depth` are real millimetres and flat facets genuinely help — they slice without the
|
|
||||||
// stair-stepping a tessellated curve produces, and they print without support on the
|
|
||||||
// shallow front face.
|
|
||||||
//
|
|
||||||
// If the intent is instead the VIEWPORT GLYPH for a CAD mate connector, then:
|
|
||||||
// - `clr` and `depth` are meaningless: a symbol does not mate with anything;
|
|
||||||
// - all dimensions must become SCREEN PIXELS scaled by upp = 1/zoom, because every gizmo
|
|
||||||
// in that viewport is screen-constant and must not shrink with the model;
|
|
||||||
// - "low-poly for rendering performance" is not a real reason at ~2-20 glyphs per frame.
|
|
||||||
// The real reason to keep flat facets there is LEGIBILITY: hard normals give distinct
|
|
||||||
// value steps between facets, and that is what lets a 22-px solid read as an oriented
|
|
||||||
// object instead of a grey blob.
|
|
||||||
// The vertex logic above is identical under both readings. Only the units and the clearance
|
|
||||||
// change.
|
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,226 +0,0 @@
|
|||||||
solid OpenSCAD_Model
|
|
||||||
facet normal 1 -0 0
|
|
||||||
outer loop
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex 15 9 -8
|
|
||||||
vertex 15 9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 1 0 0
|
|
||||||
outer loop
|
|
||||||
vertex 15 9 -8
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex 15 -9 -8
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex 15 9 0
|
|
||||||
vertex 5.3246 1.63218 0
|
|
||||||
vertex 15 -9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex 15 9 0
|
|
||||||
vertex -4.79494 3.42759 0
|
|
||||||
vertex 5.3246 1.63218 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex 15 9 0
|
|
||||||
vertex -5.97725 3.87059 0
|
|
||||||
vertex -4.79494 3.42759 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -5.97725 3.87059 0
|
|
||||||
vertex -15 9 0
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -15 9 0
|
|
||||||
vertex -5.97725 3.87059 0
|
|
||||||
vertex 15 9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex 5.3246 -1.63218 0
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex 5.3246 1.63218 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -4.79494 -3.42759 0
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex 5.3246 -1.63218 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex -4.79494 -3.42759 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
vertex -15 -9 0
|
|
||||||
vertex 15 -9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -15 -9 0
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
vertex -15 9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 -1
|
|
||||||
outer loop
|
|
||||||
vertex -15 -9 -8
|
|
||||||
vertex 15 9 -8
|
|
||||||
vertex 15 -9 -8
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0 0 -1
|
|
||||||
outer loop
|
|
||||||
vertex 15 9 -8
|
|
||||||
vertex -15 -9 -8
|
|
||||||
vertex -15 9 -8
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -1 0 0
|
|
||||||
outer loop
|
|
||||||
vertex -15 -9 -8
|
|
||||||
vertex -15 9 0
|
|
||||||
vertex -15 9 -8
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -1 -0 0
|
|
||||||
outer loop
|
|
||||||
vertex -15 9 0
|
|
||||||
vertex -15 -9 -8
|
|
||||||
vertex -15 -9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 1 -0
|
|
||||||
outer loop
|
|
||||||
vertex 15 9 -8
|
|
||||||
vertex -15 9 0
|
|
||||||
vertex 15 9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 1 0
|
|
||||||
outer loop
|
|
||||||
vertex -15 9 0
|
|
||||||
vertex 15 9 -8
|
|
||||||
vertex -15 9 -8
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 -1 0
|
|
||||||
outer loop
|
|
||||||
vertex -15 -9 -8
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex -15 -9 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 -1 -0
|
|
||||||
outer loop
|
|
||||||
vertex 15 -9 0
|
|
||||||
vertex -15 -9 -8
|
|
||||||
vertex 15 -9 -8
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex -6.2 4.2 -0.4
|
|
||||||
vertex 6.2 -2 -0.4
|
|
||||||
vertex 6.2 2 -0.4
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0 0 1
|
|
||||||
outer loop
|
|
||||||
vertex 6.2 -2 -0.4
|
|
||||||
vertex -6.2 4.2 -0.4
|
|
||||||
vertex -6.2 -4.2 -0.4
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0.873667 0 -0.486524
|
|
||||||
outer loop
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
vertex -6.2 4.2 -0.4
|
|
||||||
vertex -5.97725 3.87059 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal 0.873667 0 -0.486524
|
|
||||||
outer loop
|
|
||||||
vertex -6.2 4.2 -0.4
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
vertex -6.2 -4.2 -0.4
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.107146 0.603912 -0.789816
|
|
||||||
outer loop
|
|
||||||
vertex 6.2 -2 -0.4
|
|
||||||
vertex -4.79494 -3.42759 0
|
|
||||||
vertex 5.3246 -1.63218 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.107147 0.603918 -0.789812
|
|
||||||
outer loop
|
|
||||||
vertex -4.79494 -3.42759 0
|
|
||||||
vertex 6.2 -2 -0.4
|
|
||||||
vertex -6.2 -4.2 -0.4
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.304068 0.811519 -0.498978
|
|
||||||
outer loop
|
|
||||||
vertex -4.79494 -3.42759 0
|
|
||||||
vertex -6.2 -4.2 -0.4
|
|
||||||
vertex -5.97725 -3.87059 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.304068 -0.811519 -0.498978
|
|
||||||
outer loop
|
|
||||||
vertex -5.97725 3.87059 0
|
|
||||||
vertex -6.2 4.2 -0.4
|
|
||||||
vertex -4.79494 3.42759 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.107146 -0.603912 -0.789816
|
|
||||||
outer loop
|
|
||||||
vertex -4.79494 3.42759 0
|
|
||||||
vertex 6.2 2 -0.4
|
|
||||||
vertex 5.3246 1.63218 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.107147 -0.603918 -0.789812
|
|
||||||
outer loop
|
|
||||||
vertex 6.2 2 -0.4
|
|
||||||
vertex -4.79494 3.42759 0
|
|
||||||
vertex -6.2 4.2 -0.4
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.415603 0 -0.909546
|
|
||||||
outer loop
|
|
||||||
vertex 5.3246 -1.63218 0
|
|
||||||
vertex 6.2 2 -0.4
|
|
||||||
vertex 6.2 -2 -0.4
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
facet normal -0.415603 0 -0.909546
|
|
||||||
outer loop
|
|
||||||
vertex 6.2 2 -0.4
|
|
||||||
vertex 5.3246 -1.63218 0
|
|
||||||
vertex 5.3246 1.63218 0
|
|
||||||
endloop
|
|
||||||
endfacet
|
|
||||||
endsolid OpenSCAD_Model
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Female half alone, for the legibility test: is a recessed faceted pocket readable in a
|
|
||||||
// shaded view, or does a concave feature just read as a dark hole with no orientation?
|
|
||||||
use <faceted_ridge_key.scad>
|
|
||||||
|
|
||||||
// The plate must be THICKER than the key is tall, or the "pocket" is a through-hole. The
|
|
||||||
// first version used 3 mm against a 4.5 mm key and cut straight through — caught only by
|
|
||||||
// rendering it. Minimum stock = H + clearance + pocket depth + a wall to print against.
|
|
||||||
PLATE = [30, 18, 8];
|
|
||||||
difference() {
|
|
||||||
translate([-PLATE[0]/2, -PLATE[1]/2, -PLATE[2]]) cube(PLATE);
|
|
||||||
ridge_key_female();
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Measure the assembled fit between the supplied male and the generated female.
|
|
||||||
# This is the number that matters: the minimum gap in the seated position.
|
|
||||||
# Run: /snap/bin/freecad.cmd fit_check.py
|
|
||||||
import os
|
|
||||||
import Part
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
male = Part.Shape(); male.read(os.path.join(HERE, "bear.step"))
|
|
||||||
fem = Part.Shape(); fem.read(os.path.join(HERE, "BearConnector_Female.step"))
|
|
||||||
male, fem = male.Solids[0], fem.Solids[0]
|
|
||||||
|
|
||||||
d = male.distToShape(fem)
|
|
||||||
print(f"RESULT minimum gap male<->female, seated: {d[0]:.4f} mm (design clearance 0.20)")
|
|
||||||
|
|
||||||
c = male.common(fem)
|
|
||||||
print(f"RESULT interference volume: {(c.Volume if c.Solids else 0.0):.6f} mm3")
|
|
||||||
|
|
||||||
p = d[1][0][0]
|
|
||||||
print(f"RESULT tightest point on the male: ({p.x:.2f}, {p.y:.2f}, {p.z:.2f})")
|
|
||||||
print(f"RESULT male {male.Volume/1000:.2f} cm3 / female {fem.Volume/1000:.2f} cm3")
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,99 +0,0 @@
|
|||||||
"""Render the SIMPLIFIED glyph exactly as render_mate_face() draws it — x0kd.
|
|
||||||
|
|
||||||
This is the panel the study was missing. simplify_study.py measured a FLAT outline and
|
|
||||||
relief_sheet.py measured the FULL 1508-facet part; neither showed the simplified glyph WITH its
|
|
||||||
relief, which is what the code actually draws and the only thing that answers "is the snout still
|
|
||||||
protruding". Same facet list, same painter order, same camera-fixed lambert as the C++.
|
|
||||||
"""
|
|
||||||
import math, os
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
T = open(os.path.join(HERE, "bear_glyph_table.h")).read()
|
|
||||||
def grab(name, n):
|
|
||||||
body = T.split(name + "[] = {")[1].split("};")[0]
|
|
||||||
body = "\n".join(l.split("//")[0] for l in body.splitlines())
|
|
||||||
out = []
|
|
||||||
for tok in body.replace("\n", " ").split("},"):
|
|
||||||
tok = tok.strip().lstrip("{").strip()
|
|
||||||
if not tok: continue
|
|
||||||
v = [float(x) for x in tok.replace("{", "").split(",")[:n]]
|
|
||||||
if len(v) == n: out.append(tuple(v))
|
|
||||||
return out
|
|
||||||
OUT = grab("kBearOutline", 2)
|
|
||||||
CHIN = grab("kBearChin", 2) # NB: this table entry is the CHIN BAR, not the snout
|
|
||||||
MARKS = grab("kBearMarks", 3)
|
|
||||||
CREST = grab("kBearCrest", 3)
|
|
||||||
SBASE = grab("kBearSnoutBase", 2)
|
|
||||||
PLATE = float(T.split("kBearPlateZ = ")[1].split(";")[0])
|
|
||||||
|
|
||||||
|
|
||||||
def facets():
|
|
||||||
F = []
|
|
||||||
n = len(OUT)
|
|
||||||
for i in range(n): # plate sides -> the grazing silhouette
|
|
||||||
a, b = OUT[i], OUT[(i+1) % n]
|
|
||||||
F.append(([(a[0],a[1],0.0),(b[0],b[1],0.0),(b[0],b[1],PLATE),(a[0],a[1],PLATE)], "body", True))
|
|
||||||
F.append(([(x,y,PLATE) for x,y in OUT], "body", True)) # plate top
|
|
||||||
zm = PLATE + 0.004
|
|
||||||
for cx,cy,r in MARKS: # eyes + cheek dot
|
|
||||||
F.append(([(cx+r*math.cos(2*math.pi*i/12), cy+r*math.sin(2*math.pi*i/12), zm) for i in range(12)], "mark", False))
|
|
||||||
F.append(([(x,y,zm) for x,y in CHIN], "mark", False)) # chin bar
|
|
||||||
A, B = CREST # THE MUZZLE: base quad + crest
|
|
||||||
nl=(SBASE[0][0],SBASE[0][1],PLATE); nr=(SBASE[1][0],SBASE[1][1],PLATE)
|
|
||||||
tr=(SBASE[2][0],SBASE[2][1],PLATE); tl=(SBASE[3][0],SBASE[3][1],PLATE)
|
|
||||||
F += [([nl,tl,B,A],"body",True), # left flank
|
|
||||||
([nr,A,B,tr],"body",True), # right flank
|
|
||||||
([nl,A,nr],"body",True), # nose cap, sloping because the base overhangs the crest
|
|
||||||
([tr,B,tl],"body",True)] # tail cap
|
|
||||||
return F
|
|
||||||
FACETS = facets()
|
|
||||||
|
|
||||||
BODY=(0.42,0.46,0.52); MARK=(0.126,0.138,0.156)
|
|
||||||
def render(px, elev_deg, ss=8):
|
|
||||||
S=px*ss; a=math.radians(elev_deg); ca,sa=math.cos(a),math.sin(a)
|
|
||||||
# camera orbits down; the connector's +Z (relief) tips toward the horizon
|
|
||||||
xf=lambda p:(p[0], p[1]*sa + p[2]*ca, -p[1]*ca + p[2]*sa)
|
|
||||||
light=(-0.70,0.30,0.45)
|
|
||||||
img=Image.new("RGB",(S,S),(24,27,32)); d=ImageDraw.Draw(img)
|
|
||||||
tris=[]
|
|
||||||
for pts,kind,shade in FACETS:
|
|
||||||
q=[xf(p) for p in pts]
|
|
||||||
tris.append((sum(v[2] for v in q)/len(q), q, kind, shade))
|
|
||||||
tris.sort(key=lambda t:t[0]) # far first
|
|
||||||
for _,q,kind,shade in tris:
|
|
||||||
(x0,y0,z0),(x1,y1,z1),(x2,y2,z2)=q[0],q[1],q[2]
|
|
||||||
ux,uy,uz=x1-x0,y1-y0,z1-z0; vx,vy,vz=x2-x0,y2-y0,z2-z0
|
|
||||||
nx,ny,nz=uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx
|
|
||||||
nn=math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0
|
|
||||||
nx,ny,nz=nx/nn,ny/nn,nz/nn
|
|
||||||
if nz<0: nx,ny,nz=-nx,-ny,-nz
|
|
||||||
base=BODY if kind=="body" else MARK
|
|
||||||
k=(0.42+0.58*max(0.0,nx*light[0]+ny*light[1]+nz*light[2])) if shade else 1.0
|
|
||||||
col=tuple(min(255,int(255*c*k)) for c in base)
|
|
||||||
d.polygon([(S/2+p[0]*S*0.92, S/2-p[1]*S*0.92) for p in q], fill=col)
|
|
||||||
return img.resize((px,px), Image.LANCZOS)
|
|
||||||
|
|
||||||
SIZES=[22,32,48]; ELEVS=[(90,"flat on"),(47,"47"),(16,"16"),(6,"6")]
|
|
||||||
pad,cell=8,58
|
|
||||||
W=pad+len(SIZES)*len(ELEVS)*cell+pad; H=pad+cell+pad
|
|
||||||
sheet=Image.new("RGB",(W,H),(24,27,32))
|
|
||||||
for ci,(e,_) in enumerate(ELEVS):
|
|
||||||
for si,px in enumerate(SIZES):
|
|
||||||
g=render(px,e)
|
|
||||||
sheet.paste(g, (pad+(ci*len(SIZES)+si)*cell+(cell-px)//2, pad+(cell-px)//2))
|
|
||||||
sheet.resize((W*2,H*2), Image.NEAREST).save(os.path.join(HERE,"glyph-preview.png"))
|
|
||||||
|
|
||||||
# how much of the glyph is the snout: render with and without the tent and diff
|
|
||||||
def render_no_tent(px, elev):
|
|
||||||
global FACETS
|
|
||||||
keep=FACETS; FACETS=FACETS[:-4]
|
|
||||||
try: return render(px, elev)
|
|
||||||
finally: FACETS=keep
|
|
||||||
print(f"{'elev':>8} {'lit px@32':>10} {'snout px':>9} {'snout share':>12}")
|
|
||||||
for e,_ in ELEVS:
|
|
||||||
a=render(32,e); b=render_no_tent(32,e)
|
|
||||||
la=sum(1 for p in a.get_flattened_data() if p!=(24,27,32))
|
|
||||||
diff=sum(1 for p,q in zip(a.get_flattened_data(), b.get_flattened_data()) if p!=q)
|
|
||||||
print(f"{e:>8} {la:>10} {diff:>9} {100.0*diff/max(1,la):>11.1f}%")
|
|
||||||
print("WROTE glyph-preview.png")
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Mate-connector glyph probe — built as REAL solids on REAL mechanical geometry,
|
|
||||||
# so the shape can be judged in a 3D viewport instead of in a browser mock.
|
|
||||||
#
|
|
||||||
# Four polarity treatments, side by side on one bracket:
|
|
||||||
# A Onshape baseline ...... ring + roll quadrant + three short axis arms
|
|
||||||
# B solid cone ............ ring + quadrant + one-sided Z arrow, filled head (driven)
|
|
||||||
# C hollow collar ......... ring + quadrant + one-sided Z arrow, shell head (fixed)
|
|
||||||
# D pin / cup ............. polarity by RELIEF: a raised pin vs a sunk cup
|
|
||||||
#
|
|
||||||
# D is the one that only a 3D test can settle: in a shaded viewport, solid-vs-hollow is a
|
|
||||||
# weak cue that depends on angle and lighting, while convex-vs-concave is a strong one --
|
|
||||||
# and male/female is the mechanical language for polarity anyway.
|
|
||||||
#
|
|
||||||
# Scale note: in the real viewport gizmos are screen-constant (~15-40 px via upp = 1/zoom).
|
|
||||||
# At a zoom where a 60 mm part fills ~600 px, 40 px is about 4 mm, so R = 4.5 mm here.
|
|
||||||
|
|
||||||
import FreeCAD as App
|
|
||||||
import FreeCADGui as Gui
|
|
||||||
import Part
|
|
||||||
from FreeCAD import Vector
|
|
||||||
|
|
||||||
DOC = "GlyphProbe"
|
|
||||||
for d in list(App.listDocuments()):
|
|
||||||
App.closeDocument(d)
|
|
||||||
doc = App.newDocument(DOC)
|
|
||||||
|
|
||||||
R = 4.5 # disc radius, the module everything scales from
|
|
||||||
GOLD = (0.93, 0.66, 0.09)
|
|
||||||
BLUE = (0.18, 0.44, 0.93)
|
|
||||||
GREY = (0.42, 0.46, 0.52)
|
|
||||||
RED = (0.85, 0.29, 0.24)
|
|
||||||
GREEN = (0.23, 0.65, 0.35)
|
|
||||||
|
|
||||||
def add(name, shape, color, transparency=0):
|
|
||||||
o = doc.addObject("Part::Feature", name)
|
|
||||||
o.Shape = shape
|
|
||||||
o.ViewObject.ShapeColor = color
|
|
||||||
o.ViewObject.LineColor = color
|
|
||||||
o.ViewObject.PointColor = color
|
|
||||||
o.ViewObject.Transparency = transparency
|
|
||||||
return o
|
|
||||||
|
|
||||||
def frame(origin, zdir, xdir):
|
|
||||||
"""Right-handed placement matrix from origin + Z + X (X orthonormalised against Z)."""
|
|
||||||
z = Vector(*zdir); z.normalize()
|
|
||||||
xr = Vector(*xdir)
|
|
||||||
x = xr.sub(Vector(z).multiply(z.dot(xr))); x.normalize()
|
|
||||||
y = z.cross(x)
|
|
||||||
return App.Matrix(x.x, y.x, z.x, origin[0],
|
|
||||||
x.y, y.y, z.y, origin[1],
|
|
||||||
x.z, y.z, z.z, origin[2],
|
|
||||||
0, 0, 0, 1)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- the bracket
|
|
||||||
plate = Part.makeBox(120, 46, 8)
|
|
||||||
bore = Part.makeCylinder(7, 40, Vector(96, 23, -6)) # a real bore, curved face
|
|
||||||
boss = Part.makeCylinder(11, 7, Vector(96, 23, 8))
|
|
||||||
part = plate.fuse(boss).cut(bore)
|
|
||||||
add("Bracket", part, (0.60, 0.63, 0.66))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- glyph pieces
|
|
||||||
def ring(t=None):
|
|
||||||
t = t or R * 0.10
|
|
||||||
return Part.makeCylinder(R, t).cut(Part.makeCylinder(R * 0.84, t))
|
|
||||||
|
|
||||||
def quadrant(t=None):
|
|
||||||
t = t or R * 0.10
|
|
||||||
return Part.makeCylinder(R * 0.84, t, Vector(0, 0, 0), Vector(0, 0, 1), 90)
|
|
||||||
|
|
||||||
def stem(L=None, r=None):
|
|
||||||
return Part.makeCylinder(r or R * 0.09, L or R * 2.3)
|
|
||||||
|
|
||||||
def solid_head():
|
|
||||||
return Part.makeCone(R * 0.32, 0, R * 0.80, Vector(0, 0, R * 2.3))
|
|
||||||
|
|
||||||
def shell_head():
|
|
||||||
outer = Part.makeCone(R * 0.32, 0, R * 0.80, Vector(0, 0, R * 2.3))
|
|
||||||
inner = Part.makeCone(R * 0.22, 0, R * 0.62, Vector(0, 0, R * 2.3))
|
|
||||||
return outer.cut(inner)
|
|
||||||
|
|
||||||
def short_axis(direction, L=None):
|
|
||||||
L = L or R * 1.15
|
|
||||||
return Part.makeCylinder(R * 0.07, L, Vector(0, 0, 0), Vector(*direction))
|
|
||||||
|
|
||||||
def place(shape, m):
|
|
||||||
s = shape.copy()
|
|
||||||
s.transformShape(m)
|
|
||||||
return s
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- the variants
|
|
||||||
def variant_A(tag, origin): # Onshape baseline
|
|
||||||
m = frame(origin, (0, 0, 1), (1, 0, 0))
|
|
||||||
add(tag + "_ring", place(ring(), m), GREY)
|
|
||||||
add(tag + "_quad", place(quadrant(), m), GOLD)
|
|
||||||
add(tag + "_x", place(short_axis((1, 0, 0)), m), RED)
|
|
||||||
add(tag + "_y", place(short_axis((0, 1, 0)), m), GREEN)
|
|
||||||
add(tag + "_z", place(short_axis((0, 0, 1), R * 1.6), m), BLUE)
|
|
||||||
|
|
||||||
def variant_B(tag, origin, zdir=(0, 0, 1)): # solid cone = driven
|
|
||||||
m = frame(origin, zdir, (1, 0, 0))
|
|
||||||
add(tag + "_ring", place(ring(), m), BLUE)
|
|
||||||
add(tag + "_quad", place(quadrant(), m), GOLD)
|
|
||||||
add(tag + "_body", place(stem().fuse(solid_head()), m), BLUE)
|
|
||||||
|
|
||||||
def variant_C(tag, origin, zdir=(0, 0, 1)): # hollow collar = fixed
|
|
||||||
m = frame(origin, zdir, (1, 0, 0))
|
|
||||||
add(tag + "_ring", place(ring(), m), GREY)
|
|
||||||
add(tag + "_quad", place(quadrant(), m), GOLD)
|
|
||||||
add(tag + "_body", place(stem().fuse(shell_head()), m), GREY)
|
|
||||||
|
|
||||||
def variant_D_pin(tag, origin, zdir=(0, 0, 1)): # polarity by relief: raised PIN
|
|
||||||
m = frame(origin, zdir, (1, 0, 0))
|
|
||||||
pin = Part.makeCylinder(R * 0.30, R * 1.5).fuse(
|
|
||||||
Part.makeCone(R * 0.30, 0, R * 0.55, Vector(0, 0, R * 1.5)))
|
|
||||||
add(tag + "_ring", place(ring(), m), BLUE)
|
|
||||||
add(tag + "_quad", place(quadrant(), m), GOLD)
|
|
||||||
add(tag + "_pin", place(pin, m), BLUE)
|
|
||||||
|
|
||||||
def variant_D_cup(tag, origin, zdir=(0, 0, 1)): # polarity by relief: sunk CUP
|
|
||||||
m = frame(origin, zdir, (1, 0, 0))
|
|
||||||
cup = Part.makeCylinder(R * 0.62, R * 0.9).cut(
|
|
||||||
Part.makeCylinder(R * 0.40, R * 0.9, Vector(0, 0, -0.01)))
|
|
||||||
add(tag + "_ring", place(ring(), m), GREY)
|
|
||||||
add(tag + "_quad", place(quadrant(), m), GOLD)
|
|
||||||
add(tag + "_cup", place(cup, m), GREY)
|
|
||||||
|
|
||||||
# four treatments across the plate, all on the same flat face, same Z
|
|
||||||
variant_A("A", (14, 30, 8))
|
|
||||||
variant_B("B", (40, 30, 8))
|
|
||||||
variant_C("C", (64, 30, 8))
|
|
||||||
variant_D_pin("Dpin", (14, 10, 8))
|
|
||||||
variant_D_cup("Dcup", (40, 10, 8))
|
|
||||||
|
|
||||||
# the hard cases, which is the whole reason for doing this in 3D:
|
|
||||||
variant_B("Bore", (96, 23, 15)) # on the boss above a bore
|
|
||||||
variant_B("Edge", (64, 0, 8), (0, -0.7071, 0.7071)) # tilted, on an edge, oblique Z
|
|
||||||
|
|
||||||
doc.recompute()
|
|
||||||
|
|
||||||
v = Gui.activeDocument().activeView()
|
|
||||||
v.viewIsometric()
|
|
||||||
Gui.SendMsgToActiveView("ViewFit")
|
|
||||||
App.Console.PrintMessage("glyph probe built: %d objects\n" % len(doc.Objects))
|
|
||||||
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,112 +0,0 @@
|
|||||||
"""Give the bear a handedness mark that survives rasterisation — wi3z, Tommaso's call 2.
|
|
||||||
|
|
||||||
The study showed the left/right cue lives in sub-millimetre corner radii and is therefore invisible
|
|
||||||
at glyph size: one pixel is 2.6 mm at 32 px. Roll and verse are safe; handedness is not.
|
|
||||||
|
|
||||||
THE MEASURE IS THE QUESTION ITSELF. Render the glyph, render its mirror image, and count how many
|
|
||||||
pixels differ. If a human is to tell left from right, the two must differ on screen; a candidate
|
|
||||||
that scores near zero is invisible however elegant it looks in CAD. Reported as a percentage of the
|
|
||||||
glyph's own lit area, so the sizes are comparable.
|
|
||||||
"""
|
|
||||||
import json, math, os
|
|
||||||
from PIL import Image, ImageDraw, ImageChops
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
D = json.load(open(os.path.join(HERE, "bear_outline.json")))
|
|
||||||
def unit(pts):
|
|
||||||
p = [(x, -z) for x, z in pts]
|
|
||||||
return p
|
|
||||||
outer = unit(D["outer"]); holes = [unit(h["pts"]) for h in D["holes"]]
|
|
||||||
ALL = outer + [p for h in holes for p in h]
|
|
||||||
xs=[p[0] for p in ALL]; ys=[p[1] for p in ALL]
|
|
||||||
CX,CY = (min(xs)+max(xs))/2,(min(ys)+max(ys))/2
|
|
||||||
SPAN = max(max(xs)-min(xs), max(ys)-min(ys))
|
|
||||||
U = lambda pts: [((x-CX)/SPAN,(y-CY)/SPAN) for x,y in pts]
|
|
||||||
OUT = U(outer)
|
|
||||||
EYES = [U(h) for h,m in zip(holes, D["holes"]) if m["d"] < 20]
|
|
||||||
MUZ = U([h for h,m in zip(holes, D["holes"]) if m["d"] >= 20][0])
|
|
||||||
|
|
||||||
def rdp(pts, eps):
|
|
||||||
if len(pts) < 3: return pts
|
|
||||||
ax,ay=pts[0]; bx,by=pts[-1]; dx,dy=bx-ax,by-ay
|
|
||||||
n=math.hypot(dx,dy); best,bi=-1.0,0
|
|
||||||
for i in range(1,len(pts)-1):
|
|
||||||
px,py=pts[i]
|
|
||||||
d=abs(dx*(ay-py)-(ax-px)*dy)/n if n>1e-12 else math.hypot(px-ax,py-ay)
|
|
||||||
if d>best: best,bi=d,i
|
|
||||||
if best<=eps: return [pts[0],pts[-1]]
|
|
||||||
return rdp(pts[:bi+1],eps)[:-1]+rdp(pts[bi:],eps)
|
|
||||||
def simp(pts,eps):
|
|
||||||
r=rdp(pts+[pts[0]],eps); return r[:-1]
|
|
||||||
|
|
||||||
BASE = simp(OUT, .030) # the 22-vertex outline the study settled on
|
|
||||||
def centroid(p): return (sum(q[0] for q in p)/len(p), sum(q[1] for q in p)/len(p))
|
|
||||||
def circ(cx,cy,r,n=16): return [(cx+r*math.cos(2*math.pi*i/n), cy+r*math.sin(2*math.pi*i/n)) for i in range(n)]
|
|
||||||
EYE_D = []
|
|
||||||
for e in EYES:
|
|
||||||
c=centroid(e); r=(max(p[0] for p in e)-min(p[0] for p in e))/2
|
|
||||||
EYE_D.append((c[0],c[1],r))
|
|
||||||
EYE_D.sort() # [0] = left (x<0), [1] = right
|
|
||||||
|
|
||||||
TOP = max(p[1] for p in BASE)
|
|
||||||
H = TOP - min(p[1] for p in BASE)
|
|
||||||
def ear_tip(sign):
|
|
||||||
cands=[p for p in BASE if p[1] > TOP-0.18*H and (p[0]*sign) > 0]
|
|
||||||
return max(cands, key=lambda p: p[0]*sign) if cands else None
|
|
||||||
LT, RT = ear_tip(-1), ear_tip(+1)
|
|
||||||
|
|
||||||
def notch(tip, sign, k=0.085):
|
|
||||||
"""A wedge bitten out of one ear — background-filled, exactly how the eyes are already drawn."""
|
|
||||||
x,y = tip
|
|
||||||
return [(x, y+0.02), (x - sign*k, y - k*0.55), (x + sign*k*0.15, y - k*1.05)]
|
|
||||||
|
|
||||||
CANDS = {
|
|
||||||
"H0 none": dict(cuts=[], eyes=EYE_D),
|
|
||||||
"H1 notch R ear": dict(cuts=[notch(RT, +1)], eyes=EYE_D),
|
|
||||||
"H2 notch both": dict(cuts=[notch(RT, +1), notch(LT, -1, 0.045)], eyes=EYE_D),
|
|
||||||
"H3 cheek dot": dict(cuts=[circ(EYE_D[1][0]+0.085, EYE_D[1][1]-0.10, 0.038)], eyes=EYE_D),
|
|
||||||
"H4 uneven eyes": dict(cuts=[], eyes=[EYE_D[0], (EYE_D[1][0], EYE_D[1][1], EYE_D[1][2]*1.55)]),
|
|
||||||
}
|
|
||||||
|
|
||||||
def render(c, px, ss=8, mirror=False):
|
|
||||||
S=px*ss; img=Image.new("L",(S,S),0); d=ImageDraw.Draw(img)
|
|
||||||
m = lambda p: (S/2 + (-p[0] if mirror else p[0])*S*0.92, S/2 - p[1]*S*0.92)
|
|
||||||
d.polygon([m(p) for p in BASE], fill=255)
|
|
||||||
d.polygon([m(p) for p in MUZ], fill=0)
|
|
||||||
for cx,cy,r in c["eyes"]:
|
|
||||||
a=m((cx-r,cy+r)); b=m((cx+r,cy-r))
|
|
||||||
d.ellipse([min(a[0],b[0]), min(a[1],b[1]), max(a[0],b[0]), max(a[1],b[1])], fill=0)
|
|
||||||
for cut in c["cuts"]:
|
|
||||||
d.polygon([m(p) for p in cut], fill=0)
|
|
||||||
return img.resize((px,px), Image.LANCZOS)
|
|
||||||
|
|
||||||
SIZES=[22,32,48]
|
|
||||||
print(f"{'candidate':16} " + " ".join(f"{s}px" for s in SIZES) + " (pixels differing from own mirror, % of lit area)")
|
|
||||||
print("-"*84)
|
|
||||||
scores={}
|
|
||||||
for name,c in CANDS.items():
|
|
||||||
row=[]
|
|
||||||
for px in SIZES:
|
|
||||||
a=render(c,px); b=render(c,px,mirror=True)
|
|
||||||
diff=ImageChops.difference(a,b)
|
|
||||||
nd=sum(1 for v in diff.getdata() if v>40)
|
|
||||||
lit=sum(1 for v in a.getdata() if v>40) or 1
|
|
||||||
row.append(100.0*nd/lit)
|
|
||||||
scores[name]=row
|
|
||||||
print(f"{name:16} " + " ".join(f"{v:5.1f}" for v in row))
|
|
||||||
|
|
||||||
pad,cell=8,58
|
|
||||||
W=pad+len(SIZES)*2*cell+pad; Hh=pad+len(CANDS)*cell+pad
|
|
||||||
sheet=Image.new("RGB",(W,Hh),(24,27,32))
|
|
||||||
for r,(name,c) in enumerate(CANDS.items()):
|
|
||||||
for mi,mir in enumerate((False,True)):
|
|
||||||
for si,px in enumerate(SIZES):
|
|
||||||
g=render(c,px,mirror=mir)
|
|
||||||
tile=Image.new("RGB",(px,px),(24,27,32))
|
|
||||||
tile.paste(Image.new("RGB",(px,px),(237,168,23)),(0,0),g)
|
|
||||||
x=pad+(mi*len(SIZES)+si)*cell+(cell-px)//2
|
|
||||||
y=pad+r*cell+(cell-px)//2
|
|
||||||
sheet.paste(tile,(x,y))
|
|
||||||
sheet.resize((W*2,Hh*2), Image.NEAREST).save(os.path.join(HERE,"handedness-sheet.png"))
|
|
||||||
print("\nleft block = as drawn, right block = mirrored. rows: " + ", ".join(CANDS))
|
|
||||||
print("WROTE handedness-sheet.png")
|
|
||||||
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,135 +0,0 @@
|
|||||||
# Build the complementary FEMALE for BearConnector.step.
|
|
||||||
#
|
|
||||||
# Method: take the supplied male B-rep as-is, grow it by a uniform clearance, and subtract that
|
|
||||||
# from a block. Working on the real solid rather than re-modelling the bear is the whole point —
|
|
||||||
# the pocket is then exactly complementary by construction, including every deliberate asymmetry.
|
|
||||||
#
|
|
||||||
# The offset uses join=2 (Intersection), which extends the adjacent planes and meets them at a
|
|
||||||
# sharp corner. For a faceted part that is the correct join: the arc join would round every convex
|
|
||||||
# edge and blunt the very cues the design depends on.
|
|
||||||
#
|
|
||||||
# THE MALE'S NATIVE FRAME: the flat back is the plane Y=0 and the relief rises to Y=+17.27.
|
|
||||||
# X and Z carry the face (83.34 x 66.69). The frame is kept exactly as supplied so that male and
|
|
||||||
# female drop into the same assembly without anyone having to re-orient one of them.
|
|
||||||
# Insertion is therefore along +Y, and the pocket must OPEN on the Y=0 plane.
|
|
||||||
#
|
|
||||||
# A first version of this script assumed the relief ran along +Z, built the block around the wrong
|
|
||||||
# axis, and produced a sealed cavity with no way in. It passed a "male does not intersect female"
|
|
||||||
# check, because that only tests the seated position and says nothing about whether the part can
|
|
||||||
# get there. The straight-pull test below is what catches it.
|
|
||||||
#
|
|
||||||
# Run: /snap/bin/freecad.cmd make_female.py
|
|
||||||
|
|
||||||
import os, sys, math
|
|
||||||
import FreeCAD as App
|
|
||||||
import Part
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
MALE = os.path.join(HERE, "bear.step")
|
|
||||||
OUT_STEP = os.path.join(HERE, "BearConnector_Female.step")
|
|
||||||
|
|
||||||
CLEAR = 0.20 # per-face clearance, mm
|
|
||||||
WALL = 4.0 # material around the pocket, mm
|
|
||||||
FLOOR = 3.0 # material behind the deepest point of the pocket, mm
|
|
||||||
|
|
||||||
male = Part.Shape(); male.read(MALE)
|
|
||||||
if len(male.Solids) != 1:
|
|
||||||
print(f"FAIL: expected 1 solid in the male, found {len(male.Solids)}"); sys.exit(1)
|
|
||||||
male = male.Solids[0]
|
|
||||||
bb = male.BoundBox
|
|
||||||
print(f"male : {bb.XLength:.2f} (X) x {bb.YLength:.2f} (Y) x {bb.ZLength:.2f} (Z) mm, "
|
|
||||||
f"{len(male.Faces)} faces, {male.Volume/1000:.2f} cm3")
|
|
||||||
print(f" relief runs Y {bb.YMin:.2f} .. {bb.YMax:.2f} -> insertion along +Y, mouth at Y={bb.YMin:.2f}")
|
|
||||||
|
|
||||||
# ---- 1. can the male even be withdrawn along the insertion axis? ----------------------
|
|
||||||
# Ray-cast a grid along +Y through the tessellated male and count crossings. A straight pull is
|
|
||||||
# possible only if no ray enters the solid more than once; a second entry is an undercut.
|
|
||||||
verts, facets = male.tessellate(0.15)
|
|
||||||
V = [(v.x, v.y, v.z) for v in verts]
|
|
||||||
worst, undercut_pts = 0, 0
|
|
||||||
NX = NZ = 90
|
|
||||||
for i in range(NX):
|
|
||||||
x = bb.XMin + (i + 0.5) * bb.XLength / NX
|
|
||||||
for j in range(NZ):
|
|
||||||
z = bb.ZMin + (j + 0.5) * bb.ZLength / NZ
|
|
||||||
hits = 0
|
|
||||||
for (ia, ib, ic) in facets: # ray (x, *, z) along +Y vs triangle
|
|
||||||
ax, ay, az = V[ia]; bx, by, bz = V[ib]; cx, cy, cz = V[ic]
|
|
||||||
# 2D point-in-triangle in the XZ plane
|
|
||||||
d = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz)
|
|
||||||
if abs(d) < 1e-12: continue
|
|
||||||
u = ((bz - cz) * (x - cx) + (cx - bx) * (z - cz)) / d
|
|
||||||
v = ((cz - az) * (x - cx) + (ax - cx) * (z - cz)) / d
|
|
||||||
if u < 0 or v < 0 or u + v > 1: continue
|
|
||||||
hits += 1
|
|
||||||
worst = max(worst, hits)
|
|
||||||
if hits > 2: undercut_pts += 1
|
|
||||||
print(f"pull : max crossings along +Y = {worst}, undercut samples = {undercut_pts}/{NX*NZ}")
|
|
||||||
if undercut_pts:
|
|
||||||
print("FAIL: the male has an undercut along +Y; a straight pocket cannot release it")
|
|
||||||
sys.exit(1)
|
|
||||||
print(" no undercut -> a straight-pull pocket works")
|
|
||||||
|
|
||||||
# ---- 2. grow the male by the clearance -----------------------------------------------
|
|
||||||
grown = None
|
|
||||||
for join, name in ((2, "Intersection"), (1, "Tangent"), (0, "Arc")):
|
|
||||||
try:
|
|
||||||
g = male.makeOffsetShape(CLEAR, 1e-6, False, False, 0, join, False)
|
|
||||||
if g.isValid() and g.Solids:
|
|
||||||
grown = g.Solids[0]; print(f"offset: join={name}, {grown.Volume/1000:.2f} cm3"); break
|
|
||||||
except Exception as e:
|
|
||||||
print(f"offset: join={name} failed -- {e}")
|
|
||||||
if grown is None:
|
|
||||||
print("FAIL: could not offset the male; refusing to emit a zero-clearance pocket"); sys.exit(1)
|
|
||||||
|
|
||||||
# ---- 3. the block: walls in X and Z, depth in +Y, OPEN at the Y=0 mouth ---------------
|
|
||||||
gb = grown.BoundBox
|
|
||||||
y_mouth = bb.YMin # the male's flat back plane
|
|
||||||
depth = gb.YMax - y_mouth
|
|
||||||
block = Part.makeBox(gb.XLength + 2*WALL, depth + FLOOR, gb.ZLength + 2*WALL,
|
|
||||||
App.Vector(gb.XMin - WALL, y_mouth, gb.ZMin - WALL))
|
|
||||||
print(f"block : {gb.XLength + 2*WALL:.2f} x {depth + FLOOR:.2f} x {gb.ZLength + 2*WALL:.2f} mm, "
|
|
||||||
f"mouth on the Y={y_mouth:.2f} plane")
|
|
||||||
|
|
||||||
female = block.cut(grown)
|
|
||||||
|
|
||||||
# ---- 4. verify --------------------------------------------------------------------------
|
|
||||||
ok = True
|
|
||||||
if not female.isValid(): print("FAIL: invalid shape"); ok = False
|
|
||||||
if len(female.Solids) != 1: print(f"FAIL: {len(female.Solids)} solids"); ok = False
|
|
||||||
|
|
||||||
clash = male.common(female)
|
|
||||||
cv = clash.Volume if clash.Solids else 0.0
|
|
||||||
print(f"check : male ∩ female = {cv:.6f} mm3 (seated fit, must be ~0)")
|
|
||||||
if cv > 1e-3: print("FAIL: male collides with female"); ok = False
|
|
||||||
|
|
||||||
# the mouth must actually be open: the pocket has to reach the Y=y_mouth face of the block
|
|
||||||
mouth_face_area = 0.0
|
|
||||||
for f in female.Faces:
|
|
||||||
c = f.CenterOfMass
|
|
||||||
if abs(c.y - y_mouth) < 1e-6:
|
|
||||||
mouth_face_area += f.Area
|
|
||||||
solid_mouth = (gb.XLength + 2*WALL) * (gb.ZLength + 2*WALL)
|
|
||||||
open_area = solid_mouth - mouth_face_area
|
|
||||||
print(f"check : mouth plane -- material {mouth_face_area:.1f} mm2, opening {open_area:.1f} mm2 "
|
|
||||||
f"({100*open_area/solid_mouth:.1f}% of the face)")
|
|
||||||
if open_area < 100:
|
|
||||||
print("FAIL: the pocket is sealed -- the male cannot be inserted"); ok = False
|
|
||||||
|
|
||||||
cavity = block.Volume - female.Volume
|
|
||||||
print(f"check : cavity {cavity/1000:.2f} cm3 vs male {male.Volume/1000:.2f} cm3 "
|
|
||||||
f"-> clearance shell {(cavity-male.Volume)/1000:.2f} cm3")
|
|
||||||
if cavity < male.Volume: print("FAIL: cavity smaller than the male"); ok = False
|
|
||||||
|
|
||||||
if not ok:
|
|
||||||
print("\nREFUSING to write the STEP"); sys.exit(1)
|
|
||||||
|
|
||||||
doc = App.newDocument("Female")
|
|
||||||
obj = doc.addObject("Part::Feature", "BearConnector_Female")
|
|
||||||
obj.Shape = female
|
|
||||||
doc.recompute()
|
|
||||||
Part.export([obj], OUT_STEP)
|
|
||||||
fb = female.BoundBox
|
|
||||||
print(f"\nwrote {OUT_STEP}")
|
|
||||||
print(f"female: {fb.XLength:.2f} x {fb.YLength:.2f} x {fb.ZLength:.2f} mm, "
|
|
||||||
f"{len(female.Faces)} faces, {female.Volume/1000:.2f} cm3")
|
|
||||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,71 +0,0 @@
|
|||||||
"""The muzzle has to READ, not just be present — wi3z.
|
|
||||||
|
|
||||||
Faithfully scaled, the part's ridge is 11.3 mm on an 83 mm face: 13.6 % of the width. At glyph
|
|
||||||
size that is a scratch. A glyph is a symbol, not a scale model, so the question is how much
|
|
||||||
emphasis it takes before the only +Z feature actually reads. Variants, all with the same crest
|
|
||||||
geometry, differing only in width and colour.
|
|
||||||
"""
|
|
||||||
import math, os, importlib.util
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
spec=importlib.util.spec_from_file_location("gp","glyph_preview.py")
|
|
||||||
gp=importlib.util.module_from_spec(spec); spec.loader.exec_module(gp)
|
|
||||||
|
|
||||||
OUT, CHIN, MARKS, CREST, SBASE, PLATE = gp.OUT, gp.CHIN, gp.MARKS, gp.CREST, gp.SBASE, gp.PLATE
|
|
||||||
BODY=(0.42,0.46,0.52); MARK=(0.126,0.138,0.156); GOLD=(0.93,0.66,0.09)
|
|
||||||
|
|
||||||
def facets(widen=1.0, muzzle_gold=False):
|
|
||||||
F=[]; n=len(OUT)
|
|
||||||
for i in range(n):
|
|
||||||
a,b=OUT[i],OUT[(i+1)%n]
|
|
||||||
F.append(([(a[0],a[1],0.0),(b[0],b[1],0.0),(b[0],b[1],PLATE),(a[0],a[1],PLATE)],BODY,True))
|
|
||||||
F.append(([(x,y,PLATE) for x,y in OUT],BODY,True))
|
|
||||||
zm=PLATE+0.004
|
|
||||||
for cx,cy,r in MARKS:
|
|
||||||
F.append(([(cx+r*math.cos(2*math.pi*i/12),cy+r*math.sin(2*math.pi*i/12),zm) for i in range(12)],MARK,False))
|
|
||||||
F.append(([(x,y,zm) for x,y in CHIN],MARK,False))
|
|
||||||
A,B=CREST
|
|
||||||
w=lambda p:(p[0]*widen,p[1],PLATE)
|
|
||||||
nl,nr,tr,tl=(w(SBASE[0]),w(SBASE[1]),w(SBASE[2]),w(SBASE[3]))
|
|
||||||
col = GOLD if muzzle_gold else BODY
|
|
||||||
F+=[([nl,tl,B,A],col,True),([nr,A,B,tr],col,True),
|
|
||||||
([nl,A,nr],col,True), ([tr,B,tl],col,True)]
|
|
||||||
return F
|
|
||||||
|
|
||||||
def render(F, px, elev, ss=8):
|
|
||||||
S=px*ss; a=math.radians(elev); ca,sa=math.cos(a),math.sin(a)
|
|
||||||
xf=lambda p:(p[0],p[1]*sa+p[2]*ca,-p[1]*ca+p[2]*sa)
|
|
||||||
light=(-0.70,0.30,0.45)
|
|
||||||
img=Image.new("RGB",(S,S),(24,27,32)); d=ImageDraw.Draw(img)
|
|
||||||
tris=sorted(((sum(v[2] for v in [xf(q) for q in pts])/len(pts),[xf(q) for q in pts],c,sh)
|
|
||||||
for pts,c,sh in F), key=lambda t:t[0])
|
|
||||||
for _,q,base,shade in tris:
|
|
||||||
(x0,y0,z0),(x1,y1,z1),(x2,y2,z2)=q[0],q[1],q[2]
|
|
||||||
ux,uy,uz=x1-x0,y1-y0,z1-z0; vx,vy,vz=x2-x0,y2-y0,z2-z0
|
|
||||||
nx,ny,nz=uy*vz-uz*vy,uz*vx-ux*vz,ux*vy-uy*vx
|
|
||||||
L=math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0; nx,ny,nz=nx/L,ny/L,nz/L
|
|
||||||
if nz<0: nx,ny,nz=-nx,-ny,-nz
|
|
||||||
k=(0.42+0.58*max(0.0,nx*light[0]+ny*light[1]+nz*light[2])) if shade else 1.0
|
|
||||||
d.polygon([(S/2+p[0]*S*0.92,S/2-p[1]*S*0.92) for p in q],
|
|
||||||
fill=tuple(min(255,int(255*c*k)) for c in base))
|
|
||||||
return img.resize((px,px),Image.LANCZOS)
|
|
||||||
|
|
||||||
VAR=[("V1 faithful", 1.0, False),
|
|
||||||
("V2 gold muzzle", 1.0, True),
|
|
||||||
("V3 gold + 1.8x wide",1.8, True),
|
|
||||||
("V4 body + 1.8x wide",1.8, False)]
|
|
||||||
big=Image.new("RGB",(4*250+30,4*140+30),(24,27,32))
|
|
||||||
for r,(name,wd,gold) in enumerate(VAR):
|
|
||||||
F=facets(wd,gold)
|
|
||||||
for c,e in enumerate((90,47,16,6)):
|
|
||||||
big.paste(render(F,120,e),(15+c*250+60,15+r*140+10))
|
|
||||||
big.save("/tmp/muzzle-variants.png")
|
|
||||||
for name,wd,gold in VAR:
|
|
||||||
F=facets(wd,gold); F0=[f for f in F][:-4]
|
|
||||||
row=[]
|
|
||||||
for e in (90,16,6):
|
|
||||||
a=render(F,32,e); b=render(F0,32,e)
|
|
||||||
la=sum(1 for p in a.get_flattened_data() if p!=(24,27,32))
|
|
||||||
df=sum(1 for p,q in zip(a.get_flattened_data(),b.get_flattened_data()) if p!=q)
|
|
||||||
row.append(f"{100.0*df/max(1,la):5.1f}%")
|
|
||||||
print(f"{name:22} muzzle share at 90/16/6 deg: " + " ".join(row))
|
|
||||||
print("WROTE /tmp/muzzle-variants.png")
|
|
||||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,99 +0,0 @@
|
|||||||
"""Flat glyph vs 3D relief, at the elevations that killed the disc — wi3z.
|
|
||||||
|
|
||||||
The flat study collapsed at 16 deg because anything drawn IN the connector's plane foreshortens by
|
|
||||||
sin(elevation). This renders the SAME bear as its real relief (1508 facets off the supplied male)
|
|
||||||
with a simple lambert shade, so the silhouette does the work at a grazing angle. Two rows, same
|
|
||||||
sizes, same elevations, so the comparison is direct.
|
|
||||||
"""
|
|
||||||
import json, math, os
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
M = json.load(open(os.path.join(HERE, "bear_mesh.json")))
|
|
||||||
V, F = M["v"], M["f"]
|
|
||||||
|
|
||||||
# Part frame: face carried by X (right) and Z (down-negative), relief along +Y.
|
|
||||||
P = [(v[0], -v[2], v[1]) for v in V] # -> (x right, y up, z out of the face)
|
|
||||||
xs=[p[0] for p in P]; ys=[p[1] for p in P]; zs=[p[2] for p in P]
|
|
||||||
CX,CY,CZ = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2, (min(zs)+max(zs))/2
|
|
||||||
SPAN = max(max(xs)-min(xs), max(ys)-min(ys))
|
|
||||||
P = [((x-CX)/SPAN, (y-CY)/SPAN, (z-CZ)/SPAN) for x,y,z in P]
|
|
||||||
|
|
||||||
def shade(px, elev_deg, supersample=8):
|
|
||||||
"""Camera orbits down from straight-on (90) to grazing (small). Rotate about the screen x-axis."""
|
|
||||||
S = px*supersample
|
|
||||||
a = math.radians(elev_deg)
|
|
||||||
ca, sa = math.cos(a), math.sin(a)
|
|
||||||
# view: rotate the model so the face normal tips away from the camera
|
|
||||||
def xf(p):
|
|
||||||
x,y,z = p
|
|
||||||
return (x, y*sa + z*ca, -y*ca + z*sa) # third component = depth toward camera
|
|
||||||
Q = [xf(p) for p in P]
|
|
||||||
img = Image.new("L", (S,S), 0)
|
|
||||||
d = ImageDraw.Draw(img)
|
|
||||||
order = []
|
|
||||||
for tri in F:
|
|
||||||
a3 = [Q[i] for i in tri]
|
|
||||||
order.append((sum(v[2] for v in a3)/3.0, tri, a3))
|
|
||||||
order.sort(key=lambda t: t[0]) # painter: far first
|
|
||||||
light = (-0.35, 0.55, 0.76)
|
|
||||||
for _, tri, a3 in order:
|
|
||||||
(x0,y0,z0),(x1,y1,z1),(x2,y2,z2) = a3
|
|
||||||
ux,uy,uz = x1-x0, y1-y0, z1-z0
|
|
||||||
vx,vy,vz = x2-x0, y2-y0, z2-z0
|
|
||||||
nx,ny,nz = uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx
|
|
||||||
n = math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0
|
|
||||||
nx,ny,nz = nx/n, ny/n, nz/n
|
|
||||||
if nz < 0: nx,ny,nz = -nx,-ny,-nz # face the camera
|
|
||||||
lam = max(0.0, nx*light[0] + ny*light[1] + nz*light[2])
|
|
||||||
val = int(70 + 185*lam)
|
|
||||||
pts = [(S/2 + x*S*0.92, S/2 - y*S*0.92) for x,y,_ in a3]
|
|
||||||
d.polygon(pts, fill=val)
|
|
||||||
return img.resize((px,px), Image.LANCZOS)
|
|
||||||
|
|
||||||
# flat outline, for the side-by-side
|
|
||||||
D = json.load(open(os.path.join(HERE, "bear_outline.json")))
|
|
||||||
def unit(pts):
|
|
||||||
p=[(x,-z) for x,z in pts]
|
|
||||||
return [((x-CX)/SPAN,(y-CY)/SPAN) for x,y in p]
|
|
||||||
OUT = unit(D["outer"])
|
|
||||||
HOLES = [unit(h["pts"]) for h in D["holes"]]
|
|
||||||
|
|
||||||
def flat(px, elev_deg, supersample=8):
|
|
||||||
S=px*supersample
|
|
||||||
img=Image.new("L",(S,S),0); d=ImageDraw.Draw(img)
|
|
||||||
k=math.sin(math.radians(elev_deg))
|
|
||||||
m=lambda p:(S/2+p[0]*S*0.92, S/2-p[1]*S*0.92*k)
|
|
||||||
d.polygon([m(p) for p in OUT], fill=255)
|
|
||||||
for h in HOLES: d.polygon([m(p) for p in h], fill=0)
|
|
||||||
return img.resize((px,px), Image.LANCZOS)
|
|
||||||
|
|
||||||
SIZES=[22,32,48]; ELEVS=[(90,"flat on"),(47,"47"),(16,"16"),(6,"6")]
|
|
||||||
pad,cell=8,58
|
|
||||||
W=pad+len(SIZES)*len(ELEVS)*cell+pad; H=pad+2*cell+pad
|
|
||||||
sheet=Image.new("RGB",(W,H),(24,27,32))
|
|
||||||
for r,fn in enumerate((flat, shade)):
|
|
||||||
for ci,(elev,_) in enumerate(ELEVS):
|
|
||||||
for si,px in enumerate(SIZES):
|
|
||||||
g=fn(px,elev)
|
|
||||||
tile=Image.new("RGB",(px,px),(24,27,32))
|
|
||||||
if fn is flat:
|
|
||||||
tile.paste(Image.new("RGB",(px,px),(237,168,23)),(0,0),g)
|
|
||||||
else:
|
|
||||||
gg=g.convert("L")
|
|
||||||
tile=Image.merge("RGB",(gg.point(lambda v:min(255,int(v*1.00))),
|
|
||||||
gg.point(lambda v:int(v*0.71)),
|
|
||||||
gg.point(lambda v:int(v*0.16))))
|
|
||||||
x=pad+(ci*len(SIZES)+si)*cell+(cell-px)//2
|
|
||||||
y=pad+r*cell+(cell-px)//2
|
|
||||||
sheet.paste(tile,(x,y))
|
|
||||||
sheet.resize((W*2,H*2), Image.NEAREST).save(os.path.join(HERE,"relief-sheet.png"))
|
|
||||||
|
|
||||||
# how much ink survives — the same measure used on the disc glyph
|
|
||||||
print(f"{'elev':>6} {'flat px@32':>11} {'relief px@32':>13}")
|
|
||||||
for elev,_ in ELEVS:
|
|
||||||
f32=flat(32,elev); s32=shade(32,elev)
|
|
||||||
fi=sum(1 for v in f32.getdata() if v>40)
|
|
||||||
si=sum(1 for v in s32.getdata() if v>40)
|
|
||||||
print(f"{elev:>6} {fi:>11} {si:>13}")
|
|
||||||
print("WROTE relief-sheet.png")
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Export the real male's relief as a triangle mesh, so the grazing test uses the actual geometry.
|
|
||||||
import os, json
|
|
||||||
import Part
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
s = Part.Shape(); s.read(os.path.join(HERE, "bear.step"))
|
|
||||||
verts, facets = s.Solids[0].tessellate(0.25)
|
|
||||||
V = [[round(p.x,4), round(p.y,4), round(p.z,4)] for p in verts]
|
|
||||||
json.dump({"v": V, "f": facets}, open(os.path.join(HERE, "bear_mesh.json"), "w"))
|
|
||||||
print(f"verts {len(V)} facets {len(facets)}")
|
|
||||||