The tower travel took retract()'s default vertical Z hop instead of the
configured one, so the nozzle rose in place over the part and oozed
rather than departing with the travel. Pass the filament's z_hop_types
through, mapping Auto to a spiral lift as append_tcr does.
* Sync WipeTower from BambuStudio(through ca1881761)
* Fix post-slice self-invalidation on custom multi-extruder printers
* Complete the rib wipe tower port in WipeTower2
The rib tower is now always square (prime_tower_width is ignored, as the
GUI already implies), carries the rib origin offset like the BBL tower so
the rib tips sit inside the configured position, clamps the rib length to
the tower diagonal, and extends the ribs for short towers.
* Use the squared rib tower size in arrange estimates
estimate_wipe_tower_polygon reserved the arrange footprint and clamped the
tower X position with the raw prime_tower_width, under-reserving space
whenever the rib wall squares the tower to a different width.
* Print the WipeTower2 shell with a non-support, non-soluble filament
Like the BBL tower: the layer's sparse infill, wall, and brim go to the
first toolchange to a non-support/non-soluble filament, or are printed
with the incoming filament before any toolchange. The minimal-purge
clamp now also covers toolchanges that get no finish-layer saving.
Output is unchanged when no support/soluble filament is used.
* Port the skip-points gap wall to WipeTower2
prime_tower_skip_points was stubbed for Type2 towers: the wall call
hard-coded skip_points=false, the gap cutter received an empty vector,
and append_tcr2 never routed the entry travel. Now the toolchange entry
positions are precomputed from the finalized plan, the wall is cut open
at each entry, and the entry travel approaches around the tower bounding
box through the opening when it starts outside the tower. The geometry
helpers are re-synced with the BBL versions (add_extra_point guards,
per-point side selection). The cone wall keeps its separate path, where
the option stays inert.
Behavior change: non-BBL towers now honor the (default-on) checkbox with
gap walls and routed entries; with the option off the output is
unchanged, and the BBL tower path is untouched.
* Route the in-place toolchange tower entry through the skip-point gap
On multi-tool printers without ramming the tool changes away from the
tower and the entry travel is the tcr's own positioning move, which went
straight across the printed wall. Append the avoid-perimeter path to the
change-filament gcode instead, so the head approaches around the tower
and enters through the wall opening (append_tcr parity).
* Iron the purge start out through the skip-point gap in WipeTower2
Port the BBL tower's entry line ironing: extrude the first 3 mm of the
purge, retract, drag the nozzle 1.5x back out through the wall gap at
F600, creep back at F240 and unretract, so the toolchange start blob
ends up in the gap instead of on the wall. Fires only when the purge
starts at the left-edge entry heading right (in-place toolchangers);
SEMM ram/cooling wipes start mid-box and the priming line has no wall,
so both keep their previous output.
* Reserve WipeTower2 toolchange depth to match the printed purge
The planner reserved ramming rows gated only on enable_filament_ramming and
sized them with the SEMM 0.25s time step, while toolchange_Unload rams on
(semm && enable_filament_ramming) || filament_multitool_ramming with the
multitool time step. Disabling multitool ramming therefore left ~3 unprinted
rows per toolchange as blank bands in the tower. Without ramming the first
wipe line also needs reserved depth of its own (it no longer rides the last
ramming row), plus the y_step/2 offset the wipe start inherits from the
ramming start position - otherwise the tightened boxes truncate the ordered
purge at the box edge.
* Tile WipeTower2 purge rows contiguously across toolchange blocks
Without ramming, each purge block reserved one wipe pitch more than its
rows occupy (ceil+1 rounding plus the ram-geometry start offset), and the
wipe began a full pitch inside the block, leaving a blank band of exactly
two pitches between adjacent blocks. Plan the block as whole wipe rows,
start the first row so the row lattice continues across the block
boundary, and fill the reserved box instead of stopping at the ordered
volume, mirroring how the BBL WipeTower keeps planned depth identical to
printed rows. Ram-printing toolchanges (SEMM with ramming enabled,
multitool ramming) are unchanged.
* Scrub the WipeTower2 toolchange entry with the BBL flat-ironing spiral
The entry scrub now matches the BBL tower's toolchange_wipe_new sequence:
after the ironing drag the retracted nozzle runs a dry expanding-square
spiral centred on the wall-gap entry point before resuming the purge row.
The spiral runs whenever the gap wall is on (disable per filament via
filament_tower_ironing_area = 0); WipeTower2 no longer reads
prime_tower_flat_ironing.
* Restart the WipeTower2 wipe at the box boundary after multitool ramming
With the gap wall on a multi-tool printer, quantize the ram band up to its
whole reserved rows (as the BBL tower does for the old-tool purge) and start
CP TOOLCHANGE WIPE at the left-edge boundary on a fresh row below it instead
of continuing from wherever the ram serpentine ended. The entry scrub then
runs at the wall gap on ram toolchanges too, and the wipe box is whole rows,
so it is filled completely like the no-ram case. SEMM and skip-points-off
behavior is unchanged.
* Move the WipeTower2 wall gap to the wipe start row for ram toolchanges
* code cleanup
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix typo
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
On multi-tool printers without ramming the tool changes away from the
tower and the entry travel is the tcr's own positioning move, which went
straight across the printed wall. Append the avoid-perimeter path to the
change-filament gcode instead, so the head approaches around the tower
and enters through the wall opening (append_tcr parity).
prime_tower_skip_points was stubbed for Type2 towers: the wall call
hard-coded skip_points=false, the gap cutter received an empty vector,
and append_tcr2 never routed the entry travel. Now the toolchange entry
positions are precomputed from the finalized plan, the wall is cut open
at each entry, and the entry travel approaches around the tower bounding
box through the opening when it starts outside the tower. The geometry
helpers are re-synced with the BBL versions (add_extra_point guards,
per-point side selection). The cone wall keeps its separate path, where
the option stays inert.
Behavior change: non-BBL towers now honor the (default-on) checkbox with
gap walls and routed entries; with the option off the output is
unchanged, and the BBL tower path is untouched.
The rib tower is now always square (prime_tower_width is ignored, as the
GUI already implies), carries the rib origin offset like the BBL tower so
the rib tips sit inside the configured position, clamps the rib length to
the tower diagonal, and extends the ribs for short towers.
* fix: constrain PA_Line calibration bounding box to model geometry
- PA_Line bounding box now uses actual model convex hull for X constraint instead of full printable area
- Skip EXCLUDE_OBJECT_DEFINE for PA_Line mode (single object, no exclusion needed)
- Add tool config files to .gitignore
* modified: .gitignore
* fix: add gcode type annotations and fix box height in PA line calibration
- Label calibration segments with appropriate TYPE comments (Outer wall, Bottom surface, Top surface, Custom) for proper gcode processing
- Fix bounding box height calculation to account for z_offset
- Add LAYER_CHANGE and HEIGHT comments for multi-layer numbering display
* fix: lock PA_Line bounding box X to bed centre instead of model hull
* Added extra TYPE to ensure text/numbers/glyphs are labelled correctly
` gcode << ";TYPE:Outer wall\n"; `
* Remove hardcoded Perl path in OpenSSL.cmake
Remove hardcoded Perl path for Windows configuration.
* Add cross compilation support for Windows in OpenSSL.cmake
* Remove unnecessary blank line in OpenSSL.cmake
* Set perl config command back to variable
Accidentally uploaded version with hardcoded path for my local environment
* whitespace adjustment in previous
* removed personal .gitignore config
Remove specific files and directories from .gitignore.
* Fix box height parameter in DrawBoxOptArgs
Update DrawBoxOptArgs to use `m_height_layer` instead of `m_height_layer*2+z_offset`. This isn't a Z coordinate, it's a layer height
* Replace hardcoded extrusion type with call to `GCodeProcessor::reserved_tag` Etags
* Get pa_line bounding box from actual calibration variables
Updated calibration line bounding box calculation to use actual geometry for X bounds, using a fake call to generate the calibration pattern.
This will accurately get the size of the calibration pattern, massively reducing wasted time from bed mesh probing.
* Implement print_extents method in CalibPressureAdvanceLine
Add print_extents method to calculate bounding box extents based on bed dimensions.
* Declare print_extents method in CalibPressureAdvanceLine
Added print_extents method to return X-bounds of the pattern.
* Fixed whitespace issues
* Adjust print_extents to account for delta printers
Check if the printer is delta layout and adjust bed dimensions if so.
Used code from `CalibPressureAdvanceLine::generate_test`
* Added semicolons to reserved tags
Didn't realise etags wouldn't add semicolon - added these
* only include number list in bounding box if number list is used
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* avoid bounding box overflowing bed
* Tabs to spaces
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
A dual-nozzle H2C print with support filament hangs at its first nozzle
switch. The emitted file shows the change-filament block's M620 O ordinal
jumping from O1 straight to O230, plus a duplicate "M1020 S<n>" toolchange
command right after every change block. Two causes, fixed together because
they interlock (the ordinal check keys off the same toolchange detection
that suppresses the duplicate):
- append_tcr incremented m_toolchange_count once per prime-tower visit
(roughly once per layer), while the change-filament template only emits
its M620 O{toolchange_count + 1} line on real filament changes. With 229
change-less sparse tower layers below the first support layer, the first
real change reported ordinal 230. The counter now advances only when the
expanded change block really contains a toolchange command, and the
placeholder exposes the upcoming change's ordinal (count + 1). The
set_extruder path already counted per real change and is unchanged.
- toolchange_prefix() returned "M1020 S" for BBL printers, so the
custom_gcode_changes_tool() dedup could never match the stock profiles'
line-leading "T[next_filament_id] ..." commands and the writer's own
toolchange was appended after every change block on dual-extruder
machines. The prefix is now the plain "T" (the manual-filament-change tag
branch stays first), and the M1020 form moved into GCodeWriter::toolchange()
as an explicit branch that also carries the nozzle:
"M1020 S<filament> H<nozzle>". The nozzle parameter is signed on purpose:
the null-safe nozzle lookup legitimately yields -1, matching the stock
templates' own H-1 convention.
The prefix change also lets the CoolingBuffer recognize the change blocks'
T commands as tool boundaries on BBL printers (its per-filament attribution
previously keyed off the duplicate M1020, or nothing at all on
single-extruder models); its existing out-of-range guard ignores
T1000-class machine commands.
Verification: full suites green (libslic3r 48998 assertions / 169 cases;
fff_print 692 / 65 including three new scenarios - writer emission per
printer kind, dedup + ordinal progression on sequential prints, and a
prime-tower regression scenario verified to fail against the old per-visit
counting). Byte gate: 18 of 20 fixtures bit-identical; the sequential repro
differs by exactly its 3 removed duplicate M1020 lines, deterministic
across two runs. Reslicing the field project that exposed the hang yields
M620 O1 followed by a gapless O2..O59 and zero duplicate M1020 lines.
Co-authored-by: songwei.li <songwei.li@bambulab.com>
The time estimator's speed/acceleration limits were indexed by time
mode only, reading slot 0 of the per-(extruder x volume-type) arrays
the multi-extruder profiles already carry (H2C 0.4: 8 entries, H2D
0.4: 10). Every move was therefore modelled with the first machine
slot's limits regardless of which nozzle variant was printing -
estimation fidelity only, since emitted feedrates/accelerations are
decided on the slicing side.
Now the estimator resolves the machine slot of the nozzle currently
mounted in the active extruder: the nozzle grouping context is handed
to the processor BEFORE the streaming replay (new member + setter -
deliberately separate from the post-stream result-field handover that
gates the richer change-time model, whose timing is unchanged), the
occupancy recorder is populated on every filament change (bookkeeping
decoupled from the gated time model; recorder writes have no time
effect), and get_machine_config_idx maps (volume type x extruder type
x extruder) to the slot via the printer's variant layout, newly
carried on the processor result. The feedrate/acceleration getters
gain a slot parameter indexing [slot*2 + mode]; jerk and the
print/travel/retract accelerations stay mode-only. Reloaded sliced
projects re-estimate with the result's saved grouping context;
imported bare g-code degrades to slot 0 - the historical read.
M201/M203 write the parsed value into EVERY slot's mode entry (a
firmware envelope change is global), which keeps per-slot reads in
lockstep with the mode-only reads they replace: the fleet emits
envelope lines before any motion, so estimates - hence the estimated
time header, M73 lines, and every other byte - are unchanged (20/20
pinned-slice byte gate bit-identical, incl. the sequential repro
sliced twice). Fidelity improves where envelope emission is off or a
migrating per-layer plan moves filaments across variants.
Tests: a stub-driven processor case proving the slot follows the
active nozzle through the exact production path (T..H.. commands,
fallback recorder bookkeeping, 4x time ratio on the slow variant),
that emitted M201/M203 reach every slot, and that a missing context
degrades to slot 0. Suites green (libslic3r 48998/169, fff_print
667/62).
When a per-layer nozzle grouping migrates a filament across nozzle
variants, the write-back turns two groups of config arrays from
filament-indexed into column-indexed: the per-variant filament options
(one column per variant a filament uses) and the merged extruder
retract overrides (resized to the column count by apply_override).
Export-path readers that still indexed them with the raw filament id
read a neighbor's column for every filament ordered after a migrating
one: toolchange/standby temperatures (M104/M109), retraction lengths
and feedrates, wipe distance, z-hop types, air-filtration keys, and -
through the Extruder's cached flow term - the extrusion E of every
move.
Now every such read resolves its column through the existing
layer-aware resolver (get_filament_config_index ->
Print::get_filament_config_indx), which returns the raw filament id
whenever no per-layer grouping result is published, so static prints
are byte-inert by construction. The Extruder itself has no layer
knowledge, so it gains an injected config column (set_config_index,
default = filament id) that the generator refreshes at the only two
resolution-changing events - layer change and writer toolchange - and
that re-syncs the cached e_per_mm3 flow term. Old-filament reads
resolve at the current layer, which is safe because the per-layer maps
are gap-filled carry-forward. Whole-array placeholder copies
(toolchange temperature overrides) are rebuilt in filament order,
mirroring the existing per-variant placeholder remap. The resolvers
move to the public section so non-friend helpers (ooze prevention) can
resolve too.
Documented, deliberately unchanged: the wipe tower's per-filament
parameter rows (no layer dimension; tower x per-layer grouping is a
follow-up), travel_slope's physical-extruder read, estimator pre-heat
bookkeeping temps, and index-0 header diagnostics.
Verification: new Extruder column-injection scenario (defaults, column
follow + flow-cache rescale, filament-indexed reads unaffected, reset
semantics) and a migrating write-back case proving the column shift for
filaments ordered after a migrator and the resolver tracking it (11 +
14 assertions); suites green (libslic3r 48998/169, fff_print 655/61);
20/20 pinned-slice byte gate bit-identical (incl. sequential repro x2
deterministic).
Sequential (by-object) prints were incoherent with the per-layer filament
selector (enable_filament_dynamic_map): the by-object branch published a
static grouping while each per-object ToolOrdering independently ran the
dynamic planner from an empty nozzle status and wrote its own map to the
config (one write per object, last object wins). The exported toolchange
sequences then disagreed with the published result that drives the
per-layer maps, placeholders, and selector emission.
Now the by-object branch, when the selector is enabled, plans each unique
object once — threading the physical nozzle occupancy and the previous
object's last filament into the next plan — stitches the per-object
per-layer nozzle maps into one print-wide result (gap-filled by the new
normalize_nozzle_map_per_layer so any layer index resolves a filament's
nozzle consistently), publishes it, and writes the derived extruder map
back once. The plans are cached on the Print and g-code export consumes
the cache: the ToolOrdering seed changes the plan input (dontcare
assignment, first-layer reorder), so a fresh export-time construction
could re-plan differently from the published stitch. The per-object
dynamic write-back is gated off for sequential prints.
Every change is gated behind is_dynamic_group_reorder(); no profile sets
the flag, so the static fleet's instruction stream is unchanged (20/20
pinned-slice byte gate identical, incl. the by-object repro sliced twice).
Tests: normalize unit coverage (carry-forward, back-fill, ragged input),
stitched-blocks selector detection, and an end-to-end by-object selector
slice (apply -> process -> export) asserting the published stitched
result, one cached plan per object, the config write-back, and a clean
export. Suites green (libslic3r 48958/165, fff_print 633/60).
The Print-level LayeredNozzleGroupResult had a single producer, the
by-layer branch of ToolOrdering, which is gated to non-sequential prints.
The by-object branch in Print::process computed a grouping only in auto
map modes and never stored it, so a sequential slice exported with a null
group result: the per-nozzle placeholder tables came up empty and any
start g-code indexing nozzle_diameter_at_nozzle_id[] aborted with
"Indexing an empty vector variable". A prior by-layer slice masked the
bug by leaving its (never cleared) result on the Print.
Now the by-object branch runs get_recommended_filament_maps in every
static map mode (in manual modes the result mirrors the user's
assignment, deviations throw as in by-layer) and publishes it
print-wide. The config write-back stays gated to auto modes: in manual
modes it would only re-store the pre-slice values.
Regression test: a two-object by-object print must publish a non-null
group result and resolve nozzle_diameter_at_nozzle_id[] in start g-code
(both fail without the fix). Suites green (libslic3r 48929/162,
fff_print 633/60); 18-fixture byte gate identical; the by-object repro
project goes from the export error to valid g-code, determinism x2.
- the g-code writer tracks the current layer id and resolves
FILAMENT_CONFIG/NOZZLE_CONFIG (plus every non-macro variant lookup,
toolchange placeholder scalars, and the change-filament flush
overrides) through Print's per-filament, per-layer config-index
resolvers instead of the filament->extruder collapse
- update_layer_related_config refreshes the per-layer
extruder/volume/nozzle maps in the writer config;
update_placeholder_parser_with_variant_params remaps the
filament-variant arrays into filament-id space for custom g-code
(Orca's flush placeholder computation moves inside it)
- the engine's concrete per-filament volume assignment now merges into
the config write-back (the temporary hold from the producer commit
is lifted together with these consumers), and the background process
reads the computed volume map back to the plate
- append_full_config dumps the resolved filament_map_2 slots
- update_used_filament_values gains a bounds guard
- tests: per-filament Hybrid slot resolution + null-result fallback
Result: on a Hybrid extruder, each filament's features slice with its
assigned sub-nozzle's variant values (speeds, volumetric limits,
retraction). Verified on a 4-filament H2C Hybrid project: outer walls
split into three feedrate populations (30/50/200 mm/s), toolpath
geometry byte-identical, deterministic across repeated slices. All 18
non-Hybrid reference fixtures stay byte-identical except the
filament_map_2 header value now showing the real slot. Auto grouping
ties (multiple zero-flush perfect matchings) may pick a different
filament-to-nozzle isolation than other slicers; verified co-optimal.
GCode::extrude_support declared its per-path speed helper as a function-local
static lambda that captures `this` by reference. The closure is built once, on
the first extrude_support call, and reused for the rest of the process, so a
second G-code export in the same process runs the helper against a `this` from
the first export's stack frame, which has already returned.
The stale `this` flows through NOZZLE_CONFIG(...) -> cur_extruder_index() ->
GCodeWriter::filament(), reading a garbage current-extruder id and indexing
with it. It is silent whenever the reused stack still holds a usable pointer,
and an order-dependent SIGSEGV otherwise; AddressSanitizer reports it as a
stack-use-after-return in GCodeWriter::filament(). It is the only static
capturing lambda in libslic3r.
Drop static so the closure is rebuilt each call against the live frame. Add an
fff_print regression test that slices a support object twice in one process; it
fails without the fix (stack-use-after-return under ASan) and passes with it.
WipeTowerIntegration::append_tcr processed filament_end_gcode with only
layer_num in its placeholder config, so a filament_end_gcode referencing
{layer_z} could not be evaluated and slicing aborted. This affects any
multi-filament print that routes tool changes through the prime/wipe tower
(for example a support filament on a Bambu printer); the same macro works
in machine_end_gcode and on the non-wipe-tower set_extruder path, which
both define layer_z.
Set layer_z to tcr.print_z, the value this function already provides to its
change_filament_gcode and tcr_rotated_gcode placeholders.
Fixes#10119
Refactor skirt and brim ownership and emission flow
Refactor skirt and brim generation around a common object/group
ownership model.
Skirts and brims are now emitted as a coordinated preamble
(skirt -> brim -> object) instead of being generated and emitted
through multiple independent code paths.
Changes:
- Fix repeated skirt emission caused by the previous skirt state
tracking logic.
- Restore local skirt/brim ordering for per-object skirts in
By Layer mode.
- Emit brims together with their owning object or object group.
- Handle combined brims independently from skirt grouping.
- Handle draft shields through the same ownership model as skirts.
- Fix draft shield generation when skirt height is zero.
- Generate draft shields after brim geometry is known, preventing
draft shields from overlapping brims.
- Reject unsafe grouped per-object skirt configurations in
By Object mode.
- Remove legacy skirt emission paths and state-management
workarounds.
Support brim generation remains unchanged.
Co-authored-by: SoftFever <softfeverever@gmail.com>
* feat: add support for 3MF file format in printer configurations and export options
* fix file extension
* enable 3mf for X Max 4
* disable use_3mf for X Plus 4
* Fixed an issue where `label_object_enabled` was not properly propagated to 3mf
* enable exclude object for Max 4
* remove hardcoded use 3mf for flashforge, move them to the new printer profiles config
* Fix null-deref and arranger bugs that gate headless slicing tests
export_gcode dereferenced a null result out-param, enum serialization
dereferenced a null keys_map, and get_arrange_polys left bed_idx unseeded so
the arranger dropped items. All only affect the headless test/CLI path.
* Fix the headless test harness and add G-code test helpers
Use the real arranger, fix temp-file handling with an RAII guard, and add
layers_with_role / max_z for inspecting sliced G-code.
* Re-enable the Model construction test
* Re-enable SupportMaterial tests and add an enforced-support test
* Re-enable and extend PrintObject layer-height and perimeter tests
* Re-enable Print skirt, brim, and solid-surface tests
* Re-enable and extend PrintGCode tests
Un-hide the basic scenario (dead-key fixes, reframes, trimmed trivia) and add
initial-layer-height, sequential-order, and null-result export tests.
* Re-enable and reframe the skirt/brim tests
Detect skirt/brim by G-code role comment instead of a sentinel speed, and
resolve the previously-unfinished skirt-enclosure test.
* Replace the stale lift()/unlift() test with a z_hop test
* Delete the stub and broken Flow tests
* Preserve support base outline/fill order
Honor no_sort when emitting support toolpaths to keep outline-first order.
Group tree support base paths (including lightning) into per-area no_sort collections to prevent interleaving across islands.
Keep lightning layer lookup side-effect free.
* Tag Orca specific changes
Tag Orca specific changes vs. Bambu using the comment //ORCA: . This helps when reviewing merge commits from upstream Bambu so we don't end up causing regressions when pulling in commits from upstream
* Fix air filtration gcode emitted even if not not supported
- do not emit air filtration gcode if not supported by the printer
- removed redundant "add_eol" parameter from "set_exhaust_fan()" function
* fix: restore version placeholder in custom G-code
PlaceholderParser sets "version" in its constructor, but Print::apply() calls clear_config() which wipes it. Unlike timestamp/user (restored during G-code export), version was never restored, so [version]/{version} threw "Variable does not exist" in custom G-code while working in output filenames.
Re-set version after both clear_config() calls so it resolves everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: resolve timestamp and user placeholders in File header G-code
file_start_gcode is processed via print.placeholder_parser() directly, before the G-code parser integration copy that restores timestamp/user. As a result {timestamp}, {year}..{second} and {user} threw "Variable does not exist" in the File header G-code field while working in Machine start/end G-code.
Inject fresh timestamp and user into the file_start_gcode config so they resolve, matching the other custom G-code fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: expose initial_extruder and extruded_*_total placeholders in output filenames
PrintStatistics exposed initial_tool (not its documented alias initial_extruder) and total_weight/extruded_volume (not the documented extruded_weight_total/extruded_volume_total). Filename formats using the missing names failed with "not a variable name".
Add the missing aliases to PrintStatistics::config() and placeholders().
Fixes#12436Fixes#10708
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: populate total_toolchanges without a wipe tower
total_toolchanges is documented as available while change_filament_gcode (and the wipe-tower toolchange flow) is evaluated, but it was sourced only from WipeTowerData::number_of_toolchanges, which stays -1 (clamped to 0) when no wipe tower is generated. Manual filament swaps and toolchanger/IDEX setups without a wipe tower therefore always saw total_toolchanges = 0 in custom G-code and output filenames, despite real tool changes occurring -- breaking the placeholder's documented contract.
Add a tool-ordering fallback: when number_of_toolchanges < 0, count tool changes from the print's tool ordering (the transitions in the per-layer extruder sequence). Wipe-tower prints are untouched -- number_of_toolchanges >= 0 still wins -- so their reported count does not change.
Limitation: sequential (by-object) prints without a wipe tower leave Print::tool_ordering() empty, so total_toolchanges stays 0 there (unchanged from before).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>