feat: real multi-nozzle printers + user-definable filament slots (v0.8.0)#160
Conversation
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 Pages snapshot: https://Hiosdra.github.io/OrcaWeb/previews/pr-160/ Aktualizowany po każdym pushu do tego PR-a. |
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>
… 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>
…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>
Hiosdra
left a comment
There was a problem hiding this comment.
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 fromprint_config_definstead of mirroring a type table in JS remains the right call — it cannot drift from the vendored engine version.set_plate_origin()mirrors the existingset_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_CHANGEDcarryingslotCountis 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 (_passthroughis spread over the resolved config) rather than just asserting the value. padHook()sizing the G-code hooks without blanking them, andnextSlotMaterial()picking the first unused material — both are small, but both are the difference between "works" and "works without quietly degrading something".buildPlateExtruderIds()returningundefinedwhen nothing is assigned, so a single-material plate posts a byte-identicalSLICE_MULTImessage. Good instinct for not churning the common case; same instinct as the single-entry-array collapse inprofiles.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:
- Slot 0's
filament_typeis the flattened scalar. An imported dual-material profile producesfilamentSlotLabels()→["PLA,PETG", "PETG"], so the picker readsSlot 1 · PLA,PETGand the engine receives that literal string as slot 1's filament type. Reproduced on this branch. - Imported per-slot values are overwritten, not padded.
filament_colour→SLOT_COLOURS,filament_diameter→ hardcoded1.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. [""]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_SLOTScaps the UI atSLOT_COLOURS.length, butdeclaredSlotCount()is uncapped andSLOT_COLOURS[i % length]wraps. A 10-filament import gets two pairs of identically-coloured slots. The engine's count comes fromvalues.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_layerare 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_multiarranges viaarrange_objects();orc_sliceusescenter_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"anddata-testid="extruder-select"were added but nothing ine2e/uses them. The reducer andprofiles.tscoverage 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
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>
Hiosdra
left a comment
There was a problem hiding this comment.
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 thefilament_start_gcodereads atGCode.cpp:780/:1063would not have thrown — butGCode.cpp:3150usesvalues.at(initial_non_support_extruder_id)(which does throwstd::out_of_range) and:3174uses a rawvalues[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 afterapply()but beforevalidate()/process(); the only pre-process()reader ofm_originissequential_print_clearance_valid()(Print.cpp:613-1003), reached throughvalidate(). I checkedm_plate_indexfor the same class of bug — it has{ 0 }(PrintBase.hpp:558), so there is no third landmine of this shape. coPointsGroupsandescape_strings_cstyle()both exist in the pinned engine, and reading the separator out ofprint_config_definstead 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-165really does keep apackage.jsonthat 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.
filament_diameterreverts 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.hot_plate_temp_initial_layeris missing fromPANEL_EDITED_SLOT_OPTIONS, buttoEngineConfig()writes it from the same "Bed temp" field ashot_plate_temp. Typing 60 with an import that declares 80 giveshot_plate_temp: ["60","80"]andhot_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 functionset_plate_origin(Print&)has the same name as thePrintmember it wraps.zero_plate_origin()would read better besideset_is_bbl_printer(), and would not make the call site look like it is passing an origin.smoke-test.mjs'sDUAL_NOZZLE_CONFIGis a hand-written copy of whatwithFilamentSlots()produces, so it will not catch a drift between the two. Deliberate and documented (same reason asBASE_CONFIG), but worth an assertion inprofiles.test.tsthat pins the shapewithFilamentSlots()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.
… 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>
Hiosdra
left a comment
There was a problem hiding this comment.
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 ofprint_config_defrather 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"", sofilament_start_gcode: ["", ""]survivesorc_init'sif (sv.empty()) continueand 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, andmax - 6·chroma·(t - ½)is identicallymin + 6·chroma·(⅔ - t), so both joins meet. Channel order(h+⅓, h, h+⅔)is the standard R/G/B withh-⅓ ≡ h+⅔ (mod 1), andhue ∈ [0,1)keeps the single% 1sufficient. No bug here — the §8 rewrite is correct.zero_plate_origin()lands afterapply()but beforeprocess(), which is whereshift_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 forn_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.
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>
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>
Hiosdra
left a comment
There was a problem hiding this comment.
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_origin → zero_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
- Slot 0 mixes the import's
filament_typewith the UI material's temperatures —profiles.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. - Removing a slot from the middle re-points assignments at a different material —
useSliceQueue.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>
* 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).
) (#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>
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:coStringsuses;with c-style escaping,coPointsGroupsuses#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 — leavingnozzle_diameterlength 2 with length-1 companions, indexed by extruder id, dying inBrim.cpp::outer_inner_brim_area()andToolOrdering'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_MAPcollapses arrays to one scalar for display, sonozzle_diameter: ["0.4","0.4"]became0.4— a dual-nozzle machine sliced as single-nozzle.Uninitialised plate origin.
Print::m_originis an EigenVec3dwith no default member initializer, and only the desktop GUI's PartPlate code callsset_plate_origin(). Headless it stayed garbage, andshift_without_plate_offset()subtracts it from every instance shift — soBrim.cpptranslated 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 insmoke-test.mjsbegan 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 offilament_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 countfilament_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, andflush_multiplierone entry per nozzle;GCode::append_full_config()divides the former by the latter to validate the shapefilament_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 firstSlots 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.0as-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_multicarries 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_gcodeper slot is what makes any slot but the first usable at all — assignments of[1]and[1,2]worked while[2]and[2,2]threwstd::out_of_range.6. Follow-up: review fixes
Three of these were silent — nothing in the UI showed the wrong state.
withFilamentSlots()built every slot fromFILAMENT_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.filament_start_gcode/filament_end_gcodewith empty strings; it now pads the existing value across the new slots.filament_type, which an imported profile can carry without the UI having defined any slots.withFilamentSlots()now sizes formax(UI slots, declared), and both the picker and the queue read one sharedfilamentSlotLabels().buildPlateExtruderIdsstill sent them.selectedFilamentswas 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.status.mdstill listed real multi-nozzle as deliberately blocked, and four comments pointed at the deletedisMultiExtruderProfile().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 —_passthroughis spread over the resolved config, so anything written there beats what was imported:filament_colour["#FF0000","#00FF00"]SLOT_COLOURSfilament_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]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_multiplierandfilament_mapare kept only at exactly the required shape — andfilament_maponly when it names nozzles this machine has.Slot 0's
filament_typecould not be read off the resolved config at all.ORCA_FIELD_MAPcollapses a multi-value option to one scalar for display, so an imported dual-material profile leftconfig.filament_typeas the joined"PLA,PETG"— which reached the engine as slot 1's type and showed in the queue's picker asSlot 1 · PLA,PETG. Taken from the unflattened passthrough array instead.Two smaller ones:
parseOrcaProfileJsonstored 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"whenCONFIG_CHANGEDstops dropping it.8. Follow-up: review fixes
Two more of the same shape as §6 and §7 —
_passthroughis spread over the resolved config, so whatwithFilamentSlots()writes beats what is underneath it. Both silent; the panel kept showing the value the engine was not using.filament_diametersurvived 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 whilepickactually reads the slot's material. NoFILAMENT_PRESETSentry carries a diameter, so it was passed() => undefinedand the derived value was the bare literal1.75: the branch had nothing to contribute and could only overwrite. An import declaring2.85kept 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 joinsfilament_colouron a newdeclaredOr()helper, andper()'s doc now states the precondition its middle branch depends on.hot_plate_temp_initial_layerwas missing fromPANEL_EDITED_SLOT_OPTIONS, though the panel edits it:toEngineConfig()fans the single "Bed temp" field out to both it andhot_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_layerstays 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 staticset_plate_origin()is renamedzero_plate_origin()so the call site stops reading as though it passed an origin; andsmoke-test.mjs's hand-writtenDUAL_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.
ASSIGN_EXTRUDERflagged only an item alreadydone, and the plate only when it already had G-code — whichPLATE_STARTEDclears for the duration of the slice.SLICE_DONE/PLATE_DONEthen overwrotestalefrom theconfigEpochcomparison, and an assignment never touchesconfigSnapshotRef, so it has no epoch to be caught by. The picker stays enabled throughout (SliceCardshides it only forconverting/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_QUEUEclearsstalebefore 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. BumpingconfigEpochinstead is the obvious fix and the wrong one:configSnapshotRefwould sit one behind for the rest of the session and mark every later result stale.buildConfig()mergesFILAMENT_PRESETS[slots[0]]; the rest live in_passthrough). So the Slice tab readMaterial: PLAbeside 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 readsfilamentSlotLabels()now — the count the picker andCONFIG_CHANGEDalready share.escape_strings_cstyle(), which quotes any element containing;; a bare string reachesConfigOptionStrings::deserialize()unquoted and itsunescape_strings_cstyle()splits on every;and strips leading whitespace. Sofilament_start_gcode: ["M900 K0.02 ; set K"]arrived chopped in two with only the first fragment ever run — andpadHook()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 newcollapsesCleanly()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 backfilamentSlots()'s[;,]split, so a profile carrying a plain"PLA;PETG"offered a Slot 2 thatwithFilamentSlots()— reading the samedeclaredSlotCount()— had sized no per-filament vectors for. Clamped to one label rather than teachingdeclaredSlotCount()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.mdwas 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.mddescribedorc_slice_multi'sextruder_idsas "the classic single-nozzle multi-material path, not a multi-nozzle machine". It names a filament slot either way;filament_mapis what decides.nozzle_diameterarray. §1 added exactly that scenario, so it joins the list and the scenario × mesh count goes 8 → 10.fetch-orca-profiles.tsboth pointed at thestatus.mdsection §6 deleted. The generator's comment gives two reasons for stripping_passthroughfrom the bundled presets, and the second is the resolved crash — only thePlaceholderParserone 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 --strictpasses.11. Follow-up: the slot removal that renumbers
§6 taught
CONFIG_CHANGEDto 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
extruderIdtoo. So removing one from the middle renumbers every slot after it while leaving every id in range: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 · ABSwith 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_CHANGEDnow carries the slot labels rather than the count, and the reducer keeps the previous list. No new plumbing:useSliceQueuealready calledfilamentSlotLabels(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
Autorather 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.mjsgains 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 withsaw [0]when the slots collapse to one filament. It runs on every future PR, on both engine variants:Against a locally built engine, driving the config the UI actually produces:
filament_map[1,1][1,2]; filament: 1@220 °C, slot 2 →; filament: 2@240 °C114/114 unit tests, 10/10 Playwright e2e, typecheck, lint and production build clean;
smoke-test.mjspassed 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