mesh_combine.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """Combine several STL files into one multi-object 3MF.
  2. The slicer sidecar takes exactly one model file per slice, so slicing several
  3. separate STLs onto one plate means building that one file first. A plain 3MF
  4. (core spec, no Bambu/Orca ``Metadata/`` entries) is enough: both CLIs load it
  5. as a project with one object per build item, and ``--arrange`` lays them out
  6. on the target bed.
  7. Each source mesh is written once as a 3MF ``<object>`` and every copy of it is
  8. a ``<build><item>`` pointing at that object with its own transform, so ten
  9. copies of a 5 MB STL cost 5 MB, not 50. trimesh's own 3MF exporter duplicates
  10. the mesh per scene node, which is why this writes the XML directly, streamed
  11. into the zip entry so the model never exists as one string in memory.
  12. Copies are pre-placed on a simple shelf grid with a gap between footprints.
  13. The slicer re-arranges them anyway when arrange is on, but a grid means the
  14. file also opens sensibly in a desktop slicer and renders a readable
  15. thumbnail, rather than every object stacked on the origin.
  16. The preview is rendered here, from the meshes already in memory, and embedded
  17. as ``Metadata/thumbnail.png``. Loading the finished 3MF back to render it would
  18. expand every build item into its own copy of the mesh: 100 copies of a
  19. 327k-face STL peaked at 13.6 GB that way. Here each source is simplified once,
  20. to a share of a fixed face budget, before its copies are placed.
  21. """
  22. from __future__ import annotations
  23. import io
  24. import logging
  25. import math
  26. import re
  27. import zipfile
  28. from dataclasses import dataclass
  29. from pathlib import Path
  30. logger = logging.getLogger(__name__)
  31. # Hard cap on build items (sum of copies). Every item is a full object for the
  32. # slicer to arrange, support and slice; past this a single plate cannot hold
  33. # them anyway and the request is more likely a typo than a real print.
  34. MAX_COMBINE_INSTANCES = 100
  35. # Caps on the sources themselves, so a request of many large STLs can't hold
  36. # the whole lot in memory. Bytes are checked from the file sizes before
  37. # anything is loaded; faces after each load, so the request stops at the first
  38. # model that crosses the line. A typical printable STL is 1 to 20 MB; a binary
  39. # STL spends 50 bytes per face, so the two caps sit close to each other.
  40. MAX_COMBINE_SOURCE_BYTES = 300 * 1024 * 1024
  41. MAX_COMBINE_SOURCE_FACES = 5_000_000
  42. # Faces drawn in the embedded preview, shared across every copy on the plate.
  43. # Each source is simplified once to its per-copy share; the floor keeps a
  44. # 100-copy plate recognisable (100 x 1500 faces stays inside the budget).
  45. THUMBNAIL_FACE_BUDGET = 200_000
  46. THUMBNAIL_MIN_FACES_PER_COPY = 1_500
  47. THUMBNAIL_PATH = "Metadata/thumbnail.png"
  48. # Space between neighbouring footprints in the pre-placed grid, in mm.
  49. LAYOUT_GAP_MM = 5.0
  50. # Rows per chunk written to the model entry.
  51. _XML_CHUNK_ROWS = 20_000
  52. _CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
  53. _CONTENT_TYPES_XML = (
  54. '<?xml version="1.0" encoding="UTF-8"?>\n'
  55. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
  56. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
  57. '<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>'
  58. '<Default Extension="png" ContentType="image/png"/>'
  59. "</Types>"
  60. )
  61. _MODEL_REL = (
  62. '<Relationship Target="/3D/3dmodel.model" Id="rel0" '
  63. 'Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>'
  64. )
  65. # The OPC package thumbnail relationship: how a 3MF names its preview image.
  66. _THUMBNAIL_REL = (
  67. f'<Relationship Target="/{THUMBNAIL_PATH}" Id="rel1" '
  68. 'Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>'
  69. )
  70. def _rels_xml(with_thumbnail: bool) -> str:
  71. return (
  72. '<?xml version="1.0" encoding="UTF-8"?>\n'
  73. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
  74. + _MODEL_REL
  75. + (_THUMBNAIL_REL if with_thumbnail else "")
  76. + "</Relationships>"
  77. )
  78. class MeshCombineError(ValueError):
  79. """A source could not be combined (unreadable, empty, or over the limits).
  80. The message is user-facing: the route returns it as the 400 detail.
  81. """
  82. @dataclass(frozen=True)
  83. class CombinePart:
  84. """One source model and how many copies of it go on the plate."""
  85. name: str
  86. path: Path
  87. copies: int = 1
  88. @dataclass(frozen=True)
  89. class _LoadedPart:
  90. name: str
  91. vertices: object # numpy (N, 3) float array, footprint min at (0, 0), z min at 0
  92. faces: object # numpy (M, 3) int array
  93. width: float
  94. depth: float
  95. copies: int
  96. def _object_name(filename: str) -> str:
  97. """Object name shown in the slicer's object list: the filename sans extension."""
  98. stem = Path(filename).stem or "object"
  99. # Control characters are not valid in XML attribute values.
  100. return re.sub(r"[\x00-\x1f]", "", stem)[:200] or "object"
  101. def _load_part(part: CombinePart) -> _LoadedPart:
  102. import trimesh
  103. try:
  104. mesh = trimesh.load(str(part.path), force="mesh")
  105. except Exception as exc:
  106. raise MeshCombineError(f"Could not read {part.name}: {exc}") from exc
  107. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
  108. raise MeshCombineError(f"{part.name} contains no printable geometry")
  109. vertices = mesh.vertices.copy()
  110. lo = vertices.min(axis=0)
  111. hi = vertices.max(axis=0)
  112. # Normalise so the footprint starts at the origin and the model sits on
  113. # the bed; the layout below then only has to translate in X/Y.
  114. vertices -= lo
  115. return _LoadedPart(
  116. name=_object_name(part.name),
  117. vertices=vertices,
  118. faces=mesh.faces,
  119. width=float(hi[0] - lo[0]),
  120. depth=float(hi[1] - lo[1]),
  121. copies=part.copies,
  122. )
  123. def _check_source_bytes(parts: list[CombinePart]) -> None:
  124. total = 0
  125. for part in parts:
  126. try:
  127. total += part.path.stat().st_size
  128. except OSError as exc:
  129. raise MeshCombineError(f"Could not read {part.name}: {exc}") from exc
  130. if total > MAX_COMBINE_SOURCE_BYTES:
  131. raise MeshCombineError(
  132. f"The selected models are too large to combine: {total / 1024**2:.0f} MB, "
  133. f"at most {MAX_COMBINE_SOURCE_BYTES // 1024**2} MB in total"
  134. )
  135. def _load_parts(parts: list[CombinePart]) -> list[_LoadedPart]:
  136. loaded: list[_LoadedPart] = []
  137. faces = 0
  138. for part in parts:
  139. item = _load_part(part)
  140. faces += len(item.faces)
  141. if faces > MAX_COMBINE_SOURCE_FACES:
  142. raise MeshCombineError(
  143. f"The selected models are too detailed to combine: more than "
  144. f"{MAX_COMBINE_SOURCE_FACES:,} triangles in total"
  145. )
  146. loaded.append(item)
  147. return loaded
  148. def layout_offsets(footprints: list[tuple[float, float]], gap: float = LAYOUT_GAP_MM) -> list[tuple[float, float]]:
  149. """Shelf-pack footprints ``(width, depth)``; return each one's min-corner offset.
  150. Rows are filled left to right up to a width that keeps the overall layout
  151. roughly square, tallest footprints first so rows don't waste depth. The
  152. result is centred on the origin. Offsets come back in input order.
  153. """
  154. if not footprints:
  155. return []
  156. area = sum((w + gap) * (d + gap) for w, d in footprints)
  157. row_limit = max(max(w for w, _ in footprints), math.sqrt(area))
  158. order = sorted(range(len(footprints)), key=lambda i: footprints[i][1], reverse=True)
  159. offsets: list[tuple[float, float]] = [(0.0, 0.0)] * len(footprints)
  160. x = y = row_depth = 0.0
  161. total_w = 0.0
  162. for i in order:
  163. w, d = footprints[i]
  164. if x > 0 and x + w > row_limit:
  165. y += row_depth + gap
  166. x = row_depth = 0.0
  167. offsets[i] = (x, y)
  168. total_w = max(total_w, x + w)
  169. x += w + gap
  170. row_depth = max(row_depth, d)
  171. total_d = y + row_depth
  172. cx, cy = total_w / 2, total_d / 2
  173. return [(ox - cx, oy - cy) for ox, oy in offsets]
  174. def thumbnail_faces_per_copy(total_copies: int) -> int:
  175. """Face budget for one copy in the preview."""
  176. return max(THUMBNAIL_MIN_FACES_PER_COPY, THUMBNAIL_FACE_BUDGET // max(total_copies, 1))
  177. def _preview_mesh(loaded: list[_LoadedPart], owners: list[int], offsets: list[tuple[float, float]]):
  178. """The plate as one mesh for the thumbnail, within the face budget.
  179. Each source is simplified once, before its copies are placed, so the
  180. work and the memory scale with the budget rather than with copies times
  181. source faces.
  182. """
  183. import numpy as np
  184. import trimesh
  185. per_copy = thumbnail_faces_per_copy(len(owners))
  186. reduced = []
  187. for part in loaded:
  188. mesh = trimesh.Trimesh(vertices=part.vertices, faces=part.faces, process=False)
  189. if len(mesh.faces) > per_copy:
  190. try:
  191. mesh = mesh.simplify_quadric_decimation(face_count=per_copy)
  192. except Exception as exc:
  193. # Without simplification the budget can't be held, so no preview.
  194. logger.warning("Thumbnail skipped: could not simplify %s: %s", part.name, exc)
  195. return None
  196. reduced.append(mesh)
  197. vertex_blocks = []
  198. face_blocks = []
  199. base = 0
  200. for owner, (ox, oy) in zip(owners, offsets, strict=True):
  201. mesh = reduced[owner]
  202. vertex_blocks.append(mesh.vertices + np.array([ox, oy, 0.0]))
  203. face_blocks.append(mesh.faces + base)
  204. base += len(mesh.vertices)
  205. return trimesh.Trimesh(vertices=np.vstack(vertex_blocks), faces=np.vstack(face_blocks), process=False)
  206. def _render_thumbnail(loaded: list[_LoadedPart], owners: list[int], offsets: list[tuple[float, float]]) -> bytes | None:
  207. """PNG preview of the plate, or None. Never fails the combine."""
  208. from backend.app.services.stl_thumbnail import render_mesh_png
  209. try:
  210. mesh = _preview_mesh(loaded, owners, offsets)
  211. if mesh is None:
  212. return None
  213. return render_mesh_png(mesh, label="combined 3MF")
  214. except Exception as exc:
  215. logger.warning("Could not render the combined 3MF thumbnail: %s", exc, exc_info=True)
  216. return None
  217. def _fmt(value: float) -> str:
  218. # 6 significant decimals is well below slicer resolution and keeps the
  219. # XML compact; strip trailing zeros so "10.000000" becomes "10".
  220. text = f"{value:.6f}".rstrip("0").rstrip(".")
  221. return "0" if text in ("", "-0") else text
  222. def _attr(value: str) -> str:
  223. return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
  224. def _write_rows(out, rows, render) -> None:
  225. """Write ``rows`` (a numpy array) through ``render`` in fixed-size chunks."""
  226. for start in range(0, len(rows), _XML_CHUNK_ROWS):
  227. chunk = rows[start : start + _XML_CHUNK_ROWS].tolist()
  228. out.write("".join(render(*row) for row in chunk).encode("utf-8"))
  229. def _write_model(out, loaded: list[_LoadedPart], owners: list[int], offsets: list[tuple[float, float]]) -> None:
  230. """Stream the 3MF model XML into the open zip entry ``out``."""
  231. out.write(
  232. (
  233. '<?xml version="1.0" encoding="UTF-8"?>\n'
  234. f'<model unit="millimeter" xml:lang="en-US" xmlns="{_CORE_NS}">'
  235. '<metadata name="Application">Bambuddy</metadata>'
  236. "<resources>"
  237. ).encode()
  238. )
  239. for idx, part in enumerate(loaded):
  240. out.write(f'<object id="{idx + 1}" name="{_attr(part.name)}" type="model"><mesh><vertices>'.encode())
  241. _write_rows(out, part.vertices, lambda x, y, z: f'<vertex x="{_fmt(x)}" y="{_fmt(y)}" z="{_fmt(z)}"/>')
  242. out.write(b"</vertices><triangles>")
  243. _write_rows(out, part.faces, lambda a, b, c: f'<triangle v1="{a}" v2="{b}" v3="{c}"/>')
  244. out.write(b"</triangles></mesh></object>")
  245. out.write(b"</resources><build>")
  246. out.write(
  247. "".join(
  248. f'<item objectid="{owner + 1}" transform="1 0 0 0 1 0 0 0 1 {_fmt(ox)} {_fmt(oy)} 0"/>'
  249. for owner, (ox, oy) in zip(owners, offsets, strict=True)
  250. ).encode("utf-8")
  251. )
  252. out.write(b"</build></model>")
  253. def combine_parts_to_3mf(parts: list[CombinePart]) -> bytes:
  254. """Build a plain multi-object 3MF from ``parts``; return the zip bytes.
  255. Blocking (mesh parsing, the preview render and XML writing are CPU-bound):
  256. call it through ``asyncio.to_thread`` from request handlers.
  257. Raises:
  258. MeshCombineError: no parts, a copy count outside 1..MAX, too many
  259. instances in total, sources over the size or face caps, or a
  260. source that can't be read as a mesh.
  261. """
  262. if not parts:
  263. raise MeshCombineError("Select at least one model to combine")
  264. for part in parts:
  265. if part.copies < 1:
  266. raise MeshCombineError(f"{part.name}: copies must be at least 1")
  267. total = sum(p.copies for p in parts)
  268. if total > MAX_COMBINE_INSTANCES:
  269. raise MeshCombineError(f"Too many objects: {total} requested, at most {MAX_COMBINE_INSTANCES} per plate")
  270. _check_source_bytes(parts)
  271. loaded = _load_parts(parts)
  272. footprints: list[tuple[float, float]] = []
  273. owners: list[int] = []
  274. for idx, part in enumerate(loaded):
  275. for _ in range(part.copies):
  276. footprints.append((part.width, part.depth))
  277. owners.append(idx)
  278. offsets = layout_offsets(footprints)
  279. thumbnail = _render_thumbnail(loaded, owners, offsets)
  280. buf = io.BytesIO()
  281. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  282. zf.writestr("[Content_Types].xml", _CONTENT_TYPES_XML)
  283. zf.writestr("_rels/.rels", _rels_xml(with_thumbnail=thumbnail is not None))
  284. with zf.open("3D/3dmodel.model", "w", force_zip64=True) as out:
  285. _write_model(out, loaded, owners, offsets)
  286. if thumbnail is not None:
  287. zf.writestr(THUMBNAIL_PATH, thumbnail)
  288. logger.info(
  289. "Combined %d model(s) into %d object(s) on one plate (%d bytes, thumbnail %s)",
  290. len(loaded),
  291. total,
  292. buf.tell(),
  293. "embedded" if thumbnail is not None else "skipped",
  294. )
  295. return buf.getvalue()