fix: move preview generation to own separate thread

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-27 07:40:49 +02:00
1 parent 355833ca24
commit ba08d30e1e
6 files changed
+923 -709

No files matched your search

+6
View File
@@ -30,6 +30,12 @@ export const PREVIEWS = path.join(STORAGE, "previews");
// Pre-rendered GLB caches for the interactive 3D preview (see services/modelPreviewCache.ts) --
// generated once per .3mf Plate so the viewer never has to re-parse a huge raw 3MF on every open.
export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews");
// Limits on the worker thread that renders one of those GLBs: past either, the thread is killed
// and that plate falls back to the viewer's live parser. Memory is how much the whole process may
// grow while a render runs (and the worker's heap cap) -- a watchdog check, so a fast allocation
// burst can overshoot it briefly.
export const MODEL_PREVIEW_MAX_MEMORY_MB = envInt("MODEL_PREVIEW_MAX_MEMORY_MB", 2048);
export const MODEL_PREVIEW_TIMEOUT_SECONDS = envInt("MODEL_PREVIEW_TIMEOUT_SECONDS", 180);
// Base URL this instance is publicly reachable at -- needed to build absolute links in outgoing
// emails (e.g. the email-verification link), which unlike API responses can't rely on the
+122 -708
View File
@@ -1,694 +1,15 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import * as cheerio from "cheerio";
import { MODEL_PREVIEWS } from "../config";
import { listZipEntries, readZipEntry, walkZipEntries } from "../utils/zipReader";
// Generates the cached GLB the interactive 3D preview loads instead of re-parsing a raw .3mf
// on every open (see frontend/src/utils/bambuThreeMf.ts, whose *rules* this file mirrors -- same
// extruder-resolution priority, same plate-assignment fallbacks, same component p:path handling
// -- but not its parsing mechanism. That file walks the DOM node-by-node; a Bambu-sliced 3MF for
// a genuinely large model can have millions of <vertex>/<triangle> elements, and DOM attribute
// access at that scale is what causes Thingport's "3D Preview" to hang indefinitely (root-caused
// against a real 224MB/2.66M-triangle repro). This file never builds a DOM for the bulk mesh data
// -- it scans the raw XML text with a handful of fixed-shape regexes straight into typed arrays,
// which is the actual fix. It only runs once per plate (in the background, off the request path)
// rather than on every viewer open.
// ---- FileReader polyfill --------------------------------------------------------------------
//
// Node has no FileReader; three.js's GLTFExporter (examples/jsm/exporters/GLTFExporter.js) needs
// one for its binary (GLB) export path even though nothing here uses images/textures. Confirmed
// working against a real export+reload round-trip (including custom userData surviving through
// glTF `extras`) before this file was written. Node's built-in Blob already provides
// `.arrayBuffer()`, so the polyfill only needs to bridge that to the old onload/onloadend
// callback shape the exporter expects.
class NodeFileReader {
onload?: (e: { target: NodeFileReader }) => void;
onloadend?: (e: { target: NodeFileReader }) => void;
onerror?: (e: { target: NodeFileReader }) => void;
result: ArrayBuffer | string | null = null;
error: unknown = null;
readAsArrayBuffer(blob: Blob): void {
blob
.arrayBuffer()
.then((buf) => {
this.result = buf;
this.onload?.({ target: this });
this.onloadend?.({ target: this });
})
.catch((err) => {
this.error = err;
this.onerror?.({ target: this });
this.onloadend?.({ target: this });
});
}
readAsDataURL(blob: Blob): void {
blob
.arrayBuffer()
.then((buf) => {
this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(buf).toString("base64")}`;
this.onload?.({ target: this });
this.onloadend?.({ target: this });
})
.catch((err) => {
this.error = err;
this.onerror?.({ target: this });
this.onloadend?.({ target: this });
});
}
}
if (typeof (globalThis as { FileReader?: unknown }).FileReader === "undefined") {
(globalThis as unknown as { FileReader: unknown }).FileReader = NodeFileReader;
}
// ---- Small XML helpers (operate on a tag's raw attribute string, not a DOM node) --------------
function getAttr(attrs: string, name: string): string | null {
const m = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`));
return m ? m[1] : null;
}
/** Mirrors bambuThreeMf.ts's parsePlateIdFromAttributes: accepts plate_id/plater_id (any casing,
* optionally namespace-prefixed) since different Bambu Studio versions have used both spellings. */
function findPlateIdAttr(attrs: string): number | null {
const m = attrs.match(/(?:^|\s)(?:[\w-]+:)?(?:plate_id|plater_id|plateid|platerid)="([^"]*)"/i);
if (!m) return null;
const parsed = Number.parseInt(m[1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
/** 3MF's 3x4 affine transform, in the string's own order: [m00 m01 m02 m10 m11 m12 m20 m21 m22
* tx ty tz]. Applied directly against plain vertex triples below (no THREE.Matrix4 needed) --
* the mapping is derived from, and produces identical results to, bambuThreeMf.ts's
* `matrix.set(...)` + `v.applyMatrix4(matrix)`. */
function parseTransform3MF(str: string | null): number[] | null {
if (!str) return null;
const v = str.trim().split(/\s+/).map(Number);
return v.length >= 12 ? v : null;
}
function applyAffineToVertices(vertices: Float32Array, t: number[]): Float32Array {
const out = new Float32Array(vertices.length);
const [t0, t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11] = t;
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const y = vertices[i + 1];
const z = vertices[i + 2];
out[i] = t0 * x + t1 * y + t2 * z + t9;
out[i + 1] = t3 * x + t4 * y + t5 * z + t10;
out[i + 2] = t6 * x + t7 * y + t8 * z + t11;
}
return out;
}
// ---- Mesh extraction (the hot path) ------------------------------------------------------------
type FastMesh = { vertices: Float32Array; triangles: Uint32Array; extruder: number };
const MESH_RE = /<mesh\b[^>]*>([\s\S]*?)<\/mesh>/g;
const VERTEX_RE = /<vertex\s+x="([^"]*)"\s+y="([^"]*)"\s+z="([^"]*)"/g;
const TRIANGLE_RE = /<triangle\s+v1="(\d+)"\s+v2="(\d+)"\s+v3="(\d+)"/g;
/** Finds every <mesh> block in `xml` and extracts its vertices/triangles directly via regex --
* no DOM, no per-node attribute lookups. This is the piece that actually fixes the hang: a
* 2.66M-triangle file that would grind the DOM-based parser to a halt runs through here in a
* couple of seconds. */
function extractMeshesFast(xml: string, extruder: number): FastMesh[] {
const meshes: FastMesh[] = [];
MESH_RE.lastIndex = 0;
let meshMatch: RegExpExecArray | null;
while ((meshMatch = MESH_RE.exec(xml))) {
const inner = meshMatch[1];
const vertices: number[] = [];
VERTEX_RE.lastIndex = 0;
let vm: RegExpExecArray | null;
while ((vm = VERTEX_RE.exec(inner))) {
vertices.push(parseFloat(vm[1]), parseFloat(vm[2]), parseFloat(vm[3]));
}
const triangles: number[] = [];
TRIANGLE_RE.lastIndex = 0;
let tm: RegExpExecArray | null;
while ((tm = TRIANGLE_RE.exec(inner))) {
triangles.push(parseInt(tm[1], 10), parseInt(tm[2], 10), parseInt(tm[3], 10));
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices: Float32Array.from(vertices), triangles: Uint32Array.from(triangles), extruder });
}
}
return meshes;
}
// ---- Structural parsing (small documents -- cheerio/JSON are fine here) -----------------------
type ObjectData = { id: string; meshes: FastMesh[]; plateId: number | null };
type BuildItem = { objectId: string; transform: number[] | null; plateId: number | null };
export type PlateSummary = { index: number; name: string | null; objectCount: number };
type StructuralData = {
extruderMapById: Map<string, number>;
partExtruderMap: Map<string, number>;
objectNameById: Map<string, string>;
plateAssignmentsByObjectId: Map<string, number>;
plateNames: Map<number, string>;
plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
};
/** Mirrors bambuThreeMf.ts's model_settings.config handling -- small document (a few hundred
* nodes at most), so a real XML parser (cheerio, already a backend dependency) is fine here. */
function parseModelSettingsConfig(xml: string): StructuralData {
const data: StructuralData = {
extruderMapById: new Map(),
partExtruderMap: new Map(),
objectNameById: new Map(),
plateAssignmentsByObjectId: new Map(),
plateNames: new Map(),
plateOffsets: new Map(),
};
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(xml, { xml: true });
} catch {
return data;
}
$("object").each((_i, objEl) => {
const $obj = $(objEl);
const objectId = $obj.attr("id");
if (!objectId) return;
const extruderMeta = $obj.children('metadata[key="extruder"]').first();
const extruderVal = extruderMeta.attr("value");
if (extruderVal) data.extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
const nameVal = $obj.children('metadata[key="name"]').first().attr("value");
if (nameVal) data.objectNameById.set(objectId, nameVal);
$obj.find("part").each((_j, partEl) => {
const $part = $(partEl);
const partId = $part.attr("id");
if (!partId) return;
const partExtruderVal = $part.children('metadata[key="extruder"]').first().attr("value");
if (partExtruderVal) data.partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(partExtruderVal, 10) - 1));
});
});
$("plate").each((_i, plateEl) => {
const $plate = $(plateEl);
let plateId: number | null = null;
let offsetX = 0;
let offsetY = 0;
let plateName: string | undefined;
$plate.find("> metadata").each((_j, metaEl) => {
const $meta = $(metaEl);
const key = $meta.attr("key");
const value = $meta.attr("value");
if ((key === "plater_id" || key === "plate_id") && value) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) plateId = parsed;
} else if (key === "pos_x" && value) {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) offsetX = parsed;
} else if (key === "pos_y" && value) {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) offsetY = parsed;
} else if (key === "plater_name" && value?.trim()) {
plateName = value.trim();
}
});
if (plateId == null) return;
if (offsetX !== 0 || offsetY !== 0) data.plateOffsets.set(plateId, { offsetX, offsetY });
if (plateName) data.plateNames.set(plateId, plateName);
$plate.find("model_instance").each((_j, instEl) => {
const objectIdVal = $(instEl).children('metadata[key="object_id"]').first().attr("value");
if (objectIdVal) data.plateAssignmentsByObjectId.set(objectIdVal, plateId as number);
});
});
return data;
}
/** project_settings.config is already JSON (unlike the other Metadata/*.config files) -- direct
* port of bambuThreeMf.ts's parseProjectSettings, nothing DOM-related to replace here. */
function parseProjectSettingsJson(text: string): { filamentColors: string[]; buildVolume: { x: number; y: number } | null } {
try {
const json = JSON.parse(text) as Record<string, unknown>;
const filamentColors = Array.isArray(json.filament_colour)
? json.filament_colour.filter((c): c is string => typeof c === "string")
: [];
let buildVolume: { x: number; y: number } | null = null;
const area = json.printable_area;
if (Array.isArray(area) && area.length >= 3 && typeof area[2] === "string") {
const match = area[2].match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/);
if (match) buildVolume = { x: parseFloat(match[1]), y: parseFloat(match[2]) };
}
return { filamentColors, buildVolume };
} catch {
return { filamentColors: [], buildVolume: null };
}
}
// ---- Main model parsing (the hot path, orchestrated) -------------------------------------------
const OBJECT_RE = /<object\b([^>]*)>([\s\S]*?)<\/object>/g;
const COMPONENT_RE = /<component\b([^>]*)\/?>/g;
const ITEM_RE = /<item\b([^>]*)\/?>/g;
type InternalComponentRef = { refId: string; transform: number[] | null; extruder: number };
const MAX_COMPONENT_DEPTH = 8;
/** Counts <triangle .../> tags without the per-match array a `match(/.../g)` would allocate
* (millions of strings for a big model). "<triangles>" (the container) is skipped. */
function countTriangleTags(xml: string): number {
let count = 0;
let i = xml.indexOf("<triangle");
while (i !== -1) {
const next = xml.charCodeAt(i + 9);
// whitespace, "/" or ">" -- anything else is "<triangles" or some other tag
if (next === 32 || next === 9 || next === 10 || next === 13 || next === 47 || next === 62) count++;
i = xml.indexOf("<triangle", i + 9);
}
return count;
}
/** Triangles the preview would actually materialize: every build item's object, including
* geometry pulled in through p:path components (where Bambu files keep all their meshes) and
* same-document component references, counted once per placement -- the same expansion
* parseMainModel + buildGlbGroup perform, but on tag counts instead of real arrays. Checked
* before any mesh is parsed so an over-budget file never allocates its geometry. */
async function countRenderedTriangles(
xml: string,
loadExternalModel: (path: string) => Promise<string | null>,
): Promise<number> {
const ownCount = new Map<string, number>();
const internalRefs = new Map<string, string[]>();
const externalCount = new Map<string, number>();
OBJECT_RE.lastIndex = 0;
let om: RegExpExecArray | null;
while ((om = OBJECT_RE.exec(xml))) {
const objectId = getAttr(om[1], "id");
if (!objectId) continue;
const inner = om[2];
let count = countTriangleTags(inner);
const refs: string[] = [];
COMPONENT_RE.lastIndex = 0;
let cm: RegExpExecArray | null;
while ((cm = COMPONENT_RE.exec(inner))) {
const cAttrs = cm[1];
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
const compObjectId = getAttr(cAttrs, "objectid");
if (extPath) {
if (!externalCount.has(extPath)) {
const extXml = await loadExternalModel(extPath);
externalCount.set(extPath, extXml ? countTriangleTags(extXml) : 0);
}
count += externalCount.get(extPath)!;
} else if (compObjectId) {
refs.push(compObjectId);
}
}
ownCount.set(objectId, count);
if (refs.length > 0) internalRefs.set(objectId, refs);
}
// Memoized by (object, depth) so a pathological fan-out can't make the count itself explode.
const memo = new Map<string, number>();
const totalFor = (objectId: string, depth: number): number => {
const key = `${objectId}:${depth}`;
const cached = memo.get(key);
if (cached !== undefined) return cached;
let total = ownCount.get(objectId) ?? 0;
if (depth < MAX_COMPONENT_DEPTH) {
for (const ref of internalRefs.get(objectId) ?? []) total += totalFor(ref, depth + 1);
}
memo.set(key, total);
return total;
};
let rendered = 0;
const buildMatch = xml.match(/<build\b[\s\S]*?<\/build>/);
if (buildMatch) {
ITEM_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = ITEM_RE.exec(buildMatch[0]))) {
const objectId = getAttr(im[1], "objectid");
if (objectId) rendered += totalFor(objectId, 0);
}
}
return rendered;
}
async function parseMainModel(
xml: string,
structural: StructuralData,
plateAssignmentsByName: Map<string, number>,
loadExternalModel: (path: string) => Promise<string | null>,
): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
const objects = new Map<string, ObjectData>();
// <component objectid="X" .../> with no p:path references another <object> in this SAME
// document rather than an external file -- common wrapper pattern (an outer "part" object
// whose only content is a transformed reference to an inner object that holds the actual
// mesh). Order in the file isn't guaranteed, so these are resolved in a second pass once
// every object's own direct meshes are known, not inline during the main loop.
const internalRefsByObjectId = new Map<string, InternalComponentRef[]>();
OBJECT_RE.lastIndex = 0;
let om: RegExpExecArray | null;
while ((om = OBJECT_RE.exec(xml))) {
const attrs = om[1];
const inner = om[2];
const objectId = getAttr(attrs, "id");
if (!objectId) continue;
const objectPlateId = findPlateIdAttr(attrs) ?? structural.plateAssignmentsByObjectId.get(objectId) ?? null;
let defaultExtruder = structural.extruderMapById.get(objectId) ?? -1;
if (defaultExtruder < 0) {
const extruderAttr = getAttr(attrs, "p:extruder") ?? getAttr(attrs, "extruder") ?? "1";
defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
}
const meshes = extractMeshesFast(inner, defaultExtruder);
COMPONENT_RE.lastIndex = 0;
let cm: RegExpExecArray | null;
while ((cm = COMPONENT_RE.exec(inner))) {
const cAttrs = cm[1];
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
const compObjectId = getAttr(cAttrs, "objectid");
const transform = parseTransform3MF(getAttr(cAttrs, "transform"));
const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
const compExtruder = partKey ? structural.partExtruderMap.get(partKey) ?? defaultExtruder : defaultExtruder;
if (extPath) {
const extXml = await loadExternalModel(extPath);
if (!extXml) continue;
const extMeshes = extractMeshesFast(extXml, compExtruder);
for (const mesh of extMeshes) {
meshes.push({
vertices: transform ? applyAffineToVertices(mesh.vertices, transform) : mesh.vertices,
triangles: mesh.triangles,
extruder: compExtruder,
});
}
} else if (compObjectId) {
if (!internalRefsByObjectId.has(objectId)) internalRefsByObjectId.set(objectId, []);
internalRefsByObjectId.get(objectId)!.push({ refId: compObjectId, transform, extruder: compExtruder });
}
}
// Always keep the entry once an object has *something* (direct meshes or a pending internal
// reference) -- a pure wrapper object has zero direct meshes at this point but still needs
// an entry for the resolution pass below to attach the referenced geometry to.
if (meshes.length > 0 || internalRefsByObjectId.has(objectId)) {
objects.set(objectId, { id: objectId, meshes, plateId: objectPlateId });
}
}
// Resolve internal component references now that every object's own direct meshes are known.
// A bounded depth guard (rather than a visited-set) is enough protection against a malformed/
// cyclic file without needing per-object cycle bookkeeping -- real Bambu wrapper chains are
// one level deep.
const resolveInternalRefs = (objectId: string, depth: number): FastMesh[] => {
const refs = internalRefsByObjectId.get(objectId);
if (!refs || depth >= MAX_COMPONENT_DEPTH) return [];
const resolved: FastMesh[] = [];
for (const ref of refs) {
const target = objects.get(ref.refId);
if (!target) continue;
const targetMeshes = [...target.meshes, ...resolveInternalRefs(ref.refId, depth + 1)];
for (const mesh of targetMeshes) {
resolved.push({
vertices: ref.transform ? applyAffineToVertices(mesh.vertices, ref.transform) : mesh.vertices,
triangles: mesh.triangles,
extruder: ref.extruder,
});
}
}
return resolved;
};
// Resolve everything first, append after: appending as we go would put an inner object's
// already-resolved meshes into target.meshes, and an outer object referencing it would then
// pick them up twice (once from target.meshes, once from recursing) -- doubling per level.
const resolvedByObjectId = new Map<string, FastMesh[]>();
for (const objectId of internalRefsByObjectId.keys()) {
resolvedByObjectId.set(objectId, resolveInternalRefs(objectId, 0));
}
for (const [objectId, resolved] of resolvedByObjectId) {
const object = objects.get(objectId);
if (object) for (const mesh of resolved) object.meshes.push(mesh);
}
const buildItems: BuildItem[] = [];
const buildMatch = xml.match(/<build\b[\s\S]*?<\/build>/);
if (buildMatch) {
ITEM_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = ITEM_RE.exec(buildMatch[0]))) {
const attrs = im[1];
const objectId = getAttr(attrs, "objectid");
if (!objectId) continue;
const transform = parseTransform3MF(getAttr(attrs, "transform"));
const itemPlateId = findPlateIdAttr(attrs);
const objectPlateId = objects.get(objectId)?.plateId ?? null;
const objectName = structural.objectNameById.get(objectId);
const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
}
}
return { objects, buildItems };
}
// ---- Top-level orchestration --------------------------------------------------------------------
type ParsedModel = {
objects: Map<string, ObjectData>;
buildItems: BuildItem[];
plateNames: Map<number, string>;
plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
plateThumbnails: Map<number, string>;
filamentColors: string[];
buildVolume: { x: number; y: number };
};
const MAIN_MODEL_PATH = "3D/3dmodel.model";
// Rendered triangles, i.e. after expanding external/internal components and every build-item
// placement (see countRenderedTriangles) -- not just tags in the main model, which for a Bambu
// file (meshes live in 3D/Objects/*.model) is close to zero no matter how heavy the model is.
const MAX_TRIANGLES = 8_000_000;
// Model XML is read whole into strings; V8 can't hold a single string past ~512MB anyway, and the
// sum bounds how much the parse holds at once. Checked from the zip's central directory before
// anything is decompressed.
const MAX_MODEL_ENTRY_BYTES = 500 * 1024 * 1024;
const MAX_MODEL_TOTAL_BYTES = 1024 * 1024 * 1024;
/** Reads and fast-parses a Bambu Studio project .3mf. Returns null for anything not worth
* caching (no main model, no objects, or over the complexity cap) -- callers fall back to the
* existing live client-side parser exactly as they do today. */
async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
const modelEntries = (await listZipEntries(srcPath)).filter(
(entry) => !entry.isDirectory && entry.name.toLowerCase().endsWith(".model"),
);
if (!modelEntries.some((entry) => entry.name === MAIN_MODEL_PATH)) return null;
if (modelEntries.some((entry) => entry.size > MAX_MODEL_ENTRY_BYTES)) return null;
if (modelEntries.reduce((sum, entry) => sum + entry.size, 0) > MAX_MODEL_TOTAL_BYTES) return null;
let modelSettingsText: string | null = null;
let projectSettingsText: string | null = null;
let mainModelText: string | null = null;
const plateJsonEntries: { plateIndex: number; text: string }[] = [];
const plateThumbBytes = new Map<number, Buffer>();
await walkZipEntries(
srcPath,
(entry) => {
if (entry.name === "Metadata/model_settings.config") return true;
if (entry.name === "Metadata/project_settings.config") return true;
if (entry.name === MAIN_MODEL_PATH) return true;
if (/^Metadata\/plate_\d+\.json$/.test(entry.name)) return true;
if (/^Metadata\/(plate|top)_\d+\.png$/.test(entry.name)) return true;
return false;
},
async (entry, stream) => {
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Buffer>) chunks.push(chunk);
const buf = Buffer.concat(chunks);
if (entry.name === "Metadata/model_settings.config") modelSettingsText = buf.toString("utf-8");
else if (entry.name === "Metadata/project_settings.config") projectSettingsText = buf.toString("utf-8");
else if (entry.name === MAIN_MODEL_PATH) mainModelText = buf.toString("utf-8");
else {
const plateMatch = entry.name.match(/^Metadata\/plate_(\d+)\.json$/);
if (plateMatch) plateJsonEntries.push({ plateIndex: Number.parseInt(plateMatch[1], 10), text: buf.toString("utf-8") });
const thumbMatch = entry.name.match(/^Metadata\/(?:plate|top)_(\d+)\.png$/);
if (thumbMatch) {
const idx = Number.parseInt(thumbMatch[1], 10);
// "plate_N" (rendered on the bed) takes priority over "top_N" (plain top-down render);
// whichever is found first for a given index wins, matching a simple "don't overwrite".
if (!plateThumbBytes.has(idx)) plateThumbBytes.set(idx, buf);
}
}
},
);
if (!mainModelText) return null;
const mainModelXml: string = mainModelText;
const structural = modelSettingsText ? parseModelSettingsConfig(modelSettingsText) : parseModelSettingsConfig("");
const { filamentColors, buildVolume } = projectSettingsText
? parseProjectSettingsJson(projectSettingsText)
: { filamentColors: [], buildVolume: null };
// plate_*.json fallback for source-only (unsliced-by-MakerWorld) files: bbox_objects names,
// keyed by object *name* rather than id -- mirrors bambuThreeMf.ts's same fallback.
const plateAssignmentsByName = new Map<string, number>();
for (const { plateIndex, text } of plateJsonEntries) {
try {
const json = JSON.parse(text) as { bbox_objects?: { name?: string }[] };
for (const entry of json.bbox_objects ?? []) {
if (entry?.name) plateAssignmentsByName.set(entry.name, plateIndex);
}
} catch {
// Ignore malformed plate_N.json.
}
}
const externalModelCache = new Map<string, string | null>();
const loadExternalModel = async (rawPath: string): Promise<string | null> => {
const normalized = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
if (externalModelCache.has(normalized)) return externalModelCache.get(normalized) ?? null;
const buf = await readZipEntry(srcPath, normalized, MAX_MODEL_ENTRY_BYTES);
const text = buf ? buf.toString("utf-8") : null;
externalModelCache.set(normalized, text);
return text;
};
// Complexity check before any geometry is allocated, so an over-budget file fails after a few
// string scans instead of after exhausting the host's memory.
if ((await countRenderedTriangles(mainModelXml, loadExternalModel)) > MAX_TRIANGLES) return null;
const { objects, buildItems } = await parseMainModel(mainModelXml, structural, plateAssignmentsByName, loadExternalModel);
if (objects.size === 0) return null;
const plateThumbnails = new Map<number, string>();
for (const [idx, bytes] of plateThumbBytes) {
plateThumbnails.set(idx, `data:image/png;base64,${bytes.toString("base64")}`);
}
return {
objects,
buildItems,
plateNames: structural.plateNames,
plateOffsets: structural.plateOffsets,
plateThumbnails,
filamentColors,
buildVolume: buildVolume ?? { x: 256, y: 256 },
};
}
// ---- GLB construction ----------------------------------------------------------------------
/** Builds the same per-(plate, extruder) merged-mesh grouping bambuThreeMf.ts's
* buildBambuModelGroup computes on every render, just once, here. The resulting THREE.Group is
* exported straight to GLB -- the viewer loads the merged meshes directly with no client-side
* geometry work beyond toggling which plate's sub-group is visible. */
async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group> {
const THREE = await import("three");
const { mergeGeometries } = await import("three/examples/jsm/utils/BufferGeometryUtils.js");
const objectCountByPlate = new Map<number, number>();
for (const item of parsed.buildItems) {
if (item.plateId == null) continue;
objectCountByPlate.set(item.plateId, (objectCountByPlate.get(item.plateId) ?? 0) + 1);
}
const plateIndexes = Array.from(objectCountByPlate.keys()).toSorted((a, b) => a - b);
const hasPlateAssignments = plateIndexes.length > 0;
// No real plate assignments at all (e.g. a single-object 3MF with no Bambu plate metadata) --
// put everything under one synthetic "plate 0" bucket instead of dropping it.
const effectivePlateIndexes = hasPlateAssignments ? plateIndexes : [0];
const root = new THREE.Group();
root.name = "thingport-preview-root";
const plates: PlateSummary[] = [];
for (const plateIndex of effectivePlateIndexes) {
const itemsForPlate = hasPlateAssignments
? parsed.buildItems.filter((item) => item.plateId === plateIndex)
: parsed.buildItems;
const geometriesByExtruder = new Map<number, InstanceType<typeof THREE.BufferGeometry>[]>();
for (const item of itemsForPlate) {
const objectData = parsed.objects.get(item.objectId);
if (!objectData) continue;
for (const mesh of objectData.meshes) {
const positioned = item.transform ? applyAffineToVertices(mesh.vertices, item.transform) : mesh.vertices;
// 3MF Z-up -> three.js Y-up as a rotation, (x, y, z) -> (x, z, -y) -- kept identical to
// bambuThreeMf.ts's createGeometryFromMesh (see there for why it can't be a plain swap).
// Changing this changes every cached GLB: bump PREVIEW_FORMAT_VERSION.
const swapped = new Float32Array(positioned.length);
for (let i = 0; i < positioned.length; i += 3) {
swapped[i] = positioned[i];
swapped[i + 1] = positioned[i + 2];
swapped[i + 2] = -positioned[i + 1];
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(swapped, 3));
geometry.setIndex(new THREE.BufferAttribute(mesh.triangles, 1));
geometry.computeVertexNormals();
if (!geometriesByExtruder.has(mesh.extruder)) geometriesByExtruder.set(mesh.extruder, []);
geometriesByExtruder.get(mesh.extruder)!.push(geometry);
}
}
const plateGroup = new THREE.Group();
plateGroup.name = `plate-${plateIndex}`;
// Deliberately left visible=true (the default) for every plate here: GLTFExporter's default
// onlyVisible option drops invisible nodes from the export entirely, which would silently
// delete every plate but the first. Which plate is *shown* is a client-side concern (see
// ModelViewer/index.tsx), applied after loading -- not baked into the cached file.
let objectCount = 0;
const fallbackColor = new THREE.Color(0xdddddd);
for (const [extruder, geometries] of geometriesByExtruder) {
if (geometries.length === 0) continue;
// Cast: mergeGeometries' bundled .d.ts expects a narrower BufferGeometry generic than the
// one `await import("three")` infers here -- a type-only mismatch (both are the real
// three.js BufferGeometry class at runtime), not a real incompatibility.
const merged =
geometries.length === 1
? geometries[0]
: (mergeGeometries(geometries as never, false) as InstanceType<typeof THREE.BufferGeometry> | null);
if (merged) {
const colorStr = parsed.filamentColors[extruder];
const color = colorStr ? new THREE.Color(colorStr) : fallbackColor;
const material = new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.0 });
const mesh = new THREE.Mesh(merged as never, material);
mesh.name = `extruder-${extruder}`;
plateGroup.add(mesh);
objectCount++;
}
if (geometries.length > 1) geometries.forEach((g) => g.dispose());
}
root.add(plateGroup);
plates.push({ index: plateIndex, name: parsed.plateNames.get(plateIndex) ?? null, objectCount });
}
root.userData = {
thingportPreview: JSON.stringify({
plates,
plateThumbnails: Object.fromEntries(parsed.plateThumbnails),
filamentColors: parsed.filamentColors,
buildVolume: parsed.buildVolume,
}),
};
return root;
}
import { Worker } from "node:worker_threads";
import { MODEL_PREVIEWS, MODEL_PREVIEW_MAX_MEMORY_MB, MODEL_PREVIEW_TIMEOUT_SECONDS } from "../config";
import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelPreviewWorker";
// Owns the pre-rendered GLB cache for the interactive 3D preview: which plates have one, which
// failed, and running the render itself -- in a worker thread (modelPreviewWorker.ts ->
// modelPreviewRender.ts) under a memory and time limit, so a pathological .3mf costs at most that
// thread, never the server's event loop or the host's memory.
// ---- Cache path helpers (same shape as printService.ts's plateThumbPath/plateThumbExists) -----
@@ -721,23 +42,108 @@ export function modelPreviewGlbExists(plateId: string): boolean {
return fsSync.existsSync(modelPreviewGlbPath(plateId));
}
// ---- Failure bookkeeping ------------------------------------------------------------------------
const ERROR_RETRY_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
// Error-file prefix for failures that would just repeat (file refused as too complex, render
// killed for exceeding its limits): never retried automatically, unlike a transient error which
// gets another go after the cooldown. Delete the .error file to force a retry, e.g. after raising
// MODEL_PREVIEW_MAX_MEMORY_MB.
const PERMANENT_FAILURE_PREFIX = "permanent: ";
function recentlyFailed(plateId: string): boolean {
const errorPath = modelPreviewErrorPath(plateId);
try {
const stat = fsSync.statSync(modelPreviewErrorPath(plateId));
return Date.now() - stat.mtimeMs < ERROR_RETRY_COOLDOWN_MS;
const stat = fsSync.statSync(errorPath);
if (Date.now() - stat.mtimeMs < ERROR_RETRY_COOLDOWN_MS) return true;
return fsSync.readFileSync(errorPath, "utf-8").startsWith(PERMANENT_FAILURE_PREFIX);
} catch {
return false;
}
}
// ---- Worker supervision -------------------------------------------------------------------------
// From compiled output (dist/*.js) the worker is plain JS. From TypeScript source (the `tsx watch`
// dev server, vitest) it needs tsx's CommonJS hook registered inside the thread first -- workers
// don't inherit the parent's loader, and on Node 20 `--import tsx` in the worker's execArgv doesn't
// apply to its entry file either, hence an eval'd bootstrap that requires both explicitly.
const WORKER_FILE = path.join(__dirname, `modelPreviewWorker${path.extname(__filename)}`);
const WORKER_BOOTSTRAP = WORKER_FILE.endsWith(".ts")
? `require(${JSON.stringify(createRequire(__filename).resolve("tsx/cjs"))}); require(${JSON.stringify(WORKER_FILE)});`
: `require(${JSON.stringify(WORKER_FILE)});`;
const MEMORY_POLL_MS = 250;
type RenderOutcome =
| { status: "ok" }
| { status: "too-complex" }
| { status: "limit"; reason: string }
| { status: "error"; error: string };
/** Runs one render in a fresh worker thread and resolves once that thread is gone (so its memory
* is released before the next queued render starts). Never rejects. Limits:
* - heap: the worker's own V8 old-generation cap (resourceLimits) -- exceeding it terminates just
* the worker (ERR_WORKER_OUT_OF_MEMORY);
* - total memory: mesh data lives in typed arrays, outside any V8 heap cap, so a watchdog on this
* side also polls the process RSS and terminates the worker if it has grown past the budget
* since the render started;
* - time: terminated after MODEL_PREVIEW_TIMEOUT_SECONDS. */
function renderInWorker(input: ModelPreviewWorkerInput): Promise<RenderOutcome> {
return new Promise((resolve) => {
const maxGrowthBytes = MODEL_PREVIEW_MAX_MEMORY_MB * 1024 * 1024;
const baselineRss = process.memoryUsage.rss();
const worker = new Worker(WORKER_BOOTSTRAP, {
eval: true,
workerData: input,
resourceLimits: { maxOldGenerationSizeMb: MODEL_PREVIEW_MAX_MEMORY_MB },
});
let settled = false;
const finish = (outcome: RenderOutcome) => {
if (settled) return;
settled = true;
clearInterval(watchdog);
clearTimeout(timeout);
worker.terminate().then(
() => resolve(outcome),
() => resolve(outcome),
);
};
const watchdog = setInterval(() => {
const grown = process.memoryUsage.rss() - baselineRss;
if (grown > maxGrowthBytes) {
finish({
status: "limit",
reason: `memory grew by ${Math.round(grown / 1024 / 1024)} MB (limit ${MODEL_PREVIEW_MAX_MEMORY_MB} MB)`,
});
}
}, MEMORY_POLL_MS);
const timeout = setTimeout(
() => finish({ status: "limit", reason: `took longer than ${MODEL_PREVIEW_TIMEOUT_SECONDS}s` }),
MODEL_PREVIEW_TIMEOUT_SECONDS * 1000,
);
worker.once("message", (result: ModelPreviewWorkerResult) => finish(result));
worker.once("error", (err: NodeJS.ErrnoException) => {
finish(
err.code === "ERR_WORKER_OUT_OF_MEMORY"
? { status: "limit", reason: `worker heap exceeded ${MODEL_PREVIEW_MAX_MEMORY_MB} MB` }
: { status: "error", error: err.stack ?? String(err) },
);
});
worker.once("exit", (code) => finish({ status: "error", error: `worker exited (code ${code}) without a result` }));
});
}
// ---- Public entry point -------------------------------------------------------------------------
const inFlight = new Set<string>();
const crashWarned = new Set<string>();
// One generation at a time, process-wide: a multi-profile import creates several .3mf plates at
// once, and running their parses in parallel multiplies peak memory for no real gain (the work is
// CPU-bound on the same event loop either way).
// One render at a time, process-wide: a multi-profile import creates several .3mf plates at once,
// and rendering them in parallel would multiply peak memory past the per-render limit.
let queue: Promise<void> = Promise.resolve();
/** Generates (or refuses to, gracefully) the cached GLB for one plate. Always resolves --
@@ -763,27 +169,35 @@ export async function generateModelPreviewGlb(plateId: string, srcPath: string):
async function runGeneration(plateId: string, srcPath: string): Promise<void> {
const pendingPath = modelPreviewPendingPath(plateId);
const errorPath = modelPreviewErrorPath(plateId);
const dest = modelPreviewGlbPath(plateId);
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(pendingPath, new Date().toISOString());
const parsed = await parseThreeMfFast(srcPath);
if (!parsed) {
await fs.writeFile(modelPreviewErrorPath(plateId), "unparseable-or-too-complex");
return;
const outcome = await renderInWorker({ srcPath, destPath: tmp });
switch (outcome.status) {
case "ok":
await fs.rename(tmp, dest);
await fs.rm(errorPath, { force: true });
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
break;
case "too-complex":
await fs.writeFile(errorPath, `${PERMANENT_FAILURE_PREFIX}unparseable-or-too-complex`);
break;
case "limit":
console.warn(`Model preview for plate ${plateId} stopped: ${outcome.reason}`);
await fs.writeFile(errorPath, `${PERMANENT_FAILURE_PREFIX}${outcome.reason}`);
break;
case "error":
console.error(`Model preview generation failed for plate ${plateId}:`, outcome.error);
await fs.writeFile(errorPath, outcome.error);
break;
}
const group = await buildGlbGroup(parsed);
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
const result = await new GLTFExporter().parseAsync(group, { binary: true });
const buf = Buffer.from(result as ArrayBuffer);
const dest = modelPreviewGlbPath(plateId);
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, buf);
await fs.rename(tmp, dest);
await fs.rm(modelPreviewErrorPath(plateId), { force: true });
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
} catch (err) {
console.error(`Model preview generation failed for plate ${plateId}:`, err);
await fs.writeFile(modelPreviewErrorPath(plateId), String(err)).catch(() => undefined);
await fs.writeFile(errorPath, String(err)).catch(() => undefined);
} finally {
await fs.rm(tmp, { force: true }).catch(() => undefined);
await fs.rm(pendingPath, { force: true }).catch(() => undefined);
inFlight.delete(plateId);
}
+706
View File
@@ -0,0 +1,706 @@
import fs from "node:fs/promises";
import * as cheerio from "cheerio";
import { listZipEntries, readZipEntry, walkZipEntries } from "../utils/zipReader";
// Generates the cached GLB the interactive 3D preview loads instead of re-parsing a raw .3mf
// on every open (see frontend/src/utils/bambuThreeMf.ts, whose *rules* this file mirrors -- same
// extruder-resolution priority, same plate-assignment fallbacks, same component p:path handling
// -- but not its parsing mechanism. That file walks the DOM node-by-node; a Bambu-sliced 3MF for
// a genuinely large model can have millions of <vertex>/<triangle> elements, and DOM attribute
// access at that scale is what causes Thingport's "3D Preview" to hang indefinitely (root-caused
// against a real 224MB/2.66M-triangle repro). This file never builds a DOM for the bulk mesh data
// -- it scans the raw XML text with a handful of fixed-shape regexes straight into typed arrays,
// which is the actual fix. It only runs once per plate (in the background, off the request path)
// rather than on every viewer open.
//
// Everything here runs inside a worker thread (modelPreviewWorker.ts), never on the server's own
// event loop -- modelPreviewCache.ts owns the cache files and puts memory/time limits on that
// thread. Keep this module free of config/db imports so the worker stays lightweight.
// ---- FileReader polyfill --------------------------------------------------------------------
//
// Node has no FileReader; three.js's GLTFExporter (examples/jsm/exporters/GLTFExporter.js) needs
// one for its binary (GLB) export path even though nothing here uses images/textures. Confirmed
// working against a real export+reload round-trip (including custom userData surviving through
// glTF `extras`) before this file was written. Node's built-in Blob already provides
// `.arrayBuffer()`, so the polyfill only needs to bridge that to the old onload/onloadend
// callback shape the exporter expects.
class NodeFileReader {
onload?: (e: { target: NodeFileReader }) => void;
onloadend?: (e: { target: NodeFileReader }) => void;
onerror?: (e: { target: NodeFileReader }) => void;
result: ArrayBuffer | string | null = null;
error: unknown = null;
readAsArrayBuffer(blob: Blob): void {
blob
.arrayBuffer()
.then((buf) => {
this.result = buf;
this.onload?.({ target: this });
this.onloadend?.({ target: this });
})
.catch((err) => {
this.error = err;
this.onerror?.({ target: this });
this.onloadend?.({ target: this });
});
}
readAsDataURL(blob: Blob): void {
blob
.arrayBuffer()
.then((buf) => {
this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(buf).toString("base64")}`;
this.onload?.({ target: this });
this.onloadend?.({ target: this });
})
.catch((err) => {
this.error = err;
this.onerror?.({ target: this });
this.onloadend?.({ target: this });
});
}
}
if (typeof (globalThis as { FileReader?: unknown }).FileReader === "undefined") {
(globalThis as unknown as { FileReader: unknown }).FileReader = NodeFileReader;
}
// ---- Small XML helpers (operate on a tag's raw attribute string, not a DOM node) --------------
function getAttr(attrs: string, name: string): string | null {
const m = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`));
return m ? m[1] : null;
}
/** Mirrors bambuThreeMf.ts's parsePlateIdFromAttributes: accepts plate_id/plater_id (any casing,
* optionally namespace-prefixed) since different Bambu Studio versions have used both spellings. */
function findPlateIdAttr(attrs: string): number | null {
const m = attrs.match(/(?:^|\s)(?:[\w-]+:)?(?:plate_id|plater_id|plateid|platerid)="([^"]*)"/i);
if (!m) return null;
const parsed = Number.parseInt(m[1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
/** 3MF's 3x4 affine transform, in the string's own order: [m00 m01 m02 m10 m11 m12 m20 m21 m22
* tx ty tz]. Applied directly against plain vertex triples below (no THREE.Matrix4 needed) --
* the mapping is derived from, and produces identical results to, bambuThreeMf.ts's
* `matrix.set(...)` + `v.applyMatrix4(matrix)`. */
function parseTransform3MF(str: string | null): number[] | null {
if (!str) return null;
const v = str.trim().split(/\s+/).map(Number);
return v.length >= 12 ? v : null;
}
function applyAffineToVertices(vertices: Float32Array, t: number[]): Float32Array {
const out = new Float32Array(vertices.length);
const [t0, t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11] = t;
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const y = vertices[i + 1];
const z = vertices[i + 2];
out[i] = t0 * x + t1 * y + t2 * z + t9;
out[i + 1] = t3 * x + t4 * y + t5 * z + t10;
out[i + 2] = t6 * x + t7 * y + t8 * z + t11;
}
return out;
}
// ---- Mesh extraction (the hot path) ------------------------------------------------------------
type FastMesh = { vertices: Float32Array; triangles: Uint32Array; extruder: number };
const MESH_RE = /<mesh\b[^>]*>([\s\S]*?)<\/mesh>/g;
const VERTEX_RE = /<vertex\s+x="([^"]*)"\s+y="([^"]*)"\s+z="([^"]*)"/g;
const TRIANGLE_RE = /<triangle\s+v1="(\d+)"\s+v2="(\d+)"\s+v3="(\d+)"/g;
/** Finds every <mesh> block in `xml` and extracts its vertices/triangles directly via regex --
* no DOM, no per-node attribute lookups. This is the piece that actually fixes the hang: a
* 2.66M-triangle file that would grind the DOM-based parser to a halt runs through here in a
* couple of seconds. */
function extractMeshesFast(xml: string, extruder: number): FastMesh[] {
const meshes: FastMesh[] = [];
MESH_RE.lastIndex = 0;
let meshMatch: RegExpExecArray | null;
while ((meshMatch = MESH_RE.exec(xml))) {
const inner = meshMatch[1];
const vertices: number[] = [];
VERTEX_RE.lastIndex = 0;
let vm: RegExpExecArray | null;
while ((vm = VERTEX_RE.exec(inner))) {
vertices.push(parseFloat(vm[1]), parseFloat(vm[2]), parseFloat(vm[3]));
}
const triangles: number[] = [];
TRIANGLE_RE.lastIndex = 0;
let tm: RegExpExecArray | null;
while ((tm = TRIANGLE_RE.exec(inner))) {
triangles.push(parseInt(tm[1], 10), parseInt(tm[2], 10), parseInt(tm[3], 10));
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices: Float32Array.from(vertices), triangles: Uint32Array.from(triangles), extruder });
}
}
return meshes;
}
// ---- Structural parsing (small documents -- cheerio/JSON are fine here) -----------------------
type ObjectData = { id: string; meshes: FastMesh[]; plateId: number | null };
type BuildItem = { objectId: string; transform: number[] | null; plateId: number | null };
export type PlateSummary = { index: number; name: string | null; objectCount: number };
type StructuralData = {
extruderMapById: Map<string, number>;
partExtruderMap: Map<string, number>;
objectNameById: Map<string, string>;
plateAssignmentsByObjectId: Map<string, number>;
plateNames: Map<number, string>;
plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
};
/** Mirrors bambuThreeMf.ts's model_settings.config handling -- small document (a few hundred
* nodes at most), so a real XML parser (cheerio, already a backend dependency) is fine here. */
function parseModelSettingsConfig(xml: string): StructuralData {
const data: StructuralData = {
extruderMapById: new Map(),
partExtruderMap: new Map(),
objectNameById: new Map(),
plateAssignmentsByObjectId: new Map(),
plateNames: new Map(),
plateOffsets: new Map(),
};
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(xml, { xml: true });
} catch {
return data;
}
$("object").each((_i, objEl) => {
const $obj = $(objEl);
const objectId = $obj.attr("id");
if (!objectId) return;
const extruderMeta = $obj.children('metadata[key="extruder"]').first();
const extruderVal = extruderMeta.attr("value");
if (extruderVal) data.extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
const nameVal = $obj.children('metadata[key="name"]').first().attr("value");
if (nameVal) data.objectNameById.set(objectId, nameVal);
$obj.find("part").each((_j, partEl) => {
const $part = $(partEl);
const partId = $part.attr("id");
if (!partId) return;
const partExtruderVal = $part.children('metadata[key="extruder"]').first().attr("value");
if (partExtruderVal) data.partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(partExtruderVal, 10) - 1));
});
});
$("plate").each((_i, plateEl) => {
const $plate = $(plateEl);
let plateId: number | null = null;
let offsetX = 0;
let offsetY = 0;
let plateName: string | undefined;
$plate.find("> metadata").each((_j, metaEl) => {
const $meta = $(metaEl);
const key = $meta.attr("key");
const value = $meta.attr("value");
if ((key === "plater_id" || key === "plate_id") && value) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) plateId = parsed;
} else if (key === "pos_x" && value) {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) offsetX = parsed;
} else if (key === "pos_y" && value) {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) offsetY = parsed;
} else if (key === "plater_name" && value?.trim()) {
plateName = value.trim();
}
});
if (plateId == null) return;
if (offsetX !== 0 || offsetY !== 0) data.plateOffsets.set(plateId, { offsetX, offsetY });
if (plateName) data.plateNames.set(plateId, plateName);
$plate.find("model_instance").each((_j, instEl) => {
const objectIdVal = $(instEl).children('metadata[key="object_id"]').first().attr("value");
if (objectIdVal) data.plateAssignmentsByObjectId.set(objectIdVal, plateId as number);
});
});
return data;
}
/** project_settings.config is already JSON (unlike the other Metadata/*.config files) -- direct
* port of bambuThreeMf.ts's parseProjectSettings, nothing DOM-related to replace here. */
function parseProjectSettingsJson(text: string): { filamentColors: string[]; buildVolume: { x: number; y: number } | null } {
try {
const json = JSON.parse(text) as Record<string, unknown>;
const filamentColors = Array.isArray(json.filament_colour)
? json.filament_colour.filter((c): c is string => typeof c === "string")
: [];
let buildVolume: { x: number; y: number } | null = null;
const area = json.printable_area;
if (Array.isArray(area) && area.length >= 3 && typeof area[2] === "string") {
const match = area[2].match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/);
if (match) buildVolume = { x: parseFloat(match[1]), y: parseFloat(match[2]) };
}
return { filamentColors, buildVolume };
} catch {
return { filamentColors: [], buildVolume: null };
}
}
// ---- Main model parsing (the hot path, orchestrated) -------------------------------------------
const OBJECT_RE = /<object\b([^>]*)>([\s\S]*?)<\/object>/g;
const COMPONENT_RE = /<component\b([^>]*)\/?>/g;
const ITEM_RE = /<item\b([^>]*)\/?>/g;
type InternalComponentRef = { refId: string; transform: number[] | null; extruder: number };
const MAX_COMPONENT_DEPTH = 8;
/** Counts <triangle .../> tags without the per-match array a `match(/.../g)` would allocate
* (millions of strings for a big model). "<triangles>" (the container) is skipped. */
function countTriangleTags(xml: string): number {
let count = 0;
let i = xml.indexOf("<triangle");
while (i !== -1) {
const next = xml.charCodeAt(i + 9);
// whitespace, "/" or ">" -- anything else is "<triangles" or some other tag
if (next === 32 || next === 9 || next === 10 || next === 13 || next === 47 || next === 62) count++;
i = xml.indexOf("<triangle", i + 9);
}
return count;
}
/** Triangles the preview would actually materialize: every build item's object, including
* geometry pulled in through p:path components (where Bambu files keep all their meshes) and
* same-document component references, counted once per placement -- the same expansion
* parseMainModel + buildGlbGroup perform, but on tag counts instead of real arrays. Checked
* before any mesh is parsed so an over-budget file never allocates its geometry. */
async function countRenderedTriangles(
xml: string,
loadExternalModel: (path: string) => Promise<string | null>,
): Promise<number> {
const ownCount = new Map<string, number>();
const internalRefs = new Map<string, string[]>();
const externalCount = new Map<string, number>();
OBJECT_RE.lastIndex = 0;
let om: RegExpExecArray | null;
while ((om = OBJECT_RE.exec(xml))) {
const objectId = getAttr(om[1], "id");
if (!objectId) continue;
const inner = om[2];
let count = countTriangleTags(inner);
const refs: string[] = [];
COMPONENT_RE.lastIndex = 0;
let cm: RegExpExecArray | null;
while ((cm = COMPONENT_RE.exec(inner))) {
const cAttrs = cm[1];
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
const compObjectId = getAttr(cAttrs, "objectid");
if (extPath) {
if (!externalCount.has(extPath)) {
const extXml = await loadExternalModel(extPath);
externalCount.set(extPath, extXml ? countTriangleTags(extXml) : 0);
}
count += externalCount.get(extPath)!;
} else if (compObjectId) {
refs.push(compObjectId);
}
}
ownCount.set(objectId, count);
if (refs.length > 0) internalRefs.set(objectId, refs);
}
// Memoized by (object, depth) so a pathological fan-out can't make the count itself explode.
const memo = new Map<string, number>();
const totalFor = (objectId: string, depth: number): number => {
const key = `${objectId}:${depth}`;
const cached = memo.get(key);
if (cached !== undefined) return cached;
let total = ownCount.get(objectId) ?? 0;
if (depth < MAX_COMPONENT_DEPTH) {
for (const ref of internalRefs.get(objectId) ?? []) total += totalFor(ref, depth + 1);
}
memo.set(key, total);
return total;
};
let rendered = 0;
const buildMatch = xml.match(/<build\b[\s\S]*?<\/build>/);
if (buildMatch) {
ITEM_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = ITEM_RE.exec(buildMatch[0]))) {
const objectId = getAttr(im[1], "objectid");
if (objectId) rendered += totalFor(objectId, 0);
}
}
return rendered;
}
async function parseMainModel(
xml: string,
structural: StructuralData,
plateAssignmentsByName: Map<string, number>,
loadExternalModel: (path: string) => Promise<string | null>,
): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
const objects = new Map<string, ObjectData>();
// <component objectid="X" .../> with no p:path references another <object> in this SAME
// document rather than an external file -- common wrapper pattern (an outer "part" object
// whose only content is a transformed reference to an inner object that holds the actual
// mesh). Order in the file isn't guaranteed, so these are resolved in a second pass once
// every object's own direct meshes are known, not inline during the main loop.
const internalRefsByObjectId = new Map<string, InternalComponentRef[]>();
OBJECT_RE.lastIndex = 0;
let om: RegExpExecArray | null;
while ((om = OBJECT_RE.exec(xml))) {
const attrs = om[1];
const inner = om[2];
const objectId = getAttr(attrs, "id");
if (!objectId) continue;
const objectPlateId = findPlateIdAttr(attrs) ?? structural.plateAssignmentsByObjectId.get(objectId) ?? null;
let defaultExtruder = structural.extruderMapById.get(objectId) ?? -1;
if (defaultExtruder < 0) {
const extruderAttr = getAttr(attrs, "p:extruder") ?? getAttr(attrs, "extruder") ?? "1";
defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
}
const meshes = extractMeshesFast(inner, defaultExtruder);
COMPONENT_RE.lastIndex = 0;
let cm: RegExpExecArray | null;
while ((cm = COMPONENT_RE.exec(inner))) {
const cAttrs = cm[1];
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
const compObjectId = getAttr(cAttrs, "objectid");
const transform = parseTransform3MF(getAttr(cAttrs, "transform"));
const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
const compExtruder = partKey ? structural.partExtruderMap.get(partKey) ?? defaultExtruder : defaultExtruder;
if (extPath) {
const extXml = await loadExternalModel(extPath);
if (!extXml) continue;
const extMeshes = extractMeshesFast(extXml, compExtruder);
for (const mesh of extMeshes) {
meshes.push({
vertices: transform ? applyAffineToVertices(mesh.vertices, transform) : mesh.vertices,
triangles: mesh.triangles,
extruder: compExtruder,
});
}
} else if (compObjectId) {
if (!internalRefsByObjectId.has(objectId)) internalRefsByObjectId.set(objectId, []);
internalRefsByObjectId.get(objectId)!.push({ refId: compObjectId, transform, extruder: compExtruder });
}
}
// Always keep the entry once an object has *something* (direct meshes or a pending internal
// reference) -- a pure wrapper object has zero direct meshes at this point but still needs
// an entry for the resolution pass below to attach the referenced geometry to.
if (meshes.length > 0 || internalRefsByObjectId.has(objectId)) {
objects.set(objectId, { id: objectId, meshes, plateId: objectPlateId });
}
}
// Resolve internal component references now that every object's own direct meshes are known.
// A bounded depth guard (rather than a visited-set) is enough protection against a malformed/
// cyclic file without needing per-object cycle bookkeeping -- real Bambu wrapper chains are
// one level deep.
const resolveInternalRefs = (objectId: string, depth: number): FastMesh[] => {
const refs = internalRefsByObjectId.get(objectId);
if (!refs || depth >= MAX_COMPONENT_DEPTH) return [];
const resolved: FastMesh[] = [];
for (const ref of refs) {
const target = objects.get(ref.refId);
if (!target) continue;
const targetMeshes = [...target.meshes, ...resolveInternalRefs(ref.refId, depth + 1)];
for (const mesh of targetMeshes) {
resolved.push({
vertices: ref.transform ? applyAffineToVertices(mesh.vertices, ref.transform) : mesh.vertices,
triangles: mesh.triangles,
extruder: ref.extruder,
});
}
}
return resolved;
};
// Resolve everything first, append after: appending as we go would put an inner object's
// already-resolved meshes into target.meshes, and an outer object referencing it would then
// pick them up twice (once from target.meshes, once from recursing) -- doubling per level.
const resolvedByObjectId = new Map<string, FastMesh[]>();
for (const objectId of internalRefsByObjectId.keys()) {
resolvedByObjectId.set(objectId, resolveInternalRefs(objectId, 0));
}
for (const [objectId, resolved] of resolvedByObjectId) {
const object = objects.get(objectId);
if (object) for (const mesh of resolved) object.meshes.push(mesh);
}
const buildItems: BuildItem[] = [];
const buildMatch = xml.match(/<build\b[\s\S]*?<\/build>/);
if (buildMatch) {
ITEM_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = ITEM_RE.exec(buildMatch[0]))) {
const attrs = im[1];
const objectId = getAttr(attrs, "objectid");
if (!objectId) continue;
const transform = parseTransform3MF(getAttr(attrs, "transform"));
const itemPlateId = findPlateIdAttr(attrs);
const objectPlateId = objects.get(objectId)?.plateId ?? null;
const objectName = structural.objectNameById.get(objectId);
const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
}
}
return { objects, buildItems };
}
// ---- Top-level orchestration --------------------------------------------------------------------
type ParsedModel = {
objects: Map<string, ObjectData>;
buildItems: BuildItem[];
plateNames: Map<number, string>;
plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
plateThumbnails: Map<number, string>;
filamentColors: string[];
buildVolume: { x: number; y: number };
};
const MAIN_MODEL_PATH = "3D/3dmodel.model";
// Rendered triangles, i.e. after expanding external/internal components and every build-item
// placement (see countRenderedTriangles) -- not just tags in the main model, which for a Bambu
// file (meshes live in 3D/Objects/*.model) is close to zero no matter how heavy the model is.
const MAX_TRIANGLES = 8_000_000;
// Model XML is read whole into strings; V8 can't hold a single string past ~512MB anyway, and the
// sum bounds how much the parse holds at once. Checked from the zip's central directory before
// anything is decompressed.
const MAX_MODEL_ENTRY_BYTES = 500 * 1024 * 1024;
const MAX_MODEL_TOTAL_BYTES = 1024 * 1024 * 1024;
/** Reads and fast-parses a Bambu Studio project .3mf. Returns null for anything not worth
* caching (no main model, no objects, or over the complexity cap) -- callers fall back to the
* existing live client-side parser exactly as they do today. */
async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
const modelEntries = (await listZipEntries(srcPath)).filter(
(entry) => !entry.isDirectory && entry.name.toLowerCase().endsWith(".model"),
);
if (!modelEntries.some((entry) => entry.name === MAIN_MODEL_PATH)) return null;
if (modelEntries.some((entry) => entry.size > MAX_MODEL_ENTRY_BYTES)) return null;
if (modelEntries.reduce((sum, entry) => sum + entry.size, 0) > MAX_MODEL_TOTAL_BYTES) return null;
let modelSettingsText: string | null = null;
let projectSettingsText: string | null = null;
let mainModelText: string | null = null;
const plateJsonEntries: { plateIndex: number; text: string }[] = [];
const plateThumbBytes = new Map<number, Buffer>();
await walkZipEntries(
srcPath,
(entry) => {
if (entry.name === "Metadata/model_settings.config") return true;
if (entry.name === "Metadata/project_settings.config") return true;
if (entry.name === MAIN_MODEL_PATH) return true;
if (/^Metadata\/plate_\d+\.json$/.test(entry.name)) return true;
if (/^Metadata\/(plate|top)_\d+\.png$/.test(entry.name)) return true;
return false;
},
async (entry, stream) => {
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Buffer>) chunks.push(chunk);
const buf = Buffer.concat(chunks);
if (entry.name === "Metadata/model_settings.config") modelSettingsText = buf.toString("utf-8");
else if (entry.name === "Metadata/project_settings.config") projectSettingsText = buf.toString("utf-8");
else if (entry.name === MAIN_MODEL_PATH) mainModelText = buf.toString("utf-8");
else {
const plateMatch = entry.name.match(/^Metadata\/plate_(\d+)\.json$/);
if (plateMatch) plateJsonEntries.push({ plateIndex: Number.parseInt(plateMatch[1], 10), text: buf.toString("utf-8") });
const thumbMatch = entry.name.match(/^Metadata\/(?:plate|top)_(\d+)\.png$/);
if (thumbMatch) {
const idx = Number.parseInt(thumbMatch[1], 10);
// "plate_N" (rendered on the bed) takes priority over "top_N" (plain top-down render);
// whichever is found first for a given index wins, matching a simple "don't overwrite".
if (!plateThumbBytes.has(idx)) plateThumbBytes.set(idx, buf);
}
}
},
);
if (!mainModelText) return null;
const mainModelXml: string = mainModelText;
const structural = modelSettingsText ? parseModelSettingsConfig(modelSettingsText) : parseModelSettingsConfig("");
const { filamentColors, buildVolume } = projectSettingsText
? parseProjectSettingsJson(projectSettingsText)
: { filamentColors: [], buildVolume: null };
// plate_*.json fallback for source-only (unsliced-by-MakerWorld) files: bbox_objects names,
// keyed by object *name* rather than id -- mirrors bambuThreeMf.ts's same fallback.
const plateAssignmentsByName = new Map<string, number>();
for (const { plateIndex, text } of plateJsonEntries) {
try {
const json = JSON.parse(text) as { bbox_objects?: { name?: string }[] };
for (const entry of json.bbox_objects ?? []) {
if (entry?.name) plateAssignmentsByName.set(entry.name, plateIndex);
}
} catch {
// Ignore malformed plate_N.json.
}
}
const externalModelCache = new Map<string, string | null>();
const loadExternalModel = async (rawPath: string): Promise<string | null> => {
const normalized = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
if (externalModelCache.has(normalized)) return externalModelCache.get(normalized) ?? null;
const buf = await readZipEntry(srcPath, normalized, MAX_MODEL_ENTRY_BYTES);
const text = buf ? buf.toString("utf-8") : null;
externalModelCache.set(normalized, text);
return text;
};
// Complexity check before any geometry is allocated, so an over-budget file fails after a few
// string scans instead of after exhausting the host's memory.
if ((await countRenderedTriangles(mainModelXml, loadExternalModel)) > MAX_TRIANGLES) return null;
const { objects, buildItems } = await parseMainModel(mainModelXml, structural, plateAssignmentsByName, loadExternalModel);
if (objects.size === 0) return null;
const plateThumbnails = new Map<number, string>();
for (const [idx, bytes] of plateThumbBytes) {
plateThumbnails.set(idx, `data:image/png;base64,${bytes.toString("base64")}`);
}
return {
objects,
buildItems,
plateNames: structural.plateNames,
plateOffsets: structural.plateOffsets,
plateThumbnails,
filamentColors,
buildVolume: buildVolume ?? { x: 256, y: 256 },
};
}
// ---- GLB construction ----------------------------------------------------------------------
/** Builds the same per-(plate, extruder) merged-mesh grouping bambuThreeMf.ts's
* buildBambuModelGroup computes on every render, just once, here. The resulting THREE.Group is
* exported straight to GLB -- the viewer loads the merged meshes directly with no client-side
* geometry work beyond toggling which plate's sub-group is visible. */
async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group> {
const THREE = await import("three");
const { mergeGeometries } = await import("three/examples/jsm/utils/BufferGeometryUtils.js");
const objectCountByPlate = new Map<number, number>();
for (const item of parsed.buildItems) {
if (item.plateId == null) continue;
objectCountByPlate.set(item.plateId, (objectCountByPlate.get(item.plateId) ?? 0) + 1);
}
const plateIndexes = Array.from(objectCountByPlate.keys()).toSorted((a, b) => a - b);
const hasPlateAssignments = plateIndexes.length > 0;
// No real plate assignments at all (e.g. a single-object 3MF with no Bambu plate metadata) --
// put everything under one synthetic "plate 0" bucket instead of dropping it.
const effectivePlateIndexes = hasPlateAssignments ? plateIndexes : [0];
const root = new THREE.Group();
root.name = "thingport-preview-root";
const plates: PlateSummary[] = [];
for (const plateIndex of effectivePlateIndexes) {
const itemsForPlate = hasPlateAssignments
? parsed.buildItems.filter((item) => item.plateId === plateIndex)
: parsed.buildItems;
const geometriesByExtruder = new Map<number, InstanceType<typeof THREE.BufferGeometry>[]>();
for (const item of itemsForPlate) {
const objectData = parsed.objects.get(item.objectId);
if (!objectData) continue;
for (const mesh of objectData.meshes) {
const positioned = item.transform ? applyAffineToVertices(mesh.vertices, item.transform) : mesh.vertices;
// 3MF Z-up -> three.js Y-up as a rotation, (x, y, z) -> (x, z, -y) -- kept identical to
// bambuThreeMf.ts's createGeometryFromMesh (see there for why it can't be a plain swap).
// Changing this changes every cached GLB: bump PREVIEW_FORMAT_VERSION.
const swapped = new Float32Array(positioned.length);
for (let i = 0; i < positioned.length; i += 3) {
swapped[i] = positioned[i];
swapped[i + 1] = positioned[i + 2];
swapped[i + 2] = -positioned[i + 1];
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(swapped, 3));
geometry.setIndex(new THREE.BufferAttribute(mesh.triangles, 1));
geometry.computeVertexNormals();
if (!geometriesByExtruder.has(mesh.extruder)) geometriesByExtruder.set(mesh.extruder, []);
geometriesByExtruder.get(mesh.extruder)!.push(geometry);
}
}
const plateGroup = new THREE.Group();
plateGroup.name = `plate-${plateIndex}`;
// Deliberately left visible=true (the default) for every plate here: GLTFExporter's default
// onlyVisible option drops invisible nodes from the export entirely, which would silently
// delete every plate but the first. Which plate is *shown* is a client-side concern (see
// ModelViewer/index.tsx), applied after loading -- not baked into the cached file.
let objectCount = 0;
const fallbackColor = new THREE.Color(0xdddddd);
for (const [extruder, geometries] of geometriesByExtruder) {
if (geometries.length === 0) continue;
// Cast: mergeGeometries' bundled .d.ts expects a narrower BufferGeometry generic than the
// one `await import("three")` infers here -- a type-only mismatch (both are the real
// three.js BufferGeometry class at runtime), not a real incompatibility.
const merged =
geometries.length === 1
? geometries[0]
: (mergeGeometries(geometries as never, false) as InstanceType<typeof THREE.BufferGeometry> | null);
if (merged) {
const colorStr = parsed.filamentColors[extruder];
const color = colorStr ? new THREE.Color(colorStr) : fallbackColor;
const material = new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.0 });
const mesh = new THREE.Mesh(merged as never, material);
mesh.name = `extruder-${extruder}`;
plateGroup.add(mesh);
objectCount++;
}
if (geometries.length > 1) geometries.forEach((g) => g.dispose());
}
root.add(plateGroup);
plates.push({ index: plateIndex, name: parsed.plateNames.get(plateIndex) ?? null, objectCount });
}
root.userData = {
thingportPreview: JSON.stringify({
plates,
plateThumbnails: Object.fromEntries(parsed.plateThumbnails),
filamentColors: parsed.filamentColors,
buildVolume: parsed.buildVolume,
}),
};
return root;
}
// ---- Entry point (called from the worker) ----------------------------------------------------
/** Parses `srcPath` and writes the preview GLB to `destPath`. "too-complex" means the file was
* refused before any geometry was built (no main model, no objects, or over a size budget). */
export async function renderModelPreviewGlb(srcPath: string, destPath: string): Promise<"ok" | "too-complex"> {
const parsed = await parseThreeMfFast(srcPath);
if (!parsed) return "too-complex";
const group = await buildGlbGroup(parsed);
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
const result = await new GLTFExporter().parseAsync(group, { binary: true });
await fs.writeFile(destPath, Buffer.from(result as ArrayBuffer));
return "ok";
}
@@ -0,0 +1,25 @@
import { parentPort, workerData } from "node:worker_threads";
import { renderModelPreviewGlb } from "./modelPreviewRender";
// Worker-thread entry point for one preview render (spawned per job by modelPreviewCache.ts, which
// enforces the memory/time limits). Reports exactly one message, then lets the thread exit.
export type ModelPreviewWorkerInput = { srcPath: string; destPath: string };
export type ModelPreviewWorkerResult =
| { status: "ok" }
| { status: "too-complex" }
| { status: "error"; error: string };
async function run(): Promise<void> {
const { srcPath, destPath } = workerData as ModelPreviewWorkerInput;
let result: ModelPreviewWorkerResult;
try {
result = { status: await renderModelPreviewGlb(srcPath, destPath) };
} catch (err) {
result = { status: "error", error: err instanceof Error ? err.stack ?? err.message : String(err) };
}
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- a worker_threads port, not window.postMessage.
parentPort?.postMessage(result);
}
void run();
+1 -1
View File
@@ -338,7 +338,7 @@ describe("modelPreviewCache -- memory safety", () => {
await generateModelPreviewGlb(plateId, fixture);
expect(modelPreviewGlbExists(plateId)).toBe(false);
const errorPath = path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.error`);
expect(fsSync.readFileSync(errorPath, "utf-8")).toBe("unparseable-or-too-complex");
expect(fsSync.readFileSync(errorPath, "utf-8")).toBe("permanent: unparseable-or-too-complex");
});
it("resolves a two-level wrapper chain to exactly one copy of the geometry", async () => {
+63
View File
@@ -0,0 +1,63 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
// Its own file because the limits are read from env when config.ts loads: a 1 MB budget is below
// what merely starting a worker thread costs, so every render here is guaranteed to be stopped by
// a limit -- exercising the kill path without needing a genuinely huge fixture.
vi.hoisted(() => {
process.env.MODEL_PREVIEW_MAX_MEMORY_MB = "1";
});
const { writeZip } = await import("../src/utils/zipWriter");
const { generateModelPreviewGlb, modelPreviewGlbExists, modelPreviewGlbPath } = await import(
"../src/services/modelPreviewCache"
);
const MODEL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" unit="millimeter">
<resources><object id="1" type="model"><mesh><vertices><vertex x="0" y="0" z="0" /><vertex x="10" y="0" z="0" /><vertex x="0" y="10" z="0" /></vertices><triangles><triangle v1="0" v2="1" v3="2" /></triangles></mesh></object></resources>
<build><item objectid="1" /></build>
</model>
`;
describe("modelPreviewCache -- worker limits", () => {
let fixture: string;
const plateId = `test-fixture-limits-${Date.now()}`;
const cacheDir = () => path.dirname(modelPreviewGlbPath("x"));
beforeAll(async () => {
await fs.mkdir(cacheDir(), { recursive: true });
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-limits-"));
const modelPath = path.join(dir, "3dmodel.model");
await fs.writeFile(modelPath, MODEL_XML);
fixture = path.join(dir, "limits.3mf");
await writeZip(fixture, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]);
});
afterAll(async () => {
for (const ext of [".v2.glb", ".error", ".pending"]) {
await fs.rm(path.join(cacheDir(), `${plateId}${ext}`), { force: true });
}
});
it("kills a render that exceeds its memory budget and keeps the server process alive", async () => {
await generateModelPreviewGlb(plateId, fixture);
expect(modelPreviewGlbExists(plateId)).toBe(false);
const error = fsSync.readFileSync(path.join(cacheDir(), `${plateId}.error`), "utf-8");
expect(error).toMatch(/^permanent: (memory grew|worker heap exceeded)/);
expect(fsSync.existsSync(path.join(cacheDir(), `${plateId}.pending`))).toBe(false);
expect(fsSync.readdirSync(cacheDir()).some((f) => f.startsWith(plateId) && f.endsWith(".tmp"))).toBe(false);
});
it("does not retry a limit failure, even once the cooldown has passed", async () => {
const errorPath = path.join(cacheDir(), `${plateId}.error`);
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
await fs.utimes(errorPath, longAgo, longAgo);
await generateModelPreviewGlb(plateId, fixture);
// Untouched: a retry would have rewritten it. (Rounded -- mtimeMs comes back with float noise.)
expect(Math.round(fsSync.statSync(errorPath).mtimeMs)).toBe(longAgo.getTime());
});
});