mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix: correct multi-part 3MF previews and stop risky in-browser fallback
Bambu Studio keeps every part of a multi-part object in one 3D/Objects file and references each part separately. The server renderer, its pre-render triangle count and the browser parser took the whole file for every reference, so a model with k parts was built k times over, each part also in its siblings' colours, and was often refused as too complex. Components now take only the object they name, and each part file is parsed once. The preview format and failure markers move to v3, so affected previews are rebuilt and wrongly refused models retried. When the server had no preview yet, the viewer parsed the raw 3MF in the browser, which for heavy models exhausted its memory. The preview endpoint's 404 now carries a code: the viewer waits while it's being generated, shows "too complex" when the server couldn't make one, and parses in the browser only when previews are disabled or the 3MF layout is one the server parser doesn't read.
This commit is contained in:
1 parent
5685514da6
commit
65236c5803
10 files changed
+441
-75
No files matched your search
@@ -18,7 +18,7 @@ import {
|
||||
import { availablePlateFilename, plateThumbPath, relocatePrint, saveThumbFromBytes } from "../services/printService";
|
||||
import { addGeneratedPreviewImageIfNone } from "../services/previewImageService";
|
||||
import { printOutById } from "../services/printLoader";
|
||||
import { generateModelPreviewGlb, modelPreviewGlbPath } from "../services/modelPreviewCache";
|
||||
import { generateModelPreviewGlb, modelPreviewGlbPath, modelPreviewState } from "../services/modelPreviewCache";
|
||||
import { getPreviewMode } from "../services/settingsService";
|
||||
|
||||
const router = Router();
|
||||
@@ -169,15 +169,27 @@ router.get(
|
||||
if (!plate) throw new HttpError(404, "Not found");
|
||||
const glbPath = modelPreviewGlbPath(plate.id);
|
||||
if (!fs.existsSync(glbPath)) {
|
||||
// Self-heal for a plate that predates this cache (or whose background generation hasn't
|
||||
// finished/started yet): kick it off and let the client fall back to the live parser for
|
||||
// this one request -- generateModelPreviewGlb no-ops if it's already running or exists.
|
||||
// "disabled" never generates server-side; the viewer just uses the live parser.
|
||||
const srcPath = plate.filename.toLowerCase().endsWith(".3mf") && (await getPreviewMode()) !== "disabled"
|
||||
? resolvePlateFilePath(plate)
|
||||
: null;
|
||||
if (srcPath) void generateModelPreviewGlb(plate.id, srcPath);
|
||||
throw new HttpError(404, "Not found");
|
||||
// The 404's code tells the viewer what to do instead. PREVIEW_DISABLED: parse the file in
|
||||
// the browser, the admin's choice. Otherwise generation is kicked off here if it hasn't been
|
||||
// (self-heal for a plate that predates the cache; generateModelPreviewGlb no-ops if it's
|
||||
// already running or exists), and the viewer waits (PREVIEW_GENERATING) or gives up
|
||||
// (PREVIEW_FAILED) -- never parsing in the browser a file the server is still working on or
|
||||
// found too heavy, which is what used to exhaust the browser's memory on big models.
|
||||
if (!plate.filename.toLowerCase().endsWith(".3mf")) throw new HttpError(404, "Not found");
|
||||
if ((await getPreviewMode()) === "disabled") {
|
||||
throw new HttpError(404, "Preview generation is disabled", "PREVIEW_DISABLED");
|
||||
}
|
||||
const srcPath = resolvePlateFilePath(plate);
|
||||
if (!srcPath) throw new HttpError(404, "Model file is missing", "PREVIEW_FAILED");
|
||||
void generateModelPreviewGlb(plate.id, srcPath);
|
||||
const state = modelPreviewState(plate.id);
|
||||
if (state === "unsupported") {
|
||||
throw new HttpError(404, "This 3MF's layout isn't supported by the server preview", "PREVIEW_UNSUPPORTED");
|
||||
}
|
||||
if (state === "failed") {
|
||||
throw new HttpError(404, "No preview could be generated for this model", "PREVIEW_FAILED");
|
||||
}
|
||||
throw new HttpError(404, "Preview is being generated", "PREVIEW_GENERATING");
|
||||
}
|
||||
res.setHeader("Content-Type", "model/gltf-binary");
|
||||
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
||||
|
||||
@@ -13,10 +13,12 @@ import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelP
|
||||
|
||||
// ---- Cache path helpers (same shape as printService.ts's plateThumbPath/plateThumbExists) -----
|
||||
|
||||
// Part of the cache filename, so a change to what buildGlbGroup produces (v2: the Z-up -> Y-up
|
||||
// conversion became a rotation instead of a mirroring swap) makes every older GLB a cache miss
|
||||
// and gets it regenerated on next view, rather than serving stale geometry forever.
|
||||
const PREVIEW_FORMAT_VERSION = 2;
|
||||
// Part of the cache filename, so a change to what buildGlbGroup produces makes every older GLB a
|
||||
// cache miss and gets it regenerated on next view, rather than serving stale geometry forever.
|
||||
// v2: the Z-up -> Y-up conversion became a rotation instead of a mirroring swap. v3: a component
|
||||
// takes only its own object from a shared part file (multi-part models were built k times over).
|
||||
// Failure markers carry it too, so a file an older renderer refused gets another attempt.
|
||||
const PREVIEW_FORMAT_VERSION = 3;
|
||||
|
||||
export function modelPreviewGlbPath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.v${PREVIEW_FORMAT_VERSION}.glb`);
|
||||
@@ -28,6 +30,11 @@ function legacyModelPreviewGlbPath(plateId: string): string {
|
||||
}
|
||||
|
||||
function modelPreviewErrorPath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.v${PREVIEW_FORMAT_VERSION}.error`);
|
||||
}
|
||||
|
||||
/** Pre-versioning failure marker -- ignored, and removed alongside a successful render. */
|
||||
function legacyModelPreviewErrorPath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.error`);
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ const ERROR_RETRY_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
|
||||
// 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: ";
|
||||
const UNSUPPORTED_MARKER = `${PERMANENT_FAILURE_PREFIX}unsupported`;
|
||||
|
||||
function recentlyFailed(plateId: string): boolean {
|
||||
const errorPath = modelPreviewErrorPath(plateId);
|
||||
@@ -78,6 +86,7 @@ const MEMORY_POLL_MS = 250;
|
||||
type RenderOutcome =
|
||||
| { status: "ok" }
|
||||
| { status: "too-complex" }
|
||||
| { status: "unsupported" }
|
||||
| { status: "limit"; reason: string }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
@@ -167,6 +176,27 @@ export async function generateModelPreviewGlb(plateId: string, srcPath: string):
|
||||
await run;
|
||||
}
|
||||
|
||||
/** Where a plate's preview stands, for the viewer to decide between waiting, giving up, and
|
||||
* loading it: "generating" covers queued too; "failed" covers a file too heavy to preview, a
|
||||
* render that hit its limits, and one interrupted by a crash -- none of which will retry on
|
||||
* their own soon; "unsupported" is a 3MF layout the server's parser doesn't handle, left to the
|
||||
* browser's more general loaders. */
|
||||
export type ModelPreviewState = "ready" | "generating" | "failed" | "unsupported";
|
||||
|
||||
export function modelPreviewState(plateId: string): ModelPreviewState {
|
||||
if (modelPreviewGlbExists(plateId)) return "ready";
|
||||
if (inFlight.has(plateId)) return "generating";
|
||||
if (recentlyFailed(plateId)) {
|
||||
try {
|
||||
return fsSync.readFileSync(modelPreviewErrorPath(plateId), "utf-8") === UNSUPPORTED_MARKER ? "unsupported" : "failed";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
if (fsSync.existsSync(modelPreviewPendingPath(plateId))) return "failed";
|
||||
return "generating";
|
||||
}
|
||||
|
||||
async function runGeneration(plateId: string, srcPath: string): Promise<void> {
|
||||
const pendingPath = modelPreviewPendingPath(plateId);
|
||||
const errorPath = modelPreviewErrorPath(plateId);
|
||||
@@ -180,9 +210,13 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
|
||||
await fs.rename(tmp, dest);
|
||||
await fs.rm(errorPath, { force: true });
|
||||
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
|
||||
await fs.rm(legacyModelPreviewErrorPath(plateId), { force: true });
|
||||
break;
|
||||
case "too-complex":
|
||||
await fs.writeFile(errorPath, `${PERMANENT_FAILURE_PREFIX}unparseable-or-too-complex`);
|
||||
await fs.writeFile(errorPath, `${PERMANENT_FAILURE_PREFIX}too-complex`);
|
||||
break;
|
||||
case "unsupported":
|
||||
await fs.writeFile(errorPath, UNSUPPORTED_MARKER);
|
||||
break;
|
||||
case "limit":
|
||||
console.warn(`Model preview for plate ${plateId} stopped: ${outcome.reason}`);
|
||||
|
||||
@@ -277,18 +277,118 @@ function countTriangleTags(xml: string): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- Part files (3D/Objects/*.model) ----------------------------------------------------------
|
||||
|
||||
/** The part-file objects a component takes: the one its objectid names, or every object in the
|
||||
* file when it names none (or one the file doesn't have). */
|
||||
function partFileTargetIds(objects: Map<string, string>, objectId: string | null): string[] {
|
||||
return objectId && objects.has(objectId) ? [objectId] : [...objects.keys()];
|
||||
}
|
||||
|
||||
/** Loads a component's geometry out of a part file. A part file can hold several <object>s:
|
||||
* Bambu Studio keeps every part of a multi-part object in one file and references each part
|
||||
* separately (same p:path, a different objectid). So a component takes only the object its
|
||||
* objectid names -- taking the whole file for each reference built every part once per sibling
|
||||
* (k parts -> k times the geometry, each part also drawn in its siblings' colours). A component
|
||||
* with no objectid, or one naming an object the file doesn't have, still takes the whole file.
|
||||
*
|
||||
* Its own regex instances throughout: callers are mid-way through a global OBJECT_RE/COMPONENT_RE
|
||||
* scan of the main model when they call in here. */
|
||||
function createPartFileResolver(loadExternalModel: (path: string) => Promise<string | null>) {
|
||||
const objectsByPath = new Map<string, Map<string, string> | null>();
|
||||
const meshCache = new Map<string, FastMesh[]>();
|
||||
const countCache = new Map<string, number>();
|
||||
|
||||
const objectsIn = async (path: string): Promise<Map<string, string> | null> => {
|
||||
if (objectsByPath.has(path)) return objectsByPath.get(path)!;
|
||||
const xml = await loadExternalModel(path);
|
||||
let objects: Map<string, string> | null = null;
|
||||
if (xml) {
|
||||
objects = new Map();
|
||||
const objectRe = new RegExp(OBJECT_RE.source, "g");
|
||||
let om: RegExpExecArray | null;
|
||||
while ((om = objectRe.exec(xml))) {
|
||||
const id = getAttr(om[1], "id");
|
||||
if (id) objects.set(id, om[2]);
|
||||
}
|
||||
}
|
||||
objectsByPath.set(path, objects);
|
||||
return objects;
|
||||
};
|
||||
|
||||
/** Same-file component references inside a part-file object ({ refId, transform }). */
|
||||
const innerRefs = (inner: string, objects: Map<string, string>) => {
|
||||
const refs: { refId: string; transform: number[] | null }[] = [];
|
||||
const componentRe = new RegExp(COMPONENT_RE.source, "g");
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = componentRe.exec(inner))) {
|
||||
const refId = getAttr(cm[1], "objectid");
|
||||
const hasPath = (getAttr(cm[1], "p:path") ?? getAttr(cm[1], "path")) !== null;
|
||||
if (refId && !hasPath && objects.has(refId)) refs.push({ refId, transform: parseTransform3MF(getAttr(cm[1], "transform")) });
|
||||
}
|
||||
return refs;
|
||||
};
|
||||
|
||||
/** Geometry of one object in a part file (untransformed, extruder unset), parsed once however
|
||||
* many times it's placed. */
|
||||
const objectMeshes = async (path: string, objects: Map<string, string>, id: string, depth: number): Promise<FastMesh[]> => {
|
||||
const key = `${path}#${id}`;
|
||||
const cached = meshCache.get(key);
|
||||
if (cached) return cached;
|
||||
const inner = objects.get(id) ?? "";
|
||||
const meshes = extractMeshesFast(inner, 0);
|
||||
if (depth < MAX_COMPONENT_DEPTH) {
|
||||
for (const ref of innerRefs(inner, objects)) {
|
||||
for (const mesh of await objectMeshes(path, objects, ref.refId, depth + 1)) {
|
||||
meshes.push(ref.transform ? { ...mesh, vertices: applyAffineToVertices(mesh.vertices, ref.transform) } : mesh);
|
||||
}
|
||||
}
|
||||
}
|
||||
meshCache.set(key, meshes);
|
||||
return meshes;
|
||||
};
|
||||
|
||||
const objectTriangles = (path: string, objects: Map<string, string>, id: string, depth: number): number => {
|
||||
const key = `${path}#${id}`;
|
||||
const cached = countCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const inner = objects.get(id) ?? "";
|
||||
let count = countTriangleTags(inner);
|
||||
if (depth < MAX_COMPONENT_DEPTH) {
|
||||
for (const ref of innerRefs(inner, objects)) count += objectTriangles(path, objects, ref.refId, depth + 1);
|
||||
}
|
||||
countCache.set(key, count);
|
||||
return count;
|
||||
};
|
||||
|
||||
return {
|
||||
/** The component's meshes, in its part file's own coordinates. */
|
||||
async meshes(path: string, objectId: string | null): Promise<FastMesh[]> {
|
||||
const objects = await objectsIn(path);
|
||||
if (!objects) return [];
|
||||
const out: FastMesh[] = [];
|
||||
for (const id of partFileTargetIds(objects, objectId)) out.push(...(await objectMeshes(path, objects, id, 0)));
|
||||
return out;
|
||||
},
|
||||
/** How many triangles the component brings in, without parsing any geometry. */
|
||||
async triangles(path: string, objectId: string | null): Promise<number> {
|
||||
const objects = await objectsIn(path);
|
||||
if (!objects) return 0;
|
||||
return partFileTargetIds(objects, objectId).reduce((sum, id) => sum + objectTriangles(path, objects, id, 0), 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type PartFileResolver = ReturnType<typeof createPartFileResolver>;
|
||||
|
||||
/** 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> {
|
||||
async function countRenderedTriangles(xml: string, partFiles: PartFileResolver): 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;
|
||||
@@ -305,11 +405,7 @@ async function countRenderedTriangles(
|
||||
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)!;
|
||||
count += await partFiles.triangles(extPath, compObjectId);
|
||||
} else if (compObjectId) {
|
||||
refs.push(compObjectId);
|
||||
}
|
||||
@@ -349,7 +445,7 @@ async function parseMainModel(
|
||||
xml: string,
|
||||
structural: StructuralData,
|
||||
plateAssignmentsByName: Map<string, number>,
|
||||
loadExternalModel: (path: string) => Promise<string | null>,
|
||||
partFiles: PartFileResolver,
|
||||
): 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
|
||||
@@ -387,10 +483,7 @@ async function parseMainModel(
|
||||
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) {
|
||||
for (const mesh of await partFiles.meshes(extPath, compObjectId)) {
|
||||
meshes.push({
|
||||
vertices: transform ? applyAffineToVertices(mesh.vertices, transform) : mesh.vertices,
|
||||
triangles: mesh.triangles,
|
||||
@@ -489,16 +582,19 @@ const MAX_TRIANGLES = 8_000_000;
|
||||
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> {
|
||||
/** Why a file gets no server preview: "too-complex" is over a size budget (the browser mustn't
|
||||
* try it either), "unsupported" is a layout this parser doesn't handle (no main model at the
|
||||
* usual path, no objects) -- the browser's own, more general 3MF loaders may still manage. */
|
||||
export type PreviewRefusal = "too-complex" | "unsupported";
|
||||
|
||||
/** Reads and fast-parses a Bambu Studio project .3mf. */
|
||||
async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | PreviewRefusal> {
|
||||
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;
|
||||
if (!modelEntries.some((entry) => entry.name === MAIN_MODEL_PATH)) return "unsupported";
|
||||
if (modelEntries.some((entry) => entry.size > MAX_MODEL_ENTRY_BYTES)) return "too-complex";
|
||||
if (modelEntries.reduce((sum, entry) => sum + entry.size, 0) > MAX_MODEL_TOTAL_BYTES) return "too-complex";
|
||||
|
||||
let modelSettingsText: string | null = null;
|
||||
let projectSettingsText: string | null = null;
|
||||
@@ -538,7 +634,7 @@ async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
|
||||
},
|
||||
);
|
||||
|
||||
if (!mainModelText) return null;
|
||||
if (!mainModelText) return "unsupported";
|
||||
const mainModelXml: string = mainModelText;
|
||||
|
||||
const structural = modelSettingsText ? parseModelSettingsConfig(modelSettingsText) : parseModelSettingsConfig("");
|
||||
@@ -572,10 +668,11 @@ async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
|
||||
|
||||
// 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 partFiles = createPartFileResolver(loadExternalModel);
|
||||
if ((await countRenderedTriangles(mainModelXml, partFiles)) > MAX_TRIANGLES) return "too-complex";
|
||||
|
||||
const { objects, buildItems } = await parseMainModel(mainModelXml, structural, plateAssignmentsByName, loadExternalModel);
|
||||
if (objects.size === 0) return null;
|
||||
const { objects, buildItems } = await parseMainModel(mainModelXml, structural, plateAssignmentsByName, partFiles);
|
||||
if (objects.size === 0) return "unsupported";
|
||||
|
||||
const plateThumbnails = new Map<number, string>();
|
||||
for (const [idx, bytes] of plateThumbBytes) {
|
||||
@@ -693,11 +790,11 @@ async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group
|
||||
|
||||
// ---- 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"> {
|
||||
/** Parses `srcPath` and writes the preview GLB to `destPath`, or says why it didn't (see
|
||||
* PreviewRefusal) -- decided before any geometry is built. */
|
||||
export async function renderModelPreviewGlb(srcPath: string, destPath: string): Promise<"ok" | PreviewRefusal> {
|
||||
const parsed = await parseThreeMfFast(srcPath);
|
||||
if (!parsed) return "too-complex";
|
||||
if (typeof parsed === "string") return parsed;
|
||||
const group = await buildGlbGroup(parsed);
|
||||
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
|
||||
const result = await new GLTFExporter().parseAsync(group, { binary: true });
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ModelPreviewWorkerInput = { srcPath: string; destPath: string };
|
||||
export type ModelPreviewWorkerResult =
|
||||
| { status: "ok" }
|
||||
| { status: "too-complex" }
|
||||
| { status: "unsupported" }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
async function run(): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,12 @@ import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeZip } from "../src/utils/zipWriter";
|
||||
import { generateModelPreviewGlb, modelPreviewGlbExists, modelPreviewGlbPath } from "../src/services/modelPreviewCache";
|
||||
import {
|
||||
generateModelPreviewGlb,
|
||||
modelPreviewGlbExists,
|
||||
modelPreviewGlbPath,
|
||||
modelPreviewState,
|
||||
} from "../src/services/modelPreviewCache";
|
||||
|
||||
// A hand-built 2-object/2-extruder/2-plate Bambu-style .3mf, small enough to commit as test
|
||||
// data inline rather than shipping a binary fixture file. Exercises the same fast regex-based
|
||||
@@ -320,7 +325,7 @@ describe("modelPreviewCache -- memory safety", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of plateIds) {
|
||||
for (const ext of [".v2.glb", ".error", ".pending"]) {
|
||||
for (const ext of [".v3.glb", ".v3.error", ".pending"]) {
|
||||
await fs.rm(path.join(path.dirname(modelPreviewGlbPath("x")), `${id}${ext}`), { force: true });
|
||||
}
|
||||
}
|
||||
@@ -337,8 +342,8 @@ describe("modelPreviewCache -- memory safety", () => {
|
||||
const plateId = newPlateId("fanout");
|
||||
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("permanent: unparseable-or-too-complex");
|
||||
const errorPath = path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.v3.error`);
|
||||
expect(fsSync.readFileSync(errorPath, "utf-8")).toBe("permanent: too-complex");
|
||||
});
|
||||
|
||||
it("resolves a two-level wrapper chain to exactly one copy of the geometry", async () => {
|
||||
@@ -387,3 +392,140 @@ describe("modelPreviewCache -- memory safety", () => {
|
||||
expect(fsSync.existsSync(path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.pending`))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function cacheDir(): string {
|
||||
return path.dirname(modelPreviewGlbPath("x"));
|
||||
}
|
||||
|
||||
/** A triangle at x offset `x`, so each part's geometry is recognizable in the output. */
|
||||
function triangleAt(x: number): string {
|
||||
return `<mesh><vertices><vertex x="${x}" y="0" z="0" /><vertex x="${x + 10}" y="0" z="0" /><vertex x="${x}" y="10" z="0" /></vertices><triangles><triangle v1="0" v2="1" v3="2" /></triangles></mesh>`;
|
||||
}
|
||||
|
||||
async function loadGlbMeshes(plateId: string): Promise<any[]> {
|
||||
const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js");
|
||||
const buf = fsSync.readFileSync(modelPreviewGlbPath(plateId));
|
||||
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
const gltf = await new Promise<any>((resolve, reject) => {
|
||||
new GLTFLoader().parse(arrayBuffer, "", resolve, reject);
|
||||
});
|
||||
const meshes: any[] = [];
|
||||
gltf.scene.traverse((obj: any) => {
|
||||
if (obj.isMesh) meshes.push(obj);
|
||||
});
|
||||
return meshes;
|
||||
}
|
||||
|
||||
describe("modelPreviewCache -- multi-part objects sharing one part file", () => {
|
||||
const plateIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of plateIds) {
|
||||
for (const ext of [".v3.glb", ".v3.error", ".pending"]) await fs.rm(path.join(cacheDir(), `${id}${ext}`), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// How Bambu Studio stores a multi-part (often multi-colour) object: every part in one
|
||||
// 3D/Objects file, each referenced by its own component with a different objectid.
|
||||
it("takes only the referenced object from the file for each component, in its own colour", async () => {
|
||||
await fs.mkdir(cacheDir(), { recursive: true });
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-parts-"));
|
||||
const mainPath = path.join(dir, "3dmodel.model");
|
||||
const partsPath = path.join(dir, "object_1.model");
|
||||
const settingsPath = path.join(dir, "model_settings.config");
|
||||
await fs.writeFile(
|
||||
mainPath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" unit="millimeter">
|
||||
<resources><object id="9" type="model"><components>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="1" />
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="2" />
|
||||
</components></object></resources>
|
||||
<build><item objectid="9" /></build>
|
||||
</model>`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
partsPath,
|
||||
`<?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">${triangleAt(0)}</object>
|
||||
<object id="2" type="model">${triangleAt(100)}</object>
|
||||
</resources></model>`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config><object id="9"><metadata key="extruder" value="1"/>
|
||||
<part id="1" subtype="normal_part"><metadata key="extruder" value="1"/></part>
|
||||
<part id="2" subtype="normal_part"><metadata key="extruder" value="2"/></part>
|
||||
</object></config>`,
|
||||
);
|
||||
const fixture = path.join(dir, "parts.3mf");
|
||||
await writeZip(fixture, [
|
||||
{ arcname: "3D/3dmodel.model", filePath: mainPath },
|
||||
{ arcname: "3D/Objects/object_1.model", filePath: partsPath },
|
||||
{ arcname: "Metadata/model_settings.config", filePath: settingsPath },
|
||||
]);
|
||||
|
||||
const plateId = `test-fixture-parts-${Date.now()}`;
|
||||
plateIds.push(plateId);
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewState(plateId)).toBe("ready");
|
||||
|
||||
const meshes = await loadGlbMeshes(plateId);
|
||||
const byName = Object.fromEntries(meshes.map((m) => [m.name, positionsOf(m)]));
|
||||
// One triangle per part -- not both parts under each extruder, as before.
|
||||
expect(byName).toEqual({
|
||||
"extruder-0": [0, 0, 0, 10, 0, 0, 0, 0, -10],
|
||||
"extruder-1": [100, 0, 0, 110, 0, 0, 100, 0, -10],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelPreviewState", () => {
|
||||
const plateIds: string[] = [];
|
||||
const newPlateId = (label: string) => {
|
||||
const id = `test-fixture-state-${label}-${Date.now()}`;
|
||||
plateIds.push(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of plateIds) {
|
||||
for (const ext of [".v3.glb", ".v3.error", ".pending"]) await fs.rm(path.join(cacheDir(), `${id}${ext}`), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("says a too-heavy file failed, so the viewer won't parse it in the browser", async () => {
|
||||
let objects = `<object id="1" type="model">${ONE_TRIANGLE_MESH}</object>`;
|
||||
for (let id = 2; id <= 7; id++) objects += `<object id="${id}" type="model">${fanOut(String(id - 1))}</object>`;
|
||||
const fixture = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "thingport-state-")), "heavy.3mf");
|
||||
await buildModelOnly3mf(fixture, singleItemModelXml(objects, "7"));
|
||||
const plateId = newPlateId("heavy");
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewState(plateId)).toBe("failed");
|
||||
});
|
||||
|
||||
it("says a layout it can't read is unsupported, leaving it to the browser's loaders", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-state-"));
|
||||
const modelPath = path.join(dir, "model.model");
|
||||
await fs.writeFile(modelPath, singleItemModelXml(`<object id="1" type="model">${ONE_TRIANGLE_MESH}</object>`, "1"));
|
||||
const fixture = path.join(dir, "elsewhere.3mf");
|
||||
// Valid per the 3MF spec (the real main-model path comes from _rels/.rels), but not where
|
||||
// this parser looks.
|
||||
await writeZip(fixture, [{ arcname: "3D/model.model", filePath: modelPath }]);
|
||||
const plateId = newPlateId("unsupported");
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewState(plateId)).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("says generating while a render is queued or running", async () => {
|
||||
const fixture = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "thingport-state-")), "ok.3mf");
|
||||
await buildModelOnly3mf(fixture, singleItemModelXml(`<object id="1" type="model">${ONE_TRIANGLE_MESH}</object>`, "1"));
|
||||
const plateId = newPlateId("running");
|
||||
const run = generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewState(plateId)).toBe("generating");
|
||||
await run;
|
||||
expect(modelPreviewState(plateId)).toBe("ready");
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ describe("modelPreviewCache -- worker limits", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const ext of [".v2.glb", ".error", ".pending"]) {
|
||||
for (const ext of [".v3.glb", ".v3.error", ".pending"]) {
|
||||
await fs.rm(path.join(cacheDir(), `${plateId}${ext}`), { force: true });
|
||||
}
|
||||
});
|
||||
@@ -46,14 +46,14 @@ describe("modelPreviewCache -- worker limits", () => {
|
||||
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");
|
||||
const error = fsSync.readFileSync(path.join(cacheDir(), `${plateId}.v3.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 errorPath = path.join(cacheDir(), `${plateId}.v3.error`);
|
||||
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
await fs.utimes(errorPath, longAgo, longAgo);
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
|
||||
@@ -77,7 +77,14 @@ const XRAY_OPACITY = 0.3;
|
||||
// Bambu X1/P1/A1's 256x256mm bed -- the stand-in for formats with no bed size of their own.
|
||||
const DEFAULT_BED_SIZE = 256;
|
||||
|
||||
type ViewErrorKey = "unsupported" | "failed";
|
||||
type ViewErrorKey = "unsupported" | "failed" | "tooComplex";
|
||||
|
||||
// While the server is still generating a 3MF's preview, the viewer asks again this often, for at
|
||||
// most this long (a queued collection import can hold it back a while) -- instead of parsing the
|
||||
// raw file in the browser, which for the heavy models that take the server longest is exactly
|
||||
// what exhausts the browser's memory.
|
||||
const SERVER_PREVIEW_POLL_MS = 3000;
|
||||
const SERVER_PREVIEW_WAIT_MS = 10 * 60 * 1000;
|
||||
|
||||
const BAMBU_PLATE_COLOR = 0x00ae42;
|
||||
|
||||
@@ -158,6 +165,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
||||
const rebuildBambuPlateRef = useRef<((plateId: number | null) => void) | null>(null);
|
||||
const [viewError, setViewError] = useState<ViewErrorKey | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [waitingForServer, setWaitingForServer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -167,6 +175,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
||||
const palette = paletteForTheme(theme);
|
||||
setViewError(null);
|
||||
setIsLoading(true);
|
||||
setWaitingForServer(false);
|
||||
const reportError = (key: ViewErrorKey) => {
|
||||
if (!disposed) {
|
||||
setViewError(key);
|
||||
@@ -466,13 +475,33 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
||||
|
||||
const e = (ext || "").toLowerCase();
|
||||
|
||||
// Tries the server pre-rendered GLB cache for a 3MF; returns true if it was used (scene
|
||||
// already fully set up), false if there's no cache yet (or it failed to load) and the
|
||||
// caller should fall back to the live parser exactly as if this function didn't exist.
|
||||
// Tries the server's pre-rendered GLB for a 3MF, waiting while it's still being generated.
|
||||
// Returns true when the server settled it -- the scene is set up, or an error is showing
|
||||
// -- and false when the caller should parse the raw file in the browser instead (see
|
||||
// CachedGlbOutcome's "fallback").
|
||||
const tryLoadCachedGlb = async (): Promise<boolean> => {
|
||||
if (!previewGlbUrl) return false;
|
||||
const cached = await loadCachedBambuGlb(previewGlbUrl);
|
||||
if (!cached || disposed) return false;
|
||||
const deadline = Date.now() + SERVER_PREVIEW_WAIT_MS;
|
||||
let outcome = await loadCachedBambuGlb(previewGlbUrl);
|
||||
while (outcome.status === "generating") {
|
||||
if (disposed) return true;
|
||||
if (Date.now() > deadline) {
|
||||
reportError("failed");
|
||||
return true;
|
||||
}
|
||||
setWaitingForServer(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, SERVER_PREVIEW_POLL_MS));
|
||||
if (disposed) return true;
|
||||
outcome = await loadCachedBambuGlb(previewGlbUrl);
|
||||
}
|
||||
if (disposed) return true;
|
||||
setWaitingForServer(false);
|
||||
if (outcome.status === "failed") {
|
||||
reportError("tooComplex");
|
||||
return true;
|
||||
}
|
||||
if (outcome.status !== "ready") return false;
|
||||
const cached = outcome.glb;
|
||||
cachedGlbRoot = cached.rootGroup;
|
||||
buildVolume = cached.buildVolume;
|
||||
layoutBuildPlate();
|
||||
@@ -665,6 +694,11 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<BrandMark theme={theme} size="lg" />
|
||||
<CircularProgress size={22} />
|
||||
{waitingForServer && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ px: 2, textAlign: "center" }}>
|
||||
{t("library:modelViewer.previewPreparing")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -684,7 +718,9 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
||||
<Typography variant="body2" fontWeight={600} color="error.light">
|
||||
{viewError === "unsupported"
|
||||
? t("library:modelViewer.previewUnsupported")
|
||||
: t("library:modelViewer.previewFailed")}
|
||||
: viewError === "tooComplex"
|
||||
? t("library:modelViewer.previewTooComplex")
|
||||
: t("library:modelViewer.previewFailed")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -729,6 +729,8 @@
|
||||
"previewAlt": "Preview",
|
||||
"previewUnsupported": "Preview unsupported for this file type.",
|
||||
"previewFailed": "Preview failed to load.",
|
||||
"previewTooComplex": "This model is too complex for a 3D preview.",
|
||||
"previewPreparing": "Preparing the 3D preview… Large models can take a minute.",
|
||||
"generatingPreview": "Generating preview...",
|
||||
"waitingToGenerate": "Waiting to generate preview...",
|
||||
"generatePreview": "Generate preview",
|
||||
|
||||
@@ -731,6 +731,8 @@
|
||||
"previewAlt": "Peržiūra",
|
||||
"previewUnsupported": "Šio failo tipo peržiūra nepalaikoma.",
|
||||
"previewFailed": "Nepavyko įkelti peržiūros.",
|
||||
"previewTooComplex": "Šis modelis per sudėtingas 3D peržiūrai.",
|
||||
"previewPreparing": "Ruošiama 3D peržiūra… Dideliems modeliams tai gali užtrukti minutę.",
|
||||
"generatingPreview": "Generuojama peržiūra...",
|
||||
"waitingToGenerate": "Laukiama peržiūros generavimo...",
|
||||
"generatePreview": "Generuoti peržiūrą",
|
||||
|
||||
@@ -188,10 +188,14 @@ async function parse3MF(zipEntries: Record<string, Uint8Array>): Promise<ParsedB
|
||||
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
||||
return zipEntries[normalized];
|
||||
};
|
||||
// Parsed once per part file: a multi-part object references the same file once per part.
|
||||
const modelFileCache = new Map<string, Document | null>();
|
||||
const loadModelFile = (path: string): Document | null => {
|
||||
if (modelFileCache.has(path)) return modelFileCache.get(path) ?? null;
|
||||
const bytes = findEntry(path);
|
||||
if (!bytes) return null;
|
||||
return parser.parseFromString(decoder.decode(bytes), "application/xml");
|
||||
const doc = bytes ? parser.parseFromString(decoder.decode(bytes), "application/xml") : null;
|
||||
modelFileCache.set(path, doc);
|
||||
return doc;
|
||||
};
|
||||
|
||||
const extruderMapById = new Map<string, number>();
|
||||
@@ -381,7 +385,16 @@ async function parse3MF(zipEntries: Record<string, Uint8Array>): Promise<ParsedB
|
||||
|
||||
const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
|
||||
const compExtruder = partKey ? partExtruderMap.get(partKey) ?? defaultExtruder : defaultExtruder;
|
||||
const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);
|
||||
// Only the object this component names: a part file can hold every part of a multi-part
|
||||
// object, each referenced by its own component (same p:path, different objectid) -- taking
|
||||
// the whole file per reference built each part once per sibling, in the siblings' colours.
|
||||
// Mirrors backend/src/services/modelPreviewRender.ts's createPartFileResolver.
|
||||
const targetEl = compObjectId
|
||||
? Array.from(extDoc.getElementsByTagName("object")).find((el) => el.getAttribute("id") === compObjectId)
|
||||
: undefined;
|
||||
const targetMeshes = targetEl ? await parseMeshFromDoc(targetEl, compExtruder) : [];
|
||||
// No objectid, one the file doesn't have, or an object that only wraps others: the whole file.
|
||||
const extMeshes = targetMeshes.length > 0 ? targetMeshes : await parseMeshFromDoc(extDoc, compExtruder);
|
||||
const compTransformStr = compEl.getAttribute("transform");
|
||||
const compTransform = parseTransform3MF(compTransformStr);
|
||||
|
||||
@@ -457,14 +470,38 @@ export type CachedBambuGlb = {
|
||||
getPlateThumbnail: (plateIndex: number) => Promise<string | null>;
|
||||
};
|
||||
|
||||
/** Loads the server pre-rendered GLB for a plate (see backend/src/services/modelPreviewCache.ts)
|
||||
* instead of parsing the raw .3mf -- the fast path. Returns null for anything that doesn't look
|
||||
* like a cache this app produced (wrong shape, fetch failure, load failure); callers fall back
|
||||
* to parseBambuThreeMF/loadBambuThreeMFForViewer exactly as if no cache existed yet. */
|
||||
export async function loadCachedBambuGlb(url: string): Promise<CachedBambuGlb | null> {
|
||||
/** What the server's preview endpoint said, besides a usable GLB. "generating": it's being made,
|
||||
* ask again shortly. "failed": the server couldn't make one (too heavy, hit its limits) -- the
|
||||
* browser mustn't try either, since parsing such a file here is what exhausts its memory.
|
||||
* "fallback": parse the raw file in the browser -- previews turned off by the admin, a 3MF
|
||||
* layout the server doesn't handle, an older backend without these codes, or a cache that
|
||||
* didn't load. */
|
||||
export type CachedGlbOutcome =
|
||||
| { status: "ready"; glb: CachedBambuGlb }
|
||||
| { status: "generating" }
|
||||
| { status: "failed" }
|
||||
| { status: "fallback" };
|
||||
|
||||
async function previewErrorCode(res: Response): Promise<string | null> {
|
||||
try {
|
||||
const body = (await res.json()) as { code?: unknown };
|
||||
return typeof body.code === "string" ? body.code : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the server's pre-rendered GLB preview for a 3MF (see
|
||||
* backend/src/services/modelPreviewCache.ts) instead of parsing the raw .3mf -- the fast path. */
|
||||
export async function loadCachedBambuGlb(url: string): Promise<CachedGlbOutcome> {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {
|
||||
const code = res.status === 404 ? await previewErrorCode(res) : null;
|
||||
if (code === "PREVIEW_GENERATING") return { status: "generating" };
|
||||
if (code === "PREVIEW_FAILED") return { status: "failed" };
|
||||
return { status: "fallback" };
|
||||
}
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
|
||||
const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js");
|
||||
@@ -476,7 +513,7 @@ export async function loadCachedBambuGlb(url: string): Promise<CachedBambuGlb |
|
||||
gltf.scene.traverse((obj) => {
|
||||
if (!root && typeof obj.userData?.thingportPreview === "string") root = obj;
|
||||
});
|
||||
if (!root) return null;
|
||||
if (!root) return { status: "fallback" };
|
||||
|
||||
const meta = JSON.parse((root as THREE.Object3D).userData.thingportPreview) as {
|
||||
plates: PlateSummary[];
|
||||
@@ -486,15 +523,18 @@ export async function loadCachedBambuGlb(url: string): Promise<CachedBambuGlb |
|
||||
};
|
||||
|
||||
return {
|
||||
rootGroup: root as THREE.Group,
|
||||
plates: meta.plates,
|
||||
filamentColors: meta.filamentColors,
|
||||
buildVolume: meta.buildVolume,
|
||||
getPlateThumbnail: async (plateIndex: number) => meta.plateThumbnails[String(plateIndex)] ?? null,
|
||||
status: "ready",
|
||||
glb: {
|
||||
rootGroup: root as THREE.Group,
|
||||
plates: meta.plates,
|
||||
filamentColors: meta.filamentColors,
|
||||
buildVolume: meta.buildVolume,
|
||||
getPlateThumbnail: async (plateIndex: number) => meta.plateThumbnails[String(plateIndex)] ?? null,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("Cached GLB preview load failed, falling back to live 3MF parse", err);
|
||||
return null;
|
||||
return { status: "fallback" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user