Upstream moved pressure advance onto the extruder variant: enable_pressure_advance,
pressure_advance and the four adaptive keys joined filament_options_with_variant,
the repeated inline blocks in set_extruder() became a helper, and the lookups
moved from the filament id to get_filament_config_index().
All three conflicts were the same collision, because this branch had modified two
of those same inline blocks to pass a tool qualifier so each carriage is addressed
explicitly in parallel modes. Taking either side whole would have lost something:
upstream's drops the qualifier and leaves parallel carriages with no pressure
advance, ours drops the per-variant indexing and reads the wrong column on a
multi-variant printer. The helper now takes an optional tool, defaulting to -1,
which omits the qualifier. imex_pem_tool_for() already returns -1 off IMEX and in
primary mode, so non-IMEX output is unchanged, and the three call sites that never
passed a tool keep upstream's behavior exactly.
The third conflict was two test cases appended at the same place. Both are kept.
Separately, one defect that merged cleanly and so was not flagged: the loop that
emits pressure advance for secondary carriages at the start of a print still
bounded and indexed those vectors with a raw filament id. They are variant
expanded now, so their length is columns rather than filament slots -- the value
read was the wrong column, and the bound no longer sat in slot space, letting an
out-of-slot filament through. It now bounds on filament_diameter and translates
with get_filament_config_index(), which is what the sibling second-layer
temperature loop already does.
Verified: both changed translation units compile clean under -Werror. The merge
was resolved independently twice and the two resolutions agree on every line of
code. Not yet run: the Release test suite and a parallel-mode slice sweep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two conflicts are both places where upstream landed on top of IDEX/IQEX
code. In GCode.cpp the relocated file header block meets the IMEX placeholder
block, and the placeholders are set first: file_start_gcode is processed through
the placeholder parser now, which throws on a name it does not know, so a script
naming {imex_mode} would abort the export if the header ran first. In
test_multifilament.cpp both sides appended a case at the end of the file.
Four files overlapped, and each resolution favours upstream where the two sides
had done the same work:
WipeTower's M104/M109 tool qualifier. Both sides bounds-checked the physical
extruder map lookup; upstream omits the T qualifier when the map cannot answer,
where this branch fell back to the logical index and so named a carriage that may
not be the one printing. Upstream's behaviour is what this branch documents
elsewhere, so its version is taken and the local helper is dropped.
get_extruders' mixed-slot switch. Upstream added the same concept to the CLI
overload as expand_mixed_slots, so the GUI overloads' parameter is renamed to
match rather than carrying two names for one idea.
GLCanvas3D's sequential-clearance branch gains upstream's
update_compacted_wipe_tower_clearance for the by-layer case.
The printer_agent re-sync in TabPrinter::reload_config was upstream's and their
preset-undo fix removed it, so it goes; the IMEX modes grid re-sync beside it
stays, since it spans three options and is not a Field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Toolchange Cyclic Order
* Apply cyclic order to first layer
* Unit test
* Copilot fixes
---------
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
* Run every profile maintenance job from one tool
orca_id_tool.py becomes orca_profile_tool.py, and orca_extra_profile_check.py
and orca_filament_lib.py fold into it as subcommands: check, generate-id, fix,
trim, update-index and update-snapshot. The three scripts already overlapped --
the checker imported half of its rules from the id tool, which in turn kept a
copy-pasted set of output helpers to avoid the resulting import cycle -- while
disagreeing on how a vendor is enumerated, how a JSON file is read and what the
exit code means. One file settles all three.
check, normalize, trim and update-index reproduce their predecessors exactly; normalize and
update-index were diffed byte-for-byte against the old scripts over a copy of
the whole tree. Deliberate changes: the compatible-printers check no longer
switches itself off when --check-materials is passed, an error exits 1 rather
than -1, update-index honours --profile-type and reports a profile it cannot
place instead of dropping it from the index, fix and update-index gained
--dry-run, trim keeps an unindexed file some surviving profile still inherits
from, and vendors are enumerated as directories with an index -- which is why
blacklist.json, a data file that an unscoped index rebuild once wrote four empty
list sections into, loses them here and will not collect them again. The dead
rename_filament_system() helper is gone.
The suite under scripts/tests now covers the maintenance commands too, and CI
runs it; nothing there ran in CI before. No shipped profile data changes apart
from those four keys.
* update vendor index files with "python3 ./scripts/orca_profile_tool.py update-index" and "python3 ./scripts/orca_profile_tool.py normalize"
* 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.
No IMEX code upstream, so nothing in this merge touches the feature. All 21 overlapping
files auto-resolved; verified every upstream addition is present in the merged tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of these came from the review; they were found auditing the branch for the
same class of leak review comment 6 identified.
physical_extruder_map was normalised through effective_physical_extruder_map on
every Print::apply(), so any printer with more than one nozzle and no authored map
got the identity [0,1,...,n-1] where the single-element {0} default belongs. The
key carries two readings: the IMEX paths index it by logical extruder and need one
entry per extruder, while the inherited BBL paths read it through the clamping
get_at(), for which {0} means "everything is physical 0". Deriving unconditionally
imposed the IMEX reading on profiles that mean the other one, changing the config
block line and the {first_tools} / {first_filaments} / {curr_physical_extruder_id}
placeholders for multi-nozzle non-IMEX printers. Gated on is_imex; every consumer
needing the per-extruder form is already IMEX-gated, and profiles with an authored
map of the right length are unaffected either way.
That gating unmasked a latent out-of-bounds read: WipeTower's M104/M109 emitters
index m_physical_extruder_map by tool with no bounds check, which reads past the
end of the single-element default on a multi-nozzle machine. Upstream's bug, from
the BambuStudio wipe tower sync, previously hidden because the map was being
widened for everyone. Now bounds-checked, falling back to the tool's own index --
the form GCodeProcessor already uses for the same map.
Preset::save() and get_preset_differed_for_save() carried a branch storing the
full vector whenever a child and its parent had different lengths. It was written
against a set_with_nil that threw on mismatched sizes; upstream #13035 replaced
that with a tolerant version that keeps the child vector verbatim and nil-marks
only the overlapping range, and that fix was already in the tree when this branch
was rebased. Left in, it defeated the delta encoding for every user printer preset
whose extruder count differs from its parent's: a 7-extruder profile inheriting a
single-extruder base wrote out all of its per-variant retraction keys as literals,
including ones identical to the parent, pinning them against future vendor updates
while the UI still reported the preset as inheriting. Removed; the two save loops
are now identical to upstream. Note this only affects new saves -- presets already
written keep their frozen values until re-saved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Validate mixed-filament definitions during the published material pass: definitions whose components reference slots that do not exist or hold other mixed filaments, or that carry fewer than two components, are reported through the shared skipped_keys channel instead of shipping a mix the GUI integrity check would only flag later.
Fix the slot-limit exhaustion report being silently dropped: it wrote to published_config->skipped_keys, which the pass's final move-assignment from the local vector clobbers. All rejections now go through the local.
Remove the unreachable persist branch from add_detached_preset: no caller passes save_to_project=false, so the parameter is gone and the copy is always project-embedded.
Tests: cover the exhaustion path, the new definition validation, the identity-tier matching matrix (including substitute reporting), the structural-key denylist, whole-vector size-mismatch skips, relocation payload degradation, the "(Published 2)" uniquify chain, mixed blend colours staying out of shared preset configs, and duplicate-slot last-wins. Also fix the legacy-3mf scenario passing vacuously behind an if-guarded assertion. All existing published/3mf tests pass unchanged.
Brings in 54 upstream commits, the bulk of them the BambuStudio-ported color
mixing / mixed filament subsystem (#15347) plus its follow-ups, along with the
Assimp-backed colored OBJ import, warning-policy build changes, and assorted
profile and localization updates.
Two conflicts, both "each side added at the same point", resolved by keeping
both:
- Print::validate() -- our IMEX multi-color block and upstream's new gradient
mixed filament warning were inserted at the same spot after the empty
extruders check. They test unrelated conditions, so both are kept, each with
its own closing brace.
- tests/libslic3r/test_3mf.cpp -- our three IMEX per-plate round-trip scenarios
and upstream's mixed-filament round-trip scenario both append to the end of
the file, and each side added one include. All four scenarios and both
includes are kept.
Everything else merged cleanly, including GCode.cpp, ToolOrdering.cpp,
PartPlate.cpp and PrintConfig.cpp. Upstream left the is_extruder_used block
untouched, so the IMEX supplement still applies, and estimate_wipe_tower_polygon
is unchanged, so the prime tower hull work is unaffected.
Not addressed here, and worth its own change: a mixed filament is a virtual slot
that no nozzle carries, while physical_extruder_map routes logical slots to
physical heads. Print::extruders() lists mixed slots under their own id whereas
tool_ordering.all_extruders() lists them post-expansion, so the IMEX pem lookups
have no defined answer for a mixed slot. Upstream's own guards reject a mixed
filament where a physical slot is required; IMEX likely wants the same.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Description
This PR ports the color mixing feature from BambuStudio.
The port is based on the previous work by @ianalexis in #15231.
This PR completes the port and fixes various bugs.
Several improvements were also made during the porting process.
WIP
# Screenshots/Recordings/Graphs
<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->
## Tests
<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->
<!--
> A guide for users on how to download the artifacts from this PR.
-->
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
Upstream replaced MainFrame's fixed-index TabPosition enum with string-based
page ids ("feat: refactor notebook/tabs to be string based instead of fixed
index based"). The IMEX toolhead-visibility menu item was the only consumer of
that enum left on this branch, so its enable check now compares
m_tabpanel->GetSelectedPageName() against TAB_ID_PREVIEW -- the same form the
neighbouring upstream menu items use.
That mismatch is what broke CI: the branch built on its own, but the merge
commit CI builds no longer had TabPosition declared. No other conflicts.
666/666 tests pass in Release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add caching system for presets
* Removing user\bundle serialization and keeping it only for system presets
* Integrate caching into WebGuideDialog which speeds up time of SetupWizzard and PrinterSelection dialog
* Add CI\CD step to prepare cache file in ahead of time so user does not need to wait
* Add partial cache generation when only one of the vendros is changed to speed up recalculation time
* Handle corrupted files
* Add cache to GuideDialog as previos version didn't work as expected
* Add inspecting tool and fix CI cache generation
* Generate cache per vendor
* Simplify code by mergin it in PresetBundle
* Simplify code a bit more
* Add cereal serialize() to VendorProfile, PrinterModel, Preset, and Semver
* Remove CachedPrinterModel/VendorProfile/Preset mirror structs from VendorCache
* Fix use-after-free in CallAfter lambda; replace raw thread pointer with unique_ptr
* Use get_vendor_cache_key() to match cache keys written by the app
* Remove BOM added by VSC
* Skip invalid vendors
* Remove leftover cache file
* Fix build for windows arm64
* Revert json cache back
* Update check for stale cache
* Serealize all value fields for Preset class to minimize regression later
* Minimize field duplication by moving Cache thing into PresetBundle
* Add tests for Cache system
* Add a bit more tests
* Merge branch 'main' into feature/cache_profiles_and_optimize_loading_speed
* Rvert from per-verndor to single cache file
Replace N per-vendor .cache files with a single system_presets.cache
that holds all vendors and presets in one serialized blob.
Cache load is now all-or-nothing: on hit all vendors are applied from
the bundle (sub-second); on miss all vendors are parsed from JSON and
a fresh bundle is written to the user cache dir.
Invalidation is driven by bundle_key - a sorted concatenation of all
vendor JSON version strings. Any vendor update invalidates the whole
cache and triggers re-parse on next launch.
Guide wizard (WebGuideDialog) loads the bundled cache into a plain
PresetBundle instead of a separate VendorGuideData struct, removing
the duplicate data model.
generate_system_cache simplified from a per-vendor loop to a single
save_system_presets_cache() call producing one output file.
* Transfer all Preset fields from cache via move assignmet
apply_vendor_preset_group was copying fields manually and missed
bundle_id, user_id, base_id, sync_info, updated_time, key_values,
ini_str. Replace field-by-field copy with move assignment of the
fully-deserialized Preset, then restore the vendor pointer which
is excluded from serialization.
* Ignore cache for future
* Remove not used files
* Ship one preset cache per vendor in place of the profile JSONs
Each vendor's system presets serialize into a single <vendor>.opc built at
package time, and a shipped build carries that file alone — the profile JSON
and its sub-file tree are pruned. The vendor loader, the setup wizard's profile
list and the resource installer all read a vendor through its cache, falling
back to parsing whenever one is absent, stale or unreadable, so the cache stays
an optimization and never a source of truth. Caches hold presets in source form
and resolve inheritance at load, through the same code the JSON path uses.
* Make the preset cache self-describing and load each vendor from the system folder alone
The cached DynamicPrintConfig is keyed by name, through a per-file dictionary of the
distinct opt_keys, the type each was written as, and the distinct enum value names,
instead of by serialization_key_ordinal — a position assigned by declaration order at
static init, where inserting one option shifts every later ordinal and the lookup then
succeeds on the wrong option. Because a name-keyed payload drops the options this build
cannot place rather than being rejected wholesale, the schema fingerprint goes, and with
it the two fallbacks that existed only because an installed cache died on every app
upgrade: the second lookup tier into resources/profiles and the parse fallback to the
same place. A vendor is loaded from <data_dir>/system/ and nowhere else, as on main —
which is what makes the app write its .opc files there again.
* Simplify the preset cache internals after review
* Use the shared temp-dir helper in the preset bundle loading test
* Bound stamp string reads in the preset cache
* Speed up the setup wizard with a profile-data cache
The wizard's per-vendor fast path threw on vendors present only in
resources, falling back to a ~29 s raw JSON scan on every open. Each
vendor now loads from the directory it was found in, and the derived
model/machine/filament/process catalog is cached whole in
<data_dir>/cache/wizard_profile_data.json, stamped by each vendor's
name and version - a fresh cache makes an open one file read, with no
bundle built and no presets installed (~0.2 s vs ~2 s).
* Remove debug SVG dump from a geometry test
* Move the per-vendor cache file format into PresetCacheFormat
* Move the vendor install helpers from PresetBundle into Utils
* rename
* fix flatpak
* change cache version to 1
---------
Co-authored-by: SoftFever <softfeverever@gmail.com>
Adds a printer option that picks up the new tool without a blocking temperature
wait, travels to the wipe tower, and waits there right before purging, parked
beside the tower so the ooze from the heat-up lands next to it rather than on the
model. The incoming filament's target is raised ahead of the tool change, so the
heat-up overlaps both the change itself and the travel to the tower.
Off by default, and only offered for multi-extruder printers using a Type 2 wipe
tower; the generic toolchanger profile enables it.
* update snapmaker profiles. largely ported for Snapmaker Orca fork
* update prime volume
* set precise_outer_wall to 1
* Update per-material multi-tool ramming to the filament library
* Add per-filament overrides for toolchange retraction
* Set toolchange retraction per filament for Snapmaker U1
* set default support type to tree
* format snapmaker profiles
# Description
Changing a slicing plugin's configuration had no effect on the sliced
result until you forced a re-slice some other way; it now applies
immediately. Print, printer and filament presets also keep their plugin
configuration separately, so configuring a plugin on one no longer wipes
out what you set on another.
A plugin's custom configuration page gets the same round of improvements
in both the Plugins dialog and the per-preset dialog: it follows the
app's light/dark theme, keeps its state while you edit instead of
resetting under the cursor, and can tell whether it is being edited
globally or for a preset, so "Restore defaults" can be labeled for what
it will actually do. The two bundled examples show this off — Twistify
now ships a custom configuration UI, and Inspector is themed, groups
# Screenshots/Recordings/Graphs
https://github.com/user-attachments/assets/02ca062a-5143-49a3-abe0-a2a040b3a928
## Tests
<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->
<!--
> A guide for users on how to download the artifacts from this PR.
-->
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
## Problem
On H2C (carousel) printers, the wipe tower purge volume calculation in
`_make_wipe_tower()` tracks filament state **per-extruder** (2 slots).
Since H2C has up to 7 carousel nozzle slots on a single extruder, all
filaments sharing that extruder are collapsed into one tracking slot.
This causes:
Test:
[5cubes.3mf.zip](https://github.com/user-attachments/files/30092551/5cubes.3mf.zip)
- **Massive redundant AMS flushing** every filament change on the
carousel triggers a full purge against the "previous" filament, even
when the target nozzle slot already has the correct filament loaded
- **60.9g total weight** instead of ~17g (**3.5× material waste**)
- **3h09m print time** instead of ~1h57m (**60% longer**)
## Root Cause
The code uses `nozzle_cur_filament_ids[extruder_id]` (a 2-element array)
to track which filament was last used for each extruder. BambuStudio
uses `NozzleStatusRecorder`, which tracks per `group_id` (physical
carousel slot 0..6).
## Changes
| File | Change |
|---|---|
| `Print.cpp` | Replace `nozzle_cur_filament_ids` with
`NozzleStatusRecorder`. Use `get_nozzle_for_filament()` to resolve the
physical carousel slot per layer. Select `filament_prime_volume_nc` for
nozzle changes, `filament_prime_volume` for filament changes. |
| `PrintConfig.hpp` | Add `ConfigOptionFloats filament_prime_volume`
(per-filament EC prime volume, missing from upstream but present in BBS
and H2C profiles) |
| `PrintConfig.cpp` | Register `filament_prime_volume` with default
45mm³ (matching BBS) |
| `Preset.cpp` | Add `filament_prime_volume` to preset keys |
Also includes `tests/compare_analyzer/` - two standalone Python tools
for G-code slice comparison and temperature timeline analysis (stdlib
only, no dependencies).
## Test Results (5-color H2C Hybrid print, same 3mf project)
| Metric | Upstream (broken) | **Fixed** | BBS (reference) |
|---|---|---|---|
| **Total weight** | 60.90g | **16.20g** ✅ | 17.47g |
| **Print time** | 3h09m | **1h57m** ✅ | 1h51m |
| **Filament changes** | 105 | 105 | 140 |
| **Tool changes** | 35 | 35 | 35 |
| **Critical discrepancies vs BBS** | ⚠️ YES | ✅ None | — |
## Analysis Tools (`tests/compare_analyzer/`)
Two standalone Python tools (stdlib only, no dependencies) for deep
G-code comparison:
- **`compare_slices.py`** - comprehensive .3mf slice comparison:
filament usage, nozzle mapping, tool change sequences, prime tower
analysis, temperature timeline, retract parameters, and automatic
critical discrepancy detection (weight/time anomalies)
- **`show_temp_plot.py`** - interactive HTML temperature timeline
plotter for visualising heater profiles during multi-nozzle prints
(supports single-file and side-by-side comparison)
Usage:
```bash
python3 tests/compare_analyzer/compare_slices.py file1.3mf file2.3mf --labels "Upstream" "Fixed"
python3 tests/compare_analyzer/show_temp_plot.py file1.3mf file2.3mf
```
## Screenshots
### OrcaSlicer Upstream (unfixed) - 60.90g, 3h09m
<img width="1512" height="982" alt="Screenshot 2026-07-16 at 15 22 58"
src="https://github.com/user-attachments/assets/3efb2bff-ff1e-43db-9669-feafa5921b51"
/>
### OrcaSlicer Fixed - 16.20g, 1h57m
<img width="1512" height="982" alt="Screenshot 2026-07-16 at 15 23 08"
src="https://github.com/user-attachments/assets/c1d36dc5-9b01-4691-80ef-7364540e1f4e"
/>
### BambuStudio Reference - 17.47g, 1h51m
<img width="1512" height="982" alt="Screenshot 2026-07-16 at 15 24 52"
src="https://github.com/user-attachments/assets/5c0b11e2-64f4-40e9-8c7c-3f42519786c3"
/>
### Temperature Timeline: Upstream vs Fixed
<img width="1511" height="829" alt="Screenshot 2026-07-16 at 15 23 35"
src="https://github.com/user-attachments/assets/926c2cb5-dfd4-4ce0-bb40-82e6165eb134"
/>
### Temperature Timeline: Fixed vs BBS
<img width="1512" height="825" alt="Screenshot 2026-07-16 at 15 23 51"
src="https://github.com/user-attachments/assets/85c72dda-e779-4aa6-8118-fb17e5f8482d"
/>
## Compatibility
Safe for non-carousel printers: when each extruder has a single nozzle,
`group_id == extruder_id`, so `NozzleStatusRecorder` behaves identically
to the original per-extruder tracking. The `filament_prime_volume`
default (45mm³) matches the existing global `prime_volume` default.
## Reference
BambuStudio `Print.cpp` `_make_wipe_tower()` L3341-3392 -
`NozzleStatusRecorder` pattern.