mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
Slicing for a P2S failed with "filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle" — naming a profile shown nowhere in the dialog. The picked profile was "Overture PLA Matte @0.2", whose inheritance chain roots in that X1C profile. The dialog classifies a profile by its compatible_printers list and falls back to reading the printer out of its name. That name carries no model, and the list — present on the imported copy — is not shipped by every source: Bambu Cloud omits it deliberately (rate limits), and Orca Cloud shipped it but Bambuddy only mined filament type and colour from the same content. Orca Cloud entries now carry their own compatible_printers, and the existing same-name enrichment bridge carries the list onto entries that lack one, in both directions between the cloud tiers. A bare "@<size>" name tag is read as a nozzle size as a last resort: it can rule a printer out but never rules one in, and implausible values are ignored rather than guessed at.
326 lines
15 KiB
Python
326 lines
15 KiB
Python
"""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 ``<metadata key="plater_id" value="..."/>`` entries — each
|
|
# ``<plate>`` 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'<metadata key="plater_id" value="(\d+)"', xml))
|
|
|
|
|
|
def extract_source_printer_model(zip_bytes: bytes) -> 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"<plate>.*?</plate>", 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
|
|
``<plate>`` 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 <plate>...</plate> 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 = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
"<config>\n"
|
|
" <header>\n"
|
|
' <header_item key="X-BBL-Client-Type" value="slicer"/>\n'
|
|
' <header_item key="X-BBL-Client-Version" value="02.06.00.51"/>\n'
|
|
" </header>\n" + "\n".join(f" {block}" for block in plate_blocks) + "\n</config>\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 from the plate's lowest *used* slot.
|
|
|
|
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) and for printer
|
|
compatibility ("filament preset X (slot N) is not compatible with
|
|
printer Y", exit -5) — even though the plate's G-code never touches
|
|
the unused slot. Substituting unused entries with a used slot's
|
|
filament keeps the per-filament array length intact (so the source
|
|
3MF's per-slot references stay valid) while making the loaded set
|
|
both materially homogeneous and printer-correct, so both validators
|
|
pass.
|
|
|
|
The anchor is the lowest used slot, NOT slot 1 (#2628). Slot 1 is
|
|
itself unused on plenty of plates, and anchoring there did the two
|
|
things this function exists to prevent: the substitution became a
|
|
no-op for the slot that needed it most, and — with more than one
|
|
unused slot — it propagated slot 1's own preset (in the reported
|
|
case an ``@Bambu Lab H2D`` profile baked into the source 3MF) into
|
|
every other unused slot, blocking an A1 slice on slots the plate
|
|
doesn't even use.
|
|
|
|
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,
|
|
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
|
|
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)
|
|
# Anchor on the lowest used slot that actually exists in the list. A
|
|
# plate can reference a slot beyond the submitted list (a truncated or
|
|
# mismatched pick set) — those can't be an anchor, and if none of the
|
|
# used slots is in range there is nothing trustworthy to copy from, so
|
|
# leave the user's picks alone rather than inventing a substitution.
|
|
in_range_used = sorted(s for s in used if 1 <= s <= len(out))
|
|
if not in_range_used:
|
|
return items
|
|
anchor_slot = in_range_used[0]
|
|
substituted = []
|
|
for idx in range(len(out)):
|
|
slot = idx + 1
|
|
if slot not in used:
|
|
substituted.append(slot)
|
|
out[idx] = out[anchor_slot - 1]
|
|
if substituted:
|
|
logger.info(
|
|
"Substituted slot-%s filament for unused slot(s) %s on plate %s "
|
|
"(avoids loaded-filament temp-spread and printer-compatibility validators)",
|
|
anchor_slot,
|
|
substituted,
|
|
plate_id,
|
|
)
|
|
return out
|