From dfeac792fbc337bfc38df25e38b7568cdf0b0b24 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 13 Aug 2026 17:05:08 +0200 Subject: [PATCH] Stop an H2C refusing a multi-colour print as a hotend mismatch The print uploaded, the printer took the command, and stopped at once with HMS 0500-4047 -- "the available hotend quantity or model does not match the sliced file". nozzle_mapping told the printer one of the plate's filaments went to no hotend while ams_mapping named the tray it comes from, and the firmware will not start a job on that contradiction. Each filament in a 3MF names the group it belongs to, and on every other dual-nozzle Bambu the group number is also the extruder index, so it was read as one. On a rack machine it is not: the rack carriage holds six hotends to the fixed carriage's one, so the slicer writes a group per nozzle rather than per carriage. The failing plate carried groups 0, 1 and 2 against a two-entry physical_extruder_map, and the filament in group 2 was dropped -- indistinguishable downstream from a slot the plate does not print, which is what reached the wire as -1. extract_nozzle_mapping_from_3mf now resolves the group through the table the file states for itself, the elements in slice_info.config. Files carrying no such table keep the direct index, so H2D slices are unaffected. A filament that still cannot be placed drops the whole mapping with a logged reason instead of half an answer: the firmware then picks its own nozzle, which is the pre-existing behaviour and far better than an answer that contradicts itself. Two related faults fixed in the same pass. The mapping was read across every plate in the file, so on a multi-plate project a slot took its extruder from whichever plate came last; it is now scoped to the plate being dispatched, in extract_filament_requirements as well. And the array is now one entry per filament slot, matching BambuStudio's own dispatch of [1, 16, 16] for a three-filament plate, rather than padded to a fixed 32. Verified against the file that failed: slot extruders [-1, 1, 0] became [0, 1, 0], and the wire [-1, 16, 1, -1 x29] became [1, 16, 1]. --- CHANGELOG.md | 1 + backend/app/services/bambu_mqtt.py | 12 +- backend/app/services/filament_requirements.py | 6 +- backend/app/services/print_scheduler.py | 2 +- backend/app/utils/threemf_tools.py | 162 +++++++++++++++--- .../unit/test_nozzle_rack_mapping_2800.py | 161 ++++++++++++++++- 6 files changed, 307 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15eecad99..1fa9d8533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to Bambuddy will be documented in this file. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **An H2C refused to start a multi-colour print, reporting that its hotends did not match the sliced file** — The print uploaded, the printer took the command, and then stopped immediately with "the print has stopped because the available hotend quantity or model does not match the sliced file" (HMS 0500-4047). Bambuddy was telling the printer that one of the filaments the plate prints goes to no hotend at all, while the AMS mapping alongside it named the exact tray that filament comes from — a contradiction the firmware will not start a job on. The mistake was in reading the sliced file. Each filament in a 3MF names the *group* it belongs to, and on every other dual-nozzle Bambu that group number happens to also be the extruder it prints on, so Bambuddy read it as one. On a nozzle-rack machine it is not: the rack carriage can hold six hotends against the fixed carriage's one, so the slicer writes one group per nozzle it wants rather than one per carriage, and a three-colour plate can carry groups 0, 1 and 2 on a printer with two extruders. The filament in the group that ran past the end was quietly dropped, and a dropped filament is indistinguishable further down from a slot the plate genuinely does not print. Bambuddy now reads the group-to-extruder table the file states for itself, which is the only place the real answer is written. Where a filament still cannot be placed, no mapping is sent at all rather than a partial one, and the reason is logged: the printer then chooses its own nozzle, which is what it did before any of this existed and is much better than being handed an answer that contradicts itself. Two things were corrected alongside it. The mapping is now read from the plate being printed rather than from every plate in the file at once — a project with several plates can assign the same slot to different extruders on each, and the wrong plate's answer was as likely as the right one. And the mapping sent to the printer is now as long as the plate has filament slots, matching what Bambu Studio itself sends, instead of being padded to a fixed 32 entries. Covered by backend tests, and verified against the file that failed. - **Interchangeable filaments were reported as the wrong material, and the pre-flight check disagreed with what would actually print** — Bambu's firmware treats PA-CF, PA12-CF and PAHT-CF as the same material, and the scheduler has always matched them accordingly, so a PA12-CF spool would happily print a job asking for PA-CF. The interface compared the raw type names instead, so it labelled that same pairing a type mismatch — the badge contradicted what the printer was about to do, and the manual override picker made it worse by *offering* the spool the badge then rejected. Every place that judges whether a spool suits a requirement now reads one shared table. The pipeline pre-flight check had drifted the other way: it carried its own copy of that table, and the copy had come to disagree in both directions — it treated **PLA Basic** as interchangeable with **PLA** where the matcher never has, so a run could clear the check and then fail to map its slots, and it lacked the nylon grouping, so it flagged runs the matcher handles without complaint. It now answers with the matcher's rules, which is the only answer worth giving: a check that predicts dispatch is wrong whenever it disagrees with dispatch, whichever way it leans. **This makes the pre-flight check stricter in one case** — a printer reporting a product name such as "PLA Basic" where the generic material is expected is now flagged rather than passed. That is the honest answer, and it is rare in practice, because the printer reports the material and the product name in separate fields. Nothing about which spool a print actually uses has changed. Covered by backend and frontend tests. - **A process preset that turns supports on had them switched back off by the file being sliced (#2820, reported by @zevulos)** — The server slicer is handed the picked process preset as the authoritative settings for the slice, so anything the preset does not name comes from the file's own embedded settings. Since #1881 four support fields travel the other way as well — supports on/off, the support and interface filament slots, and tree versus normal — because Bambu's shipped process presets all set supports off (supports are a decision per print, not per quality level), and without carrying them a project exported with supports configured came out of the slicer as a single-material print with a PVA slot loaded and never used. That carry ran in both directions, which is wrong in the direction nobody asked for: a file that ships with supports off, which is very nearly every published model, stripped supports back out of a preset that deliberately turned them on. The reporter's own preset turns supports on with normal(auto) and snug, and the slice came back with supports disabled and set to tree(auto) — the only setting of the three that survived was the style, and only because it is not one of the four fields carried and they had re-entered it in the slice dialog. The file can now switch supports on but never off. Nothing is lost by that: since every shipped preset has supports off, a preset that has them on is a deliberate choice by whoever wrote it, and a file that wants supports still gets them along with its slot assignments. A file that never states whether it wants supports at all is treated the same as one that says no. The carry is also written to the log now, naming the fields it took, because the slice dialog shows the picked preset's values and a carried field quietly disagrees with what was on screen — with nothing in the log to say so, this bug reads as the preset being ignored, and the log line the report understandably keyed on was an unrelated one about the source file's own settings. - **Printing several copies of a file uploaded straight to a printer left every copy after the first unable to run (#2819, reported by @ooniiik)** — Dropping a file onto a printer card uploads it to the library, prints it, and deletes it again: the file is a courier, not something you asked to keep. Ask for more than one copy, though, and each copy is its own queue entry pointing at that same temporary file — so the first one to dispatch deleted the file the others were still waiting for. What happened next depended on which database Bambuddy is running on, and neither answer was right. On SQLite the remaining copies were left pointing at a file that no longer existed: starting one failed with "Library file not found", and a copy set to start manually simply sat in the queue forever, giving no reason. On PostgreSQL, which enforces the same rule the schema has always described, the remaining copies were deleted outright — the jobs vanished from the queue with no error and no history, which is harder to notice and harder still to explain. On a farm running batches across twenty printers, both leave work that looks queued and is not. The copy that dispatches first now hands the rest the archive it just created, before the file is deleted: they print the same bytes from Print History, and the flag that consumes a library file is cleared on them so they cannot delete anything themselves. A copy already printing from its own archive keeps it, and a finished one keeps its outcome — but both stop referring to the deleted file, because on PostgreSQL that reference is the only thing tying them to the deletion, and the finished rows are what a batch order counts its progress from. Cross-model jobs (#671) that had this file as one of several alternatives keep their other alternatives and simply lose this one; one left with no alternative at all is handed the archive like any other copy. The same fix covers batch orders, which inherit the file from the item they were cloned from, and copies held back by a printer's previous-print gate, which come back the moment that gate is cleared. Reprints from Print History and any file kept in the library are untouched — nothing is deleted there in the first place. Covered by backend tests, and verified row for row against PostgreSQL as well as SQLite. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 2350a754f..e52622829 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -286,8 +286,11 @@ def apply_tray_exist_bits( # doing exactly that is what #2800 was. _RACK_NOZZLE_IDS = frozenset(range(16, 22)) -# BambuStudio dispatches a fixed-length nozzle_mapping on rack models: one -# physical nozzle ID per filament slot, -1 for slots the plate does not print. +# Ceiling on the nozzle_mapping we will build, not the length we send. The wire +# carries one physical nozzle ID per filament slot the plate declares, and -1 +# for a slot it does not print: BambuStudio's own dispatch of a three-filament +# H2C plate is [1, 16, 16], and the hardware A/B in #2800 ran four-slot plates +# as four entries. Padding to a fixed 32 was an over-generalisation of that. _RACK_WIRE_SLOTS = 32 # The two carriages, as extruder indices in the form the queue stores (already @@ -317,7 +320,8 @@ def resolve_rack_nozzle_mapping( plate does not print. ``rack_nozzle_id`` is the rack position the printer reports as live. - Returns a ``_RACK_WIRE_SLOTS``-long list of physical nozzle IDs, or None + Returns one physical nozzle ID per slot given, matching BambuStudio's own + dispatch length, or None when the mapping cannot be resolved with confidence -- in which case the caller omits the field entirely and the firmware falls back to its own nozzle pick, exactly as it did before this translation existed. Omitting @@ -367,7 +371,7 @@ def resolve_rack_nozzle_mapping( if _RACK_EXTRUDER_ID not in normalised: return None - wire = [-1] * _RACK_WIRE_SLOTS + wire = [-1] * len(normalised) for index, extruder in enumerate(normalised): if extruder < 0: continue diff --git a/backend/app/services/filament_requirements.py b/backend/app/services/filament_requirements.py index 193fc1894..97d00462f 100644 --- a/backend/app/services/filament_requirements.py +++ b/backend/app/services/filament_requirements.py @@ -91,7 +91,11 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None) # Dual-nozzle printers (H2D / X2D) — annotate which extruder each # slot is fed into. Empty mapping for single-nozzle printers, in # which case we just don't add the key. - nozzle_mapping = extract_nozzle_mapping_from_3mf(zf) + # Same plate the filaments above were collected from: a multi-plate + # file can assign one slot to different extruders per plate, and + # annotating slot 2 with plate 3's nozzle is worse than not + # annotating it. + nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id) if nozzle_mapping: for filament in filaments: filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"]) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 0764fdefe..c11113dfe 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -5761,7 +5761,7 @@ class PrintScheduler: # thrown away on every dispatch. nozzle_slot_extruders = None if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model): - slot_extruders = extract_slot_extruders_from_3mf(file_path) + slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=item.plate_id or 1) if slot_extruders: nozzle_slot_extruders = json.dumps(slot_extruders) diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index 937d2c5be..2265a9445 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -16,6 +16,10 @@ from dataclasses import dataclass, field from pathlib import Path from threading import Lock +# Parsing goes through defusedxml; the element type it hands back is the stdlib +# one, and defusedxml does not re-export it, so annotations name it directly. +from xml.etree.ElementTree import Element as XmlElement + import defusedxml.ElementTree as ET logger = logging.getLogger(__name__) @@ -330,7 +334,7 @@ def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | No _MAX_DENSE_FILAMENT_SLOTS = 64 -def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: +def extract_slot_extruders_from_3mf(file_path: Path, plate_id: int | None = None) -> list[int] | None: """Per-slot extruder assignment as a dense list, or None (#2800). Same data as :func:`extract_nozzle_mapping_from_3mf`, reshaped for the @@ -340,6 +344,11 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: picking a nozzle themselves, which can level with one hotend and print with another, several millimetres off the bed. + ``plate_id`` scopes the answer to the plate actually being dispatched. A + multi-plate 3MF carries one filament list per plate and they need not + agree, so without it a slot can take its extruder from a plate this print + is not going to run. + Takes a path rather than an open archive because the dispatcher is handling the file, not the zip, and a broken file there must not take the print down: an unreadable or non-3MF path returns None, and the caller @@ -347,7 +356,7 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: """ try: with zipfile.ZipFile(file_path) as zf: - by_slot = extract_nozzle_mapping_from_3mf(zf) + by_slot = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id) except (zipfile.BadZipFile, OSError) as exc: logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc) return None @@ -371,7 +380,63 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)] -def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None: +def _plates_in_scope(si_root: XmlElement, plate_id: int | None) -> list[XmlElement]: + """The ```` elements a lookup should read, narrowed to one if asked. + + A 3MF holds every plate in the project, each with its own filament list, and + two plates may assign the same slot to different extruders. Falls back to + every plate when no id is given or none matches, which is what this module + did before plates were distinguished at all. + """ + plates = si_root.findall(".//plate") + if not plates: + return [si_root] + if plate_id is None: + return plates + for plate in plates: + for metadata in plate.findall("metadata"): + if metadata.get("key") != "index": + continue + try: + if int(metadata.get("value") or "") == plate_id: + return [plate] + except (TypeError, ValueError): + pass + return plates + + +def _group_extruder_indices(plates: list[XmlElement]) -> dict[int, int] | None: + """Map each filament group to the slicer extruder index it prints on. + + ``slice_info.config`` states this directly, as ````. Reading it matters on nozzle-rack + printers, where the group id is *not* an extruder index: the H2C's rack + carriage can host six hotends (``extruder_max_nozzle_count`` is ``['1', + '6']``), so the slicer emits more groups than the machine has extruders and + several groups share one carriage. A plate of the reporter's carried groups + 0, 1 and 2 against a two-entry ``physical_extruder_map``. + + Returns None when the file states no table, or when two plates in scope + disagree about a group — in which case the caller keeps treating the group + id as the extruder index, which is what every H2D file in practice wants + and what this module has always done. + """ + table: dict[int, int] = {} + for plate in plates: + for nozzle in plate.findall(".//nozzle"): + try: + group_id = int(nozzle.get("id") or "") + extruder_index = int(nozzle.get("extruder_id") or "") - 1 + except (TypeError, ValueError): + return None + if extruder_index < 0: + return None + if table.setdefault(group_id, extruder_index) != extruder_index: + return None + return table or None + + +def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile, plate_id: int | None = None) -> dict[int, int] | None: """Extract per-slot nozzle/extruder mapping from a 3MF file. On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a @@ -380,17 +445,27 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non attributes, not from the user's filament_nozzle_map preference. Priority: - 1. group_id on elements in slice_info.config (actual assignment) + 1. group_id on elements in slice_info.config (actual assignment), + resolved through the file's own group-to-extruder table 2. filament_nozzle_map in project_settings.config (user preference fallback) Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left). + Returns None rather than a partial answer whenever a filament the plate + prints cannot be placed. The gap does not stay a gap downstream: the dense + form fills it with -1, which already means "slot not printed", and + dispatching that against an ams_mapping that *does* name a tray for the slot + is a contradiction the firmware rejects outright with HMS 0500-4047, "the + available hotend quantity or model does not match the sliced file". Giving + no mapping at all costs only the firmware's own nozzle pick. + Args: zf: An open ZipFile of the 3MF archive + plate_id: 1-based plate to read, or None for every plate in the file Returns: Dictionary mapping {slot_id: extruder_id} for dual-nozzle files, - or None if single-nozzle, missing data, or parse error. + or None if single-nozzle, missing data, unplaceable, or parse error. """ try: if "Metadata/project_settings.config" not in zf.namelist(): @@ -417,12 +492,17 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non # Parse slice_info once: needed by both the single-active shortcut # (to verify the slice is actually single-group, #1825) and Priority 1. - si_root: ET.Element | None = None + si_root: XmlElement | None = None + filament_elems: list[XmlElement] = [] + group_extruders: dict[int, int] | None = None distinct_group_ids: set[int] = set() if "Metadata/slice_info.config" in zf.namelist(): si_content = zf.read("Metadata/slice_info.config").decode() si_root = ET.fromstring(si_content) - for filament_elem in si_root.findall(".//filament"): + plates = _plates_in_scope(si_root, plate_id) + group_extruders = _group_extruder_indices(plates) + filament_elems = [elem for plate in plates for elem in plate.findall(".//filament")] + for filament_elem in filament_elems: gid = filament_elem.get("group_id") if gid is not None: try: @@ -440,29 +520,61 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non nozzle_mapping: dict[int, int] = {} active_idx = active_extruders.index(1) target_extruder = int(physical_extruder_map[active_idx]) - if si_root is not None: - for filament_elem in si_root.findall(".//filament"): - try: - nozzle_mapping[int(filament_elem.get("id"))] = target_extruder - except (ValueError, TypeError): - pass + for filament_elem in filament_elems: + try: + nozzle_mapping[int(filament_elem.get("id"))] = target_extruder + except (ValueError, TypeError): + pass return nozzle_mapping or None # Priority 1: Use group_id from slice_info filament elements. # This reflects the actual slicer assignment (respects "Auto For Flush"). nozzle_mapping: dict[int, int] = {} - if si_root is not None: - for filament_elem in si_root.findall(".//filament"): - group_id_str = filament_elem.get("group_id") - filament_id_str = filament_elem.get("id") - if group_id_str is not None and filament_id_str: - try: - group_id = int(group_id_str) - slot_id = int(filament_id_str) - if group_id < len(physical_extruder_map): - nozzle_mapping[slot_id] = int(physical_extruder_map[group_id]) - except (ValueError, TypeError, IndexError): - pass + ungrouped = 0 + for filament_elem in filament_elems: + group_id_str = filament_elem.get("group_id") + filament_id_str = filament_elem.get("id") + if not filament_id_str: + continue + if group_id_str is None: + # Counted rather than returned on: a file where *no* filament + # carries a group falls through to Priority 2 as it always has. + # Only a file that groups some and not others is unplaceable. + ungrouped += 1 + continue + try: + group_id = int(group_id_str) + slot_id = int(filament_id_str) + except (ValueError, TypeError): + logger.warning( + "Ignoring nozzle mapping: unreadable filament id=%r group_id=%r", + filament_id_str, + group_id_str, + ) + return None + # The group id is an extruder index only where the file states no + # table of its own — true of every H2D slice, not of an H2C one. + extruder_index = group_id if group_extruders is None else group_extruders.get(group_id) + if extruder_index is None or not 0 <= extruder_index < len(physical_extruder_map): + logger.warning( + "Ignoring nozzle mapping: filament slot %s is in group %s, which " + "resolves to extruder %r outside physical_extruder_map %r", + slot_id, + group_id, + extruder_index, + physical_extruder_map, + ) + return None + nozzle_mapping[slot_id] = int(physical_extruder_map[extruder_index]) + + if nozzle_mapping and ungrouped: + logger.warning( + "Ignoring nozzle mapping: %d filament(s) carry no group_id while %d do, " + "so the ungrouped slots would dispatch as unprinted", + ungrouped, + len(nozzle_mapping), + ) + return None if nozzle_mapping: return nozzle_mapping diff --git a/backend/tests/unit/test_nozzle_rack_mapping_2800.py b/backend/tests/unit/test_nozzle_rack_mapping_2800.py index f1ebe0d5c..9b5ba6ea5 100644 --- a/backend/tests/unit/test_nozzle_rack_mapping_2800.py +++ b/backend/tests/unit/test_nozzle_rack_mapping_2800.py @@ -38,10 +38,18 @@ class TestIsNozzleRackModel: class TestResolveRackNozzleMapping: def test_rack_slot_takes_the_live_rack_position(self): mapping = resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) - assert mapping is not None - assert len(mapping) == _RACK_WIRE_SLOTS - assert mapping[0] == 17 - assert set(mapping[1:]) == {-1} + assert mapping == [17] + + def test_the_wire_is_as_long_as_the_plate_has_slots(self): + """One entry per filament slot, not a fixed-length padded array. + + BambuStudio's own dispatch of a three-filament H2C plate is + [1, 16, 16] -- three entries, captured from the maintainer's machine. + The earlier fixed 32-length padding was a generalisation from nothing. + """ + assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) == [17] + assert resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16) == [1, 16, 1] + assert len(resolve_rack_nozzle_mapping([1, 0, 0, -1], rack_nozzle_id=16)) == 4 def test_the_fixed_hotend_takes_its_own_physical_id(self): """Both carriages are translated; neither extruder index reaches the wire. @@ -118,8 +126,7 @@ class TestResolveRackNozzleMapping: [1, 17, ...] and [17, 1, ...] depending on filament slot order. """ wire = resolve_rack_nozzle_mapping([0, -1, -1, 1], rack_nozzle_id=17) - assert wire[:4] == [1, -1, -1, 17] - assert set(wire[4:]) == {-1} + assert wire == [1, -1, -1, 17] swapped = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=17) assert swapped[:2] == [17, 1] @@ -299,3 +306,145 @@ class TestSlotExtrudersFromFile: """ source = _write_dual_nozzle_3mf(tmp_path / f"s{abs(slot_id)}.3mf", {slot_id: 1}) assert extract_slot_extruders_from_3mf(source) is None + + +def _write_h2c_3mf(path, plates): + """3MF in the shape BambuStudio writes for a nozzle-rack machine. + + ``plates`` is a list of ``(index, {slot: group}, {group: 1-based extruder})``. + The per-plate ```` elements are the group-to-extruder table; the + fixture above deliberately omits them, because H2D files carry group ids + that already are extruder indices and both shapes have to keep working. + """ + body = "" + for index, filaments, nozzles in plates: + elems = "".join(f'' for slot, group in filaments.items()) + elems += "".join(f'' for group, ext in nozzles.items()) + body += f'{elems}' + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "Metadata/project_settings.config", + json.dumps( + { + "physical_extruder_map": [1, 0], + # One nozzle on the fixed carriage, four racked, as the + # reporter's H2C reports itself. + "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"], + } + ), + ) + zf.writestr("Metadata/slice_info.config", f"{body}") + return path + + +class TestGroupsBeyondTheExtruderCount: + """A rack carriage hosts six hotends, so groups outnumber extruders. + + The H2C's `extruder_max_nozzle_count` is ['1', '6'], and the slicer emits + one filament group per nozzle it wants rather than one per carriage. Group + ids therefore run past the end of `physical_extruder_map`, which has an + entry per *extruder*. Treating the group id as an index into it silently + dropped those filaments, and a dropped filament densifies to -1 -- the same + value that means "this plate does not print the slot". + """ + + def test_the_dropped_filament_from_the_hms_0500_4047_report(self, tmp_path): + """The maintainer's own plate, first print on a new H2C. + + Three filaments in groups 2, 0 and 1 against a two-entry + physical_extruder_map. Slot 1 fell out of the mapping and dispatched as + [-1, 16, 1, ...] while ams_mapping named tray 6 for that same slot; the + printer stopped with "the available hotend quantity or model does not + match the sliced file". The file's own nozzle table says group 2 prints + on extruder 2, the rack side, same as group 1. + """ + source = _write_h2c_3mf( + tmp_path / "benchy.3mf", + [(1, {1: 2, 2: 0, 3: 1}, {0: 1, 1: 2, 2: 2})], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [0, 1, 0] + wire = resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16) + assert wire == [1, 16, 1] + + def test_a_group_the_file_never_places_omits_the_whole_mapping(self, tmp_path): + """Refusing beats answering for the slots that did resolve. + + A partial mapping is not a smaller answer, it is a wrong one: the gap + reaches the printer as -1, contradicting the ams_mapping entry for the + same slot. Omitting costs only the firmware's own nozzle pick. + """ + source = _write_h2c_3mf( + tmp_path / "orphan.3mf", + [(1, {1: 5, 2: 0}, {0: 1, 1: 2})], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) is None + + def test_a_slot_the_plate_genuinely_skips_still_reads_minus_one(self, tmp_path): + """-1 keeps its meaning where it is earned rather than inferred.""" + source = _write_h2c_3mf( + tmp_path / "gap.3mf", + [(1, {1: 0, 3: 1}, {0: 1, 1: 2})], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, -1, 0] + + def test_filaments_grouped_and_ungrouped_in_one_plate_omit_the_mapping(self, tmp_path): + """Half an answer has the same failure mode as a dropped group.""" + path = tmp_path / "mixed.3mf" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "Metadata/project_settings.config", + json.dumps( + { + "physical_extruder_map": [1, 0], + "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"], + } + ), + ) + zf.writestr( + "Metadata/slice_info.config", + '' + '' + '', + ) + assert extract_slot_extruders_from_3mf(path, plate_id=1) is None + + def test_files_without_a_nozzle_table_keep_the_group_as_the_index(self, tmp_path): + """Every H2D slice in the wild carries groups 0 and 1 and no table.""" + source = _write_dual_nozzle_3mf(tmp_path / "h2d.3mf", {1: 0, 2: 1}) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0] + + +class TestPlateScoping: + """A 3MF holds every plate in the project, not just the one being printed.""" + + def test_each_plate_answers_for_itself(self, tmp_path): + source = _write_h2c_3mf( + tmp_path / "two.3mf", + [ + (1, {1: 0, 2: 1}, {0: 1, 1: 2}), + (2, {1: 1, 2: 0}, {0: 1, 1: 2}), + ], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0] + assert extract_slot_extruders_from_3mf(source, plate_id=2) == [0, 1] + + def test_an_unknown_plate_falls_back_to_the_whole_file(self, tmp_path): + """Not every file indexes its plates; this must not become a hard stop.""" + source = _write_h2c_3mf(tmp_path / "one.3mf", [(1, {1: 0, 2: 1}, {0: 1, 1: 2})]) + assert extract_slot_extruders_from_3mf(source, plate_id=7) == [1, 0] + + def test_plates_that_disagree_about_a_group_fall_back_to_the_index(self, tmp_path): + """Reading every plate at once can only be done on the old terms. + + With no plate asked for, two plates naming different extruders for one + group leave no table worth trusting, so the group id is read as the + extruder index exactly as it was before the table existed. + """ + source = _write_h2c_3mf( + tmp_path / "conflict.3mf", + [ + (1, {1: 0}, {0: 1}), + (2, {2: 0}, {0: 2}), + ], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=None) == [1, 1]