Просмотр исходного кода

fix(dispatch): honour multi-group slices in 3MF nozzle mapping (#1825)

extract_nozzle_mapping_from_3mf has a single-active-extruder shortcut
(added in #851 for #827) at threemf_tools.py:354 that runs before the
per-filament group_id mapping. It fires whenever
extruder_nozzle_stats reports exactly one extruder as active. On
multi-nozzle Bambu printers (H2D / H2D Pro / X2D / H2C) the slicer
under-reports the second extruder when its nozzle volume-type isn't
enumerated in the slice's profile (common with HT-AMS / High-Flow
asymmetric setups, e.g. HT-AMS feeding the right nozzle on an H2D):
['Standard#1', 'Standard#0'] even when both extruders are genuinely
used. sum(active_extruders) == 1 → every filament was force-assigned
to physical_extruder_map[active_idx], the authoritative group_id was
discarded, and the Filament Mapping panel showed both filaments
badged L with the auto-match hard filter blocking the wrong-nozzle
tray as "Type not found".

Bug is parser-side and model-agnostic, not gated on AMS hardware —
typical dual-AMS H2D slices contain ['Standard#1', 'Standard#1']
(sum==2), never enter the shortcut, and work fine. Physical extrude
routing was not affected (gcode + project_file nozzle_mapping path
from #1780 is authoritative). User-visible harm: wrong L/R badge and
no auto-match for the second nozzle.

Gate the shortcut on len(distinct_group_ids) <= 1 from
slice_info.config. The slice_info parse is hoisted above the shortcut
and reused by Priority 1, so the gate adds zero extra I/O. The gate
only narrows the shortcut — it can't widen the bug onto any
previously-working slice. Generalizes to H2C and any N-nozzle printer
for free (no per-printer branching).
maziggy 2 месяцев назад
Родитель
Сommit
0fb49274a2
3 измененных файлов с 79 добавлено и 7 удалено
  1. 0 0
      CHANGELOG.md
  2. 24 7
      backend/app/utils/threemf_tools.py
  3. 55 0
      backend/tests/unit/test_scheduler_ams_mapping.py

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 24 - 7
backend/app/utils/threemf_tools.py

@@ -351,13 +351,32 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
             nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
             active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
 
-        if sum(active_extruders) == 1:
+        # 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
+        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"):
+                gid = filament_elem.get("group_id")
+                if gid is not None:
+                    try:
+                        distinct_group_ids.add(int(gid))
+                    except (ValueError, TypeError):
+                        pass
+
+        # Single-active shortcut: only safe when the slice actually uses one
+        # group. extruder_nozzle_stats can under-report a second installed
+        # nozzle when its volume-type differs from the profile's enumerated
+        # types (HT-AMS / High-Flow asymmetry on H2D, #1825); without this
+        # guard the shortcut collapses a real multi-extruder slice onto one
+        # nozzle and the group_id mapping below is skipped.
+        if sum(active_extruders) == 1 and len(distinct_group_ids) <= 1:
             nozzle_mapping: dict[int, int] = {}
             active_idx = active_extruders.index(1)
             target_extruder = int(physical_extruder_map[active_idx])
-            if "Metadata/slice_info.config" in zf.namelist():
-                si_content = zf.read("Metadata/slice_info.config").decode()
-                si_root = ET.fromstring(si_content)
+            if si_root is not None:
                 for filament_elem in si_root.findall(".//filament"):
                     try:
                         nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
@@ -368,9 +387,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         # 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 "Metadata/slice_info.config" in zf.namelist():
-            si_content = zf.read("Metadata/slice_info.config").decode()
-            si_root = ET.fromstring(si_content)
+        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")

+ 55 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -1029,6 +1029,61 @@ class TestExtractNozzleMappingFrom3mf:
         assert result is None
         zf.close()
 
+    def test_single_active_under_report_with_multi_group_falls_through(self):
+        """#1825: extruder_nozzle_stats under-reports the second nozzle, but the
+        slice genuinely uses two extruders (group_id 0 and 1). The shortcut must
+        NOT fire — the parser has to honour the per-filament group_id assignment.
+
+        H2D HT-AMS scenario from the report: physical_extruder_map ['1','0'],
+        extruder_nozzle_stats ['Standard#1','Standard#0'] (sum==1), but ASA is
+        group_id 0 (→ LEFT) and PETG is group_id 1 (→ RIGHT). Before the fix
+        the shortcut collapsed both onto LEFT; now group_id wins.
+        """
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="ASA"  color="#FF0000" used_g="5.0" group_id="0"/>
+            <filament id="2" type="PETG" color="#00FF00" used_g="3.0" group_id="1"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "physical_extruder_map": ["1", "0"],
+                "extruder_nozzle_stats": ["Standard#1", "Standard#0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)
+        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
+        assert result == {1: 1, 2: 0}
+        zf.close()
+
+    def test_single_active_with_single_group_still_uses_shortcut(self):
+        """When stats report one active extruder AND the slice is truly
+        single-group, the shortcut is still correct and must continue to fire
+        (preserves the #851 behaviour for genuine single-nozzle prints made on
+        a multi-nozzle printer where only one nozzle is installed).
+        """
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="PLA" color="#FF0000" used_g="5.0" group_id="0"/>
+            <filament id="2" type="PLA" color="#00FF00" used_g="3.0" group_id="0"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "physical_extruder_map": ["1", "0"],
+                "extruder_nozzle_stats": ["Standard#1", "Standard#0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # Only extruder index 0 is active → physical_extruder_map[0] = 1 (LEFT)
+        assert result == {1: 1, 2: 1}
+        zf.close()
+
 
 class TestNozzleAwareMapping:
     """Test nozzle-aware filament matching in the print scheduler."""

Некоторые файлы не были показаны из-за большого количества измененных файлов