mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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.
This commit is contained in:
@@ -9,6 +9,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
|
||||
|
||||
### Fixed
|
||||
- **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server.
|
||||
- **Moving a queued job from "Any P2S" to one P2S threw away its filament colour (#3133, reported by @bgrr74)** — a job queued for any printer of a model can print in a different filament from the one it was sliced with: brown in the 3MF, Bone White on the spool. Editing that job and switching it to a specific printer of the same model reset the choice, and the job went back to brown. The print dialog cleared overrides whenever the target model changed, and "Specific Printer" empties the target model, so the switch counted as a change. Clearing would not have been the whole problem anyway: in specific-printer mode the dialog matched trays against the 3MF's colours, never sent the override, and left the old one on the queue item, where it contradicted the trays the job would actually use. Now the override is kept through the switch, and the chosen printer's trays are matched against the requested filament, so Bone White picks the Bone White spool, and a printer without one shows the usual colour-mismatch warning for Bone White instead of quietly settling on brown. The job keeps the override on the queue item, so if the dialog could not resolve every tray, the scheduler's match at dispatch looks for the same colour. Moving the job to a different printer model, or picking a different plate, still clears the override, as before. A printer's queue card and badge no longer hide a job assigned to that printer because its override colour is not loaded. That check is for picking among printers of a model, which a job for one printer has already done. The same goes for a virtual printer's force-colour jobs sent to a specific printer, which the card used to hide the same way even though the scheduler dispatched them. Saving a virtual-printer job in the print dialog now keeps the PLA-variant pin (Basic, Matte or Silk) its force-colour overrides carry. Editing one queued for any printer of a model used to strip it.
|
||||
- **Asking for 25 copies across two printer models queued exactly one (#3101, reported by @Leander-Vh)** — the reporter sliced one project for two printer types, selected both files in the File Manager, set the count and pressed Print. One item printed, the queue was empty behind it, and the Batches tab held nothing. Two features had met badly. Cross-model alternatives (#671) put several sliced files behind one queue slot, and a multi-plate file (#342) moves the run count out of the modal's single Quantity field onto a stepper beside each plate. The cross-model submit reads the field, not the steppers — and on a multi-plate file that field is not rendered at all, so it stayed at its initial 1 with nothing on screen able to change it. The modal read "19 runs in total" directly above a button that queued one. Cross-model no longer borrows the per-plate steppers: such a job picks its plate per candidate, down in the alternatives list, so there is only ever one number to give and the ordinary Quantity field is back. The plate selector above it is gone in that mode for the same reason — its choice never reached the request. It only decided which plate the filament panel described, which is why picking plate 3 for a candidate while plate 1 stayed ticked above it produced overrides computed from a plate the job would not print; that panel now follows the first candidate's own plate. The backend needed nothing for the count itself — it has always created one item per copy and given each its own candidate rows, so the same request now produces 25 jobs, each free to land on whichever of the two models frees up first. It did need a name: a cross-model job carries neither an archive nor a library file, because the candidates are the files, so both branches that name a batch missed and the order the reporter went looking for would have appeared in the Batches tab as "Batch". It is now named after its first candidate, the same file the print dialog names the job after.
|
||||
- **A print from Bambu Studio archived under the name `plate_1` instead of its own (#3126, reported by @alex-2000-ac-ghetto)** — the reporter's X2D archived Bambu Studio prints with no thumbnail, no filament total and no layer count, while the same project sent from OrcaSlicer archived in full. Two separate things were happening. The first is the known internal-storage limitation: Bambu Studio filed the sliced file on the printer's eMMC (`"url": "brtc://emmc/..."`), which FTPS on port 990 does not serve, and the X2D does this like the H2 series and the P2S. Bambuddy's bounded probe then found a file of exactly that name on the card and downloaded it — but it was an earlier slice of the same project, plate 4, while the running print was plate 1. The guard added in #1204 caught the contradiction and refused it, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it did next was not. Having rejected the file, it asked `swap_plate_suffix` for a name with the correct plate number and blanked the print's name entirely whenever that came back `None` — but `None` covers a name that carries no `- Plate N` suffix at all, and such a name holds no stale plate number to be wrong about. So a single-plate project lost the name Bambuddy was holding and the row fell through to the gcode_file path, titled `plate_1`. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way; it is now kept as the archive's title, and only a suffix that really does point at the wrong plate is rewritten. Kept as the title and nothing else, though: a name the guard has just watched fetch another plate's 3MF still gets disowned everywhere a file is looked up by name. That includes the `_active_prints` keys, and it matters — the cover endpoint downloads the running print's 3MF under that same name for a thumbnail and offers the bytes to the fallback-recovery path, which checks that a candidate is a readable 3MF but never which plate it holds. Registering the row under the rejected name would have let it be filled in with the exact file this branch had just discarded. The archive is still thumbnail-less — nothing can read that eMMC file — but it carries the print's name again. Separately, the Archives banner and the connection diagnostic named only "H2-series and P2S" as the models that do this, in all 14 languages; an X2D owner reading either was told the explanation did not apply to them. All three now name the X2D too.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user