printhive/tools/threemf_stream.py
dlawler489 fcb418f119 Show 3MF projects plate by plate in their filament colours
- threemf_stream reads Bambu Studio / OrcaSlicer project metadata: plates
  (name, objects, preview images), filaments, and each part's type and
  extruder. Placements now record which build item and part they belong to.
- convert_3mf.py leaves out negative/modifier/support parts, colours each
  part by its filament (a part's extruder 0 falls back to its object's), and
  takes --plate N. Geometry is still stored once per (part, colour).
- New threemf_info.py lists plates and filaments without loading geometry
  and extracts plate preview images; served via /api/{files,entries}/:id/project.
- /glb?plate=N builds and caches a per-plate preview. Cached GLB names carry
  a converter version, so previews made before colours are rebuilt.
- Model detail: plate chips (with the slicer's plate images) under the
  viewer — multi-plate projects open on plate 1 — plus "All plates", and a
  Filaments section with colour swatches.
- Fingerprints leave out negative/modifier parts, so a project matches the
  model it prints.

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

272 lines
11 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 read_project(path):
"""Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent:
{plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
filaments: [{colour, type}],
objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}}
"""
project = {"plates": [], "filaments": [], "objects": {}}
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
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]}