Merge origin/main (00429da739) into belt-printer.
Conflicts resolved:
- src/CMakeLists.txt: keep both wxInspector workarounds.
- GCodeProcessor.cpp: keep the belt compare_pos / z_for_height lines.
- PrintObjectSlice.cpp: the belt bbox-Z guard also covers main's
printable_region_ids bookkeeping.
- TreeSupport.cpp: the belt-floor check runs before main's PendingNode
queueing.
- Tab.hpp: keep the belt fields, drop the removed upload description
fields.
- tests/libslic3r/CMakeLists.txt: keep both test files.
Also included:
- eSUN PLA belt presets declare their own filament_id (OFkrxQC4) and
scripts/filament_id_snapshot.json is regenerated, as main's filament_id
check requires.
- Custom.json version bumped to 02.04.00.05 so the belt entries reach
existing installs.
- Fix the ambiguous WithinRel call in the belt apron width test, which
otherwise breaks the fff_print build.
* Fix internal bridges over Hilbert Curve/Octagram Spiral sparse infill
For patterns with curved/turning anchor lines (Hilbert Curve, Octagram
Spiral), the bridge_over_infill algorithm produced incorrect results:
1. determine_bridging_angle: sampling curved anchor orientations
produced noise across all turning directions (0/90/180/270°)
instead of a single dominant one, yielding unstable bridge angles
with 180° spread. Fix: use the configured infill_direction + 90°
directly, bypassing the noisy sampling. The old blind +0.25*PI
(Hilbert) and +1/16*PI (Octagram) offsets are removed.
2. construct_anchored_polygon: curved Hilbert/Octagram anchors
intersected each vertical scan line many times at wildly different
Y positions, producing chaotic polygon sections — holes in random
places, bridges over air, rotated bridges. Fix: replace the curved
infill polylines with synthetic straight lines parallel to
infill_direction, spaced at the real infill line spacing
(flow_spacing / density). Lines are centered on the limiting_area
bbox center so that after rotation they span the full bridged_area.
Anchors are left at full bbox length (not clipped) to guarantee
every scan line finds an anchor.
Rectilinear and other straight-line patterns are unaffected.
Known limitation: some bridge edges may still terminate over air in
edge cases where the nearest synthetic anchor line is more than one
infill spacing away from the bridge boundary. This will be addressed
in a follow-up.
* fix: anchor internal bridges to actual sparse infill
Preserve real anchors across regions and align plane-path anchor origins with printed infill. Respect lower-layer rotation templates and model alignment, and sample curved bridge boundaries more finely.
Add regression coverage for anchor alignment, bridge angles and region isolation, with Orca comments explaining the geometry constraints. Verified 175 FFF tests before the comment-only follow-up; preserve CRLF in modified files.
* Fix internal bridge support contacts and separated infill origins
Restore anchor contact after bridge smoothing and share per-body pattern origins between anchors and printed infill. Recompute origins when preparation settings change.
Cover multiline counts 1, 2 and 3 and add regressions for printed bridge support, separated infill alignment and reslicing.
* Add explicit standard headers to PrintObject tests
* test: cover surface centering when infill settings change
Verify top and bottom Archimedean Chords and Octagram Spiral paths after switching centering modes or toggling separated infills. Compare reslicing against fresh slicing and document dependent infill invalidation.
* test: preserve directional surface infill when settings change
* perf: index layer islands for connected-body detection
* test: use public print pipeline for body centering checks
The clamps and validation work from estimates. Once the tower is
generated, _make_wipe_tower re-tests the exact first-layer footprint,
brim and cone base included, against the printable area and the
exclusion zone, so an off-plate tower fails with a clear error instead
of exporting unprintable G-code. The rectangle-wall mesh footprint
learns the Type2 cone base so that check and the post-generation
validation see the real outline.
Pre-generation, validation hard-checks the body plus an explicit brim
and warns on the estimated auto brim and cone base with the existing
"may collide" strings, so the user hears about a marginal position on
the first slice rather than only at generation time.
Two fff_print fixtures that print a tower at the default position move
it onto the 200 mm test bed, as the multifilament fixtures already do:
the shipped default y of 220 is off that bed, and the backstop now says
so instead of exporting the tower.
The shared estimate reserved every tower with one volume-per-purge rule
and the stability floor. Both planners do more: WipeTower (Type1) wipes
each filament's own prime volume in whole lines, one block per
adhesiveness category sized by its worst layer, rams the leaving
filament at every nozzle change, and squares a rib tower from the
planned depth; WipeTower2 (Type2) spaces its lines by
wipe_tower_extra_spacing, not the Type1-only infill gap, and its extra
flow cancels out of the depth. Both extend the ribs rather than the body
below the stability minimum, size every layer including a thinner first
one, and lay the brim in whole loops, WipeTower reporting half a spacing
of line width on top.
All of that now lives in estimate_wipe_tower_footprint, fed the planner
(resolve_wipe_tower_type mirrors Print::wipe_tower_type and the CLI's
Bambu Lab detection) and the filament ids rather than a count. Print
passes its own tool set; the PartPlate adapter derives the plate's ids
from the passed config and treats an explicit count as a floor, so the
CLI's count-only callers size per filament too. The placement clamp also
reserves a Type2 cone's base bulge, which the body box does not cover.
The planner-mirroring helpers sit beside the planners in WipeTower and
WipeTower2 so the two stay in sync; the libslic3r cases pin them to
footprints measured from generated G-code.
A raft is not a reason to reserve a tower. Print::apply runs
normalize_fdm_2, which clears enable_prime_tower for a plate that purges
one filament unless smooth timelapse or wrapping detection is on, so a
single-filament plate with a raft prints no tower at all and the estimate
was reserving bed area for one. Drop the input; need_wipe_tower is now
exactly the two exceptions normalize_fdm_2 honours, named there so the
next reason added has to be checked against it.
The GUI preview and the validation containment check each re-derived
"is a tower printed here" from the filament count instead of reading the
estimate, so both missed the towers printed with no tool change to purge
for. They now take the answer from the footprint, which is the drift this
shared estimate exists to remove. A tower that is not printed estimates to
zero, so its hull is degenerate and every check on it passes trivially -
the containment check needs no gate of its own.
WipeTowerData::width was written only by the pre-generation estimate and
left at zero for the whole post-generation life of the Print, while its
neighbour depth held the real value. Set it from the generator in both
branches.
The plate's height scan transformed every model part's full mesh per
instance on each scene reload, discarding all but the z extent. The
cached convex hull has the same z extent.
A plate loaded from a sliced .gcode.3mf holds no objects and its filaments
live in slice_filaments_info; the config-taking get_extruders overload
returned an empty list for it, which sized the tower for a placeholder two
filaments. It now answers the way the wx overload does, without reaching
the plater.
Also drop estimate_wipe_tower_size, which has no callers.
* Make tree support deterministic without giving up its parallelism
* Break equal-distance ties in the tree support MST by coordinates
* test: cover the determinism this PR fixes
The MST unit tests here cover the tie-break, but the drop_nodes rework
has no test.
Adds two cases to the tree support suite. The thread-scheduling one
slices five configs twice each and compares the support point sequence,
which is what the node ordering moves. The MST tie one pins the branch
diameter and line width that carry Prim's equal-distance ties into the
toolpaths.
slice_with_tree_support takes an optional config list so the second case
can add the tree parameters it needs, and the double-slice comparison is
shared rather than written twice.
Both fail on main without this PR. The first passes from 60d1ceb580, the
second from e148865dd6.
---------
Co-authored-by: raistlin7447 <kris.austin@gmail.com>
* Normalize the junction direction vector over XYZE
calc_vmax_junction_deviation() treats the dot product of two jd_unit_vec as a
cosine, but the vectors were scaled by 1 / block.distance, which is the XYZ
length. On an extruding move the E component then pushes the 4D norm above 1 and
the dot product below -1, so the corner reads as straighter than it is and is
planned too fast -- the more so the higher the flow. Measured on a 6 degree
corner at scv 5: 86.9mm/s with no extrusion, 94.4mm/s at 0.029mm/mm, 150.0mm/s
at 0.1mm/mm.
Neither firmware does that. Marlin normalizes over XYZE for any extruding move
(planner.cpp: `if (... || esteps > 0) normalize_junction_vector(unit_vec)`) and
Klipper leaves E out of the cosine entirely, dotting only axes_r[0..2]
(toolhead.py::Move.calc_junction). Normalizing satisfies both: with E normalized
in, the cosine differs from the XYZ-only one by ~1e-5 at printing flow rates.
This is a deliberate divergence from PrusaSlicer, which still scales by
1 / distance -- it carries an older Marlin's behaviour.
Travel moves are unaffected, their vector was already unit length.
Reported by Copilot in review of #15304.
* Test that extrusion rate does not change corner planning
The junction deviation tests were all travel-only, which is exactly why the E
component of the junction vector went unchecked. Cover it: the same corner has
to be planned the same whether nothing, an ordinary 0.42 x 0.2 line, or a fat
large-nozzle line is extruded through it, on both Klipper and Marlin 2.
Reported by Copilot in review of #15304.
* Plan corners with junction deviation where the firmware uses it
The time estimator only ever had the classic per-axis jerk model, which limits a
corner by the largest single-axis component of the velocity change. That is
anisotropic: the same corner is allowed sqrt(2) more speed on a diagonal than on
an axis, which paints a four-lobed ripple around every circular wall in the
actual speed and actual flow views, worst on small parts whose walls are made of
short segments.
Klipper has no classic jerk at all and Marlin 2 has none while M205 J is in use;
both plan corners with junction deviation, which sees only the corner angle. Add
that model and use it for those machines:
- Klipper: derived from the square corner velocity, as the firmware does
(jd = scv^2 * (sqrt(2) - 1) / max_accel), reading the scv from
machine_max_jerk_x, where process_SET_VELOCITY_LIMIT() already stores
SQUARE_CORNER_VELOCITY.
- Marlin 2: machine_max_junction_deviation, which was already loaded into the
machine limits but never reached the planner.
- Every other flavor keeps the classic jerk path unchanged.
The model has no per-axis jerk floor, so this also drops the hard slow spot the
estimator drew at the start of every loop from machine_max_jerk_e.
Toolpaths are unaffected: on a full export the only lines that change are M73.
The junction deviation maths, including Marlin's JD_HANDLE_SMALL_SEGMENTS arc
approximation, is ported from PrusaSlicer's src/libslic3r/GCode/GCodeProcessor.cpp.
The Klipper mapping is not in PrusaSlicer, which ignores SET_VELOCITY_LIMIT.
* Add tests for junction deviation corner planning
Cover the three properties the change rests on:
- a right angle on Klipper is planned at exactly the square corner velocity,
the identity that makes the scv to junction deviation mapping correct, and a
shallow corner is planned far faster than per-axis jerk allows;
- junction deviation gives the same speed whatever the corner's orientation,
while classic jerk keeps its sqrt(2) spread, which is the four-lobed ripple;
- machines that do not plan with junction deviation are provably untouched,
including a Marlin 2 printer that has it disabled.
## Problem
`Toolchange temperature commands are unchanged when the wipe tower wait
is off`
(added in #15144) fails on both Linux runners and passes on Windows and
macOS.
It is the only failing test in the suite, and it has been failing on
main since
that PR merged.
| Job | Result |
| --- | --- |
| Windows x64 / Unit Tests | pass |
| Windows arm64 / Unit Tests | pass |
| macOS arm64 / Unit Tests | pass |
| Linux x86_64 / Unit Tests | **fail** |
| Linux aarch64 / Unit Tests | **fail** |
From the merge commit
([Linux
x86_64](https://github.com/OrcaSlicer/OrcaSlicer/actions/runs/31072382258/job/92531704095),
[Linux
aarch64](https://github.com/OrcaSlicer/OrcaSlicer/actions/runs/31072382258/job/92531704075)),
still reproducing on current main:
```
first difference at trace entry 29
main: M104 S240 T0 ; preheat T0 time: 31s lead 30.9s
branch: M104 S240 T0 ; preheat T0 time: 30s lead 30.3s
```
## Cause
Each preheat entry records the same quantity twice: `lead` at one
decimal, and
`time:` inside the command text as that value rounded to a whole second.
`split_lead` already compares `lead` with a 0.5s tolerance and explains
why the
estimate moves. `time:` sits in the exactly-compared command text, so it
never
got that tolerance — and being rounded, it flips on a drift far below
0.5s
(30.4 and 30.6 render as `30s` and `31s`). Entry 29 is the only entry in
the
163-entry golden whose lead rounds up; every other preheat sits at
30.0–30.4 and
rounds down, which is why it is the only one that fails.
The variation is per-toolchain, not run to run. Both Linux arches
produce
exactly `lead 30.3s`; Windows x64/arm64 and macOS arm64 all produce
exactly
`30.9s`. Repeated local runs are byte-identical. macOS arm64 passing
while Linux
aarch64 fails rules out the ISA — it is floating-point accumulation over
a few
thousand move durations under GCC vs Clang vs MSVC.
The mechanism makes it discrete rather than gradual: the backtrace parks
the
preheat at the first exported line at least `preheat_time` before the
tool
change, so `lead` is `preheat_time` plus the leftover of whichever move
that
landed on. A sub-tenth difference selects the neighbouring move and
`lead` steps
by that move's whole duration.
Entries 1–28 match exactly, including five earlier preheats whose leads
fall
inside the existing tolerance, so the toolpaths themselves are
identical. I also
reverted the two prime-tower commits that landed between the golden's
capture
point and now, rebuilt, and got a byte-identical trace — this is not
behavioural
drift.
That also rules out regenerating the golden: no single capture satisfies
all
three toolchains, and recapturing on Linux would turn the three
currently-green
runners red.
## Fix
Test-only.
- `lead` keeps a tolerance, widened to 1.5s (measured drift 0.6s; a
preheat
actually leaving its backtrace position would move by tens of seconds).
- `time:` is **not** compared across runs at all. Being a rounding of
`lead`, it
carries nothing the tolerance does not already cover, and comparing it
across
runs can only reproduce the flake. It is instead checked against its own
entry's `lead` — a correct rounding keeps `|time - lead| <= 0.5`.
That second point matters: simply tolerating `time:` numerically would
have made
the test blind to a real change, because drift and a wrong rounding both
move it
by 1. The self-consistency check keeps that coverage. I verified it by
changing
`(int) std::round(time_diffs[0])` to `(int) time_diffs[0]` in
`GCodeProcessor::export_lines` — the test fails with
`"time:" is not its entry's "lead" rounded to a whole second`, where a
plain
tolerance would have passed silently.
Everything else is still compared exactly: all M104/M109 values, tool
ids,
block markers, ordering, entry count, and the annotation text including
its
trailing `s`. The other 138 entries remain byte-exact.
No production code, no golden regeneration. The golden file and these
helpers
are used by this one test and nothing else, and the tolerance only
widens, so
Windows and macOS keep passing unchanged. A note is added to the
golden's header
so the next mismatch in those fields is not "fixed" by recapturing.
## How to verify
Before, on Linux:
```bash
git checkout main && ./build_linux.sh -t
ctest --test-dir build/tests -R "Toolchange temperature commands are unchanged" --output-on-failure
# fails at trace entry 29
```
After:
```bash
cmake --build build --config Release --target fff_print_tests
ctest --test-dir build/tests --output-on-failure # 463/463
```
Deterministic tests for: coincident brim at first belt contact not dropped
(C), single- and multi-extruder brim tool selection with no doubling (B),
multi-object apron ordering, inner-only+leading-only not rejecting prime
tower/spiral (D), and inner/holed + leading-edge-only geometry.
The placement clamps and the tower-approach router both stood in the bed's
bounding box for the bed itself, so on a delta or hexagonal bed the prime tower
could be parked in a corner that does not exist and the nozzle could be routed
across it. Both now test the real printable outline, slicing reports a tower
that does not fit instead of printing it off the bed, and a tower parked near an
edge is routed along the clamped side rather than falling back to a straight
line across the tower.
Also fixes the placement validation rotating the tower hull by degrees read as
radians about the plate origin, and never rotating the generated tower footprint
at all.
Six issues found by reviewing the previous commit against belt-printer, two of
them release-blocking.
Data race (high). Print::process() runs generate_support_material() for all
objects in a tbb::parallel_for, and make_belt_brim() runs at its tail, but
belt_brim_obstacles() read every OTHER object's support_layers() - which a
concurrent task may be inside clear_support_layers() deleting. That is a
use-after-free, and even when it survives, the obstacle set depends on which
object finishes first. Only this object's own supports are consulted now; they
are complete at that point. Foreign objects still contribute their slices,
which are finished and immutable before the support phase.
Apron bands dropped (high), two separate causes. An apron band prints below
its own object's first layer, but another object can already be printing at
that print_z, in which case process_layer() takes the ordinary path and never
emitted the band - the emission is now shared by both paths. Separately, a
band whose print_z matched a support layer of the SAME object was overwritten
in the print-wide merge, which keeps one record per object per z and could not
detect the collision because LayerToPrint::layer() is null for a band. The
per-object pairing loop is now a three-way merge over object, support and apron
streams, so each object contributes at most one record per z.
Multi-instance was far too strict (medium). It refused belt brim for every
multi-instance object, killing plain brim width and inner brim too, and only
warned when a leading length was set. Only movement ALONG the belt changes an
instance's belt-floor Z, so copies side by side ACROSS the belt share one set of
bands perfectly well; belt_brim_instances_compatible() now tests just that, and
the warning fires whenever the brim is actually suppressed.
Apron layer bookkeeping (medium). Apron layers count toward m_layer_count and
advance m_layer_index, but emitted no Z/height tags, left m_last_layer_z,
m_max_layer_z and m_last_height stale - so the first object layer computed its
height against a pre-apron Z - and skipped before_layer_change_gcode and
layer_change_gcode entirely. All of that now matches the ordinary path.
Obstacle cost (low). belt_brim_obstacles() ran a full-plate union per band.
A bounding-box pre-filter drops non-overlapping objects before materialising any
polygon, and the union is skipped for trivial inputs.
Deliberately unchanged: every apron band still reports cooling layer_id 0.
CoolingBuffer uses it for the initial_layer_fan_speed override and the
close_fan_the_first_x_layers gate, and every band lies on the belt plane itself,
so it is all first-layer material by the only definition that means anything on
a belt. Numbering the bands would ramp the fan up while still printing on the
belt. Now documented at the assignment rather than left implicit.
A belt printer slices in a rotated frame, so the belt surface is a tilted
plane rather than the Z=0 bed plane. Each slicing layer touches the belt
only along a narrow strip at its leading edge - about 0.2mm at 45 degrees -
so a part's first layer is really a first line, with almost no contact patch
to hold it down while the belt drags it forward. Brim was hard-disabled on
belt printers, leaving no remedy at all.
Generate the brim on the belt plane instead. The object's belt footprint is
the union over layers of each slice clipped to that layer's contact band; the
brim is offset from it in a "flattened" frame where the shear axis is
stretched by 1/cos(tilt), so ordinary Clipper offsets measure true on-belt
distance. It is emitted as cross-belt lines, one per layer band, anchored to
a fixed fraction of the band so every line shares a nozzle-to-belt clearance
and therefore comes out the same width; flow is matched to the resulting band
pitch, keeping the sheet uniform and gap-free.
Three new controls, all belt-only:
* Leading brim length - extends the brim ahead of the part along the belt,
on every downhill-facing edge of its contact area. This apron necessarily
prints BELOW the object's first layer, since layer 0 is the part's leading
contact, so it needs brim-only bands of its own.
* Extra brim width - widens the brim sideways across the belt only.
* Brim type "Leading edge only" - brim at the part's first belt contact and
nothing after it. Appended last in BrimType so no existing value shifts;
degrades to an outer brim off belt printers, with a warning.
The apron bands are lightweight records rather than a Layer subclass, so no
fabricated Layer::id() can leak into initial-layer temperature selection, the
spiral vase probe, cooling or gradual interpolation. They are generated in
posSupportMaterial because their print_z values must exist before ToolOrdering
is built at psWipeTower, and they are emitted from a short dedicated branch in
process_layer that runs before any layer pointer is dereferenced.
The footprint is closed before offsetting outwards: a belt contact patch is
often a broken-up strip, and the merged offset rings of two islands closer
than 2 x brim_width would otherwise fill the space between them - space that
lies under the part.
Also fixes a pre-existing bug where PrintObject::get_first_layer_bbox()
overwrote a valid bbox with an unassigned one on any belt printer with a brim
configured, because has_brim() was true while make_brim() returned early.
Belt brim is refused alongside the prime tower and spiral vase, and requires
one instance per PrintObject - translating an instance along the belt axis
changes its physical belt-floor Z. Untilted belt printers are unchanged: they
still get no brim, since the plate brim is emitted out of skirt_brim_groups(),
which _make_skirt() never builds for a belt printer.
Adds a printer option that picks up the new tool without a blocking temperature
wait, travels to the wipe tower, and waits there right before purging, parked
beside the tower so the ooze from the heat-up lands next to it rather than on the
model. The incoming filament's target is raised ahead of the tool change, so the
heat-up overlaps both the change itself and the travel to the tower.
Off by default, and only offered for multi-extruder printers using a Type 2 wipe
tower; the generic toolchanger profile enables it.
The wipe tower's "Delay after unloading" never happened on Klipper. It was
emitted as G4 S<seconds>, and Klipper's G4 reads only the P parameter, in
milliseconds, so the pause was silently skipped. The option now produces a
dwell Klipper actually performs.
Also corrects the planner flush rationale, which cited an extruder position
reset that Klipper resolves at parse time and does not need synchronized, and
adds end-to-end coverage that slices a two-filament print and checks the
emitted wipe tower G-code on both a Klipper and a non-Klipper flavor.
No change to any other firmware flavor's output, and no shipped profile sets a
non-zero delay, so no shipped profile's output moves either.
The wipe tower emitted G4 S0 to make the firmware finish its queued moves
before commands that must not take effect early. Klipper's G4 reads only the
P parameter, so that flush never happened there and a temperature change could
land seconds ahead of the moves it was meant to follow. Klipper now gets M400
instead, through one helper shared by both wipe tower implementations.
No change to any other firmware flavor's output, so no shipped profile or saved
project is affected.
Processes a minimal belt start sequence through GCodeProcessor::process_buffer
and asserts the move preceding the first extrusion keeps its real Z, so it can
no longer back-transform to model Y~=0 and produce the phantom extrusion line.
Belt printers are non-Bambu, so the processor uses the compatible reserved
tags ("TYPE:"); the test sets s_IsBBLPrinter=false (saved/restored via an RAII
guard) to mirror the real printer. Proven to fail without the fix (the
prepare-stage move's Z is pinned to the first-layer height, 0 here) and pass
with it.
Locks in the fix from the previous commit. A fresh BeltGCodeWriter has an
unestablished planar position (is_current_position_clear() == false) and its
m_pos.xy is the origin (0,0). With a pending NormalLift z-hop, travel_to_xyz
used to lift in place via _travel_to_z(), which in belt mode shears the origin
into a machine Y ~= the layer Z — a move far up the gantry.
The test configures an X-tilt 45 deg belt transform, defers a z-hop via
lazy_lift, travels to a near-belt first point (transformed gantry Y ~= 1mm),
and asserts no emitted move has Y anywhere near the layer Z. Verified to fail
without the fix (max emitted Y = 100.0 vs the destination's ~1.0) and pass with
it.
# Description
Changing a slicing plugin's configuration had no effect on the sliced
result until you forced a re-slice some other way; it now applies
immediately. Print, printer and filament presets also keep their plugin
configuration separately, so configuring a plugin on one no longer wipes
out what you set on another.
A plugin's custom configuration page gets the same round of improvements
in both the Plugins dialog and the per-preset dialog: it follows the
app's light/dark theme, keeps its state while you edit instead of
resetting under the cursor, and can tell whether it is being edited
globally or for a preset, so "Restore defaults" can be labeled for what
it will actually do. The two bundled examples show this off — Twistify
now ships a custom configuration UI, and Inspector is themed, groups
# Screenshots/Recordings/Graphs
https://github.com/user-attachments/assets/02ca062a-5143-49a3-abe0-a2a040b3a928
## Tests
<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->
<!--
> A guide for users on how to download the artifacts from this PR.
-->
[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
Resolve five conflicts, all of which needed both sides rather than a pick:
- BackgroundSlicingProcess: ours was a pure tabs->spaces reformat of base, so
keep main's per-filament volume/nozzle map read-back (its only change here).
- GUI_App: main's #12506 else-if attached to an `if` this branch deleted;
re-expressed onto the same-agent early-return path (the agent factory caches
per id, so pointer equality is the same predicate).
- MainFrame: both sides relocated Sync Presets independently; keep main's
push_notification plus the branch's Plugins menu items.
- Tab: the "TODO: Orca: Support hybrid" blocks were unchanged base, not a branch
decision; take main's enabled Hybrid to match the already auto-merged siblings.
- test_config: union of both sides' cases (6 plugin + 9 multi-nozzle).