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>
55 lines
2.6 KiB
JavaScript
55 lines
2.6 KiB
JavaScript
// 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 });
|
|
}
|
|
});
|