// 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/); });