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.
This commit is contained in:
maziggy
2026-07-07 11:01:25 +02:00
8 changed files with 504 additions and 28 deletions
+60
View File
@@ -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)
+24 -27
View File
@@ -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
+9 -1
View File
@@ -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
View File
@@ -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
@@ -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"
@@ -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"]
@@ -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
View File
@@ -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()