mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: tint generated model renders with the accent
Model cards can lead with the model's own pictures (default) or with our render, a choice saved per user in Appearance. The server draws each model once in grey shading on transparent, from the first file it can draw, simplifying oversized meshes and viewing flat models from above; the browser tints it with the accent, so every theme and accent shows at once. The model page's 3D preview offers the accent palette and opens in the chosen accent.
This commit is contained in:
1 parent
d26f730bee
commit
908ae80b5b
23 files changed
+556
-151
No files matched your search
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "cardPreview" TEXT;
|
||||
@@ -138,6 +138,8 @@ model User {
|
||||
dashboardLayout String?
|
||||
// The dashboard lists' model pictures, one of DASHBOARD_THUMBNAILS; null means "photos".
|
||||
dashboardThumbnails String?
|
||||
// What model cards lead with, one of CARD_PREVIEWS; null means "images".
|
||||
cardPreview String?
|
||||
// Where the dashboard's weather is for: { lat, lon, name, source }. Null until the user sets one.
|
||||
weatherLocation Json?
|
||||
authorPreviewEnabled Boolean @default(true)
|
||||
|
||||
+29
-2
@@ -17,7 +17,12 @@ import type {
|
||||
import { plateThumbExists, plateThumbPath } from "./services/printService";
|
||||
import { previewImageExists, previewImagePath } from "./services/previewImageService";
|
||||
import { preparedFilename } from "./services/preparedPrint";
|
||||
import { modelPreviewGlbExists, modelPreviewGlbPath } from "./services/modelPreviewCache";
|
||||
import {
|
||||
canRenderModelImage,
|
||||
modelImagePath,
|
||||
modelPreviewGlbExists,
|
||||
modelPreviewGlbPath,
|
||||
} from "./services/modelPreviewCache";
|
||||
import { buildImportSourceUrl } from "./services/importService";
|
||||
import { openSourceGaps, type SourceGap } from "./services/sourceGaps";
|
||||
import { parseQueuedLinkOptions } from "./services/importJobRunner";
|
||||
@@ -95,8 +100,13 @@ export type PlateOut = {
|
||||
size: number;
|
||||
url: string;
|
||||
thumb_url: string | null;
|
||||
/** The thumb is our own render, not a picture from an import or upload. */
|
||||
thumb_generated: boolean;
|
||||
/** Null until generated, and always for non-3MF plates; the viewer then parses the file itself. */
|
||||
preview_glb_url: string | null;
|
||||
/** The server's grey render for model cards to tint; unversioned until it exists (asking for it queues it), null for
|
||||
* files the server can't draw. */
|
||||
render_url: string | null;
|
||||
};
|
||||
|
||||
export type PrintFileOut = {
|
||||
@@ -111,6 +121,8 @@ export type PreviewImageOut = {
|
||||
id: string;
|
||||
position: number;
|
||||
url: string;
|
||||
/** Our own render, not a picture from an import or upload. */
|
||||
generated: boolean;
|
||||
};
|
||||
|
||||
export type PrintOut = {
|
||||
@@ -148,6 +160,8 @@ export type PrintOut = {
|
||||
plates: PlateOut[];
|
||||
preview_images: PreviewImageOut[];
|
||||
thumb_url: string | null;
|
||||
thumb_generated: boolean;
|
||||
render_url: string | null;
|
||||
supporting_file_count: number;
|
||||
/** All plates plus supporting and prepared files; gallery images aren't counted. */
|
||||
total_size: number;
|
||||
@@ -202,6 +216,14 @@ function previewGlbUrl(plate: Plate): string | null {
|
||||
return `/plate/${plate.id}/preview.glb?v=${mtime}`;
|
||||
}
|
||||
|
||||
function renderUrl(plate: Plate): string | null {
|
||||
if (!canRenderModelImage(plate.filename)) return null;
|
||||
const file = modelImagePath(plate.id, "render");
|
||||
return fs.existsSync(file)
|
||||
? `/plate/${plate.id}/render.png?v=${fs.statSync(file).mtimeMs}`
|
||||
: `/plate/${plate.id}/render.png`;
|
||||
}
|
||||
|
||||
export function toPlateOut(printId: string, plate: Plate): PlateOut {
|
||||
return {
|
||||
id: plate.id,
|
||||
@@ -212,7 +234,9 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut {
|
||||
size: plate.size,
|
||||
url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`,
|
||||
thumb_url: plateThumbUrl(plate.id),
|
||||
thumb_generated: plate.thumbGenerated,
|
||||
preview_glb_url: previewGlbUrl(plate),
|
||||
render_url: renderUrl(plate),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,7 +262,7 @@ function previewImageUrl(id: string): string | null {
|
||||
export function toPreviewImageOut(image: PreviewImage): PreviewImageOut | null {
|
||||
const url = previewImageUrl(image.id);
|
||||
if (!url) return null;
|
||||
return { id: image.id, position: image.position, url };
|
||||
return { id: image.id, position: image.position, url, generated: image.generated };
|
||||
}
|
||||
|
||||
export function toPrintFileOut(printFile: PrintFile): PrintFileOut {
|
||||
@@ -339,6 +363,9 @@ export function toPrintOut(
|
||||
plates: plateOuts,
|
||||
preview_images: previewImageOuts,
|
||||
thumb_url: plateOuts[0]?.thumb_url ?? null,
|
||||
thumb_generated: plateOuts[0]?.thumb_generated ?? false,
|
||||
// The first file the server can draw: a STEP first plate mustn't leave the card without one.
|
||||
render_url: plateOuts.find((plate) => plate.render_url)?.render_url ?? null,
|
||||
supporting_file_count: supportingCount,
|
||||
total_size: totalSize,
|
||||
prepared_print: prepared,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
relocatePrint,
|
||||
saveThumbFromBytes,
|
||||
} from "../services/printService";
|
||||
import { addGeneratedPreviewImageIfNone } from "../services/previewImageService";
|
||||
import { addGeneratedPreviewImageIfNone, generateCardRender } from "../services/previewImageService";
|
||||
import { printOutById } from "../services/printLoader";
|
||||
import {
|
||||
generateModelPreviewGlb,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
generateModelImage,
|
||||
modelImagePath,
|
||||
modelImageState,
|
||||
type ModelImageKind,
|
||||
canRenderModelImage,
|
||||
} from "../services/modelPreviewCache";
|
||||
import { getPreviewMode } from "../services/settingsService";
|
||||
@@ -193,16 +194,15 @@ 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) => {
|
||||
/** Serves one of the server's pictures of a plate. Built on first request for models from before that kind of picture
|
||||
* existed; new uploads queue theirs straight away. */
|
||||
function modelPictureHandler(kind: ModelImageKind) {
|
||||
return 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);
|
||||
const imagePath = modelImagePath(plate.id, kind);
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
if (!canRenderModelImage(plate.filename))
|
||||
throw new HttpError(404, "No picture for this file type", "PREVIEW_UNSUPPORTED");
|
||||
@@ -211,8 +211,9 @@ router.get(
|
||||
}
|
||||
const srcPath = resolvePlateFilePath(plate);
|
||||
if (!srcPath) throw new HttpError(404, "Model file is missing", "PREVIEW_FAILED");
|
||||
void generateModelImage(plate.id, srcPath, plate.filename);
|
||||
const state = modelImageState(plate.id);
|
||||
if (kind === "render") void generateCardRender(plate.id, srcPath, plate.filename);
|
||||
else void generateModelImage(plate.id, srcPath, plate.filename);
|
||||
const state = modelImageState(plate.id, kind);
|
||||
if (state === "unsupported") {
|
||||
throw new HttpError(404, "This file's layout can't be drawn by the server", "PREVIEW_UNSUPPORTED");
|
||||
}
|
||||
@@ -225,8 +226,13 @@ router.get(
|
||||
// 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));
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The model drawn in one colour, for small slots like the dashboard lists.
|
||||
router.get("/plate/:plateId/model.png", modelPictureHandler("icon"));
|
||||
// The model in grey shading on transparent, which model cards tint with the accent.
|
||||
router.get("/plate/:plateId/render.png", modelPictureHandler("render"));
|
||||
|
||||
router.post(
|
||||
"/plate/:plateId/thumbnail-generated",
|
||||
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
updateAiConnection,
|
||||
} from "../services/aiConnectionService";
|
||||
import { getDisabledProviders, PROVIDERS, setProviderEnabled } from "../services/providerSettingsService";
|
||||
import { CARD_PREVIEWS, getCardPreview, setCardPreview } from "../services/cardPreviewService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { testFlaresolverr } from "../services/flaresolverr";
|
||||
|
||||
@@ -1083,6 +1084,22 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/card-preview",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ preview: await getCardPreview(req.userId!) });
|
||||
}),
|
||||
);
|
||||
|
||||
const cardPreviewSchema = z.object({ preview: z.enum(CARD_PREVIEWS) });
|
||||
router.patch(
|
||||
"/settings/card-preview",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(cardPreviewSchema, req.body);
|
||||
res.json({ preview: await setCardPreview(req.userId!, body.preview) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/weather-location",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
// Kept in sync by hand with the frontend's CardPreview. "images" leads with a model's pictures from its import or
|
||||
// upload and falls back to our render; "renders" leads with the render and falls back to the pictures.
|
||||
export const CARD_PREVIEWS = ["images", "renders"] as const;
|
||||
export type CardPreview = (typeof CARD_PREVIEWS)[number];
|
||||
|
||||
export async function getCardPreview(userId: string): Promise<CardPreview> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { cardPreview: true } });
|
||||
return CARD_PREVIEWS.find((option) => option === user?.cardPreview) ?? "images";
|
||||
}
|
||||
|
||||
export async function setCardPreview(userId: string, preview: CardPreview): Promise<CardPreview> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { cardPreview: preview } });
|
||||
return preview;
|
||||
}
|
||||
@@ -1,23 +1,37 @@
|
||||
import fs from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { loadThreeMfGroup, type PreviewRefusal } from "./modelPreviewRender";
|
||||
import { loadThreeMfGroup, simplifyGroupMeshes, 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.
|
||||
// Past this the picture wouldn't look different, and the render would only take longer: a model over it is simplified
|
||||
// to SIMPLIFY_TO first, as 3D previews are.
|
||||
const MAX_TRIANGLES = 4_000_000;
|
||||
const SIMPLIFY_TO = 1_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;
|
||||
// The live viewer's opening angle: from the front right and above. A flat model, like a bookmark or a sign, is seen
|
||||
// from steeply above instead, or it's a sliver and its relief is lost.
|
||||
const THREE_QUARTER_VIEW: [number, number, number] = [0.9, 0.7, 2.1];
|
||||
const FLAT_THREE_QUARTER_VIEW: [number, number, number] = [0.45, 1.8, 1];
|
||||
|
||||
export type ModelImageOptions = { size: number; color: string };
|
||||
export type ModelImageOptions = {
|
||||
size: number;
|
||||
color: string;
|
||||
/** "front" is straight on (from above too for a flat model); "threeQuarter" is the live viewer's angle. */
|
||||
view?: "front" | "threeQuarter";
|
||||
/** Light every face gets, however it's turned. */
|
||||
ambient?: number;
|
||||
/** Light from the camera, so faces turned away from the main light keep some shape. */
|
||||
fill?: number;
|
||||
};
|
||||
|
||||
type Vec = [number, number, number];
|
||||
const sub = (a: Vec, b: Vec): Vec => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
@@ -57,18 +71,39 @@ async function loadTriangles(srcPath: string, ext: string): Promise<Float32Array
|
||||
} else {
|
||||
return "unsupported";
|
||||
}
|
||||
root.updateMatrixWorld(true);
|
||||
// A group around the model, so a lone STL mesh can be simplified the way a 3MF's plates are.
|
||||
const holder = new THREE.Group();
|
||||
holder.add(root);
|
||||
|
||||
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 countTriangles = () => {
|
||||
const meshes: import("three").Mesh[] = [];
|
||||
let total = 0;
|
||||
holder.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();
|
||||
total += (index ? index.count : mesh.geometry.getAttribute("position").count) / 3;
|
||||
});
|
||||
return { meshes, total };
|
||||
};
|
||||
let { meshes, total: count } = countTriangles();
|
||||
if (count > MAX_TRIANGLES) {
|
||||
// STL and OBJ load as loose triangles; the simplifier needs them joined at shared corners. Their per-face normals
|
||||
// would keep corners apart, and shading here comes from the triangles anyway.
|
||||
const { mergeVertices } = await import("three/examples/jsm/utils/BufferGeometryUtils.js");
|
||||
for (const mesh of meshes) {
|
||||
if (mesh.geometry.getIndex()) continue;
|
||||
const loose = mesh.geometry;
|
||||
for (const name of Object.keys(loose.attributes)) if (name !== "position") loose.deleteAttribute(name);
|
||||
mesh.geometry = mergeVertices(loose);
|
||||
loose.dispose();
|
||||
}
|
||||
await simplifyGroupMeshes(holder, SIMPLIFY_TO);
|
||||
({ meshes, total: count } = countTriangles());
|
||||
if (count > MAX_TRIANGLES) return "too-complex";
|
||||
}
|
||||
holder.updateMatrixWorld(true);
|
||||
|
||||
const out = new Float32Array(Math.floor(count) * 9);
|
||||
const vertex = new THREE.Vector3();
|
||||
@@ -101,7 +136,7 @@ export async function renderModelImage(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
ext: string,
|
||||
{ size, color }: ModelImageOptions,
|
||||
{ size, color, view = "front", ambient = 0.3, fill = 0 }: ModelImageOptions,
|
||||
): Promise<"ok" | PreviewRefusal> {
|
||||
const triangles = await loadTriangles(srcPath, ext);
|
||||
if (typeof triangles === "string") return triangles;
|
||||
@@ -118,7 +153,9 @@ export async function renderModelImage(
|
||||
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 toCamera = normalize(
|
||||
view === "threeQuarter" ? (flat ? FLAT_THREE_QUARTER_VIEW : THREE_QUARTER_VIEW) : 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.
|
||||
@@ -155,7 +192,7 @@ export async function renderModelImage(
|
||||
);
|
||||
// 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 brightness = ambient + (1 - ambient - fill) * Math.max(0, dot(normal, light)) + fill * dot(normal, toCamera);
|
||||
|
||||
const x0 = width / 2 + projected[t] * scale,
|
||||
y0 = width / 2 - projected[t + 1] * scale,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { MODEL_PREVIEWS, MODEL_PREVIEW_MAX_MEMORY_MB, MODEL_PREVIEW_TIMEOUT_SECONDS, STORAGE } from "../config";
|
||||
import { prisma } from "../db";
|
||||
import type { ModelImageOptions } from "./modelImageRender";
|
||||
import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelPreviewWorker";
|
||||
import { getSimplifyPreviews } from "./settingsService";
|
||||
import { workerBootstrap } from "../utils/workerBootstrap";
|
||||
@@ -264,25 +265,38 @@ async function renderPlateFile<T extends { status: string }>(
|
||||
return moved !== srcPath && fsSync.existsSync(moved) ? render(moved) : outcome;
|
||||
}
|
||||
|
||||
// Pictures of a model for small slots like the dashboard lists: near-black on transparent.
|
||||
// They share the GLB queue and memory limit, so the two never render at once, but wait behind GLBs.
|
||||
// Pictures of a model, drawn on the server in one colour on transparent: an "icon" for small slots like the dashboard
|
||||
// lists, and a "render" for model cards. They share the GLB queue and memory limit, so the two never render at once,
|
||||
// but wait behind GLBs.
|
||||
|
||||
/** 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";
|
||||
// A thumbnail isn't worth holding up the queue the way a model's own 3D preview is.
|
||||
const MODEL_IMAGE_TIMEOUT_SECONDS = 20;
|
||||
export type ModelImageKind = "icon" | "render";
|
||||
type ModelImageKindDef = {
|
||||
/** Part of the filename, with a version, so changing a look regenerates older pictures on next request. */
|
||||
file: string;
|
||||
options: ModelImageOptions;
|
||||
timeoutSeconds: number;
|
||||
};
|
||||
// A dashboard icon isn't worth holding up the queue the way a model's own 3D preview is; a card's render is the
|
||||
// model's main picture, so a heavy model gets longer.
|
||||
const MODEL_IMAGE_KINDS: Record<ModelImageKind, ModelImageKindDef> = {
|
||||
// Shading scales the colour down, so it comes out between near-black in shadow and charcoal in the light. The
|
||||
// dashboard inverts it for the dark theme.
|
||||
icon: { file: "image.v2", options: { size: 128, color: "#5a5a5a" }, timeoutSeconds: 20 },
|
||||
// Grey shading the cards tint with the accent, so one picture serves every theme and accent.
|
||||
render: {
|
||||
file: "render.v2",
|
||||
options: { size: 512, color: "#ffffff", view: "threeQuarter", ambient: 0.35, fill: 0.2 },
|
||||
timeoutSeconds: 60,
|
||||
},
|
||||
};
|
||||
|
||||
export function modelImagePath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.image.v${MODEL_IMAGE_VERSION}.png`);
|
||||
export function modelImagePath(plateId: string, kind: ModelImageKind = "icon"): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.${MODEL_IMAGE_KINDS[kind].file}.png`);
|
||||
}
|
||||
function modelImageErrorPath(plateId: string): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.image.v${MODEL_IMAGE_VERSION}.error`);
|
||||
function modelImageErrorPath(plateId: string, kind: ModelImageKind): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.${MODEL_IMAGE_KINDS[kind].file}.error`);
|
||||
}
|
||||
|
||||
export function canRenderModelImage(filename: string): boolean {
|
||||
@@ -291,14 +305,22 @@ export function canRenderModelImage(filename: string): boolean {
|
||||
|
||||
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);
|
||||
/** Never throws. Concurrent calls for the same picture no-op; a failure isn't retried for a while. `onReady` runs
|
||||
* with the finished picture's path. */
|
||||
export async function generateModelImage(
|
||||
plateId: string,
|
||||
srcPath: string,
|
||||
filename: string,
|
||||
kind: ModelImageKind = "icon",
|
||||
onReady?: (imagePath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const errorPath = modelImageErrorPath(plateId, kind);
|
||||
const key = `${kind}:${plateId}`;
|
||||
if (!canRenderModelImage(filename) || fsSync.existsSync(modelImagePath(plateId, kind))) return;
|
||||
if (imagesInFlight.has(key) || recentlyFailed(plateId, errorPath)) return;
|
||||
imagesInFlight.add(key);
|
||||
await scheduleRender("image", async () => {
|
||||
const dest = modelImagePath(plateId);
|
||||
const dest = modelImagePath(plateId, kind);
|
||||
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
const outcome = await renderPlateFile(plateId, srcPath, (from) =>
|
||||
@@ -308,14 +330,15 @@ export async function generateModelImage(plateId: string, srcPath: string, filen
|
||||
srcPath: from,
|
||||
destPath: tmp,
|
||||
ext: path.extname(filename).toLowerCase(),
|
||||
image: { size: MODEL_IMAGE_SIZE, color: MODEL_IMAGE_COLOR },
|
||||
image: MODEL_IMAGE_KINDS[kind].options,
|
||||
},
|
||||
MODEL_IMAGE_TIMEOUT_SECONDS,
|
||||
MODEL_IMAGE_KINDS[kind].timeoutSeconds,
|
||||
),
|
||||
);
|
||||
if (outcome.status === "ok") {
|
||||
await fs.rename(tmp, dest);
|
||||
await fs.rm(errorPath, { force: true });
|
||||
await onReady?.(dest);
|
||||
} else {
|
||||
const reason =
|
||||
outcome.status === "error" ? outcome.error : outcome.status === "limit" ? outcome.reason : outcome.status;
|
||||
@@ -328,16 +351,19 @@ export async function generateModelImage(plateId: string, srcPath: string, filen
|
||||
await fs.writeFile(errorPath, String(err)).catch(() => undefined);
|
||||
} finally {
|
||||
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
||||
imagesInFlight.delete(plateId);
|
||||
imagesInFlight.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** "unsupported" is a file the renderer read but couldn't draw, e.g. a 3MF layout it doesn't know. */
|
||||
export function modelImageState(plateId: string): "ready" | "generating" | "failed" | "unsupported" {
|
||||
if (fsSync.existsSync(modelImagePath(plateId))) return "ready";
|
||||
if (imagesInFlight.has(plateId)) return "generating";
|
||||
const errorPath = modelImageErrorPath(plateId);
|
||||
export function modelImageState(
|
||||
plateId: string,
|
||||
kind: ModelImageKind = "icon",
|
||||
): "ready" | "generating" | "failed" | "unsupported" {
|
||||
if (fsSync.existsSync(modelImagePath(plateId, kind))) return "ready";
|
||||
if (imagesInFlight.has(`${kind}:${plateId}`)) return "generating";
|
||||
const errorPath = modelImageErrorPath(plateId, kind);
|
||||
if (!recentlyFailed(plateId, errorPath)) return "generating";
|
||||
try {
|
||||
return fsSync.readFileSync(errorPath, "utf-8") === UNSUPPORTED_MARKER ? "unsupported" : "failed";
|
||||
|
||||
@@ -734,8 +734,11 @@ function meshTriangleCount(mesh: import("three").Mesh): number {
|
||||
}
|
||||
|
||||
/** Simplifies merged meshes in place when their total exceeds `budget` triangles; null when
|
||||
* already within budget. */
|
||||
async function simplifyGroupMeshes(root: import("three").Group, budget: number): Promise<SimplifiedSummary | null> {
|
||||
* already within budget. Only indexed meshes are simplified. */
|
||||
export 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) => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { PREVIEWS } from "../config";
|
||||
import { prisma } from "../db";
|
||||
import { plateThumbReplaceable, saveThumbFromBytes } from "./printService";
|
||||
import { generateModelImage } from "./modelPreviewCache";
|
||||
import { plateThumbExists, plateThumbReplaceable, saveThumbFromBytes } from "./printService";
|
||||
import type { PreviewImage } from "@prisma/client";
|
||||
|
||||
export function previewImagePath(id: string): string {
|
||||
@@ -91,3 +92,26 @@ export async function deleteAllPreviewImages(printId: string): Promise<void> {
|
||||
await fs.rm(previewImagePath(row.id), { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// The card render is white shading for the cards to tint, too pale on a light background as it is; as a thumb it's
|
||||
// slate, like the live viewer's model.
|
||||
const RENDER_THUMB_SHADE = [0.58, 0.64, 0.72, 1];
|
||||
|
||||
/** Our own render stands in as the plate's thumb, and the model's cover, while it has no picture at all, so mosaics
|
||||
* and lists that show thumbs have one without the model being opened. */
|
||||
async function adoptRenderAsThumb(plateId: string, renderPath: string): Promise<void> {
|
||||
if (plateThumbExists(plateId)) return;
|
||||
const plate = await prisma.plate.findUnique({ where: { id: plateId }, select: { printId: true } });
|
||||
if (!plate) return;
|
||||
const buffer = await sharp(renderPath).linear(RENDER_THUMB_SHADE, [0, 0, 0, 0]).png().toBuffer();
|
||||
if (!(await saveThumbFromBytes(plateId, buffer))) return;
|
||||
await prisma.plate.update({ where: { id: plateId }, data: { thumbGenerated: true } });
|
||||
await addGeneratedPreviewImageIfNone(plate.printId, buffer);
|
||||
}
|
||||
|
||||
/** Queues the model card's render. Never throws. */
|
||||
export function generateCardRender(plateId: string, srcPath: string, filename: string): Promise<void> {
|
||||
return generateModelImage(plateId, srcPath, filename, "render", (renderPath) =>
|
||||
adoptRenderAsThumb(plateId, renderPath).catch((err) => console.error(`Render thumb failed for ${plateId}:`, err)),
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
saveThumbFromFile,
|
||||
} from "./printService";
|
||||
import { generateModelPreviewGlb, canRenderModelImage, generateModelImage } from "./modelPreviewCache";
|
||||
import { generateCardRender } from "./previewImageService";
|
||||
import { deleteNormalized3mf } from "./normalized3mfCache";
|
||||
import { getPreviewMode } from "./settingsService";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -101,6 +102,7 @@ async function thumbnailAndSniff(plateId: string, filename: string, mime: string
|
||||
// Queued behind the GLB, which the model page needs first.
|
||||
if (canRenderModelImage(filename) && (await getPreviewMode()) === "automatic") {
|
||||
void generateModelImage(plateId, effectivePath, filename);
|
||||
void generateCardRender(plateId, effectivePath, filename);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ export type Plate = {
|
||||
size: number;
|
||||
url: string; // /print/{printId}/plate/{plateId}/file/{filename}
|
||||
thumb_url?: string | null; // /plate/{plateId}/thumb.jpg?v=... if a thumb exists
|
||||
thumb_generated?: boolean; // the thumb is our own render, not a picture from an import or upload
|
||||
preview_glb_url?: string | null; // /plate/{plateId}/preview.glb?v=... once background pre-render finishes
|
||||
render_url?: string | null; // /plate/{plateId}/render.png[?v=...]: grey shading for cards to tint; null if undrawable
|
||||
};
|
||||
|
||||
export type PrintFile = {
|
||||
@@ -35,6 +37,7 @@ export type PreviewImage = {
|
||||
id: string;
|
||||
position: number; // dense, 0-based, ordered; 0 is the default/main image
|
||||
url: string; // /preview-image/{id}/file.jpg?v=...
|
||||
generated?: boolean; // our own render, not a picture from an import or upload
|
||||
};
|
||||
|
||||
export type Author = {
|
||||
@@ -85,6 +88,8 @@ export type Print = {
|
||||
plates: Plate[]; // ordered by position, length >= 1
|
||||
preview_images: PreviewImage[]; // ordered by position; [0] is the default/main gallery image
|
||||
thumb_url?: string | null; // denormalized = plates[0].thumb_url
|
||||
thumb_generated?: boolean; // denormalized = plates[0].thumb_generated
|
||||
render_url?: string | null; // the first plate's render_url that isn't null
|
||||
supporting_file_count: number;
|
||||
// Optional: an older backend (published separately) doesn't send it.
|
||||
total_size?: number;
|
||||
|
||||
@@ -77,6 +77,8 @@ export type WeatherLocation = { lat: number; lon: number; name: string | null; s
|
||||
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";
|
||||
/** What model cards lead with: the model's own pictures, or our render tinted with the accent. */
|
||||
export type CardPreview = "images" | "renders";
|
||||
/** Only the widgets the user has switched; the rest follow their default. */
|
||||
export type DashboardWidgetChoices = Partial<Record<DashboardWidgetId, boolean>>;
|
||||
|
||||
@@ -761,6 +763,23 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getCardPreview: async (): Promise<{ preview: CardPreview }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/card-preview`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load the model card preview setting");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateCardPreview: async (preview: CardPreview): Promise<{ preview: CardPreview }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/card-preview`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ preview }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the model card preview 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");
|
||||
|
||||
@@ -10,6 +10,7 @@ import { matchLanguage, SUPPORTED_LANGUAGES } from "../../../constants/languages
|
||||
import { ACCENT_COLORS, type AccentColor, type ThemeSelection } from "../../../constants/settingsOptions";
|
||||
import { setCachedAccentColor, useAccentColor } from "../../../hooks/useAccentColor";
|
||||
import { setCachedAuthorPreviewEnabled, useAuthorPreviewEnabled } from "../../../hooks/useAuthorPreviewEnabled";
|
||||
import { useCardPreview } from "../../../hooks/useCardPreview";
|
||||
import { accentSwatch } from "../../../theme";
|
||||
import ColorSwatches from "../../controls/ColorSwatches";
|
||||
import Segmented from "../../controls/Segmented";
|
||||
@@ -26,13 +27,14 @@ type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Theme, accent, language and author preview, each applied as it's changed. */
|
||||
/** Theme, accent, model card pictures, language and author preview, each applied as it's changed. */
|
||||
export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation("app");
|
||||
const authorPreview = useAuthorPreviewEnabled();
|
||||
const [authorPreviewError, setAuthorPreviewError] = React.useState<string | null>(null);
|
||||
const language = matchLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const accent = useAccentColor();
|
||||
const [cardPreview, setCardPreview] = useCardPreview();
|
||||
const [accentError, setAccentError] = React.useState<string | null>(null);
|
||||
|
||||
const changeAccent = async (next: AccentColor) => {
|
||||
@@ -93,6 +95,22 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t("appearance.cardPreview.heading")}
|
||||
<HelpTip text={t("appearance.cardPreview.help")} />
|
||||
</SectionLabel>
|
||||
<Segmented
|
||||
label={t("appearance.cardPreview.heading")}
|
||||
value={cardPreview ?? "images"}
|
||||
disabled={cardPreview === null}
|
||||
onChange={setCardPreview}
|
||||
options={[
|
||||
{ value: "images", label: t("appearance.cardPreview.images") },
|
||||
{ value: "renders", label: t("appearance.cardPreview.renders") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
|
||||
{SUPPORTED_LANGUAGES.length > MAX_SEGMENTED_LANGUAGES ? (
|
||||
|
||||
@@ -12,10 +12,12 @@ type Props<T extends string> = {
|
||||
options: { value: T; label: string; color: string; contrast: string }[];
|
||||
onChange: (value: T) => void;
|
||||
label: string;
|
||||
/** Without Segmented's track, for a toolbar that's already a surface of its own. */
|
||||
bare?: boolean;
|
||||
};
|
||||
|
||||
/** Round colour swatches on Segmented's track; the chosen one is ringed and ticked. */
|
||||
export default function ColorSwatches<T extends string>({ value, options, onChange, label }: Props<T>) {
|
||||
export default function ColorSwatches<T extends string>({ value, options, onChange, label, bare }: Props<T>) {
|
||||
return (
|
||||
<Box
|
||||
component="fieldset"
|
||||
@@ -27,11 +29,9 @@ export default function ColorSwatches<T extends string>({ value, options, onChan
|
||||
flexWrap: "wrap",
|
||||
maxWidth: "100%",
|
||||
gap: 0.75,
|
||||
p: "6px",
|
||||
borderRadius: "10px",
|
||||
bgcolor: trackBg,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
p: bare ? "4px" : "6px",
|
||||
border: 0,
|
||||
...(!bare && { borderRadius: "10px", bgcolor: trackBg, border: 1, borderColor: "divider" }),
|
||||
}}
|
||||
>
|
||||
{options.map((option) => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { keyframes } from "@emotion/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { printsApi } from "../../api/prints";
|
||||
|
||||
// A render the server is still making is asked for again after these waits, then the card gives up on it. A heavy
|
||||
// model waits behind its own 3D preview in the server's queue, so this runs to a few minutes.
|
||||
const RETRY_DELAYS_MS = [2000, 4000, 8000, ...Array<number>(13).fill(15000)];
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
/** The plate's `render_url`. */
|
||||
url: string;
|
||||
alt: string;
|
||||
/** Called once the server says it can't draw this model, or it still hasn't after the last retry. */
|
||||
onUnavailable: () => void;
|
||||
/** Shown until the render is in, e.g. the model's own picture; a skeleton without one. */
|
||||
placeholder?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The server's render of a model, in the theme's accent on a soft glow of it. The render is grey shading on
|
||||
* transparent: the accent fills the model's silhouette and the shading is multiplied over it, so one picture serves
|
||||
* every theme and accent and a switch needs nothing new from the server.
|
||||
*/
|
||||
export default function ModelRender({ url, alt, onUnavailable, placeholder }: Props) {
|
||||
// Failed loads so far; past the last retry the card falls back.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (attempt > RETRY_DELAYS_MS.length) onUnavailable();
|
||||
if (!waiting) return;
|
||||
const timer = window.setTimeout(() => setWaiting(false), RETRY_DELAYS_MS[attempt - 1]);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [waiting, attempt, onUnavailable]);
|
||||
|
||||
// Each retry asks afresh, so a cached "not ready" answer isn't reused.
|
||||
const src = printsApi.fileUrl(attempt ? `${url}${url.includes("?") ? "&" : "?"}attempt=${attempt}` : url);
|
||||
const layerSx = { position: "absolute", inset: "6%", width: "88%", height: "88%" } as const;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// Its own stacking context, so the multiply only blends with the silhouette under it.
|
||||
isolation: "isolate",
|
||||
background: (theme) =>
|
||||
`radial-gradient(circle at 50% 48%, ${alpha(theme.palette.primary.main, theme.palette.mode === "dark" ? 0.2 : 0.14)} 0%, transparent 68%)`,
|
||||
}}
|
||||
>
|
||||
{!loaded &&
|
||||
(placeholder ?? (
|
||||
<Skeleton variant="rectangular" animation="wave" sx={{ position: "absolute", inset: 0, height: "100%" }} />
|
||||
))}
|
||||
{loaded && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
...layerSx,
|
||||
bgcolor: "primary.main",
|
||||
mask: `url("${src}") center / contain no-repeat`,
|
||||
WebkitMask: `url("${src}") center / contain no-repeat`,
|
||||
animation: `${fadeIn} 250ms ease-out`,
|
||||
transition: "background-color 200ms",
|
||||
"@media (prefers-reduced-motion: reduce)": { animation: "none", transition: "none" },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!waiting && (
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => {
|
||||
// Only a render still being made is worth waiting for; the server says which it is.
|
||||
void fetch(src)
|
||||
.then((res) => res.json() as Promise<{ code?: string }>)
|
||||
.catch(() => ({ code: undefined }))
|
||||
.then(({ code }) => {
|
||||
if (code && code !== "PREVIEW_GENERATING") return onUnavailable();
|
||||
setAttempt((count) => count + 1);
|
||||
setWaiting(true);
|
||||
});
|
||||
}}
|
||||
sx={{
|
||||
...layerSx,
|
||||
objectFit: "contain",
|
||||
mixBlendMode: "multiply",
|
||||
opacity: loaded ? 1 : 0,
|
||||
animation: loaded ? `${fadeIn} 250ms ease-out` : "none",
|
||||
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
@@ -9,6 +10,8 @@ import { extOf } from "../../utils/fileExtensions";
|
||||
import ModelViewer from "./ModelViewer";
|
||||
import { ModelSnapshot } from "./ModelViewer/ModelSnapshot";
|
||||
import LightBurnPreview from "./LightBurnPreview";
|
||||
import ModelRender from "./ModelRender";
|
||||
import { useCardPreview } from "../../hooks/useCardPreview";
|
||||
|
||||
export type PreviewVariant = "card" | "modal";
|
||||
|
||||
@@ -22,6 +25,49 @@ function PreviewPlaceholder({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A model card's picture, as the user chose: its own pictures first with our tinted render standing in where there are
|
||||
* none, or the render first with the pictures standing in. The render needs a file the server can draw, and
|
||||
* generation not turned off; `fallback` is what the card showed before renders existed.
|
||||
*/
|
||||
function CardPicture({
|
||||
print,
|
||||
previewMode,
|
||||
fallback,
|
||||
}: {
|
||||
print: Print;
|
||||
previewMode: PreviewMode;
|
||||
fallback: ReactNode;
|
||||
}) {
|
||||
const [preference] = useCardPreview();
|
||||
const [renderUnavailable, setRenderUnavailable] = useState(false);
|
||||
const onUnavailable = useCallback(() => setRenderUnavailable(true), []);
|
||||
// Hidden until the choice is known, so cards don't flash the wrong picture.
|
||||
if (preference === null) return null;
|
||||
const canRender = Boolean(print.render_url) && previewMode !== "disabled" && !renderUnavailable;
|
||||
const hasOwnImage = Boolean(print.thumb_url) && !print.thumb_generated;
|
||||
if (canRender && (preference === "renders" || !hasOwnImage)) {
|
||||
// The model's own picture holds the place while the render is made; a render to come can't stand in for itself.
|
||||
const placeholder = print.thumb_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={printsApi.fileUrl(print.thumb_url)}
|
||||
alt=""
|
||||
sx={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<ModelRender
|
||||
url={print.render_url!}
|
||||
alt={print.title || print.name}
|
||||
onUnavailable={onUnavailable}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Picks the preview widget for a print's active plate.
|
||||
export function renderPreviewContent(
|
||||
print: Print,
|
||||
@@ -55,22 +101,24 @@ export function renderPreviewContent(
|
||||
const isLightBurn = LIGHTBURN_EXTS.has(ext);
|
||||
|
||||
if (variant === "card") {
|
||||
let fallback: ReactNode;
|
||||
if (thumbUrl) {
|
||||
return <Box component="img" src={thumbUrl} alt={plate.filename} sx={imgSx} />;
|
||||
fallback = <Box component="img" src={thumbUrl} alt={plate.filename} sx={imgSx} />;
|
||||
} else if (ext === "svg") {
|
||||
fallback = <Box component="img" src={plateUrl} alt={plate.filename} sx={imgSx} />;
|
||||
} else if (is3d) {
|
||||
fallback =
|
||||
previewMode === "disabled" ? (
|
||||
<PreviewPlaceholder text={t("library:modelViewer.previewDisabled")} />
|
||||
) : (
|
||||
<ModelSnapshot url={plateUrl} ext={ext} plateId={plate.id} theme={theme} mode={previewMode} />
|
||||
);
|
||||
} else if (isLightBurn) {
|
||||
fallback = <LightBurnPreview url={plateUrl} assetId={plate.id} filename={plate.filename} imgSx={imgSx} />;
|
||||
} else {
|
||||
fallback = <PreviewPlaceholder text={t("library:modelViewer.noPreview")} />;
|
||||
}
|
||||
if (ext === "svg") {
|
||||
return <Box component="img" src={plateUrl} alt={plate.filename} sx={imgSx} />;
|
||||
}
|
||||
if (is3d) {
|
||||
if (previewMode === "disabled") {
|
||||
return <PreviewPlaceholder text={t("library:modelViewer.previewDisabled")} />;
|
||||
}
|
||||
return <ModelSnapshot url={plateUrl} ext={ext} plateId={plate.id} theme={theme} mode={previewMode} />;
|
||||
}
|
||||
if (isLightBurn) {
|
||||
return <LightBurnPreview url={plateUrl} assetId={plate.id} filename={plate.filename} imgSx={imgSx} />;
|
||||
}
|
||||
return <PreviewPlaceholder text={t("library:modelViewer.noPreview")} />;
|
||||
return <CardPicture print={print} previewMode={previewMode} fallback={fallback} />;
|
||||
}
|
||||
|
||||
if (is3d) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { settingsApi, type CardPreview } from "../api/settings";
|
||||
|
||||
// Saved on the account so every device shows the same pictures. Shared by every card so there's one request, and a
|
||||
// switch shows everywhere at once.
|
||||
let cached: CardPreview | undefined;
|
||||
let inFlight: Promise<CardPreview> | null = null;
|
||||
const listeners = new Set<(value: CardPreview) => void>();
|
||||
|
||||
function load(): Promise<CardPreview> {
|
||||
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
|
||||
.getCardPreview()
|
||||
.then((res) => (cached ??= res.preview))
|
||||
.catch((): CardPreview => cached ?? "images")
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function publish(value: CardPreview) {
|
||||
cached = value;
|
||||
listeners.forEach((listener) => listener(value));
|
||||
}
|
||||
|
||||
/** Null until the saved choice is known, so cards don't flash the wrong picture first. */
|
||||
export function useCardPreview(): [CardPreview | null, (preview: CardPreview) => void] {
|
||||
const [value, setValue] = useState<CardPreview | 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 = (preview: CardPreview) => {
|
||||
publish(preview);
|
||||
// The switch already happened here; a failed save only means other devices won't follow.
|
||||
settingsApi.updateCardPreview(preview).catch(() => {});
|
||||
};
|
||||
|
||||
return [value, update];
|
||||
}
|
||||
@@ -1584,6 +1584,12 @@
|
||||
"amber": "Thangs yellow",
|
||||
"teal": "MyMiniFactory teal",
|
||||
"purple": "Cults3D purple"
|
||||
},
|
||||
"cardPreview": {
|
||||
"heading": "Model cards",
|
||||
"images": "Images",
|
||||
"renders": "3D renders",
|
||||
"help": "What model cards show first. Images: the pictures a model came with, or a 3D render where it has none. 3D renders: a render of every model in your accent color, or its pictures where the file can't be drawn."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
@@ -2622,16 +2628,7 @@
|
||||
"spin": "Spin",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"colorLabel": "Model color: {{name}}",
|
||||
"colors": {
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"grey": "Grey"
|
||||
}
|
||||
"color": "Model color"
|
||||
},
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"deleteFailed": "Failed to delete the model.",
|
||||
|
||||
@@ -1650,6 +1650,12 @@
|
||||
"amber": "Thangs geltona",
|
||||
"teal": "MyMiniFactory žydra",
|
||||
"purple": "Cults3D violetinė"
|
||||
},
|
||||
"cardPreview": {
|
||||
"heading": "Modelių kortelės",
|
||||
"images": "Nuotraukos",
|
||||
"renders": "3D vaizdai",
|
||||
"help": "Ką modelių kortelės rodo pirmiausia. Nuotraukos: paveikslėliai, su kuriais modelis atkeliavo, arba 3D vaizdas, jei jų nėra. 3D vaizdai: kiekvieno modelio vaizdas jūsų akcento spalva arba jo nuotraukos, jei failo nupiešti nepavyksta."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
@@ -2689,16 +2695,7 @@
|
||||
"spin": "Sukimas",
|
||||
"zoomIn": "Priartinti",
|
||||
"zoomOut": "Nutolinti",
|
||||
"colorLabel": "Modelio spalva: {{name}}",
|
||||
"colors": {
|
||||
"red": "Raudona",
|
||||
"orange": "Oranžinė",
|
||||
"yellow": "Geltona",
|
||||
"green": "Žalia",
|
||||
"blue": "Mėlyna",
|
||||
"purple": "Violetinė",
|
||||
"grey": "Pilka"
|
||||
}
|
||||
"color": "Modelio spalva"
|
||||
},
|
||||
"downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.",
|
||||
"deleteFailed": "Nepavyko ištrinti modelio.",
|
||||
|
||||
@@ -26,7 +26,10 @@ import ModelViewer, {
|
||||
type RenderStyle,
|
||||
} from "../../components/media/ModelViewer";
|
||||
import type { PlateSummary } from "../../utils/bambuThreeMf";
|
||||
import PreviewToolbar, { DEFAULT_PREVIEW_COLOR } from "./PreviewToolbar";
|
||||
import PreviewToolbar from "./PreviewToolbar";
|
||||
import type { AccentColor } from "../../constants/settingsOptions";
|
||||
import { useAccentColor } from "../../hooks/useAccentColor";
|
||||
import { accentSwatch } from "../../theme";
|
||||
import { fileRowText } from "./fileRowText";
|
||||
|
||||
// Light mode keeps a product-shot style grey; dark mode uses the page background so it doesn't glare.
|
||||
@@ -65,7 +68,10 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
// Lives here so it survives the viewer remounting per file.
|
||||
const viewerRef = useRef<ModelViewerHandle>(null);
|
||||
const [renderStyle, setRenderStyle] = useState<RenderStyle>("solid");
|
||||
const [modelColor, setModelColor] = useState<string>(DEFAULT_PREVIEW_COLOR);
|
||||
// Follows the accent until another colour is picked for this viewing.
|
||||
const accent = useAccentColor();
|
||||
const [pickedColor, setPickedColor] = useState<AccentColor | null>(null);
|
||||
const modelColor = pickedColor ?? accent;
|
||||
const [cameraView, setCameraView] = useState<CameraView>("topFront");
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [spin, setSpin] = useState(true);
|
||||
@@ -219,7 +225,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
ext={ext}
|
||||
initialCameraView={cameraView}
|
||||
theme={themeMode}
|
||||
colorOverride={modelColor}
|
||||
colorOverride={accentSwatch(modelColor).color}
|
||||
renderStyle={renderStyle}
|
||||
showBuildPlate={showGrid}
|
||||
buildPlateForMeshes
|
||||
@@ -262,7 +268,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
renderStyle={renderStyle}
|
||||
onRenderStyleChange={setRenderStyle}
|
||||
color={modelColor}
|
||||
onColorChange={setModelColor}
|
||||
onColorChange={setPickedColor}
|
||||
showGrid={showGrid}
|
||||
onShowGridChange={setShowGrid}
|
||||
spin={spin}
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Select from "@mui/material/Select";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import ToggleButton from "@mui/material/ToggleButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import GridOnIcon from "@mui/icons-material/GridOn";
|
||||
import ThreeSixtyIcon from "@mui/icons-material/ThreeSixty";
|
||||
import type { CameraView, RenderStyle } from "../../components/media/ModelViewer";
|
||||
|
||||
export const PREVIEW_COLORS = [
|
||||
{ key: "red", value: "#d32f2f" },
|
||||
{ key: "orange", value: "#f57c00" },
|
||||
{ key: "yellow", value: "#fbc02d" },
|
||||
{ key: "green", value: "#00b800" },
|
||||
{ key: "blue", value: "#1976d2" },
|
||||
{ key: "purple", value: "#7b1fa2" },
|
||||
{ key: "grey", value: "#78909c" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_PREVIEW_COLOR = "#00b800";
|
||||
import ColorSwatches from "../../components/controls/ColorSwatches";
|
||||
import { ACCENT_COLORS, type AccentColor } from "../../constants/settingsOptions";
|
||||
import { accentSwatch } from "../../theme";
|
||||
|
||||
const CAMERA_VIEWS: CameraView[] = ["topFront", "front", "side", "top", "bottom"];
|
||||
const RENDER_STYLES: RenderStyle[] = ["solid", "wire", "xray"];
|
||||
@@ -31,8 +20,8 @@ type Props = {
|
||||
onCameraView: (view: CameraView) => void;
|
||||
renderStyle: RenderStyle;
|
||||
onRenderStyleChange: (style: RenderStyle) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
color: AccentColor;
|
||||
onColorChange: (color: AccentColor) => void;
|
||||
showGrid: boolean;
|
||||
onShowGridChange: (show: boolean) => void;
|
||||
spin: boolean;
|
||||
@@ -56,7 +45,7 @@ export default function PreviewToolbar({
|
||||
spin,
|
||||
onSpinChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
const { t } = useTranslation(["models", "app"]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -104,31 +93,17 @@ export default function PreviewToolbar({
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
<Stack direction="row" spacing={0.75} sx={{ px: 0.5 }}>
|
||||
{PREVIEW_COLORS.map(({ key, value }) => {
|
||||
const name = t(`models:detail.previewToolbar.colors.${key}`);
|
||||
const selected = value === color;
|
||||
return (
|
||||
<Tooltip key={key} title={name}>
|
||||
<ButtonBase
|
||||
aria-label={t("models:detail.previewToolbar.colorLabel", { name }) ?? undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onColorChange(value)}
|
||||
sx={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
bgcolor: value,
|
||||
boxShadow: (theme) =>
|
||||
selected
|
||||
? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette.text.primary}`
|
||||
: `inset 0 0 0 1px rgba(0, 0, 0, 0.2)`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
{/* The accent palette, so the model can be seen in any of the app's colours. */}
|
||||
<ColorSwatches
|
||||
bare
|
||||
label={t("models:detail.previewToolbar.color")}
|
||||
value={color}
|
||||
onChange={onColorChange}
|
||||
options={ACCENT_COLORS.map((value) => {
|
||||
const { color: swatch, contrast } = accentSwatch(value);
|
||||
return { value, label: t(`app:appearance.accents.${value}`), color: swatch, contrast };
|
||||
})}
|
||||
</Stack>
|
||||
/>
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -22,6 +22,7 @@ import { extOf } from "../../utils/fileExtensions";
|
||||
import ProviderBadge from "../../components/ProviderBadge";
|
||||
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
|
||||
import { ModelSnapshot } from "../../components/media/ModelViewer/ModelSnapshot";
|
||||
import ModelRender from "../../components/media/ModelRender";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { useSmartBack } from "../../components/Layout/NavigationHistoryContext";
|
||||
import Model3DPreviewModal from "./Model3DPreviewModal";
|
||||
@@ -47,6 +48,8 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [renderUnavailable, setRenderUnavailable] = useState(false);
|
||||
const onRenderUnavailable = useCallback(() => setRenderUnavailable(true), []);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
// Returns wherever the user came from; useSmartBack skips same-route history entries.
|
||||
@@ -143,13 +146,22 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
|
||||
);
|
||||
}
|
||||
|
||||
const images = print.preview_images.toSorted((a, b) => a.position - b.position);
|
||||
// The tinted render stands in for our own older renders, which are grey whatever the theme and accent.
|
||||
const showRender = Boolean(print.render_url) && !renderUnavailable;
|
||||
const images = print.preview_images
|
||||
.filter((image) => !(showRender && image.generated))
|
||||
.toSorted((a, b) => a.position - b.position);
|
||||
const hasImages = images.length > 0;
|
||||
const activeImage = images[activeImageIndex] || images[0];
|
||||
const firstPlate = print.plates[0];
|
||||
const canPreview3d = Boolean(firstPlate) && MODEL_EXTS.has(extOf(firstPlate?.filename || ""));
|
||||
// With no preview image yet, show the live 3D view and generate a snapshot in the background.
|
||||
const needsGeneratedPreview = !hasImages && Boolean(firstPlate) && MODEL_EXTS.has(extOf(firstPlate?.filename || ""));
|
||||
// The server's render doesn't need one: it saves its own picture.
|
||||
const needsGeneratedPreview =
|
||||
print.preview_images.length === 0 &&
|
||||
!showRender &&
|
||||
Boolean(firstPlate) &&
|
||||
MODEL_EXTS.has(extOf(firstPlate?.filename || ""));
|
||||
|
||||
const goPrevImage = () => setActiveImageIndex((i) => (i - 1 + images.length) % images.length);
|
||||
const goNextImage = () => setActiveImageIndex((i) => (i + 1) % images.length);
|
||||
@@ -216,6 +228,12 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : showRender ? (
|
||||
<ModelRender
|
||||
url={print.render_url!}
|
||||
alt={print.title || print.name}
|
||||
onUnavailable={onRenderUnavailable}
|
||||
/>
|
||||
) : (
|
||||
firstPlate && renderPreviewContent(print, "modal", theme, t, "automatic", firstPlate)
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user