printhive/tools/threemf_stream.py
dlawler489 f3a69c82ec Open models in your slicer and drag files out
- Settings → Slicer: pick Bambu Studio, OrcaSlicer, PrusaSlicer, Cura, a
  custom open-link template, or plain download (remembered per browser)
- Bambu Studio / OrcaSlicer launch via their URL schemes with a 10-minute
  signed download link (/dl/<token>/<name>) that needs no session cookie
- PrusaSlicer and Cura fall back to a download (they can't open our links)
- 3MF projects report which app saved them; opening one in a different
  slicer asks first
- "slicer" action on every file row and archive entry
- Drag model cards (file, or zip of all files) and file/entry rows out to
  the desktop via Chromium's DownloadURL

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

294 lines
12 KiB
Python

"""Streaming 3MF reader shared by convert_3mf.py, fingerprint.py and threemf_info.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. It also reads Bambu Studio / OrcaSlicer project metadata
(plates, filaments, part types) when the file has it.
"""
import json
import re
import zipfile
from array import array
from collections import namedtuple
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
# Slicer part types that shape or steer printing but aren't printed themselves.
HIDDEN_SUBTYPES = {"negative_part", "modifier_part", "support_blocker", "support_enforcer"}
# One placed copy of an object's mesh. `item` is the object id of the build item
# it belongs to and `instance` which occurrence of that id it is; `part` is the
# component directly under that object (how slicer metadata names parts).
Placement = namedtuple("Placement", "key transform item instance part")
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 _int(value):
try:
return int(value)
except (TypeError, ValueError):
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: [Placement] — one per placed instance; transforms map 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, item, instance, part_id):
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(Placement(key, transform, item, instance, part_id))
for cpath, coid, m in comps:
walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1,
item, instance, part_id if depth else coid)
seen = {}
for oid, m in items:
instance = seen.get(oid, 0)
seen[oid] = instance + 1
walk(root, oid, scale @ m, 0, oid, instance, None)
return meshes, placements
def _application(z, names):
"""The app that wrote the file, from the root model's <metadata name="Application">.
Metadata comes before the geometry, so reading stops at <resources>."""
root = _root_part(z)
if root not in names:
return None
try:
with z.open(root) as fh:
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
name = _local(el.tag)
if event == "start":
if name in ("resources", "build"):
break
elif name == "metadata" and el.get("name") == "Application":
return (el.text or "").strip() or None
except etree.XMLSyntaxError:
pass
return None
def read_project(path):
"""Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent:
{application: "BambuStudio-02.01.01.52" | None,
plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
filaments: [{colour, type}],
objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}}
"""
project = {"application": None, "plates": [], "filaments": [], "objects": {}}
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
project["application"] = _application(z, names)
if "Metadata/project_settings.config" in names:
try:
settings = json.loads(z.read("Metadata/project_settings.config"))
colours = settings.get("filament_colour") or []
types = settings.get("filament_type") or []
project["filaments"] = [
{"colour": c, "type": types[i] if i < len(types) else None} for i, c in enumerate(colours)]
except (ValueError, UnicodeDecodeError, AttributeError):
pass
if "Metadata/model_settings.config" in names:
try:
root = etree.fromstring(z.read("Metadata/model_settings.config"), etree.XMLParser(**PARSER))
except etree.XMLSyntaxError:
return project
def meta(el):
return {m.get("key"): m.get("value") for m in el.findall("metadata") if m.get("key")}
for obj in root.findall("object"):
info = meta(obj)
project["objects"][obj.get("id")] = {
"name": info.get("name"),
"extruder": _int(info.get("extruder")),
"parts": {
p.get("id"): {"extruder": _int(meta(p).get("extruder")), "subtype": p.get("subtype") or "normal_part"}
for p in obj.findall("part")
},
}
for plate in root.findall("plate"):
info = meta(plate)
index = _int(info.get("plater_id")) or len(project["plates"]) + 1
large = info.get("thumbnail_file") or f"Metadata/plate_{index}.png"
small = f"Metadata/plate_{index}_small.png"
project["plates"].append({
"index": index,
"name": info.get("plater_name") or "",
"objects": [(meta(i).get("object_id"), _int(meta(i).get("instance_id")) or 0)
for i in plate.findall("model_instance")],
"thumbnail": large if large in names else None,
"thumbnail_small": small if small in names else None,
})
return project
def printable(project, placements):
"""Drops negative, modifier and support parts (known from slicer metadata)."""
def visible(p):
obj = project["objects"].get(p.item)
part = obj and obj["parts"].get(p.part)
return not part or part["subtype"] not in HIDDEN_SUBTYPES
return [p for p in placements if visible(p)]
def on_plate(project, placements, plate_index):
plate = next((pl for pl in project["plates"] if pl["index"] == plate_index), None)
if plate is None:
raise ValueError(f"no plate {plate_index} in this project")
wanted = set(plate["objects"])
return [p for p in placements if (p.item, p.instance) in wanted]
def filament_colour(project, placement):
"""The '#RRGGBB' colour a placed part prints in, or None if unknown.
A part's extruder of 0 (or none) means it uses its object's extruder."""
obj = project["objects"].get(placement.item)
if not obj:
return None
part = obj["parts"].get(placement.part) or {}
extruder = part.get("extruder") or obj["extruder"]
filaments = project["filaments"]
if extruder and 1 <= extruder <= len(filaments):
colour = filaments[extruder - 1]["colour"]
if isinstance(colour, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", colour):
return colour[:7]
return None
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 p in placements:
v, f = meshes[p.key]
t = p.transform
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]}