Skip to content

feat: real multi-nozzle printers + user-definable filament slots (v0.8.0)#160

Merged
Hiosdra merged 11 commits into
masterfrom
feature/multinozzle-engine
Jul 22, 2026
Merged

feat: real multi-nozzle printers + user-definable filament slots (v0.8.0)#160
Hiosdra merged 11 commits into
masterfrom
feature/multinozzle-engine

Conversation

@Hiosdra

@Hiosdra Hiosdra commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #140. Supersedes #154, whose commit is included here.

Real multi-nozzle machines work, and filament slots are now something you can actually create in the UI rather than only inherit from an import.

Eleven commits: the engine fix, the per-object assignment UI, the slot-definition UI, a minor version bump, six rounds of review follow-ups, and a documentation pass.

1. Engine — three defects, none where the issue predicted

The issue blamed support_different_extruders() and per-extruder G-code placeholders. The real causes were all ours:

Wrong separator for multi-value options. The passthrough joined every array with ,, but the separator depends on the option's type: coStrings uses ; with c-style escaping, coPointsGroups uses # between groups (, only separates points within a group). An H2D stores one printable area and one colour per nozzle, so comma-joining fused each pair into one entry — leaving nozzle_diameter length 2 with length-1 companions, indexed by extruder id, dying in Brim.cpp::outer_inner_brim_area() and ToolOrdering's flush-matrix lookup. The join now happens in the bridge, which reads the separator from the engine's own option registry rather than a JS-side list that could drift.

UI-mapped multi-value fields never reached the engine. ORCA_FIELD_MAP collapses arrays to one scalar for display, so nozzle_diameter: ["0.4","0.4"] became 0.4 — a dual-nozzle machine sliced as single-nozzle.

Uninitialised plate origin. Print::m_origin is an Eigen Vec3d with no default member initializer, and only the desktop GUI's PartPlate code calls set_plate_origin(). Headless it stayed garbage, and shift_without_plate_offset() subtracts it from every instance shift — so Brim.cpp translated brim polygons by that garbage and Clipper rejected them. Being uninitialised memory it was layout-dependent: it surfaced only for some meshes and flipped on unrelated edits. Caught because the synthetic icosphere in smoke-test.mjs began failing after a bridge change that never executed, confirmed with a control build where the new function was present but unreachable. Note this corrupted brim geometry for ordinary single-nozzle prints too, whenever the garbage happened to be large.

isMultiExtruderProfile() is removed — it had nothing left to guard.

2 & 3. UI — assign objects to slots, and create slots in the first place

The queue gains a per-object Filament picker (shown only when the config has more than one slot), and the Settings panel's Filament section becomes a list: add a slot, pick a material per slot, remove one. A single slot looks and behaves exactly as the old single "Material" dropdown.

Making slots real to the engine is the substantive part. Its filament count is not filament_type (that is display text — #155) but the length of filament_colour, and several vectors must agree or it reads past the end of one:

  • filament_colour — one distinct colour per slot; this is what establishes the count
  • filament_map — each slot to a physical extruder, round-robin over available nozzles: all on extruder 1 for an AMS-style single-nozzle printer, alternating on a dual-nozzle machine (which is what produces genuine T0/T1)
  • flush_volumes_matrix — one N×N sub-matrix per nozzle laid end to end, and flush_multiplier one entry per nozzle; GCode::append_full_config() divides the former by the latter to validate the shape
  • filament_start_gcode / filament_end_gcode — read with .at(filament_id), so a one-entry default throws the moment anything prints with a slot other than the first

Slots are applied after the config layers merge, not inside buildConfig(): an imported machine profile is the only way a real multi-nozzle printer reaches us today, and it only becomes visible once that layer is combined. Deriving the nozzle count earlier would put both slots on nozzle 1 and silently waste the second.

4. Version

Bumped to v0.8.0 — a feature boundary rather than a routine patch, so this takes the minor instead of letting deploy.yml auto-increment. deploy.yml respects a version already ahead of the latest tag and will tag/deploy v0.8.0 as-is.

5. Follow-up: the slot assignment outside a plate slice

Reported after the first push: changing an object's slot on the Slice tab after slicing did not offer to re-slice the way a settings change does. The missing indicator was the visible half — the assignment genuinely did nothing for a single-item slice, because only orc_slice_multi carries a per-object filament. Both halves are fixed: an item with a slot assigned is now sliced as a single-object plate, and reassigning marks its own result (and the plate's) stale.

Sizing filament_start_gcode/filament_end_gcode per slot is what makes any slot but the first usable at all — assignments of [1] and [1,2] worked while [2] and [2,2] threw std::out_of_range.

6. Follow-up: review fixes

Three of these were silent — nothing in the UI showed the wrong state.

  • Manual overrides survived. withFilamentSlots() built every slot from FILAMENT_PRESETS, slot 0 included. Those arrays land in _passthrough, which the worker spreads over the resolved config, so adding a second slot silently reverted a hand-typed nozzle or bed temperature back to the preset value — undoing Manual settings outrank profiles and survive printer/filament/preset changes #159 for any multi-slot config. Slot 0 now reads the resolved config.
  • Preset G-code hooks survived. The same function overwrote filament_start_gcode/filament_end_gcode with empty strings; it now pads the existing value across the new slots.
  • Picker and vectors can no longer disagree. The picker offered a slot per entry of a multi-value filament_type, which an imported profile can carry without the UI having defined any slots. withFilamentSlots() now sizes for max(UI slots, declared), and both the picker and the queue read one shared filamentSlotLabels().
  • Removing a slot drops assignments naming it. Below two slots the picker disappears, so those were invisible — but buildPlateExtruderIds still sent them.
  • Slots persist. selectedFilaments was missing from the settings-persistence effect's deps, so adding, removing, or editing any slot but the first was never saved. Saved user presets carry the whole list now, not slot 0 alone.
  • "+ Add filament slot" picks the first unused material rather than duplicating the last, and adding/removing a slot no longer sheds an imported filament profile — only a change to slot 0, the slot that layer describes, does.
  • Docs/comments. status.md still listed real multi-nozzle as deliberately blocked, and four comments pointed at the deleted isMultiExtruderProfile().

7. Follow-up: the imported multi-material profile

Same class as §6, one slot further down the list. withFilamentSlots() rebuilt every per-filament vector from scratch, so a profile that arrived declaring its own values had them silently replaced by stock presets the moment the slots were sized — _passthrough is spread over the resolved config, so anything written there beats what was imported:

declared by the import became
filament_colour ["#FF0000","#00FF00"] SLOT_COLOURS
filament_diameter ["2.85","2.85"] ["1.75","1.75"]
hot_plate_temp ["55","80"] ["55","70"] (stock PETG)
flush_volumes_matrix [0,700,700,0] uniform 280

Each option now prefers the value the config already carries for that slot. Two exceptions stay explicit: slot 0 of an option the settings panel edits reads the resolved config (a manual override lives there — #159), and a slot the UI names takes the material the user picked over whatever an older import said. The vectors whose length the engine validates can't be honoured entry by entry, so flush_volumes_matrix, flush_multiplier and filament_map are kept only at exactly the required shape — and filament_map only when it names nozzles this machine has.

Slot 0's filament_type could not be read off the resolved config at all. ORCA_FIELD_MAP collapses a multi-value option to one scalar for display, so an imported dual-material profile left config.filament_type as the joined "PLA,PETG" — which reached the engine as slot 1's type and showed in the queue's picker as Slot 1 · PLA,PETG. Taken from the unflattened passthrough array instead.

Two smaller ones: parseOrcaProfileJson stored a single-entry [""] as "" where the old comma-join dropped it, letting an import blank out a built-in preset's value (multi-entry arrays keep their empties, where the length is load-bearing); and colours past the hand-picked palette repeated it instead of walking the hue circle.

e2e now covers the slot UI for the first time — add a slot, see the picker appear with one option per slot, assign, remove the slot, find the assignment dropped rather than left naming a filament the engine no longer has. Verified against a negative control that fails with unexpected value "2" when CONFIG_CHANGED stops dropping it.

8. Follow-up: review fixes

Two more of the same shape as §6 and §7 — _passthrough is spread over the resolved config, so what withFilamentSlots() writes beats what is underneath it. Both silent; the panel kept showing the value the engine was not using.

  • A declared filament_diameter survived one UI slot and not two. per()'s "a slot the UI names wins" rule is there because the user picked a material, so that material's preset outranks an older import — sound only while pick actually reads the slot's material. No FILAMENT_PRESETS entry carries a diameter, so it was passed () => undefined and the derived value was the bare literal 1.75: the branch had nothing to contribute and could only overwrite. An import declaring 2.85 kept it at one slot and became ["2.85","1.75"] on adding a second — the normal way to use a dual-material import, and the path §7 stopped one step short of. Every E value on that slot is then off by (2.85/1.75)². It joins filament_colour on a new declaredOr() helper, and per()'s doc now states the precondition its middle branch depends on.
  • hot_plate_temp_initial_layer was missing from PANEL_EDITED_SLOT_OPTIONS, though the panel edits it: toEngineConfig() fans the single "Bed temp" field out to both it and hot_plate_temp. A single-slot config put the typed number in both; adding a slot left the first layer on the imported profile's temperature — Manual settings outrank profiles and survive printer/filament/preset changes #159 again, one option down, on the layer where bed temperature decides adhesion. nozzle_temperature_initial_layer stays out for the same rule read the other way (toEngineConfig() never writes it).

Reverting either fix alone fails exactly its own new test and nothing else.

Three with no behaviour change: slotColour()'s HSL ramp was discontinuous at both joins (slope 1.932 where the endpoints require 6·(max−min) = 3.804, and the stated S/L described neither endpoint) — saturation and lightness are now the inputs and everything else is derived from them; the bridge's static set_plate_origin() is renamed zero_plate_origin() so the call site stops reading as though it passed an origin; and smoke-test.mjs's hand-written DUAL_NOZZLE_CONFIG — which cannot import the TS module and so could keep proving T0/T1 for a config the app no longer builds — is now pinned by a test asserting the option set and every shape the engine validates.

9. Follow-up: review fixes

Four more. The first is §5's shape one step earlier: §5 made a reassignment after a slice offer a re-slice, and this one makes a reassignment during a slice not be lost. Silent again — the result came back looking current.

  • A reassignment made mid-slice was dropped. ASSIGN_EXTRUDER flagged only an item already done, and the plate only when it already had G-code — which PLATE_STARTED clears for the duration of the slice. SLICE_DONE/PLATE_DONE then overwrote stale from the configEpoch comparison, and an assignment never touches configSnapshotRef, so it has no epoch to be caught by. The picker stays enabled throughout (SliceCards hides it only for converting/error), and a plate slice is exactly the multi-second window where "wait, that one should be PETG" happens — so the plate finished non-stale, showing the new slot beside G-code sliced with the old one. Both DONE cases now OR in a flag the assignment sets directly. RUN_QUEUE clears stale before re-slicing, so anything surviving into a DONE case arrived during the run — which is what makes the OR safe rather than "always stale", and has its own control test. Bumping configEpoch instead is the obvious fix and the wrong one: configSnapshotRef would sit one behind for the rest of the session and mark every later result stale.
  • The config summary never counted panel-defined slots. Its "Material" row split the display scalar, which for UI slots is slot 0's material alone (buildConfig() merges FILAMENT_PRESETS[slots[0]]; the rest live in _passthrough). So the Slice tab read Material: PLA beside a picker offering three, and the row's own (N slots) branch only ever fired for an import whose flattened scalar happened to contain a separator. It reads filamentSlotLabels() now — the count the picker and CONFIG_CHANGED already share.
  • A lone value the collapse would corrupt now stays an array. The bridge routes arrays through escape_strings_cstyle(), which quotes any element containing ;; a bare string reaches ConfigOptionStrings::deserialize() unquoted and its unescape_strings_cstyle() splits on every ; and strips leading whitespace. So filament_start_gcode: ["M900 K0.02 ; set K"] arrived chopped in two with only the first fragment ever run — and padHook() already repaired this at two slots while leaving one slot broken. §7's claim that the two shapes are "equivalent once the bridge joins them" was the part that was wrong. The option's type is deliberately not visible here, so the new collapsesCleanly() keys off the value instead: anything the quoting pass would have had to touch keeps the array form, which is never less correct and is the shape the source profile used anyway.
  • filamentSlotLabels() no longer promotes display text to a slot count. Its single-slot fallback handed back filamentSlots()'s [;,] split, so a profile carrying a plain "PLA;PETG" offered a Slot 2 that withFilamentSlots() — reading the same declaredSlotCount() — had sized no per-filament vectors for. Clamped to one label rather than teaching declaredSlotCount() the split, since making display text a count is exactly what Multi-material (AMS): filament slots are modelled on a false assumption about filament_type #155 warns against.

Reverting any one of the four fails exactly its own new test and nothing else.

10. Follow-up: the docs that still described the old diagnosis

status.md was updated in §6, but three other places still called real multi-nozzle profiles a known crash risk and attributed that crash to the engine rejecting our single-extruder config — the diagnosis §1 replaces.

  • api-reference.md described orc_slice_multi's extruder_ids as "the classic single-nozzle multi-material path, not a multi-nozzle machine". It names a filament slot either way; filament_map is what decides.
  • ADR-009 listed the plate scenario as deliberately avoiding a real multi-nozzle nozzle_diameter array. §1 added exactly that scenario, so it joins the list and the scenario × mesh count goes 8 → 10.
  • ADR-008 and fetch-orca-profiles.ts both pointed at the status.md section §6 deleted. The generator's comment gives two reasons for stripping _passthrough from the bundled presets, and the second is the resolved crash — only the PlaceholderParser one still stands, so it now says which is which rather than reading as two live blockers.

Comments and docs only; no behaviour change, and mkdocs build --strict passes.

11. Follow-up: the slot removal that renumbers

§6 taught CONFIG_CHANGED to drop an assignment naming a slot the config no longer has. It tested the count, which covers both shapes of "removed the last slot" and neither shape of removing one from the middle.

Slots are positional — index is the identity, for the engine's per-filament vectors and therefore for extruderId too. So removing one from the middle renumbers every slot after it while leaving every id in range:

slots            [PLA, PETG, ABS]     object -> slot 2 (PETG)
remove slot 2 -> [PLA, ABS]           id 2 <= 2, kept
                                      object -> slot 2, which is now ABS

Silent in the way the rest of this PR's bugs were: the item is marked stale like any config change, so it is re-sliced — with the wrong material — and the picker just starts reading Slot 2 · ABS with nothing to say it used to mean PETG. It prints wrong rather than indexing out of bounds, which is why no check downstream caught it.

CONFIG_CHANGED now carries the slot labels rather than the count, and the reducer keeps the previous list. No new plumbing: useSliceQueue already called filamentSlotLabels(config) there and discarded everything but .length.

Both halves of the new condition are load-bearing. A label changing on its own is the user re-picking that slot's material, which is deliberate — the object stays on slot 2 and prints with the new filament. It is a label changing because the list got shorter that means the position now describes something else. Dropping to Auto rather than following the material to its new index is the conservative half: the picker is right beside the item, and a wrong slot is worse than an unset one.

Two of the four new tests are controls a lazier fix would fail — an object on slot 1 of the same removal keeps its assignment, and so does one on slot 2 when slot 2 is merely re-picked at the same length. Reverting the label clause alone fails exactly the one test and nothing else.

Verification

smoke-test.mjs gains the dual-nozzle scenario this PR is about — two objects mapped to different nozzles, asserting both T0 and T1 appear, with a negative control that fails with saw [0] when the slots collapse to one filament. It runs on every future PR, on both engine variants:

[smoke-test] [synthetic icosphere] plate: 2 objects on a real dual-nozzle machine (T0 + T1) ... PASS
[smoke-test] [Voron Design Cube v7] plate: 2 objects on a real dual-nozzle machine (T0 + T1) ... PASS
[smoke-test] all scenarios passed          # build (mt) and build (st)

Against a locally built engine, driving the config the UI actually produces:

scenario filament_map flush matrix result
P1S (1 nozzle) + 2 slots [1,1] 4 = 1×2² both filaments used (3595.62 / 3708.34 mm)
imported H2D (2 nozzles) + 2 slots [1,2] 8 = 2×2² T0 + T1, both filaments used
H2D dual-extruder plate 78,346 / 78,435 extrusion moves per nozzle
2 slots at different temps (PLA 220 / TPU 240) slot 1 → ; filament: 1 @220 °C, slot 2 → ; filament: 2 @240 °C

114/114 unit tests, 10/10 Playwright e2e, typecheck, lint and production build clean; smoke-test.mjs passed every scenario across repeated runs (it failed 3 until the plate-origin fix). Single-entry arrays still collapse to a plain string wherever that is lossless (see §9), so single-extruder profiles keep their previous shape, and settings saved by an older build still load.

🤖 Generated with Claude Code

Closes the engine half of #140. A genuine multi-nozzle machine profile (Bambu
Lab H2D, nozzle_diameter with two entries) now slices, including a real
dual-extruder plate with per-object filament assignment.

Three separate defects had to go, none of them where the issue assumed:

1. Multi-value options were serialized with the wrong separator. The
   passthrough joined every array with ',', but the separator is a property of
   the option's *type*: coStrings uses ';' with c-style escaping, and
   coPointsGroups uses '#' between groups (',' only separates points inside a
   group). An H2D stores one printable area and one colour per nozzle, so
   comma-joining fused each pair into a single entry. The engine then had
   nozzle_diameter of length 2 with length-1 companions and indexed them by
   extruder id, dying in Brim.cpp's outer_inner_brim_area() and ToolOrdering's
   flush-matrix lookup. The join now happens in the bridge, which reads the
   separator from the engine's own option registry instead of a hand-kept list
   that could drift from the engine version we compile against.

2. Multi-value fields that are also UI-mapped never reached the engine at all.
   ORCA_FIELD_MAP collapses an array to one scalar for display, so
   nozzle_diameter ["0.4","0.4"] became 0.4 and a dual-nozzle machine was
   sliced as single-nozzle. Those are now forwarded as arrays as well; the
   scalar still drives the UI.

3. Print::m_origin (the plate offset) is an Eigen Vec3d with no default member
   initializer, and only the desktop GUI's PartPlate code ever calls
   set_plate_origin(). Headless it stayed uninitialised, and
   PrintInstance::shift_without_plate_offset() subtracts it from every instance
   shift — so Brim.cpp translated brim polygons by garbage and Clipper rejected
   them ("Coordinate outside allowed range"). Being uninitialised memory it was
   layout-dependent: it surfaced only for some meshes and flipped on unrelated
   edits, which is why the synthetic icosphere in smoke-test.mjs started failing
   after a change that never executed. Zeroed explicitly, exactly as
   set_is_bbl_printer() already does for the other uninitialised Print member.

With those fixed, isMultiExtruderProfile() has nothing left to guard and is
removed — the crash it protected against was our own serialization, not the
engine.

Verified against a locally built engine: an H2D plate with two objects on
different filaments emits real T0/T1 tool changes with 78k extrusion moves on
each physical extruder, and the exported config reads nozzle_diameter=0.4,0.4 /
filament_type=PLA;PLA / extruder_printable_area with both areas. smoke-test.mjs
passes all scenarios across repeated runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ GitHub Pages snapshot: https://Hiosdra.github.io/OrcaWeb/previews/pr-160/

Aktualizowany po każdym pushu do tego PR-a.

github-actions Bot added a commit that referenced this pull request Jul 22, 2026
Hiosdra

This comment was marked as outdated.

Hiosdra and others added 3 commits July 22, 2026 18:24
Adds the UI half of #140 that is not blocked on the multi-nozzle engine
work: assigning each object on a plate to a filament slot.

The engine side already shipped — orc_slice_multi takes an extruder_ids
array and applies it to OrcaSlicer's per-object `extruder` key (the
single-nozzle multi-material / AMS-style path), and the worker and
wasm-loader already forward it. The only gap was that slicePlate() never
populated it and nothing in the UI could pick a slot.

- QueueItem gains `extruderId` (1-based slot, undefined/0 = inherit).
- QueueItemCard renders a Filament picker, but only when the config
  actually has more than one slot (a multi-material config encodes them
  as a comma/semicolon-joined filament_type, e.g. "PLA,PETG").
- slicePlate() builds the parallel extruderIds array via the new pure
  buildPlateExtruderIds() helper, and omits the field entirely when no
  object is assigned — so a single-material plate posts the exact same
  SLICE_MULTI message as before.
- Reassigning a slot marks an existing plate result stale, mirroring the
  rule CONFIG_CHANGED already applies to settings edits. Per-item results
  are deliberately left alone: extruderIds only reach the engine through
  slicePlate().

This is the single-nozzle multi-material path only. Real multi-nozzle
printers stay blocked by isMultiExtruderProfile() — see #140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes #140's UI half. The queue could already assign an object to a
filament slot, but nothing could create a second slot — so the picker only
ever appeared for a config that arrived by import.

The Filament section is now a list: add a slot, pick a material per slot,
remove one. A single slot renders and behaves exactly as the old single
"Material" dropdown did.

Turning slots into something the engine understands is the substantive part.
Its filament count is not filament_type (that is display text, see #155) but
the length of filament_colour, and several vectors have to agree with it or
the engine indexes past the end of one:

- filament_colour gets one distinct colour per slot, which is what establishes
  the count in the first place.
- filament_map assigns each slot to a physical extruder, spread round-robin
  over the available nozzles: every slot lands on extruder 1 for an AMS-style
  single-nozzle printer, while a dual-nozzle machine alternates — that is what
  produces real T0/T1 tool changes rather than one nozzle doing all the work.
- flush_volumes_matrix holds one N x N sub-matrix per nozzle laid end to end,
  and flush_multiplier one entry per nozzle. GCode::append_full_config()
  divides the former by the latter to validate the shape and refuses anything
  else ("Flush volumes matrix do not match to the correct size!").

The slots are applied after the config layers are merged, not inside
buildConfig(): an imported machine profile is the only way a real multi-nozzle
printer reaches us today, and it only becomes visible once the imported layer
is combined. Deriving the nozzle count any earlier would put both slots on
nozzle 1 of a dual-nozzle machine and silently waste the second one.

Verified against a locally built engine, driving the config the UI actually
produces. Single-nozzle P1S with two slots: filament_map=[1,1], 4-entry matrix,
and the G-code reports both filaments used (3595.62 / 3708.34 mm). Imported
dual-nozzle H2D with two slots: filament_map=[1,2], 8-entry matrix, physical
T0 and T1 tool changes with both filaments consumed.

Settings saved by an older build still load — `filament` is kept alongside the
new `filaments` list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Real multi-nozzle printer support and user-definable filament slots are a
feature boundary rather than a routine patch, so this takes the minor rather
than letting deploy.yml auto-increment the patch. Standard semver reset: minor
up, patch back to 0.

deploy.yml's auto-bump step respects a version that already differs from the
latest tag, so it will tag and deploy v0.8.0 as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Hiosdra Hiosdra changed the title fix(engine): make real multi-nozzle printers work feat: real multi-nozzle printers + user-definable filament slots (v0.8.0) Jul 22, 2026
github-actions Bot added a commit that referenced this pull request Jul 22, 2026
… a re-slice

Reported: changing an object's filament slot on the Slice tab after slicing
did not offer to re-slice the way a settings change does.

The missing indicator was the visible half. The reason it was missing is that
the assignment genuinely did nothing outside a plate slice: only
orc_slice_multi takes a per-object filament, so a single-item slice went
through orc_slice and silently printed with the default filament. Marking the
item stale on its own would have been worse than saying nothing — re-slicing
would have produced identical G-code.

So both halves are fixed:

- An item that has a slot assigned is now sliced as a single-object plate,
  which is the only entry point that carries the assignment. The message
  protocol is unchanged; the worker just picks the other engine call.
- Reassigning a slot marks that item's own result stale (and, as before, the
  plate's), so the Slice button offers to re-run it — and re-running now
  really does produce different output.

Sizing the per-filament G-code hooks is what makes any slot but the first
usable at all. The engine reads filament_start_gcode with
.at(filament_id) (GCode.cpp), so a vector left at its one-entry default threw
std::out_of_range the moment anything printed with filament 2: assignments of
[1] and [1,2] worked while [2] and [2,2] failed. Both hooks are now emitted
one-per-slot.

Verified against a locally built engine with two slots at different
temperatures (PLA 220 / TPU 240): slot 1 emits "; filament: 1" at 220C and
slot 2 emits "; filament: 2" at 240C, and every assignment combination that
previously crashed now slices. smoke-test.mjs still passes all scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Jul 22, 2026
…gnments

Review follow-ups on the filament-slot work in this PR. Three of these are
silent — nothing in the UI showed the wrong state.

withFilamentSlots() built every slot's per-filament values from
FILAMENT_PRESETS, including slot 0. Those arrays land in _passthrough, which
the worker spreads *over* the resolved config, so adding a second slot
silently reverted a hand-typed nozzle or bed temperature back to the preset
value while the panel kept showing the edited one — undoing #159 for any
multi-slot config. Slot 0 now reads the resolved config; slots 1+ keep taking
their stock preset.

The same function overwrote filament_start_gcode/filament_end_gcode with
empty strings. It has to size them (the engine reads them with
.at(filament_id)), but blanking them drops a preset's own start G-code, so
pad the existing value across the new slots instead.

The queue's picker offered a slot per entry of a multi-value filament_type,
which an imported multi-material profile can carry without the UI having
defined any slots — the vectors were then sized for one filament while the
picker offered several, which is the .at(filament_id) throw the code's own
comment warns about. withFilamentSlots() now sizes for max(UI slots,
declared), and both the picker and the queue read one shared
filamentSlotLabels() so the two counts cannot disagree.

Removing a slot left assignments naming a slot that no longer exists. Below
two slots the picker disappears entirely, so they were invisible, but
buildPlateExtruderIds still sent them and the engine indexed its per-filament
vectors out of range. CONFIG_CHANGED now drops them.

selectedFilaments was missing from the settings-persistence effect's deps, so
adding, removing, or editing any slot but the first was never saved — it only
reappeared if slot 0 later changed. Saved user presets stored slot 0 alone and
loaded it back over the current list, leaving a "complete selection" partly
applied; they now carry the whole list. Both were biome
useExhaustiveDependencies warnings, as was the pre-existing `let sv`; master
is warning-free, so these are now too.

"+ Add filament slot" duplicated the last material, giving two slots the
engine purges 280mm³ between for nothing and two identical picker labels — it
now picks the first unused material. Adding or removing a slot no longer
sheds an imported filament profile either; only a change to slot 0, the slot
that layer actually describes, does.

Docs: status.md still listed real multi-nozzle as deliberately blocked, and
three comments (plus one added by this PR) pointed at the deleted
isMultiExtruderProfile(). smoke-test.mjs gains the dual-nozzle scenario this
PR is about — two objects mapped to different nozzles, asserting both T0 and
T1 appear. Verified against a local engine build, including a negative control
that fails with "saw [0]" when the slots collapse to one filament.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Jul 22, 2026

@Hiosdra Hiosdra left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of the whole PR at 58bd224 — the previous review covered only the engine commit and is now hidden as outdated (its three concerns are all addressed: status.md and the four dangling isMultiExtruderProfile() references are gone, let sv is gone, and CI is green).

The engine half is convincing and, more importantly, is now defended by CI rather than by a paragraph in the PR body. The dual-nozzle scenario in smoke-test.mjs runs on both engine variants and passes:

[smoke-test] [synthetic icosphere] plate: 2 objects on a real dual-nozzle machine (T0 + T1) ... PASS
[smoke-test] [Voron Design Cube v7] plate: 2 objects on a real dual-nozzle machine (T0 + T1) ... PASS
[smoke-test] all scenarios passed          # build (mt) and build (st)

That closes the biggest gap the last review flagged. assertToolChanges() asserting both T0 and T1 is the right assertion — a config that silently collapses to one filament still slices and still passes assertSaneGcode(), so a plain "did it slice?" check would not have caught the regression this PR is about.

Locally on 58bd224: 89/89 unit tests pass, tsc --noEmit clean, biome check reports the same 10 pre-existing infos as master (all in scripts/, none in src/).

Strengths

  • json_array_to_config_string() reading the separator from print_config_def instead of mirroring a type table in JS remains the right call — it cannot drift from the vendored engine version.
  • set_plate_origin() mirrors the existing set_is_bbl_printer() pattern for the same class of bug, and the comment explains why it was flaky (stack-garbage-dependent, reproduces only for some meshes) — non-obvious from the code alone, and worth keeping.
  • CONFIG_CHANGED carrying slotCount is the right place to drop assignments naming a removed slot: it's the only point where the count can actually change, and below two slots the picker is gone so nothing else could clear them. Four focused reducer tests, including the "drops every assignment when back down to one slot" case, which is the non-obvious one.
  • Slot 0 reading the resolved config rather than FILAMENT_PRESETS (58bd224) is the correct fix for the #159 interaction, and the test spells out why (_passthrough is spread over the resolved config) rather than just asserting the value.
  • padHook() sizing the G-code hooks without blanking them, and nextSlotMaterial() picking the first unused material — both are small, but both are the difference between "works" and "works without quietly degrading something".
  • buildPlateExtruderIds() returning undefined when nothing is assigned, so a single-material plate posts a byte-identical SLICE_MULTI message. Good instinct for not churning the common case; same instinct as the single-entry-array collapse in profiles.ts.

Concerns

Three findings, all in withFilamentSlots()/parseOrcaProfileJson() and all on the imported multi-material profile path — i.e. the path declaredSlotCount() was added specifically to serve. Details are in the inline comments; summarised:

  1. Slot 0's filament_type is the flattened scalar. An imported dual-material profile produces filamentSlotLabels()["PLA,PETG", "PETG"], so the picker reads Slot 1 · PLA,PETG and the engine receives that literal string as slot 1's filament type. Reproduced on this branch.
  2. Imported per-slot values are overwritten, not padded. filament_colourSLOT_COLOURS, filament_diameter → hardcoded 1.75, hot_plate_temp → stock preset, flush_volumes_matrix → uniform 280. Same class as the slot-0 bug 58bd224 just fixed, one level further down the list. Reproduced.
  3. [""] now reaches the passthrough where master dropped it, so an imported layer can blank a lower layer's value. Reproduced; impact plausible rather than demonstrated.

(1) and (2) are worth fixing before merge — not because they crash, but because they're silent, and "silent" is exactly what the last two commits of this PR were spent hunting.

Smaller notes, none blocking

  • Colours wrap past 8 slots. MAX_FILAMENT_SLOTS caps the UI at SLOT_COLOURS.length, but declaredSlotCount() is uncapped and SLOT_COLOURS[i % length] wraps. A 10-filament import gets two pairs of identically-coloured slots. The engine's count comes from values.size() so it still works, but the picker shows duplicates — and the file's own doc says a slot's colour is what makes it a distinct slot.
  • nozzle_temperature_initial_layer / hot_plate_temp_initial_layer are derived from the main temps. Deliberate for a UI-defined slot, but it also discards an imported profile's genuinely different initial-layer values. Same family as (2).
  • A single item with a slot assigned now takes a different geometry path. orc_slice_multi arranges via arrange_objects(); orc_slice uses center_object_xy_only(). So the same item can land at a different bed position depending only on whether a slot is picked — the type doc explains why the call changes, but not that placement can move with it. Worth a sentence.
  • No e2e coverage for the new UI. data-testid="add-filament-slot" and data-testid="extruder-select" were added but nothing in e2e/ uses them. The reducer and profiles.ts coverage is genuinely good, so this is about the wiring (picker appears only above one slot, assignment survives a re-render) rather than the logic.

Assessment

The engine work is sound and now has the committed regression test it was missing — that was the last review's main ask and it's properly done, negative control and all. The UI half is well-tested for the UI-driven path.

What's left is that the imported multi-material path — newer, and the reason declaredSlotCount() exists — silently replaces values the user imported with derived defaults. Findings 1 and 2 sit in the same function that commit 58bd224 just fixed for slot 0, which suggests the fix stopped one slot short. Both are small changes; I'd want them before merge. Everything else is a note.

🤖 Generated with Claude Code

Comment thread src/lib/profiles.ts Outdated
Comment thread src/lib/profiles.ts Outdated
Comment thread src/lib/profiles.ts Outdated
Review follow-ups, all on the imported-profile path — the one
declaredSlotCount() was added to serve, and the one the previous round of
fixes stopped one slot short of.

withFilamentSlots() rebuilt every per-filament vector from scratch, so a
profile that arrived declaring its own filament colours, diameters, per-slot
temperatures or flush matrix had them silently replaced by stock preset values
the moment the slots were sized. That is the same shape as the slot-0 bug
fixed in 58bd224: _passthrough is spread *over* the resolved config, so
anything written here beats what the user actually imported. Each option now
prefers the value the config already carries for that slot. The two exceptions
stay explicit: slot 0 of an option the settings panel edits still comes from
the resolved config (a manual override lives there — #159), and a slot the UI
names still takes the material the user picked in the panel over whatever an
older import said about it.

The vectors whose *length* the engine validates cannot be honoured entry by
entry — a flush matrix sized for the profile's own filament count is what
append_full_config() rejects outright — so those are kept only at exactly the
shape this slot and nozzle count require, and filament_map additionally only
when it names nozzles this machine actually has.

Slot 0's filament_type could not be read off the resolved config at all:
ORCA_FIELD_MAP collapses a multi-value option to one scalar for display, so an
imported dual-material profile left config.filament_type as the joined
"PLA,PETG". That reached the engine as slot 1's type and showed up in the
queue's picker as "Slot 1 · PLA,PETG". Taken from the unflattened passthrough
array instead.

Two smaller ones from the same review:

- parseOrcaProfileJson stored a single-entry [""] as "" where the old
  comma-join dropped it. Real leaf profiles carry [""] for every option they
  inherit rather than override, and buildConfig() merges _passthrough field by
  field with later layers winning — so an import could blank out a built-in
  preset's value for that key. Multi-entry arrays keep their empties, where the
  length is load-bearing.
- Colours past the hand-picked palette repeated it (reachable only when an
  import declares more slots than the UI offers); they now walk the hue circle
  by the golden angle so every slot stays distinct.

Documented that an assigned single slice goes through orc_slice_multi's
arrange_objects() rather than orc_slice's centring, so picking a slot can move
the object on the bed.

e2e covers the slot list and picker wiring for the first time: add a slot, see
the picker appear with one option per slot, assign, remove the slot, and find
the assignment dropped rather than left naming a filament the engine no longer
has. Verified against a negative control that fails with `unexpected value "2"`
when CONFIG_CHANGED stops dropping it.

99 unit tests, 10 e2e, typecheck, lint, production build and smoke-test.mjs
(including the dual-nozzle scenario) all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Jul 22, 2026

@Hiosdra Hiosdra left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of the whole PR at 98db439. The three threads from the previous round are all fixed and resolved; everything they were about now has a test.

I re-derived the engine claims against the pinned OrcaSlicer source in orca-wasm/orca/ rather than taking the PR body's word for them, and they hold up:

  • padHook() is genuinely load-bearing. ConfigOptionVector::get_at() clamps (Config.hpp:624), so the filament_start_gcode reads at GCode.cpp:780/:1063 would not have thrown — but GCode.cpp:3150 uses values.at(initial_non_support_extruder_id) (which does throw std::out_of_range) and :3174 uses a raw values[initial_extruder_id] (out-of-bounds read, worse). The PR body's claim is right, and for a slightly better reason than it gives.
  • The plate-origin fix is correctly ordered. set_plate_origin(print) lands after apply() but before validate()/process(); the only pre-process() reader of m_origin is sequential_print_clearance_valid() (Print.cpp:613-1003), reached through validate(). I checked m_plate_index for the same class of bug — it has { 0 } (PrintBase.hpp:558), so there is no third landmine of this shape.
  • coPointsGroups and escape_strings_cstyle() both exist in the pinned engine, and reading the separator out of print_config_def instead of mirroring a list on the JS side is the right call — that is the part of this PR that cannot silently drift.
  • deploy.yml:150-165 really does keep a package.json that is strictly ahead of the latest tag, so the v0.8.0 bump will tag and deploy as-is.
  • The #141 / isMultiExtruderProfile() cleanup is complete — no references left anywhere in the repo, and #141 is already closed.

Local: 99/99 unit tests, typecheck clean, biome clean (the 10 remaining infos are pre-existing unsafe-fix suggestions), 10 e2e collected. CI has e2e / typecheck-build / docs green; build (mt) and build (st) — the ones that actually run the new dual-nozzle smoke scenario — were still pending when I looked.

Findings

Two real ones, both the same shape as the bug §6 and §7 already fixed twice: _passthrough is spread over the resolved config, so anything withFilamentSlots() writes beats what is underneath it. Both are silent — the panel keeps showing the value the engine is not using.

  1. filament_diameter reverts to 1.75 mm for any slot the UI names past the first. Confirmed on this branch: an import declaring ["2.85","2.85"] survives as ["2.85","2.85"] with one UI slot, and becomes ["2.85","1.75"] the moment a second slot is added. E values off by (2.85/1.75)² ≈ 2.65×. Inline below.
  2. hot_plate_temp_initial_layer is missing from PANEL_EDITED_SLOT_OPTIONS, but toEngineConfig() writes it from the same "Bed temp" field as hot_plate_temp. Typing 60 with an import that declares 80 gives hot_plate_temp: ["60","80"] and hot_plate_temp_initial_layer: ["80","80"] — a single-slot config puts 60 in both. Inline below.

And one cosmetic note on slotColour().

Everything else I went looking for came back clean: filamentSlotLabels() cannot disagree with what withFilamentSlots() sized (both go through declaredSlotCount()), buildPlateExtruderIds() omits the field entirely for an unassigned plate so the old SLICE_MULTI message is byte-identical, CONFIG_CHANGED's slotCount < 2 drop is right (a lone "slot 1" would otherwise pin the plate to the assigned code path with no picker to clear it), and the bridge's extruder_ids[i] > 0 guard matches the 0 = inherit contract the JS side encodes.

Nits

  • slicer.cpp:349 — the static free function set_plate_origin(Print&) has the same name as the Print member it wraps. zero_plate_origin() would read better beside set_is_bbl_printer(), and would not make the call site look like it is passing an origin.
  • smoke-test.mjs's DUAL_NOZZLE_CONFIG is a hand-written copy of what withFilamentSlots() produces, so it will not catch a drift between the two. Deliberate and documented (same reason as BASE_CONFIG), but worth an assertion in profiles.test.ts that pins the shape withFilamentSlots() emits for a 2-slot / 2-nozzle config against that literal, so the scenario the smoke test proves is the scenario the app actually builds.

Comment thread src/lib/profiles.ts Outdated
Comment thread src/lib/profiles.ts Outdated
Comment thread src/lib/profiles.ts Outdated
… first-layer bed temp

Review follow-ups. Both are the same shape as §6 and §7 — `_passthrough` is
spread *over* the resolved config, so anything withFilamentSlots() writes beats
what is underneath it — and both are silent: the panel keeps showing the value
the engine is not using.

filament_diameter went through per(), whose "a slot the UI names wins" rule
exists because the user picked a *material* in the panel, so that material's
stock preset should outrank an older import. That reasoning only holds while
`pick` actually reads the slot's material. No FILAMENT_PRESETS entry carries a
diameter, so it was passed `() => undefined` and the derived value was the bare
literal 1.75 — the branch had nothing of its own to contribute and could only
overwrite. An import declaring 2.85 survived at one UI slot and became
["2.85","1.75"] the moment a second was added, which is the normal way to use a
dual-material import and the one path §7 stopped short of. Every E value on
that slot is then off by (2.85/1.75)². It joins filament_colour on the new
declaredOr() helper: declared value first for every slot, constant only where
nothing said otherwise. per()'s doc now states the precondition that makes its
middle branch sound, so the next option added there has to meet it.

hot_plate_temp_initial_layer was missing from PANEL_EDITED_SLOT_OPTIONS even
though the panel edits it: toEngineConfig() fans the single "Bed temp" field out
to both hot_plate_temp and hot_plate_temp_initial_layer. A single-slot config
therefore put the typed number in both, while adding a slot left the first layer
on the imported profile's temperature — the #159 interaction the set was
introduced to close, one option down the list, and on the layer where bed
temperature actually decides adhesion. nozzle_temperature_initial_layer stays
out for the same rule read the other way: toEngineConfig() never writes it, so
no panel value can contradict a declared one. The set's doc now says "whatever
toEngineConfig() writes from a panel field" rather than "options the panel
edits", which is what made the omission look deliberate.

Verified both with negative controls: reverting either fix alone fails exactly
its own new test and nothing else.

Also from the review, no behaviour change:

- slotColour()'s HSL ramp was discontinuous at both joins. The slope has to be
  6·(max−min) = 3.804 for the given endpoints; it was 1.932, so the value jumped
  0.560 → 0.872 at t=1/6 and 0.550 → 0.238 at t=2/3, and the stated S=0.62/L=0.55
  described neither endpoint. Saturation and lightness are now the inputs and
  chroma/min/max/slope are derived from them, so the comment cannot drift from
  the arithmetic again. Reachable only past slot 8 (an import declaring more
  filaments than the UI offers).
- The bridge's static set_plate_origin() had the same name as the Print member
  it wraps, making the call site read as if it passed an origin — renamed to
  zero_plate_origin(), which is also what it has in common with
  set_is_bbl_printer() next to it. Compiles clean against the local engine build.
- smoke-test.mjs's DUAL_NOZZLE_CONFIG is a hand-written copy of what
  withFilamentSlots() emits (a node script cannot import the TS module), so it
  could keep proving T0/T1 for a config the app no longer builds. A new test
  pins the option set and every shape the engine validates against that literal.

102 unit tests, 10 e2e, typecheck, lint and the bridge object all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Jul 22, 2026

@Hiosdra Hiosdra left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fourth pass, on c398493. Reproduced locally against the branch head: 102/102 unit tests, tsc -b, biome check (10 pre-existing infos, all in scripts/, none from this PR) and the production build are clean, and CI is green on all seven jobs. All six earlier review threads are resolved and outdated — nothing is left open from §6–§8.

The substance holds up, and the §7/§8 rounds closed the interesting ones. Spot-checking the parts that are hard to test:

  • json_array_to_config_string() reading the separator out of print_config_def rather than a JS-side table is the right shape — that is the one thing that cannot drift against the engine version the bridge is compiled with. escape_strings_cstyle() quotes empty elements as "", so filament_start_gcode: ["", ""] survives orc_init's if (sv.empty()) continue and still sizes the vector to 2, which is what the smoke test depends on.
  • slotColour()'s ramp is now continuous by construction: q - p == chroma, and max - 6·chroma·(t - ½) is identically min + 6·chroma·(⅔ - t), so both joins meet. Channel order (h+⅓, h, h+⅔) is the standard R/G/B with h-⅓ ≡ h+⅔ (mod 1), and hue ∈ [0,1) keeps the single % 1 sufficient. No bug here — the §8 rewrite is correct.
  • zero_plate_origin() lands after apply() but before process(), which is where shift_without_plate_offset() is actually read, so the ordering is fine.
  • Int32Array.from([0, bytes.length]) matches the bridge's documented [start, len] pair layout for n_files = 1.

Four things below. The first is the same class §5 was about — an assignment that visibly changes nothing — but on the in-flight path rather than the already-sliced one, and it is the only one I would call blocking.

Comment thread src/hooks/useSliceQueue.ts
Comment thread src/components/SliceCards.tsx
Comment thread src/lib/profiles.ts
Comment thread src/lib/profiles.ts Outdated
Four review fixes. The first is the same shape as the round that made a
post-slice reassignment offer a re-slice, one step earlier: an assignment
made *during* a slice was dropped, and the result came back looking current.

- **A reassignment mid-slice was lost.** ASSIGN_EXTRUDER only flagged an
  item already `done`, and the plate only when it already had G-code —
  which PLATE_STARTED clears for the duration. SLICE_DONE/PLATE_DONE then
  overwrote `stale` from the configEpoch comparison, and an assignment
  never touches configSnapshotRef, so it has no epoch to be caught by.
  Both DONE cases now OR in a flag the assignment sets directly; RUN_QUEUE
  clears it before re-slicing, so anything surviving into DONE arrived
  during the run. Bumping configEpoch instead would leave the snapshot ref
  one behind forever and mark every later result stale.

- **The config summary never counted panel-defined slots.** Its "Material"
  row split the display scalar, which for UI slots is slot 0's material
  alone — so it read "PLA" beside a picker offering three, and its own
  "(N slots)" branch only fired for an import whose flattened scalar
  happened to contain a separator. It reads filamentSlotLabels() now, the
  same count the picker and CONFIG_CHANGED already share.

- **A lone value the collapse would corrupt now stays an array.** The
  bridge routes arrays through escape_strings_cstyle(), which quotes any
  element containing ';'; a bare string reaches the engine unquoted and is
  split on every ';'. A filament_start_gcode of ["M900 K0.02 ; set K"]
  therefore arrived chopped in two, with only the first fragment run —
  padHook() already repaired this for two slots and not for one. The
  option's type is not visible here by design, so key off the value:
  anything a quoting pass would touch keeps the array form, which is never
  less correct and is the shape the source profile used anyway.

- **filamentSlotLabels() no longer promotes display text to a slot count.**
  Its single-slot fallback handed back filamentSlots()'s [;,] split, so a
  profile carrying a plain "PLA;PETG" offered a Slot 2 that
  withFilamentSlots() — reading the same declaredSlotCount() — had sized
  no per-filament vectors for.

Reverting any one fix fails exactly its own new test and nothing else.
110/110 unit, 10/10 e2e, typecheck, lint and production build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Jul 22, 2026
status.md was updated when real multi-nozzle profiles started working, but
three other places still describe them as a known crash risk and attribute
that crash to the engine rejecting our single-extruder config. The actual
cause was our own serialization joining every multi-value option with ','
regardless of the separator the option's type requires.

- api-reference.md called orc_slice_multi's extruder_ids "not a multi-nozzle
  machine"; it names a filament slot either way, and filament_map decides.
- ADR-009 said the smoke test deliberately avoids a real multi-nozzle
  nozzle_diameter array — #160 added exactly that scenario, so the list gains
  it and the scenario x mesh count goes 8 -> 10.
- ADR-008 and fetch-orca-profiles.ts pointed at the status.md section #160
  deleted. The generator's second reason for stripping _passthrough from the
  bundled presets is the resolved crash; only the PlaceholderParser one still
  stands, so the comment now says so rather than reading as two live blockers.

Comments and docs only — no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/lib/profiles.ts
Comment thread src/hooks/useSliceQueue.ts Outdated

@Hiosdra Hiosdra left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Fifth pass, on the full PR at 42fdc8f (plus aec46ce, the docs commit this review pushed — see below). The four threads from the previous round are fixed and resolved, each with the test its reply claims.

Reproduced locally against the branch head: 110/110 unit tests, tsc --noEmit clean, biome check exit 0 (10 pre-existing infos, all in scripts/, none from this PR). CI is green on e2e (10/10), typecheck-build and docs; the two engine builds were still running when this was written.

The architecture holds up. _passthrough being spread over the resolved config is the hazard the last four rounds all circled, and it now has a stated priority order in one place (per() / declaredOr() / declaredIfSized()) instead of being rediscovered per option. declaredSlotCount() as the single count both withFilamentSlots() and filamentSlotLabels() read is what makes the picker/vector agreement checkable rather than coincidental.

Checked against a real engine, and clean

Built engine, same bridge as HEAD (only the set_plate_originzero_plate_origin rename since). The gap I expected to find and did not:

A dual-nozzle machine profile imported while the panel still has one filament slot. withFilamentSlots() returns at n < 2 and sizes nothing, so nozzle_diameter is length 2 while every per-filament vector stays at the engine's length-1 default — the exact "length 2 with length-1 companions" shape §1 says died in Brim.cpp. No test covers it, and DUAL_NOZZLE_CONFIG always carries both.

[A] dual nozzle_diameter, single filament — orc_slice ......................... OK (664603 bytes)
[B] dual nozzle_diameter, single filament — orc_slice_multi (2 objs) .......... OK (1305012 bytes)
[C] control: single nozzle, single filament — orc_slice ....................... OK (663497 bytes)

It slices. The engine tolerates a short companion vector where it does not tolerate a fused one, which is a sharper statement of §1 than "the arrays must agree" — worth knowing, not worth a fix.

Two findings

  1. Slot 0 mixes the import's filament_type with the UI material's temperaturesprofiles.ts:204. A slot announced to the engine as PLA and extruded at 270 °C. Silent; the panel and the picker each show real state and disagree. Narrow trigger (an import declaring per-slot types but not per-slot temperatures), and the fix is a policy choice between two consistent behaviours, so it is written up rather than pushed.
  2. Removing a slot from the middle re-points assignments at a different materialuseSliceQueue.ts:267. Same family as the out-of-range case already handled, but every id stays in range so nothing drops it. Prints the wrong filament rather than indexing out of bounds.

One note, no action needed

With an import declaring more filaments than the panel has slots, "Remove filament slot" reduces the Settings list but not the Slice tab's picker — n = max(ui.length, declaredSlotCount(config)) keeps the import's count. Correct (the profile really does declare those filaments) and documented in withFilamentSlots(), but the button reads as a no-op from the Slice tab. Only reachable via an import, so it is a wording problem at most.

Pushed: aec46ce

status.md was updated in §6; three other places were not, and still described real multi-nozzle profiles as a known crash risk caused by the engine rejecting our single-extruder config — the diagnosis §1 replaces. api-reference.md, ADR-009 (which also gains the new dual-nozzle scenario and the corrected 8 → 10 count), ADR-008, and fetch-orca-profiles.ts, whose second reason for stripping _passthrough from the bundled presets is the resolved crash. Comments and docs only; docs CI is green on it. Written up as §10 in the description.

Verdict

Approve once finding 1 is settled — it is the only one that can spoil a print, and it needs a decision rather than a patch. Finding 2 can follow separately.

… filament

Filament slots are positional — index is the identity, for the engine's
per-filament vectors and so for extruderId too. CONFIG_CHANGED dropped an
assignment only when the slot count fell below two or the id ran past the end,
which are both the "removed the last slot" shape. Removing one from the middle
renumbers every slot after it while leaving every id in range:

  slots            [PLA, PETG, ABS]     object -> slot 2 (PETG)
  remove slot 2 -> [PLA, ABS]           id 2 <= 2, kept
                                        object -> slot 2, which is now ABS

The item is marked stale like any config change, so it is re-sliced — with the
wrong material. The picker keeps reading "Slot 2 · ABS" and never reports that
it used to mean something else, so nothing distinguishes this from the user
having picked ABS. It prints wrong rather than indexing out of bounds, which is
why no other check caught it.

CONFIG_CHANGED now carries the slot labels instead of just the count, and the
reducer keeps the previous list. A label changing on its own is the user
re-picking that slot's material, which is deliberate and must leave the
assignment alone; a label changing *because the list got shorter* means the
position now describes a different filament. Both conditions together, so the
fix cannot degrade into "any removal clears everything" — which has its own
control test, alongside one for a re-pick and one for an assignment the removal
did not move.

Dropping to Auto rather than following the material to its new index is
deliberate: the picker is right beside it, and a wrong slot is worse than an
unset one.

Reverting the label clause alone fails exactly the new test and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github-actions Bot added a commit that referenced this pull request Jul 22, 2026
@Hiosdra
Hiosdra merged commit 252da17 into master Jul 22, 2026
8 checks passed
@Hiosdra
Hiosdra deleted the feature/multinozzle-engine branch July 23, 2026 05:22
Hiosdra added a commit that referenced this pull request Jul 23, 2026
* feat: prime (wipe) tower for multi-material prints (#163)

Multi-material slicing (#160) configures per-filament flush volumes but
nothing gave the purge anywhere to go. This adds the prime tower.

Config half (src/lib/profiles.ts, types, SettingsPanel):
- Model enable_prime_tower + prime_tower_width; default the tower ON for a
  multi-slot config (the engine's own default is off) with a Settings-panel
  toggle + width field, shown only for multi-material.
- withPrimeTowerAddressing() also emits the two prerequisites the engine
  hard-requires for a wipe tower: use_relative_e_distances=1 and a per-layer
  "G92 E0" reset in before_layer_change_gcode (else Print::validate fails -6
  on a Marlin/non-Bambu printer). Only when the tower is on, so no-tower
  multi-material keeps the bridge's absolute-addressing default.
- ORCA_FIELD_MAP entries so the settings import/export round-trip.

Placement half (orca-wasm/bridge/slicer.cpp):
- clamp_wipe_tower_to_bed() ports desktop OrcaSlicer's GUI-only
  set_default_wipe_tower_pos_for_plate into the headless slice path, where
  the mesh (and thus the tower's height-driven footprint) is known. Anchors
  at desktop's default (165, 250), estimates the rib-wall footprint, and
  clamps onto the bed. Matches desktop exactly where it fits (a 350mm Voron)
  and keeps the tower on-bed on shallow beds (Prusa MK4 250x210, Ender 3
  220x220) where the raw engine default (15, 220) hangs off the back edge.

Tests:
- profiles.test.ts: default-on, explicit-off, import-declared, addressing +
  reset companions, export round-trip.
- smoke-test.mjs: prime tower added to the dual-nozzle config, plus a new
  shallow-bed scenario asserting on-bed placement (guards the bridge clamp).

Verified against a locally rebuilt v0.8.1 engine: all smoke scenarios pass,
tower position matches desktop, no regression vs a pristine build.

Single-nozzle mixed-temperature filaments hit an orthogonal engine guard,
tracked separately in #164.

* fix: don't leak the prime tower onto the single-object orc_slice path

Review follow-up for #163.

A single object with no assigned filament slot slices through orc_slice,
which prints it with the default filament and never calls the new
clamp_wipe_tower_to_bed (only orc_slice_multi does). When the app has >=2
filaments, that config still carried enable_prime_tower=1, so the engine could
build a tower at the raw, unclamped PrintConfig default — the exact off-bed
placement #163 fixes, reached through a different call site.

Extract the config flatten shared by doSlice/doSliceMulti/doWrite3mf into
flattenSliceConfig(config, hasFilamentSlot) in profiles.ts, and force
enable_prime_tower off when there is no slot. The assigned-slot and plate paths
pass hasFilamentSlot=true and keep the tower (clamped in the bridge); 3MF export
keeps it for the desktop round-trip. 4 new profiles.test.ts cases.

Also document why the max(depth+extra_rib_len, volume_depth) term in
clamp_wipe_tower_to_bed is kept verbatim (structural parity with desktop's
estimate_wipe_tower_size for a clean future re-sync).
Hiosdra added a commit that referenced this pull request Jul 23, 2026
) (#166)

* fix: keep multi-material import in sync with the Material dropdown (#161)

Importing a multi-material profile left the Settings "Material" dropdown on
whatever was picked before, while the Slice tab and engine took slot 0's
material from the import. Slot 0 then took its *type* from the import but its
*temperatures* from the dropdown's material — announced as PLA, extruded at
ABS's 270 °C — with nothing in the UI showing the disagreement.

Root cause: both import handlers matched a preset against the collapsed scalar
`filament_type` ("PLA,PETG"), which matches no FILAMENT_PRESETS key, so
setSelectedFilament never fired and buildConfig kept merging the old material's
preset underneath.

- Fix 1: both import handlers now match on the unflattened
  `_passthrough.filament_type[0]` (firstFilamentType), so the dropdown follows
  the import the way a single-material one already does.
- Fix 2: slotSource() rebuilds slot 0 from the *declared* material's own preset
  whenever the panel's material disagrees with it, so a slot's type and
  temperatures can never describe different filaments — even for a material this
  app ships no preset for (it no longer inherits the panel material's temp).
  Preserves the #159 / #160 §7 behaviour when they agree (manual overrides win).
- Fix 3: importedFilamentSlotLabels() shows a filament imported under a name the
  app has no preset for (e.g. "PETG-CF @hotend") verbatim and read-only in the
  dropdown, mirroring importedPrinterLabel; bare preset keys are left as the
  normal editable dropdown.

Adds 11 tests: slot type/temperature consistency (incl. a partial import and a
no-preset material), and the label derivation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ui): lock the material dropdown for a print-type import instead of snapping back

Review follow-up for #161.

A filament import is shed the moment slot 0 is re-picked, so its labelled
Material dropdown stays live. A print (3MF) import is different: a material pick
doesn't shed it, and slotSource keeps rebuilding the slot from the import's
declared material — so the pick silently snapped back to the import's name and
read as a broken control.

Disable (grey out) the labelled slots while a print-type import is active, with
a tooltip and a one-line hint pointing at the Remove-import (×) control, so the
inert state is legible instead of looking like nothing happened. SelectField
gains optional `disabled`/`title` props. The filament-type and no-import paths
are unchanged.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Real multi-nozzle / multi-extruder printer support

1 participant