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>
This commit is contained in:
dlawler489 2026-09-15 07:01:49 +10:00
parent 00a4b4ba07
commit 502eefdec2
11 changed files with 337 additions and 71 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ thumbnails/
.env .env
glb/ glb/
tools/venv/ tools/venv/
__pycache__/

View file

@ -1,32 +1,22 @@
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can // Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't). // render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't).
const path = require('path'); const { runScript } = require('./python');
const { execFile } = require('child_process');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
// Returns { glb, volume_cm3, bbox, faces } or throws. // Returns { glb, volume_cm3, bbox, faces } or throws.
function convert3mfToGlb(inputPath, outputPath) { async function convert3mfToGlb(inputPath, outputPath) {
return new Promise((resolve, reject) => { const { err, stdout, stderr } = await runScript('convert_3mf.py', [inputPath, outputPath], { timeout: 120000 });
execFile( let result = null;
PYTHON,
[SCRIPT, inputPath, outputPath],
{ maxBuffer: 16 * 1024 * 1024, timeout: 120000 },
(err, stdout, stderr) => {
if (err) return reject(new Error(stderr ? stderr.split('\n').slice(-3).join(' ') : err.message));
let result;
try { try {
result = JSON.parse(stdout.trim().split('\n').pop()); result = JSON.parse(stdout.trim().split('\n').pop());
} catch { } catch { /* handled below */ }
return reject(new Error('Converter returned unparseable output.')); if (result && result.error) throw new Error(result.error);
if (err) {
if (err.killed) throw new Error('Timed out converting this 3MF for preview.');
throw new Error(stderr ? stderr.trim().split('\n').slice(-3).join(' ') : err.message);
} }
if (result.error) return reject(new Error(result.error)); if (!result) throw new Error('Converter returned unparseable output.');
resolve(result); return result;
}
);
});
} }
module.exports = { convert3mfToGlb }; module.exports = { convert3mfToGlb };

View file

@ -6,11 +6,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { execFile } = require('child_process');
const db = require('./db'); const db = require('./db');
const { PYTHON, runScript } = require('./python');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const SCRIPT = path.join(__dirname, '..', 'tools', 'fingerprint.py');
const GEOMETRY_FORMATS = ['stl', 'obj', '3mf']; const GEOMETRY_FORMATS = ['stl', 'obj', '3mf'];
const BATCH = 10; const BATCH = 10;
@ -45,39 +43,37 @@ function geometryKey(fp) {
// Runs the trimesh sidecar over some files. Resolves Map(absPath -> {fp}|{error}); // Runs the trimesh sidecar over some files. Resolves Map(absPath -> {fp}|{error});
// output from a run that timed out or crashed part-way is still returned. // output from a run that timed out or crashed part-way is still returned.
function measure(paths) { async function measure(paths) {
return new Promise((resolve, reject) => { const { err, stdout } = await runScript('fingerprint.py', paths,
execFile( { timeout: 60000 * (paths.length + 1), isBackground: true });
PYTHON, if (err && err.code === 'ENOENT') throw err;
[SCRIPT, ...paths],
{ maxBuffer: 4 * 1024 * 1024, timeout: 60000 * (paths.length + 1) },
(err, stdout) => {
if (err && err.code === 'ENOENT') return reject(err);
const results = new Map(); const results = new Map();
for (const line of String(stdout || '').split('\n')) { for (const line of stdout.split('\n')) {
if (!line.trim()) continue; if (!line.trim()) continue;
try { try {
const r = JSON.parse(line); const r = JSON.parse(line);
results.set(r.path, r); results.set(r.path, r);
} catch { /* ignore non-JSON noise */ } } catch { /* ignore non-JSON noise */ }
} }
resolve(results); return results;
}
);
});
} }
async function fingerprintGeometry(rows) { async function fingerprintGeometry(rows) {
const abs = (row) => path.resolve(row.file_path); const abs = (row) => path.resolve(row.file_path);
const results = await measure(rows.map(abs)); // 3MF projects can be big, so each gets its own run; that keeps a queued
for (const row of rows) { // preview from waiting behind a whole batch of them.
const chunks = [rows.filter((r) => r.format !== '3mf'), ...rows.filter((r) => r.format === '3mf').map((r) => [r])];
for (const chunk of chunks.filter((c) => c.length)) {
const results = await measure(chunk.map(abs));
for (const row of chunk) {
let r = results.get(abs(row)); let r = results.get(abs(row));
// A batch that died part-way: retry the missing file on its own. // A batch that died part-way: retry the missing file on its own.
if (!r && rows.length > 1) r = (await measure([abs(row)])).get(abs(row)); if (!r && chunk.length > 1) r = (await measure([abs(row)])).get(abs(row));
const key = r && r.fp ? geometryKey(r.fp) : null; const key = r && r.fp ? geometryKey(r.fp) : null;
await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1', await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1',
[row.id, key, key ? 'ok' : 'error']); [row.id, key, key ? 'ok' : 'error']);
} }
}
} }
async function run() { async function run() {

42
lib/python.js Normal file
View file

@ -0,0 +1,42 @@
// Runs the Python/trimesh sidecar scripts one at a time. Loading a large mesh
// can take hundreds of MB, so a preview conversion and background
// fingerprinting never run at once. Jobs someone is waiting on (previews) go
// ahead of queued background work.
const path = require('path');
const { execFile } = require('child_process');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const TOOLS = path.join(__dirname, '..', 'tools');
const urgent = [];
const background = [];
let busy = false;
// Resolves { err, stdout, stderr } — never rejects, so callers can use partial
// output from a run that failed part-way.
function runScript(script, args, { timeout = 120000, isBackground = false } = {}) {
return new Promise((resolve) => {
(isBackground ? background : urgent).push({ script, args, timeout, resolve });
next();
});
}
function next() {
if (busy) return;
const job = urgent.shift() || background.shift();
if (!job) return;
busy = true;
execFile(
PYTHON,
[path.join(TOOLS, job.script), ...job.args],
{ maxBuffer: 16 * 1024 * 1024, timeout: job.timeout },
(err, stdout, stderr) => {
busy = false;
job.resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
next();
}
);
}
module.exports = { PYTHON, runScript };

BIN
test/fixtures/components.3mf vendored Normal file

Binary file not shown.

BIN
test/fixtures/cube.stl vendored Normal file

Binary file not shown.

BIN
test/fixtures/cube_rotated.3mf vendored Normal file

Binary file not shown.

55
test/geometry.test.js Normal file
View file

@ -0,0 +1,55 @@
// Exercises the Python sidecar scripts against small fixtures. Skipped when the
// Python venv isn't installed.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { PYTHON } = require('../lib/python');
const { geometryKey } = require('../lib/fingerprint');
const TOOLS = path.join(__dirname, '..', 'tools');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
function fingerprints(...names) {
const out = execFileSync(PYTHON, [path.join(TOOLS, 'fingerprint.py'), ...names.map(fixture)], { encoding: 'utf8' });
return out.trim().split('\n').map((line) => JSON.parse(line));
}
test('a cube matches across binary STL and a rotated, moved 3MF', { skip }, () => {
const [stl, threemf] = fingerprints('cube.stl', 'cube_rotated.3mf');
assert.ok(stl.fp && threemf.fp, JSON.stringify([stl, threemf]));
assert.equal(threemf.fp.faces, 12);
assert.equal(geometryKey(stl.fp), geometryKey(threemf.fp));
});
// components.3mf is laid out like a Bambu project: the root model's objects are
// components pointing (via p:path) at object 1 of a separate part file, which
// also holds a decoy object 2. One placement is mirrored.
test('3MF components resolve to the one object they reference, once per placement', { skip }, () => {
const [r] = fingerprints('components.3mf');
assert.ok(r.fp, JSON.stringify(r));
assert.equal(r.fp.faces, 24);
assert.ok(Math.abs(r.fp.volume - 16000) < 1e-6, `volume ${r.fp.volume}`);
assert.deepEqual(r.fp.extents.map(Math.round).sort((a, b) => a - b), [10, 40, 120]);
});
test('3MF converts to GLB with shared geometry and a combined bounding box', { skip }, () => {
const out = path.join(os.tmpdir(), `printhive-test-${process.pid}.glb`);
try {
const stdout = execFileSync(PYTHON, [path.join(TOOLS, 'convert_3mf.py'), fixture('components.3mf'), out], { encoding: 'utf8' });
const summary = JSON.parse(stdout.trim().split('\n').pop());
assert.equal(summary.faces, 24);
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [120, 40, 10]);
assert.ok(Math.abs(summary.volume_cm3 - 16) < 1e-6);
const glb = fs.readFileSync(out);
const gltf = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
assert.equal(gltf.meshes.length, 1, 'the object is stored once');
assert.equal(gltf.nodes.filter((n) => n.mesh !== undefined).length, 2, 'and placed twice');
} finally {
fs.rmSync(out, { force: true });
}
});

View file

@ -6,8 +6,11 @@ Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
""" """
import sys import sys
import json import json
import trimesh import trimesh
import threemf_stream
def main(): def main():
if len(sys.argv) != 3: if len(sys.argv) != 3:
@ -15,28 +18,38 @@ def main():
return 2 return 2
in_path, out_path = sys.argv[1], sys.argv[2] in_path, out_path = sys.argv[1], sys.argv[2]
scene = trimesh.load(in_path, force="scene") try:
geoms = list(scene.geometry.values()) meshes, placements = threemf_stream.load(in_path)
if not geoms: 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"})) print(json.dumps({"error": "no geometry found in 3MF"}))
return 1 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") scene.export(out_path, file_type="glb")
combined = trimesh.util.concatenate(geoms) stats = threemf_stream.measure(meshes, placements)
ext = combined.bounds[1] - combined.bounds[0] ext = stats["extents"]
summary = { print(json.dumps({
"glb": out_path, "glb": out_path,
"bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)}, "bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)},
"faces": int(sum(len(g.faces) for g in geoms)), "faces": stats["faces"],
} # mm^3 -> cm^3 to match the rest of the app
try: "volume_cm3": round(stats["volume"] / 1000.0, 3),
# trimesh volume is in model units (mm) -> cm^3 to match the rest of the app }))
summary["volume_cm3"] = round(float(combined.volume) / 1000.0, 3)
except Exception:
summary["volume_cm3"] = None
print(json.dumps(summary))
return 0 return 0

View file

@ -7,10 +7,19 @@ Multi-object files (e.g. 3MF projects) are measured as one combined mesh.
""" """
import sys import sys
import json import json
import trimesh import trimesh
import threemf_stream
def measure(path): 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") mesh = trimesh.load(path, force="mesh")
if mesh is None or len(mesh.faces) == 0: if mesh is None or len(mesh.faces) == 0:
raise ValueError("no geometry found") raise ValueError("no geometry found")

160
tools/threemf_stream.py Normal file
View file

@ -0,0 +1,160 @@
"""Streaming 3MF reader shared by convert_3mf.py and fingerprint.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.
"""
import zipfile
from array import array
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
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 _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: [(key, 4x4 transform)] — one per placed instance, mapping 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):
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((key, transform))
for cpath, coid, m in comps:
walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1)
for oid, m in items:
walk(root, oid, scale @ m, 0)
return meshes, placements
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 key, t in placements:
v, f = meshes[key]
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]}