slice_preview.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. """Preview-slice cache for the SliceModal.
  2. The slice modal needs the per-plate filament list before the user picks
  3. profiles. For sliced files this lives in ``Metadata/slice_info.config`` and
  4. the ``/filament-requirements`` endpoint can read it directly. For unsliced
  5. project files it doesn't exist yet — only the slicer can produce it, since
  6. Bambu Studio applies its own pruning to painted-face data at slice time.
  7. This module wraps the sidecar's slice call so the endpoint can run a preview
  8. slice, parse the result's slice_info, and return the actual filament list.
  9. The preview always uses the file's embedded settings (``slice_without_profiles``):
  10. the slot-mapping is a model property, independent of process settings, so
  11. we don't need to thread the user's profile triplet through here. That choice
  12. also protects the numbers — overriding the process preset drops the project's
  13. own support configuration, which loses whole slots from the answer.
  14. The one thing that can defeat those embedded settings is a custom G-code
  15. template written by a Studio newer than the sidecar, which fails to parse
  16. before any slice_info exists. That case gets one retry with the offending
  17. template blanked; see ``_blank_custom_gcode``.
  18. Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
  19. repeat opens on the same plate are instant. LRU eviction keeps the cache
  20. bounded. Hash invalidation handles in-place file replacement; no TTL is
  21. used because preview-slice output is deterministic for a given input.
  22. """
  23. from __future__ import annotations
  24. import asyncio
  25. import hashlib
  26. import json
  27. import logging
  28. import re
  29. import zipfile
  30. from collections import OrderedDict
  31. from io import BytesIO
  32. import defusedxml.ElementTree as ET
  33. from backend.app.services.slicer_api import (
  34. SlicerApiError,
  35. SlicerApiService,
  36. )
  37. logger = logging.getLogger(__name__)
  38. _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
  39. # The slicer names the offending G-code field in its stderr, e.g.
  40. # timelapse_gcode Parsing error at line 13: Not a variable name
  41. # {if timelapse_inline_photo}
  42. _GCODE_PARSE_ERROR_RE = re.compile(
  43. r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s+Parsing error at line \d+:",
  44. re.MULTILINE,
  45. )
  46. # Custom G-code fields we are willing to blank to get a preview through.
  47. #
  48. # Deliberately narrow, and the narrowness is the whole point: blanking a
  49. # field that *extrudes* would change the very numbers the preview exists to
  50. # report. `machine_start_gcode` lays a prime line, `change_filament_gcode`
  51. # purges — silence either and the returned grams are quietly wrong, which is
  52. # worse than returning nothing. Everything below only moves the toolhead or
  53. # emits markers, so removing it cannot alter filament accounting. Verified
  54. # against a real H2D slice: blanking `time_lapse_gcode` left every
  55. # used_g/used_m in slice_info byte-identical.
  56. #
  57. # Keys are normalised (see `_normalise_option`) because the slicer reports
  58. # `timelapse_gcode` while the 3MF stores `time_lapse_gcode`.
  59. _BLANKABLE_GCODE_FIELDS = frozenset(
  60. {
  61. "timelapsegcode",
  62. "layerchangegcode",
  63. "beforelayerchangegcode",
  64. "machinepausegcode",
  65. "templatecustomgcode",
  66. "printingbyobjectgcode",
  67. }
  68. )
  69. def _normalise_option(name: str) -> str:
  70. """Fold a config-option name to a comparable form.
  71. Bambu Studio's error text and its 3MF config disagree on word breaks for
  72. the same option (`timelapse_gcode` vs `time_lapse_gcode`), so matching on
  73. the literal string silently fails to find the field it just named.
  74. """
  75. return re.sub(r"[^a-z0-9]", "", name.lower())
  76. _PREVIEW_CACHE_MAX = 256
  77. _PreviewCacheKey = tuple[str, int, int, str]
  78. # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
  79. # returned a 3MF without filament data for this plate — caching the negative
  80. # avoids burning 30s+ per modal open on a known-bad input).
  81. _preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
  82. # Per-key locks prevent N concurrent modal opens on the same (file, plate)
  83. # from launching N redundant preview slices — only the first one runs, the
  84. # rest wait and read from the cache. Locks are evicted alongside cache
  85. # entries to keep the dict bounded; we do NOT cache transient sidecar
  86. # failures (network errors etc.) so those retry naturally on next request.
  87. _preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
  88. def _content_hash(file_bytes: bytes) -> str:
  89. return hashlib.sha256(file_bytes).hexdigest()[:16]
  90. def _unparsable_gcode_option(error_text: str) -> str | None:
  91. """The normalised name of the custom-G-code field the slicer choked on.
  92. Returns ``None`` when the failure was something else entirely, or when the
  93. named field is one whose removal could change filament accounting — see
  94. ``_BLANKABLE_GCODE_FIELDS``. Callers treat ``None`` as "don't retry".
  95. """
  96. match = _GCODE_PARSE_ERROR_RE.search(error_text)
  97. if match is None:
  98. return None
  99. option = _normalise_option(match.group(1))
  100. return option if option in _BLANKABLE_GCODE_FIELDS else None
  101. def _blank_custom_gcode(file_bytes: bytes, option: str) -> bytes | None:
  102. """Return a copy of the 3MF with ``option``'s G-code template emptied.
  103. A 3MF saved by a newer Bambu Studio can carry a machine G-code template
  104. that references a config variable an older sidecar doesn't define — e.g.
  105. Studio 2.8 writes ``{if timelapse_inline_photo}`` into ``time_lapse_gcode``
  106. without exporting a definition for it, so the template is unresolvable the
  107. moment it leaves Studio. Slicing then dies with a placeholder parse error
  108. before producing any slice_info, and the preview has nothing to read.
  109. Emptying just the one named template lets the slice complete on the file's
  110. own settings, which is what keeps the answer trustworthy: process settings,
  111. support configuration and per-slot filament assignments are all preserved,
  112. so the filament list matches what the file would really produce.
  113. Returns ``None`` when there is nothing to do — not a 3MF, no embedded
  114. settings, no matching field, or a field that is already empty — so the
  115. caller can skip a retry that would fail identically.
  116. """
  117. try:
  118. with zipfile.ZipFile(BytesIO(file_bytes)) as zf:
  119. if _PROJECT_SETTINGS_PATH not in zf.namelist():
  120. return None
  121. entries = [(info, zf.read(info.filename)) for info in zf.infolist()]
  122. settings = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8", "replace"))
  123. except (zipfile.BadZipFile, OSError, UnicodeDecodeError, json.JSONDecodeError):
  124. return None
  125. if not isinstance(settings, dict):
  126. return None
  127. # Match on the normalised name so the slicer's spelling finds the config's.
  128. # Only `*_gcode` keys are eligible, so a same-stem non-template setting
  129. # can never be caught by the fold.
  130. blanked: list[str] = []
  131. for key, value in settings.items():
  132. if not key.endswith("_gcode") or _normalise_option(key) != option:
  133. continue
  134. if isinstance(value, str) and value:
  135. settings[key] = ""
  136. elif isinstance(value, list) and any(value):
  137. # Preserve the container type — a per-extruder template is a list,
  138. # and handing the CLI a bare string where it expects one would
  139. # trade this parse error for a different one.
  140. settings[key] = [""] * len(value)
  141. else:
  142. continue
  143. blanked.append(key)
  144. if not blanked:
  145. return None
  146. out = BytesIO()
  147. try:
  148. with zipfile.ZipFile(out, "w") as zf_out:
  149. for info, data in entries:
  150. if info.filename == _PROJECT_SETTINGS_PATH:
  151. data = json.dumps(settings, indent=4).encode("utf-8")
  152. # Carry each member's original compression across so the copy
  153. # stays a 3MF the slicer reads the same way as the original.
  154. zf_out.writestr(info, data, compress_type=info.compress_type)
  155. except (OSError, ValueError):
  156. return None
  157. logger.debug("Preview slice: emptied custom G-code field(s) %s for retry", ", ".join(blanked))
  158. return out.getvalue()
  159. async def get_preview_filaments(
  160. *,
  161. kind: str,
  162. source_id: int,
  163. plate_id: int,
  164. file_bytes: bytes,
  165. file_name: str,
  166. api_url: str,
  167. request_id: str | None = None,
  168. timeout_seconds: float | None = None,
  169. ) -> list[dict] | None:
  170. """Run a preview slice for ``plate_id``, parse the resulting slice_info,
  171. and return the per-plate filament list.
  172. Uses the file's embedded settings (``slice_without_profiles``) since the
  173. slot mapping is a model property, independent of any user-picked profile
  174. triplet. A slice killed by an unparsable custom G-code template is retried
  175. once with that template blanked, still on the file's own settings.
  176. Returns ``None`` when the preview slice fails — the caller should fall
  177. back to whatever heuristic it has (typically the project_filaments +
  178. painted-face approach in ``threemf_tools``).
  179. """
  180. h = _content_hash(file_bytes)
  181. key: _PreviewCacheKey = (kind, source_id, plate_id, h)
  182. cached = _preview_cache.get(key)
  183. if cached is not None:
  184. _preview_cache.move_to_end(key)
  185. return cached
  186. lock = _preview_locks.setdefault(key, asyncio.Lock())
  187. async with lock:
  188. # Re-check after acquiring the lock — another coroutine may have
  189. # populated the cache while we were waiting on it.
  190. cached = _preview_cache.get(key)
  191. if cached is not None:
  192. _preview_cache.move_to_end(key)
  193. return cached
  194. # Preview slices are bounded the same way as real ones (#2730):
  195. # a heavy plate can take a long time and must not be cut off
  196. # while the slicer is visibly working.
  197. svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
  198. async def _slice(model_bytes: bytes):
  199. async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
  200. return await svc.slice_without_profiles(
  201. model_bytes=model_bytes,
  202. model_filename=file_name,
  203. plate=plate_id,
  204. export_3mf=True,
  205. request_id=request_id,
  206. )
  207. try:
  208. result = await _slice(file_bytes)
  209. except SlicerApiError as e:
  210. # One retry, and only for a custom-G-code template the sidecar
  211. # cannot parse — a file from a Studio newer than the sidecar. The
  212. # alternative is to give the caller nothing and let it fall back to
  213. # its painted-face heuristic, so a retry that reproduces the file's
  214. # own settings is strictly better than the status quo. Anything
  215. # else (unreachable sidecar, timeout, bad input) returns as before.
  216. #
  217. # Whether a retry is even possible is decided *before* anything is
  218. # logged, so a slice that recovers never announces itself as a
  219. # failure. Logging the first attempt at WARNING regardless sent a
  220. # reader looking for a bug in a path that had already fixed itself
  221. # twenty seconds later, several screens further down the log.
  222. retry_bytes = None
  223. option = _unparsable_gcode_option(str(e))
  224. if option is not None:
  225. retry_bytes = _blank_custom_gcode(file_bytes, option)
  226. if retry_bytes is None:
  227. logger.warning(
  228. "Preview slice failed for %s/%s plate %s: %s",
  229. kind,
  230. source_id,
  231. plate_id,
  232. e,
  233. )
  234. return None
  235. logger.info(
  236. "Preview slice for %s/%s plate %s hit unparsable custom G-code; retrying without it. "
  237. "The file's G-code references a setting this slicer build does not know, so it is "
  238. "probably from a newer Bambu Studio than the sidecar. Original failure: %s",
  239. kind,
  240. source_id,
  241. plate_id,
  242. e,
  243. )
  244. try:
  245. result = await _slice(retry_bytes)
  246. except SlicerApiError as retry_exc:
  247. logger.warning(
  248. "Preview slice retry without the unparsable G-code also failed for %s/%s plate %s: %s",
  249. kind,
  250. source_id,
  251. plate_id,
  252. retry_exc,
  253. )
  254. return None
  255. except Exception as retry_exc: # noqa: BLE001 — never break the modal on sidecar issues
  256. logger.warning("Preview slice retry unexpected error: %s", retry_exc)
  257. return None
  258. logger.info("Preview slice for %s/%s plate %s succeeded on retry", kind, source_id, plate_id)
  259. except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
  260. logger.warning("Preview slice unexpected error: %s", e)
  261. return None
  262. filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id)
  263. # Negative-cache the parse failure: a slice that succeeds but yields
  264. # no parsable filament data for this plate is a deterministic
  265. # property of the input. Re-running the slice produces the same
  266. # result, just N seconds slower. Empty list signals "preview was
  267. # tried, no usable data" so the caller can fall through.
  268. cache_value: list[dict] = filaments if filaments is not None else []
  269. _preview_cache[key] = cache_value
  270. if len(_preview_cache) > _PREVIEW_CACHE_MAX:
  271. evicted_key, _ = _preview_cache.popitem(last=False)
  272. # Drop the matching lock so the dict doesn't grow forever.
  273. # Safe to discard: the lock isn't held here, and any later
  274. # request for the same key will mint a fresh lock.
  275. _preview_locks.pop(evicted_key, None)
  276. return filaments
  277. def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None:
  278. """Extract ``<filament>`` entries for ``plate_id`` from a sliced 3MF's
  279. Metadata/slice_info.config. Returns ``None`` on any parse error so the
  280. caller knows to fall back."""
  281. try:
  282. with zipfile.ZipFile(BytesIO(content)) as zf:
  283. if "Metadata/slice_info.config" not in zf.namelist():
  284. return None
  285. data = zf.read("Metadata/slice_info.config").decode()
  286. except (zipfile.BadZipFile, OSError):
  287. return None
  288. try:
  289. root = ET.fromstring(data)
  290. except ET.ParseError:
  291. return None
  292. for plate_elem in root.findall(".//plate"):
  293. idx = None
  294. for meta in plate_elem.findall("metadata"):
  295. if meta.get("key") == "index":
  296. try:
  297. idx = int(meta.get("value", ""))
  298. except (ValueError, TypeError):
  299. pass
  300. break
  301. if idx != plate_id:
  302. continue
  303. out: list[dict] = []
  304. for f in plate_elem.findall("filament"):
  305. fid = f.get("id")
  306. if not fid:
  307. continue
  308. try:
  309. slot_id = int(fid)
  310. except (ValueError, TypeError):
  311. continue
  312. try:
  313. used_grams = float(f.get("used_g", "0"))
  314. except (ValueError, TypeError):
  315. used_grams = 0
  316. try:
  317. used_meters = float(f.get("used_m", "0"))
  318. except (ValueError, TypeError):
  319. used_meters = 0
  320. out.append(
  321. {
  322. "slot_id": slot_id,
  323. "type": f.get("type", ""),
  324. "color": f.get("color", ""),
  325. "used_grams": round(used_grams, 1),
  326. "used_meters": used_meters,
  327. "tray_info_idx": f.get("tray_info_idx", ""),
  328. },
  329. )
  330. return sorted(out, key=lambda x: x["slot_id"])
  331. return None