slice_preview.py 17 KB

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