fix(slicer): strip zero-valued filament-index sentinels, and sanitise the preview slice too (issue #3030)

Bambu Studio writes 0 into wall_filament, sparse_infill_filament and
solid_infill_filament to mean "use whichever filament the object is set
to". Bambu Studio and OrcaSlicer 2.4 define these min 0 and accept it;
OrcaSlicer 2.3 and earlier used the 1-based scheme (min 1, default 1)
and reject it with "0 not in range [1.000000,...]". Sidecar images are
version-tagged, so an install can be pinned to one of those builds.

Same shape as the -1 inherit markers from #1201 with a different marker,
so the allowlist becomes a key-to-marker map rather than one global
constant. The buckets must not bleed: a -1 on a filament index is a real
value, and a 0 on a raft field is a setting the user chose.

The key is removed rather than rewritten, which is what makes it safe on
every build. The CLI then uses its own default: 0 where 0 was legal
(unchanged), 1 on the older builds, which is what "the active filament"
means under that scheme.

The preview slice never ran the sanitiser at all, so a file that sliced
fine could still fail its automatic plate preview and fall back to the
painted-face heuristic. It matters more there than in a real slice: the
preview runs on the file's own embedded settings, so there is no
--load-settings pass that could supply a replacement for a field the
range validator has already rejected. That also explains the reported
"same error on a later attempt of an unchanged file" without any second
copy of the keys -- the validator that emits it reads the merged global
config, which per-object model_settings.config overrides never reach.

The sanitiser moves to utils/threemf_tools so the service can use it
without importing a route module, and both preview callers pick it up
from one place. Drops _strip_3mf_embedded_settings and its constant,
which have had no callers since the strip-everything experiment was
reverted.
This commit is contained in:
maziggy
2026-09-07 14:37:49 +02:00
parent e2493132bf
commit 9a837d19a1
6 changed files with 393 additions and 169 deletions
+9 -143
View File
@@ -94,6 +94,7 @@ from backend.app.utils.threemf_tools import (
extract_embedded_presets_from_3mf,
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
sanitize_project_settings_sentinels,
select_plate_gcode_name,
supports_enabled_in_config,
)
@@ -3492,142 +3493,6 @@ async def get_library_file_filament_requirements(
}
_STRIPPABLE_3MF_CONFIGS = frozenset(
{
# Settings dump used by --load-settings validation; the CLI tries to
# match its sentinel values (`prime_tower_brim_width: -1`, empty
# arrays) against the supplied profile and rejects out-of-range.
"Metadata/project_settings.config",
# Per-object settings overrides referencing the source plate's
# filament IDs / printer IDs. When the user picks a different
# printer / filament triplet, the IDs no longer resolve and the
# CLI exits non-zero on input validation.
"Metadata/model_settings.config",
# Slicer-version + plate-config + filament-mapping snapshot from
# the original slice. Includes the original printer model and
# filament references; mismatches against `--load-settings`
# consistently surfaced as `Slicer CLI failed (500)` for every
# 3MF in production. Removing it lets the CLI build a fresh slice
# plan from the supplied profile triplet.
"Metadata/slice_info.config",
# Multi-part / split-mesh metadata referencing object IDs from the
# original slice. Strip for the same reason — preserves the geometry
# in `3D/3dmodel.model` while dropping the orphan references.
"Metadata/cut_information.xml",
}
)
def _strip_3mf_embedded_settings(zip_bytes: bytes) -> bytes:
"""Remove embedded slicer-config metadata from a 3MF.
Bambuddy supplies the slicer profile triplet via the sidecar's
``--load-settings`` path; the 3MF's embedded settings would otherwise be
validated by the CLI first and can fail with sentinel-value range
checks (`prime_tower_brim_width: -1 not in range`, etc.) regardless of
what we pass via ``--load-settings``. Stripping the embedded configs
forces the CLI to use the supplied profiles only. Geometry
(``3D/3dmodel.model``), thumbnails, color, and multi-part data inside
the 3MF are preserved.
The set of strippable filenames is centralised in
``_STRIPPABLE_3MF_CONFIGS`` — see that constant for the per-file
rationale. Project-settings alone wasn't enough: real-world Bambu
Studio 3MFs cross-reference printer / filament IDs from the other
metadata configs, and any single leftover triggered the validation
failure that made every profile-driven slice fall back to embedded
settings.
"""
from io import BytesIO
src = BytesIO(zip_bytes)
dst = BytesIO()
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename in _STRIPPABLE_3MF_CONFIGS:
continue
zout.writestr(item, zin.read(item.filename))
return dst.getvalue()
# Keys in ``Metadata/project_settings.config`` that BambuStudio writes ``"-1"``
# to when the user wants the value inherited from the parent process preset.
# The CLI's ``StaticPrintConfig`` validator runs against the embedded settings
# *before* ``--load-settings`` overrides apply, so a sentinel ``"-1"`` trips
# the field's lower-bound range check and the CLI exits non-zero before our
# profile triplet is ever consulted (#1201 — MakerWorld P2S models).
#
# Allowlisted (rather than "strip every '-1' value") because some fields
# legitimately accept negative numbers (z_offset, translation values, etc.)
# and a blanket strip would silently corrupt those.
#
# Add new entries here as more reports surface — the slicer's error message
# names the offending field directly (`<field>: -1 not in range [...]`).
_PROJECT_SETTINGS_SENTINEL_KEYS = frozenset(
{
# Reported in #1201 (MakerWorld P2S 3MFs).
"raft_first_layer_expansion",
"tree_support_wall_count",
# Cited in the strip-experiment comment block above as a known sentinel
# case from earlier reports.
"prime_tower_brim_width",
}
)
def _sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
"""Strip ``"-1"`` inherit-from-parent sentinels from the 3MF's
``Metadata/project_settings.config`` so the slicer CLI's range validator
accepts the file (#1201).
Removes only allowlisted keys (see ``_PROJECT_SETTINGS_SENTINEL_KEYS``)
when their value is exactly ``"-1"``. The rest of the config — and every
other entry in the zip — is preserved byte-for-byte. Unlike the earlier
full-strip experiment (see ``_strip_3mf_embedded_settings`` and the
cautionary comment in ``_run_slicer_with_fallback``) this leaves
``StaticPrintConfig`` initialisation intact: the file is still present,
still parses, and the slicer falls back to the supplied
``--load-settings`` value for the removed key.
Returns the original bytes unchanged when no sanitisation is needed
(input isn't a valid zip, no ``project_settings.config``, no allowlisted
sentinels present, or any other parse failure) so the caller can pass
the result on without further checks.
"""
from io import BytesIO
try:
with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
if "Metadata/project_settings.config" not in zin.namelist():
return zip_bytes
try:
config = json.loads(zin.read("Metadata/project_settings.config").decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return zip_bytes
if not isinstance(config, dict):
return zip_bytes
removed = [key for key in _PROJECT_SETTINGS_SENTINEL_KEYS if config.get(key) == "-1"]
if not removed:
return zip_bytes
for key in removed:
config.pop(key, None)
patched = json.dumps(config)
logger.info(
"3MF sanitiser: removed sentinel '-1' for keys %s — slicer will use --load-settings defaults",
sorted(removed),
)
dst = BytesIO()
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == "Metadata/project_settings.config":
zout.writestr(item, patched)
else:
zout.writestr(item, zin.read(item.filename))
return dst.getvalue()
except (zipfile.BadZipFile, OSError):
return zip_bytes
def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
"""Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
to the slicer sidecar.
@@ -4022,13 +3887,14 @@ async def _run_slicer_with_fallback(
is_3mf = model_filename.lower().endswith(".3mf")
primary_bytes = model_bytes
if is_3mf:
# Strip "-1" inherit-from-parent sentinels from
# Metadata/project_settings.config so the CLI's StaticPrintConfig
# range validator accepts the file (#1201). Surgical — keeps the
# config present, just removes the offending keys; the supplied
# --load-settings (and the fallback's embedded values for keys we
# didn't touch) still drive the slice.
primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
# Strip inherit/unset sentinels from Metadata/project_settings.config
# so the CLI's StaticPrintConfig range validator accepts the file
# (#1201, #3030). Surgical — keeps the config present, just removes
# the offending keys; the supplied --load-settings (and the fallback's
# embedded values for keys we didn't touch) still drive the slice.
# The preview-slice path applies the same sanitiser in
# ``slice_preview.get_preview_filaments``.
primary_bytes = sanitize_project_settings_sentinels(primary_bytes)
# #2622: the process settings the file's designer moved off the stock
# preset. Read once — the support patch below needs to know which of