mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user