diff --git a/.gitignore b/.gitignore index cd8b875..3deaf18 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ thumbnails/ .env glb/ tools/venv/ +__pycache__/ diff --git a/lib/converter.js b/lib/converter.js index f2939f7..827db60 100644 --- a/lib/converter.js +++ b/lib/converter.js @@ -1,32 +1,22 @@ // 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). -const path = require('path'); -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'); +const { runScript } = require('./python'); // Returns { glb, volume_cm3, bbox, faces } or throws. -function convert3mfToGlb(inputPath, outputPath) { - return new Promise((resolve, reject) => { - execFile( - 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 { - result = JSON.parse(stdout.trim().split('\n').pop()); - } catch { - return reject(new Error('Converter returned unparseable output.')); - } - if (result.error) return reject(new Error(result.error)); - resolve(result); - } - ); - }); +async function convert3mfToGlb(inputPath, outputPath) { + const { err, stdout, stderr } = await runScript('convert_3mf.py', [inputPath, outputPath], { timeout: 120000 }); + let result = null; + try { + result = JSON.parse(stdout.trim().split('\n').pop()); + } catch { /* handled below */ } + 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) throw new Error('Converter returned unparseable output.'); + return result; } module.exports = { convert3mfToGlb }; diff --git a/lib/fingerprint.js b/lib/fingerprint.js index fb2afce..d4031e4 100644 --- a/lib/fingerprint.js +++ b/lib/fingerprint.js @@ -6,11 +6,9 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { execFile } = require('child_process'); 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 BATCH = 10; @@ -45,38 +43,36 @@ function geometryKey(fp) { // 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. -function measure(paths) { - return new Promise((resolve, reject) => { - execFile( - PYTHON, - [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(); - for (const line of String(stdout || '').split('\n')) { - if (!line.trim()) continue; - try { - const r = JSON.parse(line); - results.set(r.path, r); - } catch { /* ignore non-JSON noise */ } - } - resolve(results); - } - ); - }); +async function measure(paths) { + const { err, stdout } = await runScript('fingerprint.py', paths, + { timeout: 60000 * (paths.length + 1), isBackground: true }); + if (err && err.code === 'ENOENT') throw err; + const results = new Map(); + for (const line of stdout.split('\n')) { + if (!line.trim()) continue; + try { + const r = JSON.parse(line); + results.set(r.path, r); + } catch { /* ignore non-JSON noise */ } + } + return results; } async function fingerprintGeometry(rows) { const abs = (row) => path.resolve(row.file_path); - const results = await measure(rows.map(abs)); - for (const row of rows) { - let r = results.get(abs(row)); - // 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)); - const key = r && r.fp ? geometryKey(r.fp) : null; - await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1', - [row.id, key, key ? 'ok' : 'error']); + // 3MF projects can be big, so each gets its own run; that keeps a queued + // 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)); + // A batch that died part-way: retry the missing file on its own. + if (!r && chunk.length > 1) r = (await measure([abs(row)])).get(abs(row)); + const key = r && r.fp ? geometryKey(r.fp) : null; + await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1', + [row.id, key, key ? 'ok' : 'error']); + } } } diff --git a/lib/python.js b/lib/python.js new file mode 100644 index 0000000..c0d3389 --- /dev/null +++ b/lib/python.js @@ -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 }; diff --git a/test/fixtures/components.3mf b/test/fixtures/components.3mf new file mode 100644 index 0000000..0c5d72c Binary files /dev/null and b/test/fixtures/components.3mf differ diff --git a/test/fixtures/cube.stl b/test/fixtures/cube.stl new file mode 100644 index 0000000..f65a00c Binary files /dev/null and b/test/fixtures/cube.stl differ diff --git a/test/fixtures/cube_rotated.3mf b/test/fixtures/cube_rotated.3mf new file mode 100644 index 0000000..90d1657 Binary files /dev/null and b/test/fixtures/cube_rotated.3mf differ diff --git a/test/geometry.test.js b/test/geometry.test.js new file mode 100644 index 0000000..8bafba6 --- /dev/null +++ b/test/geometry.test.js @@ -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 }); + } +}); diff --git a/tools/convert_3mf.py b/tools/convert_3mf.py index 5277c9b..fb0f90a 100644 --- a/tools/convert_3mf.py +++ b/tools/convert_3mf.py @@ -6,8 +6,11 @@ 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: @@ -15,28 +18,38 @@ def main(): return 2 in_path, out_path = sys.argv[1], sys.argv[2] - scene = trimesh.load(in_path, force="scene") - geoms = list(scene.geometry.values()) - if not geoms: + 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") - combined = trimesh.util.concatenate(geoms) - ext = combined.bounds[1] - combined.bounds[0] - summary = { + stats = threemf_stream.measure(meshes, placements) + ext = stats["extents"] + print(json.dumps({ "glb": out_path, - "bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)}, - "faces": int(sum(len(g.faces) for g in geoms)), - } - try: - # 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)) + "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 diff --git a/tools/fingerprint.py b/tools/fingerprint.py index 22475ab..edac29f 100644 --- a/tools/fingerprint.py +++ b/tools/fingerprint.py @@ -7,10 +7,19 @@ 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") diff --git a/tools/threemf_stream.py b/tools/threemf_stream.py new file mode 100644 index 0000000..4a2d11a --- /dev/null +++ b/tools/threemf_stream.py @@ -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]}