fix: H2C carousel - port BBS NozzleStatusRecorder for per-slot purge tracking (#14800)

## 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.
This commit is contained in:
SoftFever
2026-07-22 12:59:17 +08:00
committed by GitHub
13 changed files with 3300 additions and 47 deletions

View File

@@ -11988,12 +11988,53 @@ bool Plater::priv::check_ams_status_impl(bool is_slice_all)
auto nozzle_volumes_values = preset_bundle->project_config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type")->values;
assert(obj->GetExtderSystem()->GetTotalExtderCount() == 2 && nozzle_volumes_values.size() == 2);
if (obj->GetExtderSystem()->GetTotalExtderCount() == 2 && nozzle_volumes_values.size() == 2) {
// Map device flow->volume via the table, not `flowtype - 1` (which mis-maps U_FLOW to nvtHybrid).
NozzleVolumeType right_nozzle_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(0));
NozzleVolumeType left_nozzle_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(1));
NozzleVolumeType preset_left_type = NozzleVolumeType(nozzle_volumes_values[0]);
NozzleVolumeType preset_right_type = NozzleVolumeType(nozzle_volumes_values[1]);
is_same_as_printer = (left_nozzle_type == preset_left_type && right_nozzle_type == preset_right_type);
// [Vortek] H2C: Use BBS-style NozzleGroupInfo comparison instead of direct nozzle type match.
// This correctly handles Hybrid presets (which expand into per-type counts) and detects
// never-synced state (nozzle_count==0) so the first sync dialog appears.
// After device sync, extruder_nozzle_stats matches printer → dialog suppressed.
// Reference to BBS: BambuStudio/src/slic3r/GUI/Plater.cpp is_extruder_stat_synced()
using namespace MultiNozzleUtils;
auto nozzle_diameter_values = preset_bundle->printers.get_edited_preset().config.option<ConfigOptionFloatsNullable>("nozzle_diameter")->values;
// Build preset nozzle groups from extruder_nozzle_stats config
std::vector<std::vector<NozzleGroupInfo>> preset_nozzle_infos(nozzle_diameter_values.size());
for (size_t extruder_id = 0; extruder_id < nozzle_diameter_values.size(); ++extruder_id) {
NozzleVolumeType preset_volume_type = NozzleVolumeType(nozzle_volumes_values[extruder_id]);
std::string preset_diameter = format_diameter_to_str(nozzle_diameter_values[extruder_id]);
if (preset_volume_type == nvtHybrid) {
// Hybrid: expand into separate groups for each nozzle type from stats
int std_count = getExtruderNozzleCount(preset_bundle, extruder_id, nvtStandard);
int hf_count = getExtruderNozzleCount(preset_bundle, extruder_id, nvtHighFlow);
if (std_count > 0)
preset_nozzle_infos[extruder_id].emplace_back(preset_diameter, nvtStandard, extruder_id, std_count);
if (hf_count > 0)
preset_nozzle_infos[extruder_id].emplace_back(preset_diameter, nvtHighFlow, extruder_id, hf_count);
// If both are 0 → never synced → empty group → will mismatch
} else {
int count = getExtruderNozzleCount(preset_bundle, extruder_id, preset_volume_type);
preset_nozzle_infos[extruder_id].emplace_back(preset_diameter, preset_volume_type, extruder_id, count);
}
}
// Compare with printer nozzle groups
auto printer_groups = obj->GetNozzleSystem()->GetNozzleGroups();
for (const auto& preset_groups : preset_nozzle_infos) {
for (const auto& preset_group : preset_groups) {
if (preset_group.nozzle_count == 0) {
// Never synced: if printer has nozzles of this type → needs sync
if (std::find_if(printer_groups.begin(), printer_groups.end(),
[&preset_group](const NozzleGroupInfo& elem) { return preset_group.is_same_type(elem); })
!= printer_groups.end()) {
is_same_as_printer = false;
break;
}
} else if (std::find(printer_groups.begin(), printer_groups.end(), preset_group) == printer_groups.end()) {
is_same_as_printer = false;
break;
}
}
}
}
std::vector<std::map<int, int>> ams_count_info;