Compare commits

..

25 Commits

Author SHA1 Message Date
Ian Chua
bc6ae243ca Merge branch 'main' into fix/plugin-bugs 2026-07-22 13:14:03 +08:00
SoftFever
7db0a59cc1 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.
2026-07-22 12:59:17 +08:00
Ian Chua
b121051220 Merge branch 'main' into fix/plugin-bugs 2026-07-21 12:56:52 +08:00
SoftFever
203ae1588e Merge branch 'main' into feature/h2c_support_clean_fixes 2026-07-20 17:36:16 +08:00
SoftFever
9b5eb76478 Misc updates and clean up
- WipeTower: use filament_ramming_volumetric_speed(_nc) for ramming, falling back to
  max_vol_speed only when nil; gate precool temps on enable_pre_heating
- ToolOrderUtils: disable the inter-layer forecast in the per-nozzle base reorder so
  H2D/H2C ordering is unchanged
- PrintConfig: stop stripping filament_prime_volume in handle_legacy; document that
  prime_volume drives the Type2 wipe tower and filament_prime_volume the Type1 one
- GCodeProcessor: exclude post-print end-gcode M400 dwells from the M73 estimate and
  drop the dead air-filtration state
- Trim verbose BambuStudio source-location comments across the port
2026-07-20 17:34:13 +08:00
Ian Chua
242e100feb fix: stale .whl cache when .whl changes, added option to delete cache and reload 2026-07-20 14:47:26 +08:00
Ian Chua
2a46bd1384 fix: crash transferring Slicing Pipeline Plugin changes across a printer switch 2026-07-20 13:58:57 +08:00
Ian Chua
ddbbc7f7f2 fix: only the top level frame should have the orca syles and bridges injected 2026-07-20 12:48:17 +08:00
Ian Chua
3a48086f5d fix: plugin descriptor should not reply on changelog for latest version 2026-07-20 12:47:18 +08:00
SoftFever
65ab9b61a8 Merge branch 'main' into feature/h2c_support_clean_fixes 2026-07-20 00:02:43 +08:00
denis svinarchuk
d6b9f0e5d4 fix(H2C): port BBS is_extruder_stat_synced() for sync dialog
Replace hand-rolled nozzle type comparison + Hybrid hack with
BBS-style NozzleGroupInfo comparison in check_ams_status_impl.

Previous approach: direct nozzle_volume_type == printer_flow_type
with a Hybrid tolerance lambda. This either suppressed the dialog
entirely (Hybrid always matched) or showed it on every Preview switch.

New approach (matching BBS):
- Build preset NozzleGroupInfo from extruder_nozzle_stats config
- For Hybrid presets: expand into per-type counts (Std#N, HF#M)
- nozzle_count==0 (never synced): dialog appears for first sync
- nozzle_count>0 (after sync): compare with printer GetNozzleGroups()
- Counts match → dialog suppressed on Prepare↔Preview switches

Safe for all multi-extruder printers (H2C, H2D): non-Hybrid presets
use single NozzleGroupInfo per extruder. Single-extruder printers
exit at is_multi_extruders() guard before reaching this code.

Reference to BBS: BambuStudio/src/slic3r/GUI/Plater.cpp
is_extruder_stat_synced() line 16642
2026-07-17 11:00:16 +01:00
denis svinarchuk
d6f57b3066 perf: enable inter-layer forecast in single-nozzle flush ordering
Enable use_forcast in reorder_filaments_for_minimum_flush_volume_base
to match the multi-extruder path behavior (line 1227).

The forecast solver (solve_extruder_order_with_forcast) considers the
next layer's filament set when choosing ordering for the current layer,
minimizing inter-layer transition flush cost.

Previously disabled (hardcoded false) in the single-nozzle/base path,
causing suboptimal inter-layer transitions. The multi-extruder path
already had this enabled.

Measured on 5cubes (5 filaments, 35 layers, H2C):
- Print time: -12 min (-10%)
- Waste filament: -5g (-28%)
- WT extrusion: -44%

Limited to ≤5 filaments per nozzle per layer (O(N!×M!) complexity).
2026-07-17 02:08:53 +01:00
denis svinarchuk
ba21ed0a12 show_temp_plot: add precool zone visualization
- Add precool (cooldown) zone rendering on departing nozzle panels
- Lime green color for precool, crimson for preheat
- Unified badge-style tooltips (Pheat/Pcool/TC/Wipe)
- Filter invalid precool events (S0, <2s duration)
2026-07-16 22:43:03 +01:00
denis svinarchuk
11f900aa2e WipeTower: add per_cooling_max_speed clamping for H2C extruder changes
Clamp ramming speed during extruder changes so the departing nozzle
has enough time to reach precool_target_temp before carousel rotation.
Only applies to extruder changes (not carousel nozzle changes).

Reference to BBS: BambuStudio/src/libslic3r/GCode/WipeTower.cpp
ramming() L3449-3462
2026-07-16 22:40:27 +01:00
denis svinarchuk
da2ea10fe4 show_temp_plot: scale physical timeline by M73 trapezoid estimate
Physical dist/speed ignores acceleration/deceleration, giving
underestimated total time (29 min vs real 48 min). M73 from the
trapezoid planner accounts for accel/decel and is closer to reality.

Now we keep physical DISTRIBUTION (proportions per-filament) but
scale the X-axis so total time matches M73 trapezoid estimate.
2026-07-16 21:44:50 +01:00
denis svinarchuk
9f394df985 fix(show_temp_plot): use physical G1 motion time for timeline instead of M73
Replace M73-based timeline interpolation with physical time calculation
from G1 feedrates and M400 delays. M73 has 1-minute resolution and
non-uniform granularity which distorts the time axis (e.g. P83→P100
jump makes last filament appear much longer than it actually is).

Physical timeline computes cumulative time per gcode line from actual
move distances and feedrates, giving accurate filament duration on plot.
Falls back to M73 interpolation when raw gcode lines are not available.
2026-07-16 21:06:05 +01:00
denis svinarchuk
403292cb63 fix(GCodeProcessor): exclude end gcode M400 delays from M73 time estimation
End gcode contains firmware-conditional M400 waits for air purification,
timelapse capture, and sound notification that are post-print operations.
These were incorrectly included in M73 total time, inflating the estimate.

The fix detects MACHINE_END_GCODE_START tag during the streaming parse
(process_tags) and sets m_skip_end_gcode_delays=true. process_M400 then
skips timed delays (S/P params) in the end gcode scope.

BBS achieves the same effect by dropping leftover in calculate_time
(is_final=true). We skip at the source instead, which is more surgical
and leaves calculate_time behavior unchanged for all printers.

Affects all BBL printers with MACHINE_END_GCODE_START tag.
Non-BBL printers are unaffected (no tag = no skip).
2026-07-16 20:49:36 +01:00
denis svinarchuk
fc705612a4 show_temp_plot: fix M620 timeline weight for accurate toolchange duration
The M620→M621 firmware toolchange block weight (500x) was only applied
to sparse track sample lines. Hundreds of G1 moves between samples
inside the M620 block got weight=1, causing firmware toolchange to
appear compressed on the timeline plot.

Build continuous M620→M621 line ranges from track samples and apply
weight=500 to ALL lines within those ranges. This makes toolchange
and wipe tower zones proportionally accurate on the timeline.
2026-07-16 18:59:34 +01:00
denis svinarchuk
b0dddb4648 wipe_tower: carousel barrier parity with BBS (precool, ramming speed, reverse travel)
Match BBS ramming() L3421-3535 and toolchange_wipe_new L4039-4044:

nozzle_change_new (carousel = !extruder_change):
  - M632 barrier filled: M400 + M104 precool + M106 fan (BBS L3425-3428)
  - Carousel-specific ramming speed via max_e_ramming_speed.second (BBS L3435)
  - Reverse travel after ramming via ramming_travel_time (BBS L3499-3526)
  - Heater index via m_physical_extruder_map (matches BBS get_extruder_id)

toolchange_wipe_new (add_M104_by_requirement):
  - format_line_M104: M400+M104 instead of M632+M104+M633 (BBS L1328-1341)
  - No carousel suppression: fires for both extruder and carousel changes (BBS L4040-4044)
  - m_is_multiple_nozzle gate retained (Orca calls this for all printers; BBS has it H2C-only)

FilamentParameters (WipeTower.hpp):
  - Added: max_e_ramming_speed, ramming_travel_time, precool_target_temp, precool_t
  - Added: is_need_reverse_travel() method

Init (WipeTower.cpp):
  - Carousel params from config: ramming speed, precool temp/timing, travel time
  - Matches BBS init L1878-1932

Non-H2C safety: all carousel paths gated by m_is_multiple_nozzle (false for non-H2C).
Ramming speed fallback chain ensures identical values for non-H2C printers.
2026-07-16 18:36:39 +01:00
denis svinarchuk
b90ac13d86 fix(H2C): carousel nozzle change zone — ramming depth, M632 barriers, preheat suppression
- Add nozzle_change_depth override for carousel rotations (same extruder,
  different nozzle) in plan_toolchange() and plan_tower_new() — previously
  depth was 0 because condition checked m_filament_map (extruder-level)
  instead of m_filament_nozzle_map (nozzle-level)
- Emit M632/M633 carousel barriers inside NOZZLE_CHANGE_START/END markers
  in nozzle_change_new(), matching BBS ramming() L3421-3429 pattern
- Add is_extruder_change flag to NozzleChangeResult (matches BBS field)
  to propagate carousel/extruder-change distinction downstream
- Suppress duplicate M632 in add_M104_by_requirement for carousel —
  M632 already emitted inside nozzle_change_new(); extruder change
  preheat M104 preserved (BBS has no add_M104_by_requirement at all)

Reference to BBS: BambuStudio/src/libslic3r/GCode/WipeTower.cpp
  ramming() extruder_change flag, is_need_ramming(), plan_toolchange()
2026-07-16 18:00:22 +01:00
denis svinarchuk
8dd3f784be chore: remove accidentally staged embedded test repo 2026-07-16 15:49:48 +01:00
denis svinarchuk
4f62778b1b chore: remove screenshots from repo (use PR attachments instead) 2026-07-16 15:48:23 +01:00
denis svinarchuk
18f62e634e docs: add H2C purge regression screenshots for PR
Screenshots comparing upstream (60.9g), fixed (16.2g), and BBS reference (17.5g)
slicing results, plus temperature timeline plots.
2026-07-16 15:26:43 +01:00
denis svinarchuk
6a584c7c79 test: add compare_analyzer tools for G-code slice comparison
Add two standalone Python tools for deep comparison and analysis of .3mf
slicing project files:

- compare_slices.py: comprehensive slice comparison with filament usage,
  nozzle mapping, tool change sequences, prime tower analysis, temperature
  timeline, and automatic critical discrepancy detection
- show_temp_plot.py: interactive HTML temperature timeline plotter for
  visualizing heater profiles during multi-nozzle prints

Both tools use only Python stdlib (no external dependencies).
Primary use case: regression testing H2C carousel purge volumes and
BBS compatibility verification.
2026-07-16 15:22:26 +01:00
denis svinarchuk
407c78fb30 fix: port BBS NozzleStatusRecorder pattern for per-carousel-slot purge tracking
The upstream _make_wipe_tower() tracked purge volumes per-extruder (2 slots),
which collapsed all H2C carousel filaments into one slot and caused massive
redundant AMS flushing (~40g instead of ~0.4g).

Changes:
- Print.cpp: Replace per-extruder nozzle_cur_filament_ids with BBS
  NozzleStatusRecorder that tracks per group_id (carousel slot 0..6).
  Use get_nozzle_for_filament() to resolve physical slot per layer.
  Select filament_prime_volume_nc for nozzle changes, filament_prime_volume
  for filament changes (BBS pattern).
- PrintConfig.hpp/cpp: Register filament_prime_volume (per-filament EC prime
  volume, default 45mm³) matching BBS PrintConfig.
- Preset.cpp: Add filament_prime_volume to preset keys list.

Safe for non-carousel printers: group_id == extruder_id when each extruder
has one nozzle, so NozzleStatusRecorder behaves identically to the original
per-extruder tracking.

Reference to BBS: BambuStudio/src/libslic3r/Print.cpp _make_wipe_tower() L3341-3392
2026-07-16 14:54:46 +01:00
19 changed files with 3369 additions and 63 deletions

View File

@@ -614,6 +614,8 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P
float leftover = 0.0f;
for (size_t i = additional_buffer_idx; i < additional_buffer.size(); ++i)
leftover += additional_buffer[i].second;
BOOST_LOG_TRIVIAL(debug) << "calculate_time(is_final): leftover=" << leftover
<< "s from " << (additional_buffer.size() - additional_buffer_idx) << " items";
time += double(leftover);
gcode_time.cache += leftover;
} else {
@@ -3492,6 +3494,7 @@ void GCodeProcessor::reset()
m_extruder_blocks.clear();
m_machine_start_gcode_end_line_id = (unsigned int) (-1);
m_machine_end_gcode_start_line_id = (unsigned int) (-1);
m_skip_end_gcode_delays = false;
m_remaining_volume = std::vector<float>(MAXIMUM_EXTRUDER_NUMBER, 0.f);
m_line_id = 0;
@@ -4219,6 +4222,14 @@ void GCodeProcessor::process_tags(const std::string_view comment, bool producers
return;
}
// End gcode marker: skip post-print M400 S/P dwells after this point so the M73 estimate reports
// print-completion time, not post-print filtration/cooldown. BBS drops the same remainder in
// calculate_time(is_final).
if (comment == Machine_End_GCode_Start_Tag) {
m_skip_end_gcode_delays = true;
return;
}
// Orca: Integrate filament consumption for purging performed to an external device and controlled via macros
// (eg. Happy Hare) in the filament consumption stats.
if (boost::starts_with(comment, GCodeProcessor::External_Purge_Tag)) {
@@ -6401,6 +6412,10 @@ void GCodeProcessor::process_M400(const GCodeReader::GCodeLine& line)
float value_p = 0.0;
if (line.has_value('S', value_s) || line.has_value('P', value_p)) {
value_s += value_p * 0.001;
// Skip post-print end-gcode dwells so they don't inflate the M73 estimate (see
// m_skip_end_gcode_delays). Only omits dwell time — no state is updated here.
if (m_skip_end_gcode_delays)
return;
simulate_st_synchronize(value_s);
}
}

View File

@@ -1109,6 +1109,10 @@ class Print;
std::vector<ExtruderPreHeating::ExtruderUsageBlcok> m_extruder_blocks;
unsigned int m_machine_start_gcode_end_line_id{ (unsigned int) (-1) };
unsigned int m_machine_end_gcode_start_line_id{ (unsigned int) (-1) };
// Set when the MACHINE_END_GCODE_START tag is seen during the streaming parse; tells
// process_M400 to skip post-print end-gcode dwells (air purification, timelapse, sound)
// so they don't inflate the M73 estimate. BBS excludes them in calculate_time(is_final).
bool m_skip_end_gcode_delays{ false };
// Tracks, during the stream, which filament sits in each physical nozzle and which nozzle each
// extruder currently carries. Written by both branches of the two-arg process_filament_change
// (the fallback branch does occupancy bookkeeping only); read by the richer change-time model

View File

@@ -1091,6 +1091,16 @@ namespace Slic3r
if (layer + 1 < layer_filaments.size()) next_lf = layer_filaments[layer + 1];
std::vector<unsigned int> filament_used_next_layer = collect_filaments_in_groups<unsigned int>(filament_sets, next_lf);
// Enable inter-layer forecast: when choosing filament ordering for current layer,
// also consider next layer's filament set to minimize inter-layer transition flush.
// solve_extruder_order_with_forcast() tries all permutations of curr+next layer
// and picks the ordering that minimizes total flush across both layers.
// This avoids expensive inter-layer transitions (e.g. ending layer with F2 when
// next layer starts with F3, costing flush[F2→F3], instead of ending with F3
// which gives flush[F3→F3]=0). Limited to ≤5 filaments due to O(N!×M!) complexity.
// The per-nozzle base reorder does not use the inter-layer forecast. This function drives
// BBL multi-extruder grouping cost and H2C ordering, so keeping it false avoids perturbing
// existing H2D/H2C output.
bool use_forcast = false;
float tmp_cost = 0;
std::vector<unsigned int> sequence;

View File

@@ -1609,6 +1609,64 @@ void WipeTower::set_extruder(size_t idx, const PrintConfig& config)
if (max_vol_speed!= 0.f)
m_filpar[idx].max_e_speed = (max_vol_speed / filament_area());
// Vortek H2C: carousel-specific ramming, precool, and reverse travel parameters
{
// Ramming speed: .first = extruder change, .second = nozzle change (carousel)
// Use the dedicated ramming volumetric speed, falling back to max_vol_speed only when
// the setting is nil/-1.
float ramming_vol_speed = float(config.filament_ramming_volumetric_speed.get_at(idx));
if (config.filament_ramming_volumetric_speed.is_nil(idx) || is_approx(config.filament_ramming_volumetric_speed.get_at(idx), -1.))
ramming_vol_speed = max_vol_speed;
m_filpar[idx].max_e_ramming_speed.first = (ramming_vol_speed / filament_area());
float ramming_vol_speed_nc = float(config.filament_ramming_volumetric_speed_nc.get_at(idx));
if (config.filament_ramming_volumetric_speed_nc.is_nil(idx) || is_approx(config.filament_ramming_volumetric_speed_nc.get_at(idx), -1.))
ramming_vol_speed_nc = max_vol_speed;
m_filpar[idx].max_e_ramming_speed.second = (ramming_vol_speed_nc / filament_area());
}
{
// Precool target temp: .first = extruder change, .second = nozzle change (carousel)
// Precool is only active when enable_pre_heating is on; otherwise no precool temp/timing is
// applied and the downstream precool_t stays 0, matching printers with pre-heating disabled.
m_filpar[idx].precool_target_temp = {0, 0};
if (config.enable_pre_heating.value) {
if (!config.filament_pre_cooling_temperature.is_nil(idx) && config.filament_pre_cooling_temperature.get_at(idx) != 0)
m_filpar[idx].precool_target_temp.first = config.filament_pre_cooling_temperature.get_at(idx);
if (!config.filament_pre_cooling_temperature_nc.is_nil(idx) && config.filament_pre_cooling_temperature_nc.get_at(idx) != 0)
m_filpar[idx].precool_target_temp.second = config.filament_pre_cooling_temperature_nc.get_at(idx);
}
}
{
// Precool timing: (nozzle_temp - precool_temp) / hotend_cooling_rate
int extruder_count = m_is_multi_extruder ? 2 : 1; // H2C = 2 extruders
float nozzle_temp = float(config.nozzle_temperature.is_nil(idx) ? 0 : config.nozzle_temperature.get_at(idx));
float nozzle_temp_fl = float(config.nozzle_temperature_initial_layer.is_nil(idx) ? nozzle_temp : config.nozzle_temperature_initial_layer.get_at(idx));
m_filpar[idx].precool_t.first.resize(extruder_count, 0.f);
m_filpar[idx].precool_t.second.resize(extruder_count, 0.f);
m_filpar[idx].precool_t_first_layer.first.resize(extruder_count, 0.f);
m_filpar[idx].precool_t_first_layer.second.resize(extruder_count, 0.f);
std::vector<double> cooling_rates = config.hotend_cooling_rate.values;
for (int i = 0; i < extruder_count && i < (int)cooling_rates.size(); i++) {
if (cooling_rates[i] < EPSILON) continue;
if (m_filpar[idx].precool_target_temp.first != 0) {
m_filpar[idx].precool_t.first[i] = std::max(0.f, nozzle_temp - float(m_filpar[idx].precool_target_temp.first)) / float(cooling_rates[i]);
m_filpar[idx].precool_t_first_layer.first[i] = std::max(0.f, nozzle_temp_fl - float(m_filpar[idx].precool_target_temp.first)) / float(cooling_rates[i]);
}
if (m_filpar[idx].precool_target_temp.second != 0) {
m_filpar[idx].precool_t.second[i] = std::max(0.f, nozzle_temp - float(m_filpar[idx].precool_target_temp.second)) / float(cooling_rates[i]);
m_filpar[idx].precool_t_first_layer.second[i] = std::max(0.f, nozzle_temp_fl - float(m_filpar[idx].precool_target_temp.second)) / float(cooling_rates[i]);
}
}
}
{
// Ramming travel time: .first = extruder change, .second = nozzle change (carousel)
m_filpar[idx].ramming_travel_time = {0.f, 0.f};
if (!config.filament_ramming_travel_time.is_nil(idx))
m_filpar[idx].ramming_travel_time.first = float(config.filament_ramming_travel_time.get_at(idx));
if (!config.filament_ramming_travel_time_nc.is_nil(idx))
m_filpar[idx].ramming_travel_time.second = float(config.filament_ramming_travel_time_nc.get_at(idx));
}
m_perimeter_width = nozzle_diameter * Width_To_Nozzle_Ratio; // all extruders are now assumed to have the same diameter
m_nozzle_change_perimeter_width = 2*m_perimeter_width;
// BBS: remove useless config
@@ -1893,7 +1951,7 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change(int old_filament_id, int
.set_initial_tool(m_current_tool)
.set_extrusion_flow(m_extrusion_flow)
.set_y_shift(m_y_shift + (new_filament_id != (unsigned int) (-1) && (m_current_shape == SHAPE_REVERSED) ? m_layer_info->depth - m_layer_info->toolchanges_depth() : 0.f))
.append("; Nozzle change start\n");
.append(format_nozzle_change_tag(true, old_filament_id, new_filament_id));
box_coordinates cleaning_box(Vec2f(m_perimeter_width, m_perimeter_width), m_wipe_tower_width - 2 * m_perimeter_width,
(new_filament_id != (unsigned int) (-1) ? wipe_depth + m_depth_traversed - m_perimeter_width : m_wipe_tower_depth - m_perimeter_width));
@@ -1969,7 +2027,7 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change(int old_filament_id, int
}
}
writer.append("; Nozzle change end\n");
writer.append(format_nozzle_change_tag(false, old_filament_id, new_filament_id));
result.start_pos = writer.start_pos_rotated();
result.end_pos = writer.pos();
@@ -2546,6 +2604,19 @@ void WipeTower::plan_toolchange(float z_par, float layer_height_par, unsigned in
nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width;
depth += nozzle_change_depth;
}
if (nozzle_change_depth == 0
&& !m_filament_nozzle_map.empty()
&& old_tool < m_filament_nozzle_map.size() && new_tool < m_filament_nozzle_map.size()
&& m_filament_nozzle_map[old_tool] != m_filament_nozzle_map[new_tool]) {
double e_flow = nozzle_change_extrusion_flow(layer_height_par);
double length = m_filaments_change_length[old_tool] / e_flow;
int nozzle_change_line_count = length / (m_wipe_tower_width - 2*m_nozzle_change_perimeter_width) + 1;
if (has_tpu_filament())
nozzle_change_depth = m_tpu_fixed_spacing * nozzle_change_line_count * m_nozzle_change_perimeter_width;
else
nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width;
depth += nozzle_change_depth;
}
WipeTowerInfo::ToolChange tool_change = WipeTowerInfo::ToolChange(old_tool, new_tool, depth, 0.f, 0.f, wipe_volume, length_to_extrude, purge_volume);
tool_change.nozzle_change_depth = nozzle_change_depth;
m_plan.back().tool_changes.push_back(tool_change);
@@ -2695,6 +2766,13 @@ bool WipeTower::is_petg_filament(int filament_id) const
return m_filpar[filament_id].material == "PETG";
}
bool WipeTower::is_need_reverse_travel(int filament_id, bool extruder_change) const
{
if (extruder_change)
return m_filpar[filament_id].ramming_travel_time.first > EPSILON;
return m_filpar[filament_id].ramming_travel_time.second > EPSILON;
}
// BBS: consider both soluable and support properties
// Return index of first toolchange that switches to non-soluble and non-support extruder
// ot -1 if there is no such toolchange.
@@ -2819,6 +2897,13 @@ WipeTower::ToolChangeResult WipeTower::tool_change_new(size_t new_tool, bool sol
&& is_valid_last_layer(m_current_tool, m_cur_layer_id, m_z_pos)) {
m_nozzle_change_result = nozzle_change_new(m_current_tool, new_tool, solid_nozzlechange);
}
if (m_nozzle_change_result.gcode.empty()
&& !m_filament_nozzle_map.empty()
&& m_current_tool < m_filament_nozzle_map.size() && new_tool < m_filament_nozzle_map.size()
&& m_filament_nozzle_map[m_current_tool] != m_filament_nozzle_map[new_tool]
&& is_valid_last_layer(m_current_tool, m_cur_layer_id, m_z_pos)) {
m_nozzle_change_result = nozzle_change_new(m_current_tool, new_tool, solid_nozzlechange);
}
size_t old_tool = m_current_tool;
float wipe_depth = 0.f;
@@ -2983,20 +3068,40 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id,
}
float nz_extrusion_flow = nozzle_change_extrusion_flow(m_layer_height);
float nozzle_change_speed = 60.0f * m_filpar[m_current_tool].max_e_speed / nz_extrusion_flow;
nozzle_change_speed = solid_infill ? 40.f * 60.f : nozzle_change_speed;//If the contact layers belong to different categories, then reduce the speed.
bool extruder_change = !is_in_same_extruder(old_filament_id, new_filament_id);
float max_e_ramming = extruder_change
? m_filpar[m_current_tool].max_e_ramming_speed.first
: m_filpar[m_current_tool].max_e_ramming_speed.second;
if (max_e_ramming < EPSILON) max_e_ramming = m_filpar[m_current_tool].max_e_speed; // fallback
float nozzle_change_speed = 60.0f * max_e_ramming / nz_extrusion_flow;
nozzle_change_speed = solid_infill ? 40.f * 60.f : nozzle_change_speed;
if (is_tpu_filament(m_current_tool)) {
nozzle_change_speed *= 0.25;
}
float bridge_speed = std::min(60.0f * m_filpar[m_current_tool].max_e_speed / nozzle_change_extrusion_flow(0.2), nozzle_change_speed); // limit the bridge speed by add flow
float bridge_speed = std::min(60.0f * max_e_ramming / nozzle_change_extrusion_flow(0.2), nozzle_change_speed);
WipeTowerWriter writer(m_layer_height, m_nozzle_change_perimeter_width, m_gcode_flavor, m_filpar);
writer.set_extrusion_flow(nz_extrusion_flow)
.set_z(m_z_pos)
.set_initial_tool(m_current_tool)
.set_y_shift(m_y_shift + (new_filament_id != (unsigned int) (-1) && (m_current_shape == SHAPE_REVERSED) ? m_layer_info->depth - m_layer_info->toolchanges_depth() : 0.f))
.append("; Nozzle change start\n");
.append(format_nozzle_change_tag(true, old_filament_id, new_filament_id));
if (!extruder_change && m_is_multiple_nozzle) {
writer.append("M632 S" + std::to_string(new_filament_id) + " M N\n");
// Use m_physical_extruder_map for heater index (matches format_line_M104 in add_M104_by_requirement)
if (m_filpar[m_current_tool].precool_target_temp.second != 0) {
int logical_ext = m_filament_map.empty() ? 0 : m_filament_map[m_current_tool] - 1;
int phys_ext = (logical_ext >= 0 && logical_ext < (int)m_physical_extruder_map.size())
? m_physical_extruder_map[logical_ext] : logical_ext;
writer.append("M400\n");
writer.append("M104 T" + std::to_string(phys_ext) + " S" +
std::to_string(m_filpar[m_current_tool].precool_target_temp.second) + " N0\n");
writer.append("M106 S255\n");
}
writer.append("M633\n");
}
WipeTowerBlock* block = get_block_by_category(m_filpar[old_filament_id].category, false);
if (!block) {
@@ -3021,6 +3126,23 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id,
dy = solid_infill ? m_nozzle_change_perimeter_width : dy;
nozzle_change_line_count = solid_infill ? std::numeric_limits<int>::max() : nozzle_change_line_count;
m_left_to_right = true;
if (extruder_change) {
float ramming_length = nozzle_change_line_count * (xr - xl);
int extruder_id = m_filament_map.empty() ? 0 : m_filament_map[m_current_tool] - 1;
float precool_t = (extruder_id >= 0 && extruder_id < (int)m_filpar[m_current_tool].precool_t.first.size())
? m_filpar[m_current_tool].precool_t.first[extruder_id] : 0.f;
float precool_t_fl = (extruder_id >= 0 && extruder_id < (int)m_filpar[m_current_tool].precool_t_first_layer.first.size())
? m_filpar[m_current_tool].precool_t_first_layer.first[extruder_id] : 0.f;
float per_cooling_max_speed = nozzle_change_speed;
if (is_first_layer() && precool_t_fl > EPSILON)
per_cooling_max_speed = ramming_length / precool_t_fl * 60.f;
else if (precool_t > EPSILON)
per_cooling_max_speed = ramming_length / precool_t * 60.f;
if (nozzle_change_speed > per_cooling_max_speed) nozzle_change_speed = per_cooling_max_speed;
if (bridge_speed > per_cooling_max_speed) bridge_speed = per_cooling_max_speed;
}
int real_nozzle_change_line_count = 0;
bool need_change_flow = false;
for (int i = 0; true; ++i) {
@@ -3053,9 +3175,40 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id,
block->last_nozzle_change_id = old_filament_id;
NozzleChangeResult result;
if (is_tpu_filament(m_current_tool)) {
if (!extruder_change && m_is_multiple_nozzle) {
writer.append("M632 S" + std::to_string(new_filament_id) + " M N\n");
}
if (is_need_reverse_travel(m_current_tool, extruder_change)) {
bool left_to_right = !m_left_to_right;
int tpu_line_count = (real_nozzle_change_line_count + 2 - 1) / 2; // nozzle_change_line_count / 2 round up
int tpu_line_count = real_nozzle_change_line_count;
float reverse_speed = nozzle_change_speed * 2; // reverse travel runs at double the nozzle-change speed
float rt_time = extruder_change ? m_filpar[m_current_tool].ramming_travel_time.first
: m_filpar[m_current_tool].ramming_travel_time.second;
float need_reverse_travel_dis = rt_time * reverse_speed / 60.f;
float real_travel_dis = tpu_line_count * (xr - xl - 2 * m_perimeter_width);
if (real_travel_dis < need_reverse_travel_dis)
reverse_speed *= real_travel_dis / need_reverse_travel_dis;
writer.travel(writer.x(), writer.y() + dy/2);
for (int i = 0; true; ++i) {
need_reverse_travel_dis -= (xr - xl - 2 * m_perimeter_width);
float offset_dis = 0.f;
if (need_reverse_travel_dis < 0)
offset_dis = -need_reverse_travel_dis;
if (left_to_right)
writer.travel(xr - m_perimeter_width - offset_dis, writer.y(), reverse_speed);
else
writer.travel(xl + m_perimeter_width + offset_dis, writer.y(), reverse_speed);
if (need_reverse_travel_dis < EPSILON) break;
if (i == tpu_line_count - 1)
break;
writer.travel(writer.x(), writer.y() - dy);
left_to_right = !left_to_right;
}
} else if (is_tpu_filament(m_current_tool)) {
bool left_to_right = !m_left_to_right;
int tpu_line_count = (real_nozzle_change_line_count + 2 - 1) / 2;
nozzle_change_speed *= 2;
writer.travel(writer.x(), writer.y() - m_nozzle_change_perimeter_width);
@@ -3080,12 +3233,15 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id,
}
}
writer.append("; Nozzle change end\n");
if (!extruder_change && m_is_multiple_nozzle) writer.append("M633\n");
writer.append(format_nozzle_change_tag(false, old_filament_id, new_filament_id));
result.start_pos = writer.start_pos_rotated();
result.origin_start_pos = initial_position;
result.end_pos = writer.pos_rotated();
result.gcode = writer.gcode();
result.is_extruder_change = extruder_change;
return result;
}
@@ -3506,29 +3662,26 @@ void WipeTower::toolchange_wipe_new(WipeTowerWriter &writer, const box_coordinat
// Emit the arriving-hotend pre-heat inside the M632/M633 nozzle-change barrier. `M632 S<tool>[ H<nozzle>]
// M N` opens the barrier (M = firmware nozzle-change flag, N = slicer generated), the M104 sets the
// arriving hotend temp, and `M633` closes it. H2C's grouping is static (no dynamic nozzle map), so the
// H<nozzle> field is omitted (a dynamic nozzle map would supply a real nozzle id, a static map -1 => no
// H<nozzle> field is omitted (a dynamic nozzle map would supply a real nozzle id, a static map -1 =>no
// H). The counterproductive fan-on (M106 S255) used for departing-tool cooldown is intentionally
// omitted, since this is a pre-HEAT of the arriving tool. The whole helper is only ever called from
// add_M104_by_requirement, which is gated on m_is_multiple_nozzle (extruder_max_nozzle_count>1) => H2C
// only; every other printer's wipe tower is untouched. The M632 M-flag is itself a firmware barrier, so
// a preceding M400 wait is subsumed.
// only; every other printer's wipe tower is untouched.
// BBS: extruder change preheat uses M400 + M104 WITHOUT M632/M633 barrier.
// M632 barriers are only for carousel nozzle changes (emitted in nozzle_change_new/ramming).
auto format_line_M104 = [this](int target_temp, int target_extruder = -1, bool wait_for_moves = true, const std::string &comment = "") {
std::string buffer;
buffer += "M632 S" + std::to_string(m_current_tool) + " M N\n";
if (wait_for_moves)
buffer += "M400\n";
buffer += "M104";
if (target_extruder != -1 && target_extruder < (int) m_physical_extruder_map.size())
buffer += (" T" + std::to_string(m_physical_extruder_map[target_extruder]));
buffer += " S" + std::to_string(target_temp) + " N0"; // N0 means the gcode is generated by the slicer
if (!comment.empty()) buffer += " ;" + comment;
buffer += '\n';
buffer += "M633\n";
(void) wait_for_moves; // the M632 M-flag barrier replaces the former M400 wait
return buffer;
};
// Suppress the pre-heat M104 on the first layer and on solid (contact) toolchanges (should_heating).
// m_is_multiple_nozzle folds in the H2C gate so single-nozzle output is untouched.
// Orca: the arriving extruder id is resolved as m_filament_map[tool]-1 (layer-static) because Orca's
// wipe tower is extruder-level rather than tracking a per-layer nozzle map.
// m_is_multiple_nozzle gate needed because Orca calls toolchange_wipe_new for ALL printers (BBS has it H2C-only).
bool should_heating = m_is_multiple_nozzle && m_filpar[m_current_tool].filament_cooling_before_tower > EPSILON &&
!solid_tool_toolchange && !is_first_layer();
auto add_M104_by_requirement = [&writer, &format_line_M104, &should_heating, this]() {
@@ -3710,6 +3863,18 @@ bool WipeTower::is_in_same_extruder(int filament_id_1, int filament_id_2)
return m_filament_map[filament_id_1] == m_filament_map[filament_id_2];
}
std::string WipeTower::format_nozzle_change_tag(bool start, int old_filament_id, int new_filament_id) const
{
const std::string &tag = start ? GCodeProcessor::Nozzle_Change_Start_Tag : GCodeProcessor::Nozzle_Change_End_Tag;
int old_nozzle = (old_filament_id >= 0 && old_filament_id < (int)m_filament_nozzle_map.size())
? m_filament_nozzle_map[old_filament_id] : -1;
int new_nozzle = (new_filament_id >= 0 && new_filament_id < (int)m_filament_nozzle_map.size())
? m_filament_nozzle_map[new_filament_id] : -1;
char buff[96];
snprintf(buff, sizeof(buff), ";%s OF%d NF%d ON%d NN%d\n", tag.c_str(), old_filament_id, new_filament_id, old_nozzle, new_nozzle);
return std::string(buff);
}
// Per-extruder printable-height clamp: is an extruder still allowed to print on this wipe-tower layer,
// or is it its final layer above the extruder's printable height?
// Orca: the arriving extruder id is resolved as m_filament_map[tool]-1 (1-based map, layer-static),
@@ -3908,6 +4073,19 @@ void WipeTower::plan_tower_new()
nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width;
depth += nozzle_change_depth;
}
if (nozzle_change_depth == 0
&& !m_filament_nozzle_map.empty()
&& toolchange.old_tool < (int)m_filament_nozzle_map.size() && toolchange.new_tool < (int)m_filament_nozzle_map.size()
&& m_filament_nozzle_map[toolchange.old_tool] != m_filament_nozzle_map[toolchange.new_tool]) {
double e_flow = nozzle_change_extrusion_flow(m_plan[idx].height);
double length = m_filaments_change_length[toolchange.old_tool] / e_flow;
int nozzle_change_line_count = length / (m_wipe_tower_width - 2*m_nozzle_change_perimeter_width) + 1;
if (has_tpu_filament())
nozzle_change_depth = m_tpu_fixed_spacing * nozzle_change_line_count * m_nozzle_change_perimeter_width;
else
nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width;
depth += nozzle_change_depth;
}
toolchange.nozzle_change_depth = nozzle_change_depth;
toolchange.required_depth = depth;
}

View File

@@ -58,6 +58,7 @@ public:
Vec2f origin_start_pos; // not rotated
std::vector<Vec2f> wipe_path;
bool is_extruder_change{true};
};
struct ToolChangeResult
@@ -309,6 +310,8 @@ public:
int get_number_of_toolchanges() const { return m_num_tool_changes; }
void set_filament_map(const std::vector<int> &filament_map) { m_filament_map = filament_map; }
// Vortek H2C: filament_id → physical nozzle_id for carousel rotation detection
void set_filament_nozzle_map(const std::vector<int> &nozzle_map) { m_filament_nozzle_map = nozzle_map; }
void set_has_tpu_filament(bool has_tpu) { m_has_tpu_filament = has_tpu; }
bool has_tpu_filament() const { return m_has_tpu_filament; }
@@ -356,6 +359,12 @@ public:
// Distance (in mm of filament) that a hotend is allowed to pre-cool before the
// tower is reached; drives the prime-tower heating-during-wipe model (multi-nozzle only).
float filament_cooling_before_tower = 0.f;
// .first = extruder change, .second = nozzle change (carousel)
std::pair<float,float> max_e_ramming_speed{0.f, 0.f};
std::pair<float,float> ramming_travel_time{0.f, 0.f};
std::pair<int,int> precool_target_temp{0, 0};
std::pair<std::vector<float>,std::vector<float>> precool_t;
std::pair<std::vector<float>,std::vector<float>> precool_t_first_layer;
};
@@ -395,6 +404,8 @@ public:
void add_depth_to_block(int filament_id, int filament_adhesiveness_category, float depth, bool is_nozzle_change = false);
int get_filament_category(int filament_id);
bool is_in_same_extruder(int filament_id_1, int filament_id_2);
// Vortek H2C: format BBS-compatible NOZZLE_CHANGE_START/END tag with OF/NF/ON/NN payload
std::string format_nozzle_change_tag(bool start, int old_filament_id, int new_filament_id) const;
void reset_block_status();
int get_wall_filament_for_all_layer();
// for generate new wipe tower
@@ -453,6 +464,7 @@ private:
size_t m_cur_layer_id;
NozzleChangeResult m_nozzle_change_result;
std::vector<int> m_filament_map;
std::vector<int> m_filament_nozzle_map; // Vortek H2C: filament_id → physical nozzle_id
bool m_has_tpu_filament{false};
bool m_is_multi_extruder{false};
bool m_use_gap_wall{false};
@@ -555,6 +567,7 @@ private:
bool is_tpu_filament(int filament_id) const;
bool is_petg_filament(int filament_id) const;
bool is_need_reverse_travel(int filament_id, bool extruder_change) const;
// BBS
box_coordinates align_perimeter(const box_coordinates& perimeter_box);

View File

@@ -1376,7 +1376,7 @@ static std::vector<std::string> s_Preset_filament_options {/*"filament_colour",
"filament_ramming_travel_time", "filament_ramming_travel_time_nc",
"filament_pre_cooling_temperature", "filament_pre_cooling_temperature_nc",
"filament_preheat_temperature_delta", "filament_retract_length_nc",
"filament_change_length_nc", "filament_prime_volume_nc",
"filament_change_length_nc", "filament_prime_volume", "filament_prime_volume_nc",
"long_retractions_when_ec", "retraction_distances_when_ec",
"plugin_config_overrides",
//ams chamber

View File

@@ -4018,6 +4018,8 @@ void Print::_make_wipe_tower()
m_wipe_tower_data.tool_ordering.empty() ? 0.f : m_wipe_tower_data.tool_ordering.back().print_z, m_wipe_tower_data.tool_ordering.all_extruders());
wipe_tower.set_has_tpu_filament(this->has_tpu_filament());
wipe_tower.set_filament_map(this->get_filament_maps());
// Vortek H2C: pass nozzle-level map for carousel rotation detection in tool_change_new()
wipe_tower.set_filament_nozzle_map(this->get_filament_nozzle_maps());
// Feed the has_filament_switcher device flag (develop-only dynamic key, read defensively from
// the full config — no shipping profile sets it) and the shared printable bed used by the PETG
// pre-extrusion offset clamp. Both are inert unless has_filament_switcher is set.
@@ -4053,14 +4055,32 @@ void Print::_make_wipe_tower()
multi_extruder_flush.emplace_back(wipe_volumes);
}
std::vector<int>filament_maps = get_filament_maps();
// Use NozzleStatusRecorder for per-carousel-slot tracking (BBS pattern).
// The original Orca code tracked per-extruder (2 slots), which collapsed all
// carousel filaments into one slot and caused massive redundant AMS flushing.
auto group_result = get_layered_nozzle_group_result();
MultiNozzleUtils::NozzleStatusRecorder nozzle_recorder;
// Fallback (group_result == null) per-physical-nozzle tracking, matching the original
// pre-port behavior: remembers the last filament loaded in each physical nozzle slot.
std::vector<unsigned int> nozzle_cur_filament_ids(nozzle_nums, (unsigned int) -1);
std::vector<int>filament_maps = get_filament_maps();
int layer_idx = -1;
std::vector<unsigned int> nozzle_cur_filament_ids(nozzle_nums, -1);
unsigned int current_filament_id = m_wipe_tower_data.tool_ordering.first_extruder();
size_t cur_nozzle_id = filament_maps[current_filament_id] - 1;
nozzle_cur_filament_ids[cur_nozzle_id] = current_filament_id;
// Initialize NozzleStatusRecorder with the first filament's carousel slot
if (group_result) {
auto nozzle = group_result->get_nozzle_for_filament(current_filament_id, layer_idx);
if (nozzle)
nozzle_recorder.set_nozzle_status(nozzle->group_id, current_filament_id, nozzle->extruder_id);
} else {
size_t cur_nozzle_id = filament_maps[current_filament_id] - 1;
nozzle_cur_filament_ids[cur_nozzle_id] = current_filament_id;
}
for (auto& layer_tools : m_wipe_tower_data.tool_ordering.layer_tools()) { // for all layers
++layer_idx;
if (!layer_tools.has_wipe_tower) continue;
bool first_layer = &layer_tools == &m_wipe_tower_data.tool_ordering.front();
wipe_tower.plan_toolchange((float)layer_tools.print_z, (float)layer_tools.wipe_tower_layer_height, current_filament_id, current_filament_id);
@@ -4071,30 +4091,76 @@ void Print::_make_wipe_tower()
if (filament_id == current_filament_id)
continue;
int nozzle_id = filament_maps[filament_id] - 1;
unsigned int pre_filament_id = nozzle_cur_filament_ids[nozzle_id];
float volume_to_purge = 0;
if (pre_filament_id != (unsigned int)(-1) && pre_filament_id != filament_id) {
volume_to_purge = multi_extruder_flush[nozzle_id][pre_filament_id][filament_id];
// Fast purge mode uses flush_multiplier_fast; Default is inert.
float flush_multiplier = (m_config.prime_volume_mode == PrimeVolumeMode::pvmFast) ? m_config.flush_multiplier_fast.get_at(nozzle_id)
: m_config.flush_multiplier.get_at(nozzle_id);
volume_to_purge *= flush_multiplier;
volume_to_purge = pre_filament_id == -1 ? 0 :
layer_tools.wiping_extrusions().mark_wiping_extrusions(*this, current_filament_id, filament_id, volume_to_purge);
// Per-carousel-slot purge tracking via NozzleStatusRecorder
if (group_result) {
auto nozzle_info = group_result->get_nozzle_for_filament(filament_id, layer_idx);
if (nozzle_info) {
int extruder_id = nozzle_info->extruder_id;
int nozzle_id = nozzle_info->group_id;
int prev_nozzle_filament = nozzle_recorder.get_filament_in_nozzle(nozzle_id);
if (!nozzle_recorder.is_nozzle_empty(nozzle_id) &&
static_cast<int>(filament_id) != prev_nozzle_filament) {
volume_to_purge = multi_extruder_flush[extruder_id][prev_nozzle_filament][filament_id];
// Fast purge mode uses flush_multiplier_fast; Default is inert.
float flush_multiplier = (m_config.prime_volume_mode == PrimeVolumeMode::pvmFast)
? m_config.flush_multiplier_fast.get_at(extruder_id)
: m_config.flush_multiplier.get_at(extruder_id);
volume_to_purge *= flush_multiplier;
volume_to_purge = layer_tools.wiping_extrusions().mark_wiping_extrusions(
*this, current_filament_id, filament_id, volume_to_purge);
}
nozzle_recorder.set_nozzle_status(nozzle_id, filament_id, extruder_id);
}
} else {
// Fallback: original Orca per-physical-nozzle path (non-carousel printers).
// Flush source is the last filament that occupied THIS nozzle, guarded so the
// first use of a nozzle incurs no flush.
int nozzle_id = filament_maps[filament_id] - 1;
unsigned int pre_filament_id = nozzle_cur_filament_ids[nozzle_id];
if (pre_filament_id != (unsigned int) -1 && pre_filament_id != filament_id) {
volume_to_purge = multi_extruder_flush[nozzle_id][pre_filament_id][filament_id];
float flush_multiplier = (m_config.prime_volume_mode == PrimeVolumeMode::pvmFast)
? m_config.flush_multiplier_fast.get_at(nozzle_id)
: m_config.flush_multiplier.get_at(nozzle_id);
volume_to_purge *= flush_multiplier;
volume_to_purge = layer_tools.wiping_extrusions().mark_wiping_extrusions(
*this, current_filament_id, filament_id, volume_to_purge);
}
nozzle_cur_filament_ids[nozzle_id] = filament_id;
}
//During the filament change, the extruder will extrude an extra length of grab_length for the corresponding detection, so the purge can reduce this length.
float grab_purge_volume = m_config.grab_length.get_at(nozzle_id) * 2.4; //(diameter/2)^2*PI=2.4
int grab_extruder_id = filament_maps[filament_id] - 1;
float grab_purge_volume = m_config.grab_length.get_at(grab_extruder_id) * 2.4; //(diameter/2)^2*PI=2.4
volume_to_purge = std::max(0.f, volume_to_purge - grab_purge_volume);
// Saving mode reduces the prime volume to 15 mm3; Default is inert.
float prime_volume = (m_config.prime_volume_mode == PrimeVolumeMode::pvmSaving) ? 15.f : (float) m_config.prime_volume;
// Select prime volume per-filament: nozzle change (carousel rotation) uses
// filament_prime_volume_nc, filament change (same nozzle slot) uses filament_prime_volume.
float wipe_volume_ec = filament_id < m_config.filament_prime_volume.values.size()
? m_config.filament_prime_volume.values[filament_id]
: (float) m_config.prime_volume;
float wipe_volume_nc = filament_id < m_config.filament_prime_volume_nc.values.size()
? m_config.filament_prime_volume_nc.values[filament_id]
: (float) m_config.prime_volume;
float prime_volume = wipe_volume_ec;
if (group_result) {
bool is_nozzle_change = group_result->are_filaments_same_extruder(current_filament_id, filament_id, layer_idx) &&
!group_result->are_filaments_same_nozzle(current_filament_id, filament_id, layer_idx);
if (is_nozzle_change) {
prime_volume = wipe_volume_nc;
}
}
if (m_config.prime_volume_mode == PrimeVolumeMode::pvmSaving) {
prime_volume = 15.f;
}
wipe_tower.plan_toolchange((float)layer_tools.print_z, (float)layer_tools.wipe_tower_layer_height, current_filament_id, filament_id,
prime_volume, volume_to_purge);
current_filament_id = filament_id;
nozzle_cur_filament_ids[nozzle_id] = filament_id;
}
layer_tools.wiping_extrusions().ensure_perimeters_infills_order(*this);

View File

@@ -7384,7 +7384,7 @@ void PrintConfigDef::init_fff_params()
def->tooltip = L("The flush multiplier used in fast purge mode.");
def->set_default_value(new ConfigOptionFloats{1.2});
// BBS
// Orca: used by the generic (Type2) wipe tower; also the fallback for filament_prime_volume on Type1.
def = this->add("prime_volume", coFloat);
def->label = L("Prime volume");
def->tooltip = L("This is the volume of material to prime the extruder with on the tower.");
@@ -8030,6 +8030,16 @@ void PrintConfigDef::init_fff_params()
def->mode = comDevelop;
def->set_default_value(new ConfigOptionBool(false));
// Used by the Type1 wipe tower: filament_prime_volume on a filament change,
// filament_prime_volume_nc on a hotend/nozzle change. Type2 uses prime_volume instead.
def = this->add("filament_prime_volume", coFloats);
def->label = L("Filament change");
def->tooltip = L("The volume of material required to prime the extruder on the tower, excluding a hotend change.");
def->sidetext = L("mm³");
def->min = 1.0;
def->mode = comSimple;
def->set_default_value(new ConfigOptionFloats{45.});
def = this->add("filament_prime_volume_nc", coFloats);
def->label = L("Hotend change");
def->tooltip = L("The volume of material required to prime the extruder for a hotend change on the tower.");
@@ -9050,7 +9060,7 @@ void PrintConfigDef::handle_legacy(t_config_option_key &opt_key, std::string &va
"retraction_distance_when_cut",
"internal_bridge_support_thickness", "top_area_threshold", "reduce_wall_solid_infill","filament_load_time","filament_unload_time",
"smooth_coefficient", "overhang_totally_speed", "silent_mode",
"overhang_speed_classic", "filament_prime_volume",
"overhang_speed_classic",
"anisotropic_surfaces", // superseded by top_surface_fill_order / bottom_surface_fill_order
};

View File

@@ -1841,6 +1841,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
// BBS: wipe tower is only used for priming
((ConfigOptionFloat, prime_volume))
// Nozzle-change (nc) prime volume + pre-heat delta
((ConfigOptionFloats, filament_prime_volume))
((ConfigOptionFloats, filament_prime_volume_nc))
((ConfigOptionFloatsNullable, filament_preheat_temperature_delta))
((ConfigOptionFloats, flush_multiplier))

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;

View File

@@ -48,13 +48,14 @@ std::string plugin_defaults_user_script()
return WebViewHostDialog::document_start_injector(css, "orca-plugin-defaults", "beforeend");
}
// Injected into every page at document start (before the plugin's own scripts).
// Defines window.orca as the only host surface the page may use. It references
// window.wx lazily (at call time) so it never races the backend's deferred
// registration of the "wx" message handler. Guarded against double-injection so
// it is harmless if also prepended.
// Injected into the top-level page at document start (before the plugin's own
// scripts). Defines window.orca as the only host surface the page may use. It
// references window.wx lazily (at call time) so it never races the backend's
// deferred registration of the "wx" message handler. Guarded against
// double-injection so it is harmless if also prepended.
constexpr char ORCA_BRIDGE_JS[] = R"JS(
(function () {
if (window.top !== window.self) return;
if (window.orca) return;
var handlers = [];
function send(kind, data) {

View File

@@ -300,7 +300,12 @@ PluginAvailableActions evaluate_action_policy(const PluginDialogItem& item)
add_action("open_folder", "Show in folder", has_local);
add_action("reinstall_plugin", "Reinstall");
if (is_cloud) {
add_action("reinstall_plugin", "Reinstall");
} else {
add_action("reload_plugin", "Reload");
add_action("clear_cache_reload_plugin", "Delete cache and reload");
}
return available_actions;
}
@@ -739,11 +744,13 @@ void PluginsDialog::handle_plugin_menu_action(const std::string& plugin_key, con
unsubscribe_cloud_plugin(row_data);
} else if (action == "delete_mine_plugin") {
delete_mine_local_and_cloud_plugin(plugin_key);
} else if (action == "reload_plugin") {
reload_local_plugin(plugin_key, /*clear_cache=*/false);
} else if (action == "clear_cache_reload_plugin") {
reload_local_plugin(plugin_key, /*clear_cache=*/true);
} else if (action == "reinstall_plugin") {
if (row_data.is_cloud_plugin())
reinstall_cloud_plugin(row_data);
else
reinstall_local_plugin(plugin_key);
}
}
@@ -1123,7 +1130,7 @@ void PluginsDialog::unsubscribe_cloud_plugin(const PluginDescriptor& plugin)
_L("Unsubscribing plugin"), _L("Deleting local files and unsubscribing plugin..."));
}
void PluginsDialog::reinstall_local_plugin(const std::string& plugin_key)
void PluginsDialog::reload_local_plugin(const std::string& plugin_key, bool clear_cache)
{
if (plugin_key.empty())
return;
@@ -1132,11 +1139,34 @@ void PluginsDialog::reinstall_local_plugin(const std::string& plugin_key)
std::pair<bool, std::string> reload_result{false, ""};
try {
reload_result = run_with_dialog_wait(
[plugin_key, was_loaded]() -> std::pair<bool, std::string> {
[plugin_key, was_loaded, clear_cache]() -> std::pair<bool, std::string> {
PluginManager& manager = PluginManager::instance();
boost::filesystem::path cache_dir;
if (clear_cache) {
PluginDescriptor descriptor;
if (!manager.try_get_plugin_descriptor(plugin_key, descriptor))
return {false, "Plugin not found."};
boost::filesystem::path resolved_root;
std::string resolve_error;
if (!resolve_allowed_plugin_root(descriptor, {get_orca_plugins_dir()},
"Refusing to clear a plugin cache outside the local plugin directory.",
resolved_root, resolve_error))
return {false, resolve_error};
cache_dir = resolved_root / "__whl_extracted__";
}
if (!manager.unload_plugin(plugin_key))
return {false, "Failed to unload plugin."};
if (clear_cache) {
boost::system::error_code ec;
boost::filesystem::remove_all(cache_dir, ec);
if (ec)
return {false, "Failed to clear plugin cache: " + ec.message()};
}
manager.load_plugin(plugin_key, false);
std::string error;
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || !manager.is_plugin_loaded(plugin_key))

View File

@@ -96,7 +96,7 @@ private:
void open_plugin_folder(const Slic3r::PluginDescriptor& plugin);
void delete_local_plugin(const Slic3r::PluginDescriptor& plugin);
void unsubscribe_cloud_plugin(const Slic3r::PluginDescriptor& plugin);
void reinstall_local_plugin(const std::string& plugin_key);
void reload_local_plugin(const std::string& plugin_key, bool clear_cache);
void reinstall_cloud_plugin(const Slic3r::PluginDescriptor& plugin);
void delete_mine_local_and_cloud_plugin(const std::string& plugin_key);

View File

@@ -96,6 +96,9 @@ std::string WebViewHostDialog::document_start_injector(const std::string& markup
const std::string literal = nlohmann::json(markup).dump();
std::string s;
s += "(function(){";
// wxWebView's AddUserScript runs in child frames too (including cross-origin
// frames on WebView2). Host theme state belongs only to the top-level page.
s += "if(window.top!==window.self)return;";
s += prelude;
s += "var css=" + literal + ";";
s += "function inject(){";

View File

@@ -54,7 +54,7 @@ struct PluginDescriptor
std::string description; // Plugin description
std::string author; // Plugin author from manifest, if available
std::string version; // Selected plugin version
std::string latest_version; // Latest available cloud version fallback when changelog is unavailable.
std::string latest_version; // Authoritative latest available cloud version.
std::string installed_version; // Locally installed package version. Preserved across cloud merges, which overwrite `version` with the latest cloud version. Empty when not installed.
std::vector<std::string> display_types; // Display-only "compatibility" labels (cloud: raw service labels; local: from real capabilities). Never used for dispatch.
std::string plugin_root; // Installed plugin directory, even when entry_path is invalid or ambiguous.
@@ -113,10 +113,6 @@ struct PluginDescriptor
bool is_unauthorized() const { return get_update_status() == PluginUpdateStatus::Unauthorized; }
std::string latest_available_version() const
{
for (const PluginChangelog& entry : changelog) {
if (!entry.version.empty())
return entry.version;
}
if (!latest_version.empty())
return latest_version;
return version;

View File

@@ -0,0 +1,88 @@
# Compare Analyzer — G-code Slicing Comparison Tools
Tools for deep comparison and analysis of `.3mf` slicing project files, designed for
verifying multi-nozzle (H2C carousel) and multi-extruder slicing correctness.
## Tools
### `compare_slices.py` — Slice Comparison Analyzer
Deep comparison of two `.3mf` files (OrcaSlicer, BambuStudio, or any compatible slicer).
Generates a comprehensive Markdown report covering:
- **Filament usage** — per-filament weight/length with color mapping
- **Nozzle/extruder mapping** — Vortek carousel slot assignments
- **Tool change sequences** — T-code ordering and count
- **Prime tower analysis** — tower entries, G-code line count
- **Temperature timeline** — pre-heat lead times, target temperatures per tool change
- **Retract parameters** — M620.11 analysis during nozzle switches
- **Filament change G-code blocks** — line-by-line diff of change_filament_gcode
- **Control command diff** — timeline of M/G-code differences
- **Critical discrepancy detection** — automatic flagging of weight/time anomalies
#### Usage
```bash
# Compare two slice files
python3 compare_slices.py file1.3mf file2.3mf
# With custom labels
python3 compare_slices.py file1.3mf file2.3mf --labels "Upstream" "Fixed"
```
#### Output
Markdown report saved to `mp_reports/compare_report_YYYYMMDD_HHMMSS.md`
#### Example: Detecting H2C purge regression
```
⚠️ CRITICAL DISCREPANCY: Huge difference in part weight:
OrcaSlicer 60.90 g vs BambuStudio 17.47 g (difference 43.43 g or 71.3%).
The reason is incorrect nozzle mapping, causing huge AMS flushing.
```
---
### `show_temp_plot.py` — Temperature Timeline Plotter
Generates interactive HTML temperature plots for analyzing thermal profiles during
multi-nozzle prints. Visualizes heater temperature commands (M104/M109) per tool change,
showing pre-heat timing and temperature convergence.
#### Architecture
- H2C dual-extruder layout with Vortek carousel nozzles
- Physical heaters mapped dynamically:
- Heater 0: Extruder 2 (right nozzle slot, T0/T2/T3/T4)
- Heater 1: Extruder 1 (left nozzle slot, T1)
- Active heater mapping derived from G-code temperature signals
#### Usage
```bash
# Single file analysis
python3 show_temp_plot.py file.3mf
# Side-by-side comparison of two files
python3 show_temp_plot.py file1.3mf file2.3mf
```
#### Output
Interactive HTML report saved to Desktop as `temp_plot_v3.html`
---
## Requirements
- **Python 3.8+**
- **No external dependencies** — uses only Python standard library
(`json`, `zipfile`, `xml.etree.ElementTree`, `difflib`, `webbrowser`)
## Use Cases
1. **Regression testing** — compare slices before/after code changes to verify
no unintended differences in purge volumes, tool ordering, or temperature timing
2. **BBS compatibility verification** — compare OrcaSlicer output against BambuStudio
reference slices to ensure behavioral parity
3. **H2C carousel validation** — verify per-slot nozzle tracking produces correct
purge volumes (not collapsed per-extruder)
4. **Temperature protocol analysis** — verify pre-heat lead times and cooling
temperatures during nozzle changes match expected profiles

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,29 @@ print('ok')
} // namespace
TEST_CASE("plugin latest version uses the authoritative catalog field", "[PluginDescriptor]")
{
PluginDescriptor descriptor;
descriptor.version = "1.3.0";
descriptor.latest_version = "1.3.0";
PluginChangelog changelog;
changelog.version = "1.2.0";
descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.3.0");
}
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
{
PluginDescriptor descriptor;
descriptor.version = "1.1.0";
PluginChangelog changelog;
changelog.version = "1.0.0";
descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.1.0");
}
// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via