- 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>
77 lines
2.7 KiB
Python
77 lines
2.7 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> [--plate N]
|
|
Prints a JSON summary {glb, plate, volume_cm3, bbox, faces} to stdout.
|
|
Negative/modifier/support parts are left out, parts are coloured by their
|
|
filament when the project says so, and --plate keeps just that plate.
|
|
"""
|
|
import argparse
|
|
import json
|
|
|
|
import trimesh
|
|
from trimesh.visual import TextureVisuals
|
|
from trimesh.visual.material import PBRMaterial
|
|
|
|
import threemf_stream
|
|
|
|
|
|
def mesh_in(vertices, faces, colour):
|
|
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
|
|
if colour:
|
|
rgb = [int(colour[i:i + 2], 16) for i in (1, 3, 5)]
|
|
mesh.visual = TextureVisuals(material=PBRMaterial(
|
|
baseColorFactor=rgb + [255], metallicFactor=0.1, roughnessFactor=0.7))
|
|
return mesh
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("input")
|
|
parser.add_argument("output")
|
|
parser.add_argument("--plate", type=int)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
meshes, placements = threemf_stream.load(args.input)
|
|
project = threemf_stream.read_project(args.input)
|
|
placements = threemf_stream.printable(project, placements)
|
|
if args.plate is not None:
|
|
placements = threemf_stream.on_plate(project, placements, args.plate)
|
|
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, colour) is stored once; repeated placements become instances of it.
|
|
scene = trimesh.Scene()
|
|
added = set()
|
|
for i, p in enumerate(placements):
|
|
colour = threemf_stream.filament_colour(project, p)
|
|
geom = f"{p.key}|{colour or ''}"
|
|
node = f"part{i}"
|
|
if geom in added:
|
|
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=p.transform, geometry=geom)
|
|
else:
|
|
v, f = meshes[p.key]
|
|
scene.add_geometry(mesh_in(v, f, colour), node_name=node, geom_name=geom, transform=p.transform)
|
|
added.add(geom)
|
|
scene.export(args.output, file_type="glb")
|
|
|
|
stats = threemf_stream.measure(meshes, placements)
|
|
ext = stats["extents"]
|
|
print(json.dumps({
|
|
"glb": args.output,
|
|
"plate": args.plate,
|
|
"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__":
|
|
raise SystemExit(main())
|