mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: anable complex model simplification
This commit is contained in:
1 parent
65236c5803
commit
7464583780
18 files changed
+610
-18
No files matched your search
Generated
+11
-4
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
File renamed without changes.
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user