plate_thumbnail.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. """Plate thumbnail injection for sliced 3MFs.
  2. When the slicer CLI (Bambu Studio or OrcaSlicer in the docker sidecar)
  3. produces a ``.gcode.3mf`` without ``Metadata/plate_N.png``, the archive
  4. card has nothing to show. Both CLIs skip the plate-thumbnail render when
  5. invoked with ``--slice --export-3mf`` headlessly — that render is a
  6. GUI-side action that only fires in the desktop Studio. The
  7. ``--export-png`` flag exists but is mutually exclusive with
  8. ``--export-3mf`` and additionally needs a Wayland compositor in the
  9. container, so we can't reach it from the sidecar's current invocation
  10. shape.
  11. This module fills the gap server-side: it parses the sliced 3MF, and
  12. for every ``plate_N.gcode`` entry that doesn't have a matching
  13. ``plate_N.png`` it renders one from the embedded 3D model using the
  14. same trimesh + matplotlib path as :mod:`backend.app.services.stl_thumbnail`,
  15. then injects ``Metadata/plate_N.png`` (512x512) + ``Metadata/plate_N_small.png``
  16. (128x128) into the zip. Best-effort: any failure (no model file,
  17. trimesh can't parse, matplotlib render fails) returns the input bytes
  18. unchanged so the slice flow itself never breaks.
  19. """
  20. from __future__ import annotations
  21. import io
  22. import logging
  23. import re
  24. import threading
  25. import zipfile
  26. from collections import defaultdict
  27. from dataclasses import dataclass, field
  28. logger = logging.getLogger(__name__)
  29. # Bambu Studio's plate covers. Match the dimensions BS uses on desktop so
  30. # the rendered images flow through the same archive UI code paths without
  31. # special-casing.
  32. _PLATE_PNG_SIZE = 512
  33. _PLATE_PNG_SMALL_SIZE = 128
  34. # Mirror stl_thumbnail.py's palette so archive cards rendered through
  35. # this path are visually consistent with the rest of Bambuddy's library
  36. # thumbnails — same Bambu green on the same dark background.
  37. _BAMBU_GREEN = "#00AE42"
  38. _BACKGROUND_COLOR = "#1a1a1a"
  39. # Faces the whole plate is rendered with, every instance counted. Render cost
  40. # is faces, not vertices: matplotlib's Poly3DCollection slows down nonlinearly
  41. # past ~200k of them, and a 512x512 PNG resolves nothing finer. Roughly what
  42. # stl_thumbnail's 100k-vertex cap comes to on a closed mesh.
  43. _RENDER_FACE_BUDGET = 200_000
  44. # A mesh is never decimated below this, however many times it is placed, or a
  45. # plate of small parts renders as a field of blobs.
  46. _MIN_FACES_PER_MESH = 200
  47. # Past this many faces after decimation (a plate of thousands of parts, each
  48. # already at the floor above) the thumbnail is skipped. It is best-effort, and
  49. # the render's memory grows with every face it is handed (#3135).
  50. _MAX_PLACED_FACES = 1_000_000
  51. # Bounds on the object graph: components nest, and a file that references
  52. # itself, or places one part a million times, must not be walked forever.
  53. _MAX_COMPONENT_DEPTH = 16
  54. _MAX_PLACEMENTS = 20_000
  55. _MODEL_ROOT = "3D/3dmodel.model"
  56. # One plate render at a time. The slice routes run this off the event loop, and
  57. # a render holds the whole placed plate in memory; two slices finishing
  58. # together must not hold two. pyplot is NOT what this guards — the renderer
  59. # below never touches it (see ``_render_at_size``).
  60. _render_lock = threading.Lock()
  61. # Plate-gcode entries look like ``Metadata/plate_1.gcode``,
  62. # ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
  63. _PLATE_GCODE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
  64. def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
  65. """Return ``threemf_bytes`` with ``plate_N.png`` injected for every
  66. plate that's missing one.
  67. No-op fast path when every plate already has a thumbnail — the input
  68. bytes are returned verbatim (same object identity), so the common
  69. case of a desktop-Studio-sliced 3MF flowing through this function
  70. is essentially free.
  71. On any failure the input bytes are returned unchanged. A missing
  72. thumbnail is a visual degradation; failing the slice would be worse.
  73. """
  74. try:
  75. with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
  76. names = set(zf.namelist())
  77. missing = _missing_plate_ids(names)
  78. if not missing:
  79. return threemf_bytes
  80. if _MODEL_ROOT not in names:
  81. logger.debug(
  82. "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
  83. sorted(missing),
  84. )
  85. return threemf_bytes
  86. except (zipfile.BadZipFile, OSError) as exc:
  87. logger.warning("plate_thumbnail: input is not a readable zip: %s", exc)
  88. return threemf_bytes
  89. try:
  90. with _render_lock:
  91. large_png, small_png = _render_model_thumbnails(threemf_bytes)
  92. except Exception as exc:
  93. logger.warning(
  94. "plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
  95. exc,
  96. exc_info=True,
  97. )
  98. return threemf_bytes
  99. if large_png is None or small_png is None:
  100. return threemf_bytes
  101. try:
  102. return _inject_pngs(threemf_bytes, missing, large_png, small_png)
  103. except (zipfile.BadZipFile, OSError) as exc:
  104. logger.warning("plate_thumbnail: zip re-pack failed: %s", exc)
  105. return threemf_bytes
  106. def _missing_plate_ids(names: set[str]) -> list[int]:
  107. """Plate IDs that have a ``plate_N.gcode`` but no ``plate_N.png``.
  108. Multi-plate slices produce one gcode per plate; we render the model
  109. once and reuse it for every missing plate. The visual is identical
  110. across plates of the same model, which matches what users see today
  111. for desktop-Studio-sliced multi-plate projects — Studio also reuses
  112. the model render across plates that share geometry.
  113. """
  114. plate_ids: list[int] = []
  115. for name in names:
  116. m = _PLATE_GCODE_RE.match(name)
  117. if not m:
  118. continue
  119. n = int(m.group(1))
  120. if f"Metadata/plate_{n}.png" not in names:
  121. plate_ids.append(n)
  122. return sorted(plate_ids)
  123. def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes | None]:
  124. """Render an isometric view of the 3MF's model at both plate sizes.
  125. Returns (large, small) PNG bytes, or (None, None) if the model
  126. couldn't be loaded. Mirrors stl_thumbnail.py's style (Bambu green
  127. mesh on dark background, ~25deg elev / 45deg azim) so this output
  128. blends into Bambuddy's existing library/archive cards.
  129. """
  130. # Local imports so a `import backend.app.services.plate_thumbnail` from
  131. # an environment without matplotlib/trimesh doesn't fail at import time —
  132. # the function will simply degrade to no-op via the exception branch.
  133. #
  134. # The light angle is IMPORTED rather than mirrored like the palette above.
  135. # "A plate card and a library thumbnail of the same model look alike" is the
  136. # whole reason these two renderers share a look, and a second copy of the
  137. # angle is exactly how that silently stops being true. A palette can afford a
  138. # copy; a number nobody would notice drifting cannot.
  139. from backend.app.services.stl_thumbnail import (
  140. _configure_matplotlib_cache,
  141. _repair_winding,
  142. _shade_kwargs,
  143. )
  144. _configure_matplotlib_cache()
  145. import trimesh
  146. from matplotlib.colors import LightSource
  147. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  148. with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
  149. placed = _load_plate_geometry(zf, trimesh, _repair_winding)
  150. if placed is None:
  151. return None, None
  152. vertices, faces = placed
  153. bounds_min = vertices.min(axis=0)
  154. bounds_max = vertices.max(axis=0)
  155. centered = vertices - (bounds_min + bounds_max) / 2
  156. max_extent = (bounds_max - bounds_min).max()
  157. scaled = centered / max_extent if max_extent > 0 else centered
  158. # ndarray, not a list of lists — shading walks this to build normals, and the
  159. # list form is ~30x slower to construct. Paid twice per plate: once per size.
  160. poly3d = scaled[faces]
  161. # Resolved once and shared: both sizes must be lit identically or the 128px
  162. # card and the 512px view disagree. Empty for a mesh matplotlib cannot shade,
  163. # which keeps such a plate rendering flat instead of failing — see
  164. # ``_shade_kwargs``.
  165. shade_kw = _shade_kwargs(poly3d, LightSource)
  166. large = _render_at_size(poly3d, _PLATE_PNG_SIZE, Poly3DCollection, shade_kw)
  167. small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, Poly3DCollection, shade_kw)
  168. return large, small
  169. @dataclass
  170. class _Object3MF:
  171. """One ``<object>``: its own mesh, and the objects it places as components."""
  172. vertices: object = None # np.ndarray (n, 3) or None
  173. faces: object = None # np.ndarray (m, 3) or None
  174. # (model path or None for "same file", object id, 4x4 transform)
  175. components: list = field(default_factory=list)
  176. def _local(tag: str) -> str:
  177. return tag.rsplit("}", 1)[-1]
  178. def _transform(attr: str | None):
  179. """A 3MF ``transform`` attribute as a 4x4 matrix for column vectors.
  180. 3MF lists the 3x4 matrix row by row for ROW vectors (``m00 m01 m02 m10 ...
  181. m32``, the last three being the translation); transposing it gives the usual
  182. column-vector form. Same reading as trimesh's ``_attrib_to_transform``.
  183. """
  184. import numpy as np
  185. matrix = np.eye(4)
  186. if attr:
  187. values = [float(x) for x in attr.split()]
  188. if len(values) == 12:
  189. matrix[:3, :4] = np.array(values).reshape(4, 3).T
  190. return matrix
  191. def _parse_model_file(zf: zipfile.ZipFile, path: str) -> tuple[dict[str, _Object3MF], list]:
  192. """Every object in one model file, and its build items (root file only).
  193. Streams the file and drops each element as soon as it is read, so memory
  194. stays at the numbers collected rather than an XML tree — one Bambu model
  195. file seen in the wild is a single 163 MB mesh. lxml rather than the stdlib
  196. parser: ElementTree builds a Python object per vertex and took ~3x as long
  197. on that file. The input is untrusted, so entities, DTDs and network access
  198. are all off; trimesh, which this replaces here, parses the same files with
  199. lxml already.
  200. """
  201. import numpy as np
  202. from lxml import etree
  203. objects: dict[str, _Object3MF] = {}
  204. build: list = []
  205. vertices: list = []
  206. triangles: list = []
  207. components: list = []
  208. parse = etree.iterparse(
  209. io.BytesIO(zf.read(path)),
  210. events=("end",),
  211. resolve_entities=False,
  212. no_network=True,
  213. load_dtd=False,
  214. )
  215. for _event, elem in parse:
  216. name = _local(elem.tag) if isinstance(elem.tag, str) else ""
  217. if name == "vertex":
  218. try:
  219. vertices.append((float(elem.get("x")), float(elem.get("y")), float(elem.get("z"))))
  220. except (TypeError, ValueError):
  221. vertices.append((0.0, 0.0, 0.0)) # keeps the indices of later vertices right
  222. elif name == "triangle":
  223. try:
  224. triangles.append((int(elem.get("v1")), int(elem.get("v2")), int(elem.get("v3"))))
  225. except (TypeError, ValueError):
  226. pass
  227. elif name == "component" and elem.get("objectid") is not None:
  228. # ``p:path`` (production extension): the object lives in another
  229. # model file. Bambu Studio and OrcaSlicer put every mesh in
  230. # ``3D/Objects/`` and place it this way.
  231. ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
  232. components.append(
  233. (ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform")))
  234. )
  235. elif name == "object":
  236. obj = _Object3MF(components=components)
  237. if triangles:
  238. v = np.array(vertices, dtype=float).reshape(-1, 3)
  239. f = np.array(triangles, dtype=np.int64).reshape(-1, 3)
  240. # A triangle naming a vertex that isn't there would index past
  241. # the array at render time; drop it here instead.
  242. obj.vertices, obj.faces = v, f[(f >= 0).all(axis=1) & (f < len(v)).all(axis=1)]
  243. if elem.get("id") is not None:
  244. objects[elem.get("id")] = obj
  245. vertices, triangles, components = [], [], []
  246. elif name == "item" and elem.get("objectid") is not None:
  247. # The production extension allows ``p:path`` here too, naming the
  248. # file the object lives in; the root file when absent.
  249. ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
  250. build.append((ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform"))))
  251. else:
  252. continue
  253. # Free what has been read: the element, and the siblings before it that
  254. # lxml would otherwise keep attached to the parent.
  255. elem.clear()
  256. while elem.getprevious() is not None:
  257. del elem.getparent()[0]
  258. return objects, build
  259. def _load_plate_geometry(zf: zipfile.ZipFile, trimesh, repair_winding):
  260. """The plate as one (vertices, faces) pair, every instance placed, within budget.
  261. Not ``trimesh.load``: its 3MF reader re-parses a ``p:path`` component's file
  262. for EVERY component that references it and appends the meshes again each
  263. time. Bambu Studio and OrcaSlicer write each instance as its own object with
  264. one such component, so N copies of a part came back as one mesh holding N
  265. copies of every triangle — N² of them once placed — while the vertex count,
  266. merged back down, looked normal. 25 bins of 10k faces loaded as 6.4M faces
  267. and took 8.4 GB to render (#3135; trimesh 4.12 and 5.1 alike).
  268. Here each model file is parsed once and each mesh is kept once, decimated
  269. once to its share of the face budget, and only then placed per instance.
  270. Returns None when there is nothing to draw or the plate is over the ceiling.
  271. """
  272. import numpy as np
  273. files: dict[str, dict[str, _Object3MF]] = {}
  274. names = set(zf.namelist())
  275. def objects_in(path: str) -> dict[str, _Object3MF]:
  276. if path not in files:
  277. files[path] = _parse_model_file(zf, path)[0] if path in names else {}
  278. return files[path]
  279. root_objects, build = _parse_model_file(zf, _MODEL_ROOT)
  280. files[_MODEL_ROOT] = root_objects
  281. placements: dict[tuple[str, str], list] = defaultdict(list)
  282. count = 0
  283. def place(path: str, object_id: str, matrix, depth: int, trail: frozenset) -> None:
  284. nonlocal count
  285. key = (path, object_id)
  286. if depth > _MAX_COMPONENT_DEPTH or key in trail or count > _MAX_PLACEMENTS:
  287. return
  288. obj = objects_in(path).get(object_id)
  289. if obj is None:
  290. return
  291. if obj.faces is not None and len(obj.faces):
  292. placements[key].append(matrix)
  293. count += 1
  294. for ref, child_id, child_matrix in obj.components:
  295. place(ref or path, child_id, matrix @ child_matrix, depth + 1, trail | {key})
  296. for ref, object_id, matrix in build:
  297. place(ref or _MODEL_ROOT, object_id, matrix, 0, frozenset())
  298. if count > _MAX_PLACEMENTS:
  299. logger.info("plate_thumbnail: over %d placed parts, skipping the thumbnail", _MAX_PLACEMENTS)
  300. return None
  301. if not placements:
  302. logger.debug("plate_thumbnail: 3MF places no mesh")
  303. return None
  304. def faces_of(key) -> int:
  305. return len(files[key[0]][key[1]].faces)
  306. def over_ceiling(faces: int) -> bool:
  307. if faces <= _MAX_PLACED_FACES:
  308. return False
  309. logger.info(
  310. "plate_thumbnail: %d faces even after decimation (ceiling %d), skipping the thumbnail",
  311. faces,
  312. _MAX_PLACED_FACES,
  313. )
  314. return True
  315. total = sum(faces_of(key) * len(ms) for key, ms in placements.items())
  316. scale = min(1.0, _RENDER_FACE_BUDGET / total)
  317. targets = {key: max(_MIN_FACES_PER_MESH, int(faces_of(key) * scale)) for key in placements}
  318. # What decimation can actually reach: it removes at most 99% of a mesh, so a
  319. # part needing more keeps 1% of its faces rather than its target. Checked
  320. # before any mesh is built, so a hopeless plate costs nothing but the parse.
  321. reachable = sum(
  322. min(faces_of(key), max(targets[key], -(-faces_of(key) // 100))) * len(ms) for key, ms in placements.items()
  323. )
  324. if over_ceiling(reachable):
  325. return None
  326. prepared = []
  327. for key, matrices in placements.items():
  328. obj = files[key[0]][key[1]]
  329. mesh = trimesh.Trimesh(vertices=obj.vertices, faces=obj.faces, process=True)
  330. if targets[key] < len(mesh.faces):
  331. try:
  332. # ``percent`` (the share to REMOVE), the form this module has always
  333. # called. ``face_count`` reaches the same size, but on a real 2M-face
  334. # model it left the winding inconsistent where ``percent`` did not,
  335. # which costs the repair below ~14 s.
  336. reduction = 1.0 - targets[key] / len(mesh.faces)
  337. mesh = mesh.simplify_quadric_decimation(max(0.01, min(0.99, reduction)))
  338. except Exception as exc:
  339. logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
  340. prepared.append((mesh, matrices))
  341. # Again on what decimation delivered: it can stop short of its target, or
  342. # fail and leave the mesh whole, and the render's memory follows the faces
  343. # it is actually handed.
  344. if over_ceiling(sum(len(mesh.faces) * len(ms) for mesh, ms in prepared)):
  345. return None
  346. all_vertices = []
  347. all_faces = []
  348. offset = 0
  349. for mesh, matrices in prepared:
  350. # Once per mesh, before it is placed: ``faces`` below index these vertices,
  351. # so a repair that ever moves one would leave the two out of step. Shared
  352. # with stl_thumbnail rather than copied — the renderers agree because they
  353. # run the same code.
  354. try:
  355. repair_winding(mesh, trimesh, "plate_thumbnail")
  356. except Exception as e: # best-effort, as the whole module is
  357. logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
  358. vertices = np.asarray(mesh.vertices, dtype=float)
  359. faces = np.asarray(mesh.faces, dtype=np.int64)
  360. for matrix in matrices:
  361. all_vertices.append(vertices @ matrix[:3, :3].T + matrix[:3, 3])
  362. # A mirroring transform turns every triangle inside out; flip the
  363. # winding back so shading still sees the outside.
  364. placed = faces[:, ::-1] if np.linalg.det(matrix[:3, :3]) < 0 else faces
  365. all_faces.append(placed + offset)
  366. offset += len(vertices)
  367. return np.vstack(all_vertices), np.vstack(all_faces)
  368. def _render_at_size(poly3d, size: int, Poly3DCollection, shade_kw: dict) -> bytes:
  369. """Render the prepared poly3d collection to an in-memory PNG.
  370. Matplotlib's object API, not pyplot. This runs in a worker thread (#3135)
  371. while stl_thumbnail renders through pyplot on the event loop, and pyplot's
  372. figure registry and "current figure" are process-global: its
  373. ``subplots_adjust`` would lay out whichever figure the other thread made
  374. last, and neither lock placement is acceptable — held on the loop it stalls
  375. the server for the whole plate render. A ``Figure`` with its own Agg canvas
  376. shares nothing, so the two can run at once.
  377. """
  378. # Local, like every other import in this module, so importing plate_thumbnail
  379. # in an environment without matplotlib still works. stl_thumbnail's own
  380. # module level is import-light, so this costs nothing after the first call.
  381. from matplotlib.backends.backend_agg import FigureCanvasAgg
  382. from matplotlib.figure import Figure
  383. from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
  384. fig = Figure(figsize=(size / 100, size / 100), dpi=100)
  385. FigureCanvasAgg(fig)
  386. fig.patch.set_facecolor(_BACKGROUND_COLOR)
  387. ax = fig.add_subplot(111, projection="3d")
  388. ax.set_facecolor(_BACKGROUND_COLOR)
  389. # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
  390. # colours alongside the face colours, and an empty array (``"none"``) makes
  391. # it raise on the broadcast. Keep the two in step if either moves.
  392. ax.add_collection3d(
  393. Poly3DCollection(
  394. poly3d,
  395. facecolors=_BAMBU_GREEN,
  396. edgecolors=_BAMBU_GREEN,
  397. linewidths=0.1,
  398. alpha=0.9,
  399. **shade_kw,
  400. )
  401. )
  402. ax.set_xlim(-0.6, 0.6)
  403. ax.set_ylim(-0.6, 0.6)
  404. ax.set_zlim(-0.6, 0.6)
  405. ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
  406. ax.set_axis_off()
  407. ax.grid(False)
  408. fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
  409. buf = io.BytesIO()
  410. fig.savefig(
  411. buf,
  412. format="png",
  413. facecolor=_BACKGROUND_COLOR,
  414. edgecolor="none",
  415. bbox_inches="tight",
  416. pad_inches=0.05,
  417. dpi=100,
  418. )
  419. return buf.getvalue()
  420. def _inject_pngs(
  421. threemf_bytes: bytes,
  422. plate_ids: list[int],
  423. large_png: bytes,
  424. small_png: bytes,
  425. ) -> bytes:
  426. """Copy every entry from the input zip to a new one, then append the
  427. plate PNGs. Re-pack rather than mutate-in-place because zipfile doesn't
  428. support adding entries to an existing archive read from bytes."""
  429. out_buf = io.BytesIO()
  430. with (
  431. zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as src,
  432. zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as dst,
  433. ):
  434. for item in src.infolist():
  435. dst.writestr(item, src.read(item.filename))
  436. for n in plate_ids:
  437. dst.writestr(f"Metadata/plate_{n}.png", large_png)
  438. dst.writestr(f"Metadata/plate_{n}_small.png", small_png)
  439. return out_buf.getvalue()