slicer_3mf_convert.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """Per-slice 3MF input normalisation for the slicer pipeline.
  2. This module currently exposes one helper, :func:`substitute_unused_plate_filaments`,
  3. which rewrites the user's filament list so unused-slot entries don't trip
  4. BambuStudio's loaded-filament temperature validator. The original goal of
  5. this module — a two-pass cross-nozzle-class config-splice (#1493) — was
  6. replaced by a simpler approach: forwarding the sidecar's existing
  7. ``--arrange`` flag (see ``slicer_api.SlicerApiService.slice_with_profiles``
  8. and ``_run_slicer_with_fallback`` in ``api/routes/library.py``). BambuStudio
  9. itself reconciles the embedded ``project_settings.config`` against the
  10. target printer when ``--arrange`` is on, so Bambuddy never has to reproduce
  11. that schema logic locally.
  12. """
  13. from __future__ import annotations
  14. import json
  15. import logging
  16. import re
  17. import zipfile
  18. from io import BytesIO
  19. logger = logging.getLogger(__name__)
  20. _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
  21. _MODEL_SETTINGS_PATH = "Metadata/model_settings.config"
  22. _SLICE_INFO_PATH = "Metadata/slice_info.config"
  23. def count_plates_in_3mf(zip_bytes: bytes) -> int:
  24. """Return the number of plates the source 3MF defines, or ``0`` if the
  25. file isn't a parseable 3MF / has no plate metadata. Used by the
  26. cross-class slice-all loop (#1493) to know how many ``--slice N``
  27. calls to dispatch before merging the per-plate outputs back into one
  28. multi-plate 3MF.
  29. """
  30. try:
  31. with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
  32. if _MODEL_SETTINGS_PATH not in zf.namelist():
  33. return 0
  34. xml = zf.read(_MODEL_SETTINGS_PATH).decode("utf-8", errors="replace")
  35. except (zipfile.BadZipFile, OSError, KeyError):
  36. return 0
  37. # Count ``<metadata key="plater_id" value="..."/>`` entries — each
  38. # ``<plate>`` element carries exactly one. Cheap and tolerant of the
  39. # full schema (no need to parse the whole XML, which is large and may
  40. # contain CDATA quirks).
  41. return len(re.findall(r'<metadata key="plater_id" value="(\d+)"', xml))
  42. def extract_source_printer_model(zip_bytes: bytes) -> str | None:
  43. """Return the canonical short model code (e.g. ``"X1C"``, ``"H2D"``) for
  44. the 3MF's embedded ``printer_model`` field, or ``None`` if the input
  45. isn't a 3MF, has no embedded settings, the field is missing, or the
  46. model isn't recognised. Canonicalisation goes through
  47. :func:`normalize_printer_model`, which strips the ``"Bambu Lab "``
  48. vendor prefix and maps long display names to the short codes that
  49. :func:`is_dual_nozzle_model` matches against (the raw field is
  50. ``"Bambu Lab H2D"``, not ``"H2D"``).
  51. """
  52. from backend.app.utils.printer_models import normalize_printer_model
  53. try:
  54. with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
  55. if _PROJECT_SETTINGS_PATH not in zf.namelist():
  56. return None
  57. cfg = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8"))
  58. except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
  59. return None
  60. if not isinstance(cfg, dict):
  61. return None
  62. raw = cfg.get("printer_model")
  63. if not raw:
  64. return None
  65. canonical = normalize_printer_model(str(raw))
  66. return canonical or None
  67. _PLATE_BLOCK_RE = re.compile(r"<plate>.*?</plate>", re.DOTALL)
  68. def merge_plate_3mfs(
  69. plate_outputs: list[tuple[int, bytes]],
  70. source_3mf_bytes: bytes | None = None,
  71. ) -> bytes:
  72. """Combine N single-plate sliced 3MFs into one multi-plate 3MF.
  73. Used by the cross-class slice-all loop (#1493) where Bambuddy slices
  74. each plate independently against the target printer (BS CLI's
  75. ``--arrange`` is project-wide so a single ``--slice 0`` call would
  76. consolidate every plate's objects onto one bed — the bug this whole
  77. path exists to work around). Each input is a single-plate 3MF whose
  78. ``Metadata/plate_N.gcode`` / ``plate_N.json`` / ``plate_N.png``
  79. entries already carry the right plate index because the BS CLI
  80. preserves the requested plate number in the output filenames.
  81. The merge strategy:
  82. - The first plate's 3MF is the base — its ``project_settings.config``
  83. (target printer), ``3D/3dmodel.model``, and Auxiliaries images
  84. carry forward.
  85. - Per-plate artifacts from the other inputs (``plate_N.gcode``,
  86. ``plate_N.gcode.md5``, ``plate_N.json``, ``plate_N.png``,
  87. ``plate_N_small.png``, ``plate_no_light_N.png``, ``top_N.png``,
  88. ``pick_N.png``) are overlaid into the base.
  89. - ``slice_info.config`` is re-assembled from each input's single
  90. ``<plate>`` block so the resulting file lists all N plates.
  91. - ``source_3mf_bytes``, when supplied, is used as a fallback source
  92. of per-plate thumbnails (``plate_N.png`` and ``plate_N_small.png``)
  93. when the sliced outputs don't carry them — BS CLI with ``--arrange``
  94. regenerates the plate gcode but rarely writes a fresh per-plate
  95. preview, so without this fallback the merged 3MF would only have
  96. a cover image for plate 1 (the base 3MF) and the archive page's
  97. per-plate previews would be blank.
  98. Returns the merged 3MF bytes. Single-element input is a passthrough.
  99. Empty input raises ``ValueError``.
  100. """
  101. if not plate_outputs:
  102. raise ValueError("merge_plate_3mfs: at least one plate output required")
  103. ordered = sorted(plate_outputs, key=lambda p: p[0])
  104. if len(ordered) == 1:
  105. return ordered[0][1]
  106. # Collect each plate's <plate>...</plate> block out of its
  107. # slice_info.config. The single-plate slice output puts exactly one
  108. # such block; if a plate's output is missing the section (shouldn't
  109. # happen on a successful slice, but stay defensive) skip it — better
  110. # to ship a partial multi-plate 3MF than to fail the whole merge.
  111. plate_blocks: list[str] = []
  112. for plate_num, plate_bytes in ordered:
  113. try:
  114. with zipfile.ZipFile(BytesIO(plate_bytes), "r") as zf:
  115. if _SLICE_INFO_PATH not in zf.namelist():
  116. continue
  117. xml = zf.read(_SLICE_INFO_PATH).decode("utf-8", errors="replace")
  118. except (zipfile.BadZipFile, OSError, KeyError) as exc:
  119. logger.warning("merge_plate_3mfs: couldn't read plate %d slice_info (%s)", plate_num, exc)
  120. continue
  121. match = _PLATE_BLOCK_RE.search(xml)
  122. if match:
  123. plate_blocks.append(match.group(0))
  124. combined_slice_info = (
  125. '<?xml version="1.0" encoding="UTF-8"?>\n'
  126. "<config>\n"
  127. " <header>\n"
  128. ' <header_item key="X-BBL-Client-Type" value="slicer"/>\n'
  129. ' <header_item key="X-BBL-Client-Version" value="02.06.00.51"/>\n'
  130. " </header>\n" + "\n".join(f" {block}" for block in plate_blocks) + "\n</config>\n"
  131. ).encode("utf-8")
  132. # Per-plate artifact filenames we lift from each input into the base.
  133. def _per_plate_entries(n: int) -> set[str]:
  134. return {
  135. f"Metadata/plate_{n}.gcode",
  136. f"Metadata/plate_{n}.gcode.md5",
  137. f"Metadata/plate_{n}.json",
  138. f"Metadata/plate_{n}.png",
  139. f"Metadata/plate_{n}_small.png",
  140. f"Metadata/plate_no_light_{n}.png",
  141. f"Metadata/top_{n}.png",
  142. f"Metadata/pick_{n}.png",
  143. }
  144. # When the per-plate slices skip writing ``plate_N.png`` (BS CLI with
  145. # ``--arrange`` does this — the gcode is fresh but the preview slot
  146. # is empty), fall back to the source 3MF's stored render of the same
  147. # plate. The visual layout will differ from the arranged H2D version
  148. # but a recognisable preview is much better than a blank card.
  149. def _source_thumbnail_fallback(plate_num: int) -> dict[str, bytes]:
  150. if source_3mf_bytes is None:
  151. return {}
  152. wanted = {
  153. f"Metadata/plate_{plate_num}.png",
  154. f"Metadata/plate_{plate_num}_small.png",
  155. }
  156. found: dict[str, bytes] = {}
  157. try:
  158. with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as src_zf:
  159. for name in src_zf.namelist():
  160. if name in wanted:
  161. found[name] = src_zf.read(name)
  162. except (zipfile.BadZipFile, OSError) as exc:
  163. logger.warning("merge_plate_3mfs: source thumbnail fallback failed (%s)", exc)
  164. return found
  165. base_num, base_bytes = ordered[0]
  166. out_buf = BytesIO()
  167. base_zip_names: set[str] = set()
  168. with (
  169. zipfile.ZipFile(BytesIO(base_bytes), "r") as base_zf,
  170. zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as out_zf,
  171. ):
  172. # Pass 1: emit base entries. Track which per-plate-N thumbnails
  173. # the base actually had so the fallback pass below can fill in
  174. # the ones that are missing.
  175. for item in base_zf.infolist():
  176. base_zip_names.add(item.filename)
  177. if item.filename == _SLICE_INFO_PATH:
  178. out_zf.writestr(item, combined_slice_info)
  179. else:
  180. out_zf.writestr(item, base_zf.read(item.filename))
  181. # Source-thumbnail fallback for the base plate when the slicer
  182. # didn't write its own preview.
  183. for name, payload in _source_thumbnail_fallback(base_num).items():
  184. if name not in base_zip_names:
  185. out_zf.writestr(name, payload)
  186. base_zip_names.add(name)
  187. # Pass 2: overlay per-plate artifacts from the other plates'
  188. # 3MFs, falling back to the source for any plate-N thumbnails
  189. # the slicer didn't write.
  190. for plate_num, plate_bytes in ordered[1:]:
  191. wanted = _per_plate_entries(plate_num)
  192. written: set[str] = set()
  193. try:
  194. with zipfile.ZipFile(BytesIO(plate_bytes), "r") as plate_zf:
  195. for name in plate_zf.namelist():
  196. if name in wanted:
  197. out_zf.writestr(name, plate_zf.read(name))
  198. written.add(name)
  199. except (zipfile.BadZipFile, OSError) as exc:
  200. logger.warning(
  201. "merge_plate_3mfs: couldn't read plate %d artifacts (%s); skipping",
  202. plate_num,
  203. exc,
  204. )
  205. continue
  206. for name, payload in _source_thumbnail_fallback(plate_num).items():
  207. if name not in written and name not in base_zip_names:
  208. out_zf.writestr(name, payload)
  209. return out_buf.getvalue()
  210. def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]:
  211. """Replace any filament-list entry whose 1-indexed slot isn't used by
  212. ``plate_id`` with the entry at slot 1 (index 0).
  213. Why: the slice modal lets the user pick a filament profile per slot,
  214. but each plate in a multi-plate project only uses a subset of those
  215. slots. The modal labels the unused rows "not used by this plate" yet
  216. still submits their dropdown values. BambuStudio then validates every
  217. loaded filament for material compatibility — PLA in a used slot +
  218. ABS defaulted into an unused slot trips
  219. "the temperature difference of the filaments used is too large"
  220. (exit 194), even though the plate's G-code never touches the ABS
  221. slot. Substituting unused entries with slot 1's filament keeps the
  222. per-filament array length intact (so the source 3MF's per-slot
  223. references stay valid) while making the loaded-filament set
  224. materially homogeneous, so the validator passes.
  225. The substitution is a no-op when:
  226. - ``plate_id`` is None (we can't determine which slots are unused),
  227. - the source isn't a valid 3MF / zip,
  228. - the source doesn't carry plate-extruder metadata (parse returns
  229. empty set — treat as "every slot is used", same fallback the
  230. SliceModal uses),
  231. - ``items`` has fewer than 2 entries (nothing to substitute).
  232. """
  233. if plate_id is None or len(items) < 2:
  234. return items
  235. # Local import keeps the bytes->ZipFile boundary in this module and
  236. # avoids dragging zipfile into every caller.
  237. from backend.app.utils.threemf_tools import extract_plate_extruder_set_from_3mf
  238. try:
  239. with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
  240. used = extract_plate_extruder_set_from_3mf(zf, plate_id)
  241. except (zipfile.BadZipFile, OSError) as exc:
  242. logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc)
  243. return items
  244. if not used:
  245. # Empty result usually means the source 3MF has no per-object
  246. # extruder metadata (single-filament unsliced project). Treating
  247. # "no info" as "every slot is used" matches the SliceModal's
  248. # fail-open default — better to send the user's picks through
  249. # than to silently rewrite them.
  250. return items
  251. out = list(items)
  252. substituted = []
  253. for idx in range(len(out)):
  254. slot = idx + 1
  255. if slot not in used:
  256. substituted.append(slot)
  257. out[idx] = out[0]
  258. if substituted:
  259. logger.info(
  260. "Substituted slot-1 filament for unused slot(s) %s on plate %s "
  261. "(avoids loaded-filament temp-spread validator)",
  262. substituted,
  263. plate_id,
  264. )
  265. return out