printhive/tools/convert_3mf.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

57 lines
1.8 KiB
Python

#!/usr/bin/env python3
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
Usage: convert_3mf.py <input.3mf> <output.glb>
Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
"""
import sys
import json
import trimesh
import threemf_stream
def main():
if len(sys.argv) != 3:
print(json.dumps({"error": "usage: convert_3mf.py <in.3mf> <out.glb>"}))
return 2
in_path, out_path = sys.argv[1], sys.argv[2]
try:
meshes, placements = threemf_stream.load(in_path)
except Exception as exc:
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
return 1
if not placements:
print(json.dumps({"error": "no geometry found in 3MF"}))
return 1
# Each object is stored once; repeated placements become instances of it.
scene = trimesh.Scene()
added = set()
for i, (key, transform) in enumerate(placements):
node = f"part{i}"
if key in added:
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=transform, geometry=key)
else:
v, f = meshes[key]
scene.add_geometry(trimesh.Trimesh(vertices=v, faces=f, process=False),
node_name=node, geom_name=key, transform=transform)
added.add(key)
scene.export(out_path, file_type="glb")
stats = threemf_stream.measure(meshes, placements)
ext = stats["extents"]
print(json.dumps({
"glb": out_path,
"bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)},
"faces": stats["faces"],
# mm^3 -> cm^3 to match the rest of the app
"volume_cm3": round(stats["volume"] / 1000.0, 3),
}))
return 0
if __name__ == "__main__":
sys.exit(main())