mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix: importing models due preview generation uses all resources
This commit is contained in:
1 parent
057fbf06d3
commit
355833ca24
4 files changed
+267
-19
No files matched your search
@@ -19,6 +19,7 @@ import { availablePlateFilename, plateThumbPath, relocatePrint, saveThumbFromByt
|
||||
import { addGeneratedPreviewImageIfNone } from "../services/previewImageService";
|
||||
import { printOutById } from "../services/printLoader";
|
||||
import { generateModelPreviewGlb, modelPreviewGlbPath } from "../services/modelPreviewCache";
|
||||
import { getPreviewMode } from "../services/settingsService";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -171,7 +172,10 @@ router.get(
|
||||
// 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.
|
||||
const srcPath = plate.filename.toLowerCase().endsWith(".3mf") ? resolvePlateFilePath(plate) : null;
|
||||
// "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");
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as cheerio from "cheerio";
|
||||
import { MODEL_PREVIEWS, IMPORT_MAX_BYTES } from "../config";
|
||||
import { readZipEntry, walkZipEntries } from "../utils/zipReader";
|
||||
import { MODEL_PREVIEWS } from "../config";
|
||||
import { listZipEntries, readZipEntry, walkZipEntries } from "../utils/zipReader";
|
||||
|
||||
// Generates the cached GLB the interactive 3D preview loads instead of re-parsing a raw .3mf
|
||||
// on every open (see frontend/src/utils/bambuThreeMf.ts, whose *rules* this file mirrors -- same
|
||||
@@ -260,6 +260,90 @@ const ITEM_RE = /<item\b([^>]*)\/?>/g;
|
||||
|
||||
type InternalComponentRef = { refId: string; transform: number[] | null; extruder: number };
|
||||
|
||||
const MAX_COMPONENT_DEPTH = 8;
|
||||
|
||||
/** Counts <triangle .../> tags without the per-match array a `match(/.../g)` would allocate
|
||||
* (millions of strings for a big model). "<triangles>" (the container) is skipped. */
|
||||
function countTriangleTags(xml: string): number {
|
||||
let count = 0;
|
||||
let i = xml.indexOf("<triangle");
|
||||
while (i !== -1) {
|
||||
const next = xml.charCodeAt(i + 9);
|
||||
// whitespace, "/" or ">" -- anything else is "<triangles" or some other tag
|
||||
if (next === 32 || next === 9 || next === 10 || next === 13 || next === 47 || next === 62) count++;
|
||||
i = xml.indexOf("<triangle", i + 9);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Triangles the preview would actually materialize: every build item's object, including
|
||||
* geometry pulled in through p:path components (where Bambu files keep all their meshes) and
|
||||
* same-document component references, counted once per placement -- the same expansion
|
||||
* parseMainModel + buildGlbGroup perform, but on tag counts instead of real arrays. Checked
|
||||
* before any mesh is parsed so an over-budget file never allocates its geometry. */
|
||||
async function countRenderedTriangles(
|
||||
xml: string,
|
||||
loadExternalModel: (path: string) => Promise<string | null>,
|
||||
): Promise<number> {
|
||||
const ownCount = new Map<string, number>();
|
||||
const internalRefs = new Map<string, string[]>();
|
||||
const externalCount = new Map<string, number>();
|
||||
|
||||
OBJECT_RE.lastIndex = 0;
|
||||
let om: RegExpExecArray | null;
|
||||
while ((om = OBJECT_RE.exec(xml))) {
|
||||
const objectId = getAttr(om[1], "id");
|
||||
if (!objectId) continue;
|
||||
const inner = om[2];
|
||||
let count = countTriangleTags(inner);
|
||||
const refs: string[] = [];
|
||||
COMPONENT_RE.lastIndex = 0;
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = COMPONENT_RE.exec(inner))) {
|
||||
const cAttrs = cm[1];
|
||||
const extPath = getAttr(cAttrs, "p:path") ?? getAttr(cAttrs, "path");
|
||||
const compObjectId = getAttr(cAttrs, "objectid");
|
||||
if (extPath) {
|
||||
if (!externalCount.has(extPath)) {
|
||||
const extXml = await loadExternalModel(extPath);
|
||||
externalCount.set(extPath, extXml ? countTriangleTags(extXml) : 0);
|
||||
}
|
||||
count += externalCount.get(extPath)!;
|
||||
} else if (compObjectId) {
|
||||
refs.push(compObjectId);
|
||||
}
|
||||
}
|
||||
ownCount.set(objectId, count);
|
||||
if (refs.length > 0) internalRefs.set(objectId, refs);
|
||||
}
|
||||
|
||||
// Memoized by (object, depth) so a pathological fan-out can't make the count itself explode.
|
||||
const memo = new Map<string, number>();
|
||||
const totalFor = (objectId: string, depth: number): number => {
|
||||
const key = `${objectId}:${depth}`;
|
||||
const cached = memo.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
let total = ownCount.get(objectId) ?? 0;
|
||||
if (depth < MAX_COMPONENT_DEPTH) {
|
||||
for (const ref of internalRefs.get(objectId) ?? []) total += totalFor(ref, depth + 1);
|
||||
}
|
||||
memo.set(key, total);
|
||||
return total;
|
||||
};
|
||||
|
||||
let rendered = 0;
|
||||
const buildMatch = xml.match(/<build\b[\s\S]*?<\/build>/);
|
||||
if (buildMatch) {
|
||||
ITEM_RE.lastIndex = 0;
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = ITEM_RE.exec(buildMatch[0]))) {
|
||||
const objectId = getAttr(im[1], "objectid");
|
||||
if (objectId) rendered += totalFor(objectId, 0);
|
||||
}
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
async function parseMainModel(
|
||||
xml: string,
|
||||
structural: StructuralData,
|
||||
@@ -330,7 +414,6 @@ async function parseMainModel(
|
||||
// A bounded depth guard (rather than a visited-set) is enough protection against a malformed/
|
||||
// cyclic file without needing per-object cycle bookkeeping -- real Bambu wrapper chains are
|
||||
// one level deep.
|
||||
const MAX_COMPONENT_DEPTH = 8;
|
||||
const resolveInternalRefs = (objectId: string, depth: number): FastMesh[] => {
|
||||
const refs = internalRefsByObjectId.get(objectId);
|
||||
if (!refs || depth >= MAX_COMPONENT_DEPTH) return [];
|
||||
@@ -349,9 +432,16 @@ async function parseMainModel(
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
// Resolve everything first, append after: appending as we go would put an inner object's
|
||||
// already-resolved meshes into target.meshes, and an outer object referencing it would then
|
||||
// pick them up twice (once from target.meshes, once from recursing) -- doubling per level.
|
||||
const resolvedByObjectId = new Map<string, FastMesh[]>();
|
||||
for (const objectId of internalRefsByObjectId.keys()) {
|
||||
resolvedByObjectId.set(objectId, resolveInternalRefs(objectId, 0));
|
||||
}
|
||||
for (const [objectId, resolved] of resolvedByObjectId) {
|
||||
const object = objects.get(objectId);
|
||||
if (object) object.meshes.push(...resolveInternalRefs(objectId, 0));
|
||||
if (object) for (const mesh of resolved) object.meshes.push(mesh);
|
||||
}
|
||||
|
||||
const buildItems: BuildItem[] = [];
|
||||
@@ -388,12 +478,27 @@ type ParsedModel = {
|
||||
};
|
||||
|
||||
const MAIN_MODEL_PATH = "3D/3dmodel.model";
|
||||
const MAX_TRIANGLES = 8_000_000; // generous safety cap -- see generateModelPreviewGlb
|
||||
// Rendered triangles, i.e. after expanding external/internal components and every build-item
|
||||
// placement (see countRenderedTriangles) -- not just tags in the main model, which for a Bambu
|
||||
// file (meshes live in 3D/Objects/*.model) is close to zero no matter how heavy the model is.
|
||||
const MAX_TRIANGLES = 8_000_000;
|
||||
// Model XML is read whole into strings; V8 can't hold a single string past ~512MB anyway, and the
|
||||
// sum bounds how much the parse holds at once. Checked from the zip's central directory before
|
||||
// anything is decompressed.
|
||||
const MAX_MODEL_ENTRY_BYTES = 500 * 1024 * 1024;
|
||||
const MAX_MODEL_TOTAL_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
/** Reads and fast-parses a Bambu Studio project .3mf. Returns null for anything not worth
|
||||
* caching (no main model, no objects, or over the complexity cap) -- callers fall back to the
|
||||
* existing live client-side parser exactly as they do today. */
|
||||
async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
|
||||
const modelEntries = (await listZipEntries(srcPath)).filter(
|
||||
(entry) => !entry.isDirectory && entry.name.toLowerCase().endsWith(".model"),
|
||||
);
|
||||
if (!modelEntries.some((entry) => entry.name === MAIN_MODEL_PATH)) return null;
|
||||
if (modelEntries.some((entry) => entry.size > MAX_MODEL_ENTRY_BYTES)) return null;
|
||||
if (modelEntries.reduce((sum, entry) => sum + entry.size, 0) > MAX_MODEL_TOTAL_BYTES) return null;
|
||||
|
||||
let modelSettingsText: string | null = null;
|
||||
let projectSettingsText: string | null = null;
|
||||
let mainModelText: string | null = null;
|
||||
@@ -454,21 +559,20 @@ async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Quick complexity check before doing the real work: a single fast pass counting triangle
|
||||
// tags, so a pathological file fails immediately instead of after minutes of parsing.
|
||||
const triangleCount = (mainModelXml.match(/<triangle\b/g) || []).length;
|
||||
if (triangleCount > MAX_TRIANGLES) return null;
|
||||
|
||||
const externalModelCache = new Map<string, string | null>();
|
||||
const loadExternalModel = async (rawPath: string): Promise<string | null> => {
|
||||
const normalized = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
|
||||
if (externalModelCache.has(normalized)) return externalModelCache.get(normalized) ?? null;
|
||||
const buf = await readZipEntry(srcPath, normalized, IMPORT_MAX_BYTES);
|
||||
const buf = await readZipEntry(srcPath, normalized, MAX_MODEL_ENTRY_BYTES);
|
||||
const text = buf ? buf.toString("utf-8") : null;
|
||||
externalModelCache.set(normalized, text);
|
||||
return text;
|
||||
};
|
||||
|
||||
// Complexity check before any geometry is allocated, so an over-budget file fails after a few
|
||||
// string scans instead of after exhausting the host's memory.
|
||||
if ((await countRenderedTriangles(mainModelXml, loadExternalModel)) > MAX_TRIANGLES) return null;
|
||||
|
||||
const { objects, buildItems } = await parseMainModel(mainModelXml, structural, plateAssignmentsByName, loadExternalModel);
|
||||
if (objects.size === 0) return null;
|
||||
|
||||
@@ -535,7 +639,7 @@ async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(swapped, 3));
|
||||
geometry.setIndex(Array.from(mesh.triangles));
|
||||
geometry.setIndex(new THREE.BufferAttribute(mesh.triangles, 1));
|
||||
geometry.computeVertexNormals();
|
||||
if (!geometriesByExtruder.has(mesh.extruder)) geometriesByExtruder.set(mesh.extruder, []);
|
||||
geometriesByExtruder.get(mesh.extruder)!.push(geometry);
|
||||
@@ -606,6 +710,13 @@ function modelPreviewErrorPath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.error`);
|
||||
}
|
||||
|
||||
/** Written before generation starts and removed when it ends (either way). Still being there
|
||||
* with nothing in flight means the process died mid-generation (OOM kill, container restart), so
|
||||
* that plate is never retried automatically -- delete this file to allow another attempt. */
|
||||
function modelPreviewPendingPath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.pending`);
|
||||
}
|
||||
|
||||
export function modelPreviewGlbExists(plateId: string): boolean {
|
||||
return fsSync.existsSync(modelPreviewGlbPath(plateId));
|
||||
}
|
||||
@@ -622,14 +733,38 @@ function recentlyFailed(plateId: string): boolean {
|
||||
}
|
||||
|
||||
const inFlight = new Set<string>();
|
||||
const crashWarned = new Set<string>();
|
||||
|
||||
// One generation at a time, process-wide: a multi-profile import creates several .3mf plates at
|
||||
// once, and running their parses in parallel multiplies peak memory for no real gain (the work is
|
||||
// CPU-bound on the same event loop either way).
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Generates (or refuses to, gracefully) the cached GLB for one plate. Always resolves --
|
||||
* never throws -- so callers can fire-and-forget it without a .catch(). Safe to call
|
||||
* concurrently for the same plateId (subsequent calls no-op while one is already running). */
|
||||
* concurrently for the same plateId (subsequent calls no-op while one is queued or running). */
|
||||
export async function generateModelPreviewGlb(plateId: string, srcPath: string): Promise<void> {
|
||||
if (modelPreviewGlbExists(plateId) || inFlight.has(plateId) || recentlyFailed(plateId)) return;
|
||||
if (fsSync.existsSync(modelPreviewPendingPath(plateId))) {
|
||||
if (!crashWarned.has(plateId)) {
|
||||
crashWarned.add(plateId);
|
||||
console.warn(
|
||||
`Model preview for plate ${plateId} was interrupted by a previous crash; not retrying. ` +
|
||||
`Delete ${modelPreviewPendingPath(plateId)} to allow another attempt.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
inFlight.add(plateId);
|
||||
const run = queue.then(() => runGeneration(plateId, srcPath));
|
||||
queue = run;
|
||||
await run;
|
||||
}
|
||||
|
||||
async function runGeneration(plateId: string, srcPath: string): Promise<void> {
|
||||
const pendingPath = modelPreviewPendingPath(plateId);
|
||||
try {
|
||||
await fs.writeFile(pendingPath, new Date().toISOString());
|
||||
const parsed = await parseThreeMfFast(srcPath);
|
||||
if (!parsed) {
|
||||
await fs.writeFile(modelPreviewErrorPath(plateId), "unparseable-or-too-complex");
|
||||
@@ -649,6 +784,7 @@ export async function generateModelPreviewGlb(plateId: string, srcPath: string):
|
||||
console.error(`Model preview generation failed for plate ${plateId}:`, err);
|
||||
await fs.writeFile(modelPreviewErrorPath(plateId), String(err)).catch(() => undefined);
|
||||
} finally {
|
||||
await fs.rm(pendingPath, { force: true }).catch(() => undefined);
|
||||
inFlight.delete(plateId);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
saveThumbFromFile,
|
||||
} from "./printService";
|
||||
import { generateModelPreviewGlb } from "./modelPreviewCache";
|
||||
import { getPreviewMode } from "./settingsService";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Plate, Print } from "@prisma/client";
|
||||
|
||||
@@ -91,11 +92,11 @@ async function thumbnailAndSniff(plateId: string, filename: string, mime: string
|
||||
await saveThumbFromFile(plateId, effectivePath);
|
||||
} else if (ext === ".3mf") {
|
||||
await ensurePlateThumbnail(plateId, effectivePath);
|
||||
// Not awaited: pre-rendering the interactive 3D preview can take real time for a large/
|
||||
// high-poly model, and doing it here would block the upload/import response on that. It
|
||||
// runs in the background and self-heals (see routes/plates.ts's preview.glb route) if this
|
||||
// fires before the process is ready or somehow never completes.
|
||||
void generateModelPreviewGlb(plateId, effectivePath);
|
||||
// Only "automatic" pre-renders the interactive 3D preview at import; "on-demand" leaves it to
|
||||
// the first viewer open (routes/plates.ts's preview.glb self-heal) and "disabled" never builds
|
||||
// one. Not awaited: it can take real time for a large/high-poly model, and doing it here would
|
||||
// block the upload/import response on that.
|
||||
if ((await getPreviewMode()) === "automatic") void generateModelPreviewGlb(plateId, effectivePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,3 +280,110 @@ describe("modelPreviewCache -- internal <component> references", () => {
|
||||
expect(meshes["extruder-0"].positions).toEqual([5, 0, 0, 15, 0, 0, 5, 0, -10]);
|
||||
});
|
||||
});
|
||||
|
||||
async function buildModelOnly3mf(destPath: string, modelXml: string): Promise<void> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-model-only-"));
|
||||
const modelPath = path.join(dir, "3dmodel.model");
|
||||
await fs.writeFile(modelPath, modelXml);
|
||||
await writeZip(destPath, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]);
|
||||
}
|
||||
|
||||
const ONE_TRIANGLE_MESH = `<mesh><vertices><vertex x="0" y="0" z="0" /><vertex x="10" y="0" z="0" /><vertex x="0" y="10" z="0" /></vertices><triangles><triangle v1="0" v2="1" v3="2" /></triangles></mesh>`;
|
||||
|
||||
function singleItemModelXml(objects: string, buildItemObjectId: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" unit="millimeter">
|
||||
<resources>${objects}</resources>
|
||||
<build><item objectid="${buildItemObjectId}" /></build>
|
||||
</model>
|
||||
`;
|
||||
}
|
||||
|
||||
/** A <components> block referencing `refId` 20 times. */
|
||||
function fanOut(refId: string): string {
|
||||
return `<components>${Array.from({ length: 20 }, () => `<component objectid="${refId}" />`).join("")}</components>`;
|
||||
}
|
||||
|
||||
describe("modelPreviewCache -- memory safety", () => {
|
||||
let outDir: string;
|
||||
const plateIds: string[] = [];
|
||||
const newPlateId = (label: string) => {
|
||||
const id = `test-fixture-${label}-${Date.now()}`;
|
||||
plateIds.push(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.mkdir(path.dirname(modelPreviewGlbPath("x")), { recursive: true });
|
||||
outDir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-safety-out-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of plateIds) {
|
||||
for (const ext of [".v2.glb", ".error", ".pending"]) {
|
||||
await fs.rm(path.join(path.dirname(modelPreviewGlbPath("x")), `${id}${ext}`), { force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a file whose rendered triangle count explodes through nested component references", async () => {
|
||||
// One triangle, referenced 20x per level, six levels deep: 64M rendered triangles from a
|
||||
// few-KB file. Must bail on the count, not by materializing the geometry.
|
||||
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(outDir, "fanout.3mf");
|
||||
await buildModelOnly3mf(fixture, singleItemModelXml(objects, "7"));
|
||||
|
||||
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("unparseable-or-too-complex");
|
||||
});
|
||||
|
||||
it("resolves a two-level wrapper chain to exactly one copy of the geometry", async () => {
|
||||
const objects =
|
||||
`<object id="1" type="model">${ONE_TRIANGLE_MESH}</object>` +
|
||||
`<object id="2" type="model"><components><component objectid="1" /></components></object>` +
|
||||
`<object id="3" type="model"><components><component objectid="2" /></components></object>`;
|
||||
const fixture = path.join(outDir, "chain.3mf");
|
||||
await buildModelOnly3mf(fixture, singleItemModelXml(objects, "3"));
|
||||
|
||||
const plateId = newPlateId("chain");
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewGlbExists(plateId)).toBe(true);
|
||||
|
||||
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);
|
||||
});
|
||||
expect(meshes).toHaveLength(1);
|
||||
expect(positionsOf(meshes[0])).toEqual([0, 0, 0, 10, 0, 0, 0, 0, -10]);
|
||||
});
|
||||
|
||||
it("never retries a plate whose previous generation died mid-run", async () => {
|
||||
const fixture = path.join(outDir, "crashed.3mf");
|
||||
await buildModelOnly3mf(fixture, singleItemModelXml(`<object id="1" type="model">${ONE_TRIANGLE_MESH}</object>`, "1"));
|
||||
|
||||
const plateId = newPlateId("crashed");
|
||||
await fs.writeFile(path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.pending`), "left by a killed process");
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewGlbExists(plateId)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes its in-progress marker once generation finishes", async () => {
|
||||
const fixture = path.join(outDir, "ok.3mf");
|
||||
await buildModelOnly3mf(fixture, singleItemModelXml(`<object id="1" type="model">${ONE_TRIANGLE_MESH}</object>`, "1"));
|
||||
|
||||
const plateId = newPlateId("ok");
|
||||
await generateModelPreviewGlb(plateId, fixture);
|
||||
expect(modelPreviewGlbExists(plateId)).toBe(true);
|
||||
expect(fsSync.existsSync(path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.pending`))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user