Commit Graph
12 Commits
Author SHA1 Message Date
packerlschupfer ca668a3bc9 CLI: --inspect-paint — dump per-facet paint state as JSON (#14608)
* CLI: --inspect-paint — dump per-facet paint state as JSON

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

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

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

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

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

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

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

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

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

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

Without an input file or --load-assemble-list there is nothing to
inspect, and the run printed nothing and exited 0. Reject it up front
with CLI_INVALID_PARAMS, next to the other invalid-parameter checks.
2026-09-17 12:01:49 +08:00
packerlschupfer 93c8b3f2b0 CLI: --ground-* orientation from the Lay on Face planes, and --inspect-mesh (#15073)
* CLI: --ground-face-* / --lay-flat / --center-on-bed orientation primitives

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

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

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

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

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

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

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

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

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

No behaviour change when the flags are absent.

(cherry picked from commit c45a9795e1)

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

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

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

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

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

## `--strict`

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

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

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

## `result.json`

Two new top-level fields:

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

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

## Tests

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

Built and run locally on Linux (GCC 14) on current `main`: both tests
pass,
and the touched files compile clean under Clang with `-Werror`.
2026-09-16 12:54:48 +08:00
packerlschupfer 31eb8a2bd1 CLI: let --export-settings - write the merged config to stdout
--export-settings already writes the merged config as JSON at the right
point in the CLI flow. Passing - writes the same document to stdout.

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

Settings passed on the command line (--sparse-infill-density 25% ...) override
the loaded presets when m_extra_config is applied to m_print_config, but nothing
recorded them in different_settings_to_system. The exported project therefore
carried the new value with no mark that it was modified, and re-opening it in the
GUI reverted it to the system preset's value -- the same failure the preset-leaf
diff fixes for user presets, via a different source of override.

The key set comes from m_config, not m_extra_config. read_cli() puts only what the
user typed into m_config and setup() adds nothing but CLI-own defaults (none of
the keys run() materialises there is a preset option), whereas the CLI writes its
own values into m_extra_config (has_filament_switcher, filament_colour,
filament_map ...), which must not be reported as user overrides.

Values are snapshotted just before the apply and only keys the override actually
changed are recorded: a typed value equal to the loaded one modifies nothing, and
listing it would read as a spurious difference against what the GUI writes. Each
key lands in the column(s) whose preset type owns it -- process, every filament,
printer -- and a key already present is not duplicated. Keys no preset owns
(curr_bed_type, a project setting) land nowhere, as in the GUI.

Follow-up to #15595, split out at review.

* CLI: judge command-line overrides the way the value is read

Review follow-ups on the override recording:

- Lists were compared as whole serialized strings. read_cli() builds a fresh
  one-entry list, so --nozzle-temperature 245 against 245,245,245 on a
  three-filament project was recorded in every filament column although nothing
  changed. Lists are now compared entry by entry with a missing entry read as the
  first, as get_at() reads it (and as resize() pads).

- The log line fired for every changed key, including ones no preset owns
  (curr_bed_type) and which therefore land in no column. It now fires only when a
  column took the key.

- m_print_config.has(key) straight after apply(m_extra_config, true) was always
  true, both configs sharing print_config_def; removed. columns.size() >= 2 also
  always holds after the resize to filament_count + 2 -- different_settings_to_system
  is not a CLI option, so nothing in between can shrink it -- but that rests on code
  far away, so it stays a plain check rather than an assert: release builds compile
  asserts out, and a _GLIBCXX_ASSERTIONS build would abort on columns[0].

Deliberately NOT done: comparing a key the loaded config lacks against its
built-in default. On reopen the GUI restores an unlisted key from the SYSTEM
preset, not the default. A 3MF written before an option existed leaves it absent
here, so --sparse-infill-density 20% (the default) against a Prusa system 15% would
go unrecorded and be reverted to 15%. Absent keys stay always-recorded:
over-recording is cosmetic, under-recording loses the value. Verified that such a
key really is absent at this point, rather than filled from the system preset.

Reported by HanifKoh and raistlin7447 in review of #15642.
2026-09-14 11:37:05 +08:00
packerlschupfer ccd6086787 CLI: evaluate compatible_printers_condition in the compat checks (#15449)
* CLI: evaluate compatible_printers_condition in the compat checks

Slicing from the CLI with --load-settings exits with
CLI_PROCESS_NOT_COMPATIBLE (-17), "The selected printer is not compatible
with the process preset in the 3mf.", for process/printer pairs the GUI
accepts. Reproducible with stock, unmodified Prusa system profiles:

  orca-slicer --datadir <datadir> \
    --load-settings "<datadir>/system/Prusa/process/0.20mm SPEED @CORE One HF 0.4.json;<datadir>/system/Prusa/machine/Prusa CORE One HF 0.4 nozzle.json" \
    --load-filaments "<datadir>/system/Prusa/filament/Prusament PETG @CORE One HF 0.4.json" \
    --slice 0 --outputdir /tmp/out model.stl

The four compat checks in CLI::run did a literal name match against the
`compatible_printers` list only:

    for (index ...) if (new_print_compatible_printers[index] == new_printer_system_name)
        process_compatible = true;

Process profiles that declare compatibility through
`compatible_printers_condition` and leave `compatible_printers` empty are
therefore always reported incompatible -- the condition is never consulted.
For 0.20mm SPEED @CORE One HF 0.4 that condition is:

    printer_notes=~/.*PRINTER_MODEL_COREONE[^_a-zA-Z0-9].*/ and
    nozzle_diameter[0]==0.4 and printer_notes=~/.*HF_NOZZLE.*/

The GUI does not have this bug: is_compatible_with_printer() in Preset.cpp
treats an empty list as "no explicit constraint" and evaluates the
condition in that case.

Fix: replace the four loops with a check_compat lambda that calls
is_compatible_with_printer() -- the same helper the GUI uses -- wrapping
the already-loaded DynamicPrintConfigs in lightweight Preset /
PresetWithVendorProfile shells. The 3MF-embedded process/printer full
configs are kept in current_process_full_config /
current_printer_full_config so the condition can be evaluated for the
reprocess paths too; those fall back to the previous literal match when
the full config was not preserved.

Behaviour is unchanged where an explicit compatible_printers list exists:
is_compatible_with_printer() performs the same name match, and returns
true when both list and condition are empty, matching the existing
"old 3mf, no compatible printers, set to compatible" path.

Split out of #13731 (section 1) as a standalone, single-purpose change.
Orthogonal to the inherits-chain resolution work in #14718 / #15302 /
#15438; those decide which values a preset resolves to, this decides
whether the resulting pair is considered compatible.

* CLI: translate the 3MF's renamed compatibility keys before the compat check

The 3MF fallback fed the project config to is_compatible_with_printer() as-is,
but a project config does not carry compatible_printers or
compatible_printers_condition. PresetBundle::construct_full_config() erases both
and re-emits them as print_compatible_printers and
compatible_machine_expression_group; they are renamed back only on the
PresetBundle load path, which the CLI does not take. The check therefore saw no
list and no condition, read that as 'no constraint' and accepted every printer.

That is not just a wrong accept. An early true skips the !process_compatible
block that sets machine_switch, so the new printer is never appended to
print_compatible_printers and the exported 3MF stays marked compatible only with
the printer it came from -- which is exactly what that block exists to prevent.

Translate the two keys back before the check. Index 0 of the expression group is
the print preset; the group is filled print, filaments, printer.

Also note in the comment that profiles/BBL/{process,machine}_full/ are gitignored
and generated by nothing in-tree, so current_*_full_config is always empty and
this fallback is the only live path -- not the rare non-BBL case the original
comment implied.

Reported with measurements by HanifKoh in review of #15449.

Preset: add a config-level is_compatible_with_printer() overload

The CLI holds resolved DynamicPrintConfigs, not Presets, so it wrapped them in
throwaway Preset shells at the call site. Moving that into Preset.cpp puts the
compatibility policy -- including the documented fail-open on a malformed
compatible_printers_condition -- in one place for the GUI and the CLI, rather
than leaving a second copy of the plumbing in OrcaSlicer.cpp to drift.

Purely additive: neither existing overload changes, so no GUI behaviour moves.

Requested by HanifKoh in review of #15449.

(cherry picked from commit 14ca1972ef4d3c7d90935d159423013a40a6bd70)

* CLI: never overwrite a real compat key with an empty renamed one

7e7f0e3 translated compatible_machine_expression_group[0] into
compatible_printers_condition whenever the group vector was non-empty. A project
the CLI exported itself carries the real compatible_printers_condition AND an
all-empty group, ["", "", ""], so the valid condition was overwritten with
"", the check saw no constraint, and every printer was accepted.

That fixed GUI-shaped projects and broke CLI-shaped ones. Bisected across six
builds re-slicing one CLI-exported CORE One project with an MK4S: every build
before 7e7f0e3 gives 'compatible 0' and takes the machine-switch path; with it,
'compatible 1' and no switch.

The raw keys now win whenever they carry something; the renamed ones are only a
fallback, and an empty value is never written over a real one. Same for the list:
print_compatible_printers is used only when compatible_printers is absent or
empty and it itself is not.

Found by a peer session re-testing the installed build.
2026-09-12 11:17:10 +08:00
packerlschupfer 1e76e733b7 CLI: record user overrides in different_settings_to_system for 3MF export (#15595)
* CLI: record user overrides in different_settings_to_system for 3MF export

Three sites in CLI::run wrote an empty `different_settings_to_system` column
and left a //todo:

    //todo: support user machine preset's different settings
    different_settings[filament_count+1] = "";
    //todo: support system process preset
    different_settings[0] = "";
    //todo: update different settings of filaments
    different_settings[filament_index] = "";

So a 3MF exported by the CLI does not record which keys the user actually
overrode relative to the system parent. Re-opening such a project in the GUI
then shows spurious "unsaved changes", and accepting that dialog can revert
inherited process/filament/machine values to system defaults.

The column could not be filled before because the CLI had no resolved view of
the parent preset. It does now: #15438 builds a PresetBundle for inherits
resolution, so the parent can be looked up by name and diffed against the
resolved leaf. This adds no extra loading -- the bundle is the one already
built, and the helper returns "" whenever it is unavailable or the parent
cannot be found, which is the previous behaviour.

Preset metadata is filtered out of the diff: `inherits`, the three
`*_settings_id` keys, and `compatible_printers` / `compatible_prints` and
their `_condition` variants, which have their own tracking columns
(`inherits_group`, per-slot lists) and would otherwise double-count.

A value already carried by the loaded JSON still wins for the process slot, so
presets saved with a `different_settings_to_system` field behave as before;
the computed value only fills the gap where that field is absent, which is the
case for every user preset in my datadir (0 of 47 carry it).

System presets keep an empty column: there are no user overrides to record.

* CLI: diff the filament slot before load_default_gcodes_to_config

The process and machine slots compute their different_settings_to_system column
before load_default_gcodes_to_config(); the filament slot did it after. That
call materialises absent gcode keys via option(..., true), and
DynamicConfig::diff only compares keys present in both configs -- so a gcode key
the resolved leaf did not carry would go from 'not compared' to 'compared as
empty against the parent' and land in the column as an override the user never
made.

Hoisted into a local above the call, guarded by load_filament_count > 0 so the
work is skipped exactly where it was before, and assigned at the original site.
The diff now also runs before config.erase("filament_settings_id"), which is
immaterial: cli_different_settings already filters filament_settings_id along
with the other *_settings_id keys.

This is a consistency fix rather than a demonstrated defect -- resolve_preset
merges the parent config, so in practice the gcode keys are already present on
both sides and the diff is unaffected. It removes the dependence on that
invariant, which the other two slots never had.

Reported by HanifKoh in review of #15595.
2026-09-12 04:31:12 +08:00
packerlschupfer 705c82688e PartPlate::check_outside: guard m_plater null deref (fix CLI rotate SIGSEGV) (#14415) 2026-07-22 09:50:32 -03:00
packerlschupfer ae0193899d calib: default-init Calib_Params scalar fields (#14413) 2026-07-21 15:28:36 -03:00
packerlschupfer 88262ee504 CLI: guard 4 null derefs when loading a 3mf without preset ids (#14580)
CLI: guard 4 null derefs when loading a 3mf with no preset ids

At OrcaSlicer.cpp:1700-1704 the post-load block reads printer_settings_id,
print_settings_id, filament_settings_id, and nozzle_diameter from the
config that the 3mf carries. If the 3mf is a BBL/BBS-flavored 3mf but
was produced by a non-GUI writer (e.g. CLI --export-3mf without a
loaded preset) any of those keys can be absent, and config.option<T>(...)
returns nullptr — the ->value / ->values deref then SIGSEGVs.

Wrap each optional lookup in an if-let. printer_model, printer_extruder_variant,
and print_extruder_variant already pass create_if_missing=true and are safe.

Repro (BEFORE this patch):
  orca-slicer --export-3mf out.3mf in.stl   # produces preset-less 3mf
  orca-slicer --info out.3mf                # SIGSEGV at :1700
  bt: __cxx11::basic_string::_M_assign
    -> Slic3r::CLI::run @ OrcaSlicer.cpp:1700

AFTER: --info out.3mf returns exit 0 with the mesh summary.

The same failure mode affected --inspect-mesh, --inspect-paint, and
every other action that has to walk the loaded model's config; --slice
would only survive because it always injects a printer via
--load-settings.
2026-07-13 15:10:33 +08:00
packerlschupfer a529f8c473 Print Host: add Moonraker (Klipper) host type (#13991)
OrcaSlicer currently ships an "Octo/Klipper" host type that maps to the
OctoPrint REST endpoints (api/version, api/files/local). It works for
Klipper setups that run Moonraker with the OctoPrint-emulation plugin,
but native Moonraker — and Moonraker-compatible firmwares like the
Prusa-Firmware-Buddy buddy-klipper fork — speak a different shape:
distinct paths, JSON body for /printer/print/start, {"result":...}
envelope. There's no host type for that today.

Add a new Moonraker class deriving from PrintHost. Endpoints used,
matching the Moonraker spec:

- GET  /server/info                      — connection test, reads
                                            result.klippy_state
- GET  /server/files/roots               — storage-picker dropdown
                                            (returns roots with 'w'
                                            permission); gracefully
                                            degrades if absent
- POST /server/files/upload (multipart)  — upload (form fields:
                                            file, root)
- POST /printer/print/start (json)       — {"filename":"<path>"}; the
                                            filename is whatever the
                                            upload response returned
                                            in result.item.path, so any
                                            server-side rename
                                            (collision suffix etc.) is
                                            respected. JSON body is
                                            built via property_tree
                                            write_json so exotic
                                            characters in the path are
                                            properly escaped.

Auth: X-Api-Key header, only when printhost_apikey is non-empty
(Moonraker can be configured to require it but doesn't by default).
HTTP Basic / Digest are not part of the Moonraker spec and are not
sent.

Storage root is read from upload_data.storage with "gcodes" as the
fallback default, so the existing storage-picker plumbing in
PrintHostDialogs lights up automatically once enumerable roots are
returned.

UI: registers as the "Moonraker (Klipper)" entry under host_type;
selectable via the existing Physical Printer dialog (sidebar's
connection button on the printer card).

Verified against a Prusa-Firmware-Buddy buddy-klipper fork (firmware
identifies as moonraker_version "0.8.0-prusalink-shim"): /server/info
test, multipart upload to /server/files/upload, and JSON
/printer/print/start all work end-to-end. The existing "Octo/Klipper"
entry is left untouched so users currently relying on Moonraker's
OctoPrint-emulation plugin keep working.
2026-06-06 11:12:42 +08:00
packerlschupferandPackerlschupfer f760f4e462 Fix Flow Ratio dialog shrinking after main window minimize (#13545)
FlowRateCalibrationDialog was missing the SetSizeHints call that every
other calibration dialog in calib_dlg.cpp uses (Cornering, Pa, Pa Pattern,
Max Volumetric Speed, Temperature, Retraction, VFA, Input Shaping). Without
it, the dialog has no enforced minimum size: on wxGTK, iconizing the main
window while the dialog is open triggers a layout/refresh that re-runs
Fit() with transient zero-sized children, collapsing the dialog to ~222x249
px with the OK button clipped and unreachable. The window manager then
honors the (incorrect) small geometry hint, so the user cannot resize it
back manually.

Two changes:

* Add v_sizer->SetSizeHints(this) after Fit() in the constructor, matching
  the pattern used by all other dialogs in this file. This locks in the
  correct minimum the first time Fit() runs, before any iconize event.

* Remove the redundant Fit() from on_dpi_changed. With SetSizeHints in
  place the WM enforces the minimum, but the unconditional Fit() on every
  DPI/refresh signal was the trigger for the shrink path; Refresh() alone
  is sufficient here.

Co-authored-by: Packerlschupfer <packerl@schupfer.at>
2026-05-16 21:29:01 +08:00