feat: add sanitized bambu 3mf open option

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-30 08:31:16 +02:00
1 parent 9549148dfd
commit 0d8cd62fa7
24 files changed
+2463 -28

No files matched your search

+89 -1
View File
@@ -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",
+1
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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 });
}
+25 -1
View File
@@ -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) => {
+2 -7
View File
@@ -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;
+3 -1
View File
@@ -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 });
+23
View File
@@ -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();
+143
View File
@@ -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
+12
View File
@@ -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});`;
}
+147
View 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);
});
});
+442
View File
@@ -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 });
});
});
+11
View File
@@ -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();
+11
View File
@@ -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";
+7
View File
@@ -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",
+7
View File
@@ -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 };
}