"""Per-slice 3MF input normalisation for the slicer pipeline. This module currently exposes one helper, :func:`substitute_unused_plate_filaments`, which rewrites the user's filament list so unused-slot entries don't trip BambuStudio's loaded-filament temperature validator. The original goal of this module — a two-pass cross-nozzle-class config-splice (#1493) — was replaced by a simpler approach: forwarding the sidecar's existing ``--arrange`` flag (see ``slicer_api.SlicerApiService.slice_with_profiles`` and ``_run_slicer_with_fallback`` in ``api/routes/library.py``). BambuStudio itself reconciles the embedded ``project_settings.config`` against the target printer when ``--arrange`` is on, so Bambuddy never has to reproduce that schema logic locally. """ from __future__ import annotations import json import logging import re import zipfile from io import BytesIO logger = logging.getLogger(__name__) _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config" _MODEL_SETTINGS_PATH = "Metadata/model_settings.config" _SLICE_INFO_PATH = "Metadata/slice_info.config" def count_plates_in_3mf(zip_bytes: bytes) -> int: """Return the number of plates the source 3MF defines, or ``0`` if the file isn't a parseable 3MF / has no plate metadata. Used by the cross-class slice-all loop (#1493) to know how many ``--slice N`` calls to dispatch before merging the per-plate outputs back into one multi-plate 3MF. """ try: with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf: if _MODEL_SETTINGS_PATH not in zf.namelist(): return 0 xml = zf.read(_MODEL_SETTINGS_PATH).decode("utf-8", errors="replace") except (zipfile.BadZipFile, OSError, KeyError): return 0 # Count ```` entries — each # ```` element carries exactly one. Cheap and tolerant of the # full schema (no need to parse the whole XML, which is large and may # contain CDATA quirks). return len(re.findall(r' str | None: """Return the canonical short model code (e.g. ``"X1C"``, ``"H2D"``) for the 3MF's embedded ``printer_model`` field, or ``None`` if the input isn't a 3MF, has no embedded settings, the field is missing, or the model isn't recognised. Canonicalisation goes through :func:`normalize_printer_model`, which strips the ``"Bambu Lab "`` vendor prefix and maps long display names to the short codes that :func:`is_dual_nozzle_model` matches against (the raw field is ``"Bambu Lab H2D"``, not ``"H2D"``). """ from backend.app.utils.printer_models import normalize_printer_model try: with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf: if _PROJECT_SETTINGS_PATH not in zf.namelist(): return None cfg = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")) except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError): return None if not isinstance(cfg, dict): return None raw = cfg.get("printer_model") if not raw: return None canonical = normalize_printer_model(str(raw)) return canonical or None _PLATE_BLOCK_RE = re.compile(r".*?", re.DOTALL) def merge_plate_3mfs( plate_outputs: list[tuple[int, bytes]], source_3mf_bytes: bytes | None = None, ) -> bytes: """Combine N single-plate sliced 3MFs into one multi-plate 3MF. Used by the cross-class slice-all loop (#1493) where Bambuddy slices each plate independently against the target printer (BS CLI's ``--arrange`` is project-wide so a single ``--slice 0`` call would consolidate every plate's objects onto one bed — the bug this whole path exists to work around). Each input is a single-plate 3MF whose ``Metadata/plate_N.gcode`` / ``plate_N.json`` / ``plate_N.png`` entries already carry the right plate index because the BS CLI preserves the requested plate number in the output filenames. The merge strategy: - The first plate's 3MF is the base — its ``project_settings.config`` (target printer), ``3D/3dmodel.model``, and Auxiliaries images carry forward. - Per-plate artifacts from the other inputs (``plate_N.gcode``, ``plate_N.gcode.md5``, ``plate_N.json``, ``plate_N.png``, ``plate_N_small.png``, ``plate_no_light_N.png``, ``top_N.png``, ``pick_N.png``) are overlaid into the base. - ``slice_info.config`` is re-assembled from each input's single ```` block so the resulting file lists all N plates. - ``source_3mf_bytes``, when supplied, is used as a fallback source of per-plate thumbnails (``plate_N.png`` and ``plate_N_small.png``) when the sliced outputs don't carry them — BS CLI with ``--arrange`` regenerates the plate gcode but rarely writes a fresh per-plate preview, so without this fallback the merged 3MF would only have a cover image for plate 1 (the base 3MF) and the archive page's per-plate previews would be blank. Returns the merged 3MF bytes. Single-element input is a passthrough. Empty input raises ``ValueError``. """ if not plate_outputs: raise ValueError("merge_plate_3mfs: at least one plate output required") ordered = sorted(plate_outputs, key=lambda p: p[0]) if len(ordered) == 1: return ordered[0][1] # Collect each plate's ... block out of its # slice_info.config. The single-plate slice output puts exactly one # such block; if a plate's output is missing the section (shouldn't # happen on a successful slice, but stay defensive) skip it — better # to ship a partial multi-plate 3MF than to fail the whole merge. plate_blocks: list[str] = [] for plate_num, plate_bytes in ordered: try: with zipfile.ZipFile(BytesIO(plate_bytes), "r") as zf: if _SLICE_INFO_PATH not in zf.namelist(): continue xml = zf.read(_SLICE_INFO_PATH).decode("utf-8", errors="replace") except (zipfile.BadZipFile, OSError, KeyError) as exc: logger.warning("merge_plate_3mfs: couldn't read plate %d slice_info (%s)", plate_num, exc) continue match = _PLATE_BLOCK_RE.search(xml) if match: plate_blocks.append(match.group(0)) combined_slice_info = ( '\n' "\n" "
\n" ' \n' ' \n' "
\n" + "\n".join(f" {block}" for block in plate_blocks) + "\n
\n" ).encode("utf-8") # Per-plate artifact filenames we lift from each input into the base. def _per_plate_entries(n: int) -> set[str]: return { f"Metadata/plate_{n}.gcode", f"Metadata/plate_{n}.gcode.md5", f"Metadata/plate_{n}.json", f"Metadata/plate_{n}.png", f"Metadata/plate_{n}_small.png", f"Metadata/plate_no_light_{n}.png", f"Metadata/top_{n}.png", f"Metadata/pick_{n}.png", } # When the per-plate slices skip writing ``plate_N.png`` (BS CLI with # ``--arrange`` does this — the gcode is fresh but the preview slot # is empty), fall back to the source 3MF's stored render of the same # plate. The visual layout will differ from the arranged H2D version # but a recognisable preview is much better than a blank card. def _source_thumbnail_fallback(plate_num: int) -> dict[str, bytes]: if source_3mf_bytes is None: return {} wanted = { f"Metadata/plate_{plate_num}.png", f"Metadata/plate_{plate_num}_small.png", } found: dict[str, bytes] = {} try: with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as src_zf: for name in src_zf.namelist(): if name in wanted: found[name] = src_zf.read(name) except (zipfile.BadZipFile, OSError) as exc: logger.warning("merge_plate_3mfs: source thumbnail fallback failed (%s)", exc) return found base_num, base_bytes = ordered[0] out_buf = BytesIO() base_zip_names: set[str] = set() with ( zipfile.ZipFile(BytesIO(base_bytes), "r") as base_zf, zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as out_zf, ): # Pass 1: emit base entries. Track which per-plate-N thumbnails # the base actually had so the fallback pass below can fill in # the ones that are missing. for item in base_zf.infolist(): base_zip_names.add(item.filename) if item.filename == _SLICE_INFO_PATH: out_zf.writestr(item, combined_slice_info) else: out_zf.writestr(item, base_zf.read(item.filename)) # Source-thumbnail fallback for the base plate when the slicer # didn't write its own preview. for name, payload in _source_thumbnail_fallback(base_num).items(): if name not in base_zip_names: out_zf.writestr(name, payload) base_zip_names.add(name) # Pass 2: overlay per-plate artifacts from the other plates' # 3MFs, falling back to the source for any plate-N thumbnails # the slicer didn't write. for plate_num, plate_bytes in ordered[1:]: wanted = _per_plate_entries(plate_num) written: set[str] = set() try: with zipfile.ZipFile(BytesIO(plate_bytes), "r") as plate_zf: for name in plate_zf.namelist(): if name in wanted: out_zf.writestr(name, plate_zf.read(name)) written.add(name) except (zipfile.BadZipFile, OSError) as exc: logger.warning( "merge_plate_3mfs: couldn't read plate %d artifacts (%s); skipping", plate_num, exc, ) continue for name, payload in _source_thumbnail_fallback(plate_num).items(): if name not in written and name not in base_zip_names: out_zf.writestr(name, payload) return out_buf.getvalue() def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]: """Replace any filament-list entry whose 1-indexed slot isn't used by ``plate_id`` with the entry at slot 1 (index 0). Why: the slice modal lets the user pick a filament profile per slot, but each plate in a multi-plate project only uses a subset of those slots. The modal labels the unused rows "not used by this plate" yet still submits their dropdown values. BambuStudio then validates every loaded filament for material compatibility — PLA in a used slot + ABS defaulted into an unused slot trips "the temperature difference of the filaments used is too large" (exit 194), even though the plate's G-code never touches the ABS slot. Substituting unused entries with slot 1's filament keeps the per-filament array length intact (so the source 3MF's per-slot references stay valid) while making the loaded-filament set materially homogeneous, so the validator passes. The substitution is a no-op when: - ``plate_id`` is None (we can't determine which slots are unused), - the source isn't a valid 3MF / zip, - the source doesn't carry plate-extruder metadata (parse returns empty set — treat as "every slot is used", same fallback the SliceModal uses), - ``items`` has fewer than 2 entries (nothing to substitute). """ if plate_id is None or len(items) < 2: 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 try: with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf: used = extract_plate_extruder_set_from_3mf(zf, plate_id) except (zipfile.BadZipFile, OSError) as exc: logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc) return items if not used: # Empty result usually means the source 3MF has no per-object # extruder metadata (single-filament unsliced project). Treating # "no info" as "every slot is used" matches the SliceModal's # fail-open default — better to send the user's picks through # than to silently rewrite them. return items out = list(items) substituted = [] for idx in range(len(out)): slot = idx + 1 if slot not in used: substituted.append(slot) out[idx] = out[0] if substituted: logger.info( "Substituted slot-1 filament for unused slot(s) %s on plate %s " "(avoids loaded-filament temp-spread validator)", substituted, plate_id, ) return out