Sfoglia il codice sorgente

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.
maziggy 9 ore fa
parent
commit
9a837d19a1

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **Server-side slicing rejected MakerWorld 3MFs over filament-index fields, and the plate preview was never sanitised at all (#3030, reported by @kielsucks)** — 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 both accept that; OrcaSlicer 2.3 and earlier counted these from 1 and reject it outright with `wall_filament: 0 not in range [1.000000,...]`, so a sidecar pinned to one of those images refused the file before it ever looked at the profiles Bambuddy supplied. This is the same shape as the `-1` inherit markers fixed in #1201, with a different marker value, so the sanitiser's allowlist is now a key-to-marker map rather than one global constant — a `-1` on a filament index is a real value and is left alone, as is a `0` on a raft field. The key is removed rather than rewritten, which is what makes it safe on every build: the slicer then falls back to its own default, which is the same `0` on the versions where `0` was legal and `1` on the older ones, which is what "the active filament" means there. Fixed alongside, and the more consequential half: **the plate preview never ran the sanitiser**, so a file that sliced fine could still fail the automatic preview and fall back to a guessed filament list — and it matters more there than in a real slice, because the preview deliberately runs on the file's own embedded settings and so has no `--load-settings` pass that could supply a replacement for a rejected field. Both paths now share one sanitiser.
 - **The Slice action offered Bambu Studio files its URI handler cannot load (#3029)** — "Open in Slicer" was offered for `.stl`, `.step` and `.stp` as well as `.3mf`, on the reasonable assumption that a slicer which opens an STL from its own File menu will open one from a link. Bambu Studio does not. Every `bambustudio://` and `bambustudioopen://` URL goes through one import path that refuses any filename which is not `.3mf`, and it refuses it *before* fetching anything — the user gets "Download failed, unknown file format", which names the format and so reads as a broken model rather than an unsupported handoff. OrcaSlicer has no such limit: only MakerWorld links go down its equivalent path, and a link to your own Bambuddy goes to its general downloader, which does not look at the extension at all. So the action is now offered per slicer. With **Open in Slicer** set to Bambu Studio the File Manager offers Slice on 3MF only; set it to OrcaSlicer and STL, STEP and STP come back. In the 3D preview, where both slicers are reachable from one split button, a file the configured slicer cannot take is offered to the other one instead, with the button naming it rather than quietly sending the file somewhere the setting did not ask for. None of this touches the in-app slicer: with **Use Slicer API** on, Slice goes to the sidecar and still takes STL and 3MF whichever desktop slicer is configured.
 - **"Slice" handed the slicer a download link that only worked once (#3029)** — the Slice action in the File Manager, and Open in Slicer on the Archives page, mint a short-lived token and put it in the URL, because a protocol handler cannot carry an `Authorization` header. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once — and nothing guarantees that. Bambu Studio's own downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. Whichever request arrived first won and every later one got a 403, so the slicer opened on an error about the file instead of on the file, and the message it showed described the 403 body it had been handed rather than anything wrong with the model — which made this very hard to read from the user's side. The three protocol-handler downloads — a library file, an archive's sliced 3MF, an archive's source 3MF — now accept their token for the rest of its five-minute life. Nothing else about it changed: it is still bound to the single file it was minted for, still expires in five minutes, and is still refused everywhere else. The two downloads that are not handoffs stay single-use, because what they hand over is itself consumed — a prepared printer-file bundle is deleted the moment it has been streamed. Fixed on the way through: **Open source 3MF in slicer never worked at all with authentication enabled**. The gateway that lets these token-in-URL downloads past the login check matches the path fragment `/dl/`, and the source 3MF's route says `source-dl`, which does not contain it — so the slicer's header-less request was turned away as unauthenticated before the route's own token check ever ran. It failed on every install with auth on, and only there.
 - **`camera:view` was required to see any image in the app (#3025, reported by @lonix)** — thirteen routes that have nothing to do with a camera took the camera stream token as their credential: library and archive thumbnails, plate previews and plate thumbnails, timelapses, print photos, archive QR codes, project cover images, print-log thumbnails, printer cover images and external-link icons. A browser cannot put an `Authorization` header on an `<img src>`, so those routes need a credential that fits in the URL, and the camera token was the only one there was. But the only way to mint one is `camera:view`, so a user granted library access to their own files got a grid of broken images until they were also granted the live camera — which on a home install points at the room the printer is in, and is not something you hand to a commission client so their own thumbnails will load. Those routes now take a new media token, minted by `POST /auth/media-token` behind plain authentication and carrying the identity of whoever asked, so each of them applies the permission its own resource is governed by: `library:read_own` for a library thumbnail, `projects:read` for a project cover, `external_links:read` for a sidebar icon. The camera stream token keeps the three routes it was named for. The frontend fetches a media token for every signed-in user and asks for a camera token only when the user can actually mint one, which also stops the 403 that used to fire on every page load for everyone else. Fixed on the way through: the printer file-browser's plate thumbnails, which had no query-token support at all and so 401'd in the file manager whenever auth was enabled; and the project cover image in the edit dialog, whose cache-busting `?v=` was appended after the token and corrupted it.

+ 9 - 143
backend/app/api/routes/library.py

@@ -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

+ 27 - 8
backend/app/services/slice_preview.py

@@ -14,10 +14,13 @@ we don't need to thread the user's profile triplet through here. That choice
 also protects the numbers — overriding the process preset drops the project's
 own support configuration, which loses whole slots from the answer.
 
-The one thing that can defeat those embedded settings is a custom G-code
-template written by a Studio newer than the sidecar, which fails to parse
-before any slice_info exists. That case gets one retry with the offending
-template blanked; see ``_blank_custom_gcode``.
+Two things can defeat those embedded settings. A custom G-code template
+written by a Studio newer than the sidecar fails to parse before any
+slice_info exists; that case gets one retry with the offending template
+blanked, see ``_blank_custom_gcode``. And Bambu Studio writes inherit/unset
+markers into ``project_settings.config`` that some slicer builds' range
+validator rejects outright, so the same sanitiser the real slice runs is
+applied here too, see ``sanitize_project_settings_sentinels``.
 
 Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
 repeat opens on the same plate are instant. LRU eviction keeps the cache
@@ -42,6 +45,7 @@ from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiService,
 )
+from backend.app.utils.threemf_tools import sanitize_project_settings_sentinels
 
 logger = logging.getLogger(__name__)
 
@@ -203,13 +207,17 @@ async def get_preview_filaments(
 
     Uses the file's embedded settings (``slice_without_profiles``) since the
     slot mapping is a model property, independent of any user-picked profile
-    triplet. A slice killed by an unparsable custom G-code template is retried
-    once with that template blanked, still on the file's own settings.
+    triplet. Those settings are sentinel-sanitised first (#1201, #3030). A
+    slice killed by an unparsable custom G-code template is retried once with
+    that template blanked, still on the file's own settings.
 
     Returns ``None`` when the preview slice fails — the caller should fall
     back to whatever heuristic it has (typically the project_filaments +
     painted-face approach in ``threemf_tools``).
     """
+    # Hash the file as it was given to us, not as it is sent: the key
+    # identifies the source file, and sanitising is deterministic, so folding
+    # it in would only make two names for one thing.
     h = _content_hash(file_bytes)
     key: _PreviewCacheKey = (kind, source_id, plate_id, h)
     cached = _preview_cache.get(key)
@@ -231,6 +239,17 @@ async def get_preview_filaments(
         # while the slicer is visibly working.
         svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
 
+        # Same sanitiser the real slice runs (#1201, #3030). It matters more
+        # here, not less: this path slices on the file's own embedded
+        # settings, so there is no --load-settings pass that could supply a
+        # replacement for a field the CLI's range validator has already
+        # rejected. Without it a MakerWorld 3MF carrying Bambu's inherit
+        # markers fails before producing any slice_info, and the modal falls
+        # back to its painted-face heuristic for a file the slicer could have
+        # answered exactly. Applied before the G-code retry below so that
+        # retry inherits it rather than reintroducing the markers.
+        slice_bytes = sanitize_project_settings_sentinels(file_bytes)
+
         async def _slice(model_bytes: bytes):
             async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
                 return await svc.slice_without_profiles(
@@ -242,7 +261,7 @@ async def get_preview_filaments(
                 )
 
         try:
-            result = await _slice(file_bytes)
+            result = await _slice(slice_bytes)
         except SlicerApiError as e:
             # One retry, and only for a custom-G-code template the sidecar
             # cannot parse — a file from a Studio newer than the sidecar. The
@@ -259,7 +278,7 @@ async def get_preview_filaments(
             retry_bytes = None
             option = _unparsable_gcode_option(str(e))
             if option is not None:
-                retry_bytes = _blank_custom_gcode(file_bytes, option)
+                retry_bytes = _blank_custom_gcode(slice_bytes, option)
             if retry_bytes is None:
                 logger.warning(
                     "Preview slice failed for %s/%s plate %s: %s",

+ 122 - 0
backend/app/utils/threemf_tools.py

@@ -1767,3 +1767,125 @@ def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> s
                     used.update(_scan_paint(path))
         break
     return used
+
+
+# Keys in ``Metadata/project_settings.config`` that Bambu Studio writes an
+# "inherit / unset" marker into, mapped to the marker it uses for that key.
+# The slicer CLI's ``StaticPrintConfig`` validator runs against the embedded
+# settings *before* ``--load-settings`` overrides apply, so a marker the CLI's
+# own range check rejects makes it exit non-zero before our profile triplet is
+# ever consulted.
+#
+# There are two markers because there are two conventions, and which one a
+# given CLI rejects depends on the build:
+#
+#   "-1" -- inherit from the parent process preset (#1201, MakerWorld P2S
+#   3MFs). ``raft_first_layer_expansion`` and ``tree_support_wall_count`` are
+#   min 0 in every OrcaSlicer to date, so those still fail on the current
+#   sidecar; ``prime_tower_brim_width`` gained min -1 in Orca 2.4.2 and now
+#   passes there, but not on older builds.
+#
+#   "0" -- "use the active object/part filament", the default Bambu Studio
+#   writes for the three feature-filament indices (#3030). Bambu Studio and
+#   OrcaSlicer 2.4.0+ both define these min 0, so 0 is legal there; OrcaSlicer
+#   2.3.x and earlier still 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.
+#
+# Removing the key rather than rewriting it is what makes this safe on every
+# build: the CLI then falls back to its own compiled default, which is 0 on
+# the builds where 0 was legal (so nothing changes) and 1 on the older ones,
+# which is what "the active filament" means under that scheme.
+#
+# Allowlisted (rather than "strip every marker-shaped value") because some
+# fields legitimately take the marker value -- z_offset, translations, and any
+# feature index a user really did set to a first filament -- and a blanket
+# strip would silently corrupt those.
+#
+# Add new entries as reports surface: the slicer names the offending field
+# directly, e.g. ``<field>: <value> not in range [...]``.
+PROJECT_SETTINGS_SENTINELS: dict[str, str] = {
+    # Reported in #1201 (MakerWorld P2S 3MFs).
+    "raft_first_layer_expansion": "-1",
+    "tree_support_wall_count": "-1",
+    # Known sentinel case from earlier reports, cited in #1201.
+    "prime_tower_brim_width": "-1",
+    # Reported in #3030 (MakerWorld 3MF, OrcaSlicer sidecar).
+    "wall_filament": "0",
+    "sparse_infill_filament": "0",
+    "solid_infill_filament": "0",
+}
+
+PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
+
+def _is_sentinel(value: object, sentinel: str) -> bool:
+    """Does ``value`` carry ``sentinel``, whether stored as text or a number?
+
+    Bambu Studio writes every ``project_settings.config`` value as a string,
+    but a 3MF that has been round-tripped through another tool can carry the
+    same field as a JSON number. ``bool`` is excluded explicitly: it is an
+    ``int`` subclass in Python, and ``str(False)`` would otherwise never match
+    anyway -- the exclusion is there so a future numeric sentinel like ``0``
+    cannot be matched by ``False``.
+    """
+    if isinstance(value, bool):
+        return False
+    if isinstance(value, (str, int)):
+        return str(value) == sentinel
+    return False
+
+
+def sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
+    """Strip inherit/unset sentinels from a 3MF's ``project_settings.config``
+    so the slicer CLI's range validator accepts the file (#1201, #3030).
+
+    Removes only allowlisted keys (see ``PROJECT_SETTINGS_SENTINELS``) and only
+    when the value is exactly that key's sentinel. The rest of the config --
+    and every other entry in the zip -- is preserved byte-for-byte. Unlike a
+    whole-file strip this leaves ``StaticPrintConfig`` initialisation intact:
+    the file is still present, still parses, and the slicer falls back to the
+    supplied ``--load-settings`` value, or to its own default, 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 PROJECT_SETTINGS_PATH not in zin.namelist():
+                return zip_bytes
+            try:
+                config = json.loads(zin.read(PROJECT_SETTINGS_PATH).decode("utf-8"))
+            except (json.JSONDecodeError, UnicodeDecodeError):
+                return zip_bytes
+            if not isinstance(config, dict):
+                return zip_bytes
+            removed = {
+                key: sentinel
+                for key, sentinel in PROJECT_SETTINGS_SENTINELS.items()
+                if _is_sentinel(config.get(key), sentinel)
+            }
+            if not removed:
+                return zip_bytes
+            for key in removed:
+                config.pop(key, None)
+            patched = json.dumps(config)
+            logger.info(
+                "3MF sanitiser: removed inherit sentinels %s - slicer will use its defaults for those keys",
+                sorted(f"{key}={sentinel}" for key, sentinel in removed.items()),
+            )
+            dst = BytesIO()
+            with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+                for item in zin.infolist():
+                    if item.filename == PROJECT_SETTINGS_PATH:
+                        zout.writestr(item, patched)
+                    else:
+                        zout.writestr(item, zin.read(item.filename))
+            return dst.getvalue()
+    except (zipfile.BadZipFile, OSError):
+        return zip_bytes

+ 136 - 0
backend/tests/unit/services/test_slice_preview.py

@@ -495,3 +495,139 @@ class TestPreviewRetriesUnparsableGcode:
         assert len(stub.calls) == 2
         # A failed retry must not be cached — the sidecar may be upgraded.
         assert not slice_preview._preview_cache
+
+
+class _RecordingService:
+    """Succeeds every slice, recording the bytes it was handed."""
+
+    def __init__(self, response_bytes: bytes) -> None:
+        self.response_bytes = response_bytes
+        self.calls: list[bytes] = []
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *exc):
+        return False
+
+    async def slice_without_profiles(self, **kw):
+        self.calls.append(kw["model_bytes"])
+        return SliceResult(
+            content=self.response_bytes,
+            print_time_seconds=0,
+            filament_used_g=0.0,
+            filament_used_mm=0.0,
+        )
+
+
+class TestPreviewSanitisesSentinels:
+    """The preview slices on the file's own embedded settings, so there is no
+    ``--load-settings`` pass to supply a replacement for a field the CLI's
+    range validator has already rejected. Until #3030 this path handed the
+    sidecar raw bytes, so a MakerWorld 3MF carrying Bambu's inherit markers
+    failed before producing any slice_info and the modal fell back to its
+    painted-face heuristic for a file the slicer could have answered exactly.
+    """
+
+    @pytest.mark.asyncio
+    async def test_the_slicer_is_handed_sanitised_bytes(self):
+        original = _make_project_3mf(
+            {
+                "wall_filament": "0",
+                "raft_first_layer_expansion": "-1",
+                "layer_height": "0.2",
+            }
+        )
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _RecordingService(body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            result = await get_preview_filaments(
+                kind="library_file",
+                source_id=1,
+                plate_id=1,
+                file_bytes=original,
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        assert result is not None
+        sent = _settings_of(stub.calls[0])
+        assert "wall_filament" not in sent
+        assert "raft_first_layer_expansion" not in sent
+        # Everything the user actually configured still reaches the slicer.
+        assert sent["layer_height"] == "0.2"
+
+    @pytest.mark.asyncio
+    async def test_a_file_without_sentinels_is_forwarded_untouched(self):
+        original = _make_project_3mf({"layer_height": "0.2", "wall_filament": "2"})
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _RecordingService(body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            await get_preview_filaments(
+                kind="library_file",
+                source_id=1,
+                plate_id=1,
+                file_bytes=original,
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        # Not merely equal: the sanitiser returns the input object when it has
+        # nothing to do, so the common case never pays for a zip rebuild.
+        assert stub.calls[0] is original
+
+    @pytest.mark.asyncio
+    async def test_the_gcode_retry_keeps_the_sanitisation(self):
+        # Both faults at once. The retry derives from the sanitised bytes, so
+        # it must not reintroduce the sentinel while blanking the template --
+        # otherwise the retry trades one CLI rejection for the other.
+        original = _make_project_3mf(
+            {
+                "time_lapse_gcode": "{if timelapse_inline_photo}",
+                "wall_filament": "0",
+                "layer_height": "0.2",
+            }
+        )
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _FailThenSucceedService(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            result = await get_preview_filaments(
+                kind="library_file",
+                source_id=1,
+                plate_id=1,
+                file_bytes=original,
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        assert result is not None
+        assert len(stub.calls) == 2
+        for sent in stub.calls:
+            assert "wall_filament" not in _settings_of(sent)
+        assert _settings_of(stub.calls[1])["time_lapse_gcode"] == ""
+        assert _settings_of(stub.calls[1])["layer_height"] == "0.2"
+
+    @pytest.mark.asyncio
+    async def test_the_cache_key_still_follows_the_source_file(self):
+        # Sanitising is deterministic, so the key stays the hash of what the
+        # caller read off disk -- a second open of the same file is a hit, not
+        # a second 30-second slice.
+        original = _make_project_3mf({"wall_filament": "0"})
+        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
+        stub = _RecordingService(body)
+        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
+            first = await get_preview_filaments(
+                kind="library_file",
+                source_id=1,
+                plate_id=1,
+                file_bytes=original,
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+            second = await get_preview_filaments(
+                kind="library_file",
+                source_id=1,
+                plate_id=1,
+                file_bytes=original,
+                file_name="x.3mf",
+                api_url="http://sidecar",
+            )
+        assert second == first
+        assert len(stub.calls) == 1

+ 98 - 18
backend/tests/unit/test_project_settings_sentinel_sanitiser.py

@@ -1,4 +1,4 @@
-"""Unit tests for ``_sanitize_project_settings_sentinels`` (#1201).
+"""Unit tests for ``sanitize_project_settings_sentinels`` (#1201, #3030).
 
 MakerWorld 3MFs sliced for the P2S (and potentially other Bambu printers)
 ship ``Metadata/project_settings.config`` entries with ``"-1"`` values on
@@ -22,6 +22,21 @@ re-zip. The slicer then falls back to the supplied ``--load-settings``
 default for the removed keys, while every other entry in the zip stays
 byte-identical.
 
+#3030 added a second sentinel value. ``wall_filament``,
+``sparse_infill_filament`` and ``solid_infill_filament`` are filament indices
+that Bambu Studio writes as ``"0"`` meaning "use the active object/part
+filament". Bambu Studio and OrcaSlicer 2.4.0+ define these ``min 0``, so the
+value is legal there; OrcaSlicer 2.3.x and earlier used the 1-based scheme
+(``min 1``, default ``1``) and answer::
+
+    wall_filament: 0 not in range [1.000000,...]
+
+Sidecar images are version-tagged, so an install can be pinned to one of
+those builds. So the allowlist is a key -> sentinel mapping now rather than
+one global constant, and the two buckets must not bleed into each other: a
+``"-1"`` on a filament index is not a sentinel, and a ``"0"`` on a raft field
+is a value the user chose.
+
 Pinning the contract here rather than via the slicer integration tests
 because the fix is purely about the bytes we hand to the sidecar — no
 slicer mock needed.
@@ -33,9 +48,9 @@ import zipfile
 
 import pytest
 
-from backend.app.api.routes.library import (
-    _PROJECT_SETTINGS_SENTINEL_KEYS,
-    _sanitize_project_settings_sentinels,
+from backend.app.utils.threemf_tools import (
+    PROJECT_SETTINGS_SENTINELS,
+    sanitize_project_settings_sentinels,
 )
 
 
@@ -65,10 +80,10 @@ def _zip_namelist(zip_bytes: bytes) -> list[str]:
 
 
 class TestRemovesSentinelValues:
-    @pytest.mark.parametrize("key", sorted(_PROJECT_SETTINGS_SENTINEL_KEYS))
-    def test_removes_each_allowlisted_key_when_value_is_minus_one(self, key):
-        original = _make_3mf(settings={key: "-1", "layer_height": "0.2"})
-        sanitised = _sanitize_project_settings_sentinels(original)
+    @pytest.mark.parametrize(("key", "sentinel"), sorted(PROJECT_SETTINGS_SENTINELS.items()))
+    def test_removes_each_allowlisted_key_at_its_own_sentinel(self, key, sentinel):
+        original = _make_3mf(settings={key: sentinel, "layer_height": "0.2"})
+        sanitised = sanitize_project_settings_sentinels(original)
 
         cfg = _read_settings(sanitised)
         assert key not in cfg, f"Sentinel key {key!r} should have been removed"
@@ -85,7 +100,7 @@ class TestRemovesSentinelValues:
                 "layer_height": "0.2",
             }
         )
-        sanitised = _sanitize_project_settings_sentinels(original)
+        sanitised = sanitize_project_settings_sentinels(original)
 
         cfg = _read_settings(sanitised)
         assert "raft_first_layer_expansion" not in cfg
@@ -99,7 +114,7 @@ class TestPreservesUnaffectedValues:
         # A user who deliberately configured raft_first_layer_expansion=0 must
         # see that 0 forwarded to the slicer — only literal "-1" gets stripped.
         original = _make_3mf(settings={"raft_first_layer_expansion": "0"})
-        sanitised = _sanitize_project_settings_sentinels(original)
+        sanitised = sanitize_project_settings_sentinels(original)
         assert _read_settings(sanitised)["raft_first_layer_expansion"] == "0"
 
     def test_does_not_touch_non_allowlisted_keys_with_minus_one(self):
@@ -107,7 +122,7 @@ class TestPreservesUnaffectedValues:
         # Some Bambu fields legitimately allow negative values (z_offset,
         # translation, etc.) and a blanket "-1" strip would corrupt those.
         original = _make_3mf(settings={"z_offset": "-1", "layer_height": "0.2"})
-        sanitised = _sanitize_project_settings_sentinels(original)
+        sanitised = sanitize_project_settings_sentinels(original)
 
         cfg = _read_settings(sanitised)
         assert cfg["z_offset"] == "-1"
@@ -117,7 +132,7 @@ class TestPreservesUnaffectedValues:
         # If nothing needs sanitising, return the input identity-equal so
         # the caller's downstream comparisons / hashes don't churn.
         original = _make_3mf(settings={"layer_height": "0.2", "z_offset": "0"})
-        sanitised = _sanitize_project_settings_sentinels(original)
+        sanitised = sanitize_project_settings_sentinels(original)
         assert sanitised is original
 
     def test_does_not_strip_array_value_even_if_includes_minus_one(self):
@@ -128,7 +143,7 @@ class TestPreservesUnaffectedValues:
         # sentinel and removed wholesale. If a future report shows the CLI
         # rejects array-form sentinels, expand this then.
         original = _make_3mf(settings={"raft_first_layer_expansion": ["-1", "0"]})
-        sanitised = _sanitize_project_settings_sentinels(original)
+        sanitised = sanitize_project_settings_sentinels(original)
         cfg = _read_settings(sanitised)
         assert cfg["raft_first_layer_expansion"] == ["-1", "0"]
 
@@ -143,7 +158,7 @@ class TestZipPreservation:
                 "Metadata/_rels/model_settings.rels": "<rels/>",
             },
         )
-        sanitised = _sanitize_project_settings_sentinels(original)
+        sanitised = sanitize_project_settings_sentinels(original)
         assert sanitised is not original
 
         names = _zip_namelist(sanitised)
@@ -171,12 +186,12 @@ class TestDefensiveFallbacks:
         # routing decides whether 3MF sanitisation runs anyway, but
         # defending here means a misrouted call can't corrupt the bytes.
         garbage = b"not a zip file"
-        assert _sanitize_project_settings_sentinels(garbage) is garbage
+        assert sanitize_project_settings_sentinels(garbage) is garbage
 
     def test_returns_original_when_settings_config_absent(self):
         # 3MF without an embedded project_settings.config — nothing to do.
         original = _make_3mf(settings=None)
-        assert _sanitize_project_settings_sentinels(original) is original
+        assert sanitize_project_settings_sentinels(original) is original
 
     def test_returns_original_on_malformed_json(self):
         # Settings file present but not valid JSON. We don't risk rebuilding
@@ -187,7 +202,7 @@ class TestDefensiveFallbacks:
             zf.writestr("3D/3dmodel.model", "<model/>")
             zf.writestr("Metadata/project_settings.config", "{not valid json")
         original = buf.getvalue()
-        assert _sanitize_project_settings_sentinels(original) is original
+        assert sanitize_project_settings_sentinels(original) is original
 
     def test_returns_original_when_settings_root_is_not_a_dict(self):
         # Real-world configs are objects, but defend against an array root
@@ -198,4 +213,69 @@ class TestDefensiveFallbacks:
             zf.writestr("3D/3dmodel.model", "<model/>")
             zf.writestr("Metadata/project_settings.config", "[]")
         original = buf.getvalue()
-        assert _sanitize_project_settings_sentinels(original) is original
+        assert sanitize_project_settings_sentinels(original) is original
+
+
+class TestTheTwoSentinelBucketsDoNotBleed:
+    """Each key has exactly one sentinel, and the other bucket's value is a
+    real setting on it. Getting this wrong in either direction is worse than
+    the bug: strip a filament index that says ``-1`` and the slice loses a
+    value nothing will put back; strip a raft field that says ``0`` and a
+    user who deliberately turned the raft expansion off gets the preset's
+    default instead."""
+
+    @pytest.mark.parametrize("key", ["wall_filament", "sparse_infill_filament", "solid_infill_filament"])
+    def test_a_filament_index_of_minus_one_is_left_alone(self, key):
+        original = _make_3mf(settings={key: "-1"})
+        assert sanitize_project_settings_sentinels(original) is original
+
+    @pytest.mark.parametrize("key", ["raft_first_layer_expansion", "tree_support_wall_count", "prime_tower_brim_width"])
+    def test_a_zero_on_a_minus_one_key_is_left_alone(self, key):
+        original = _make_3mf(settings={key: "0"})
+        assert sanitize_project_settings_sentinels(original) is original
+
+    def test_a_mixed_config_removes_exactly_the_sentinels(self):
+        original = _make_3mf(
+            settings={
+                # sentinels, both buckets
+                "raft_first_layer_expansion": "-1",
+                "wall_filament": "0",
+                # same keys' non-sentinel values, crossed over
+                "tree_support_wall_count": "0",
+                "sparse_infill_filament": "-1",
+                # an explicit filament pick
+                "solid_infill_filament": "2",
+                "layer_height": "0.2",
+            }
+        )
+        cfg = _read_settings(sanitize_project_settings_sentinels(original))
+        assert "raft_first_layer_expansion" not in cfg
+        assert "wall_filament" not in cfg
+        assert cfg["tree_support_wall_count"] == "0"
+        assert cfg["sparse_infill_filament"] == "-1"
+        assert cfg["solid_infill_filament"] == "2"
+        assert cfg["layer_height"] == "0.2"
+
+
+class TestNumericValuesAreRecognised:
+    """Bambu Studio writes every value as a string, but a 3MF round-tripped
+    through another tool can carry the same field as a JSON number. The
+    slicer's validator reads the deserialised int either way, so the
+    sanitiser has to as well."""
+
+    def test_an_integer_sentinel_is_removed(self):
+        original = _make_3mf(settings={"wall_filament": 0, "layer_height": "0.2"})
+        cfg = _read_settings(sanitize_project_settings_sentinels(original))
+        assert "wall_filament" not in cfg
+        assert cfg["layer_height"] == "0.2"
+
+    def test_an_integer_non_sentinel_survives(self):
+        original = _make_3mf(settings={"wall_filament": 2})
+        assert sanitize_project_settings_sentinels(original) is original
+
+    def test_false_is_not_a_zero_sentinel(self):
+        # bool is an int subclass in Python. A config that stores a flag as
+        # JSON ``false`` under one of these names must not be mistaken for
+        # the numeric 0 sentinel.
+        original = _make_3mf(settings={"wall_filament": False})
+        assert sanitize_project_settings_sentinels(original) is original