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 { availablePlateFilename, plateThumbPath, relocatePrint, saveThumbFromBytes } from "../services/printService";
|
||||||
import { addGeneratedPreviewImageIfNone } from "../services/previewImageService";
|
import { addGeneratedPreviewImageIfNone } from "../services/previewImageService";
|
||||||
import { printOutById } from "../services/printLoader";
|
import { printOutById } from "../services/printLoader";
|
||||||
import { generateModelPreviewGlb, modelPreviewGlbPath } from "../services/modelPreviewCache";
|
import { generateModelPreviewGlb, modelPreviewGlbPath, modelPreviewState } from "../services/modelPreviewCache";
|
||||||
import { getPreviewMode } from "../services/settingsService";
|
import { getPreviewMode } from "../services/settingsService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -169,15 +169,27 @@ router.get(
|
|||||||
if (!plate) throw new HttpError(404, "Not found");
|
if (!plate) throw new HttpError(404, "Not found");
|
||||||
const glbPath = modelPreviewGlbPath(plate.id);
|
const glbPath = modelPreviewGlbPath(plate.id);
|
||||||
if (!fs.existsSync(glbPath)) {
|
if (!fs.existsSync(glbPath)) {
|
||||||
// Self-heal for a plate that predates this cache (or whose background generation hasn't
|
// The 404's code tells the viewer what to do instead. PREVIEW_DISABLED: parse the file in
|
||||||
// finished/started yet): kick it off and let the client fall back to the live parser for
|
// the browser, the admin's choice. Otherwise generation is kicked off here if it hasn't been
|
||||||
// this one request -- generateModelPreviewGlb no-ops if it's already running or exists.
|
// (self-heal for a plate that predates the cache; generateModelPreviewGlb no-ops if it's
|
||||||
// "disabled" never generates server-side; the viewer just uses the live parser.
|
// already running or exists), and the viewer waits (PREVIEW_GENERATING) or gives up
|
||||||
const srcPath = plate.filename.toLowerCase().endsWith(".3mf") && (await getPreviewMode()) !== "disabled"
|
// (PREVIEW_FAILED) -- never parsing in the browser a file the server is still working on or
|
||||||
? resolvePlateFilePath(plate)
|
// found too heavy, which is what used to exhaust the browser's memory on big models.
|
||||||
: null;
|
if (!plate.filename.toLowerCase().endsWith(".3mf")) throw new HttpError(404, "Not found");
|
||||||
if (srcPath) void generateModelPreviewGlb(plate.id, srcPath);
|
if ((await getPreviewMode()) === "disabled") {
|
||||||
throw new HttpError(404, "Not found");
|
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("Content-Type", "model/gltf-binary");
|
||||||
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
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) -----
|
// ---- 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
|
// Part of the cache filename, so a change to what buildGlbGroup produces makes every older GLB a
|
||||||
// conversion became a rotation instead of a mirroring swap) makes every older GLB a cache miss
|
// cache miss and gets it regenerated on next view, rather than serving stale geometry forever.
|
||||||
// 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
|
||||||
const PREVIEW_FORMAT_VERSION = 2;
|
// 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 {
|
export function modelPreviewGlbPath(plateId: string): string {
|
||||||
return path.join(MODEL_PREVIEWS, `${plateId}.v${PREVIEW_FORMAT_VERSION}.glb`);
|
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 {
|
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`);
|
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
|
// gets another go after the cooldown. Delete the .error file to force a retry, e.g. after raising
|
||||||
// MODEL_PREVIEW_MAX_MEMORY_MB.
|
// MODEL_PREVIEW_MAX_MEMORY_MB.
|
||||||
const PERMANENT_FAILURE_PREFIX = "permanent: ";
|
const PERMANENT_FAILURE_PREFIX = "permanent: ";
|
||||||
|
const UNSUPPORTED_MARKER = `${PERMANENT_FAILURE_PREFIX}unsupported`;
|
||||||
|
|
||||||
function recentlyFailed(plateId: string): boolean {
|
function recentlyFailed(plateId: string): boolean {
|
||||||
const errorPath = modelPreviewErrorPath(plateId);
|
const errorPath = modelPreviewErrorPath(plateId);
|
||||||
@@ -78,6 +86,7 @@ const MEMORY_POLL_MS = 250;
|
|||||||
type RenderOutcome =
|
type RenderOutcome =
|
||||||
| { status: "ok" }
|
| { status: "ok" }
|
||||||
| { status: "too-complex" }
|
| { status: "too-complex" }
|
||||||
|
| { status: "unsupported" }
|
||||||
| { status: "limit"; reason: string }
|
| { status: "limit"; reason: string }
|
||||||
| { status: "error"; error: string };
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
@@ -167,6 +176,27 @@ export async function generateModelPreviewGlb(plateId: string, srcPath: string):
|
|||||||
await run;
|
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> {
|
async function runGeneration(plateId: string, srcPath: string): Promise<void> {
|
||||||
const pendingPath = modelPreviewPendingPath(plateId);
|
const pendingPath = modelPreviewPendingPath(plateId);
|
||||||
const errorPath = modelPreviewErrorPath(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.rename(tmp, dest);
|
||||||
await fs.rm(errorPath, { force: true });
|
await fs.rm(errorPath, { force: true });
|
||||||
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
|
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
|
||||||
|
await fs.rm(legacyModelPreviewErrorPath(plateId), { force: true });
|
||||||
break;
|
break;
|
||||||
case "too-complex":
|
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;
|
break;
|
||||||
case "limit":
|
case "limit":
|
||||||
console.warn(`Model preview for plate ${plateId} stopped: ${outcome.reason}`);
|
console.warn(`Model preview for plate ${plateId} stopped: ${outcome.reason}`);
|
||||||
|
|||||||
@@ -277,18 +277,118 @@ function countTriangleTags(xml: string): number {
|
|||||||
return count;
|
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
|
/** 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
|
* 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
|
* same-document component references, counted once per placement -- the same expansion
|
||||||
* parseMainModel + buildGlbGroup perform, but on tag counts instead of real arrays. Checked
|
* 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. */
|
* before any mesh is parsed so an over-budget file never allocates its geometry. */
|
||||||
async function countRenderedTriangles(
|
async function countRenderedTriangles(xml: string, partFiles: PartFileResolver): Promise<number> {
|
||||||
xml: string,
|
|
||||||
loadExternalModel: (path: string) => Promise<string | null>,
|
|
||||||
): Promise<number> {
|
|
||||||
const ownCount = new Map<string, number>();
|
const ownCount = new Map<string, number>();
|
||||||
const internalRefs = new Map<string, string[]>();
|
const internalRefs = new Map<string, string[]>();
|
||||||
const externalCount = new Map<string, number>();
|
|
||||||
|
|
||||||
OBJECT_RE.lastIndex = 0;
|
OBJECT_RE.lastIndex = 0;
|
||||||
let om: RegExpExecArray | null;
|
let om: RegExpExecArray | null;
|
||||||
@@ -305,11 +405,7 @@ async function countRenderedTriangles(
|
|||||||
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
|
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
|
||||||
const compObjectId = getAttr(cAttrs, "objectid");
|
const compObjectId = getAttr(cAttrs, "objectid");
|
||||||
if (extPath) {
|
if (extPath) {
|
||||||
if (!externalCount.has(extPath)) {
|
count += await partFiles.triangles(extPath, compObjectId);
|
||||||
const extXml = await loadExternalModel(extPath);
|
|
||||||
externalCount.set(extPath, extXml ? countTriangleTags(extXml) : 0);
|
|
||||||
}
|
|
||||||
count += externalCount.get(extPath)!;
|
|
||||||
} else if (compObjectId) {
|
} else if (compObjectId) {
|
||||||
refs.push(compObjectId);
|
refs.push(compObjectId);
|
||||||
}
|
}
|
||||||
@@ -349,7 +445,7 @@ async function parseMainModel(
|
|||||||
xml: string,
|
xml: string,
|
||||||
structural: StructuralData,
|
structural: StructuralData,
|
||||||
plateAssignmentsByName: Map<string, number>,
|
plateAssignmentsByName: Map<string, number>,
|
||||||
loadExternalModel: (path: string) => Promise<string | null>,
|
partFiles: PartFileResolver,
|
||||||
): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
|
): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
|
||||||
const objects = new Map<string, ObjectData>();
|
const objects = new Map<string, ObjectData>();
|
||||||
// <component objectid="X" .../> with no p:path references another <object> in this SAME
|
// <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;
|
const compExtruder = partKey ? structural.partExtruderMap.get(partKey) ?? defaultExtruder : defaultExtruder;
|
||||||
|
|
||||||
if (extPath) {
|
if (extPath) {
|
||||||
const extXml = await loadExternalModel(extPath);
|
for (const mesh of await partFiles.meshes(extPath, compObjectId)) {
|
||||||
if (!extXml) continue;
|
|
||||||
const extMeshes = extractMeshesFast(extXml, compExtruder);
|
|
||||||
for (const mesh of extMeshes) {
|
|
||||||
meshes.push({
|
meshes.push({
|
||||||
vertices: transform ? applyAffineToVertices(mesh.vertices, transform) : mesh.vertices,
|
vertices: transform ? applyAffineToVertices(mesh.vertices, transform) : mesh.vertices,
|
||||||
triangles: mesh.triangles,
|
triangles: mesh.triangles,
|
||||||
@@ -489,16 +582,19 @@ const MAX_TRIANGLES = 8_000_000;
|
|||||||
const MAX_MODEL_ENTRY_BYTES = 500 * 1024 * 1024;
|
const MAX_MODEL_ENTRY_BYTES = 500 * 1024 * 1024;
|
||||||
const MAX_MODEL_TOTAL_BYTES = 1024 * 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
|
/** Why a file gets no server preview: "too-complex" is over a size budget (the browser mustn't
|
||||||
* caching (no main model, no objects, or over the complexity cap) -- callers fall back to the
|
* try it either), "unsupported" is a layout this parser doesn't handle (no main model at the
|
||||||
* existing live client-side parser exactly as they do today. */
|
* usual path, no objects) -- the browser's own, more general 3MF loaders may still manage. */
|
||||||
async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
|
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(
|
const modelEntries = (await listZipEntries(srcPath)).filter(
|
||||||
(entry) => !entry.isDirectory && entry.name.toLowerCase().endsWith(".model"),
|
(entry) => !entry.isDirectory && entry.name.toLowerCase().endsWith(".model"),
|
||||||
);
|
);
|
||||||
if (!modelEntries.some((entry) => entry.name === MAIN_MODEL_PATH)) return null;
|
if (!modelEntries.some((entry) => entry.name === MAIN_MODEL_PATH)) return "unsupported";
|
||||||
if (modelEntries.some((entry) => entry.size > MAX_MODEL_ENTRY_BYTES)) return null;
|
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 null;
|
if (modelEntries.reduce((sum, entry) => sum + entry.size, 0) > MAX_MODEL_TOTAL_BYTES) return "too-complex";
|
||||||
|
|
||||||
let modelSettingsText: string | null = null;
|
let modelSettingsText: string | null = null;
|
||||||
let projectSettingsText: 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 mainModelXml: string = mainModelText;
|
||||||
|
|
||||||
const structural = modelSettingsText ? parseModelSettingsConfig(modelSettingsText) : parseModelSettingsConfig("");
|
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
|
// 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.
|
// 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);
|
const { objects, buildItems } = await parseMainModel(mainModelXml, structural, plateAssignmentsByName, partFiles);
|
||||||
if (objects.size === 0) return null;
|
if (objects.size === 0) return "unsupported";
|
||||||
|
|
||||||
const plateThumbnails = new Map<number, string>();
|
const plateThumbnails = new Map<number, string>();
|
||||||
for (const [idx, bytes] of plateThumbBytes) {
|
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) ----------------------------------------------------
|
// ---- Entry point (called from the worker) ----------------------------------------------------
|
||||||
|
|
||||||
/** Parses `srcPath` and writes the preview GLB to `destPath`. "too-complex" means the file was
|
/** Parses `srcPath` and writes the preview GLB to `destPath`, or says why it didn't (see
|
||||||
* refused before any geometry was built (no main model, no objects, or over a size budget). */
|
* PreviewRefusal) -- decided before any geometry is built. */
|
||||||
export async function renderModelPreviewGlb(srcPath: string, destPath: string): Promise<"ok" | "too-complex"> {
|
export async function renderModelPreviewGlb(srcPath: string, destPath: string): Promise<"ok" | PreviewRefusal> {
|
||||||
const parsed = await parseThreeMfFast(srcPath);
|
const parsed = await parseThreeMfFast(srcPath);
|
||||||
if (!parsed) return "too-complex";
|
if (typeof parsed === "string") return parsed;
|
||||||
const group = await buildGlbGroup(parsed);
|
const group = await buildGlbGroup(parsed);
|
||||||
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
|
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
|
||||||
const result = await new GLTFExporter().parseAsync(group, { binary: true });
|
const result = await new GLTFExporter().parseAsync(group, { binary: true });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ModelPreviewWorkerInput = { srcPath: string; destPath: string };
|
|||||||
export type ModelPreviewWorkerResult =
|
export type ModelPreviewWorkerResult =
|
||||||
| { status: "ok" }
|
| { status: "ok" }
|
||||||
| { status: "too-complex" }
|
| { status: "too-complex" }
|
||||||
|
| { status: "unsupported" }
|
||||||
| { status: "error"; error: string };
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import fsSync from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { writeZip } from "../src/utils/zipWriter";
|
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
|
// 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
|
// 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 () => {
|
afterAll(async () => {
|
||||||
for (const id of plateIds) {
|
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 });
|
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");
|
const plateId = newPlateId("fanout");
|
||||||
await generateModelPreviewGlb(plateId, fixture);
|
await generateModelPreviewGlb(plateId, fixture);
|
||||||
expect(modelPreviewGlbExists(plateId)).toBe(false);
|
expect(modelPreviewGlbExists(plateId)).toBe(false);
|
||||||
const errorPath = path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.error`);
|
const errorPath = path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.v3.error`);
|
||||||
expect(fsSync.readFileSync(errorPath, "utf-8")).toBe("permanent: unparseable-or-too-complex");
|
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 () => {
|
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);
|
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 () => {
|
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 });
|
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 () => {
|
it("kills a render that exceeds its memory budget and keeps the server process alive", async () => {
|
||||||
await generateModelPreviewGlb(plateId, fixture);
|
await generateModelPreviewGlb(plateId, fixture);
|
||||||
expect(modelPreviewGlbExists(plateId)).toBe(false);
|
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(error).toMatch(/^permanent: (memory grew|worker heap exceeded)/);
|
||||||
expect(fsSync.existsSync(path.join(cacheDir(), `${plateId}.pending`))).toBe(false);
|
expect(fsSync.existsSync(path.join(cacheDir(), `${plateId}.pending`))).toBe(false);
|
||||||
expect(fsSync.readdirSync(cacheDir()).some((f) => f.startsWith(plateId) && f.endsWith(".tmp"))).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 () => {
|
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);
|
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||||
await fs.utimes(errorPath, longAgo, longAgo);
|
await fs.utimes(errorPath, longAgo, longAgo);
|
||||||
await generateModelPreviewGlb(plateId, fixture);
|
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.
|
// Bambu X1/P1/A1's 256x256mm bed -- the stand-in for formats with no bed size of their own.
|
||||||
const DEFAULT_BED_SIZE = 256;
|
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;
|
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 rebuildBambuPlateRef = useRef<((plateId: number | null) => void) | null>(null);
|
||||||
const [viewError, setViewError] = useState<ViewErrorKey | null>(null);
|
const [viewError, setViewError] = useState<ViewErrorKey | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [waitingForServer, setWaitingForServer] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -167,6 +175,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
|||||||
const palette = paletteForTheme(theme);
|
const palette = paletteForTheme(theme);
|
||||||
setViewError(null);
|
setViewError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setWaitingForServer(false);
|
||||||
const reportError = (key: ViewErrorKey) => {
|
const reportError = (key: ViewErrorKey) => {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
setViewError(key);
|
setViewError(key);
|
||||||
@@ -466,13 +475,33 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
|||||||
|
|
||||||
const e = (ext || "").toLowerCase();
|
const e = (ext || "").toLowerCase();
|
||||||
|
|
||||||
// Tries the server pre-rendered GLB cache for a 3MF; returns true if it was used (scene
|
// Tries the server's pre-rendered GLB for a 3MF, waiting while it's still being generated.
|
||||||
// already fully set up), false if there's no cache yet (or it failed to load) and the
|
// Returns true when the server settled it -- the scene is set up, or an error is showing
|
||||||
// caller should fall back to the live parser exactly as if this function didn't exist.
|
// -- and false when the caller should parse the raw file in the browser instead (see
|
||||||
|
// CachedGlbOutcome's "fallback").
|
||||||
const tryLoadCachedGlb = async (): Promise<boolean> => {
|
const tryLoadCachedGlb = async (): Promise<boolean> => {
|
||||||
if (!previewGlbUrl) return false;
|
if (!previewGlbUrl) return false;
|
||||||
const cached = await loadCachedBambuGlb(previewGlbUrl);
|
const deadline = Date.now() + SERVER_PREVIEW_WAIT_MS;
|
||||||
if (!cached || disposed) return false;
|
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;
|
cachedGlbRoot = cached.rootGroup;
|
||||||
buildVolume = cached.buildVolume;
|
buildVolume = cached.buildVolume;
|
||||||
layoutBuildPlate();
|
layoutBuildPlate();
|
||||||
@@ -665,6 +694,11 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
|||||||
<Stack alignItems="center" spacing={1.5}>
|
<Stack alignItems="center" spacing={1.5}>
|
||||||
<BrandMark theme={theme} size="lg" />
|
<BrandMark theme={theme} size="lg" />
|
||||||
<CircularProgress size={22} />
|
<CircularProgress size={22} />
|
||||||
|
{waitingForServer && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ px: 2, textAlign: "center" }}>
|
||||||
|
{t("library:modelViewer.previewPreparing")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -684,7 +718,9 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
|
|||||||
<Typography variant="body2" fontWeight={600} color="error.light">
|
<Typography variant="body2" fontWeight={600} color="error.light">
|
||||||
{viewError === "unsupported"
|
{viewError === "unsupported"
|
||||||
? t("library:modelViewer.previewUnsupported")
|
? t("library:modelViewer.previewUnsupported")
|
||||||
: t("library:modelViewer.previewFailed")}
|
: viewError === "tooComplex"
|
||||||
|
? t("library:modelViewer.previewTooComplex")
|
||||||
|
: t("library:modelViewer.previewFailed")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -729,6 +729,8 @@
|
|||||||
"previewAlt": "Preview",
|
"previewAlt": "Preview",
|
||||||
"previewUnsupported": "Preview unsupported for this file type.",
|
"previewUnsupported": "Preview unsupported for this file type.",
|
||||||
"previewFailed": "Preview failed to load.",
|
"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...",
|
"generatingPreview": "Generating preview...",
|
||||||
"waitingToGenerate": "Waiting to generate preview...",
|
"waitingToGenerate": "Waiting to generate preview...",
|
||||||
"generatePreview": "Generate preview",
|
"generatePreview": "Generate preview",
|
||||||
|
|||||||
@@ -731,6 +731,8 @@
|
|||||||
"previewAlt": "Peržiūra",
|
"previewAlt": "Peržiūra",
|
||||||
"previewUnsupported": "Šio failo tipo peržiūra nepalaikoma.",
|
"previewUnsupported": "Šio failo tipo peržiūra nepalaikoma.",
|
||||||
"previewFailed": "Nepavyko įkelti peržiūros.",
|
"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...",
|
"generatingPreview": "Generuojama peržiūra...",
|
||||||
"waitingToGenerate": "Laukiama peržiūros generavimo...",
|
"waitingToGenerate": "Laukiama peržiūros generavimo...",
|
||||||
"generatePreview": "Generuoti peržiūrą",
|
"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;
|
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
||||||
return zipEntries[normalized];
|
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 => {
|
const loadModelFile = (path: string): Document | null => {
|
||||||
|
if (modelFileCache.has(path)) return modelFileCache.get(path) ?? null;
|
||||||
const bytes = findEntry(path);
|
const bytes = findEntry(path);
|
||||||
if (!bytes) return null;
|
const doc = bytes ? parser.parseFromString(decoder.decode(bytes), "application/xml") : null;
|
||||||
return parser.parseFromString(decoder.decode(bytes), "application/xml");
|
modelFileCache.set(path, doc);
|
||||||
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extruderMapById = new Map<string, number>();
|
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 partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
|
||||||
const compExtruder = partKey ? partExtruderMap.get(partKey) ?? defaultExtruder : defaultExtruder;
|
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 compTransformStr = compEl.getAttribute("transform");
|
||||||
const compTransform = parseTransform3MF(compTransformStr);
|
const compTransform = parseTransform3MF(compTransformStr);
|
||||||
|
|
||||||
@@ -457,14 +470,38 @@ export type CachedBambuGlb = {
|
|||||||
getPlateThumbnail: (plateIndex: number) => Promise<string | null>;
|
getPlateThumbnail: (plateIndex: number) => Promise<string | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Loads the server pre-rendered GLB for a plate (see backend/src/services/modelPreviewCache.ts)
|
/** What the server's preview endpoint said, besides a usable GLB. "generating": it's being made,
|
||||||
* instead of parsing the raw .3mf -- the fast path. Returns null for anything that doesn't look
|
* ask again shortly. "failed": the server couldn't make one (too heavy, hit its limits) -- the
|
||||||
* like a cache this app produced (wrong shape, fetch failure, load failure); callers fall back
|
* browser mustn't try either, since parsing such a file here is what exhausts its memory.
|
||||||
* to parseBambuThreeMF/loadBambuThreeMFForViewer exactly as if no cache existed yet. */
|
* "fallback": parse the raw file in the browser -- previews turned off by the admin, a 3MF
|
||||||
export async function loadCachedBambuGlb(url: string): Promise<CachedBambuGlb | null> {
|
* 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 {
|
try {
|
||||||
const res = await fetch(url);
|
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 arrayBuffer = await res.arrayBuffer();
|
||||||
|
|
||||||
const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js");
|
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) => {
|
gltf.scene.traverse((obj) => {
|
||||||
if (!root && typeof obj.userData?.thingportPreview === "string") root = 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 {
|
const meta = JSON.parse((root as THREE.Object3D).userData.thingportPreview) as {
|
||||||
plates: PlateSummary[];
|
plates: PlateSummary[];
|
||||||
@@ -486,15 +523,18 @@ export async function loadCachedBambuGlb(url: string): Promise<CachedBambuGlb |
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rootGroup: root as THREE.Group,
|
status: "ready",
|
||||||
plates: meta.plates,
|
glb: {
|
||||||
filamentColors: meta.filamentColors,
|
rootGroup: root as THREE.Group,
|
||||||
buildVolume: meta.buildVolume,
|
plates: meta.plates,
|
||||||
getPlateThumbnail: async (plateIndex: number) => meta.plateThumbnails[String(plateIndex)] ?? null,
|
filamentColors: meta.filamentColors,
|
||||||
|
buildVolume: meta.buildVolume,
|
||||||
|
getPlateThumbnail: async (plateIndex: number) => meta.plateThumbnails[String(plateIndex)] ?? null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Cached GLB preview load failed, falling back to live 3MF parse", 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