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()