mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
fix: move preview generation to own separate thread
This commit is contained in:
1 parent
355833ca24
commit
ba08d30e1e
6 files changed
+923
-709
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user