slice_preview.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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.
  12. Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
  13. repeat opens on the same plate are instant. LRU eviction keeps the cache
  14. bounded. Hash invalidation handles in-place file replacement; no TTL is
  15. used because preview-slice output is deterministic for a given input.
  16. """
  17. from __future__ import annotations
  18. import asyncio
  19. import hashlib
  20. import logging
  21. import zipfile
  22. from collections import OrderedDict
  23. from io import BytesIO
  24. import defusedxml.ElementTree as ET
  25. from backend.app.services.slicer_api import (
  26. SlicerApiError,
  27. SlicerApiService,
  28. )
  29. logger = logging.getLogger(__name__)
  30. _PREVIEW_CACHE_MAX = 256
  31. _PreviewCacheKey = tuple[str, int, int, str]
  32. # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
  33. # returned a 3MF without filament data for this plate — caching the negative
  34. # avoids burning 30s+ per modal open on a known-bad input).
  35. _preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
  36. # Per-key locks prevent N concurrent modal opens on the same (file, plate)
  37. # from launching N redundant preview slices — only the first one runs, the
  38. # rest wait and read from the cache. Locks are evicted alongside cache
  39. # entries to keep the dict bounded; we do NOT cache transient sidecar
  40. # failures (network errors etc.) so those retry naturally on next request.
  41. _preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
  42. def _content_hash(file_bytes: bytes) -> str:
  43. return hashlib.sha256(file_bytes).hexdigest()[:16]
  44. async def get_preview_filaments(
  45. *,
  46. kind: str,
  47. source_id: int,
  48. plate_id: int,
  49. file_bytes: bytes,
  50. file_name: str,
  51. api_url: str,
  52. request_id: str | None = None,
  53. ) -> list[dict] | None:
  54. """Run a preview slice for ``plate_id``, parse the resulting slice_info,
  55. and return the per-plate filament list.
  56. Uses the file's embedded settings (``slice_without_profiles``) since the
  57. slot mapping is a model property, independent of any user-picked profile
  58. triplet.
  59. Returns ``None`` when the preview slice fails — the caller should fall
  60. back to whatever heuristic it has (typically the project_filaments +
  61. painted-face approach in ``threemf_tools``).
  62. """
  63. h = _content_hash(file_bytes)
  64. key: _PreviewCacheKey = (kind, source_id, plate_id, h)
  65. cached = _preview_cache.get(key)
  66. if cached is not None:
  67. _preview_cache.move_to_end(key)
  68. return cached
  69. lock = _preview_locks.setdefault(key, asyncio.Lock())
  70. async with lock:
  71. # Re-check after acquiring the lock — another coroutine may have
  72. # populated the cache while we were waiting on it.
  73. cached = _preview_cache.get(key)
  74. if cached is not None:
  75. _preview_cache.move_to_end(key)
  76. return cached
  77. try:
  78. async with SlicerApiService(base_url=api_url) as svc:
  79. result = await svc.slice_without_profiles(
  80. model_bytes=file_bytes,
  81. model_filename=file_name,
  82. plate=plate_id,
  83. export_3mf=True,
  84. request_id=request_id,
  85. )
  86. except SlicerApiError as e:
  87. logger.warning(
  88. "Preview slice failed for %s/%s plate %s: %s",
  89. kind,
  90. source_id,
  91. plate_id,
  92. e,
  93. )
  94. return None
  95. except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
  96. logger.warning("Preview slice unexpected error: %s", e)
  97. return None
  98. filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id)
  99. # Negative-cache the parse failure: a slice that succeeds but yields
  100. # no parsable filament data for this plate is a deterministic
  101. # property of the input. Re-running the slice produces the same
  102. # result, just N seconds slower. Empty list signals "preview was
  103. # tried, no usable data" so the caller can fall through.
  104. cache_value: list[dict] = filaments if filaments is not None else []
  105. _preview_cache[key] = cache_value
  106. if len(_preview_cache) > _PREVIEW_CACHE_MAX:
  107. evicted_key, _ = _preview_cache.popitem(last=False)
  108. # Drop the matching lock so the dict doesn't grow forever.
  109. # Safe to discard: the lock isn't held here, and any later
  110. # request for the same key will mint a fresh lock.
  111. _preview_locks.pop(evicted_key, None)
  112. return filaments
  113. def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None:
  114. """Extract ``<filament>`` entries for ``plate_id`` from a sliced 3MF's
  115. Metadata/slice_info.config. Returns ``None`` on any parse error so the
  116. caller knows to fall back."""
  117. try:
  118. with zipfile.ZipFile(BytesIO(content)) as zf:
  119. if "Metadata/slice_info.config" not in zf.namelist():
  120. return None
  121. data = zf.read("Metadata/slice_info.config").decode()
  122. except (zipfile.BadZipFile, OSError):
  123. return None
  124. try:
  125. root = ET.fromstring(data)
  126. except ET.ParseError:
  127. return None
  128. for plate_elem in root.findall(".//plate"):
  129. idx = None
  130. for meta in plate_elem.findall("metadata"):
  131. if meta.get("key") == "index":
  132. try:
  133. idx = int(meta.get("value", ""))
  134. except (ValueError, TypeError):
  135. pass
  136. break
  137. if idx != plate_id:
  138. continue
  139. out: list[dict] = []
  140. for f in plate_elem.findall("filament"):
  141. fid = f.get("id")
  142. if not fid:
  143. continue
  144. try:
  145. slot_id = int(fid)
  146. except (ValueError, TypeError):
  147. continue
  148. try:
  149. used_grams = float(f.get("used_g", "0"))
  150. except (ValueError, TypeError):
  151. used_grams = 0
  152. try:
  153. used_meters = float(f.get("used_m", "0"))
  154. except (ValueError, TypeError):
  155. used_meters = 0
  156. out.append(
  157. {
  158. "slot_id": slot_id,
  159. "type": f.get("type", ""),
  160. "color": f.get("color", ""),
  161. "used_grams": round(used_grams, 1),
  162. "used_meters": used_meters,
  163. "tray_info_idx": f.get("tray_info_idx", ""),
  164. },
  165. )
  166. return sorted(out, key=lambda x: x["slot_id"])
  167. return None