slice_preview.py 6.8 KB

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