feat: add setting to customize dashboard model thumbnail appearance

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-10 23:22:02 +03:00
1 parent c71154a83e
commit 163d73f2a7
23 files changed
+744 -41

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "dashboardThumbnails" TEXT;
+2
View File
@@ -53,6 +53,8 @@ model User {
dashboardWidgets Json?
// How the dashboard arranges its boxes, one of DASHBOARD_LAYOUTS; null means "left".
dashboardLayout String?
// The dashboard lists' model pictures, one of DASHBOARD_THUMBNAILS; null means "photos".
dashboardThumbnails String?
// Where the dashboard's weather is for: { lat, lon, name, source }. Null until the user sets one.
weatherLocation Json?
authorPreviewEnabled Boolean @default(true)
+1
View File
@@ -23,6 +23,7 @@ function modelOut(m: DashboardModel) {
id: m.id,
name: m.name,
thumb_url: m.thumbUrl,
model_image_url: m.modelImageUrl,
view_count: m.viewCount,
print_count: m.printCount,
created_at: m.createdAt,
+40 -1
View File
@@ -24,7 +24,15 @@ import {
} from "../services/printService";
import { addGeneratedPreviewImageIfNone } from "../services/previewImageService";
import { printOutById } from "../services/printLoader";
import { generateModelPreviewGlb, modelPreviewGlbPath, modelPreviewState } from "../services/modelPreviewCache";
import {
generateModelPreviewGlb,
modelPreviewGlbPath,
modelPreviewState,
generateModelImage,
modelImagePath,
modelImageState,
canRenderModelImage,
} from "../services/modelPreviewCache";
import { getPreviewMode } from "../services/settingsService";
const router = Router();
@@ -185,6 +193,37 @@ router.get(
}),
);
// The model drawn in one colour, for small slots like the dashboard lists. Built on first
// request for models from before pictures existed; new uploads queue theirs straight away.
router.get(
"/plate/:plateId/model.png",
asyncHandler(async (req, res) => {
const plate = await prisma.plate.findFirst({
where: { id: req.params.plateId, print: { userId: req.userId } },
});
if (!plate) throw new HttpError(404, "Not found");
const imagePath = modelImagePath(plate.id);
if (!fs.existsSync(imagePath)) {
if (!canRenderModelImage(plate.filename))
throw new HttpError(404, "No picture for this file type", "PREVIEW_UNSUPPORTED");
if ((await getPreviewMode()) === "disabled") {
throw new HttpError(404, "Preview generation is disabled", "PREVIEW_DISABLED");
}
const srcPath = resolvePlateFilePath(plate);
if (!srcPath) throw new HttpError(404, "Model file is missing", "PREVIEW_FAILED");
void generateModelImage(plate.id, srcPath, plate.filename);
if (modelImageState(plate.id) === "failed") {
throw new HttpError(404, "No picture could be made for this model", "PREVIEW_FAILED");
}
throw new HttpError(404, "Picture is being made", "PREVIEW_GENERATING");
}
res.setHeader("Content-Type", "image/png");
// Versioned URLs never change; the bare one is how a missing picture gets asked for.
res.setHeader("Cache-Control", req.query.v ? "private, max-age=31536000, immutable" : "private, no-cache");
res.sendFile(path.resolve(imagePath));
}),
);
router.post(
"/plate/:plateId/thumbnail-generated",
thumbnailUpload.single("file"),
+19
View File
@@ -39,10 +39,13 @@ import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPref
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
import {
DASHBOARD_LAYOUTS,
DASHBOARD_THUMBNAILS,
DASHBOARD_WIDGETS,
getDashboardLayout,
getDashboardThumbnails,
getDashboardWidgets,
setDashboardLayout,
setDashboardThumbnails,
setDashboardWidgets,
} from "../services/dashboardWidgetsService";
import {
@@ -471,6 +474,22 @@ router.patch(
}),
);
router.get(
"/settings/dashboard-thumbnails",
asyncHandler(async (req, res) => {
res.json({ thumbnails: await getDashboardThumbnails(req.userId!) });
}),
);
const dashboardThumbnailsSchema = z.object({ thumbnails: z.enum(DASHBOARD_THUMBNAILS) });
router.patch(
"/settings/dashboard-thumbnails",
asyncHandler(async (req, res) => {
const body = parseBody(dashboardThumbnailsSchema, req.body);
res.json({ thumbnails: await setDashboardThumbnails(req.userId!, body.thumbnails) });
}),
);
router.get(
"/settings/weather-location",
asyncHandler(async (req, res) => {
+16 -3
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { createHash } from "node:crypto";
import { prisma } from "../db";
import { RENDERABLE_MODEL_EXTS } from "../config";
import { canRenderModelImage, modelImagePath } from "./modelPreviewCache";
import { plateThumbExists, plateThumbPath } from "./printService";
const TOP_MODELS_PREVIEW = 3;
@@ -15,6 +16,8 @@ export type DashboardModel = {
id: string;
name: string;
thumbUrl: string | null;
/** The model drawn in one colour. Unversioned until it exists: asking for it queues it. */
modelImageUrl: string | null;
viewCount: number;
printCount: number;
createdAt: Date;
@@ -51,11 +54,20 @@ type PrintForModelSummary = {
viewCount: number;
printCount: number;
createdAt: Date;
plates: { id: string }[];
plates: { id: string; filename: string }[];
};
function modelImageUrl(plate: { id: string; filename: string }): string | null {
if (!canRenderModelImage(plate.filename)) return null;
const file = modelImagePath(plate.id);
return fs.existsSync(file)
? `/plate/${plate.id}/model.png?v=${fs.statSync(file).mtimeMs}`
: `/plate/${plate.id}/model.png`;
}
export function toModelSummary(print: PrintForModelSummary): DashboardModel {
const plateId = print.plates[0]?.id ?? null;
const first = print.plates[0];
const plateId = first?.id ?? null;
let thumbUrl: string | null = null;
if (plateId && plateThumbExists(plateId)) {
const mtime = fs.statSync(plateThumbPath(plateId)).mtimeMs;
@@ -65,6 +77,7 @@ export function toModelSummary(print: PrintForModelSummary): DashboardModel {
id: print.id,
name: print.name,
thumbUrl,
modelImageUrl: first ? modelImageUrl(first) : null,
viewCount: print.viewCount,
printCount: print.printCount,
createdAt: print.createdAt,
@@ -77,7 +90,7 @@ export const MODEL_SUMMARY_SELECT = {
viewCount: true,
printCount: true,
createdAt: true,
plates: { take: 1, orderBy: { position: "asc" as const }, select: { id: true } },
plates: { take: 1, orderBy: { position: "asc" as const }, select: { id: true, filename: true } },
};
async function fetchTopViewed(userId: string, limit: number): Promise<DashboardModel[]> {
@@ -63,3 +63,21 @@ export async function setDashboardLayout(userId: string, layout: DashboardLayout
await prisma.user.update({ where: { id: userId }, data: { dashboardLayout: layout } });
return layout;
}
// Kept in sync by hand with the frontend's DashboardThumbnails. "monochrome" is the server's own
// one-colour picture of the model, "photos" its preview image.
export const DASHBOARD_THUMBNAILS = ["none", "photos", "monochrome"] as const;
export type DashboardThumbnails = (typeof DASHBOARD_THUMBNAILS)[number];
export async function getDashboardThumbnails(userId: string): Promise<DashboardThumbnails> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { dashboardThumbnails: true } });
return DASHBOARD_THUMBNAILS.find((option) => option === user?.dashboardThumbnails) ?? "photos";
}
export async function setDashboardThumbnails(
userId: string,
thumbnails: DashboardThumbnails,
): Promise<DashboardThumbnails> {
await prisma.user.update({ where: { id: userId }, data: { dashboardThumbnails: thumbnails } });
return thumbnails;
}
+221
View File
@@ -0,0 +1,221 @@
import fs from "node:fs/promises";
import sharp from "sharp";
import { loadThreeMfGroup, type PreviewRefusal } from "./modelPreviewRender";
// A small picture of a model: one colour, simple light, transparent background. A software
// rasterizer rather than WebGL, so it runs anywhere Node does, with no GPU or native deps.
//
// Runs in a worker thread (modelPreviewWorker.ts); keep it free of config/db imports.
// Past this the picture wouldn't look different, and the render would only take longer.
const MAX_TRIANGLES = 4_000_000;
// Drawn this many times larger in each direction, then averaged down for smooth edges.
const SUPERSAMPLE = 3;
// Margin around the model, as a fraction of the image.
const FILL = 0.9;
// A model lower than this fraction of its width is seen from above as well, not edge-on.
const FLAT_RATIO = 0.35;
const AMBIENT = 0.3;
export type ModelImageOptions = { size: number; color: string };
type Vec = [number, number, number];
const sub = (a: Vec, b: Vec): Vec => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vec, b: Vec): Vec => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const normalize = (a: Vec): Vec => {
const length = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / length, a[1] / length, a[2] / length];
};
/** Every triangle as 9 floats, in a Y-up world. */
async function loadTriangles(srcPath: string, ext: string): Promise<Float32Array | PreviewRefusal> {
const THREE = await import("three");
let root: import("three").Object3D;
if (ext === ".3mf") {
const group = await loadThreeMfGroup(srcPath);
if (typeof group === "string") return group;
// Only the first plate: a multi-plate project laid side by side would draw each part tiny.
root = group.children.find((plate) => plate.children.length > 0) ?? group;
} else if (ext === ".stl") {
const { STLLoader } = await import("three/examples/jsm/loaders/STLLoader.js");
const buffer = await fs.readFile(srcPath);
const geometry = new STLLoader().parse(
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
);
root = new THREE.Mesh(geometry);
// Print files are Z-up.
root.rotateX(-Math.PI / 2);
} else if (ext === ".obj") {
const { OBJLoader } = await import("three/examples/jsm/loaders/OBJLoader.js");
root = new OBJLoader().parse(await fs.readFile(srcPath, "utf8"));
root.rotateX(-Math.PI / 2);
} else {
return "unsupported";
}
root.updateMatrixWorld(true);
const meshes: import("three").Mesh[] = [];
let count = 0;
root.traverse((node) => {
const mesh = node as import("three").Mesh;
if (!mesh.isMesh || !mesh.geometry.getAttribute("position")) return;
meshes.push(mesh);
const index = mesh.geometry.getIndex();
count += (index ? index.count : mesh.geometry.getAttribute("position").count) / 3;
});
if (count > MAX_TRIANGLES) return "too-complex";
const out = new Float32Array(Math.floor(count) * 9);
const vertex = new THREE.Vector3();
let offset = 0;
for (const mesh of meshes) {
const position = mesh.geometry.getAttribute("position");
const index = mesh.geometry.getIndex();
const total = index ? index.count : position.count;
for (let i = 0; i + 2 < total; i += 3) {
for (let corner = 0; corner < 3; corner++) {
vertex
.fromBufferAttribute(position, index ? index.getX(i + corner) : i + corner)
.applyMatrix4(mesh.matrixWorld);
out[offset++] = vertex.x;
out[offset++] = vertex.y;
out[offset++] = vertex.z;
}
}
}
return out.subarray(0, offset);
}
function parseHex(color: string): Vec {
const value = Number.parseInt(color.replace("#", ""), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
/** Draws the model to a square PNG at `destPath`. */
export async function renderModelImage(
srcPath: string,
destPath: string,
ext: string,
{ size, color }: ModelImageOptions,
): Promise<"ok" | PreviewRefusal> {
const triangles = await loadTriangles(srcPath, ext);
if (typeof triangles === "string") return triangles;
if (triangles.length === 0) return "unsupported";
const min: Vec = [Infinity, Infinity, Infinity];
const max: Vec = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < triangles.length; i += 3) {
for (let axis = 0; axis < 3; axis++) {
min[axis] = Math.min(min[axis], triangles[i + axis]);
max[axis] = Math.max(max[axis], triangles[i + axis]);
}
}
const center: Vec = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
const flat = max[1] - min[1] < FLAT_RATIO * Math.max(max[0] - min[0], max[2] - min[2]);
// Towards the camera: straight from the front, or front and above for a flat model.
const toCamera = normalize(flat ? [0, 1, 1] : [0, 0, 1]);
const right = normalize(cross([0, 1, 0], toCamera));
const up = cross(toCamera, right);
// From above and to the left of the camera, so faces turned towards it still differ in shade.
const light = normalize([
toCamera[0] * 0.55 + up[0] - right[0] * 0.75,
toCamera[1] * 0.55 + up[1] - right[1] * 0.75,
toCamera[2] * 0.55 + up[2] - right[2] * 0.75,
]);
// Project once: screen x, screen y, depth (larger is nearer).
const projected = new Float32Array(triangles.length);
let extent = 0;
for (let i = 0; i < triangles.length; i += 3) {
const offset = sub([triangles[i], triangles[i + 1], triangles[i + 2]], center);
const x = dot(offset, right);
const y = dot(offset, up);
projected[i] = x;
projected[i + 1] = y;
projected[i + 2] = dot(offset, toCamera);
extent = Math.max(extent, Math.abs(x), Math.abs(y));
}
const width = size * SUPERSAMPLE;
const scale = ((width / 2) * FILL) / (extent || 1);
const depth = new Float32Array(width * width).fill(-Infinity);
const shade = new Float32Array(width * width);
for (let t = 0; t < triangles.length; t += 9) {
const a: Vec = [triangles[t], triangles[t + 1], triangles[t + 2]];
let normal = normalize(
cross(
sub([triangles[t + 3], triangles[t + 4], triangles[t + 5]], a),
sub([triangles[t + 6], triangles[t + 7], triangles[t + 8]], a),
),
);
// Lit from the side facing the camera, whatever the file's winding says.
if (dot(normal, toCamera) < 0) normal = [-normal[0], -normal[1], -normal[2]];
const brightness = AMBIENT + (1 - AMBIENT) * Math.max(0, dot(normal, light));
const x0 = width / 2 + projected[t] * scale,
y0 = width / 2 - projected[t + 1] * scale,
z0 = projected[t + 2];
const x1 = width / 2 + projected[t + 3] * scale,
y1 = width / 2 - projected[t + 4] * scale,
z1 = projected[t + 5];
const x2 = width / 2 + projected[t + 6] * scale,
y2 = width / 2 - projected[t + 7] * scale,
z2 = projected[t + 8];
const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
if (area === 0) continue;
const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
const maxX = Math.min(width - 1, Math.ceil(Math.max(x0, x1, x2)));
const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
const maxY = Math.min(width - 1, Math.ceil(Math.max(y0, y1, y2)));
for (let py = minY; py <= maxY; py++) {
const cy = py + 0.5;
for (let px = minX; px <= maxX; px++) {
const cx = px + 0.5;
const w0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) / area;
const w1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) / area;
const w2 = 1 - w0 - w1;
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
const z = w0 * z0 + w1 * z1 + w2 * z2;
const at = py * width + px;
if (z <= depth[at]) continue;
depth[at] = z;
shade[at] = brightness;
}
}
}
// Averages each block of samples: covered ones give colour and opacity, empty ones only transparency.
const [r, g, b] = parseHex(color);
const pixels = Buffer.alloc(size * size * 4);
const samples = SUPERSAMPLE * SUPERSAMPLE;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let covered = 0;
let lit = 0;
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
const at = (y * SUPERSAMPLE + sy) * width + x * SUPERSAMPLE + sx;
if (depth[at] === -Infinity) continue;
covered++;
lit += shade[at];
}
}
if (!covered) continue;
const brightness = lit / covered;
const out = (y * size + x) * 4;
pixels[out] = Math.min(255, Math.round(r * brightness));
pixels[out + 1] = Math.min(255, Math.round(g * brightness));
pixels[out + 2] = Math.min(255, Math.round(b * brightness));
pixels[out + 3] = Math.round((covered / samples) * 255);
}
}
await sharp(pixels, { raw: { width: size, height: size, channels: 4 } })
.png()
.toFile(destPath);
return "ok";
}
+72 -2
View File
@@ -46,8 +46,7 @@ const ERROR_RETRY_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
const PERMANENT_FAILURE_PREFIX = "permanent: ";
const UNSUPPORTED_MARKER = `${PERMANENT_FAILURE_PREFIX}unsupported`;
function recentlyFailed(plateId: string): boolean {
const errorPath = modelPreviewErrorPath(plateId);
function recentlyFailed(plateId: string, errorPath = modelPreviewErrorPath(plateId)): boolean {
try {
const stat = fsSync.statSync(errorPath);
if (Date.now() - stat.mtimeMs < ERROR_RETRY_COOLDOWN_MS) return true;
@@ -175,6 +174,7 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
await fs.writeFile(pendingPath, new Date().toISOString());
const simplify = await getSimplifyPreviews();
const outcome = await renderInWorker({
kind: "glb",
srcPath,
destPath: tmp,
options: { simplifyTo: simplify ? SIMPLIFY_TARGET_TRIANGLES : null },
@@ -211,6 +211,76 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
}
}
// Pictures of a model for small slots like the dashboard lists: near-black on transparent.
// They share the GLB queue and limits, so the two never render at once.
/** Server-side picture formats; STEP needs a CAD kernel the server doesn't have. */
export const MODEL_IMAGE_EXTS = new Set([".stl", ".obj", ".3mf"]);
// Part of the filename, so changing the look regenerates older pictures on next request.
const MODEL_IMAGE_VERSION = 2;
const MODEL_IMAGE_SIZE = 128;
// Shading scales this down, so it comes out between near-black in shadow and charcoal in the light.
// The dashboard inverts it for the dark theme.
const MODEL_IMAGE_COLOR = "#5a5a5a";
export function modelImagePath(plateId: string): string {
return path.join(MODEL_PREVIEWS, `${plateId}.image.v${MODEL_IMAGE_VERSION}.png`);
}
function modelImageErrorPath(plateId: string): string {
return path.join(MODEL_PREVIEWS, `${plateId}.image.v${MODEL_IMAGE_VERSION}.error`);
}
export function canRenderModelImage(filename: string): boolean {
return MODEL_IMAGE_EXTS.has(path.extname(filename).toLowerCase());
}
const imagesInFlight = new Set<string>();
/** Never throws. Concurrent calls for the same plate no-op; a failure isn't retried for a while. */
export async function generateModelImage(plateId: string, srcPath: string, filename: string): Promise<void> {
const errorPath = modelImageErrorPath(plateId);
if (!canRenderModelImage(filename) || fsSync.existsSync(modelImagePath(plateId))) return;
if (imagesInFlight.has(plateId) || recentlyFailed(plateId, errorPath)) return;
imagesInFlight.add(plateId);
const run = queue.then(async () => {
const dest = modelImagePath(plateId);
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
try {
const outcome = await renderInWorker({
kind: "image",
srcPath,
destPath: tmp,
ext: path.extname(filename).toLowerCase(),
image: { size: MODEL_IMAGE_SIZE, color: MODEL_IMAGE_COLOR },
});
if (outcome.status === "ok") {
await fs.rename(tmp, dest);
await fs.rm(errorPath, { force: true });
} else {
const reason =
outcome.status === "error" ? outcome.error : outcome.status === "limit" ? outcome.reason : outcome.status;
if (outcome.status === "error") console.error(`Model picture failed for plate ${plateId}:`, reason);
// Only an unexpected error is worth another try later; the rest would just repeat.
await fs.writeFile(errorPath, outcome.status === "error" ? reason : `${PERMANENT_FAILURE_PREFIX}${reason}`);
}
} catch (err) {
console.error(`Model picture failed for plate ${plateId}:`, err);
await fs.writeFile(errorPath, String(err)).catch(() => undefined);
} finally {
await fs.rm(tmp, { force: true }).catch(() => undefined);
imagesInFlight.delete(plateId);
}
});
queue = run;
await run;
}
export function modelImageState(plateId: string): "ready" | "generating" | "failed" {
if (fsSync.existsSync(modelImagePath(plateId))) return "ready";
if (imagesInFlight.has(plateId)) return "generating";
return recentlyFailed(plateId, modelImageErrorPath(plateId)) ? "failed" : "generating";
}
type GlbSummary = { triangles: number; simplified: boolean };
/** Reads only a cached GLB's JSON chunk. Null for a file this renderer didn't write. */
@@ -720,6 +720,13 @@ const SIMPLIFY_MIN_MESH_TRIANGLES = 2_000;
const SIMPLIFY_MAX_ERROR = 0.01;
const UNUSED_VERTEX = 0xffffffff;
/** The 3MF as a three.js group, one child group per plate, already turned Y-up. */
export async function loadThreeMfGroup(srcPath: string): Promise<import("three").Group | PreviewRefusal> {
const parsed = await parseThreeMfFast(srcPath);
if (typeof parsed === "string") return parsed;
return buildGlbGroup(parsed);
}
export type SimplifiedSummary = { from: number; to: number };
function meshTriangleCount(mesh: import("three").Mesh): number {
+12 -4
View File
@@ -1,17 +1,25 @@
import { parentPort, workerData } from "node:worker_threads";
import { renderModelPreviewGlb, type RenderOptions } from "./modelPreviewRender";
import { renderModelImage, type ModelImageOptions } from "./modelImageRender";
// Worker entry point for one preview render. Posts exactly one message, then exits.
// Worker entry point for one preview render, a GLB or a picture. Posts exactly one message, then exits.
export type ModelPreviewWorkerInput = { srcPath: string; destPath: string; options: RenderOptions };
export type ModelPreviewWorkerInput =
| { kind: "glb"; srcPath: string; destPath: string; options: RenderOptions }
| { kind: "image"; srcPath: string; destPath: string; ext: string; image: ModelImageOptions };
export type ModelPreviewWorkerResult =
{ status: "ok" } | { status: "too-complex" } | { status: "unsupported" } | { status: "error"; error: string };
async function run(): Promise<void> {
const { srcPath, destPath, options } = workerData as ModelPreviewWorkerInput;
const input = workerData as ModelPreviewWorkerInput;
let result: ModelPreviewWorkerResult;
try {
result = { status: await renderModelPreviewGlb(srcPath, destPath, options) };
result = {
status:
input.kind === "glb"
? await renderModelPreviewGlb(input.srcPath, input.destPath, input.options)
: await renderModelImage(input.srcPath, input.destPath, input.ext, input.image),
};
} catch (err) {
result = { status: "error", error: err instanceof Error ? (err.stack ?? err.message) : String(err) };
}
+5 -1
View File
@@ -16,7 +16,7 @@ import {
renderPlateStoragePath,
saveThumbFromFile,
} from "./printService";
import { generateModelPreviewGlb } from "./modelPreviewCache";
import { generateModelPreviewGlb, canRenderModelImage, generateModelImage } from "./modelPreviewCache";
import { deleteNormalized3mf } from "./normalized3mfCache";
import { getPreviewMode } from "./settingsService";
import { Prisma } from "@prisma/client";
@@ -93,6 +93,10 @@ async function thumbnailAndSniff(plateId: string, filename: string, mime: string
// "on-demand" builds the preview on first view, "disabled" never. Not awaited: it can be slow.
if ((await getPreviewMode()) === "automatic") void generateModelPreviewGlb(plateId, effectivePath);
}
// Queued behind the GLB, which the model page needs first.
if (canRenderModelImage(filename) && (await getPreviewMode()) === "automatic") {
void generateModelImage(plateId, effectivePath, filename);
}
}
export function resolvePlateFilePath(plate: Pick<Plate, "storagePath" | "sourcePath">): string | null {
+18
View File
@@ -72,6 +72,24 @@ describe("dashboard layout preference", () => {
});
});
describe("dashboard thumbnails preference", () => {
const getThumbnails = async () =>
(await request(app).get("/api/settings/dashboard-thumbnails").set(auth())).body.thumbnails;
const setThumbnails = (thumbnails: unknown) =>
request(app).patch("/api/settings/dashboard-thumbnails").set(auth()).send({ thumbnails });
it("starts on photos and keeps a chosen option", async () => {
expect(await getThumbnails()).toBe("photos");
expect((await setThumbnails("monochrome")).body.thumbnails).toBe("monochrome");
expect(await getThumbnails()).toBe("monochrome");
expect((await setThumbnails("none")).body.thumbnails).toBe("none");
});
it("rejects an unknown option", async () => {
expect((await setThumbnails("sepia")).status).toBe(400);
});
});
describe("weather location preference", () => {
const getLocation = async () => (await request(app).get("/api/settings/weather-location").set(auth())).body.location;
const setLocation = (location: unknown) =>
+130
View File
@@ -0,0 +1,130 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { renderModelImage } from "../src/services/modelImageRender";
let dir: string;
// An ASCII STL box, `w` x `d` x `h`, Z-up as print files are.
function boxStl(w: number, d: number, h: number): string {
const v = (x: number, y: number, z: number) => `vertex ${x * w} ${y * d} ${z * h}`;
const faces: [number, number, number][][] = [
[
[0, 0, 0],
[1, 0, 0],
[1, 1, 0],
],
[
[0, 0, 0],
[1, 1, 0],
[0, 1, 0],
],
[
[0, 0, 1],
[1, 1, 1],
[1, 0, 1],
],
[
[0, 0, 1],
[0, 1, 1],
[1, 1, 1],
],
[
[0, 0, 0],
[1, 0, 1],
[1, 0, 0],
],
[
[0, 0, 0],
[0, 0, 1],
[1, 0, 1],
],
[
[0, 1, 0],
[1, 1, 0],
[1, 1, 1],
],
[
[0, 1, 0],
[1, 1, 1],
[0, 1, 1],
],
[
[0, 0, 0],
[0, 1, 0],
[0, 1, 1],
],
[
[0, 0, 0],
[0, 1, 1],
[0, 0, 1],
],
[
[1, 0, 0],
[1, 1, 1],
[1, 1, 0],
],
[
[1, 0, 0],
[1, 0, 1],
[1, 1, 1],
],
];
const facets = faces.map(
(f) => `facet normal 0 0 0\nouter loop\n${f.map((p) => v(...p)).join("\n")}\nendloop\nendfacet`,
);
return `solid box\n${facets.join("\n")}\nendsolid box\n`;
}
async function render(name: string, stl: string) {
const src = path.join(dir, `${name}.stl`);
const dest = path.join(dir, `${name}.png`);
await fs.writeFile(src, stl);
expect(await renderModelImage(src, dest, ".stl", { size: 64, color: "#00b800" })).toBe("ok");
const { data, info } = await sharp(dest).raw().toBuffer({ resolveWithObject: true });
return { data, info };
}
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-model-image-"));
});
afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
describe("model pictures", () => {
it("draws the model in the given colour on a transparent background", async () => {
const { data, info } = await render("cube", boxStl(20, 20, 20));
expect([info.width, info.height, info.channels]).toEqual([64, 64, 4]);
const corner = data.subarray(0, 4);
expect(corner[3]).toBe(0);
const middle = data.subarray((32 * 64 + 32) * 4, (32 * 64 + 32) * 4 + 4);
expect(middle[3]).toBe(255);
// Shaded, but always the accent's hue: green dominant, no red.
expect(middle[1]).toBeGreaterThan(middle[0]);
expect(middle[1]).toBeGreaterThan(middle[2]);
});
it("looks at a flat model from above as well, so it isn't a thin line", async () => {
const coverage = async (name: string, stl: string) => {
const { data } = await render(name, stl);
let covered = 0;
for (let i = 3; i < data.length; i += 4) if (data[i] > 0) covered++;
return covered / (data.length / 4);
};
// Seen straight from the front, a 1 mm high plate would cover almost nothing.
expect(await coverage("plate", boxStl(60, 60, 1))).toBeGreaterThan(0.2);
});
it("refuses a format it can't read", async () => {
expect(
await renderModelImage(path.join(dir, "x.step"), path.join(dir, "x.png"), ".step", {
size: 64,
color: "#00b800",
}),
).toBe("unsupported");
});
});
+2
View File
@@ -6,6 +6,8 @@ export type DashboardModel = {
id: string;
name: string;
thumb_url: string | null;
/** The model drawn in one colour, made by the server. Asking for one that isn't ready queues it. */
model_image_url: string | null;
view_count: number;
print_count: number;
created_at: string;
+19
View File
@@ -32,6 +32,8 @@ export type DashboardWidgetId =
export type WeatherLocation = { lat: number; lon: number; name: string | null; source: "device" | "manual" };
// Kept in sync by hand with the backend's DASHBOARD_LAYOUTS.
export type DashboardLayout = "left" | "top" | "right" | "bottom" | "topTwo" | "puzzle";
// Kept in sync by hand with the backend's DASHBOARD_THUMBNAILS.
export type DashboardThumbnails = "none" | "photos" | "monochrome";
/** Only the widgets the user has switched; the rest follow their default. */
export type DashboardWidgetChoices = Partial<Record<DashboardWidgetId, boolean>>;
@@ -421,6 +423,23 @@ export const settingsApi = {
return res.json();
},
getDashboardThumbnails: async (): Promise<{ thumbnails: DashboardThumbnails }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-thumbnails`, { headers: authHeaders() });
assertOk(res, "Failed to load the dashboard thumbnails setting");
return res.json();
},
updateDashboardThumbnails: async (thumbnails: DashboardThumbnails): Promise<{ thumbnails: DashboardThumbnails }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-thumbnails`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ thumbnails }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the dashboard thumbnails setting"));
return res.json();
},
getDashboardLayout: async (): Promise<{ layout: DashboardLayout }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-layout`, { headers: authHeaders() });
assertOk(res, "Failed to load the dashboard layout setting");
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { settingsApi, type DashboardThumbnails } from "../api/settings";
// Saved on the account so every device shows the same pictures. Shared by every user of
// the hook so there's one request, and a switch shows everywhere at once.
let cached: DashboardThumbnails | undefined;
let inFlight: Promise<DashboardThumbnails> | null = null;
const listeners = new Set<(value: DashboardThumbnails) => void>();
function load(): Promise<DashboardThumbnails> {
if (cached !== undefined) return Promise.resolve(cached);
// A switch made while this is in flight is newer than the server's answer, so it wins.
inFlight ??= settingsApi
.getDashboardThumbnails()
.then((res) => (cached ??= res.thumbnails))
.catch((): DashboardThumbnails => cached ?? "photos")
.finally(() => {
inFlight = null;
});
return inFlight;
}
function publish(value: DashboardThumbnails) {
cached = value;
listeners.forEach((listener) => listener(value));
}
/** Null until the saved choice is known, so the lists don't flash the wrong pictures. */
export function useDashboardThumbnails(): [DashboardThumbnails | null, (thumbnails: DashboardThumbnails) => void] {
const [value, setValue] = useState<DashboardThumbnails | null>(cached ?? null);
useEffect(() => {
let cancelled = false;
void load().then((v) => {
if (!cancelled) setValue(cached ?? v);
});
listeners.add(setValue);
return () => {
cancelled = true;
listeners.delete(setValue);
};
}, []);
const update = (thumbnails: DashboardThumbnails) => {
publish(thumbnails);
// The switch already happened here; a failed save only means other devices won't follow.
settingsApi.updateDashboardThumbnails(thumbnails).catch(() => {});
};
return [value, update];
}
+13 -1
View File
@@ -400,7 +400,8 @@
"allHidden": "Every dashboard box is hidden.",
"sections": {
"layout": "Layout",
"panels": "Panels"
"panels": "Panels",
"thumbnails": "Thumbnails"
},
"layoutSubtitle": "Choose how your dashboard arranges its boxes. The coloured part shows where the counts go.",
"layouts": {
@@ -410,6 +411,17 @@
"bottom": "Counts at the bottom, three columns",
"topTwo": "Counts on top, two columns",
"puzzle": "Puzzle"
},
"thumbnailsSubtitle": "The pictures beside models in the dashboard lists.",
"thumbnails": {
"none": "Hidden",
"photos": "Photos",
"monochrome": "Monochrome"
},
"thumbnailsHelp": {
"none": "The lists show names only.",
"photos": "Each model's preview image.",
"monochrome": "Each model drawn in one tone on a transparent background, made by the server the first time it's needed. Files it can't draw show their photo."
}
}
},
+13 -1
View File
@@ -400,7 +400,8 @@
"allHidden": "Visi valdymo skydo langeliai paslėpti.",
"sections": {
"layout": "Išdėstymas",
"panels": "Langeliai"
"panels": "Langeliai",
"thumbnails": "Miniatiūros"
},
"layoutSubtitle": "Pasirinkite, kaip išdėstyti valdymo skydo langelius. Spalvota dalis rodo, kur bus skaičiai.",
"layouts": {
@@ -410,6 +411,17 @@
"bottom": "Skaičiai apačioje, trys stulpeliai",
"topTwo": "Skaičiai viršuje, du stulpeliai",
"puzzle": "Dėlionė"
},
"thumbnailsSubtitle": "Paveikslėliai šalia modelių valdymo skydo sąrašuose.",
"thumbnails": {
"none": "Paslėpti",
"photos": "Nuotraukos",
"monochrome": "Vienspalvės"
},
"thumbnailsHelp": {
"none": "Sąrašuose rodomi tik pavadinimai.",
"photos": "Kiekvieno modelio peržiūros paveikslėlis.",
"monochrome": "Kiekvienas modelis nupieštas vienu tonu permatomame fone; serveris jį sukuria, kai prireikia pirmą kartą. Failai, kurių nupiešti negalima, rodo nuotrauką."
}
}
},
@@ -6,12 +6,21 @@ import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import ViewQuiltOutlinedIcon from "@mui/icons-material/ViewQuiltOutlined";
import ToggleOnOutlinedIcon from "@mui/icons-material/ToggleOnOutlined";
import type { DashboardLayout, DashboardWidgetChoices, DashboardWidgetId } from "../../api/settings";
import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
import type {
DashboardLayout,
DashboardThumbnails,
DashboardWidgetChoices,
DashboardWidgetId,
} from "../../api/settings";
import SectionedDialog from "../../components/SectionedDialog";
import Segmented from "../../components/controls/Segmented";
import { useDashboardThumbnails } from "../../hooks/useDashboardThumbnails";
import LayoutPicker from "./LayoutPicker";
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
type Section = "layout" | "panels";
type Section = "layout" | "panels" | "thumbnails";
const THUMBNAILS: DashboardThumbnails[] = ["none", "photos", "monochrome"];
type Props = {
open: boolean;
@@ -26,6 +35,7 @@ type Props = {
export default function DashboardCustomizeDialog({ open, layout, onLayoutChange, choices, onToggle, onClose }: Props) {
const { t } = useTranslation("app");
const [section, setSection] = useState<Section>("layout");
const [thumbnails, setThumbnails] = useDashboardThumbnails();
// Each opening starts on Layout; kept while closing, so the section doesn't switch during the fade.
useEffect(() => {
@@ -43,6 +53,11 @@ export default function DashboardCustomizeDialog({ open, layout, onLayoutChange,
label: t("dashboard.widgets.sections.panels"),
icon: <ToggleOnOutlinedIcon fontSize="small" />,
},
{
id: "thumbnails" as const,
label: t("dashboard.widgets.sections.thumbnails"),
icon: <ImageOutlinedIcon fontSize="small" />,
},
];
return (
@@ -85,6 +100,25 @@ export default function DashboardCustomizeDialog({ open, layout, onLayoutChange,
</Stack>
</Stack>
)}
{section === "thumbnails" && (
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary">
{t("dashboard.widgets.thumbnailsSubtitle")}
</Typography>
{/* A column Stack would stretch the switch across the panel; as in Configuration, it fits its options. */}
<div>
<Segmented
label={t("dashboard.widgets.sections.thumbnails")}
value={thumbnails ?? "photos"}
onChange={setThumbnails}
options={THUMBNAILS.map((value) => ({ value, label: t(`dashboard.widgets.thumbnails.${value}`) }))}
/>
</div>
<Typography variant="caption" color="text.secondary">
{t(`dashboard.widgets.thumbnailsHelp.${thumbnails ?? "photos"}`)}
</Typography>
</Stack>
)}
</SectionedDialog>
);
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import Avatar from "@mui/material/Avatar";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import type { DashboardModel } from "../../api/dashboard";
import { printsApi } from "../../api/prints";
import { useDashboardThumbnails } from "../../hooks/useDashboardThumbnails";
/**
* A dashboard list row's picture, as the user chose: none, the model's photo, or the server's
* one-colour drawing of it. Until a drawing is made (the first request queues it) or for files it
* can't draw, the photo stands in.
*/
export default function DashboardModelThumb({ model }: { model: DashboardModel }) {
const [thumbnails] = useDashboardThumbnails();
const [pictureFailed, setPictureFailed] = useState(false);
// Hidden until the choice is known, so the lists don't flash photos first.
if (thumbnails === null || thumbnails === "none") return null;
const picture =
thumbnails === "monochrome" && model.model_image_url && !pictureFailed
? printsApi.fileUrl(model.model_image_url)
: null;
const photo = model.thumb_url ? printsApi.fileUrl(model.thumb_url) : undefined;
return (
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar
src={picture ?? photo}
variant="rounded"
slotProps={{ img: { onError: () => picture && setPictureFailed(true) } }}
sx={{
width: 40,
height: 40,
bgcolor: picture ? "transparent" : undefined,
color: "text.disabled",
// The drawing is dark; on a dark card it's turned light instead.
"& img": picture ? { filter: (theme) => (theme.palette.mode === "dark" ? "invert(1)" : "none") } : undefined,
}}
>
<ViewInArIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
);
}
@@ -6,13 +6,10 @@ import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import { printsApi } from "../../api/prints";
import type { DashboardModel } from "../../api/dashboard";
import SeeMoreDialog from "./SeeMoreDialog";
import DashboardModelThumb from "./DashboardModelThumb";
type Props = {
icon: React.ReactNode;
@@ -41,15 +38,7 @@ function ModelRow({
return (
<ListItemButton onClick={onClick} sx={{ borderRadius: 1, px: 1 }}>
<Typography sx={{ width: 24, flexShrink: 0, color: "text.secondary", fontWeight: 600 }}>{rank}</Typography>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar
src={model.thumb_url ? printsApi.fileUrl(model.thumb_url) : undefined}
variant="rounded"
sx={{ width: 40, height: 40 }}
>
<ViewInArIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<DashboardModelThumb model={model} />
<ListItemText primary={model.name} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{valueLabel(valueOf(model))}
@@ -5,13 +5,10 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import NewReleasesIcon from "@mui/icons-material/NewReleases";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import { printsApi } from "../../api/prints";
import type { DashboardModel } from "../../api/dashboard";
import DashboardModelThumb from "./DashboardModelThumb";
type Props = {
models: DashboardModel[];
@@ -61,15 +58,7 @@ export default function RecentlyAddedCard({ models }: Props) {
onClick={() => navigate(`/models/${model.id}`)}
sx={{ borderRadius: 1, px: 1 }}
>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar
src={model.thumb_url ? printsApi.fileUrl(model.thumb_url) : undefined}
variant="rounded"
sx={{ width: 40, height: 40 }}
>
<ViewInArIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<DashboardModelThumb model={model} />
<ListItemText primary={model.name} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{relativeTime(model.created_at, t)}