slice_preview.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  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. Two slice modes are supported:
  10. * "embedded settings" mode (default) — calls ``slice_without_profiles`` so
  11. the slicer falls back on the file's own ``Metadata/project_settings.config``.
  12. Used when the SliceModal opens before the user has picked a profile
  13. triplet and we just want the slot-mapping (which is a model property,
  14. independent of process settings).
  15. * "bundle" mode — when the caller passes a bundle id + per-category preset
  16. names, calls ``slice_with_bundle`` so the preview reflects the same
  17. triplet the real print will use. More accurate gram numbers; same slot
  18. mapping. Used after the SliceModal's Bundle tier resolves.
  19. Results are cached by ``(kind, source_id, plate_id, content_hash, bundle_key)``
  20. so different bundle picks on the same file don't collide and repeat opens
  21. on the same plate + same bundle are instant. LRU eviction keeps the cache
  22. bounded. Hash invalidation handles in-place file replacement; no TTL is
  23. used because preview-slice output is deterministic for a given input.
  24. """
  25. from __future__ import annotations
  26. import asyncio
  27. import hashlib
  28. import logging
  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. _PREVIEW_CACHE_MAX = 256
  39. # Cache key includes a bundle-context fingerprint (or "" when no bundle was
  40. # supplied) so a "preview without profiles" result and a "preview with
  41. # bundle X" result for the same file/plate occupy distinct entries instead
  42. # of clobbering each other.
  43. _PreviewCacheKey = tuple[str, int, int, str, str]
  44. # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
  45. # returned a 3MF without filament data for this plate — caching the negative
  46. # avoids burning 30s+ per modal open on a known-bad input).
  47. _preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
  48. # Per-key locks prevent N concurrent modal opens on the same (file, plate,
  49. # bundle) from launching N redundant preview slices — only the first one
  50. # runs, the rest wait and read from the cache. Locks are evicted alongside
  51. # cache entries to keep the dict bounded; we do NOT cache transient sidecar
  52. # failures (network errors etc.) so those retry naturally on next request.
  53. _preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
  54. def _content_hash(file_bytes: bytes) -> str:
  55. return hashlib.sha256(file_bytes).hexdigest()[:16]
  56. def _bundle_context_fingerprint(
  57. bundle_id: str | None,
  58. printer_name: str | None,
  59. process_name: str | None,
  60. filament_names: list[str] | None,
  61. ) -> str:
  62. """Derive a stable cache-key fragment for the bundle context. Empty
  63. string when no bundle is supplied — preserves cache compatibility with
  64. the no-bundle ("embedded settings") path so existing entries remain
  65. valid. SHA-256 prefix keeps the key short while collision-resistant
  66. enough for a 256-entry LRU.
  67. """
  68. if not (bundle_id and printer_name and process_name and filament_names):
  69. return ""
  70. parts = [bundle_id, printer_name, process_name, *filament_names]
  71. raw = "\x1f".join(parts).encode("utf-8")
  72. return hashlib.sha256(raw).hexdigest()[:12]
  73. async def get_preview_filaments(
  74. *,
  75. kind: str,
  76. source_id: int,
  77. plate_id: int,
  78. file_bytes: bytes,
  79. file_name: str,
  80. api_url: str,
  81. request_id: str | None = None,
  82. bundle_id: str | None = None,
  83. printer_name: str | None = None,
  84. process_name: str | None = None,
  85. filament_names: list[str] | None = None,
  86. ) -> list[dict] | None:
  87. """Run a preview slice for ``plate_id``, parse the resulting slice_info,
  88. and return the per-plate filament list.
  89. By default uses the file's embedded settings (``slice_without_profiles``).
  90. When all four ``bundle_*`` params are provided, uses ``slice_with_bundle``
  91. so the preview matches the profile triplet the real print will use —
  92. same slot mapping, more-accurate gram numbers. Partial bundle context
  93. (e.g. id without preset names) falls back to the embedded path rather
  94. than failing, so an in-progress modal selection doesn't surface errors.
  95. Returns ``None`` when the preview slice fails — the caller should fall
  96. back to whatever heuristic it has (typically the project_filaments +
  97. painted-face approach in ``threemf_tools``).
  98. """
  99. h = _content_hash(file_bytes)
  100. bundle_fp = _bundle_context_fingerprint(
  101. bundle_id,
  102. printer_name,
  103. process_name,
  104. filament_names,
  105. )
  106. key: _PreviewCacheKey = (kind, source_id, plate_id, h, bundle_fp)
  107. cached = _preview_cache.get(key)
  108. if cached is not None:
  109. _preview_cache.move_to_end(key)
  110. return cached
  111. lock = _preview_locks.setdefault(key, asyncio.Lock())
  112. async with lock:
  113. # Re-check after acquiring the lock — another coroutine may have
  114. # populated the cache while we were waiting on it.
  115. cached = _preview_cache.get(key)
  116. if cached is not None:
  117. _preview_cache.move_to_end(key)
  118. return cached
  119. try:
  120. async with SlicerApiService(base_url=api_url) as svc:
  121. if bundle_fp:
  122. # All four bundle params present (guaranteed non-None by
  123. # _bundle_context_fingerprint returning non-empty);
  124. # the type-checker can't see that, so assert for narrowing.
  125. assert bundle_id and printer_name and process_name
  126. assert filament_names is not None
  127. result = await svc.slice_with_bundle(
  128. model_bytes=file_bytes,
  129. model_filename=file_name,
  130. bundle_id=bundle_id,
  131. printer_name=printer_name,
  132. process_name=process_name,
  133. filament_names=filament_names,
  134. plate=plate_id,
  135. export_3mf=True,
  136. request_id=request_id,
  137. )
  138. else:
  139. result = await svc.slice_without_profiles(
  140. model_bytes=file_bytes,
  141. model_filename=file_name,
  142. plate=plate_id,
  143. export_3mf=True,
  144. request_id=request_id,
  145. )
  146. except SlicerApiError as e:
  147. logger.warning(
  148. "Preview slice failed for %s/%s plate %s (bundle=%s): %s",
  149. kind,
  150. source_id,
  151. plate_id,
  152. bundle_id or "-",
  153. e,
  154. )
  155. return None
  156. except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
  157. logger.warning("Preview slice unexpected error: %s", e)
  158. return None
  159. filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id)
  160. # Negative-cache the parse failure: a slice that succeeds but yields
  161. # no parsable filament data for this plate is a deterministic
  162. # property of the input. Re-running the slice produces the same
  163. # result, just N seconds slower. Empty list signals "preview was
  164. # tried, no usable data" so the caller can fall through.
  165. cache_value: list[dict] = filaments if filaments is not None else []
  166. _preview_cache[key] = cache_value
  167. if len(_preview_cache) > _PREVIEW_CACHE_MAX:
  168. evicted_key, _ = _preview_cache.popitem(last=False)
  169. # Drop the matching lock so the dict doesn't grow forever.
  170. # Safe to discard: the lock isn't held here, and any later
  171. # request for the same key will mint a fresh lock.
  172. _preview_locks.pop(evicted_key, None)
  173. return filaments
  174. def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None:
  175. """Extract ``<filament>`` entries for ``plate_id`` from a sliced 3MF's
  176. Metadata/slice_info.config. Returns ``None`` on any parse error so the
  177. caller knows to fall back."""
  178. try:
  179. with zipfile.ZipFile(BytesIO(content)) as zf:
  180. if "Metadata/slice_info.config" not in zf.namelist():
  181. return None
  182. data = zf.read("Metadata/slice_info.config").decode()
  183. except (zipfile.BadZipFile, OSError):
  184. return None
  185. try:
  186. root = ET.fromstring(data)
  187. except ET.ParseError:
  188. return None
  189. for plate_elem in root.findall(".//plate"):
  190. idx = None
  191. for meta in plate_elem.findall("metadata"):
  192. if meta.get("key") == "index":
  193. try:
  194. idx = int(meta.get("value", ""))
  195. except (ValueError, TypeError):
  196. pass
  197. break
  198. if idx != plate_id:
  199. continue
  200. out: list[dict] = []
  201. for f in plate_elem.findall("filament"):
  202. fid = f.get("id")
  203. if not fid:
  204. continue
  205. try:
  206. slot_id = int(fid)
  207. except (ValueError, TypeError):
  208. continue
  209. try:
  210. used_grams = float(f.get("used_g", "0"))
  211. except (ValueError, TypeError):
  212. used_grams = 0
  213. try:
  214. used_meters = float(f.get("used_m", "0"))
  215. except (ValueError, TypeError):
  216. used_meters = 0
  217. out.append(
  218. {
  219. "slot_id": slot_id,
  220. "type": f.get("type", ""),
  221. "color": f.get("color", ""),
  222. "used_grams": round(used_grams, 1),
  223. "used_meters": used_meters,
  224. "tray_info_idx": f.get("tray_info_idx", ""),
  225. },
  226. )
  227. return sorted(out, key=lambda x: x["slot_id"])
  228. return None