feat: anable complex model simplification

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-28 00:09:59 +02:00
1 parent 65236c5803
commit 7464583780
18 files changed
+610 -18

No files matched your search

+11 -4
View File
@@ -16,6 +16,7 @@
"dotenv": "^17.4.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"meshoptimizer": "^1.3.0",
"mime-types": "^2.1.35",
"multer": "^2.0.1",
"nodemailer": "^10.0.1",
@@ -1950,6 +1951,13 @@
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/three/node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@@ -3473,10 +3481,9 @@
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.3.0.tgz",
"integrity": "sha512-2oZqMAcEEiCtXWUjIGtZ71oPx1B0ZxYGTqIKsXB1wzWFhleNsCFxQuUJzgCV706bRcwPWtMGf1odmQvAcqKkIw==",
"license": "MIT"
},
"node_modules/methods": {
+2 -1
View File
@@ -5,7 +5,7 @@
"license": "MIT",
"type": "commonjs",
"engines": {
"node": ">=20"
"node": ">=20.19"
},
"scripts": {
"dev": "tsx watch src/server.ts",
@@ -25,6 +25,7 @@
"dotenv": "^17.4.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"meshoptimizer": "^1.3.0",
"mime-types": "^2.1.35",
"multer": "^2.0.1",
"nodemailer": "^10.0.1",
+30
View File
@@ -9,11 +9,13 @@ import {
setCaptchaSettings,
getAuthTokenTtl,
getPreviewMode,
getSimplifyPreviews,
getSmtpSettings,
getThingiverseAccessToken,
setAllowRegistrations,
setAuthTokenTtl,
setPreviewMode,
setSimplifyPreviews,
setSmtpSettings,
setThingiverseAccessToken,
type SmtpSettings,
@@ -35,6 +37,7 @@ import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPref
import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService";
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
import { checkForUpdates } from "../services/versionService";
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
const router = Router();
router.use(requireAuth);
@@ -168,6 +171,33 @@ router.post(
}),
);
// Administration > Rendering. A change removes just the cached previews it would alter (in the
// background -- it reads every cached preview's header), so they're rebuilt on their next view.
router.get(
"/settings/rendering",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json({ simplify_previews: await getSimplifyPreviews() });
}),
);
const renderingSchema = z.object({ simplify_previews: z.boolean() });
router.patch(
"/settings/rendering",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(renderingSchema, req.body);
const previous = await getSimplifyPreviews();
await setSimplifyPreviews(body.simplify_previews);
if (previous !== body.simplify_previews) {
void dropPreviewsAffectedBySimplification(body.simplify_previews).catch((err) =>
console.error("Couldn't refresh previews after a simplification change", err),
);
}
res.json({ simplify_previews: body.simplify_previews });
}),
);
// Read is open to every user -- UserMenu's Thingiverse connection chip needs to know whether
// imports will actually work, same as /settings/previews above. Only admins can change it: the
// token is a shared credential for the whole instance's Thingiverse imports, not a per-user
+80 -1
View File
@@ -5,6 +5,7 @@ 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";
// Owns the pre-rendered GLB cache for the interactive 3D preview: which plates have one, which
// failed, and running the render itself -- in a worker thread (modelPreviewWorker.ts ->
@@ -83,6 +84,13 @@ const WORKER_BOOTSTRAP = WORKER_FILE.endsWith(".ts")
const MEMORY_POLL_MS = 250;
// With Administration > Rendering's "simplify" on, a heavier model's preview is reduced to about
// this many triangles (see modelPreviewRender.ts's simplifyGroupMeshes). 3D printing models are
// often far denser than a screen shows: 1M triangles still looks the same, while shrinking the
// file the viewer downloads and what a phone's GPU has to hold. Lighter models are untouched.
// Defined here rather than in the renderer so the server thread never loads the renderer.
export const SIMPLIFY_TARGET_TRIANGLES = 1_000_000;
type RenderOutcome =
| { status: "ok" }
| { status: "too-complex" }
@@ -204,7 +212,12 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(pendingPath, new Date().toISOString());
const outcome = await renderInWorker({ srcPath, destPath: tmp });
const simplify = await getSimplifyPreviews();
const outcome = await renderInWorker({
srcPath,
destPath: tmp,
options: { simplifyTo: simplify ? SIMPLIFY_TARGET_TRIANGLES : null },
});
switch (outcome.status) {
case "ok":
await fs.rename(tmp, dest);
@@ -236,3 +249,69 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
inFlight.delete(plateId);
}
}
// ---- Simplification setting changes -----------------------------------------------------------
type GlbSummary = { triangles: number; simplified: boolean };
/** Reads just a cached GLB's JSON chunk (not its geometry): how many triangles it holds, and
* whether the renderer simplified it (the `simplified` note it leaves in the preview's
* metadata). Null for a file that isn't a GLB this renderer wrote. */
async function readGlbSummary(file: string): Promise<GlbSummary | null> {
const handle = await fs.open(file, "r");
try {
const header = Buffer.alloc(20);
await handle.read(header, 0, 20, 0);
// "glTF" magic, then the first chunk must be JSON ("JSON" = 0x4e4f534a).
if (header.readUInt32LE(0) !== 0x46546c67 || header.readUInt32LE(16) !== 0x4e4f534a) return null;
const json = Buffer.alloc(header.readUInt32LE(12));
await handle.read(json, 0, json.length, 20);
const gltf = JSON.parse(json.toString("utf-8")) as {
accessors?: { count: number }[];
meshes?: { primitives: { indices?: number }[] }[];
nodes?: { extras?: { thingportPreview?: string } }[];
};
let triangles = 0;
for (const mesh of gltf.meshes ?? []) {
for (const primitive of mesh.primitives) {
if (primitive.indices !== undefined) triangles += (gltf.accessors?.[primitive.indices]?.count ?? 0) / 3;
}
}
const meta = gltf.nodes?.find((node) => typeof node.extras?.thingportPreview === "string")?.extras?.thingportPreview;
const simplified = meta ? Boolean((JSON.parse(meta) as { simplified?: unknown }).simplified) : false;
return { triangles, simplified };
} finally {
await handle.close();
}
}
/** After the Rendering "simplify" setting changes, removes the cached previews that would now
* come out differently -- turned on: the ones over the triangle budget; turned off: the ones
* that were simplified -- so they're rebuilt the next time they're viewed. Every other preview
* is left as it is. Returns how many were removed. */
export async function dropPreviewsAffectedBySimplification(simplify: boolean): Promise<number> {
const suffix = `.v${PREVIEW_FORMAT_VERSION}.glb`;
let removed = 0;
let names: string[];
try {
names = await fs.readdir(MODEL_PREVIEWS);
} catch {
return 0;
}
for (const name of names) {
if (!name.endsWith(suffix)) continue;
const file = path.join(MODEL_PREVIEWS, name);
try {
const summary = await readGlbSummary(file);
if (!summary) continue;
const affected = simplify ? !summary.simplified && summary.triangles > SIMPLIFY_TARGET_TRIANGLES : summary.simplified;
if (affected) {
await fs.rm(file, { force: true });
removed++;
}
} catch (err) {
console.warn(`Couldn't check cached preview ${name} after a simplification change`, err);
}
}
return removed;
}
+93 -1
View File
@@ -788,14 +788,106 @@ async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group
return root;
}
// ---- Simplification (Administration > Rendering, off by default) --------------------------------
// The triangle budget itself comes in through RenderOptions (modelPreviewCache.ts's
// SIMPLIFY_TARGET_TRIANGLES), shared between a model's merged meshes in proportion to their size.
// Meshes this small are left alone -- nothing to gain, and a small part would lose its shape.
const SIMPLIFY_MIN_MESH_TRIANGLES = 2_000;
// The simplifier stops short of its target rather than change the shape by more than this
// fraction of the mesh's size (1%).
const SIMPLIFY_MAX_ERROR = 0.01;
const UNUSED_VERTEX = 0xffffffff;
export type SimplifiedSummary = { from: number; to: number };
function meshTriangleCount(mesh: import("three").Mesh): number {
return (mesh.geometry.index?.count ?? 0) / 3;
}
/** Simplifies every merged mesh under `root` (in place) when their total exceeds `budget`
* triangles, with meshoptimizer's edge-collapse simplifier; returns the before/after triangle
* counts, or null when the model was already within budget. Normals are recomputed from the
* simplified surface. */
async function simplifyGroupMeshes(
root: import("three").Group,
budget: number,
): Promise<SimplifiedSummary | null> {
const THREE = await import("three");
const meshes: InstanceType<typeof THREE.Mesh>[] = [];
root.traverse((obj) => {
if ((obj as InstanceType<typeof THREE.Mesh>).isMesh) meshes.push(obj as InstanceType<typeof THREE.Mesh>);
});
const total = meshes.reduce((sum, mesh) => sum + meshTriangleCount(mesh), 0);
if (total <= budget) return null;
// ESM-only; this CommonJS build turns the import() into require(), which loads ES modules from
// Node 20.19 on (hence package.json's engines).
const { MeshoptSimplifier } = await import("meshoptimizer");
await MeshoptSimplifier.ready;
let after = 0;
for (const mesh of meshes) {
const count = meshTriangleCount(mesh);
const index = mesh.geometry.index;
if (!index || count <= SIMPLIFY_MIN_MESH_TRIANGLES) {
after += count;
continue;
}
const target = Math.max(SIMPLIFY_MIN_MESH_TRIANGLES, Math.floor((count * budget) / total));
const positions = mesh.geometry.getAttribute("position").array as Float32Array;
const indices = index.array instanceof Uint32Array ? index.array : Uint32Array.from(index.array);
const [simplified] = MeshoptSimplifier.simplify(indices, positions, 3, target * 3, SIMPLIFY_MAX_ERROR, ["LockBorder"]);
// Drop the vertices no triangle uses any more, so the GLB really gets smaller: compactMesh
// renumbers `simplified` in place and says where each old vertex went.
const [remap, uniqueVertices] = MeshoptSimplifier.compactMesh(simplified);
const compacted = new Float32Array(uniqueVertices * 3);
for (let oldIndex = 0; oldIndex < remap.length; oldIndex++) {
const newIndex = remap[oldIndex];
if (newIndex === UNUSED_VERTEX) continue;
compacted[newIndex * 3] = positions[oldIndex * 3];
compacted[newIndex * 3 + 1] = positions[oldIndex * 3 + 1];
compacted[newIndex * 3 + 2] = positions[oldIndex * 3 + 2];
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(compacted, 3));
geometry.setIndex(new THREE.BufferAttribute(simplified, 1));
geometry.computeVertexNormals();
mesh.geometry.dispose();
mesh.geometry = geometry;
after += simplified.length / 3;
}
return { from: total, to: after };
}
// ---- Entry point (called from the worker) ----------------------------------------------------
export type RenderOptions = {
/** Simplify a model over this many triangles down to about that many (see
* simplifyGroupMeshes); null keeps its exact geometry. */
simplifyTo: number | null;
};
/** Parses `srcPath` and writes the preview GLB to `destPath`, or says why it didn't (see
* PreviewRefusal) -- decided before any geometry is built. */
export async function renderModelPreviewGlb(srcPath: string, destPath: string): Promise<"ok" | PreviewRefusal> {
export async function renderModelPreviewGlb(
srcPath: string,
destPath: string,
options: RenderOptions = { simplifyTo: null },
): Promise<"ok" | PreviewRefusal> {
const parsed = await parseThreeMfFast(srcPath);
if (typeof parsed === "string") return parsed;
const group = await buildGlbGroup(parsed);
if (options.simplifyTo) {
const simplified = await simplifyGroupMeshes(group, options.simplifyTo);
// Recorded in the preview itself, so a later change of the setting can tell which cached
// previews it affects without re-rendering them (modelPreviewCache.ts's
// dropPreviewsAffectedBySimplification).
if (simplified) {
const meta = JSON.parse(group.userData.thingportPreview as string) as Record<string, unknown>;
group.userData.thingportPreview = JSON.stringify({ ...meta, simplified });
}
}
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
const result = await new GLTFExporter().parseAsync(group, { binary: true });
await fs.writeFile(destPath, Buffer.from(result as ArrayBuffer));
+4 -4
View File
@@ -1,10 +1,10 @@
import { parentPort, workerData } from "node:worker_threads";
import { renderModelPreviewGlb } from "./modelPreviewRender";
import { renderModelPreviewGlb, type RenderOptions } from "./modelPreviewRender";
// Worker-thread entry point for one preview render (spawned per job by modelPreviewCache.ts, which
// enforces the memory/time limits). Reports exactly one message, then lets the thread exit.
export type ModelPreviewWorkerInput = { srcPath: string; destPath: string };
export type ModelPreviewWorkerInput = { srcPath: string; destPath: string; options: RenderOptions };
export type ModelPreviewWorkerResult =
| { status: "ok" }
| { status: "too-complex" }
@@ -12,10 +12,10 @@ export type ModelPreviewWorkerResult =
| { status: "error"; error: string };
async function run(): Promise<void> {
const { srcPath, destPath } = workerData as ModelPreviewWorkerInput;
const { srcPath, destPath, options } = workerData as ModelPreviewWorkerInput;
let result: ModelPreviewWorkerResult;
try {
result = { status: await renderModelPreviewGlb(srcPath, destPath) };
result = { status: await renderModelPreviewGlb(srcPath, destPath, options) };
} catch (err) {
result = { status: "error", error: err instanceof Error ? err.stack ?? err.message : String(err) };
}
+13
View File
@@ -50,6 +50,19 @@ export async function setAllowRegistrations(value: boolean): Promise<void> {
await setBoolSetting(ALLOW_REGISTRATIONS_KEY, value);
}
// Administration > Rendering: reduce a heavy model's triangle count in its 3D preview (see
// modelPreviewRender.ts's simplifyGroupMeshes). Off by default -- previews then show the model's
// exact geometry, as they always have.
const SIMPLIFY_PREVIEWS_KEY = "simplify_previews";
export async function getSimplifyPreviews(): Promise<boolean> {
return getBoolSetting(SIMPLIFY_PREVIEWS_KEY, false);
}
export async function setSimplifyPreviews(value: boolean): Promise<void> {
await setBoolSetting(SIMPLIFY_PREVIEWS_KEY, value);
}
export type PreviewMode = "automatic" | "on-demand" | "disabled";
const PREVIEW_MODES = new Set<PreviewMode>(["automatic", "on-demand", "disabled"]);
const PREVIEW_MODE_KEY = "preview_mode";
+180
View File
@@ -0,0 +1,180 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { writeZip } from "../src/utils/zipWriter";
import { renderModelPreviewGlb } from "../src/services/modelPreviewRender";
import { dropPreviewsAffectedBySimplification, modelPreviewGlbPath } from "../src/services/modelPreviewCache";
import { getSimplifyPreviews, setSimplifyPreviews } from "../src/services/settingsService";
const app = createApp();
const stamp = Date.now();
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
/** An n x n grid of vertices on a gently curved surface: 2(n-1)^2 triangles, all sharing
* vertices, like a real 3MF mesh. */
function gridMeshXml(n: number): string {
const vertices: string[] = [];
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) vertices.push(`<vertex x="${i}" y="${j}" z="${(Math.sin(i / 10) * Math.cos(j / 10)).toFixed(4)}"/>`);
}
const triangles: string[] = [];
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - 1; j++) {
const a = i * n + j;
triangles.push(`<triangle v1="${a}" v2="${a + 1}" v3="${a + n}"/>`, `<triangle v1="${a + 1}" v2="${a + n + 1}" v3="${a + n}"/>`);
}
}
return `<?xml version="1.0" encoding="UTF-8"?>
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" unit="millimeter">
<resources><object id="1" type="model"><mesh><vertices>${vertices.join("")}</vertices><triangles>${triangles.join("")}</triangles></mesh></object></resources>
<build><item objectid="1" /></build>
</model>`;
}
/** The parts of a GLB's JSON chunk the preview reads back. */
function readGlbJson(file: string): any {
const buf = fsSync.readFileSync(file);
return JSON.parse(buf.subarray(20, 20 + buf.readUInt32LE(12)).toString("utf-8"));
}
function glbTriangles(gltf: any): number {
let total = 0;
for (const mesh of gltf.meshes) for (const primitive of mesh.primitives) total += gltf.accessors[primitive.indices].count / 3;
return total;
}
describe("renderModelPreviewGlb with simplification", () => {
let fixture: string;
let outDir: string;
beforeAll(async () => {
outDir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-simplify-"));
const modelPath = path.join(outDir, "3dmodel.model");
await fs.writeFile(modelPath, gridMeshXml(101)); // 20,000 triangles
fixture = path.join(outDir, "grid.3mf");
await writeZip(fixture, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]);
});
it("reduces a model over the budget to about the budget, and notes it in the preview", async () => {
const dest = path.join(outDir, "simplified.glb");
expect(await renderModelPreviewGlb(fixture, dest, { simplifyTo: 5_000 })).toBe("ok");
const gltf = readGlbJson(dest);
const triangles = glbTriangles(gltf);
expect(triangles).toBeLessThanOrEqual(5_000);
expect(triangles).toBeGreaterThan(2_000);
// Unused vertices are dropped too, so the file really shrinks.
const positions = gltf.accessors[gltf.meshes[0].primitives[0].attributes.POSITION].count;
expect(positions).toBeLessThan(101 * 101 / 2);
const meta = JSON.parse(gltf.nodes.find((n: any) => n.extras?.thingportPreview).extras.thingportPreview);
expect(meta.simplified).toEqual({ from: 20_000, to: triangles });
});
it("leaves a model within the budget exactly as it is", async () => {
const dest = path.join(outDir, "untouched.glb");
await renderModelPreviewGlb(fixture, dest, { simplifyTo: 50_000 });
const gltf = readGlbJson(dest);
expect(glbTriangles(gltf)).toBe(20_000);
const meta = JSON.parse(gltf.nodes.find((n: any) => n.extras?.thingportPreview).extras.thingportPreview);
expect(meta.simplified).toBeUndefined();
});
});
/** A GLB holding only a JSON chunk -- all the simplification check reads. */
async function writeFakeGlb(file: string, triangles: number, simplified: boolean): Promise<void> {
const json = Buffer.from(
JSON.stringify({
asset: { version: "2.0" },
accessors: [{ count: triangles * 3 }],
meshes: [{ primitives: [{ indices: 0 }] }],
nodes: [{ extras: { thingportPreview: JSON.stringify(simplified ? { simplified: { from: triangles * 2, to: triangles } } : {}) } }],
}),
);
const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
const header = Buffer.alloc(20);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(20 + padded.length, 8);
header.writeUInt32LE(padded.length, 12);
header.writeUInt32LE(0x4e4f534a, 16);
await fs.writeFile(file, Buffer.concat([header, padded]));
}
describe("dropPreviewsAffectedBySimplification", () => {
const ids = {
heavy: `test-fixture-simplify-heavy-${stamp}`,
light: `test-fixture-simplify-light-${stamp}`,
simplified: `test-fixture-simplify-done-${stamp}`,
};
beforeAll(async () => {
await fs.mkdir(path.dirname(modelPreviewGlbPath("x")), { recursive: true });
});
afterAll(async () => {
for (const id of Object.values(ids)) await fs.rm(modelPreviewGlbPath(id), { force: true });
});
async function writeAll() {
await writeFakeGlb(modelPreviewGlbPath(ids.heavy), 3_000_000, false);
await writeFakeGlb(modelPreviewGlbPath(ids.light), 200_000, false);
await writeFakeGlb(modelPreviewGlbPath(ids.simplified), 1_000_000, true);
}
it("turned on: removes only unsimplified previews over the budget", async () => {
await writeAll();
await dropPreviewsAffectedBySimplification(true);
expect(fsSync.existsSync(modelPreviewGlbPath(ids.heavy))).toBe(false);
expect(fsSync.existsSync(modelPreviewGlbPath(ids.light))).toBe(true);
expect(fsSync.existsSync(modelPreviewGlbPath(ids.simplified))).toBe(true);
});
it("turned off: removes only the simplified previews", async () => {
await writeAll();
await dropPreviewsAffectedBySimplification(false);
expect(fsSync.existsSync(modelPreviewGlbPath(ids.heavy))).toBe(true);
expect(fsSync.existsSync(modelPreviewGlbPath(ids.light))).toBe(true);
expect(fsSync.existsSync(modelPreviewGlbPath(ids.simplified))).toBe(false);
});
});
describe("/settings/rendering", () => {
let adminToken: string;
let memberToken: string;
let previous: boolean;
beforeAll(async () => {
previous = await getSimplifyPreviews();
const admin = await request(app)
.post("/api/register")
.send({ displayName: "Rendering Admin", email: `rendering-admin-${stamp}@example.com`, password: "password123" });
await prisma.user.update({ where: { id: admin.body.user.id }, data: { role: "ADMIN" } });
adminToken = (await request(app).post("/api/login").send({ email: `rendering-admin-${stamp}@example.com`, password: "password123" })).body.token;
const member = await request(app)
.post("/api/register")
.send({ displayName: "Rendering Member", email: `rendering-member-${stamp}@example.com`, password: "password123" });
memberToken = member.body.token;
});
afterAll(async () => {
await setSimplifyPreviews(previous);
});
it("is off by default and admin-only", async () => {
await prisma.setting.deleteMany({ where: { key: "simplify_previews" } });
expect((await request(app).get("/api/settings/rendering").set(auth(memberToken))).status).toBe(403);
const res = await request(app).get("/api/settings/rendering").set(auth(adminToken));
expect(res.body).toEqual({ simplify_previews: false });
expect((await request(app).patch("/api/settings/rendering").set(auth(memberToken)).send({ simplify_previews: true })).status).toBe(403);
});
it("saves the admin's choice", async () => {
const res = await request(app).patch("/api/settings/rendering").set(auth(adminToken)).send({ simplify_previews: true });
expect(res.body).toEqual({ simplify_previews: true });
expect(await getSimplifyPreviews()).toBe(true);
});
});
+10 -1
View File
@@ -28,6 +28,7 @@ import LogsPage from "./pages/LogsPage";
import TriggersPage from "./pages/TriggersPage";
import ConnectionsPage from "./pages/ConnectionsPage";
import CaptchaPage from "./pages/CaptchaPage";
import RenderingPage from "./pages/RenderingPage";
import { healthApi, type HealthInfo } from "./api/health";
import { authApi, type AuthUser } from "./api/auth";
import { settingsApi, type PreviewMode } from "./api/settings";
@@ -213,7 +214,15 @@ function AppShell({
path="/admin-settings"
element={
isAdmin
? <AdminSettingsPage onUnauthorized={onUnauthorized} onPreviewModeChanged={setPreviewMode} />
? <AdminSettingsPage onUnauthorized={onUnauthorized} />
: <Navigate to="/" replace />
}
/>
<Route
path="/admin-rendering"
element={
isAdmin
? <RenderingPage onUnauthorized={onUnauthorized} onPreviewModeChanged={setPreviewMode} />
: <Navigate to="/" replace />
}
/>
+21
View File
@@ -13,6 +13,9 @@ export type StorageSettings = {
export type PreviewMode = "automatic" | "on-demand" | "disabled";
/** Administration > Rendering (admin-only). */
export type RenderingSettings = { simplify_previews: boolean };
export type AuthSettings = {
token_ttl_seconds: number;
};
@@ -101,6 +104,24 @@ export const settingsApi = {
return res.json();
},
getRendering: async (): Promise<RenderingSettings> => {
const res = await fetch(`${apiBase()}/settings/rendering`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Failed to load rendering settings");
return res.json();
},
updateRendering: async (settings: RenderingSettings): Promise<RenderingSettings> => {
const res = await fetch(`${apiBase()}/settings/rendering`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(settings),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update rendering settings"));
return res.json();
},
updatePreviews: async (mode: PreviewMode): Promise<{ mode: PreviewMode }> => {
const res = await fetch(`${apiBase()}/settings/previews`, {
method: "POST",
@@ -110,6 +110,9 @@ function useRouteChrome() {
} else if (path.startsWith("/admin-settings")) {
title = t("adminSettings.pageTitle");
onBack = () => navigate("/admin");
} else if (path.startsWith("/admin-rendering")) {
title = t("adminSettings.rendering.heading");
onBack = () => navigate("/admin");
} else if (path.startsWith("/admin-users")) {
title = t("adminSettings.users.heading");
onBack = () => navigate("/admin");
+18
View File
@@ -513,6 +513,24 @@
"collection_item_added": "Added to collection",
"collection_item_removed": "Removed from collection"
}
},
"rendering": {
"heading": "Rendering",
"simplify": {
"heading": "Simplify models before preview",
"subtitle": "Make lighter 3D previews of very detailed models.",
"infoTitle": "How simplification works",
"infoWhat": "When a model has more than 1 million triangles, its 3D preview is reduced to about 1 million. The shape stays the same to within 1% of the model's size, so the preview looks the same on screen.",
"infoFiles": "Only the preview is simplified. Model files, downloads and anything sent to a slicer are never changed.",
"infoBenefit": "Heavy previews download faster and rotate smoothly on phones, tablets and older computers.",
"infoCost": "Making the preview of a heavy model takes a few seconds longer on the server. Models under 1 million triangles are not affected.",
"infoChange": "Changing this setting rebuilds only the previews it affects, the next time each model is opened.",
"label": "Simplify previews of models over 1 million triangles",
"helpOn": "On: previews of models over 1 million triangles are simplified.",
"helpOff": "Off: previews always show a model's full detail.",
"loadFailed": "Failed to load rendering settings.",
"saveFailed": "Failed to save rendering settings."
}
}
},
"download": {
+18
View File
@@ -515,6 +515,24 @@
"collection_item_added": "Pridėta į kolekciją",
"collection_item_removed": "Pašalinta iš kolekcijos"
}
},
"rendering": {
"heading": "Atvaizdavimas",
"simplify": {
"heading": "Supaprastinti modelius prieš peržiūrą",
"subtitle": "Kurti lengvesnes labai detalių modelių 3D peržiūras.",
"infoTitle": "Kaip veikia supaprastinimas",
"infoWhat": "Jei modelis turi daugiau nei 1 milijoną trikampių, jo 3D peržiūra sumažinama iki maždaug 1 milijono. Forma nepakinta daugiau nei 1% modelio dydžio, todėl ekrane peržiūra atrodo taip pat.",
"infoFiles": "Supaprastinama tik peržiūra. Modelių failai, atsisiuntimai ir tai, kas siunčiama į pjaustyklę, niekada nekeičiami.",
"infoBenefit": "Sunkios peržiūros greičiau atsisiunčiamos ir sklandžiai sukasi telefonuose, planšetėse ir senesniuose kompiuteriuose.",
"infoCost": "Sunkaus modelio peržiūros kūrimas serveryje užtrunka keliomis sekundėmis ilgiau. Modeliams, turintiems mažiau nei 1 milijoną trikampių, tai neturi įtakos.",
"infoChange": "Pakeitus šį nustatymą, perkuriamos tik tos peržiūros, kurias jis paveikia, kai modelis bus atidarytas kitą kartą.",
"label": "Supaprastinti modelių, turinčių daugiau nei 1 milijoną trikampių, peržiūras",
"helpOn": "Įjungta: modelių, turinčių daugiau nei 1 milijoną trikampių, peržiūros supaprastinamos.",
"helpOff": "Išjungta: peržiūros visada rodo visas modelio detales.",
"loadFailed": "Nepavyko įkelti atvaizdavimo nustatymų.",
"saveFailed": "Nepavyko išsaugoti atvaizdavimo nustatymų."
}
}
},
"download": {
+2
View File
@@ -19,6 +19,7 @@ import BoltIcon from "@mui/icons-material/Bolt";
import CableIcon from "@mui/icons-material/Cable";
import PublicIcon from "@mui/icons-material/Public";
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import UpdateCheckSection from "./UpdateCheckSection";
import { adminApi, type StorageUsage } from "../../api/admin";
import { UnauthorizedError } from "../../api/client";
@@ -33,6 +34,7 @@ type Section = {
const SECTIONS: Section[] = [
{ path: "/admin-settings", icon: <SettingsIcon fontSize="small" />, labelKey: "common:settings" },
{ path: "/admin-rendering", icon: <ViewInArIcon fontSize="small" />, labelKey: "adminSettings.rendering.heading" },
{ path: "/admin-users", icon: <PeopleIcon fontSize="small" />, labelKey: "adminSettings.users.heading" },
{ path: "/admin-logs", icon: <HistoryIcon fontSize="small" />, labelKey: "adminSettings.logs.heading" },
{ path: "/admin-triggers", icon: <BoltIcon fontSize="small" />, labelKey: "adminSettings.triggers.heading" },
@@ -1,27 +1,24 @@
import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import type { PreviewMode } from "../../api/settings";
import StorageSection from "./StorageSection";
import PreviewsSection from "./PreviewsSection";
import ThingiverseSection from "./ThingiverseSection";
import SessionSection from "./SessionSection";
type Props = {
onUnauthorized?: () => void;
onPreviewModeChanged?: (mode: PreviewMode) => void;
};
// Instance-wide config, gated to admins by the sidebar link that opens this page -- unlike
// SettingsPage, nothing here is a per-user preference. All sections are shown together on one
// page rather than behind separate click-through sections, since none of them is long enough to
// need its own screen. Update Checker lives on the Administration hub instead (see AdminPage) --
// need its own screen. Model preview settings live on their own Rendering page (see
// RenderingPage). Update Checker lives on the Administration hub instead (see AdminPage) --
// it's a hub-level status, not a per-instance setting.
export default function AdminSettingsPage({ onUnauthorized, onPreviewModeChanged }: Props) {
export default function AdminSettingsPage({ onUnauthorized }: Props) {
return (
<Stack spacing={4} divider={<Divider />}>
<ThingiverseSection onUnauthorized={onUnauthorized} />
<StorageSection onUnauthorized={onUnauthorized} />
<PreviewsSection onUnauthorized={onUnauthorized} onSaved={onPreviewModeChanged} />
<SessionSection onUnauthorized={onUnauthorized} />
</Stack>
);
@@ -0,0 +1,100 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import SectionHeader from "../../components/SectionHeader";
type Props = {
onUnauthorized?: () => void;
};
/** "Simplify models before preview": the backend reduces a heavy model's 3D preview to about 1M
* triangles (see backend/src/services/modelPreviewRender.ts's simplifyGroupMeshes). Off by
* default; saves as soon as it's switched. */
export default function SimplifySection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [enabled, setEnabled] = React.useState<boolean | null>(null);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
settingsApi.getRendering().then(
(settings) => { if (active) setEnabled(settings.simplify_previews); },
(err) => {
if (!active) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.rendering.simplify.loadFailed"));
},
);
return () => { active = false; };
}, [onUnauthorized, t]);
const toggle = async (next: boolean) => {
const previous = enabled;
setEnabled(next);
setSaving(true);
setError(null);
try {
setEnabled((await settingsApi.updateRendering({ simplify_previews: next })).simplify_previews);
} catch (err) {
setEnabled(previous);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.rendering.simplify.saveFailed"));
} finally {
setSaving(false);
}
};
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.rendering.simplify.heading")}
subtitle={t("adminSettings.rendering.simplify.subtitle")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Alert severity="info">
<AlertTitle>{t("adminSettings.rendering.simplify.infoTitle")}</AlertTitle>
{t("adminSettings.rendering.simplify.infoWhat")}
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2.5 }}>
<li><Typography variant="body2">{t("adminSettings.rendering.simplify.infoFiles")}</Typography></li>
<li><Typography variant="body2">{t("adminSettings.rendering.simplify.infoBenefit")}</Typography></li>
<li><Typography variant="body2">{t("adminSettings.rendering.simplify.infoCost")}</Typography></li>
<li><Typography variant="body2">{t("adminSettings.rendering.simplify.infoChange")}</Typography></li>
</Box>
</Alert>
<Stack direction="row" spacing={1} alignItems="center">
<FormControlLabel
control={
<Switch
checked={enabled ?? false}
disabled={enabled === null || saving}
onChange={(e) => void toggle(e.target.checked)}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>{t("adminSettings.rendering.simplify.label")}</Typography>
<Typography variant="caption" color="text.secondary">{t(enabled ? "adminSettings.rendering.simplify.helpOn" : "adminSettings.rendering.simplify.helpOff")}</Typography>
</Box>
}
sx={{ alignItems: "flex-start", m: 0, "& .MuiSwitch-root": { mt: -0.5 } }}
/>
{saving && <CircularProgress size={16} />}
</Stack>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
</Stack>
</Paper>
</Stack>
);
}
@@ -0,0 +1,22 @@
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import type { PreviewMode } from "../../api/settings";
import PreviewsSection from "./PreviewsSection";
import SimplifySection from "./SimplifySection";
type Props = {
onUnauthorized?: () => void;
onPreviewModeChanged?: (mode: PreviewMode) => void;
};
/** Administration > Rendering: instance-wide settings for how model previews are made -- when
* they're generated (PreviewsSection) and whether heavy models are simplified for them
* (SimplifySection). */
export default function RenderingPage({ onUnauthorized, onPreviewModeChanged }: Props) {
return (
<Stack spacing={4} divider={<Divider />}>
<PreviewsSection onUnauthorized={onUnauthorized} onSaved={onPreviewModeChanged} />
<SimplifySection onUnauthorized={onUnauthorized} />
</Stack>
);
}