mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add setting to customize dashboard model thumbnail appearance
This commit is contained in:
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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user