|
|
@@ -25,7 +25,10 @@ from __future__ import annotations
|
|
|
import io
|
|
|
import logging
|
|
|
import re
|
|
|
+import threading
|
|
|
import zipfile
|
|
|
+from collections import defaultdict
|
|
|
+from dataclasses import dataclass, field
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@@ -42,11 +45,33 @@ _PLATE_PNG_SMALL_SIZE = 128
|
|
|
_BAMBU_GREEN = "#00AE42"
|
|
|
_BACKGROUND_COLOR = "#1a1a1a"
|
|
|
|
|
|
-# Above this vertex count, trimesh.simplify_quadric_decimation runs first.
|
|
|
-# Same cap stl_thumbnail.py uses; matplotlib's Poly3DCollection slows down
|
|
|
-# nonlinearly past ~100k faces and a plate thumbnail doesn't need detail
|
|
|
-# beyond what a 512x512 PNG can resolve.
|
|
|
-_MAX_VERTICES = 100_000
|
|
|
+# Faces the whole plate is rendered with, every instance counted. Render cost
|
|
|
+# is faces, not vertices: matplotlib's Poly3DCollection slows down nonlinearly
|
|
|
+# past ~200k of them, and a 512x512 PNG resolves nothing finer. Roughly what
|
|
|
+# stl_thumbnail's 100k-vertex cap comes to on a closed mesh.
|
|
|
+_RENDER_FACE_BUDGET = 200_000
|
|
|
+
|
|
|
+# A mesh is never decimated below this, however many times it is placed, or a
|
|
|
+# plate of small parts renders as a field of blobs.
|
|
|
+_MIN_FACES_PER_MESH = 200
|
|
|
+
|
|
|
+# Past this many faces after decimation (a plate of thousands of parts, each
|
|
|
+# already at the floor above) the thumbnail is skipped. It is best-effort, and
|
|
|
+# the render's memory grows with every face it is handed (#3135).
|
|
|
+_MAX_PLACED_FACES = 1_000_000
|
|
|
+
|
|
|
+# Bounds on the object graph: components nest, and a file that references
|
|
|
+# itself, or places one part a million times, must not be walked forever.
|
|
|
+_MAX_COMPONENT_DEPTH = 16
|
|
|
+_MAX_PLACEMENTS = 20_000
|
|
|
+
|
|
|
+_MODEL_ROOT = "3D/3dmodel.model"
|
|
|
+
|
|
|
+# One plate render at a time. The slice routes run this off the event loop, and
|
|
|
+# a render holds the whole placed plate in memory; two slices finishing
|
|
|
+# together must not hold two. pyplot is NOT what this guards — the renderer
|
|
|
+# below never touches it (see ``_render_at_size``).
|
|
|
+_render_lock = threading.Lock()
|
|
|
|
|
|
# Plate-gcode entries look like ``Metadata/plate_1.gcode``,
|
|
|
# ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
|
|
|
@@ -71,7 +96,7 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
|
|
|
missing = _missing_plate_ids(names)
|
|
|
if not missing:
|
|
|
return threemf_bytes
|
|
|
- if "3D/3dmodel.model" not in names:
|
|
|
+ if _MODEL_ROOT not in names:
|
|
|
logger.debug(
|
|
|
"plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
|
|
|
sorted(missing),
|
|
|
@@ -82,7 +107,8 @@ def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
|
|
|
return threemf_bytes
|
|
|
|
|
|
try:
|
|
|
- large_png, small_png = _render_model_thumbnails(threemf_bytes)
|
|
|
+ with _render_lock:
|
|
|
+ large_png, small_png = _render_model_thumbnails(threemf_bytes)
|
|
|
except Exception as exc:
|
|
|
logger.warning(
|
|
|
"plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
|
|
|
@@ -146,38 +172,15 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
|
|
|
|
|
|
_configure_matplotlib_cache()
|
|
|
|
|
|
- import matplotlib
|
|
|
-
|
|
|
- matplotlib.use("Agg")
|
|
|
- import matplotlib.pyplot as plt
|
|
|
import trimesh
|
|
|
from matplotlib.colors import LightSource
|
|
|
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
|
|
|
|
- loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
|
|
|
- if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
|
|
|
- logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
|
|
|
+ with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
|
|
|
+ placed = _load_plate_geometry(zf, trimesh, _repair_winding)
|
|
|
+ if placed is None:
|
|
|
return None, None
|
|
|
-
|
|
|
- mesh = loaded
|
|
|
- if len(mesh.vertices) > _MAX_VERTICES:
|
|
|
- try:
|
|
|
- keep_ratio = _MAX_VERTICES / len(mesh.vertices)
|
|
|
- target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
|
|
|
- mesh = mesh.simplify_quadric_decimation(target_reduction)
|
|
|
- except Exception as exc:
|
|
|
- logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
|
|
|
-
|
|
|
- # Before the vertices are read, not after: ``scaled`` below is indexed by
|
|
|
- # ``mesh.faces``, so a repair that ever moves a vertex would leave the two
|
|
|
- # out of step. Shared with stl_thumbnail rather than copied — the reason
|
|
|
- # these renderers agree is that they run the same code, not similar code.
|
|
|
- try:
|
|
|
- _repair_winding(mesh, trimesh, "plate_thumbnail")
|
|
|
- except Exception as e: # best-effort, as the whole module is
|
|
|
- logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
|
|
|
-
|
|
|
- vertices = mesh.vertices
|
|
|
+ vertices, faces = placed
|
|
|
bounds_min = vertices.min(axis=0)
|
|
|
bounds_max = vertices.max(axis=0)
|
|
|
centered = vertices - (bounds_min + bounds_max) / 2
|
|
|
@@ -186,7 +189,6 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
|
|
|
|
|
|
# ndarray, not a list of lists — shading walks this to build normals, and the
|
|
|
# list form is ~30x slower to construct. Paid twice per plate: once per size.
|
|
|
- faces = mesh.faces
|
|
|
poly3d = scaled[faces]
|
|
|
|
|
|
# Resolved once and shared: both sizes must be lit identically or the 128px
|
|
|
@@ -195,19 +197,263 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
|
|
|
# ``_shade_kwargs``.
|
|
|
shade_kw = _shade_kwargs(poly3d, LightSource)
|
|
|
|
|
|
- large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection, shade_kw)
|
|
|
- small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection, shade_kw)
|
|
|
+ large = _render_at_size(poly3d, _PLATE_PNG_SIZE, Poly3DCollection, shade_kw)
|
|
|
+ small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, Poly3DCollection, shade_kw)
|
|
|
return large, small
|
|
|
|
|
|
|
|
|
-def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) -> bytes:
|
|
|
- """Render the prepared poly3d collection to an in-memory PNG."""
|
|
|
+@dataclass
|
|
|
+class _Object3MF:
|
|
|
+ """One ``<object>``: its own mesh, and the objects it places as components."""
|
|
|
+
|
|
|
+ vertices: object = None # np.ndarray (n, 3) or None
|
|
|
+ faces: object = None # np.ndarray (m, 3) or None
|
|
|
+ # (model path or None for "same file", object id, 4x4 transform)
|
|
|
+ components: list = field(default_factory=list)
|
|
|
+
|
|
|
+
|
|
|
+def _local(tag: str) -> str:
|
|
|
+ return tag.rsplit("}", 1)[-1]
|
|
|
+
|
|
|
+
|
|
|
+def _transform(attr: str | None):
|
|
|
+ """A 3MF ``transform`` attribute as a 4x4 matrix for column vectors.
|
|
|
+
|
|
|
+ 3MF lists the 3x4 matrix row by row for ROW vectors (``m00 m01 m02 m10 ...
|
|
|
+ m32``, the last three being the translation); transposing it gives the usual
|
|
|
+ column-vector form. Same reading as trimesh's ``_attrib_to_transform``.
|
|
|
+ """
|
|
|
+ import numpy as np
|
|
|
+
|
|
|
+ matrix = np.eye(4)
|
|
|
+ if attr:
|
|
|
+ values = [float(x) for x in attr.split()]
|
|
|
+ if len(values) == 12:
|
|
|
+ matrix[:3, :4] = np.array(values).reshape(4, 3).T
|
|
|
+ return matrix
|
|
|
+
|
|
|
+
|
|
|
+def _parse_model_file(zf: zipfile.ZipFile, path: str) -> tuple[dict[str, _Object3MF], list]:
|
|
|
+ """Every object in one model file, and its build items (root file only).
|
|
|
+
|
|
|
+ Streams the file and drops each element as soon as it is read, so memory
|
|
|
+ stays at the numbers collected rather than an XML tree — one Bambu model
|
|
|
+ file seen in the wild is a single 163 MB mesh. lxml rather than the stdlib
|
|
|
+ parser: ElementTree builds a Python object per vertex and took ~3x as long
|
|
|
+ on that file. The input is untrusted, so entities, DTDs and network access
|
|
|
+ are all off; trimesh, which this replaces here, parses the same files with
|
|
|
+ lxml already.
|
|
|
+ """
|
|
|
+ import numpy as np
|
|
|
+ from lxml import etree
|
|
|
+
|
|
|
+ objects: dict[str, _Object3MF] = {}
|
|
|
+ build: list = []
|
|
|
+ vertices: list = []
|
|
|
+ triangles: list = []
|
|
|
+ components: list = []
|
|
|
+
|
|
|
+ parse = etree.iterparse(
|
|
|
+ io.BytesIO(zf.read(path)),
|
|
|
+ events=("end",),
|
|
|
+ resolve_entities=False,
|
|
|
+ no_network=True,
|
|
|
+ load_dtd=False,
|
|
|
+ )
|
|
|
+ for _event, elem in parse:
|
|
|
+ name = _local(elem.tag) if isinstance(elem.tag, str) else ""
|
|
|
+ if name == "vertex":
|
|
|
+ try:
|
|
|
+ vertices.append((float(elem.get("x")), float(elem.get("y")), float(elem.get("z"))))
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ vertices.append((0.0, 0.0, 0.0)) # keeps the indices of later vertices right
|
|
|
+ elif name == "triangle":
|
|
|
+ try:
|
|
|
+ triangles.append((int(elem.get("v1")), int(elem.get("v2")), int(elem.get("v3"))))
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ pass
|
|
|
+ elif name == "component" and elem.get("objectid") is not None:
|
|
|
+ # ``p:path`` (production extension): the object lives in another
|
|
|
+ # model file. Bambu Studio and OrcaSlicer put every mesh in
|
|
|
+ # ``3D/Objects/`` and place it this way.
|
|
|
+ ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
|
|
|
+ components.append(
|
|
|
+ (ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform")))
|
|
|
+ )
|
|
|
+ elif name == "object":
|
|
|
+ obj = _Object3MF(components=components)
|
|
|
+ if triangles:
|
|
|
+ v = np.array(vertices, dtype=float).reshape(-1, 3)
|
|
|
+ f = np.array(triangles, dtype=np.int64).reshape(-1, 3)
|
|
|
+ # A triangle naming a vertex that isn't there would index past
|
|
|
+ # the array at render time; drop it here instead.
|
|
|
+ obj.vertices, obj.faces = v, f[(f >= 0).all(axis=1) & (f < len(v)).all(axis=1)]
|
|
|
+ if elem.get("id") is not None:
|
|
|
+ objects[elem.get("id")] = obj
|
|
|
+ vertices, triangles, components = [], [], []
|
|
|
+ elif name == "item" and elem.get("objectid") is not None:
|
|
|
+ # The production extension allows ``p:path`` here too, naming the
|
|
|
+ # file the object lives in; the root file when absent.
|
|
|
+ ref = next((val for key, val in elem.attrib.items() if _local(key) == "path"), None)
|
|
|
+ build.append((ref.lstrip("/") if ref else None, elem.get("objectid"), _transform(elem.get("transform"))))
|
|
|
+ else:
|
|
|
+ continue
|
|
|
+ # Free what has been read: the element, and the siblings before it that
|
|
|
+ # lxml would otherwise keep attached to the parent.
|
|
|
+ elem.clear()
|
|
|
+ while elem.getprevious() is not None:
|
|
|
+ del elem.getparent()[0]
|
|
|
+ return objects, build
|
|
|
+
|
|
|
+
|
|
|
+def _load_plate_geometry(zf: zipfile.ZipFile, trimesh, repair_winding):
|
|
|
+ """The plate as one (vertices, faces) pair, every instance placed, within budget.
|
|
|
+
|
|
|
+ Not ``trimesh.load``: its 3MF reader re-parses a ``p:path`` component's file
|
|
|
+ for EVERY component that references it and appends the meshes again each
|
|
|
+ time. Bambu Studio and OrcaSlicer write each instance as its own object with
|
|
|
+ one such component, so N copies of a part came back as one mesh holding N
|
|
|
+ copies of every triangle — N² of them once placed — while the vertex count,
|
|
|
+ merged back down, looked normal. 25 bins of 10k faces loaded as 6.4M faces
|
|
|
+ and took 8.4 GB to render (#3135; trimesh 4.12 and 5.1 alike).
|
|
|
+
|
|
|
+ Here each model file is parsed once and each mesh is kept once, decimated
|
|
|
+ once to its share of the face budget, and only then placed per instance.
|
|
|
+ Returns None when there is nothing to draw or the plate is over the ceiling.
|
|
|
+ """
|
|
|
+ import numpy as np
|
|
|
+
|
|
|
+ files: dict[str, dict[str, _Object3MF]] = {}
|
|
|
+ names = set(zf.namelist())
|
|
|
+
|
|
|
+ def objects_in(path: str) -> dict[str, _Object3MF]:
|
|
|
+ if path not in files:
|
|
|
+ files[path] = _parse_model_file(zf, path)[0] if path in names else {}
|
|
|
+ return files[path]
|
|
|
+
|
|
|
+ root_objects, build = _parse_model_file(zf, _MODEL_ROOT)
|
|
|
+ files[_MODEL_ROOT] = root_objects
|
|
|
+
|
|
|
+ placements: dict[tuple[str, str], list] = defaultdict(list)
|
|
|
+ count = 0
|
|
|
+
|
|
|
+ def place(path: str, object_id: str, matrix, depth: int, trail: frozenset) -> None:
|
|
|
+ nonlocal count
|
|
|
+ key = (path, object_id)
|
|
|
+ if depth > _MAX_COMPONENT_DEPTH or key in trail or count > _MAX_PLACEMENTS:
|
|
|
+ return
|
|
|
+ obj = objects_in(path).get(object_id)
|
|
|
+ if obj is None:
|
|
|
+ return
|
|
|
+ if obj.faces is not None and len(obj.faces):
|
|
|
+ placements[key].append(matrix)
|
|
|
+ count += 1
|
|
|
+ for ref, child_id, child_matrix in obj.components:
|
|
|
+ place(ref or path, child_id, matrix @ child_matrix, depth + 1, trail | {key})
|
|
|
+
|
|
|
+ for ref, object_id, matrix in build:
|
|
|
+ place(ref or _MODEL_ROOT, object_id, matrix, 0, frozenset())
|
|
|
+
|
|
|
+ if count > _MAX_PLACEMENTS:
|
|
|
+ logger.info("plate_thumbnail: over %d placed parts, skipping the thumbnail", _MAX_PLACEMENTS)
|
|
|
+ return None
|
|
|
+ if not placements:
|
|
|
+ logger.debug("plate_thumbnail: 3MF places no mesh")
|
|
|
+ return None
|
|
|
+
|
|
|
+ def faces_of(key) -> int:
|
|
|
+ return len(files[key[0]][key[1]].faces)
|
|
|
+
|
|
|
+ def over_ceiling(faces: int) -> bool:
|
|
|
+ if faces <= _MAX_PLACED_FACES:
|
|
|
+ return False
|
|
|
+ logger.info(
|
|
|
+ "plate_thumbnail: %d faces even after decimation (ceiling %d), skipping the thumbnail",
|
|
|
+ faces,
|
|
|
+ _MAX_PLACED_FACES,
|
|
|
+ )
|
|
|
+ return True
|
|
|
+
|
|
|
+ total = sum(faces_of(key) * len(ms) for key, ms in placements.items())
|
|
|
+ scale = min(1.0, _RENDER_FACE_BUDGET / total)
|
|
|
+ targets = {key: max(_MIN_FACES_PER_MESH, int(faces_of(key) * scale)) for key in placements}
|
|
|
+ # What decimation can actually reach: it removes at most 99% of a mesh, so a
|
|
|
+ # part needing more keeps 1% of its faces rather than its target. Checked
|
|
|
+ # before any mesh is built, so a hopeless plate costs nothing but the parse.
|
|
|
+ reachable = sum(
|
|
|
+ min(faces_of(key), max(targets[key], -(-faces_of(key) // 100))) * len(ms) for key, ms in placements.items()
|
|
|
+ )
|
|
|
+ if over_ceiling(reachable):
|
|
|
+ return None
|
|
|
+
|
|
|
+ prepared = []
|
|
|
+ for key, matrices in placements.items():
|
|
|
+ obj = files[key[0]][key[1]]
|
|
|
+ mesh = trimesh.Trimesh(vertices=obj.vertices, faces=obj.faces, process=True)
|
|
|
+ if targets[key] < len(mesh.faces):
|
|
|
+ try:
|
|
|
+ # ``percent`` (the share to REMOVE), the form this module has always
|
|
|
+ # called. ``face_count`` reaches the same size, but on a real 2M-face
|
|
|
+ # model it left the winding inconsistent where ``percent`` did not,
|
|
|
+ # which costs the repair below ~14 s.
|
|
|
+ reduction = 1.0 - targets[key] / len(mesh.faces)
|
|
|
+ mesh = mesh.simplify_quadric_decimation(max(0.01, min(0.99, reduction)))
|
|
|
+ except Exception as exc:
|
|
|
+ logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
|
|
|
+ prepared.append((mesh, matrices))
|
|
|
+
|
|
|
+ # Again on what decimation delivered: it can stop short of its target, or
|
|
|
+ # fail and leave the mesh whole, and the render's memory follows the faces
|
|
|
+ # it is actually handed.
|
|
|
+ if over_ceiling(sum(len(mesh.faces) * len(ms) for mesh, ms in prepared)):
|
|
|
+ return None
|
|
|
+
|
|
|
+ all_vertices = []
|
|
|
+ all_faces = []
|
|
|
+ offset = 0
|
|
|
+ for mesh, matrices in prepared:
|
|
|
+ # Once per mesh, before it is placed: ``faces`` below index these vertices,
|
|
|
+ # so a repair that ever moves one would leave the two out of step. Shared
|
|
|
+ # with stl_thumbnail rather than copied — the renderers agree because they
|
|
|
+ # run the same code.
|
|
|
+ try:
|
|
|
+ repair_winding(mesh, trimesh, "plate_thumbnail")
|
|
|
+ except Exception as e: # best-effort, as the whole module is
|
|
|
+ logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
|
|
|
+ vertices = np.asarray(mesh.vertices, dtype=float)
|
|
|
+ faces = np.asarray(mesh.faces, dtype=np.int64)
|
|
|
+ for matrix in matrices:
|
|
|
+ all_vertices.append(vertices @ matrix[:3, :3].T + matrix[:3, 3])
|
|
|
+ # A mirroring transform turns every triangle inside out; flip the
|
|
|
+ # winding back so shading still sees the outside.
|
|
|
+ placed = faces[:, ::-1] if np.linalg.det(matrix[:3, :3]) < 0 else faces
|
|
|
+ all_faces.append(placed + offset)
|
|
|
+ offset += len(vertices)
|
|
|
+
|
|
|
+ return np.vstack(all_vertices), np.vstack(all_faces)
|
|
|
+
|
|
|
+
|
|
|
+def _render_at_size(poly3d, size: int, Poly3DCollection, shade_kw: dict) -> bytes:
|
|
|
+ """Render the prepared poly3d collection to an in-memory PNG.
|
|
|
+
|
|
|
+ Matplotlib's object API, not pyplot. This runs in a worker thread (#3135)
|
|
|
+ while stl_thumbnail renders through pyplot on the event loop, and pyplot's
|
|
|
+ figure registry and "current figure" are process-global: its
|
|
|
+ ``subplots_adjust`` would lay out whichever figure the other thread made
|
|
|
+ last, and neither lock placement is acceptable — held on the loop it stalls
|
|
|
+ the server for the whole plate render. A ``Figure`` with its own Agg canvas
|
|
|
+ shares nothing, so the two can run at once.
|
|
|
+ """
|
|
|
# Local, like every other import in this module, so importing plate_thumbnail
|
|
|
# in an environment without matplotlib still works. stl_thumbnail's own
|
|
|
# module level is import-light, so this costs nothing after the first call.
|
|
|
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
|
+ from matplotlib.figure import Figure
|
|
|
+
|
|
|
from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
|
|
|
|
|
|
- fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
|
|
|
+ fig = Figure(figsize=(size / 100, size / 100), dpi=100)
|
|
|
+ FigureCanvasAgg(fig)
|
|
|
fig.patch.set_facecolor(_BACKGROUND_COLOR)
|
|
|
ax = fig.add_subplot(111, projection="3d")
|
|
|
ax.set_facecolor(_BACKGROUND_COLOR)
|
|
|
@@ -230,7 +476,7 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
|
|
|
ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
|
|
|
ax.set_axis_off()
|
|
|
ax.grid(False)
|
|
|
- plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|
|
+ fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|
|
|
|
|
buf = io.BytesIO()
|
|
|
fig.savefig(
|
|
|
@@ -242,7 +488,6 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) ->
|
|
|
pad_inches=0.05,
|
|
|
dpi=100,
|
|
|
)
|
|
|
- plt.close(fig)
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|