Преглед изворни кода

fix(slicer): read the plate model once per part for the post-slice thumbnail (issue #3135)

The post-slice thumbnail loaded the sliced 3MF with trimesh, whose reader
mishandles the Bambu Studio / OrcaSlicer layout: for every component that
references a file in 3D/Objects/ it re-parses that file and appends all
of its meshes again. N copies of a part came back as N^2 copies of its
triangles, and each component carried every other part of the same file.
25 bins of 10k faces loaded as 6.4M faces; the render took 54 s and
8.4 GB on the event loop, and the server was OOM-killed.

Parse the 3MF directly (lxml, entities/DTD/network off, streamed and
freed element by element), walk objects, components and build items
(p:path on either) with their transforms, and keep each mesh once.
Decimate per unique mesh to its share of a face budget before placing
instances, flip winding on mirrored placements, and skip the thumbnail
above a face ceiling checked both on what decimation can reach and on
what it delivered.

Both slice routes run the render in a thread. The renderer uses
matplotlib's Figure/Agg API instead of pyplot, whose process-global
figure state let a threaded plate render and stl_thumbnail's
event-loop render lay out and close each other's figures.
maziggy пре 3 дана
родитељ
комит
0eb083b32e

Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
CHANGELOG.md


+ 7 - 4
backend/app/api/routes/library.py

@@ -4543,8 +4543,10 @@ async def slice_and_persist(
     # BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
     # inject server-side so the library card has a thumbnail. Best-effort:
     # no-op when the slicer did embed thumbs (desktop Studio path), and
-    # falls through to the unmodified bytes on any render error.
-    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
+    # falls through to the unmodified bytes on any render error. In a thread:
+    # a large plate renders for seconds, and on the event loop that stalled
+    # every request and printer connection for as long (#3135).
+    result = result._replace(content=await asyncio.to_thread(inject_plate_thumbnails_if_missing, result.content))
     out_path.write_bytes(result.content)
 
     # Extract thumbnail from the produced 3MF so the library card shows a
@@ -4691,8 +4693,9 @@ async def slice_and_persist_as_archive(
     # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
     # in headless --export-3mf, so the produced 3MF often has no thumbnail
     # at all. Server-side render fills the gap; no-op when the slicer did
-    # embed (desktop Studio path) and best-effort on any render error.
-    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
+    # embed (desktop Studio path) and best-effort on any render error. Off the
+    # event loop, like the library-slice path (#3135).
+    result = result._replace(content=await asyncio.to_thread(inject_plate_thumbnails_if_missing, result.content))
     out_path.write_bytes(result.content)
 
     # Extract a thumbnail for the new archive card. Priority order:

+ 287 - 42
backend/app/services/plate_thumbnail.py

@@ -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()
 
 

+ 223 - 0
backend/tests/unit/services/test_plate_thumbnail.py

@@ -196,3 +196,226 @@ class TestInjectPlateThumbnails:
         # Same object identity — second pass hits the no-op fast path
         # because every plate now has its plate_N.png.
         assert twice is once
+
+
+# --- Bambu Studio / OrcaSlicer layout (#3135) ------------------------------
+#
+# Those slicers keep every mesh in ``3D/Objects/*.model`` and write each
+# instance in ``3D/3dmodel.model`` as its own ``<object>`` holding one
+# ``<component p:path=...>``. trimesh's reader re-parsed the referenced file for
+# every such component and appended its meshes again each time, so N copies of
+# a part came back with N² copies of its triangles — 25 bins took 8.4 GB to
+# render and OOM-killed the server.
+
+_NS = (
+    'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" '
+    'xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/1015/06" requiredextensions="p"'
+)
+_IDENTITY = "1 0 0 0 1 0 0 0 1 0 0 0"
+
+
+def _box_mesh_xml(size: float = 10.0) -> tuple[str, int]:
+    import trimesh
+
+    box = trimesh.creation.box(extents=(size, size, size))
+    verts = "".join(f'<vertex x="{x}" y="{y}" z="{z}"/>' for x, y, z in box.vertices)
+    tris = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in box.faces)
+    return f"<mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh>", len(box.faces)
+
+
+def _bambu_layout_3mf(
+    placements: list[tuple[str, str]],
+    parts: dict[str, float] | None = None,
+    extra_root_objects: str = "",
+    extra_items: str = "",
+    mesh_xml: str | None = None,
+) -> bytes:
+    """A sliced 3MF in the Bambu/Orca layout.
+
+    ``parts``: object id -> box size, all in ``3D/Objects/object_1.model``.
+    ``placements``: (part id, build-item transform), one wrapper object each.
+    ``mesh_xml``: a ``<mesh>`` to use for every part instead of a box.
+    """
+    parts = parts or {"1": 10.0}
+    objects = "".join(
+        f'<object id="{oid}" type="model">{mesh_xml or _box_mesh_xml(size)[0]}</object>' for oid, size in parts.items()
+    )
+    part_file = f'<?xml version="1.0" encoding="UTF-8"?><model unit="millimeter" {_NS}><resources>{objects}</resources><build/></model>'
+    wrappers = "".join(
+        f'<object id="{100 + i}" type="model"><components>'
+        f'<component p:path="/3D/Objects/object_1.model" objectid="{part}" transform="{_IDENTITY}"/>'
+        f"</components></object>"
+        for i, (part, _t) in enumerate(placements)
+    )
+    items = "".join(f'<item objectid="{100 + i}" transform="{t}"/>' for i, (_p, t) in enumerate(placements))
+    root = (
+        f'<?xml version="1.0" encoding="UTF-8"?><model unit="millimeter" {_NS}>'
+        f"<resources>{wrappers}{extra_root_objects}</resources><build>{items}{extra_items}</build></model>"
+    )
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("3D/Objects/object_1.model", part_file)
+        zf.writestr("3D/3dmodel.model", root)
+        zf.writestr("Metadata/plate_1.gcode", b"; dummy gcode\n")
+    return buf.getvalue()
+
+
+def _grid(n: int) -> list[str]:
+    return [f"1 0 0 0 1 0 0 0 1 {20 * (i % 5)} {20 * (i // 5)} 5" for i in range(n)]
+
+
+def _geometry(blob: bytes):
+    import trimesh
+
+    from backend.app.services.plate_thumbnail import _load_plate_geometry
+
+    with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
+        return _load_plate_geometry(zf, trimesh, lambda *_a: None)
+
+
+@pytest.mark.skipif(not _trimesh_available(), reason="trimesh not installed")
+class TestBambuLayoutGeometry:
+    def test_each_instance_is_placed_once_not_n_squared(self):
+        _, box_faces = _box_mesh_xml()
+        blob = _bambu_layout_3mf([("1", t) for t in _grid(25)])
+
+        vertices, faces = _geometry(blob)
+
+        # 25 boxes of 12 faces. trimesh returned 25 * 25 * 12 = 7500.
+        assert len(faces) == 25 * box_faces
+        # Laid out on the grid, not stacked: 5 columns 20 mm apart plus a 10 mm box.
+        extent = vertices.max(axis=0) - vertices.min(axis=0)
+        assert extent[0] == pytest.approx(4 * 20 + 10)
+        assert extent[1] == pytest.approx(4 * 20 + 10)
+
+    def test_a_component_places_only_the_object_it_names(self):
+        # One object file holding three parts. trimesh appended all three to
+        # every part it referenced, so each placement drew the whole file.
+        _, box_faces = _box_mesh_xml()
+        blob = _bambu_layout_3mf(
+            [("1", _grid(1)[0]), ("3", "1 0 0 0 1 0 0 0 1 50 0 5")],
+            parts={"1": 10.0, "2": 40.0, "3": 10.0},
+        )
+
+        vertices, faces = _geometry(blob)
+
+        assert len(faces) == 2 * box_faces
+        # Part 2 (40 mm) is never placed, so nothing is that tall.
+        assert (vertices.max(axis=0) - vertices.min(axis=0))[2] == pytest.approx(10)
+
+    def test_a_mirrored_instance_keeps_its_faces_pointing_out(self):
+        import numpy as np
+        import trimesh
+
+        blob = _bambu_layout_3mf([("1", "-1 0 0 0 1 0 0 0 1 0 0 5")])
+
+        vertices, faces = _geometry(blob)
+
+        mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
+        outward = mesh.triangles.mean(axis=1) - mesh.vertices.mean(axis=0)
+        assert (np.einsum("ij,ij->i", mesh.face_normals, outward) > 0).all()
+
+    def test_a_self_referencing_component_terminates(self):
+        # Object 500 places the box and then itself; the walk must stop at the
+        # loop rather than recurse, and still draw the box it reached once.
+        loop = (
+            '<object id="500" type="model"><components>'
+            f'<component p:path="/3D/Objects/object_1.model" objectid="1" transform="{_IDENTITY}"/>'
+            f'<component objectid="500" transform="1 0 0 0 1 0 0 0 1 30 0 0"/></components></object>'
+        )
+        blob = _bambu_layout_3mf(
+            [],
+            extra_root_objects=loop,
+            extra_items=f'<item objectid="500" transform="{_IDENTITY}"/>',
+        )
+
+        _vertices, faces = _geometry(blob)
+
+        assert len(faces) == _box_mesh_xml()[1]
+
+    def test_a_build_item_can_name_the_file_its_object_lives_in(self):
+        # Production extension: ``p:path`` on the item itself, no wrapper object.
+        blob = _bambu_layout_3mf(
+            [],
+            extra_items=f'<item p:path="/3D/Objects/object_1.model" objectid="1" transform="{_IDENTITY}"/>',
+        )
+
+        _vertices, faces = _geometry(blob)
+
+        assert len(faces) == _box_mesh_xml()[1]
+
+    def test_a_component_pointing_at_a_missing_file_is_skipped(self):
+        blob = _bambu_layout_3mf([("1", _grid(1)[0])])
+        broken = io.BytesIO()
+        with zipfile.ZipFile(io.BytesIO(blob)) as src, zipfile.ZipFile(broken, "w") as dst:
+            for item in src.infolist():
+                if item.filename != "3D/Objects/object_1.model":
+                    dst.writestr(item, src.read(item.filename))
+
+        assert _geometry(broken.getvalue()) is None
+
+    def test_many_instances_are_decimated_to_the_face_budget(self, monkeypatch):
+        import trimesh
+
+        import backend.app.services.plate_thumbnail as pt
+
+        # 25 spheres of 5120 faces against a budget of 1000 per copy.
+        sphere = trimesh.creation.icosphere(subdivisions=4)
+        verts = "".join(f'<vertex x="{x}" y="{y}" z="{z}"/>' for x, y, z in sphere.vertices)
+        tris = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in sphere.faces)
+        blob = _bambu_layout_3mf(
+            [("1", t) for t in _grid(25)],
+            mesh_xml=f"<mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh>",
+        )
+        monkeypatch.setattr(pt, "_RENDER_FACE_BUDGET", 25 * 1000)
+
+        _vertices, faces = _geometry(blob)
+
+        # Decimated once, to its share of the budget, then placed 25 times.
+        assert len(faces) <= 25 * 1100
+        assert len(faces) % 25 == 0
+
+    def test_a_decimation_that_fails_is_still_held_to_the_ceiling(self, monkeypatch):
+        import trimesh
+
+        import backend.app.services.plate_thumbnail as pt
+
+        # The budget would bring 25 spheres to 25k faces, well under the ceiling,
+        # so the up-front check passes. Decimation then fails and leaves each
+        # sphere whole: 128k faces, which the render must not be handed.
+        sphere = trimesh.creation.icosphere(subdivisions=4)
+        verts = "".join(f'<vertex x="{x}" y="{y}" z="{z}"/>' for x, y, z in sphere.vertices)
+        tris = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in sphere.faces)
+        blob = _bambu_layout_3mf(
+            [("1", t) for t in _grid(25)],
+            mesh_xml=f"<mesh><vertices>{verts}</vertices><triangles>{tris}</triangles></mesh>",
+        )
+        monkeypatch.setattr(pt, "_RENDER_FACE_BUDGET", 25 * 1000)
+        monkeypatch.setattr(pt, "_MAX_PLACED_FACES", 50_000)
+
+        def fail(*_a, **_k):
+            raise RuntimeError("decimation failed")
+
+        monkeypatch.setattr(trimesh.Trimesh, "simplify_quadric_decimation", fail)
+
+        assert _geometry(blob) is None
+
+    def test_over_the_ceiling_skips_the_thumbnail_and_keeps_the_3mf(self, monkeypatch):
+        import backend.app.services.plate_thumbnail as pt
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        # 25 boxes can't go below 12 faces each, so a ceiling under 300 is
+        # unreachable however hard the budget decimates.
+        monkeypatch.setattr(pt, "_MIN_FACES_PER_MESH", 12)
+        monkeypatch.setattr(pt, "_MAX_PLACED_FACES", 100)
+        blob = _bambu_layout_3mf([("1", t) for t in _grid(25)])
+
+        assert inject_plate_thumbnails_if_missing(blob) is blob
+
+    def test_injects_thumbnails_for_a_bambu_layout_plate(self):
+        from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+
+        blob = _bambu_layout_3mf([("1", t) for t in _grid(25)])
+        out = inject_plate_thumbnails_if_missing(blob)
+
+        assert {"Metadata/plate_1.png", "Metadata/plate_1_small.png"} <= _names_in_zip(out)

Неке датотеке нису приказане због велике количине промена