mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 22:52:25 -05:00
feat: add sanitized bambu 3mf open option
This commit is contained in:
1 parent
9549148dfd
commit
0d8cd62fa7
24 files changed
+2463
-28
No files matched your search
Generated
+89
-1
@@ -16,6 +16,7 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.2",
|
||||
"meshoptimizer": "^1.3.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^2.0.1",
|
||||
@@ -45,7 +46,7 @@
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=20.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
@@ -2389,6 +2390,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -3076,6 +3083,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -3091,6 +3104,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -3119,6 +3138,48 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
|
||||
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -3140,6 +3201,15 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -3720,6 +3790,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
@@ -3865,6 +3941,12 @@
|
||||
"fsevents": "2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -4061,6 +4143,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.2",
|
||||
"meshoptimizer": "^1.3.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^2.0.1",
|
||||
|
||||
@@ -36,6 +36,10 @@ export const THUMBS = path.join(STORAGE, "thumbs");
|
||||
export const BUNDLES = path.join(STORAGE, "bundles");
|
||||
export const PREVIEWS = path.join(STORAGE, "previews");
|
||||
export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews");
|
||||
// Slicer-compatible copies of MakerWorld 3MFs, rebuilt when the source plate changes.
|
||||
export const SANITIZED_3MFS = path.join(STORAGE, "sanitized-3mf");
|
||||
// Large multi-part projects take minutes on a slow CPU; the page waits for it, not the request.
|
||||
export const SANITIZE_3MF_TIMEOUT_SECONDS = envInt("SANITIZE_3MF_TIMEOUT_SECONDS", 600);
|
||||
// Past either limit the render worker is killed and the plate gets no 3D preview. Memory is a
|
||||
// watchdog check, so a fast burst can overshoot it briefly.
|
||||
export const MODEL_PREVIEW_MAX_MEMORY_MB = envInt("MODEL_PREVIEW_MAX_MEMORY_MB", defaultPreviewMemoryMb());
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { STORAGE, THUMBS, BUNDLES, PREVIEWS, MODEL_PREVIEWS } from "./config";
|
||||
import { STORAGE, THUMBS, BUNDLES, PREVIEWS, MODEL_PREVIEWS, SANITIZED_3MFS } from "./config";
|
||||
|
||||
for (const dir of [STORAGE, THUMBS, BUNDLES, PREVIEWS, MODEL_PREVIEWS]) {
|
||||
for (const dir of [STORAGE, THUMBS, BUNDLES, PREVIEWS, MODEL_PREVIEWS, SANITIZED_3MFS]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { RENDERABLE_MODEL_EXTS } from "../config";
|
||||
import { estimateDownloadSize, resolvePrintsForDownload, sendPrintsZip } from "../services/downloadZip";
|
||||
import { systemCollectionKeyForId } from "../services/collectionService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { isSanitizable3mf, sanitize3mfStatus, sanitized3mfFor } from "../services/sanitized3mfCache";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const router = Router();
|
||||
@@ -383,10 +384,15 @@ router.get(
|
||||
asyncHandler(async (req, res) => {
|
||||
const plate = await prisma.plate.findFirst({
|
||||
where: { id: req.params.plateId, printId: req.params.id, print: { userId: req.userId } },
|
||||
include: { print: { select: { preparedMetadata: true } } },
|
||||
});
|
||||
if (!plate) throw new HttpError(404, "Not found");
|
||||
const filePath = resolvePlateFilePath(plate);
|
||||
let filePath = resolvePlateFilePath(plate);
|
||||
if (!filePath) throw new HttpError(404, "Not found");
|
||||
// Only "Open sanitized in <slicer>" asks for it; a plate that is itself a sliced print is never touched.
|
||||
if (req.query.sanitize === "1" && plate.print.preparedMetadata === null && isSanitizable3mf(plate.filename)) {
|
||||
filePath = (await sanitized3mfFor(plate.id, filePath)) ?? filePath;
|
||||
}
|
||||
res.setHeader("Content-Type", plate.mime || "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(plate.filename)}"`);
|
||||
res.setHeader("Cache-Control", "private, max-age=86400");
|
||||
@@ -394,6 +400,24 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
// Polled by "Open sanitized in <slicer>" until the copy is ready, so no request outlasts a proxy timeout.
|
||||
router.post(
|
||||
"/print/:id/plate/:plateId/sanitize",
|
||||
asyncHandler(async (req, res) => {
|
||||
const plate = await prisma.plate.findFirst({
|
||||
where: { id: req.params.plateId, printId: req.params.id, print: { userId: req.userId } },
|
||||
include: { print: { select: { preparedMetadata: true } } },
|
||||
});
|
||||
if (!plate) throw new HttpError(404, "Not found");
|
||||
if (plate.print.preparedMetadata !== null || !isSanitizable3mf(plate.filename)) {
|
||||
throw new HttpError(400, "Only a plain 3MF project can be sanitized.");
|
||||
}
|
||||
const filePath = resolvePlateFilePath(plate);
|
||||
if (!filePath) throw new HttpError(404, "Not found");
|
||||
res.json({ status: await sanitize3mfStatus(plate.id, filePath) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/print/:id/thumb.jpg",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { MODEL_PREVIEWS, MODEL_PREVIEW_MAX_MEMORY_MB, MODEL_PREVIEW_TIMEOUT_SECONDS } from "../config";
|
||||
import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelPreviewWorker";
|
||||
import { getSimplifyPreviews } from "./settingsService";
|
||||
import { workerBootstrap } from "../utils/workerBootstrap";
|
||||
|
||||
// Renders run in a worker thread under memory and time limits, so a pathological .3mf can't take
|
||||
// down the server.
|
||||
@@ -57,12 +57,7 @@ function recentlyFailed(plateId: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Under tsx (dev, vitest) the worker needs tsx's hook registered first: workers don't inherit the
|
||||
// parent's loader, and on Node 20 `--import tsx` doesn't apply to the entry file.
|
||||
const WORKER_FILE = path.join(__dirname, `modelPreviewWorker${path.extname(__filename)}`);
|
||||
const WORKER_BOOTSTRAP = WORKER_FILE.endsWith(".ts")
|
||||
? `require(${JSON.stringify(createRequire(__filename).resolve("tsx/cjs"))}); require(${JSON.stringify(WORKER_FILE)});`
|
||||
: `require(${JSON.stringify(WORKER_FILE)});`;
|
||||
const WORKER_BOOTSTRAP = workerBootstrap(__dirname, "modelPreviewWorker");
|
||||
|
||||
const MEMORY_POLL_MS = 250;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
saveThumbFromFile,
|
||||
} from "./printService";
|
||||
import { generateModelPreviewGlb } from "./modelPreviewCache";
|
||||
import { deleteSanitized3mf } from "./sanitized3mfCache";
|
||||
import { getPreviewMode } from "./settingsService";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Plate, Print } from "@prisma/client";
|
||||
@@ -228,7 +229,8 @@ export async function addPlatesToPrint(
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function deletePlateFiles(plate: Pick<Plate, "storagePath">): Promise<void> {
|
||||
export async function deletePlateFiles(plate: Pick<Plate, "id" | "storagePath">): Promise<void> {
|
||||
await deleteSanitized3mf(plate.id);
|
||||
const abs = path.join(STORAGE, plate.storagePath);
|
||||
try {
|
||||
await fs.rm(abs, { force: true });
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { writeSanitized3mf } from "./threeMfSanitizer";
|
||||
|
||||
// Worker entry point for one sanitize. Posts exactly one message, then exits.
|
||||
|
||||
export type Sanitize3mfWorkerInput = { srcPath: string; destPath: string };
|
||||
export type Sanitize3mfWorkerResult = { status: "ok" } | { status: "error"; error: string };
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const { srcPath, destPath } = workerData as Sanitize3mfWorkerInput;
|
||||
let result: Sanitize3mfWorkerResult;
|
||||
try {
|
||||
await writeSanitized3mf(await fs.readFile(srcPath), destPath);
|
||||
result = { status: "ok" };
|
||||
} catch (err) {
|
||||
result = { status: "error", error: err instanceof Error ? (err.stack ?? err.message) : String(err) };
|
||||
}
|
||||
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- a worker_threads port, not window.postMessage.
|
||||
parentPort?.postMessage(result);
|
||||
}
|
||||
|
||||
void run();
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { MODEL_PREVIEW_MAX_MEMORY_MB, SANITIZE_3MF_TIMEOUT_SECONDS, SANITIZED_3MFS } from "../config";
|
||||
import { workerBootstrap } from "../utils/workerBootstrap";
|
||||
import type { Sanitize3mfWorkerInput, Sanitize3mfWorkerResult } from "./sanitize3mfWorker";
|
||||
|
||||
// Part of the cache filename, so bumping it rebuilds older copies on next request.
|
||||
const SANITIZE_FORMAT_VERSION = 1;
|
||||
|
||||
const WORKER_BOOTSTRAP = workerBootstrap(__dirname, "sanitize3mfWorker");
|
||||
|
||||
function sanitizedPath(plateId: string): string {
|
||||
return path.join(SANITIZED_3MFS, `${plateId}.v${SANITIZE_FORMAT_VERSION}.3mf`);
|
||||
}
|
||||
|
||||
// Marks a source the sanitizer rejected, so each request doesn't redo the work to fail again.
|
||||
function errorPath(plateId: string): string {
|
||||
return path.join(SANITIZED_3MFS, `${plateId}.v${SANITIZE_FORMAT_VERSION}.error`);
|
||||
}
|
||||
|
||||
/** Sliced .gcode.3mf plates are left alone: dropping their G-code would leave nothing to print. */
|
||||
export function isSanitizable3mf(filename: string): boolean {
|
||||
const lower = filename.toLowerCase();
|
||||
return lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf");
|
||||
}
|
||||
|
||||
async function isFresh(file: string, srcMtimeMs: number): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(file)).mtimeMs >= srcMtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeInWorker(input: Sanitize3mfWorkerInput): Promise<Sanitize3mfWorkerResult> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(WORKER_BOOTSTRAP, {
|
||||
eval: true,
|
||||
workerData: input,
|
||||
resourceLimits: { maxOldGenerationSizeMb: MODEL_PREVIEW_MAX_MEMORY_MB },
|
||||
});
|
||||
let settled = false;
|
||||
const finish = (result: Sanitize3mfWorkerResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
worker.terminate().then(
|
||||
() => resolve(result),
|
||||
() => resolve(result),
|
||||
);
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => finish({ status: "error", error: `took longer than ${SANITIZE_3MF_TIMEOUT_SECONDS}s` }),
|
||||
SANITIZE_3MF_TIMEOUT_SECONDS * 1000,
|
||||
);
|
||||
worker.once("message", finish);
|
||||
worker.once("error", (err) => finish({ status: "error", error: err.stack ?? String(err) }));
|
||||
worker.once("exit", (code) => finish({ status: "error", error: `worker exited (code ${code}) without a result` }));
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSanitized(plateId: string, srcPath: string): Promise<string | null> {
|
||||
const dest = sanitizedPath(plateId);
|
||||
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.mkdir(SANITIZED_3MFS, { recursive: true });
|
||||
try {
|
||||
const result = await sanitizeInWorker({ srcPath, destPath: tmp });
|
||||
if (result.status === "ok") {
|
||||
await fs.rename(tmp, dest);
|
||||
await fs.rm(errorPath(plateId), { force: true });
|
||||
return dest;
|
||||
}
|
||||
console.warn(`Sanitizing 3MF for plate ${plateId} failed; serving the original:`, result.error);
|
||||
await fs.writeFile(errorPath(plateId), result.error);
|
||||
return null;
|
||||
} finally {
|
||||
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, Promise<string | null>>();
|
||||
// One at a time: a large 3MF is held in memory several times over while it's rewritten.
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// A failure is retried after this, so a timeout on a busy server doesn't block the plate for good.
|
||||
const ERROR_RETRY_MS = 60 * 60 * 1000;
|
||||
|
||||
async function recentlyFailed(plateId: string, srcMtimeMs: number): Promise<boolean> {
|
||||
try {
|
||||
const { mtimeMs } = await fs.stat(errorPath(plateId));
|
||||
return mtimeMs >= srcMtimeMs && Date.now() - mtimeMs < ERROR_RETRY_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function startJob(plateId: string, srcPath: string): Promise<string | null> {
|
||||
let pending = inFlight.get(plateId);
|
||||
if (!pending) {
|
||||
pending = queue.then(() => buildSanitized(plateId, srcPath));
|
||||
queue = pending.catch(() => undefined);
|
||||
inFlight.set(plateId, pending);
|
||||
void pending.finally(() => inFlight.delete(plateId)).catch(() => undefined);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export type Sanitize3mfStatus = "ready" | "preparing" | "failed";
|
||||
|
||||
/** Starts a sanitize in the background when there's no usable copy yet. Never throws. */
|
||||
export async function sanitize3mfStatus(plateId: string, srcPath: string): Promise<Sanitize3mfStatus> {
|
||||
try {
|
||||
const { mtimeMs } = await fs.stat(srcPath);
|
||||
if (await isFresh(sanitizedPath(plateId), mtimeMs)) return "ready";
|
||||
if (inFlight.has(plateId)) return "preparing";
|
||||
if (await recentlyFailed(plateId, mtimeMs)) return "failed";
|
||||
void startJob(plateId, srcPath).catch(() => undefined);
|
||||
return "preparing";
|
||||
} catch (err) {
|
||||
console.error(`Sanitizing 3MF for plate ${plateId} failed:`, err);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/** Path to a slicer-compatible copy of the plate's 3MF, waiting for one if needed, or null to serve
|
||||
* the original. Never throws. */
|
||||
export async function sanitized3mfFor(plateId: string, srcPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { mtimeMs } = await fs.stat(srcPath);
|
||||
if (await isFresh(sanitizedPath(plateId), mtimeMs)) return sanitizedPath(plateId);
|
||||
if (!inFlight.has(plateId) && (await recentlyFailed(plateId, mtimeMs))) return null;
|
||||
return await startJob(plateId, srcPath);
|
||||
} catch (err) {
|
||||
console.error(`Sanitizing 3MF for plate ${plateId} failed:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSanitized3mf(plateId: string): Promise<void> {
|
||||
await fs.rm(sanitizedPath(plateId), { force: true }).catch(() => undefined);
|
||||
await fs.rm(errorPath(plateId), { force: true }).catch(() => undefined);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
/** Source for `new Worker(src, { eval: true })` that loads `name` from `dir`. Under tsx (dev, vitest)
|
||||
* the worker needs tsx's hook registered first: workers don't inherit the parent's loader, and on
|
||||
* Node 20 `--import tsx` doesn't apply to the entry file. */
|
||||
export function workerBootstrap(dir: string, name: string): string {
|
||||
const ext = path.extname(__filename);
|
||||
const file = JSON.stringify(path.join(dir, `${name}${ext}`));
|
||||
if (ext !== ".ts") return `require(${file});`;
|
||||
return `require(${JSON.stringify(createRequire(__filename).resolve("tsx/cjs"))}); require(${file});`;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createApp } from "../src/app";
|
||||
|
||||
const app = createApp();
|
||||
let token: string;
|
||||
let printId: string;
|
||||
let fileUrl: string;
|
||||
|
||||
function auth(req: request.Test) {
|
||||
return req.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
async function bambuProject(): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"3D/3dmodel.model",
|
||||
`<?xml version="1.0"?>
|
||||
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p">
|
||||
<metadata name="Application">BambuStudio-02.07.01.57</metadata>
|
||||
<resources>
|
||||
<object id="2" type="model"><components><component p:path="/3D/Objects/object_1.model" objectid="1"/></components></object>
|
||||
</resources>
|
||||
<build><item objectid="2" transform="1 0 0 0 1 0 0 0 1 10 10 0" printable="1"/></build>
|
||||
</model>`,
|
||||
);
|
||||
zip.file(
|
||||
"3D/Objects/object_1.model",
|
||||
`<?xml version="1.0"?>
|
||||
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
|
||||
<resources>
|
||||
<object id="1" type="model"><mesh>
|
||||
<vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/><vertex x="0" y="0" z="1"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/><triangle v1="0" v2="1" v3="3"/><triangle v1="0" v2="2" v3="3"/><triangle v1="1" v2="2" v3="3"/></triangles>
|
||||
</mesh></object>
|
||||
</resources>
|
||||
<build/>
|
||||
</model>`,
|
||||
);
|
||||
zip.file("Metadata/project_settings.config", JSON.stringify({ wall_loops: "3", filament_colour: ["#FF0000"] }));
|
||||
return zip.generateAsync({ type: "nodebuffer" });
|
||||
}
|
||||
|
||||
async function fetchApplication(query = "?sanitize=1"): Promise<string> {
|
||||
const res = await auth(request(app).get(`/api${fileUrl}${query}`))
|
||||
.buffer(true)
|
||||
.parse((r, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
r.on("data", (c: Buffer) => chunks.push(c));
|
||||
r.on("end", () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const xml = await (await JSZip.loadAsync(res.body as Buffer)).file("3D/3dmodel.model")!.async("string");
|
||||
return /<metadata name="Application">([^<]*)</.exec(xml)?.[1] ?? "";
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `sanitize-test-${Date.now()}@example.com`;
|
||||
const registered = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Sanitize Test", email, password: "password123" });
|
||||
if (registered.status !== 200) throw new Error(`register failed: ${registered.status}`);
|
||||
token = registered.body.token;
|
||||
|
||||
const file = path.join(os.tmpdir(), `sanitize-${Date.now()}.3mf`);
|
||||
fs.writeFileSync(file, await bambuProject());
|
||||
const upload = await auth(request(app).post("/api/upload")).attach("files", file);
|
||||
fs.rmSync(file, { force: true });
|
||||
if (upload.status !== 200) throw new Error(`upload failed: ${upload.status}`);
|
||||
printId = upload.body.prints[0].id;
|
||||
fileUrl = upload.body.prints[0].plates[0].url;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (printId) await auth(request(app).delete(`/api/print/${printId}`));
|
||||
});
|
||||
|
||||
describe("GET plate file with ?sanitize=1", () => {
|
||||
it("serves the sanitized copy when asked", async () => {
|
||||
expect(await fetchApplication()).toBe("Thingport");
|
||||
});
|
||||
|
||||
it("serves the original without the flag, so downloads and the viewer are unaffected", async () => {
|
||||
expect(await fetchApplication("")).toMatch(/^BambuStudio/);
|
||||
});
|
||||
|
||||
it("ignores other flag values", async () => {
|
||||
expect(await fetchApplication("?sanitize=auto")).toMatch(/^BambuStudio/);
|
||||
});
|
||||
|
||||
it("leaves non-3MF plates untouched", async () => {
|
||||
const file = path.join(os.tmpdir(), `sanitize-${Date.now()}.stl`);
|
||||
fs.writeFileSync(file, "solid x endsolid");
|
||||
const upload = await auth(request(app).post("/api/upload")).attach("files", file);
|
||||
fs.rmSync(file, { force: true });
|
||||
const stl = upload.body.prints[0];
|
||||
try {
|
||||
const res = await auth(request(app).get(`/api${stl.plates[0].url}?sanitize=1`)).responseType("blob");
|
||||
expect(res.status).toBe(200);
|
||||
expect(Buffer.from(res.body as Buffer).toString()).toBe("solid x endsolid");
|
||||
} finally {
|
||||
await auth(request(app).delete(`/api/print/${stl.id}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST plate sanitize status", () => {
|
||||
const sanitizeUrl = () => `/api${fileUrl.replace(/\/file\/[^/]+$/, "/sanitize")}`;
|
||||
|
||||
it("reports ready once the copy exists", async () => {
|
||||
let status = "";
|
||||
for (let i = 0; i < 50 && status !== "ready"; i++) {
|
||||
const res = await auth(request(app).post(sanitizeUrl()));
|
||||
expect(res.status).toBe(200);
|
||||
status = res.body.status;
|
||||
expect(["preparing", "ready"]).toContain(status);
|
||||
if (status !== "ready") await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(status).toBe("ready");
|
||||
});
|
||||
|
||||
it("rejects plates that aren't plain 3MF projects", async () => {
|
||||
const file = path.join(os.tmpdir(), `sanitize-${Date.now()}.stl`);
|
||||
fs.writeFileSync(file, "solid x endsolid");
|
||||
const upload = await auth(request(app).post("/api/upload")).attach("files", file);
|
||||
fs.rmSync(file, { force: true });
|
||||
const stl = upload.body.prints[0];
|
||||
try {
|
||||
const url = `/api${stl.plates[0].url.replace(/\/file\/[^/]+$/, "/sanitize")}`;
|
||||
expect((await auth(request(app).post(url))).status).toBe(400);
|
||||
} finally {
|
||||
await auth(request(app).delete(`/api/print/${stl.id}`));
|
||||
}
|
||||
});
|
||||
|
||||
it("404s for someone else's plate", async () => {
|
||||
const other = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Other", email: `sanitize-other-${Date.now()}@example.com`, password: "password123" });
|
||||
const res = await request(app).post(sanitizeUrl()).set("Authorization", `Bearer ${other.body.token}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodePaintState,
|
||||
encodePaintState,
|
||||
extractBambuSettings,
|
||||
mapSettingsToSlic3r,
|
||||
parsePlates,
|
||||
sanitize3mf,
|
||||
sanitizeProjectSettings,
|
||||
stateToMaterialIndex,
|
||||
} from "../src/services/threeMfSanitizer";
|
||||
|
||||
// Pure: no database. The cache test points storage at a temp dir before loading config.
|
||||
|
||||
const MODEL_NS =
|
||||
'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p"';
|
||||
|
||||
const SINGLE_COMPONENT_ROOT = ` <object id="2" p:UUID="root" type="model">
|
||||
<components>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="1" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>
|
||||
</components>
|
||||
</object>`;
|
||||
|
||||
function modelXml(body: string, head = ' <metadata name="Title">Test Cube</metadata>'): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" xml:lang="en-US" ${MODEL_NS}>
|
||||
${head}
|
||||
<metadata name="Application">BambuStudio-02.07.01.57</metadata>
|
||||
<resources>
|
||||
${body}
|
||||
</resources>
|
||||
<build>
|
||||
<item objectid="2" p:UUID="bbbb" transform="1 0 0 0 1 0 0 0 1 10 20 3" printable="1"/>
|
||||
</build>
|
||||
</model>
|
||||
`;
|
||||
}
|
||||
|
||||
const MESH_OBJECT = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" ${MODEL_NS}>
|
||||
<resources>
|
||||
<object id="1" p:UUID="aaaa" type="other">
|
||||
<mesh>
|
||||
<vertices>
|
||||
<vertex x="0" y="0" z="0"/>
|
||||
<vertex x="1" y="0" z="0"/>
|
||||
<vertex x="0" y="1" z="0"/>
|
||||
<vertex x="1" y="1" z="0"/>
|
||||
</vertices>
|
||||
<triangles>
|
||||
<triangle v1="0" v2="1" v3="2" paint_color="4"/>
|
||||
<triangle v1="1" v2="3" v3="2" paint_color="8"/>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
</resources>
|
||||
<build/>
|
||||
</model>
|
||||
`;
|
||||
|
||||
async function makeBambuZip(overrides: { rootXml?: string } = {}): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
zip.file("3D/3dmodel.model", overrides.rootXml ?? modelXml(SINGLE_COMPONENT_ROOT));
|
||||
zip.file("3D/Objects/object_1.model", MESH_OBJECT);
|
||||
zip.file("[Content_Types].xml", '<?xml version="1.0"?><Types/>');
|
||||
zip.file("_rels/.rels", '<?xml version="1.0"?><Relationships/>');
|
||||
zip.file(
|
||||
"Metadata/project_settings.config",
|
||||
JSON.stringify({
|
||||
layer_height: "0.2",
|
||||
wall_loops: "3",
|
||||
line_width: "0.42",
|
||||
inner_wall_line_width: "0.45",
|
||||
outer_wall_line_width: "0.42",
|
||||
sparse_infill_density: "15%",
|
||||
sparse_infill_pattern: "crosshatch",
|
||||
enable_support: "1",
|
||||
filament_colour: ["#FFFFFF", "#C52C18"],
|
||||
filament_type: ["PLA", "PLA"],
|
||||
filament_diameter: ["1.75", "1.75"],
|
||||
nozzle_temperature: ["220", "220"],
|
||||
filament_settings_id: ["Generic PLA @BBL A1M", "Generic PLA @BBL A1M"],
|
||||
printer_model: "Bambu Lab A1 mini",
|
||||
machine_start_gcode: "M104 S200",
|
||||
printable_area: ["0x0", "180x180"],
|
||||
}),
|
||||
);
|
||||
zip.file(
|
||||
"Metadata/model_settings.config",
|
||||
`<?xml version="1.0"?>
|
||||
<config>
|
||||
<object id="2">
|
||||
<metadata key="name" value="DesignerPart"/>
|
||||
<metadata key="extruder" value="2"/>
|
||||
</object>
|
||||
</config>`,
|
||||
);
|
||||
zip.file("Metadata/slice_info.config", "<config/>");
|
||||
zip.file("Metadata/plate_1.gcode", "; gcode");
|
||||
zip.file("3D/_rels/3dmodel.model.rels", "<Relationships/>");
|
||||
return zip.generateAsync({ type: "uint8array" });
|
||||
}
|
||||
|
||||
async function sanitizeZip(zip: JSZip) {
|
||||
return sanitize3mf(await zip.generateAsync({ type: "uint8array" }));
|
||||
}
|
||||
|
||||
async function readOut(bytes: Uint8Array, name: string): Promise<string> {
|
||||
const file = (await JSZip.loadAsync(bytes)).file(name);
|
||||
if (!file) throw new Error(`missing ${name}`);
|
||||
return file.async("string");
|
||||
}
|
||||
|
||||
describe("extractBambuSettings", () => {
|
||||
it("reads flat project settings", () => {
|
||||
const flat = extractBambuSettings({ wall_loops: "4", layer_height: "0.16" });
|
||||
expect(flat.wall_loops).toBe("4");
|
||||
expect(flat.process_settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges legacy nested process_settings.1", () => {
|
||||
expect(
|
||||
extractBambuSettings({ printer_model: "X1", process_settings: { "1": { wall_loops: "6" } } }).wall_loops,
|
||||
).toBe("6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSettingsToSlic3r", () => {
|
||||
it("maps walls, layer height, line widths and infill", () => {
|
||||
const mapped = mapSettingsToSlic3r({
|
||||
layer_height: "0.2",
|
||||
wall_loops: "3",
|
||||
line_width: "0.42",
|
||||
inner_wall_line_width: "0.45",
|
||||
outer_wall_line_width: "0.42",
|
||||
sparse_infill_density: "15%",
|
||||
sparse_infill_pattern: "crosshatch",
|
||||
enable_support: "1",
|
||||
});
|
||||
expect(mapped).toMatchObject({
|
||||
layer_height: "0.2",
|
||||
perimeters: "3",
|
||||
extrusion_width: "0.42",
|
||||
perimeter_extrusion_width: "0.45",
|
||||
external_perimeter_extrusion_width: "0.42",
|
||||
fill_density: "15%",
|
||||
fill_pattern: "grid",
|
||||
support_material: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps filament arrays and widens extruder_colour to the full palette", () => {
|
||||
const mapped = mapSettingsToSlic3r({
|
||||
filament_colour: ["#FFFFFF", "#161616", "#C52C18"],
|
||||
filament_type: ["PLA", "PETG", "PLA"],
|
||||
nozzle_temperature: ["210", "240", "210"],
|
||||
extruder_colour: ["#018001"],
|
||||
});
|
||||
expect(mapped.filament_type).toEqual(["PLA", "PETG", "PLA"]);
|
||||
expect(mapped.temperature).toEqual(["210", "240", "210"]);
|
||||
expect(mapped.extruder_colour).toEqual(["#FFFFFF", "#161616", "#C52C18"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeProjectSettings", () => {
|
||||
it("keeps designer process settings and strips printer, machine and sentinel values", () => {
|
||||
const cleaned = sanitizeProjectSettings({
|
||||
wall_loops: "3",
|
||||
filament_colour: ["#FFFFFF", "#C52C18"],
|
||||
extruder_colour: ["#018001"],
|
||||
filament_settings_id: ["Generic PLA @BBL A1M"],
|
||||
printer_model: "Bambu Lab A1 mini",
|
||||
machine_start_gcode: "M104",
|
||||
use_relative_e_distances: "1",
|
||||
raft_first_layer_expansion: "-1",
|
||||
tree_support_wall_count: -1,
|
||||
filament_retraction_length: ["nil", "nil"],
|
||||
ensure_vertical_shell_thickness: "enabled",
|
||||
wall_filament: "0",
|
||||
});
|
||||
expect(cleaned).toEqual({
|
||||
wall_loops: "3",
|
||||
filament_colour: ["#FFFFFF", "#C52C18"],
|
||||
extruder_colour: ["#FFFFFF", "#C52C18"],
|
||||
ensure_vertical_shell_thickness: "ensure_all",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("paint state", () => {
|
||||
it("round-trips Bambu TriangleSelector states", () => {
|
||||
expect(decodePaintState("4")).toBe(1);
|
||||
expect(decodePaintState("8")).toBe(2);
|
||||
expect(decodePaintState("0C")).toBe(3);
|
||||
expect(decodePaintState("2C")).toBe(5);
|
||||
expect(decodePaintState("")).toBe(0);
|
||||
expect(encodePaintState(1)).toBe("4");
|
||||
expect(encodePaintState(3)).toBe("0C");
|
||||
expect(decodePaintState(encodePaintState(4))).toBe(4);
|
||||
expect(stateToMaterialIndex(1, 2, 5)).toBe(0);
|
||||
expect(stateToMaterialIndex(0, 2, 5)).toBe(1);
|
||||
expect(stateToMaterialIndex(5, 1, 5)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize3mf", () => {
|
||||
it("flattens the production extension into one core model", async () => {
|
||||
const result = await sanitize3mf(await makeBambuZip());
|
||||
const out = await JSZip.loadAsync(result.bytes);
|
||||
expect(out.file("3D/Objects/object_1.model")).toBeNull();
|
||||
const xml = await readOut(result.bytes, "3D/3dmodel.model");
|
||||
expect(xml).toMatch(/<vertex /);
|
||||
expect(xml).not.toMatch(/requiredextensions|p:path|xmlns:p=|type="other"/);
|
||||
expect(xml).toMatch(/<metadata name="Application">Thingport<\/metadata>/);
|
||||
expect(result.report.flattened).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps painted faces and filament colors", async () => {
|
||||
const result = await sanitize3mf(await makeBambuZip());
|
||||
const xml = await readOut(result.bytes, "3D/3dmodel.model");
|
||||
expect(xml).toMatch(/paint_color="4"/);
|
||||
expect(xml).toMatch(/slic3rpe:mmu_segmentation="8"/);
|
||||
expect(xml).toMatch(/p1="0"/);
|
||||
expect(xml).toMatch(/p1="1"/);
|
||||
expect(xml).toMatch(/displaycolor="#FFFFFFFF"/);
|
||||
expect(xml).toMatch(/displaycolor="#C52C18FF"/);
|
||||
expect(result.report).toMatchObject({ paintColors: 2, triangles: 2, vertices: 4 });
|
||||
});
|
||||
|
||||
it("writes Slic3r settings alongside, not inside, existing metadata", async () => {
|
||||
const result = await sanitize3mf(await makeBambuZip());
|
||||
const xml = await readOut(result.bytes, "3D/3dmodel.model");
|
||||
expect(xml).toMatch(/<metadata name="Title">Test Cube<\/metadata>/);
|
||||
expect(xml).toMatch(/<metadata name="slic3r:perimeters">3<\/metadata>/);
|
||||
const slic3r = await readOut(result.bytes, "Metadata/Slic3r_PE.config");
|
||||
expect(slic3r).toMatch(/; perimeters = 3/);
|
||||
expect(slic3r).toMatch(/; fill_density = 15%/);
|
||||
expect(slic3r).toMatch(/; extruder_colour = #FFFFFF;#C52C18/);
|
||||
});
|
||||
|
||||
it("closes an unclosed empty Copyright tag", async () => {
|
||||
const head =
|
||||
' <metadata name="Title">Broly</metadata>\n <metadata name="Copyright">\n <metadata name="Designer">MakerWorld</metadata>';
|
||||
const result = await sanitize3mf(await makeBambuZip({ rootXml: modelXml(SINGLE_COMPONENT_ROOT, head) }));
|
||||
const xml = await readOut(result.bytes, "3D/3dmodel.model");
|
||||
expect(xml).toMatch(/<metadata name="Copyright" \/>/);
|
||||
expect(xml).toMatch(/<metadata name="Designer">MakerWorld<\/metadata>/);
|
||||
expect(xml).not.toMatch(/<metadata name="Copyright">/);
|
||||
});
|
||||
|
||||
it("strips G-code, slice info and the printer profile", async () => {
|
||||
const result = await sanitize3mf(await makeBambuZip());
|
||||
const out = await JSZip.loadAsync(result.bytes);
|
||||
expect(out.file("Metadata/plate_1.gcode")).toBeNull();
|
||||
expect(out.file("Metadata/slice_info.config")).toBeNull();
|
||||
expect(out.file("3D/_rels/3dmodel.model.rels")).toBeNull();
|
||||
const project = JSON.parse(await readOut(result.bytes, "Metadata/project_settings.config"));
|
||||
expect(project.wall_loops).toBe("3");
|
||||
expect(project.printer_model).toBeUndefined();
|
||||
expect(project.filament_settings_id).toBeUndefined();
|
||||
expect(project.machine_start_gcode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects archives without a model", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file("readme.txt", "no model");
|
||||
await expect(sanitizeZip(zip)).rejects.toThrow(/No valid 3D model/);
|
||||
});
|
||||
|
||||
it("still sanitizes an already-flat 3MF", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"3D/3dmodel.model",
|
||||
`<?xml version="1.0"?>
|
||||
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
|
||||
<resources>
|
||||
<object id="1" type="model">
|
||||
<mesh>
|
||||
<vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
</resources>
|
||||
<build><item objectid="1" transform="1 0 0 0 1 0 0 0 1 0 0 0"/></build>
|
||||
</model>`,
|
||||
);
|
||||
const result = await sanitizeZip(zip);
|
||||
expect(result.report).toMatchObject({ triangles: 1, flattened: false });
|
||||
expect(await readOut(result.bytes, "3D/3dmodel.model")).toMatch(/pid="/);
|
||||
});
|
||||
|
||||
it("merges a multi-part assembly into one object that keeps each part's filament", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"3D/3dmodel.model",
|
||||
`<?xml version="1.0"?>
|
||||
<model unit="millimeter" ${MODEL_NS}>
|
||||
<resources>
|
||||
<object id="6" type="model">
|
||||
<components>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="1" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="2" transform="1 0 0 0 1 0 0 0 1 5 0 0"/>
|
||||
</components>
|
||||
</object>
|
||||
</resources>
|
||||
<build><item objectid="6" transform="1 0 0 0 1 0 0 0 1 10 20 3" printable="1"/></build>
|
||||
</model>`,
|
||||
);
|
||||
zip.file(
|
||||
"3D/Objects/object_1.model",
|
||||
`<?xml version="1.0"?>
|
||||
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
|
||||
<resources>
|
||||
<object id="1" type="model">
|
||||
<mesh>
|
||||
<vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
<object id="2" type="model">
|
||||
<mesh>
|
||||
<vertices><vertex x="2" y="0" z="0"/><vertex x="3" y="0" z="0"/><vertex x="2" y="1" z="0"/></vertices>
|
||||
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
||||
</mesh>
|
||||
</object>
|
||||
</resources>
|
||||
<build/>
|
||||
</model>`,
|
||||
);
|
||||
zip.file(
|
||||
"Metadata/project_settings.config",
|
||||
JSON.stringify({ filament_colour: ["#FBFCFF", "#F2910B", "#000000"], filament_type: ["PLA", "PLA", "PLA"] }),
|
||||
);
|
||||
zip.file(
|
||||
"Metadata/model_settings.config",
|
||||
`<?xml version="1.0"?>
|
||||
<config>
|
||||
<object id="6">
|
||||
<metadata key="name" value="Chip"/>
|
||||
<metadata key="extruder" value="3"/>
|
||||
<part id="1" subtype="normal_part">
|
||||
<metadata key="name" value="Background"/>
|
||||
<metadata key="extruder" value="3"/>
|
||||
</part>
|
||||
<part id="2" subtype="normal_part">
|
||||
<metadata key="name" value="QR"/>
|
||||
<metadata key="extruder" value="1"/>
|
||||
</part>
|
||||
</object>
|
||||
</config>`,
|
||||
);
|
||||
const result = await sanitizeZip(zip);
|
||||
const xml = await readOut(result.bytes, "3D/3dmodel.model");
|
||||
expect(xml.match(/<item\b/g)).toHaveLength(1);
|
||||
expect(xml).toMatch(/p1="2"/);
|
||||
expect(xml).toMatch(/p1="0"/);
|
||||
expect(xml).toMatch(/paint_color="0C"/);
|
||||
expect(xml).toMatch(/paint_color="4"/);
|
||||
const modelCfg = await readOut(result.bytes, "Metadata/model_settings.config");
|
||||
expect(modelCfg).toMatch(/value="Background"/);
|
||||
expect(modelCfg).toMatch(/value="QR"/);
|
||||
const slic3rModel = await readOut(result.bytes, "Metadata/Slic3r_PE_model.config");
|
||||
expect(slic3rModel).toMatch(/<volume firstid="0" lastid="0">/);
|
||||
expect(slic3rModel).toMatch(/<volume firstid="1" lastid="1">/);
|
||||
expect(parsePlates(modelCfg)[0].instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps multiple plates, remapped to the flattened object ids", async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"3D/3dmodel.model",
|
||||
`<?xml version="1.0"?>
|
||||
<model unit="millimeter" ${MODEL_NS}>
|
||||
<resources>
|
||||
<object id="2" type="model">
|
||||
<components><component p:path="/3D/Objects/object_1.model" objectid="1" transform="1 0 0 0 1 0 0 0 1 0 0 0"/></components>
|
||||
</object>
|
||||
<object id="4" type="model">
|
||||
<components><component p:path="/3D/Objects/object_1.model" objectid="1" transform="1 0 0 0 1 0 0 0 1 0 0 0"/></components>
|
||||
</object>
|
||||
</resources>
|
||||
<build>
|
||||
<item objectid="2" transform="1 0 0 0 1 0 0 0 1 90 90 1" printable="1"/>
|
||||
<item objectid="4" transform="1 0 0 0 1 0 0 0 1 306 90 1" printable="1"/>
|
||||
</build>
|
||||
</model>`,
|
||||
);
|
||||
zip.file("3D/Objects/object_1.model", MESH_OBJECT);
|
||||
zip.file(
|
||||
"Metadata/model_settings.config",
|
||||
`<?xml version="1.0"?>
|
||||
<config>
|
||||
<object id="2"><metadata key="name" value="ChipA"/><metadata key="extruder" value="1"/></object>
|
||||
<object id="4"><metadata key="name" value="ChipB"/><metadata key="extruder" value="2"/></object>
|
||||
<plate>
|
||||
<metadata key="plater_id" value="1"/>
|
||||
<model_instance><metadata key="object_id" value="2"/><metadata key="instance_id" value="0"/><metadata key="identify_id" value="10"/></model_instance>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="plater_id" value="2"/>
|
||||
<model_instance><metadata key="object_id" value="4"/><metadata key="instance_id" value="0"/><metadata key="identify_id" value="11"/></model_instance>
|
||||
</plate>
|
||||
</config>`,
|
||||
);
|
||||
const result = await sanitizeZip(zip);
|
||||
const xml = await readOut(result.bytes, "3D/3dmodel.model");
|
||||
const itemIds = [...xml.matchAll(/<item objectid="(\d+)"/g)].map((m) => m[1]);
|
||||
expect(itemIds).toHaveLength(2);
|
||||
expect(itemIds[0]).not.toBe(itemIds[1]);
|
||||
const modelCfg = await readOut(result.bytes, "Metadata/model_settings.config");
|
||||
const plates = parsePlates(modelCfg);
|
||||
expect(plates.map((p) => p.instances[0].objectId)).toEqual(itemIds);
|
||||
expect(modelCfg).toMatch(/<assemble>/);
|
||||
expect(result.report.plates).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitized3mfFor", () => {
|
||||
it("sanitizes in a worker, caches the copy and falls back to null for a broken file", async () => {
|
||||
const storage = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-sanitize-"));
|
||||
process.env.FILE_STORAGE = storage;
|
||||
const { sanitized3mfFor } = await import("../src/services/sanitized3mfCache");
|
||||
|
||||
const src = path.join(storage, "model.3mf");
|
||||
fs.writeFileSync(src, await makeBambuZip());
|
||||
const first = await sanitized3mfFor("plate-a", src);
|
||||
expect(first).not.toBeNull();
|
||||
expect(await readOut(fs.readFileSync(first!), "3D/3dmodel.model")).toMatch(/Thingport/);
|
||||
const mtime = fs.statSync(first!).mtimeMs;
|
||||
expect(await sanitized3mfFor("plate-a", src)).toBe(first);
|
||||
expect(fs.statSync(first!).mtimeMs).toBe(mtime);
|
||||
|
||||
const broken = path.join(storage, "broken.3mf");
|
||||
fs.writeFileSync(broken, "not a zip");
|
||||
expect(await sanitized3mfFor("plate-b", broken)).toBeNull();
|
||||
fs.rmSync(storage, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -447,6 +447,17 @@ export const printsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Starts preparing a sanitized copy for "Open sanitized in <slicer>" if needed; poll until "ready". */
|
||||
sanitizePlate: async (printId: string, plateId: string): Promise<{ status: "ready" | "preparing" | "failed" }> => {
|
||||
const res = await fetch(`${apiBase()}/print/${printId}/plate/${plateId}/sanitize`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to prepare sanitized file"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
favorite: async (id: string): Promise<Print> => {
|
||||
const res = await fetch(`${apiBase()}/print/${id}/favorite`, { method: "POST", headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -15,6 +15,17 @@ export const SLICER_OPTIONS: SlicerOption[] = [
|
||||
{ id: "other", label: "Other / Manual" },
|
||||
];
|
||||
|
||||
// Offered "Open sanitized in <slicer>" for MakerWorld 3MFs. They mis-import Bambu projects (lost colors,
|
||||
// settings or parts); Orca forks too, since each copied Bambu's 3MF handling at a different point.
|
||||
export const SANITIZE_3MF_SLICER_IDS: ReadonlySet<string> = new Set([
|
||||
"prusaslicer",
|
||||
"cura",
|
||||
"anycubicslicernext",
|
||||
"crealityprintlink",
|
||||
"elegooslicer",
|
||||
"snapmaker-orca",
|
||||
]);
|
||||
|
||||
/** "system" follows prefers-color-scheme; resolve with useResolvedTheme before rendering. */
|
||||
export type ThemeSelection = "light" | "dark" | "system";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
|
||||
@@ -1254,6 +1254,13 @@
|
||||
"removeFromCollectionFailed": "Failed to remove from collection. Please try again.",
|
||||
"openInProvider": "Open in {{provider}}",
|
||||
"openInSlicer": "Open in {{slicer}}",
|
||||
"openSanitizedInSlicer": "Open sanitized in {{slicer}}",
|
||||
"sanitizedInfo": "MakerWorld files are Bambu Studio projects, which {{slicer}} often opens without their colors or print settings. This opens a converted copy that keeps painted colors, filament colors, plates and the designer's settings (walls, layer height, infill, supports). Heads up: multi-part objects may be merged into one, per-object overrides and layer color changes are dropped, and you pick your own printer profile. The stored file isn't changed.",
|
||||
"sanitizePreparing": "Preparing sanitized file…",
|
||||
"sanitizePreparingShort": "Preparing sanitized file…",
|
||||
"sanitizeReady": "Ready – click to open in {{slicer}}",
|
||||
"sanitizeReadyShort": "Ready – click to open",
|
||||
"sanitizeFailed": "Couldn't prepare a sanitized copy of this file. Use the regular Open action instead.",
|
||||
"openInSlicerGeneric": "Open in Slicer",
|
||||
"preparedPrintFile": "Prepared print",
|
||||
"downloadPickerTitle": "Download",
|
||||
|
||||
@@ -1257,6 +1257,13 @@
|
||||
"removeFromCollectionFailed": "Nepavyko pašalinti iš kolekcijos. Bandykite dar kartą.",
|
||||
"openInProvider": "Atverti „{{provider}}“",
|
||||
"openInSlicer": "Atverti su {{slicer}}",
|
||||
"openSanitizedInSlicer": "Atverti sutvarkytą su {{slicer}}",
|
||||
"sanitizedInfo": "MakerWorld failai yra Bambu Studio projektai, kuriuos {{slicer}} dažnai atveria be spalvų ar spausdinimo nustatymų. Ši parinktis atveria konvertuotą kopiją, kurioje išlieka nudažytos spalvos, gijų spalvos, plokštės ir autoriaus nustatymai (sienelės, sluoksnio aukštis, užpildas, atramos). Atkreipkite dėmesį: kelių dalių objektai gali būti sujungti į vieną, atskirų objektų nustatymai ir spalvų keitimai tarp sluoksnių pašalinami, o spausdintuvo profilį pasirenkate patys. Saugomas failas nekeičiamas.",
|
||||
"sanitizePreparing": "Ruošiamas sutvarkytas failas…",
|
||||
"sanitizePreparingShort": "Ruošiamas sutvarkytas failas…",
|
||||
"sanitizeReady": "Paruošta – spustelėkite, kad atvertumėte su {{slicer}}",
|
||||
"sanitizeReadyShort": "Paruošta – spustelėkite, kad atvertumėte",
|
||||
"sanitizeFailed": "Nepavyko paruošti sutvarkytos šio failo kopijos. Naudokite įprastą atvėrimo veiksmą.",
|
||||
"openInSlicerGeneric": "Atverti pjaustyklėje",
|
||||
"preparedPrintFile": "Paruoštas spaudinys",
|
||||
"downloadPickerTitle": "Atsisiųsti",
|
||||
|
||||
@@ -27,6 +27,8 @@ import { importProviderInfo } from "../../constants/importProviders";
|
||||
import { useDownloadPrint } from "./useDownloadPrint";
|
||||
import { useOpenInSlicer } from "./useOpenInSlicer";
|
||||
import SlicerFileMenu from "./SlicerFileMenu";
|
||||
import SanitizeInfoIcon from "./SanitizeInfoIcon";
|
||||
import { useSanitizedOpen } from "./useSanitizedOpen";
|
||||
import DownloadPickerDialog from "./DownloadPickerDialog";
|
||||
import AddToCollectionModal from "./AddToCollectionModal";
|
||||
import EditModelModal from "./EditModelModal";
|
||||
@@ -63,6 +65,7 @@ export default function ModelActionsMenu({
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [slicerMenuAnchor, setSlicerMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [sanitizedMenuAnchor, setSanitizedMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [removingFromCollection, setRemovingFromCollection] = useState(false);
|
||||
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
|
||||
@@ -145,8 +148,15 @@ export default function ModelActionsMenu({
|
||||
};
|
||||
|
||||
const providerInfo = importProviderInfo(print.source_provider);
|
||||
const { slicerOption, targets: slicerTargets } = useOpenInSlicer(print);
|
||||
const { slicerOption, targets: slicerTargets, sanitizedTargets } = useOpenInSlicer(print);
|
||||
const openInSlicerHref = slicerTargets.length === 1 ? slicerTargets[0].href : undefined;
|
||||
const sanitized = useSanitizedOpen(print.id, recordUse, onUnauthorized);
|
||||
const sanitizedMenuLabel = (slicer: string) => {
|
||||
const state = sanitizedTargets.length === 1 ? sanitized.stateOf(sanitizedTargets[0]) : "idle";
|
||||
if (state === "preparing") return t("models:detail.sanitizePreparing");
|
||||
if (state === "ready") return t("models:detail.sanitizeReady", { slicer });
|
||||
return t("models:detail.openSanitizedInSlicer", { slicer });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -233,6 +243,30 @@ export default function ModelActionsMenu({
|
||||
: t("models:detail.openInSlicerGeneric")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
{slicerOption && sanitizedTargets.length > 0 && (
|
||||
<MenuItem
|
||||
disabled={sanitizedTargets.length === 1 && sanitized.stateOf(sanitizedTargets[0]) === "preparing"}
|
||||
onClick={() => {
|
||||
if (sanitizedTargets.length > 1) {
|
||||
setSanitizedMenuAnchor(anchorEl);
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
// Stays open while preparing, so the item can show progress and then "ready".
|
||||
void sanitized.open(sanitizedTargets[0]).then((launched) => launched && closeMenu());
|
||||
}}
|
||||
sx={{ color: "warning.main" }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: "inherit" }}>
|
||||
{sanitizedTargets.length === 1 && sanitized.stateOf(sanitizedTargets[0]) === "preparing" ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
<SanitizeInfoIcon slicerLabel={slicerOption.label} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText>{sanitizedMenuLabel(slicerOption.label)}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{providerInfo && print.source_url && (
|
||||
<MenuItem component="a" href={print.source_url} target="_blank" rel="noopener noreferrer" onClick={closeMenu}>
|
||||
<ListItemIcon>
|
||||
@@ -243,6 +277,17 @@ export default function ModelActionsMenu({
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
{slicerOption && sanitizedTargets.length > 1 && (
|
||||
<SlicerFileMenu
|
||||
anchorEl={sanitizedMenuAnchor}
|
||||
onClose={() => setSanitizedMenuAnchor(null)}
|
||||
slicerLabel={slicerOption.label}
|
||||
title={t("models:detail.openSanitizedInSlicer", { slicer: slicerOption.label })}
|
||||
targets={sanitizedTargets}
|
||||
onOpen={recordUse}
|
||||
sanitized={sanitized}
|
||||
/>
|
||||
)}
|
||||
{slicerOption && slicerTargets.length > 1 && (
|
||||
<SlicerFileMenu
|
||||
anchorEl={slicerMenuAnchor}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import { useDownloadPrint } from "./useDownloadPrint";
|
||||
import { useOpenInSlicer } from "./useOpenInSlicer";
|
||||
import SlicerFileMenu from "./SlicerFileMenu";
|
||||
import SanitizedOpenButton from "./SanitizedOpenButton";
|
||||
import { useSanitizedOpen } from "./useSanitizedOpen";
|
||||
import RollingNumber from "../../components/RollingNumber";
|
||||
import { formatFileSize } from "../../utils/fileSize";
|
||||
import AuthorHoverCard from "../../components/AuthorHoverCard";
|
||||
@@ -53,8 +55,10 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
|
||||
recordUse,
|
||||
} = useDownloadPrint(print, onUnauthorized, onUpdated);
|
||||
|
||||
const { slicerOption, targets: slicerTargets } = useOpenInSlicer(print);
|
||||
const { slicerOption, targets: slicerTargets, sanitizedTargets } = useOpenInSlicer(print);
|
||||
const [slicerMenuAnchor, setSlicerMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [sanitizedMenuAnchor, setSanitizedMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const sanitized = useSanitizedOpen(print.id, recordUse, onUnauthorized);
|
||||
|
||||
const goToCategory = () => {
|
||||
if (!print.category_id) return;
|
||||
@@ -212,6 +216,27 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
|
||||
matchAnchorWidth
|
||||
/>
|
||||
)}
|
||||
{slicerOption && sanitizedTargets.length > 0 && (
|
||||
<SanitizedOpenButton
|
||||
targets={sanitizedTargets}
|
||||
slicerLabel={slicerOption.label}
|
||||
stateOf={sanitized.stateOf}
|
||||
open={sanitized.open}
|
||||
onPick={setSanitizedMenuAnchor}
|
||||
/>
|
||||
)}
|
||||
{slicerOption && sanitizedTargets.length > 1 && (
|
||||
<SlicerFileMenu
|
||||
anchorEl={sanitizedMenuAnchor}
|
||||
onClose={() => setSanitizedMenuAnchor(null)}
|
||||
slicerLabel={slicerOption.label}
|
||||
title={t("models:detail.openSanitizedInSlicer", { slicer: slicerOption.label })}
|
||||
targets={sanitizedTargets}
|
||||
onOpen={recordUse}
|
||||
sanitized={sanitized}
|
||||
matchAnchorWidth
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import LightbulbIcon from "@mui/icons-material/Lightbulb";
|
||||
|
||||
/** The "Open sanitized in <slicer>" icon; hovering it explains what sanitizing keeps and changes. */
|
||||
export default function SanitizeInfoIcon({ slicerLabel }: { slicerLabel: string }) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
return (
|
||||
<Tooltip title={t("models:detail.sanitizedInfo", { slicer: slicerLabel })} arrow describeChild>
|
||||
<LightbulbIcon fontSize="small" sx={{ color: "inherit" }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
|
||||
import SanitizeInfoIcon from "./SanitizeInfoIcon";
|
||||
import type { SlicerTarget } from "./useOpenInSlicer";
|
||||
import type { SanitizeState } from "./useSanitizedOpen";
|
||||
|
||||
type Props = {
|
||||
targets: SlicerTarget[];
|
||||
slicerLabel: string;
|
||||
stateOf: (target: SlicerTarget) => SanitizeState;
|
||||
open: (target: SlicerTarget) => Promise<boolean>;
|
||||
/** Several targets: opens the file picker, whose rows show each file's progress. */
|
||||
onPick: (anchor: HTMLElement) => void;
|
||||
};
|
||||
|
||||
/** The side panel's "Open sanitized in <slicer>" button, in the bulb's warning color. */
|
||||
export default function SanitizedOpenButton({ targets, slicerLabel, stateOf, open, onPick }: Props) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
const single = targets.length === 1 ? targets[0] : null;
|
||||
const state: SanitizeState = single
|
||||
? stateOf(single)
|
||||
: targets.some((target) => stateOf(target) === "preparing")
|
||||
? "preparing"
|
||||
: "idle";
|
||||
const label =
|
||||
state === "preparing"
|
||||
? t("models:detail.sanitizePreparing")
|
||||
: state === "ready"
|
||||
? t("models:detail.sanitizeReady", { slicer: slicerLabel })
|
||||
: t("models:detail.openSanitizedInSlicer", { slicer: slicerLabel });
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => (single ? void open(single) : onPick(e.currentTarget))}
|
||||
// Several files stay clickable while one prepares, so the picker can be reopened.
|
||||
disabled={Boolean(single) && state === "preparing"}
|
||||
startIcon={
|
||||
state === "preparing" ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<SanitizeInfoIcon slicerLabel={slicerLabel} />
|
||||
)
|
||||
}
|
||||
endIcon={single ? undefined : <ArrowDropDownIcon />}
|
||||
fullWidth
|
||||
sx={{
|
||||
bgcolor: "background.paper",
|
||||
color: "warning.main",
|
||||
border: "1.5px solid",
|
||||
borderColor: "warning.main",
|
||||
"&:hover": {
|
||||
bgcolor: (theme) => alpha(theme.palette.warning.main, 0.08),
|
||||
borderColor: "warning.dark",
|
||||
},
|
||||
"&.Mui-disabled": { color: "warning.main", borderColor: "warning.main", opacity: 0.7 },
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import PrintIcon from "@mui/icons-material/Print";
|
||||
import PlateThumbnail from "../../components/media/PlateThumbnail";
|
||||
import { fileRowText } from "./fileRowText";
|
||||
import type { SlicerTarget } from "./useOpenInSlicer";
|
||||
import type { SanitizeState } from "./useSanitizedOpen";
|
||||
|
||||
type Props = {
|
||||
anchorEl: HTMLElement | null;
|
||||
@@ -16,6 +17,13 @@ type Props = {
|
||||
slicerLabel: string;
|
||||
targets: SlicerTarget[];
|
||||
onOpen: () => void;
|
||||
/** Heading; defaults to "Open in <slicer>". */
|
||||
title?: string;
|
||||
/** Sanitized picks go through the prepare step instead of being plain links. */
|
||||
sanitized?: {
|
||||
stateOf: (target: SlicerTarget) => SanitizeState;
|
||||
open: (target: SlicerTarget) => Promise<boolean>;
|
||||
};
|
||||
/** Opens below the anchor at its exact width, for a full-width button. */
|
||||
matchAnchorWidth?: boolean;
|
||||
};
|
||||
@@ -26,6 +34,8 @@ export default function SlicerFileMenu({
|
||||
slicerLabel,
|
||||
targets,
|
||||
onOpen,
|
||||
title,
|
||||
sanitized,
|
||||
matchAnchorWidth = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
@@ -42,23 +52,33 @@ export default function SlicerFileMenu({
|
||||
slotProps={{ paper: { sx: paperSx } }}
|
||||
>
|
||||
<ListSubheader sx={{ lineHeight: "32px" }}>
|
||||
{t("models:detail.openInSlicer", { slicer: slicerLabel })}
|
||||
{title ?? t("models:detail.openInSlicer", { slicer: slicerLabel })}
|
||||
</ListSubheader>
|
||||
{targets.map((target) => {
|
||||
const text = target.plate
|
||||
? fileRowText(t, target.filename, target.index)
|
||||
: { primary: t("models:detail.preparedPrintFile"), secondary: target.filename };
|
||||
const state = sanitized?.stateOf(target) ?? "idle";
|
||||
if (state === "preparing") text.secondary = t("models:detail.sanitizePreparingShort");
|
||||
if (state === "ready") text.secondary = t("models:detail.sanitizeReadyShort");
|
||||
const action = sanitized
|
||||
? {
|
||||
disabled: state === "preparing",
|
||||
onClick: () => {
|
||||
// Stays open while preparing, so the row can show progress and then "ready".
|
||||
void sanitized.open(target).then((launched) => launched && onClose());
|
||||
},
|
||||
}
|
||||
: {
|
||||
component: "a" as const,
|
||||
href: target.href,
|
||||
onClick: () => {
|
||||
onOpen();
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
return (
|
||||
<MenuItem
|
||||
key={target.key}
|
||||
component="a"
|
||||
href={target.href}
|
||||
title={target.filename}
|
||||
onClick={() => {
|
||||
onOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<MenuItem key={target.key} title={target.filename} {...action}>
|
||||
<ListItemIcon sx={{ minWidth: 44 }}>
|
||||
{target.plate ? (
|
||||
<PlateThumbnail plate={target.plate} />
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useMemo } from "react";
|
||||
import { type Plate, type Print, printsApi } from "../../api/prints";
|
||||
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
|
||||
import { SANITIZE_3MF_SLICER_IDS, SLICER_OPTIONS } from "../../constants/settingsOptions";
|
||||
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
|
||||
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
|
||||
|
||||
export type SlicerTarget = { key: string; href: string; filename: string; index: number; plate: Plate | null };
|
||||
|
||||
/** Sliced .gcode.3mf plates are never sanitized: dropping their G-code would leave nothing to print. */
|
||||
function isSanitizable3mf(filename: string): boolean {
|
||||
const lower = filename.toLowerCase();
|
||||
return lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf");
|
||||
}
|
||||
|
||||
/** `slicerOption` is null without a usable preference. With several targets, the caller offers a
|
||||
* pick rather than opening the first. */
|
||||
* pick rather than opening the first. `sanitizedTargets` is empty unless the slicer needs MakerWorld
|
||||
* 3MFs converted. */
|
||||
export function useOpenInSlicer(print: Print) {
|
||||
const slicerPreference = useSlicerPreference();
|
||||
const slicerOption = SLICER_OPTIONS.find((opt) => opt.id === slicerPreference && opt.id !== "other") ?? null;
|
||||
@@ -30,5 +37,24 @@ export function useOpenInSlicer(print: Print) {
|
||||
return out;
|
||||
}, [print.plates, print.slicer_url, print.slicer_filename, slicerOption]);
|
||||
|
||||
return { slicerOption, targets };
|
||||
const sanitizedTargets = useMemo<SlicerTarget[]>(() => {
|
||||
if (!slicerOption || !SANITIZE_3MF_SLICER_IDS.has(slicerOption.id) || print.source_provider !== "makerworld") {
|
||||
return [];
|
||||
}
|
||||
// A non-removable prepared print means the plates themselves are the sliced files.
|
||||
if (print.prepared_print && !print.prepared_print.removable) return [];
|
||||
return print.plates
|
||||
.toSorted((a, b) => a.position - b.position)
|
||||
.map((plate, index) => ({ plate, index }))
|
||||
.filter(({ plate }) => isSanitizable3mf(plate.filename))
|
||||
.map(({ plate, index }) => ({
|
||||
key: `sanitized-${plate.id}`,
|
||||
href: slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(`${plate.url}?sanitize=1`), plate.filename),
|
||||
filename: plate.filename,
|
||||
index,
|
||||
plate,
|
||||
}));
|
||||
}, [print.plates, print.source_provider, print.prepared_print, slicerOption]);
|
||||
|
||||
return { slicerOption, targets, sanitizedTargets };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { printsApi } from "../../api/prints";
|
||||
import type { SlicerTarget } from "./useOpenInSlicer";
|
||||
|
||||
export type SanitizeState = "idle" | "preparing" | "ready";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
/** "Open sanitized in <slicer>": asks the server for a sanitized copy and polls until it's built, so no
|
||||
* request has to outlast a proxy timeout. A cached copy opens straight away. A slow one ends in
|
||||
* "ready" and needs a second click: browsers only launch a slicer's protocol right after a click. */
|
||||
export function useSanitizedOpen(printId: string, onOpened: () => void, onUnauthorized?: () => void) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
const [states, setStates] = useState<Record<string, SanitizeState>>({});
|
||||
// Set on every mount: StrictMode unmounts and remounts once in development.
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setState = useCallback((key: string, state: SanitizeState) => {
|
||||
if (mounted.current) setStates((prev) => ({ ...prev, [key]: state }));
|
||||
}, []);
|
||||
|
||||
const stateOf = useCallback((target: SlicerTarget): SanitizeState => states[target.key] ?? "idle", [states]);
|
||||
|
||||
/** Resolves true when the slicer was launched, so a menu can close. */
|
||||
const open = useCallback(
|
||||
async (target: SlicerTarget): Promise<boolean> => {
|
||||
const launch = () => {
|
||||
window.location.href = target.href;
|
||||
setState(target.key, "idle");
|
||||
onOpened();
|
||||
return true;
|
||||
};
|
||||
const current = states[target.key] ?? "idle";
|
||||
if (current === "ready") return launch();
|
||||
if (current === "preparing" || !target.plate) return false;
|
||||
|
||||
setState(target.key, "preparing");
|
||||
try {
|
||||
let { status } = await printsApi.sanitizePlate(printId, target.plate.id);
|
||||
if (status === "ready") return launch();
|
||||
while (status === "preparing") {
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
if (!mounted.current) return false;
|
||||
({ status } = await printsApi.sanitizePlate(printId, target.plate.id));
|
||||
}
|
||||
if (status === "failed") throw new Error("Sanitizing failed");
|
||||
setState(target.key, "ready");
|
||||
return false;
|
||||
} catch (err) {
|
||||
setState(target.key, "idle");
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (mounted.current) alert(t("models:detail.sanitizeFailed"));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[states, printId, onOpened, onUnauthorized, setState, t],
|
||||
);
|
||||
|
||||
return { stateOf, open };
|
||||
}
|
||||
Reference in new issue
Block a user