diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 069b9751d..b4c4f6ac5 100644 --- a/backend/app/api/routes/library.py +++ b/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) diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 2702bc84a..816480ead 100644 --- a/backend/app/services/archive.py +++ b/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 = [] + unique_types: list[str] = [] + for ftype in filament_types: + if ftype and ftype not in unique_types: + unique_types.append(ftype) - 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) + unique_colors: list[str] = [] + for color in filament_colors: + if color and color not in unique_colors: + unique_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) + 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 diff --git a/backend/app/services/slicer_3mf_convert.py b/backend/app/services/slicer_3mf_convert.py index 8933c9385..3bcb3a6fe 100644 --- a/backend/app/services/slicer_3mf_convert.py +++ b/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 diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index dace2cb74..4482ea11d 100644 --- a/backend/app/utils/threemf_tools.py +++ b/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 diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index 324c54259..fb550a627 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/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", "") + 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" diff --git a/backend/tests/unit/services/test_slicer_3mf_convert.py b/backend/tests/unit/services/test_slicer_3mf_convert.py index 570f700f7..4bfda15e3 100644 --- a/backend/tests/unit/services/test_slicer_3mf_convert.py +++ b/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"] diff --git a/backend/tests/unit/test_slice_process_support_patch.py b/backend/tests/unit/test_slice_process_support_patch.py new file mode 100644 index 000000000..2b3f5fe6c --- /dev/null +++ b/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", "") + 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 diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index 43cc210d6..5b1f58673 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/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()