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>
49 lines
1.3 KiB
Python
49 lines
1.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Measure mesh files for geometry fingerprints (duplicate detection).
|
|
|
|
Usage: fingerprint.py <file> [<file> ...]
|
|
Prints one JSON line per file: {path, fp: {faces, area, volume, extents}} or {path, error}.
|
|
Multi-object files (e.g. 3MF projects) are measured as one combined mesh.
|
|
"""
|
|
import sys
|
|
import json
|
|
|
|
import trimesh
|
|
|
|
import threemf_stream
|
|
|
|
|
|
def measure(path):
|
|
if path.lower().endswith(".3mf"):
|
|
stats = threemf_stream.measure(*threemf_stream.load(path))
|
|
if not stats["faces"]:
|
|
raise ValueError("no geometry found")
|
|
return stats
|
|
|
|
mesh = trimesh.load(path, force="mesh")
|
|
if mesh is None or len(mesh.faces) == 0:
|
|
raise ValueError("no geometry found")
|
|
try:
|
|
volume = abs(float(mesh.volume))
|
|
except Exception:
|
|
volume = 0.0
|
|
return {
|
|
"faces": int(len(mesh.faces)),
|
|
"area": float(mesh.area),
|
|
"volume": volume,
|
|
"extents": [float(x) for x in mesh.extents],
|
|
}
|
|
|
|
|
|
def main():
|
|
for path in sys.argv[1:]:
|
|
try:
|
|
out = {"path": path, "fp": measure(path)}
|
|
except Exception as exc:
|
|
out = {"path": path, "error": str(exc)[:200]}
|
|
print(json.dumps(out), flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|