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

fix(slicer): preserve PVA-for-support intent across re-slice of source 3MF (#1881)

    Three bugs on the same PLA-model + PVA-support flow, discovered in
    sequence:

    (A) substitute_unused_plate_filaments inspected only object geometry
        (per-object extruder metadata + paint_color triangles) so a support-
        only slot was silently treated as "unused" and the user's PVA profile
        got overwritten with slot 1's PLA.

    (B) _extract_filament_info stripped filament_is_support==1 entries,
        hiding PVA from unsliced source archive cards even when the project
        explicitly configured it.

    (C) --load-settings is authoritative over the source's project_settings.
        config, and Bambu's shipped process presets ship enable_support=0
        (supports are a per-print decision, not per-quality). So even with
        (A) fixed, the sliced output had supports disabled and the PVA slot
        loaded but never consumed. Inverts BambuStudio GUI's semantics where
        the project overrides the preset.

    Fixes:
    - New extract_support_filament_slots_from_3mf reads enable_support +
      support_filament + support_interface_filament from project_settings.
      config; substitute_unused_plate_filaments unions it into the geometry-
      derived set.
    - _extract_filament_info returns all configured filament types + colours.
    - New _patch_process_support_settings overlays four fields (enable_
      support, support_filament, support_interface_filament, support_type)
      from the source 3MF onto the picked process preset JSON before
      --load-settings sees it. Deliberately targeted to what fixes #1881
      without widening to a full project-over-preset merge.
maziggy 2 месяцев назад
Родитель
Сommit
11d73b0a64

+ 60 - 0
backend/app/api/routes/library.py

@@ -3285,6 +3285,57 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     return json.dumps(profile)
 
 
+# Support-related keys we lift from the source 3MF's project_settings.config
+# into the picked process preset before `--load-settings` sees it (#1881).
+# BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
+# define `enable_support: 0` as their default — supports are a per-print
+# decision, not a per-quality one. `--load-settings` is authoritative, so
+# without preserving these fields the source's per-project support intent
+# (supports on, PVA in the interface slot, tree vs normal) gets discarded
+# and the slicer produces a single-material output with no supports at all.
+_SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = (
+    "enable_support",
+    "support_filament",
+    "support_interface_filament",
+    "support_type",
+)
+
+
+def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str:
+    """Overlay the source 3MF's support configuration onto the process JSON.
+
+    Only fires on 3MF sources — STL / STEP don't carry `project_settings.
+    config`. Silently no-ops when the source doesn't have the config, has
+    a malformed one, or when the process JSON isn't parseable — the slice
+    then runs with the process preset's own defaults, which is the safe
+    fall-back for both this bug and the pre-fix behaviour.
+    """
+    from io import BytesIO
+
+    try:
+        with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
+            if "Metadata/project_settings.config" not in zf.namelist():
+                return process_json
+            src_cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return process_json
+    if not isinstance(src_cfg, dict):
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE:
+        if key in src_cfg:
+            process_cfg[key] = src_cfg[key]
+
+    return json.dumps(process_cfg)
+
+
 # The sidecar prefixes the slicer CLI's own error_string with this when the
 # slicer ran and rejected the job (model off the bed, incompatible filament
 # temps, range validation) — as opposed to the CLI crashing before it could
@@ -3458,6 +3509,15 @@ async def _run_slicer_with_fallback(
         # didn't touch) still drive the slice.
         primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
 
+        # #1881: preserve the source 3MF's support configuration on top of
+        # the picked process preset. Bambu's shipped process presets set
+        # `enable_support: 0` by default (supports are a per-print, not
+        # per-quality, decision); `--load-settings` is authoritative so
+        # without patching, the source's `enable_support: 1` + support-slot
+        # assignments get discarded and the slice comes out single-material
+        # with a PVA slot loaded but never used.
+        presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
+
     used_embedded_settings = False
     service = SlicerApiService(api_url)
 

+ 26 - 29
backend/app/services/archive.py

@@ -375,42 +375,39 @@ class ThreeMFParser:
             pass  # G-code header parsing is best-effort; metadata may come from other sources
 
     def _extract_filament_info(self, data: dict):
-        """Extract filament info, preferring non-support filaments."""
+        """Extract filament info from project settings — includes support
+        materials so a PLA-model / PVA-support project shows both on the
+        archive card badge (#1881).
+
+        Earlier code filtered by ``filament_is_support``; that hid PVA
+        (and any other soluble/breakaway support material) from the card
+        even when the user had explicitly configured it, and made source
+        3MFs look single-material until the print completed. slice_info
+        (parsed separately) is still preferred when present — it lists
+        only filaments the print actually consumes, this fallback only
+        runs on unsliced source 3MFs.
+        """
         try:
             filament_types = data.get("filament_type", [])
             filament_colors = data.get("filament_colour", [])
-            filament_is_support = data.get("filament_is_support", [])
 
             if not filament_types:
                 return
 
-            # Collect all non-support filaments
-            non_support_types = []
-            non_support_colors = []
-
-            for i, ftype in enumerate(filament_types):
-                is_support = filament_is_support[i] if i < len(filament_is_support) else "0"
-                if is_support == "0":
-                    if ftype and ftype not in non_support_types:
-                        non_support_types.append(ftype)
-                    if i < len(filament_colors) and filament_colors[i]:
-                        color = filament_colors[i]
-                        if color not in non_support_colors:
-                            non_support_colors.append(color)
-
-            # Fallback to first filament if all are support
-            if not non_support_types and filament_types:
-                non_support_types = [filament_types[0]]
-            if not non_support_colors and filament_colors:
-                non_support_colors = [filament_colors[0]]
-
-            # Store filament type(s)
-            if non_support_types:
-                self.metadata["filament_type"] = ", ".join(non_support_types)
-
-            # Store all colors as comma-separated (for multi-color display)
-            if non_support_colors:
-                self.metadata["filament_color"] = ",".join(non_support_colors)
+            unique_types: list[str] = []
+            for ftype in filament_types:
+                if ftype and ftype not in unique_types:
+                    unique_types.append(ftype)
+
+            unique_colors: list[str] = []
+            for color in filament_colors:
+                if color and color not in unique_colors:
+                    unique_colors.append(color)
+
+            if unique_types:
+                self.metadata["filament_type"] = ", ".join(unique_types)
+            if unique_colors:
+                self.metadata["filament_color"] = ",".join(unique_colors)
 
         except Exception:
             pass  # Filament info is optional; fall back to slice_info values

+ 9 - 1
backend/app/services/slicer_3mf_convert.py

@@ -264,11 +264,19 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
         return items
     # Local import keeps the bytes->ZipFile boundary in this module and
     # avoids dragging zipfile into every caller.
-    from backend.app.utils.threemf_tools import extract_plate_extruder_set_from_3mf
+    from backend.app.utils.threemf_tools import (
+        extract_plate_extruder_set_from_3mf,
+        extract_support_filament_slots_from_3mf,
+    )
 
     try:
         with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
+            # Geometry-derived slots (per-object metadata + paint_color)
+            # plus process-derived support-filament slots. Supports aren't
+            # attached to object geometry so the geometry pass alone
+            # misses PVA-in-support-slot setups (#1881).
             used = extract_plate_extruder_set_from_3mf(zf, plate_id)
+            used |= extract_support_filament_slots_from_3mf(zf)
     except (zipfile.BadZipFile, OSError) as exc:
         logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc)
         return items

+ 47 - 0
backend/app/utils/threemf_tools.py

@@ -859,6 +859,53 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     return out
 
 
+def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
+    """Slots referenced by the process settings for support material.
+
+    Supports aren't attached to object geometry — they're generated by
+    the slicer's process pass — so :func:`extract_plate_extruder_set_from_3mf`,
+    which walks per-object extruder metadata + paint_color triangles,
+    doesn't see them. Callers that need the complete set of slots a
+    plate print will exercise (e.g. the SliceModal's filament-
+    substitution logic) must union this in — otherwise a support-only
+    slot (typical PLA-model + PVA-support setup) looks "unused" and its
+    user-picked profile gets silently overwritten with slot 1's,
+    producing a single-material print (#1881).
+
+    Returns the empty set when supports are disabled, ``support_filament``
+    / ``support_interface_filament`` are 0 (== "same as model"), the
+    project has no embedded settings, or the file isn't a valid 3MF.
+    """
+    if "Metadata/project_settings.config" not in zf.namelist():
+        return set()
+    try:
+        cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
+    except (json.JSONDecodeError, UnicodeDecodeError, OSError):
+        return set()
+    if not isinstance(cfg, dict):
+        return set()
+    # BambuStudio serialises bool config options as string "1"/"0" in
+    # project_settings.config, but forks / older versions occasionally
+    # write real booleans or ints — accept anything that isn't
+    # unambiguously falsy.
+    enable = cfg.get("enable_support")
+    if enable in (False, 0, "0", "false", "False", "", None):
+        return set()
+    out: set[int] = set()
+    for key in ("support_filament", "support_interface_filament"):
+        raw = cfg.get(key)
+        if raw is None:
+            continue
+        try:
+            slot = int(raw)
+        except (ValueError, TypeError):
+            continue
+        # Slot 0 means "same as model" — no dedicated slot to preserve.
+        if slot > 0:
+            out.add(slot)
+    return out
+
+
 _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
 
 # Painted-face quadtree leaves include both real filament assignments and

+ 73 - 0
backend/tests/unit/services/test_archive_service.py

@@ -938,3 +938,76 @@ class TestMultiPlateSliceInfoSum:
         assert meta["print_time_seconds"] == 300
         # Only the second plate's weight contributed.
         assert meta["filament_used_grams"] == 5.0
+
+
+class TestThreeMFParserSupportMaterial:
+    """#1881: `_extract_filament_info` used to filter out support materials
+    (any slot where `filament_is_support == "1"`). That hid PVA / BVOH from
+    the archive card of unsliced source 3MFs — a PLA-model + PVA-support
+    project looked single-material until the print completed. This class
+    covers the follow-up: support materials must be included in
+    `filament_type` / `filament_color`.
+    """
+
+    @staticmethod
+    def _make_3mf_with_project_settings(project_settings: dict) -> str:
+        import json
+        import os
+        import tempfile
+        import zipfile
+
+        fd, path = tempfile.mkstemp(suffix=".3mf")
+        os.close(fd)
+        with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+        return path
+
+    def test_support_filament_included_on_source_3mf(self):
+        # Reporter's exact config: PLA model + PVA support. Both must show
+        # on the archive card badge, in slot order.
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._make_3mf_with_project_settings(
+            {
+                "filament_type": ["PLA", "PVA"],
+                "filament_colour": ["#FFFFFF", "#00AA00"],
+                "filament_is_support": ["0", "1"],
+            }
+        )
+        meta = ThreeMFParser(path).parse()
+        assert meta["filament_type"] == "PLA, PVA"
+        assert meta["filament_color"] == "#FFFFFF,#00AA00"
+
+    def test_single_support_material_still_populated(self):
+        # Degenerate case: only material configured happens to be marked
+        # support. Old fallback picked the first entry; new logic keeps
+        # the same shape.
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._make_3mf_with_project_settings(
+            {
+                "filament_type": ["PVA"],
+                "filament_colour": ["#FFFFFF"],
+                "filament_is_support": ["1"],
+            }
+        )
+        meta = ThreeMFParser(path).parse()
+        assert meta["filament_type"] == "PVA"
+        assert meta["filament_color"] == "#FFFFFF"
+
+    def test_duplicate_material_types_deduped(self):
+        # Two AMS slots both PLA of different colours: type list dedupes
+        # but colour list keeps both (multi-colour print).
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._make_3mf_with_project_settings(
+            {
+                "filament_type": ["PLA", "PLA"],
+                "filament_colour": ["#FFFFFF", "#000000"],
+                "filament_is_support": ["0", "0"],
+            }
+        )
+        meta = ThreeMFParser(path).parse()
+        assert meta["filament_type"] == "PLA"
+        assert meta["filament_color"] == "#FFFFFF,#000000"

+ 45 - 0
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -326,3 +326,48 @@ class TestSubstituteUnusedPlateFilaments:
         items = ["a.json", "b.json", "c.json"]
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
         assert result == items
+
+    def test_support_material_slot_preserved(self):
+        # #1881 regression: object geometry references only slot 1 (PLA),
+        # but slot 2 (PVA) is configured as the support material in
+        # project_settings.config. Without the support-slot union, slot 2's
+        # user-picked PVA profile would be overwritten with slot 1's PLA
+        # and the print would come out single-material with PLA supports.
+        model_settings = self._model_settings_xml([(1, [1])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "2",
+                "support_interface_filament": "2",
+                "filament_type": ["PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["pla.json", "pva_support.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == ["pla.json", "pva_support.json"]
+
+    def test_support_disabled_still_substitutes_unused(self):
+        # When supports are off, slot 2 is genuinely unused — the temp-spread
+        # validator still needs the substitution to succeed.
+        model_settings = self._model_settings_xml([(1, [1])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "0",
+                "support_filament": "2",
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["pla.json", "abs_never_used.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == ["pla.json", "pla.json"]

+ 153 - 0
backend/tests/unit/test_slice_process_support_patch.py

@@ -0,0 +1,153 @@
+"""Regression tests for the #1881 support-settings patch on slice requests.
+
+BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
+define `enable_support: 0` because supports are a per-print decision, not
+a per-quality one. Bambuddy passes the picked process preset via
+`--load-settings`, which is authoritative — every field in the loaded
+JSON overrides the source 3MF's embedded `project_settings.config`. So
+without patching, a user who exported a source 3MF with supports
+configured (PLA in slot 1 + PVA in slot 2 for support_interface,
+enable_support on) got a single-material output with the PVA slot loaded
+but never used.
+
+The patch reads support-related fields from the source's
+project_settings.config and overlays them onto the process preset JSON,
+so the source's per-project support intent survives `--load-settings`.
+"""
+
+import io
+import json
+import zipfile
+
+from backend.app.api.routes.library import _patch_process_support_settings
+
+
+def _make_3mf(project_settings: dict | None) -> bytes:
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        if project_settings is not None:
+            zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+    return buf.getvalue()
+
+
+class TestPatchProcessSupportSettings:
+    def test_preserves_source_enable_support_and_interface_slot(self):
+        # Reporter's exact #1881 config: source has supports on with PVA
+        # in slot 2 for the interface. Shipped process preset has all four
+        # fields off. Post-patch, the source wins for the support keys and
+        # the process preset's own layer_height stays untouched.
+        source = _make_3mf(
+            {
+                "enable_support": "1",
+                "support_filament": "0",
+                "support_interface_filament": "2",
+                "support_type": "normal(manual)",
+                "filament_type": ["PLA", "PVA"],
+            }
+        )
+        preset = json.dumps(
+            {
+                "name": "0.20mm Standard @BBL H2D",
+                "enable_support": "0",
+                "support_filament": "0",
+                "support_interface_filament": "0",
+                "support_type": "default",
+                "layer_height": "0.20",
+            }
+        )
+        result = json.loads(_patch_process_support_settings(preset, source))
+        assert result["enable_support"] == "1"
+        assert result["support_filament"] == "0"
+        assert result["support_interface_filament"] == "2"
+        assert result["support_type"] == "normal(manual)"
+        # Non-support fields survive.
+        assert result["layer_height"] == "0.20"
+        assert result["name"] == "0.20mm Standard @BBL H2D"
+
+    def test_source_supports_off_beats_preset_supports_on(self):
+        # Symmetric: a source with supports explicitly disabled must win
+        # over a process preset that happens to have supports on. Rare in
+        # practice (Bambu's presets ship off) but the semantic is "source
+        # wins" regardless of direction — a user who exported without
+        # supports doesn't want a preset accidentally re-enabling them.
+        source = _make_3mf(
+            {
+                "enable_support": "0",
+                "support_filament": "0",
+                "support_interface_filament": "0",
+            }
+        )
+        preset = json.dumps({"enable_support": "1", "support_filament": "2", "support_interface_filament": "2"})
+        result = json.loads(_patch_process_support_settings(preset, source))
+        assert result["enable_support"] == "0"
+        assert result["support_filament"] == "0"
+        assert result["support_interface_filament"] == "0"
+
+    def test_only_patches_keys_present_in_source(self):
+        # Source with a partial support config (e.g. legacy 3MFs from an
+        # older BambuStudio) only overrides the keys it defines. Preset's
+        # values for the other support keys survive.
+        source = _make_3mf({"enable_support": "1"})
+        preset = json.dumps(
+            {
+                "enable_support": "0",
+                "support_filament": "2",
+                "support_interface_filament": "3",
+                "support_type": "tree(auto)",
+            }
+        )
+        result = json.loads(_patch_process_support_settings(preset, source))
+        assert result["enable_support"] == "1"
+        # Preset's values kept for keys the source didn't define.
+        assert result["support_filament"] == "2"
+        assert result["support_interface_filament"] == "3"
+        assert result["support_type"] == "tree(auto)"
+
+    def test_no_project_settings_in_source_returns_preset_unchanged(self):
+        # STL / STEP / a stripped-down 3MF has no project_settings.config;
+        # nothing to overlay, preset must pass through untouched.
+        source = _make_3mf(None)
+        preset = json.dumps({"enable_support": "0", "layer_height": "0.20"})
+        result = _patch_process_support_settings(preset, source)
+        # Same JSON round-trips.
+        assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"}
+
+    def test_malformed_source_returns_preset_unchanged(self):
+        # A malformed source 3MF (or a random blob) can't yield support
+        # info; the slice then runs with the preset's own defaults, which
+        # is the safe fall-back matching pre-fix behaviour.
+        preset = json.dumps({"enable_support": "0"})
+        assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"}
+
+    def test_malformed_project_settings_json_returns_preset_unchanged(self):
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/project_settings.config", "{not json")
+        source = buf.getvalue()
+        preset = json.dumps({"enable_support": "0"})
+        assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
+
+    def test_source_project_settings_not_dict_returns_preset_unchanged(self):
+        # Defensive: spec says it's a dict, but a source that ships a
+        # top-level list (or anything non-dict) shouldn't crash the slice.
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/project_settings.config", json.dumps([]))
+        source = buf.getvalue()
+        preset = json.dumps({"enable_support": "0"})
+        assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
+
+    def test_malformed_preset_json_returns_input_unchanged(self):
+        # Symmetric to test_returns_input_unchanged_when_json_is_invalid
+        # in the bed-type patch's test suite. The slicer would error on
+        # the preset anyway; the patch is a straight passthrough so
+        # failure attributes to the original input.
+        source = _make_3mf({"enable_support": "1"})
+        bogus = "not a json document"
+        assert _patch_process_support_settings(bogus, source) is bogus
+
+    def test_preset_json_not_a_dict_returns_input_unchanged(self):
+        source = _make_3mf({"enable_support": "1"})
+        not_a_dict = json.dumps(["this", "is", "an", "array"])
+        assert _patch_process_support_settings(not_a_dict, source) is not_a_dict

+ 93 - 0
backend/tests/unit/test_threemf_tools.py

@@ -16,6 +16,7 @@ from backend.app.utils.threemf_tools import (
     extract_plate_extruder_set_from_3mf,
     extract_print_time_from_3mf,
     extract_project_filaments_from_3mf,
+    extract_support_filament_slots_from_3mf,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
@@ -941,3 +942,95 @@ class TestExtractPrintTimeFrom3mf:
 
         assert extract_print_time_from_3mf(file_path) is None
         assert extract_print_time_from_3mf(file_path, plate_id=2) is None
+
+
+# ---------------------------------------------------------------------------
+# Tests for extract_support_filament_slots_from_3mf — #1881: a plate that uses
+# PVA (or any material) exclusively for supports doesn't reference the support
+# slot from object geometry, so without this helper substitute_unused_plate_
+# filaments overwrites the user's support-material profile with slot 1's.
+# ---------------------------------------------------------------------------
+
+
+class TestExtractSupportFilamentSlotsFrom3mf:
+    def test_pla_object_plus_pva_support_returns_support_slot(self):
+        # The reporter's exact scenario (#1881): slot 1 = PLA (model),
+        # slot 2 = PVA (support). enable_support on. Without this the
+        # substitute logic replaces slot 2's PVA profile with PLA and
+        # the printed supports come out in PLA.
+        cfg = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "2",
+                "support_interface_filament": "2",
+                "filament_type": ["PLA", "PVA"],
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {2}
+
+    def test_distinct_support_body_and_interface_slots(self):
+        cfg = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "2",
+                "support_interface_filament": "3",
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {2, 3}
+
+    def test_supports_disabled_returns_empty(self):
+        # enable_support off — supports won't be printed even if a slot is
+        # configured. Don't force it into the "used" set; substitution
+        # should still homogenise the loaded-filament array.
+        cfg = json.dumps(
+            {
+                "enable_support": "0",
+                "support_filament": "2",
+                "support_interface_filament": "2",
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_slot_zero_treated_as_same_as_model(self):
+        # BambuStudio's `0` for support_filament means "same as model" —
+        # no dedicated slot to preserve.
+        cfg = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "0",
+                "support_interface_filament": "0",
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_boolean_enable_support_accepted(self):
+        # Some forks / older versions write a real JSON bool instead of "1"/"0".
+        cfg = json.dumps({"enable_support": True, "support_filament": "2"})
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {2}
+
+    def test_integer_slot_value_accepted(self):
+        cfg = json.dumps({"enable_support": "1", "support_filament": 3})
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {3}
+
+    def test_missing_project_settings_returns_empty(self):
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_malformed_json_returns_empty(self):
+        with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_root_is_list_returns_empty(self):
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_non_numeric_slot_value_skipped(self):
+        cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"})
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()