- The background worker now also gives models without a thumbnail one: an embedded 3MF/plate preview, else a picture uploaded with the model, else a 3MF preview or picture inside one of its archives, else a render of an STL/OBJ/3MF (loose or inside an archive). - tools/render_thumbnail.py renders without a GPU: meshes are simplified per distinct part (the 8M-face lion project renders in ~8 s / ~200 MB), 3MF projects show their first plate in filament colours, and only camera-facing faces are painted (inside-out meshes and mirrored parts are re-oriented first). - models.thumbnail_status stops retries when there's nothing to use; adding files clears it. The browser now only renders FBX/STEP/3DM thumbnails. - The library checks back while fingerprints or thumbnails are pending and refreshes when they land. - Python sidecar needs pillow and fast-simplification; compose keeps the archive extraction cache in a volume. README covers the phase 2 features. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
79 lines
3.8 KiB
JavaScript
79 lines
3.8 KiB
JavaScript
// Server-side thumbnail rendering (tools/render_thumbnail.py).
|
|
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, spawnSync } = require('child_process');
|
|
const { PYTHON } = require('../lib/python');
|
|
|
|
const RENDER = path.join(__dirname, '..', 'tools', 'render_thumbnail.py');
|
|
const fixture = (name) => path.join(__dirname, 'fixtures', name);
|
|
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-thumb-'));
|
|
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
const lastJson = (stdout) => JSON.parse(stdout.trim().split('\n').pop());
|
|
|
|
// Size, how much of the image is covered, where, and how green/blue the model is.
|
|
function pixelStats(png) {
|
|
const script = `
|
|
import json, sys
|
|
from PIL import Image
|
|
im = Image.open(sys.argv[1]).convert("RGBA")
|
|
px = [p for p in im.getdata() if p[3] > 0]
|
|
opaque = Image.new("L", im.size); opaque.putdata([255 if p[3] > 0 else 0 for p in im.getdata()])
|
|
share = lambda test: sum(1 for p in px if test(p)) / max(len(px), 1)
|
|
from collections import Counter
|
|
top3 = sum(n for _, n in Counter(p[:3] for p in px if p[3] == 255).most_common(3)) / max(len(px), 1)
|
|
print(json.dumps({"size": im.size, "coverage": len(px) / (im.size[0] * im.size[1]), "bbox": opaque.getbbox(), "top3": top3,
|
|
"green": share(lambda p: p[1] > p[0] + 40 and p[1] > p[2] + 40),
|
|
"blue": share(lambda p: p[2] > p[0] + 40 and p[2] > p[1] + 40)}))`;
|
|
return JSON.parse(execFileSync(PYTHON, ['-c', script, png], { encoding: 'utf8' }));
|
|
}
|
|
|
|
test('an STL renders to a 512px PNG, fully in frame', { skip }, () => {
|
|
const out = path.join(tmp, 'cube.png');
|
|
const result = lastJson(execFileSync(PYTHON, [RENDER, fixture('cube.stl'), out], { encoding: 'utf8' }));
|
|
assert.equal(result.faces, 12);
|
|
const stats = pixelStats(out);
|
|
assert.deepEqual(stats.size, [512, 512]);
|
|
assert.ok(stats.coverage > 0.1 && stats.coverage < 0.9, `coverage ${stats.coverage}`);
|
|
const [left, top, right, bottom] = stats.bbox;
|
|
assert.ok(left > 2 && top > 2 && right < 510 && bottom < 510, `bbox ${stats.bbox}`);
|
|
});
|
|
|
|
// A cube shows three faces, each one flat shade; hidden faces painted over
|
|
// visible ones (or inside-out winding) would add more shades.
|
|
for (const inverted of [false, true]) {
|
|
test(`a cube${inverted ? ' with inside-out faces' : ''} shows just its three visible faces`, { skip }, () => {
|
|
let input = fixture('cube.stl');
|
|
if (inverted) {
|
|
input = path.join(tmp, 'inverted.stl');
|
|
execFileSync(PYTHON, ['-c', `
|
|
import sys, trimesh
|
|
m = trimesh.load(sys.argv[1], force="mesh"); m.invert(); m.export(sys.argv[2])`, fixture('cube.stl'), input]);
|
|
}
|
|
const out = path.join(tmp, `cube-${inverted}.png`);
|
|
execFileSync(PYTHON, [RENDER, input, out], { encoding: 'utf8' });
|
|
const { top3 } = pixelStats(out);
|
|
assert.ok(top3 > 0.9, `three most common colours cover ${top3}`);
|
|
});
|
|
}
|
|
|
|
test('a multi-plate 3MF renders its first plate in filament colours', { skip }, () => {
|
|
const out = path.join(tmp, 'plates.png');
|
|
lastJson(execFileSync(PYTHON, [RENDER, fixture('plates.3mf'), out], { encoding: 'utf8' }));
|
|
const stats = pixelStats(out);
|
|
assert.ok(stats.green > 0.5, `green share ${stats.green}`); // plate 1's cube prints in #00FF00
|
|
assert.ok(stats.blue < 0.01, `blue share ${stats.blue}`); // plate 2's blue tower isn't drawn
|
|
});
|
|
|
|
test('a file with no geometry fails with a JSON error', { skip }, () => {
|
|
const broken = path.join(tmp, 'broken.stl');
|
|
fs.writeFileSync(broken, 'this is not a mesh');
|
|
const run = spawnSync(PYTHON, [RENDER, broken, path.join(tmp, 'x.png')], { encoding: 'utf8' });
|
|
assert.equal(run.status, 1);
|
|
assert.match(lastJson(run.stdout).error, /could not render thumbnail/);
|
|
});
|