printhive/tools/threemf_stream.py
dlawler489 502eefdec2 Stream-parse 3MF files so Bambu projects don't exhaust memory
trimesh's 3MF loader re-reads a whole production-extension part file for
every component that points into it. A Bambu Studio project with 884
components across four ~50 MB part files grew past 3 GB within seconds
and failed both the preview conversion and the geometry fingerprint.

- Add tools/threemf_stream.py: parses each part once with lxml iterparse,
  resolves each component to the single object it names, and returns each
  mesh once plus its placements. The same project now converts in ~8 s at
  ~380 MB peak (fingerprinting ~190 MB).
- convert_3mf.py and fingerprint.py use it; the GLB stores each object once
  and instances repeats. Stats are computed per placement (mirrored
  placements keep a positive volume), without concatenating meshes.
- Run sidecar scripts through one queue (lib/python.js) so a preview and
  background fingerprinting never load big meshes at the same time;
  previews go first, and 3MFs are fingerprinted one per run.
- Surface the converter's JSON error / timeout instead of "Command failed".
- Tests with a Bambu-style component fixture (shared part, mirrored
  placement, decoy object) and STL/3MF fingerprint parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:02:00 +10:00

160 lines
6.1 KiB
Python

"""Streaming 3MF reader shared by convert_3mf.py and fingerprint.py.
trimesh's 3MF loader re-reads a whole production-extension part file (every
mesh in it) for each component that points into that file. Bambu Studio
projects are hundreds of components pointing into a few large part files, so
the geometry multiplies until the process runs out of memory.
This reader parses each part file once, element by element, resolves each
component to the one object it names, and returns every object's mesh once
plus the transforms where it is placed — so parts reused across plates share
their geometry.
"""
import zipfile
from array import array
import numpy as np
from lxml import etree
UNIT_MM = {"micron": 0.001, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, "foot": 304.8, "meter": 1000.0}
PARSER = dict(huge_tree=True, resolve_entities=False, no_network=True)
MAX_DEPTH = 32
def _local(name):
return name.rpartition("}")[2]
def _path_attr(el):
# The production extension's p:path, whatever prefix the file bound it to.
for key, value in el.attrib.items():
if _local(key) == "path":
return value
return None
def _matrix(text):
m = np.eye(4)
if text:
v = [float(x) for x in text.split()]
if len(v) == 12:
# 3MF lists "m00 m01 m02 m10 ... m32" for row vectors; store column form.
m[:3, :3] = np.array(v[:9]).reshape(3, 3).T
m[:3, 3] = v[9:12]
return m
def _parse_part(fh):
"""Returns (objects, build items, unit) for one model part.
objects: {id: (vertices (n,3) float64, faces (m,3) int32, [(path|None, objectid, 4x4)])}
"""
objects, items, unit = {}, [], None
oid = verts = tris = comps = None
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
name = _local(el.tag)
if event == "start":
if name == "model":
unit = el.get("unit")
elif name == "object":
oid, verts, tris, comps = el.get("id"), array("d"), array("i"), []
continue
if name == "vertex":
verts.extend((float(el.get("x")), float(el.get("y")), float(el.get("z"))))
elif name == "triangle":
tris.extend((int(el.get("v1")), int(el.get("v2")), int(el.get("v3"))))
elif name == "component":
comps.append((_path_attr(el), el.get("objectid"), _matrix(el.get("transform"))))
elif name == "item":
items.append((el.get("objectid"), _matrix(el.get("transform"))))
elif name == "object":
v = np.frombuffer(verts, dtype=np.float64).reshape(-1, 3)
f = np.frombuffer(tris, dtype=np.int32).reshape(-1, 3)
if len(f) and (f.min() < 0 or f.max() >= len(v)):
f = f[:0] # corrupt indices: drop the mesh rather than crash later
objects[oid] = (v, f, comps)
# Free what's been read so memory stays flat on huge parts.
el.clear()
parent = el.getparent()
while parent is not None and el.getprevious() is not None:
del parent[0]
return objects, items, unit
def _root_part(z):
try:
rels = etree.fromstring(z.read("_rels/.rels"), etree.XMLParser(resolve_entities=False, no_network=True))
for rel in rels:
if rel.get("Type", "").endswith("/3dmodel"):
return rel.get("Target", "").lstrip("/")
except (KeyError, etree.XMLSyntaxError):
pass
return "3D/3dmodel.model"
def load(path):
"""Returns (meshes, placements).
meshes: {key: (vertices, faces)} — each object once, in file units.
placements: [(key, 4x4 transform)] — one per placed instance, mapping to millimetres.
"""
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
parts = {}
def part(name):
name = name.lstrip("/")
if name not in parts:
if name not in names:
raise ValueError(f"3MF references a missing part: {name}")
with z.open(name) as fh:
parts[name] = _parse_part(fh)
return parts[name]
root = _root_part(z)
_, items, unit = part(root)
scale = np.diag([UNIT_MM.get(unit or "millimeter", 1.0)] * 3 + [1.0])
meshes, placements = {}, []
def walk(name, oid, transform, depth):
if depth > MAX_DEPTH:
raise ValueError("3MF components nest too deeply")
obj = part(name)[0].get(oid)
if obj is None:
return # undefined reference; slicers skip these too
v, f, comps = obj
if len(f):
key = f"{name}#{oid}"
meshes.setdefault(key, (v, f))
placements.append((key, transform))
for cpath, coid, m in comps:
walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1)
for oid, m in items:
walk(root, oid, scale @ m, 0)
return meshes, placements
def measure(meshes, placements):
"""Faces, surface area (mm^2), volume (mm^3) and extents (mm) of everything
placed, as if concatenated into one mesh — computed per instance to keep
memory flat."""
faces, area, volume = 0, 0.0, 0.0
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
for key, t in placements:
v, f = meshes[key]
w = v @ t[:3, :3].T + t[:3, 3]
a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]]
cross = np.cross(b - a, c - a)
faces += len(f)
area += 0.5 * float(np.linalg.norm(cross, axis=1).sum())
# A mirroring transform flips winding; count its volume as positive
# (matching trimesh, which re-winds faces when applying such a transform).
signed = float(np.einsum("ij,ij->i", a, np.cross(b, c)).sum()) / 6.0
volume += -signed if np.linalg.det(t[:3, :3]) < 0 else signed
if len(w):
lo, hi = np.minimum(lo, w.min(axis=0)), np.maximum(hi, w.max(axis=0))
extents = (hi - lo) if faces else np.zeros(3)
return {"faces": faces, "area": area, "volume": abs(volume), "extents": [float(x) for x in extents]}