slicer_3mf_convert.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  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 from the plate's lowest *used* slot.
  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 "the temperature difference
  219. of the filaments used is too large", exit 194) and for printer
  220. compatibility ("filament preset X (slot N) is not compatible with
  221. printer Y", exit -5) — even though the plate's G-code never touches
  222. the unused slot. Substituting unused entries with a used slot's
  223. filament keeps the per-filament array length intact (so the source
  224. 3MF's per-slot references stay valid) while making the loaded set
  225. both materially homogeneous and printer-correct, so both validators
  226. pass.
  227. The anchor is the lowest used slot, NOT slot 1 (#2628). Slot 1 is
  228. itself unused on plenty of plates, and anchoring there did the two
  229. things this function exists to prevent: the substitution became a
  230. no-op for the slot that needed it most, and — with more than one
  231. unused slot — it propagated slot 1's own preset (in the reported
  232. case an ``@Bambu Lab H2D`` profile baked into the source 3MF) into
  233. every other unused slot, blocking an A1 slice on slots the plate
  234. doesn't even use.
  235. The substitution is a no-op when:
  236. - ``plate_id`` is not a real plate — ``None`` (caller couldn't say) or
  237. ``0`` (the slice-all sentinel, where every slot is used by *some*
  238. plate so there is nothing unused to substitute). Callers that know
  239. "absent means plate 1" must resolve that themselves before calling;
  240. this function will not guess, because guessing wrong rewrites a
  241. filament the plate actually prints with.
  242. - the source isn't a valid 3MF / zip,
  243. - the source doesn't carry plate-extruder metadata (parse returns
  244. empty set — treat as "every slot is used", same fallback the
  245. SliceModal uses),
  246. - ``items`` has fewer than 2 entries (nothing to substitute).
  247. The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
  248. 1-indexed, so the geometry lookup for plate 0 matches nothing — but
  249. the support-filament slots unioned in below come from the project
  250. config and carry no plate scope at all. Without the guard a slice-all
  251. of a project with a dedicated support slot would see ``used`` as just
  252. that one slot, anchor on it, and rewrite every colour in the project
  253. to the support material (#2711).
  254. """
  255. if plate_id is None or plate_id < 1 or len(items) < 2:
  256. return items
  257. # Local import keeps the bytes->ZipFile boundary in this module and
  258. # avoids dragging zipfile into every caller.
  259. from backend.app.utils.threemf_tools import (
  260. extract_plate_extruder_set_from_3mf,
  261. extract_support_filament_slots_from_3mf,
  262. )
  263. try:
  264. with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
  265. # Geometry-derived slots (per-object metadata + paint_color)
  266. # plus process-derived support-filament slots. Supports aren't
  267. # attached to object geometry so the geometry pass alone
  268. # misses PVA-in-support-slot setups (#1881).
  269. used = extract_plate_extruder_set_from_3mf(zf, plate_id)
  270. used |= extract_support_filament_slots_from_3mf(zf)
  271. except (zipfile.BadZipFile, OSError) as exc:
  272. logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc)
  273. return items
  274. if not used:
  275. # Empty result usually means the source 3MF has no per-object
  276. # extruder metadata (single-filament unsliced project). Treating
  277. # "no info" as "every slot is used" matches the SliceModal's
  278. # fail-open default — better to send the user's picks through
  279. # than to silently rewrite them.
  280. return items
  281. out = list(items)
  282. # Anchor on the lowest used slot that actually exists in the list. A
  283. # plate can reference a slot beyond the submitted list (a truncated or
  284. # mismatched pick set) — those can't be an anchor, and if none of the
  285. # used slots is in range there is nothing trustworthy to copy from, so
  286. # leave the user's picks alone rather than inventing a substitution.
  287. in_range_used = sorted(s for s in used if 1 <= s <= len(out))
  288. if not in_range_used:
  289. return items
  290. anchor_slot = in_range_used[0]
  291. substituted = []
  292. for idx in range(len(out)):
  293. slot = idx + 1
  294. if slot not in used:
  295. substituted.append(slot)
  296. out[idx] = out[anchor_slot - 1]
  297. if substituted:
  298. logger.info(
  299. "Substituted slot-%s filament for unused slot(s) %s on plate %s "
  300. "(avoids loaded-filament temp-spread and printer-compatibility validators)",
  301. anchor_slot,
  302. substituted,
  303. plate_id,
  304. )
  305. return out