feat: check models for print problems when they are opened

Closes #37. The first time a model is opened, each of its STL, OBJ and 3MF
files is checked in the render worker for holes, non-manifold edges, flipped
or inside-out faces, loose fragments, missing 3MF parts, damaged files and an
unlikely size. The rules follow ADMesh and Blender's 3D Print Toolbox.

The model page shows a scan overlay while it runs, then a tick or a
severity-coloured warning beside 3D Preview that opens a report with an
animated tile per problem. Results are saved per plate and rechecked only
when QUALITY_CHECK_VERSION changes.

Admins can switch checks off as a whole or per problem under Rendering;
severity and switched-off problems apply when results are read, so neither
needs a recheck. Checks are on by default.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-21 07:41:24 +02:00
1 parent 8db1c02f07
commit f913710cc4
29 files changed
+2651 -31

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Plate" ADD COLUMN "qualityCheck" JSONB;
+3
View File
@@ -581,6 +581,9 @@ model Plate {
contentSha256 String?
// The thumb is our own 3D render, which an imported image replaces.
thumbGenerated Boolean @default(false)
// The printability check's result (services/meshQuality.ts QualityReport), or { version, status: "skipped" }
// for a file it couldn't check. Null until the model is first opened with checks on.
qualityCheck Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+20
View File
@@ -42,6 +42,7 @@ import { forgetAiDescription } from "../services/aiDescriptionService";
import { isNormalizable3mf, normalize3mfStatus, normalized3mfFor } from "../services/normalized3mfCache";
import { rankedPrintIds } from "../services/searchService";
import type { Prisma } from "@prisma/client";
import { printQuality, startPrintQualityCheck } from "../services/modelQualityService";
const router = Router();
router.use(requireAuth);
@@ -330,6 +331,25 @@ router.get(
}),
);
// Printability checks: the model page reads the state, starts a check for files not yet checked, and polls.
router.get(
"/print/:id/quality",
asyncHandler(async (req, res) => {
const quality = await printQuality(req.userId!, req.params.id);
if (!quality) throw new HttpError(404, "Print not found");
res.json(quality);
}),
);
router.post(
"/print/:id/quality",
asyncHandler(async (req, res) => {
const quality = await startPrintQualityCheck(req.userId!, req.params.id);
if (!quality) throw new HttpError(404, "Print not found");
res.json(quality);
}),
);
router.post(
"/print/:id/favorite",
asyncHandler(async (req, res) => {
+29
View File
@@ -13,6 +13,8 @@ import {
getAuthTokenTtl,
getPreviewMode,
getSimplifyPreviews,
getQualityChecks,
getQualityChecksOff,
getSmtpSettings,
getFlaresolverrUrl,
getPublicUrl,
@@ -25,6 +27,8 @@ import {
setAuthTokenTtl,
setPreviewMode,
setSimplifyPreviews,
setQualityChecks,
setQualityChecksOff,
setSmtpSettings,
setThingiverseAccessToken,
getMyminifactoryApiKey,
@@ -135,6 +139,7 @@ import { getDisabledProviders, PROVIDERS, setProviderEnabled } from "../services
import { CARD_PREVIEWS, getCardPreview, setCardPreview } from "../services/cardPreviewService";
import { createLog } from "../services/auditLog";
import { testFlaresolverr } from "../services/flaresolverr";
import { QUALITY_CHECK_KEYS, type QualityCheckKey } from "../services/meshQuality";
const router = Router();
router.use(requireAuth);
@@ -365,6 +370,30 @@ router.patch(
}),
);
router.get(
"/settings/quality-checks",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json({ enabled: await getQualityChecks(), checks_off: await getQualityChecksOff() });
}),
);
// Switching a check off hides what it found in results already saved; nothing is checked again.
const qualityChecksSchema = z.object({
enabled: z.boolean().optional(),
checks_off: z.array(z.enum(QUALITY_CHECK_KEYS as [QualityCheckKey, ...QualityCheckKey[]])).optional(),
});
router.patch(
"/settings/quality-checks",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(qualityChecksSchema, req.body);
if (body.enabled !== undefined) await setQualityChecks(body.enabled);
if (body.checks_off) await setQualityChecksOff(body.checks_off);
res.json({ enabled: await getQualityChecks(), checks_off: await getQualityChecksOff() });
}),
);
// Readable by every user so the UI can tell whether imports will work. GET never echoes the
// token, only whether one is set.
router.get(
+317
View File
@@ -0,0 +1,317 @@
// Printability checks on a triangle mesh: holes, non-manifold edges, flipped faces, loose fragments and an
// implausible size. The rules are the ones slicers (ADMesh) and Blender's 3D Print Toolbox use: every edge of a
// solid belongs to exactly two faces, walked in opposite directions.
//
// Pure: runs in the render worker (modelPreviewWorker.ts) and in tests, so no config/db imports.
/** Bump when the rules change, so files checked by older rules are checked again on the next open. */
export const QUALITY_CHECK_VERSION = 1;
export type MeshStats = {
triangles: number;
/** Edges with one face: the boundary of a hole. */
openEdges: number;
/** Edges with three or more faces. */
nonManifoldEdges: number;
/** Edges whose two faces walk them the same way: one of the faces is turned inside out. */
flippedEdges: number;
/** Zero-area triangles, which slicers drop silently. */
degenerateTriangles: number;
/** Connected bodies. */
shells: number;
/** Bodies tiny next to the whole mesh: stray debris rather than a part. */
fragments: number;
/** Signed volume, negative when the whole mesh faces inwards. */
volume: number;
min: [number, number, number];
max: [number, number, number];
};
// Positions closer than this (in model units, usually mm) are one corner. STL repeats each corner per triangle, and
// exporters round differently, so exact matching would leave cracks that aren't there.
const WELD_TOLERANCE = 1e-4;
// A body whose bounding box is under this fraction of the whole mesh's is a fragment.
const FRAGMENT_SIZE_RATIO = 0.01;
/** Open-addressing hash of quantised positions to welded vertex ids. */
function weldVertices(vertices: ArrayLike<number>): { ids: Uint32Array; count: number } {
const vertexCount = Math.floor(vertices.length / 3);
const ids = new Uint32Array(vertexCount);
let size = 1;
while (size < vertexCount * 2) size <<= 1;
const mask = size - 1;
const slots = new Int32Array(size).fill(-1);
// The quantised position of each welded vertex, to compare on collision.
const keys = new Float64Array(vertexCount * 3);
let count = 0;
for (let v = 0; v < vertexCount; v++) {
const qx = Math.round(vertices[v * 3] / WELD_TOLERANCE);
const qy = Math.round(vertices[v * 3 + 1] / WELD_TOLERANCE);
const qz = Math.round(vertices[v * 3 + 2] / WELD_TOLERANCE);
let slot = (Math.imul(qx | 0, 73856093) ^ Math.imul(qy | 0, 19349663) ^ Math.imul(qz | 0, 83492791)) & mask;
for (;;) {
const existing = slots[slot];
if (existing === -1) {
slots[slot] = count;
keys[count * 3] = qx;
keys[count * 3 + 1] = qy;
keys[count * 3 + 2] = qz;
ids[v] = count++;
break;
}
if (keys[existing * 3] === qx && keys[existing * 3 + 1] === qy && keys[existing * 3 + 2] === qz) {
ids[v] = existing;
break;
}
slot = (slot + 1) & mask;
}
}
return { ids, count };
}
function findRoot(parent: Uint32Array, node: number): number {
let root = node;
while (parent[root] !== root) root = parent[root];
// Path halving keeps later lookups short.
while (parent[node] !== root) {
const next = parent[node];
parent[node] = root;
node = next;
}
return root;
}
/** `triangles` indexes `vertices` three per face; without it, every three vertices are a face (as in STL). */
export function analyzeMesh(vertices: ArrayLike<number>, triangles?: ArrayLike<number> | null): MeshStats {
const { ids, count: vertexCount } = weldVertices(vertices);
const corner = triangles ? (i: number) => ids[triangles[i]] : (i: number) => ids[i];
const cornerCount = triangles ? triangles.length : ids.length;
const triangleCount = Math.floor(cornerCount / 3);
const min: [number, number, number] = [Infinity, Infinity, Infinity];
const max: [number, number, number] = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i + 2 < vertices.length; i += 3) {
for (let axis = 0; axis < 3; axis++) {
const value = vertices[i + axis];
if (value < min[axis]) min[axis] = value;
if (value > max[axis]) max[axis] = value;
}
}
const diagonal = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1;
// Twice the area below which a triangle counts as having none, relative to the mesh's size.
const degenerateArea = (diagonal * 1e-9) ** 2;
const parent = new Uint32Array(vertexCount);
for (let i = 0; i < vertexCount; i++) parent[i] = i;
// Each edge as (low * vertexCount + high) * 2 + (walked high -> low). Exact in a double up to ~67M vertices.
const edges = new Float64Array(triangleCount * 3);
let edgeCount = 0;
let degenerate = 0;
let volume = 0;
// Any original vertex of each welded one, to read its position back.
const firstVertex = new Uint32Array(vertexCount);
for (let v = ids.length - 1; v >= 0; v--) firstVertex[ids[v]] = v;
const position = (id: number, axis: number) => vertices[firstVertex[id] * 3 + axis];
for (let t = 0; t < triangleCount; t++) {
const a = corner(t * 3);
const b = corner(t * 3 + 1);
const c = corner(t * 3 + 2);
const ax = position(a, 0);
const ay = position(a, 1);
const az = position(a, 2);
const ux = position(b, 0) - ax;
const uy = position(b, 1) - ay;
const uz = position(b, 2) - az;
const vx = position(c, 0) - ax;
const vy = position(c, 1) - ay;
const vz = position(c, 2) - az;
const nx = uy * vz - uz * vy;
const ny = uz * vx - ux * vz;
const nz = ux * vy - uy * vx;
if (a === b || b === c || a === c || nx * nx + ny * ny + nz * nz <= degenerateArea * degenerateArea) {
degenerate++;
continue;
}
// The tetrahedron to the origin; the sum over a closed surface is its volume.
volume += (ax * nx + ay * ny + az * nz) / 6;
for (const [from, to] of [
[a, b],
[b, c],
[c, a],
]) {
const low = Math.min(from, to);
const high = Math.max(from, to);
edges[edgeCount++] = (low * vertexCount + high) * 2 + (from > to ? 1 : 0);
const rootFrom = findRoot(parent, from);
const rootTo = findRoot(parent, to);
if (rootFrom !== rootTo) parent[rootFrom] = rootTo;
}
}
// oxlint-disable-next-line unicorn/no-array-sort -- in place on purpose: a copy would double the largest buffer.
const sorted = edges.subarray(0, edgeCount).sort();
let openEdges = 0;
let nonManifoldEdges = 0;
let flippedEdges = 0;
for (let start = 0; start < sorted.length;) {
const edge = Math.floor(sorted[start] / 2);
let end = start;
let reversed = 0;
while (end < sorted.length && Math.floor(sorted[end] / 2) === edge) {
reversed += sorted[end] % 2;
end++;
}
const faces = end - start;
if (faces === 1) openEdges++;
else if (faces > 2) nonManifoldEdges++;
else if (reversed !== 1) flippedEdges++;
start = end;
}
// Each body's bounding box, keyed by its root vertex.
const boxes = new Map<number, number[]>();
for (let t = 0; t < triangleCount; t++) {
for (let k = 0; k < 3; k++) {
const id = corner(t * 3 + k);
const root = findRoot(parent, id);
let box = boxes.get(root);
if (!box) {
box = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity];
boxes.set(root, box);
}
for (let axis = 0; axis < 3; axis++) {
const value = position(id, axis);
if (value < box[axis]) box[axis] = value;
if (value > box[axis + 3]) box[axis + 3] = value;
}
}
}
let fragments = 0;
if (boxes.size > 1) {
for (const box of boxes.values()) {
const size = Math.hypot(box[3] - box[0], box[4] - box[1], box[5] - box[2]);
if (size < diagonal * FRAGMENT_SIZE_RATIO) fragments++;
}
}
return {
triangles: triangleCount,
openEdges,
nonManifoldEdges,
flippedEdges,
degenerateTriangles: degenerate,
shells: boxes.size,
fragments,
volume,
min,
max,
};
}
export type QualityIssueCode =
| "unreadable"
| "empty"
| "missingParts"
| "openEdges"
| "nonManifoldEdges"
| "flippedFaces"
| "insideOut"
| "fragments"
| "tooSmall"
| "tooLarge";
/** What an admin switches on or off: related issues share one. */
export type QualityCheckKey =
"damaged" | "missingParts" | "openEdges" | "nonManifoldEdges" | "flippedFaces" | "insideOut" | "fragments" | "scale";
export const QUALITY_CHECK_KEYS: readonly QualityCheckKey[] = [
"damaged",
"missingParts",
"openEdges",
"nonManifoldEdges",
"flippedFaces",
"insideOut",
"fragments",
"scale",
];
export const QUALITY_CHECK_OF_ISSUE: Record<QualityIssueCode, QualityCheckKey> = {
unreadable: "damaged",
empty: "damaged",
missingParts: "missingParts",
openEdges: "openEdges",
nonManifoldEdges: "nonManifoldEdges",
flippedFaces: "flippedFaces",
insideOut: "insideOut",
fragments: "fragments",
tooSmall: "scale",
tooLarge: "scale",
};
/** Critical: the print fails or comes out incomplete. Major: it likely prints with defects. Minor: slicers usually
* cope, or it's a quick fix in one. */
export type QualitySeverity = "critical" | "major" | "minor";
// Looked up when results are read rather than saved with them, so changing one needs no new check.
export const QUALITY_SEVERITY: Record<QualityCheckKey, QualitySeverity> = {
damaged: "critical",
missingParts: "critical",
openEdges: "major",
flippedFaces: "major",
insideOut: "major",
nonManifoldEdges: "minor",
fragments: "minor",
scale: "minor",
};
export type QualityIssue = { code: QualityIssueCode; count?: number };
export type QualityReport = {
version: number;
status: "good" | "issues";
issues: QualityIssue[];
triangles: number;
shells: number;
/** Bounding box size in model units (mm for print files). */
size: [number, number, number];
};
// A print smaller than this in every direction was almost certainly exported in metres or inches-as-mm.
const TOO_SMALL_MM = 1;
// Bigger than any consumer printer, in one direction: likely exported in the wrong unit.
const TOO_LARGE_MM = 2000;
/** One file's verdict from the stats of each mesh in it. Meshes are checked apart, since separate objects touching
* each other aren't a defect. */
export function summarizeQuality(meshes: MeshStats[], missingParts = 0): QualityReport {
const total = (key: "openEdges" | "nonManifoldEdges" | "flippedEdges" | "fragments" | "triangles" | "shells") =>
meshes.reduce((sum, mesh) => sum + mesh[key], 0);
const min = [0, 1, 2].map((axis) => Math.min(...meshes.map((mesh) => mesh.min[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...meshes.map((mesh) => mesh.max[axis])));
const size = (meshes.length ? [0, 1, 2].map((axis) => max[axis] - min[axis]) : [0, 0, 0]) as [number, number, number];
const issues: QualityIssue[] = [];
const add = (code: QualityIssueCode, count?: number) => {
if (count === undefined || count > 0) issues.push(count === undefined ? { code } : { code, count });
};
const triangles = total("triangles");
if (triangles === 0) add("empty");
add("missingParts", missingParts);
add("openEdges", total("openEdges"));
add("nonManifoldEdges", total("nonManifoldEdges"));
add("flippedFaces", total("flippedEdges"));
// Only for a closed mesh: an open one has no inside, so its sign means nothing.
const insideOut = meshes.filter((mesh) => mesh.openEdges === 0 && mesh.triangles > 0 && mesh.volume < 0).length;
add("insideOut", insideOut);
add("fragments", total("fragments"));
if (triangles > 0) {
if (Math.max(...size) < TOO_SMALL_MM) add("tooSmall");
else if (Math.max(...size) > TOO_LARGE_MM) add("tooLarge");
}
return {
version: QUALITY_CHECK_VERSION,
status: issues.length ? "issues" : "good",
issues,
triangles,
shells: total("shells"),
size,
};
}
+17
View File
@@ -6,6 +6,7 @@ import { MODEL_PREVIEWS, MODEL_PREVIEW_MAX_MEMORY_MB, MODEL_PREVIEW_TIMEOUT_SECO
import { prisma } from "../db";
import type { ModelImageOptions } from "./modelImageRender";
import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelPreviewWorker";
import type { QualityReport } from "./meshQuality";
import { getSimplifyPreviews } from "./settingsService";
import {
assetExists,
@@ -79,6 +80,7 @@ export const SIMPLIFY_TARGET_TRIANGLES = 1_000_000;
type RenderOutcome =
| { status: "ok" }
| { status: "checked"; report: QualityReport }
| { status: "too-complex" }
| { status: "unsupported" }
| { status: "limit"; reason: string }
@@ -251,6 +253,21 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
}
}
export type QualityCheckOutcome = RenderOutcome;
/** The printability check (meshQuality.ts) in the render worker, under the same limits. It waits in the GLB queue
* rather than behind pictures: someone has the model open and is watching it run. */
export function checkPlateQuality(plateId: string, srcPath: string, ext: string): Promise<QualityCheckOutcome> {
return new Promise((resolve) => {
void scheduleRender("glb", async () => {
const outcome = await renderPlateFile(plateId, srcPath, (from) =>
renderInWorker({ kind: "quality", srcPath: from, ext }),
).catch((err: unknown): RenderOutcome => ({ status: "error", error: String(err) }));
resolve(outcome);
});
});
}
/** The plate's file where it is when its render starts: renders wait in a queue, and the file may have been renamed
* (and so moved) since, as when a newly imported print profile is named apart from another. */
async function currentPlatePath(plateId: string, queuedPath: string): Promise<string> {
@@ -517,6 +517,8 @@ type ParsedModel = {
plateThumbnails: Map<number, string>;
filamentColors: string[];
buildVolume: { x: number; y: number };
/** Part files the main model points to that aren't in the archive. */
missingParts: number;
};
const MAIN_MODEL_PATH = "3D/3dmodel.model";
@@ -596,12 +598,14 @@ async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | PreviewR
}
const externalModelCache = new Map<string, string | null>();
let missingParts = 0;
const loadExternalModel = async (rawPath: string): Promise<string | null> => {
const normalized = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
if (externalModelCache.has(normalized)) return externalModelCache.get(normalized) ?? null;
const buf = await readZipEntry(srcPath, normalized, MAX_MODEL_ENTRY_BYTES);
const text = buf ? buf.toString("utf-8") : null;
externalModelCache.set(normalized, text);
if (text === null) missingParts++;
return text;
};
@@ -625,6 +629,7 @@ async function parseThreeMfFast(srcPath: string): Promise<ParsedModel | PreviewR
plateThumbnails,
filamentColors,
buildVolume: buildVolume ?? { x: 256, y: 256 },
missingParts,
};
}
@@ -727,6 +732,17 @@ export async function loadThreeMfGroup(srcPath: string): Promise<import("three")
return buildGlbGroup(parsed);
}
/** Each placed object's meshes once, untransformed, for the printability check (meshQuality.ts). */
export async function loadThreeMfMeshes(
srcPath: string,
): Promise<{ meshes: { vertices: Float32Array; triangles: Uint32Array }[]; missingParts: number } | PreviewRefusal> {
const parsed = await parseThreeMfFast(srcPath);
if (typeof parsed === "string") return parsed;
const placed = new Set(parsed.buildItems.map((item) => item.objectId));
const meshes = [...placed].flatMap((id) => parsed.objects.get(id)?.meshes ?? []);
return { meshes, missingParts: parsed.missingParts };
}
export type SimplifiedSummary = { from: number; to: number };
function meshTriangleCount(mesh: import("three").Mesh): number {
+22 -9
View File
@@ -1,25 +1,38 @@
import { parentPort, workerData } from "node:worker_threads";
import { renderModelPreviewGlb, type RenderOptions } from "./modelPreviewRender";
import { renderModelImage, type ModelImageOptions } from "./modelImageRender";
import { checkModelQuality } from "./modelQualityCheck";
import type { QualityReport } from "./meshQuality";
// Worker entry point for one preview render, a GLB or a picture. Posts exactly one message, then exits.
// Worker entry point for one preview render, a GLB or a picture, or one printability check. Posts exactly one
// message, then exits.
export type ModelPreviewWorkerInput =
| { kind: "glb"; srcPath: string; destPath: string; options: RenderOptions }
| { kind: "image"; srcPath: string; destPath: string; ext: string; image: ModelImageOptions };
| { kind: "image"; srcPath: string; destPath: string; ext: string; image: ModelImageOptions }
| { kind: "quality"; srcPath: string; ext: string };
export type ModelPreviewWorkerResult =
{ status: "ok" } | { status: "too-complex" } | { status: "unsupported" } | { status: "error"; error: string };
| { status: "ok" }
| { status: "checked"; report: QualityReport }
| { status: "too-complex" }
| { status: "unsupported" }
| { status: "error"; error: string };
async function run(): Promise<void> {
const input = workerData as ModelPreviewWorkerInput;
let result: ModelPreviewWorkerResult;
try {
result = {
status:
input.kind === "glb"
? await renderModelPreviewGlb(input.srcPath, input.destPath, input.options)
: await renderModelImage(input.srcPath, input.destPath, input.ext, input.image),
};
if (input.kind === "quality") {
const report = await checkModelQuality(input.srcPath, input.ext);
result = typeof report === "string" ? { status: report } : { status: "checked", report };
} else {
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) };
}
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs/promises";
import { analyzeMesh, summarizeQuality, type MeshStats, type QualityReport } from "./meshQuality";
import { loadThreeMfMeshes, type PreviewRefusal } from "./modelPreviewRender";
// Loads a model file and runs the printability check on each mesh in it.
//
// Runs in a worker thread (modelPreviewWorker.ts); keep it free of config/db imports.
export async function checkModelQuality(srcPath: string, ext: string): Promise<QualityReport | PreviewRefusal> {
let meshes: MeshStats[];
let missingParts = 0;
try {
if (ext === ".3mf") {
const loaded = await loadThreeMfMeshes(srcPath);
if (typeof loaded === "string") return loaded;
meshes = loaded.meshes.map((mesh) => analyzeMesh(mesh.vertices, mesh.triangles));
missingParts = loaded.missingParts;
} 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),
);
meshes = [analyzeMesh(geometry.getAttribute("position").array)];
} else if (ext === ".obj") {
const { OBJLoader } = await import("three/examples/jsm/loaders/OBJLoader.js");
const root = new OBJLoader().parse(await fs.readFile(srcPath, "utf8"));
meshes = [];
root.traverse((node) => {
const mesh = node as import("three").Mesh;
const position = mesh.isMesh ? mesh.geometry.getAttribute("position") : null;
if (position) meshes.push(analyzeMesh(position.array, mesh.geometry.getIndex()?.array));
});
} else {
return "unsupported";
}
} catch {
// The loaders throw on truncated or garbled files: that's the verdict, not a failure to check.
return { ...summarizeQuality([]), issues: [{ code: "unreadable" }] };
}
return summarizeQuality(meshes, missingParts);
}
+151
View File
@@ -0,0 +1,151 @@
import path from "node:path";
import type { Plate, Prisma } from "@prisma/client";
import { prisma } from "../db";
import {
QUALITY_CHECK_OF_ISSUE,
QUALITY_CHECK_VERSION,
QUALITY_SEVERITY,
type QualityCheckKey,
type QualityIssue,
type QualityReport,
type QualitySeverity,
} from "./meshQuality";
import { checkPlateQuality } from "./modelPreviewCache";
import { resolvePlateFilePath } from "./printCreation";
import { getQualityChecks, getQualityChecksOff } from "./settingsService";
// Printability checks run when a model is opened, never on import: each file is checked once, the first time its
// model is viewed with checks switched on, and again only when the rules change (QUALITY_CHECK_VERSION).
export const QUALITY_CHECK_EXTS = new Set([".stl", ".obj", ".3mf"]);
type SkippedCheck = { version: number; status: "skipped"; reason: string };
type StoredCheck = QualityReport | SkippedCheck;
export type FileQualityStatus = "unchecked" | "checking" | "good" | "issues" | "skipped";
export type FileQualityOut = {
plate_id: string;
filename: string;
status: FileQualityStatus;
issues: (QualityIssue & { severity: QualitySeverity })[];
triangles: number | null;
shells: number | null;
size: [number, number, number] | null;
};
export type PrintQualityOut = {
enabled: boolean;
/** "none" when the model has no file the check reads. */
status: FileQualityStatus | "none";
files: FileQualityOut[];
};
const checking = new Set<string>();
function isCheckable(plate: Pick<Plate, "filename">): boolean {
return QUALITY_CHECK_EXTS.has(path.extname(plate.filename).toLowerCase());
}
function storedCheck(plate: Pick<Plate, "qualityCheck">): StoredCheck | null {
const value = plate.qualityCheck as StoredCheck | null;
return value && typeof value === "object" && value.version === QUALITY_CHECK_VERSION ? value : null;
}
const SEVERITY_RANK: Record<QualitySeverity, number> = { critical: 0, major: 1, minor: 2 };
/** Checks run in full whatever is switched off; what those find is left out here, so switching one back on needs no
* new check. */
function toFileQualityOut(plate: Plate, checksOff: ReadonlySet<QualityCheckKey>): FileQualityOut {
const check = checking.has(plate.id) ? null : storedCheck(plate);
const report = check && check.status !== "skipped" ? check : null;
const issues = (report?.issues ?? [])
.filter((issue) => !checksOff.has(QUALITY_CHECK_OF_ISSUE[issue.code]))
.map(({ code, count }) => {
const severity = QUALITY_SEVERITY[QUALITY_CHECK_OF_ISSUE[code]];
return count === undefined ? { code, severity } : { code, count, severity };
})
.toSorted((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
const status: FileQualityStatus = checking.has(plate.id)
? "checking"
: report
? issues.length
? "issues"
: "good"
: (check?.status ?? "unchecked");
return {
plate_id: plate.id,
filename: plate.filename,
status,
issues,
triangles: report?.triangles ?? null,
shells: report?.shells ?? null,
size: report?.size ?? null,
};
}
// The model's state is its most pressing file's: one still running, then one not yet checked, then one with issues.
const STATUS_ORDER: FileQualityStatus[] = ["checking", "unchecked", "issues", "good", "skipped"];
async function toPrintQualityOut(plates: Plate[]): Promise<PrintQualityOut> {
const checksOff = new Set(await getQualityChecksOff());
const files = plates
.filter(isCheckable)
.toSorted((a, b) => a.position - b.position)
.map((plate) => toFileQualityOut(plate, checksOff));
const status = STATUS_ORDER.find((candidate) => files.some((file) => file.status === candidate)) ?? "none";
return { enabled: true, status, files };
}
async function ownedPlates(userId: string, printId: string): Promise<Plate[] | null> {
const print = await prisma.print.findFirst({ where: { id: printId, userId }, include: { plates: true } });
return print ? print.plates : null;
}
/** Null when the model isn't the user's. */
export async function printQuality(userId: string, printId: string): Promise<PrintQualityOut | null> {
const plates = await ownedPlates(userId, printId);
if (!plates) return null;
const enabled = await getQualityChecks();
return enabled ? toPrintQualityOut(plates) : { enabled: false, status: "none", files: [] };
}
/** Starts checking each of the model's files not checked yet, and returns at once with them "checking". */
export async function startPrintQualityCheck(userId: string, printId: string): Promise<PrintQualityOut | null> {
const plates = await ownedPlates(userId, printId);
if (!plates) return null;
if (!(await getQualityChecks())) return { enabled: false, status: "none", files: [] };
for (const plate of plates) {
if (!isCheckable(plate) || checking.has(plate.id) || storedCheck(plate)) continue;
checking.add(plate.id);
void checkPlate(plate);
}
return toPrintQualityOut(plates);
}
async function checkPlate(plate: Plate): Promise<void> {
let result: StoredCheck;
try {
const srcPath = resolvePlateFilePath(plate);
if (!srcPath) {
result = { version: QUALITY_CHECK_VERSION, status: "skipped", reason: "missing-file" };
} else {
const outcome = await checkPlateQuality(plate.id, srcPath, path.extname(plate.filename).toLowerCase());
if (outcome.status === "checked") result = outcome.report;
else {
if (outcome.status === "error") console.error(`Quality check failed for plate ${plate.id}:`, outcome.error);
else if (outcome.status === "limit")
console.warn(`Quality check for plate ${plate.id} stopped: ${outcome.reason}`);
// Recorded either way, so a file that can't be checked isn't retried on every open.
result = { version: QUALITY_CHECK_VERSION, status: "skipped", reason: outcome.status };
}
}
await prisma.plate.update({
where: { id: plate.id },
data: { qualityCheck: result as unknown as Prisma.InputJsonValue },
});
} catch (err) {
// Most likely the plate was deleted mid-check.
console.warn(`Quality check for plate ${plate.id} not saved:`, err);
} finally {
checking.delete(plate.id);
}
}
+30
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { FLARESOLVERR_URL, PUBLIC_URL } from "../config";
import { QUALITY_CHECK_KEYS, type QualityCheckKey } from "./meshQuality";
async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
const row = await prisma.setting.findUnique({ where: { key } });
@@ -63,6 +64,35 @@ export async function setSimplifyPreviews(value: boolean): Promise<void> {
await setBoolSetting(SIMPLIFY_PREVIEWS_KEY, value);
}
const QUALITY_CHECKS_KEY = "quality_checks";
// Instance-wide. On by default: a model is only checked when opened, so the cost comes a model at a time.
export async function getQualityChecks(): Promise<boolean> {
return getBoolSetting(QUALITY_CHECKS_KEY, true);
}
export async function setQualityChecks(value: boolean): Promise<void> {
await setBoolSetting(QUALITY_CHECKS_KEY, value);
}
const QUALITY_CHECKS_OFF_KEY = "quality_checks_off";
// The checks switched off rather than on, so a check added later starts on.
export async function getQualityChecksOff(): Promise<QualityCheckKey[]> {
const row = await prisma.setting.findUnique({ where: { key: QUALITY_CHECKS_OFF_KEY } });
const value = row?.value;
return Array.isArray(value) ? QUALITY_CHECK_KEYS.filter((key) => (value as unknown[]).includes(key)) : [];
}
export async function setQualityChecksOff(keys: QualityCheckKey[]): Promise<void> {
const value = QUALITY_CHECK_KEYS.filter((key) => keys.includes(key));
await prisma.setting.upsert({
where: { key: QUALITY_CHECKS_OFF_KEY },
create: { key: QUALITY_CHECKS_OFF_KEY, value },
update: { value },
});
}
export type PreviewMode = "automatic" | "on-demand" | "disabled";
const PREVIEW_MODES = new Set<PreviewMode>(["automatic", "on-demand", "disabled"]);
const PREVIEW_MODE_KEY = "preview_mode";
+142
View File
@@ -0,0 +1,142 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { analyzeMesh, summarizeQuality } from "../src/services/meshQuality";
import { checkModelQuality } from "../src/services/modelQualityCheck";
// A closed box as loose triangles (STL-style), outward-facing, `size` mm on each side, offset by `at`.
function box(size = 10, at: [number, number, number] = [0, 0, 0]): number[][] {
const p = (x: number, y: number, z: number) => [at[0] + x * size, at[1] + y * size, at[2] + z * size];
return [
[p(0, 0, 0), p(1, 1, 0), p(1, 0, 0)],
[p(0, 0, 0), p(0, 1, 0), p(1, 1, 0)],
[p(0, 0, 1), p(1, 0, 1), p(1, 1, 1)],
[p(0, 0, 1), p(1, 1, 1), p(0, 1, 1)],
[p(0, 0, 0), p(1, 0, 0), p(1, 0, 1)],
[p(0, 0, 0), p(1, 0, 1), p(0, 0, 1)],
[p(0, 1, 0), p(1, 1, 1), p(1, 1, 0)],
[p(0, 1, 0), p(0, 1, 1), p(1, 1, 1)],
[p(0, 0, 0), p(0, 0, 1), p(0, 1, 1)],
[p(0, 0, 0), p(0, 1, 1), p(0, 1, 0)],
[p(1, 0, 0), p(1, 1, 1), p(1, 0, 1)],
[p(1, 0, 0), p(1, 1, 0), p(1, 1, 1)],
].map((triangle) => triangle.flat());
}
const soup = (triangles: number[][]) => Float32Array.from(triangles.flat());
const flip = (triangle: number[]) => [...triangle.slice(0, 3), ...triangle.slice(6, 9), ...triangle.slice(3, 6)];
describe("analyzeMesh", () => {
it("finds nothing wrong with a closed box", () => {
const stats = analyzeMesh(soup(box()));
expect(stats).toMatchObject({ triangles: 12, openEdges: 0, nonManifoldEdges: 0, flippedEdges: 0, shells: 1 });
expect(stats.volume).toBeCloseTo(1000, 3);
expect(summarizeQuality([stats])).toMatchObject({ status: "good", issues: [] });
});
it("joins corners that differ only by float rounding", () => {
const triangles = box();
triangles[0][0] += 0.00001;
expect(analyzeMesh(soup(triangles)).openEdges).toBe(0);
});
it("reads an indexed mesh, as 3MF stores it", () => {
const vertices = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1];
const tetra = [0, 2, 1, 0, 1, 3, 1, 2, 3, 0, 3, 2];
expect(analyzeMesh(vertices, tetra)).toMatchObject({ triangles: 4, openEdges: 0, flippedEdges: 0 });
});
it("reports a missing face as a hole", () => {
const stats = analyzeMesh(soup(box().slice(1)));
expect(stats.openEdges).toBe(3);
expect(summarizeQuality([stats]).issues).toContainEqual({ code: "openEdges", count: 3 });
});
it("reports a face turned the wrong way", () => {
const triangles = box();
triangles[0] = flip(triangles[0]);
const stats = analyzeMesh(soup(triangles));
expect(stats.openEdges).toBe(0);
expect(stats.flippedEdges).toBe(3);
});
it("reports a whole mesh facing inwards", () => {
const report = summarizeQuality([analyzeMesh(soup(box().map(flip)))]);
expect(report.issues).toEqual([{ code: "insideOut", count: 1 }]);
});
it("reports an edge shared by more than two faces", () => {
// A fin on an edge of the box.
const fin = [0, 0, 0, 10, 0, 0, 5, -5, 0];
expect(analyzeMesh(soup([...box(), fin])).nonManifoldEdges).toBeGreaterThan(0);
});
it("counts degenerate triangles without treating them as holes", () => {
const sliver = [0, 0, 0, 5, 0, 0, 10, 0, 0];
const stats = analyzeMesh(soup([...box(), sliver]));
expect(stats).toMatchObject({ degenerateTriangles: 1, openEdges: 0 });
});
it("tells a stray fragment from a second part", () => {
const twoParts = analyzeMesh(soup([...box(), ...box(10, [20, 0, 0])]));
expect(twoParts).toMatchObject({ shells: 2, fragments: 0 });
const debris = analyzeMesh(soup([...box(), ...box(0.05, [30, 30, 30])]));
expect(debris).toMatchObject({ shells: 2, fragments: 1 });
});
});
describe("summarizeQuality", () => {
it("flags a model exported in the wrong unit", () => {
expect(summarizeQuality([analyzeMesh(soup(box(0.05)))]).issues).toEqual([{ code: "tooSmall" }]);
expect(summarizeQuality([analyzeMesh(soup(box(5000)))]).issues).toEqual([{ code: "tooLarge" }]);
});
it("flags a file with no geometry or missing parts", () => {
expect(summarizeQuality([]).issues).toEqual([{ code: "empty" }]);
expect(summarizeQuality([analyzeMesh(soup(box()))], 2).issues).toEqual([{ code: "missingParts", count: 2 }]);
});
it("checks meshes apart, so touching objects aren't a defect", () => {
const report = summarizeQuality([analyzeMesh(soup(box())), analyzeMesh(soup(box(10, [10, 0, 0])))]);
expect(report).toMatchObject({ status: "good", shells: 2, size: [20, 10, 10] });
});
});
describe("checkModelQuality", () => {
const asciiStl = (triangles: number[][]) =>
`solid t\n${triangles
.map(
(t) =>
`facet normal 0 0 0\nouter loop\n${[0, 3, 6].map((i) => `vertex ${t[i]} ${t[i + 1]} ${t[i + 2]}`).join("\n")}\nendloop\nendfacet`,
)
.join("\n")}\nendsolid t\n`;
it("reads an STL file and reports its holes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "quality-"));
try {
await fs.writeFile(path.join(dir, "good.stl"), asciiStl(box()));
await fs.writeFile(path.join(dir, "holed.stl"), asciiStl(box().slice(2)));
expect(await checkModelQuality(path.join(dir, "good.stl"), ".stl")).toMatchObject({ status: "good" });
expect(await checkModelQuality(path.join(dir, "holed.stl"), ".stl")).toMatchObject({
status: "issues",
issues: [{ code: "openEdges", count: 4 }],
});
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it("calls a file that won't open unreadable", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "quality-"));
try {
await fs.writeFile(path.join(dir, "broken.3mf"), "not a zip");
expect(await checkModelQuality(path.join(dir, "broken.3mf"), ".3mf")).toMatchObject({
status: "issues",
issues: [{ code: "unreadable" }],
});
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});
+46
View File
@@ -163,6 +163,37 @@ function bulkChunks(ids: string[]): string[][] {
return chunks;
}
export type QualityIssueCode =
| "unreadable"
| "empty"
| "missingParts"
| "openEdges"
| "nonManifoldEdges"
| "flippedFaces"
| "insideOut"
| "fragments"
| "tooSmall"
| "tooLarge";
/** Critical: the print fails or comes out incomplete. Major: likely defects. Minor: slicers usually cope. */
export type QualitySeverity = "critical" | "major" | "minor";
export type QualityIssue = { code: QualityIssueCode; severity: QualitySeverity; count?: number };
export type FileQualityStatus = "unchecked" | "checking" | "good" | "issues" | "skipped";
export type FileQuality = {
plate_id: string;
filename: string;
status: FileQualityStatus;
issues: QualityIssue[];
triangles: number | null;
shells: number | null;
size: [number, number, number] | null;
};
export type PrintQuality = {
enabled: boolean;
/** The most pressing file's state; "none" when the model has no file the check reads. */
status: FileQualityStatus | "none";
files: FileQuality[];
};
export const printsApi = {
fileUrl: (rel: string) => {
if (!rel) return rel;
@@ -212,6 +243,21 @@ export const printsApi = {
return res.json();
},
getQuality: async (id: string): Promise<PrintQuality> => {
const res = await fetch(`${apiBase()}/print/${id}/quality`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Failed to load the quality check");
return res.json();
},
/** Starts checking the files not checked yet; returns at once with them "checking". */
startQuality: async (id: string): Promise<PrintQuality> => {
const res = await fetch(`${apiBase()}/print/${id}/quality`, { method: "POST", headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Failed to start the quality check");
return res.json();
},
listTags: async (
params: {
q?: string;
+59
View File
@@ -2,6 +2,7 @@ import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
import type { AccentColor, RenderColor, ThemeSelection } from "../constants/settingsOptions";
import { invalidateAiCategorizationStatus } from "./aiCategorization";
import type { QualityIssueCode, QualitySeverity } from "./prints";
export type StorageToken =
"category-or-folder" | "collection" | "tags" | "creator" | "model" | "filename" | "id" | "plate";
@@ -98,6 +99,46 @@ export type ConsumeSettings = {
export type RenderingSettings = { simplify_previews: boolean };
/** One switch per kind of print problem; related issues share one. */
export type QualityCheckKey =
"damaged" | "missingParts" | "openEdges" | "nonManifoldEdges" | "flippedFaces" | "insideOut" | "fragments" | "scale";
export const QUALITY_CHECK_KEYS: readonly QualityCheckKey[] = [
"damaged",
"missingParts",
"openEdges",
"nonManifoldEdges",
"flippedFaces",
"insideOut",
"fragments",
"scale",
];
// The backend's QUALITY_CHECK_OF_ISSUE: the switch each issue in a model's report falls under.
export const QUALITY_CHECK_OF_ISSUE: Record<QualityIssueCode, QualityCheckKey> = {
unreadable: "damaged",
empty: "damaged",
missingParts: "missingParts",
openEdges: "openEdges",
nonManifoldEdges: "nonManifoldEdges",
flippedFaces: "flippedFaces",
insideOut: "insideOut",
fragments: "fragments",
tooSmall: "scale",
tooLarge: "scale",
};
// The backend's QUALITY_SEVERITY (services/meshQuality.ts), which sets each issue's severity in results.
export const QUALITY_CHECK_SEVERITY: Record<QualityCheckKey, QualitySeverity> = {
damaged: "critical",
missingParts: "critical",
openEdges: "major",
flippedFaces: "major",
insideOut: "major",
nonManifoldEdges: "minor",
fragments: "minor",
scale: "minor",
};
export type QualityCheckSettings = { enabled: boolean; checks_off: QualityCheckKey[] };
export type AuthSettings = {
token_ttl_seconds: number;
};
@@ -289,6 +330,24 @@ export const settingsApi = {
return res.json();
},
getQualityChecks: async (): Promise<QualityCheckSettings> => {
const res = await fetch(`${apiBase()}/settings/quality-checks`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Failed to load quality check settings");
return res.json();
},
updateQualityChecks: async (patch: Partial<QualityCheckSettings>): Promise<QualityCheckSettings> => {
const res = await fetch(`${apiBase()}/settings/quality-checks`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(patch),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update quality check settings"));
return res.json();
},
updatePreviews: async (mode: PreviewMode): Promise<{ mode: PreviewMode }> => {
const res = await fetch(`${apiBase()}/settings/previews`, {
method: "POST",
@@ -0,0 +1,320 @@
import { useId, type ReactNode } from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import { alpha, useTheme } from "@mui/material/styles";
import { QUALITY_CHECK_SEVERITY, type QualityCheckKey } from "../api/settings";
import { QUALITY_SEVERITY_COLOR, QUALITY_SEVERITY_ICON } from "./QualitySeverityChip";
// A small picture of each print problem, for its row in the checks dialog and the model's quality report: the model in the accent colour, the problem
// in its severity's colour. Without motion, each shows the problem at its plainest.
const SIZE = 52;
const pulse = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
`;
const split = keyframes`
0%, 30%, 100% { transform: translateX(0) rotate(0deg); }
50%, 80% { transform: translateX(var(--shift)) rotate(var(--tilt)); }
`;
const growRay = keyframes`
0%, 15% { transform: scale(0); opacity: 0; }
40%, 85% { transform: scale(1); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
`;
const flip = keyframes`
0%, 35% { transform: rotate(0deg); }
50%, 85% { transform: rotate(180deg); }
100% { transform: rotate(360deg); }
`;
const squeeze = keyframes`
0%, 100% { transform: scale(1); }
50% { transform: scale(0.82); }
`;
const bob = keyframes`
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(2px, -3px); }
`;
const resize = keyframes`
0%, 15% { transform: scale(1); }
35%, 50% { transform: scale(0.15); }
70%, 85% { transform: scale(2.2); }
100% { transform: scale(1); }
`;
const motion = (animation: string, origin = "center") => ({
animation,
transformBox: "fill-box",
transformOrigin: origin,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
});
function Drawing({
check,
line,
fill,
problem,
}: {
check: QualityCheckKey;
line: string;
fill: string;
problem: string;
}) {
const clip = useId().replace(/:/g, "");
const stroke = { stroke: line, strokeWidth: 1.2, strokeLinejoin: "round" as const };
const problemFill = alpha(problem, 0.2);
switch (check) {
case "damaged": {
// A file split down a jagged crack, its halves drifting apart.
const page = "M14 8 H29 L36 15 V42 H14 Z";
const crack = "L22 16 L27 22 L21 30 L26 36 L23 42";
return (
<>
<defs>
<clipPath id={`${clip}-l`}>
<path d={`M0 0 H25 ${crack} V48 H0 Z`} />
</clipPath>
<clipPath id={`${clip}-r`}>
<path d={`M48 0 H25 ${crack} V48 H48 Z`} />
</clipPath>
</defs>
{(["l", "r"] as const).map((side) => (
<Box
key={side}
component="g"
clipPath={`url(#${clip}-${side})`}
sx={{
"--shift": side === "l" ? "-1.5px" : "1.5px",
"--tilt": side === "l" ? "-4deg" : "4deg",
...motion(`${split} 2.6s ease-in-out infinite`, side === "l" ? "left bottom" : "right bottom"),
}}
>
<path d={page} fill={fill} {...stroke} />
<path d="M29 8 V15 H36" fill="none" {...stroke} />
</Box>
))}
<path d={`M25 8 ${crack}`} fill="none" stroke={problem} strokeWidth={1.6} strokeLinejoin="round" />
</>
);
}
case "missingParts": {
// An assembly with one block's place empty.
return (
<>
<rect x={10} y={24} width={14} height={14} fill={fill} {...stroke} />
<rect x={24} y={24} width={14} height={14} fill={fill} {...stroke} />
<rect x={10} y={10} width={14} height={14} fill={fill} {...stroke} />
<Box
component="rect"
x={24}
y={10}
width={14}
height={14}
fill={problemFill}
stroke={problem}
strokeWidth={1.6}
strokeDasharray="3 2"
sx={motion(`${pulse} 1.8s ease-in-out infinite`)}
/>
</>
);
}
case "openEdges": {
// A meshed surface with one triangle gone, its open edges lit.
const cells: ReactNode[] = [];
for (const [x, y] of [
[10, 10],
[24, 10],
[10, 24],
[24, 24],
]) {
cells.push(
<path key={`${x}-${y}-a`} d={`M${x} ${y} h14 L${x} ${y + 14} Z`} fill={fill} {...stroke} />,
x === 24 && y === 10 ? null : (
<path key={`${x}-${y}-b`} d={`M${x + 14} ${y} v14 h-14 Z`} fill={fill} {...stroke} />
),
);
}
return (
<>
{cells}
<Box
component="path"
d="M38 10 v14 h-14 Z"
fill={problemFill}
stroke={problem}
strokeWidth={1.8}
strokeLinejoin="round"
sx={motion(`${pulse} 1.6s ease-in-out infinite`)}
/>
</>
);
}
case "nonManifoldEdges": {
// An edge seen end on: two faces meet there as they should, then a third grows out of it.
return (
<>
<path d="M24 24 L7 14" {...stroke} strokeWidth={3} strokeLinecap="round" />
<path d="M24 24 L41 14" {...stroke} strokeWidth={3} strokeLinecap="round" />
<Box
component="path"
d="M24 24 L24 43"
stroke={problem}
strokeWidth={3}
strokeLinecap="round"
sx={motion(`${growRay} 2.4s ease-in-out infinite`, "center top")}
/>
<circle cx={24} cy={24} r={3.2} fill={problem} />
</>
);
}
case "flippedFaces": {
// A strip of faces whose normals point out, but one turns over and points in.
const arrow = (x: number, color: string) => (
<>
<path d={`M${x} 22 V10`} stroke={color} strokeWidth={1.6} strokeLinecap="round" />
<path d={`M${x - 3} 13 L${x} 9.5 L${x + 3} 13`} fill="none" stroke={color} strokeWidth={1.6} />
</>
);
return (
<>
<path d="M6 36 L15 24 L24 36 L33 24 L42 36 Z" fill={fill} {...stroke} />
<path d="M15 24 H33" {...stroke} />
{arrow(15, line)}
{arrow(33, line)}
<Box
component="g"
sx={{ transform: "rotate(180deg)", ...motion(`${flip} 2.8s ease-in-out infinite`, "center bottom") }}
>
{arrow(24, problem)}
</Box>
</>
);
}
case "insideOut": {
// A closed shape whose normals all point inwards.
const inward = [
[24, 7, 24, 15],
[41, 24, 33, 24],
[24, 41, 24, 33],
[7, 24, 15, 24],
];
return (
<>
<circle cx={24} cy={24} r={13} fill={fill} {...stroke} />
<Box component="g" sx={motion(`${squeeze} 2s ease-in-out infinite`)}>
{inward.map(([x1, y1, x2, y2]) => {
const dx = Math.sign(x2 - x1) * 3;
const dy = Math.sign(y2 - y1) * 3;
return (
<g key={`${x1}-${y1}`} stroke={problem} strokeWidth={1.6} strokeLinecap="round" fill="none">
<path d={`M${x1} ${y1} L${x2} ${y2}`} />
<path d={`M${x2 - dx - dy} ${y2 - dy - dx} L${x2} ${y2} L${x2 - dx + dy} ${y2 - dy + dx}`} />
</g>
);
})}
</Box>
</>
);
}
case "fragments": {
// A part with crumbs of mesh floating loose beside it.
return (
<>
<rect x={8} y={18} width={22} height={20} rx={3} fill={fill} {...stroke} />
{[
[36, 14, 0],
[40, 27, 0.5],
[34, 36, 1],
].map(([x, y, delay]) => (
<Box
key={`${x}-${y}`}
component="rect"
x={x}
y={y}
width={4}
height={4}
fill={problem}
sx={{ ...motion(`${bob} 2s ease-in-out infinite`), animationDelay: `${delay}s` }}
/>
))}
</>
);
}
case "scale":
// A model against a ruler, shrinking to a speck and swelling past the tile, as a wrong unit would.
return (
<>
<path d="M6 42 H42" {...stroke} />
{[6, 15, 24, 33, 42].map((x) => (
<path key={x} d={`M${x} 42 V38`} {...stroke} />
))}
<Box
component="rect"
x={17}
y={22}
width={14}
height={14}
fill={problemFill}
stroke={problem}
strokeWidth={1.4}
sx={motion(`${resize} 3.2s ease-in-out infinite`, "center bottom")}
/>
</>
);
}
}
/** The tile on the left of a problem's row. Decorative: the row's text says the same. `badge` pins the severity's icon
* to its corner, where a row would otherwise show it beside the tile. */
export default function QualityCheckTile({ check, badge }: { check: QualityCheckKey; badge?: boolean }) {
const theme = useTheme();
const line = theme.palette.primary.main;
const severity = QUALITY_CHECK_SEVERITY[check];
const problem = theme.palette[QUALITY_SEVERITY_COLOR[severity]].main;
const Icon = QUALITY_SEVERITY_ICON[severity];
const tile = (
<Box
aria-hidden
sx={{
width: SIZE,
height: SIZE,
flexShrink: 0,
borderRadius: "12px",
bgcolor: "#ffffff",
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08)",
overflow: "hidden",
display: "grid",
placeItems: "center",
}}
>
<svg width={44} height={44} viewBox="0 0 48 48" overflow="hidden">
<Drawing check={check} line={line} fill={alpha(line, 0.14)} problem={problem} />
</svg>
</Box>
);
if (!badge) return tile;
return (
<Box aria-hidden sx={{ position: "relative", flexShrink: 0 }}>
{tile}
<Box
sx={{
position: "absolute",
right: -6,
bottom: -6,
width: 22,
height: 22,
borderRadius: "50%",
display: "grid",
placeItems: "center",
bgcolor: "background.paper",
boxShadow: `0 0 0 2px ${problem}`,
}}
>
<Icon sx={{ fontSize: 15, color: problem }} />
</Box>
</Box>
);
}
@@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import Chip from "@mui/material/Chip";
import Tooltip from "@mui/material/Tooltip";
import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import type { PrintQuality, QualitySeverity } from "../api/prints";
export const QUALITY_SEVERITY_COLOR = { critical: "error", major: "warning", minor: "info" } as const;
export const QUALITY_SEVERITY_ICON = {
critical: ErrorOutlineRoundedIcon,
major: WarningAmberRoundedIcon,
minor: InfoOutlinedIcon,
} as const;
const SEVERITIES: QualitySeverity[] = ["critical", "major", "minor"];
/** The most serious problem in any of the model's files. */
export function worstSeverity(quality: PrintQuality): QualitySeverity {
return (
SEVERITIES.find((severity) =>
quality.files.some((file) => file.issues.some((issue) => issue.severity === severity)),
) ?? "minor"
);
}
/** How serious a print problem is, with what that level means on hover or focus. */
export default function QualitySeverityChip({ severity }: { severity: QualitySeverity }) {
const { t } = useTranslation("models");
return (
<Tooltip title={t(`detail.quality.severity.${severity}Hint`)}>
<Chip
size="small"
variant="outlined"
color={QUALITY_SEVERITY_COLOR[severity]}
label={t(`detail.quality.severity.${severity}`)}
// Focusable, so the hint reaches keyboard users too.
tabIndex={0}
sx={{ height: 20, fontSize: 11, fontWeight: 600, flexShrink: 0, "& .MuiChip-label": { px: 0.75 } }}
/>
</Tooltip>
);
}
+127 -1
View File
@@ -1288,6 +1288,56 @@
"saveFailed": "Failed to save rendering settings.",
"illustrationBefore": "Detailed",
"illustrationAfter": "Simplified"
},
"quality": {
"heading": "Print quality checks",
"subtitle": "Look for problems that could spoil a print.",
"infoTitle": "How quality checks work",
"infoWhen": "A model is checked the first time it is opened, and only then. Models nobody opens are never checked.",
"infoChecks": "The check looks for holes, broken or flipped surfaces, loose fragments, missing parts and an unlikely size, such as a model exported in the wrong unit.",
"infoCost": "Checking reads the whole model on the server: a few seconds for most models, longer for very detailed ones.",
"label": "Check models for print problems",
"helpOn": "On: each model is checked the first time it is opened. On by default.",
"helpOff": "Off: models are not checked.",
"loadFailed": "Failed to load quality check settings.",
"saveFailed": "Failed to save quality check settings.",
"configure": "Choose what to check",
"dialogTitle": "Problems to check for",
"dialogHint": "A problem switched off is hidden from every model's results straight away. Models aren't checked again.",
"checks": {
"damaged": {
"title": "Damaged files",
"desc": "Files that won't open or contain nothing to print."
},
"missingParts": {
"title": "Missing parts",
"desc": "A 3MF that refers to part files it doesn't contain."
},
"openEdges": {
"title": "Holes in the surface",
"desc": "Open edges: the surface isn't closed, so the slicer has to guess what is inside."
},
"nonManifoldEdges": {
"title": "Non-manifold edges",
"desc": "Edges shared by more than two faces, which slicers can misread."
},
"flippedFaces": {
"title": "Flipped faces",
"desc": "Faces turned the opposite way to their neighbours."
},
"insideOut": {
"title": "Inside out",
"desc": "A closed surface that faces inwards."
},
"fragments": {
"title": "Loose fragments",
"desc": "Tiny separate pieces floating apart from the model."
},
"scale": {
"title": "Unlikely size",
"desc": "Under 1 mm or over 2 m across: probably exported in the wrong unit."
}
}
}
},
"importQueue": {
@@ -2704,7 +2754,83 @@
"sizeHint": "Total size of all this model's files in storage",
"author": "Author",
"downloadModelFiles": "Download model files",
"imported": "Imported {{date}}"
"imported": "Imported {{date}}",
"quality": {
"scanning": "Checking print quality…",
"scanningFiles": "{{done}} of {{total}} files checked",
"doneGood": "Looks printable",
"doneIssues": "Problems found",
"toastIssues": "This model may not print well. See the warning next to 3D Preview.",
"badgeGood": "Quality check passed",
"badgeIssues": "Print problems found",
"dialogTitle": "Print quality",
"noIssues": "No problems found",
"skipped": "Not checked: the file is too large or in a layout the check can't read.",
"notYet": "Not checked yet.",
"slicerNote": "Slicers repair many small problems on their own. Look at the sliced preview before printing.",
"triangles_one": "{{formatted}} triangle",
"triangles_other": "{{formatted}} triangles",
"bodies_one": "{{count}} body",
"bodies_other": "{{count}} bodies",
"size": "{{x}} × {{y}} × {{z}} mm",
"issues": {
"unreadable": {
"title": "File can't be opened",
"desc": "The file is damaged or isn't a valid model file."
},
"empty": {
"title": "No geometry",
"desc": "The file contains nothing to print."
},
"missingParts": {
"title_one": "{{count}} missing part",
"title_other": "{{count}} missing parts",
"desc": "The model refers to part files that aren't in it, so pieces will be missing."
},
"openEdges": {
"title": "Holes in the surface",
"desc_one": "{{count}} open edge: the surface isn't closed, so the slicer has to guess what is inside.",
"desc_other": "{{count}} open edges: the surface isn't closed, so the slicer has to guess what is inside."
},
"nonManifoldEdges": {
"title": "Non-manifold edges",
"desc_one": "{{count}} edge is shared by more than two faces, which slicers can misread.",
"desc_other": "{{count}} edges are shared by more than two faces, which slicers can misread."
},
"flippedFaces": {
"title": "Flipped faces",
"desc_one": "{{count}} edge lies between faces turned opposite ways, so parts may print hollow or go missing.",
"desc_other": "{{count}} edges lie between faces turned opposite ways, so parts may print hollow or go missing."
},
"insideOut": {
"title": "Inside out",
"desc": "The surface faces inwards. Some slicers treat it as empty."
},
"fragments": {
"title_one": "{{count}} loose fragment",
"title_other": "{{count}} loose fragments",
"desc": "Tiny separate pieces float apart from the model. They print as blobs or not at all."
},
"tooSmall": {
"title": "Very small",
"desc": "Under 1 mm across: probably exported in the wrong unit. Scale it up in the slicer."
},
"tooLarge": {
"title": "Very large",
"desc": "Over 2 m across: probably exported in the wrong unit. Scale it down in the slicer."
}
},
"toastMinor": "Minor print problems found in this model. Slicers usually cope with these.",
"severity": {
"critical": "Critical",
"criticalHint": "The print fails or comes out incomplete.",
"major": "Major",
"majorHint": "The model likely prints with defects.",
"minor": "Minor",
"minorHint": "Slicers usually cope, or it's a quick fix in one."
},
"manageSettings": "Manage quality checks"
}
},
"edit": {
"title": "Edit model",
+127 -1
View File
@@ -1288,6 +1288,56 @@
"saveFailed": "No se pudieron guardar los ajustes de renderizado.",
"illustrationBefore": "Detallado",
"illustrationAfter": "Simplificado"
},
"quality": {
"heading": "Comprobación de calidad de impresión",
"subtitle": "Busca problemas que podrían arruinar una impresión.",
"infoTitle": "Cómo funciona la comprobación de calidad",
"infoWhen": "Un modelo se comprueba la primera vez que se abre, y solo entonces. Los modelos que nadie abre nunca se comprueban.",
"infoChecks": "La comprobación busca agujeros, superficies rotas o invertidas, fragmentos sueltos, piezas que faltan y un tamaño improbable, como un modelo exportado en la unidad equivocada.",
"infoCost": "La comprobación lee el modelo completo en el servidor: unos segundos para la mayoría de los modelos, más para los muy detallados.",
"label": "Comprobar si los modelos tienen problemas de impresión",
"helpOn": "Activado: cada modelo se comprueba la primera vez que se abre. Activado por defecto.",
"helpOff": "Desactivado: los modelos no se comprueban.",
"loadFailed": "No se pudieron cargar los ajustes de comprobación de calidad.",
"saveFailed": "No se pudieron guardar los ajustes de comprobación de calidad.",
"configure": "Elegir qué comprobar",
"dialogTitle": "Problemas que comprobar",
"dialogHint": "Un problema desactivado se oculta de inmediato en los resultados de todos los modelos. Los modelos no se vuelven a comprobar.",
"checks": {
"damaged": {
"title": "Archivos dañados",
"desc": "Archivos que no se abren o no contienen nada que imprimir."
},
"missingParts": {
"title": "Piezas que faltan",
"desc": "Un 3MF que hace referencia a archivos de piezas que no contiene."
},
"openEdges": {
"title": "Agujeros en la superficie",
"desc": "Aristas abiertas: la superficie no está cerrada, así que el slicer tiene que adivinar qué hay dentro."
},
"nonManifoldEdges": {
"title": "Aristas no múltiples",
"desc": "Aristas compartidas por más de dos caras, que los slicers pueden interpretar mal."
},
"flippedFaces": {
"title": "Caras invertidas",
"desc": "Caras orientadas al revés que sus vecinas."
},
"insideOut": {
"title": "Del revés",
"desc": "Una superficie cerrada que mira hacia dentro."
},
"fragments": {
"title": "Fragmentos sueltos",
"desc": "Pequeñas piezas separadas que flotan lejos del modelo."
},
"scale": {
"title": "Tamaño improbable",
"desc": "Menos de 1 mm o más de 2 m: probablemente se exportó en la unidad equivocada."
}
}
}
},
"importQueue": {
@@ -2704,7 +2754,83 @@
"sizeHint": "Tamaño total de todos los archivos de este modelo en el almacenamiento",
"author": "Autor",
"downloadModelFiles": "Descargar archivos del modelo",
"imported": "Importado el {{date}}"
"imported": "Importado el {{date}}",
"quality": {
"scanning": "Comprobando la calidad de impresión…",
"scanningFiles": "{{done}} de {{total}} archivos comprobados",
"doneGood": "Se puede imprimir",
"doneIssues": "Se encontraron problemas",
"toastIssues": "Es posible que este modelo no se imprima bien. Consulta el aviso junto a Vista previa 3D.",
"badgeGood": "Comprobación de calidad superada",
"badgeIssues": "Se encontraron problemas de impresión",
"dialogTitle": "Calidad de impresión",
"noIssues": "No se encontraron problemas",
"skipped": "No comprobado: el archivo es demasiado grande o tiene una estructura que la comprobación no puede leer.",
"notYet": "Aún no se ha comprobado.",
"slicerNote": "Los slicers reparan por sí solos muchos problemas pequeños. Revisa la vista previa laminada antes de imprimir.",
"triangles_one": "{{formatted}} triángulo",
"triangles_other": "{{formatted}} triángulos",
"bodies_one": "{{count}} cuerpo",
"bodies_other": "{{count}} cuerpos",
"size": "{{x}} × {{y}} × {{z}} mm",
"issues": {
"unreadable": {
"title": "No se puede abrir el archivo",
"desc": "El archivo está dañado o no es un archivo de modelo válido."
},
"empty": {
"title": "Sin geometría",
"desc": "El archivo no contiene nada que imprimir."
},
"missingParts": {
"title_one": "Falta {{count}} pieza",
"title_other": "Faltan {{count}} piezas",
"desc": "El modelo hace referencia a archivos de piezas que no contiene, así que faltarán partes."
},
"openEdges": {
"title": "Agujeros en la superficie",
"desc_one": "{{count}} arista abierta: la superficie no está cerrada, así que el slicer tiene que adivinar qué hay dentro.",
"desc_other": "{{count}} aristas abiertas: la superficie no está cerrada, así que el slicer tiene que adivinar qué hay dentro."
},
"nonManifoldEdges": {
"title": "Aristas no múltiples",
"desc_one": "{{count}} arista la comparten más de dos caras, algo que los slicers pueden interpretar mal.",
"desc_other": "{{count}} aristas las comparten más de dos caras, algo que los slicers pueden interpretar mal."
},
"flippedFaces": {
"title": "Caras invertidas",
"desc_one": "{{count}} arista separa caras orientadas en sentidos opuestos, así que algunas partes pueden salir huecas o faltar.",
"desc_other": "{{count}} aristas separan caras orientadas en sentidos opuestos, así que algunas partes pueden salir huecas o faltar."
},
"insideOut": {
"title": "Del revés",
"desc": "La superficie mira hacia dentro. Algunos slicers la tratan como vacía."
},
"fragments": {
"title_one": "{{count}} fragmento suelto",
"title_other": "{{count}} fragmentos sueltos",
"desc": "Pequeñas piezas separadas flotan lejos del modelo. Se imprimen como gotas o no se imprimen."
},
"tooSmall": {
"title": "Muy pequeño",
"desc": "Menos de 1 mm: probablemente se exportó en la unidad equivocada. Escálalo en el slicer."
},
"tooLarge": {
"title": "Muy grande",
"desc": "Más de 2 m: probablemente se exportó en la unidad equivocada. Redúcelo en el slicer."
}
},
"toastMinor": "Se encontraron problemas de impresión menores en este modelo. Los slicers suelen resolverlos.",
"severity": {
"critical": "Crítico",
"criticalHint": "La impresión falla o sale incompleta.",
"major": "Grave",
"majorHint": "Es probable que el modelo se imprima con defectos.",
"minor": "Leve",
"minorHint": "Los slicers suelen resolverlo, o se corrige rápido en uno."
},
"manageSettings": "Gestionar la comprobación de calidad"
}
},
"edit": {
"title": "Editar modelo",
+127 -1
View File
@@ -1288,6 +1288,56 @@
"saveFailed": "Échec de l’enregistrement des paramètres de rendu.",
"illustrationBefore": "Détaillé",
"illustrationAfter": "Simplifié"
},
"quality": {
"heading": "Contrôle de la qualité d’impression",
"subtitle": "Rechercher les problèmes qui pourraient gâcher une impression.",
"infoTitle": "Fonctionnement du contrôle de qualité",
"infoWhen": "Un modèle est contrôlé la première fois qu’il est ouvert, et seulement à ce moment-là. Les modèles que personne n’ouvre ne sont jamais contrôlés.",
"infoChecks": "Le contrôle recherche les trous, les surfaces cassées ou inversées, les fragments isolés, les pièces manquantes et une taille improbable, comme un modèle exporté dans la mauvaise unité.",
"infoCost": "Le contrôle lit le modèle entier sur le serveur : quelques secondes pour la plupart des modèles, davantage pour les modèles très détaillés.",
"label": "Rechercher les problèmes d’impression dans les modèles",
"helpOn": "Activé : chaque modèle est contrôlé la première fois qu’il est ouvert. Activé par défaut.",
"helpOff": "Désactivé : les modèles ne sont pas contrôlés.",
"loadFailed": "Impossible de charger les paramètres du contrôle de qualité.",
"saveFailed": "Échec de l’enregistrement des paramètres du contrôle de qualité.",
"configure": "Choisir quoi contrôler",
"dialogTitle": "Problèmes à rechercher",
"dialogHint": "Un problème désactivé est aussitôt masqué dans les résultats de tous les modèles. Les modèles ne sont pas contrôlés à nouveau.",
"checks": {
"damaged": {
"title": "Fichiers endommagés",
"desc": "Fichiers qui ne s’ouvrent pas ou ne contiennent rien à imprimer."
},
"missingParts": {
"title": "Pièces manquantes",
"desc": "Un 3MF qui fait référence à des fichiers de pièces qu’il ne contient pas."
},
"openEdges": {
"title": "Trous dans la surface",
"desc": "Arêtes ouvertes : la surface n’est pas fermée, le slicer doit donc deviner ce qui se trouve à l’intérieur."
},
"nonManifoldEdges": {
"title": "Arêtes non manifold",
"desc": "Arêtes partagées par plus de deux faces, que les slicers peuvent mal interpréter."
},
"flippedFaces": {
"title": "Faces inversées",
"desc": "Faces orientées à l’inverse de leurs voisines."
},
"insideOut": {
"title": "À l’envers",
"desc": "Une surface fermée tournée vers l’intérieur."
},
"fragments": {
"title": "Fragments isolés",
"desc": "De minuscules morceaux qui flottent à l’écart du modèle."
},
"scale": {
"title": "Taille improbable",
"desc": "Moins de 1 mm ou plus de 2 m : probablement exporté dans la mauvaise unité."
}
}
}
},
"importQueue": {
@@ -2717,7 +2767,83 @@
"sizeHint": "Taille totale de tous les fichiers de ce modèle dans le stockage",
"author": "Auteur",
"downloadModelFiles": "Télécharger les fichiers du modèle",
"imported": "Importé le {{date}}"
"imported": "Importé le {{date}}",
"quality": {
"scanning": "Contrôle de la qualité d’impression…",
"scanningFiles": "{{done}} fichiers contrôlés sur {{total}}",
"doneGood": "Imprimable",
"doneIssues": "Problèmes détectés",
"toastIssues": "Ce modèle risque de mal s’imprimer. Voir l’avertissement à côté d’Aperçu 3D.",
"badgeGood": "Contrôle de qualité réussi",
"badgeIssues": "Problèmes d’impression détectés",
"dialogTitle": "Qualité d’impression",
"noIssues": "Aucun problème détecté",
"skipped": "Non contrôlé : le fichier est trop volumineux ou sa structure ne peut pas être lue par le contrôle.",
"notYet": "Pas encore contrôlé.",
"slicerNote": "Les slicers corrigent seuls de nombreux petits problèmes. Vérifiez l’aperçu tranché avant d’imprimer.",
"triangles_one": "{{formatted}} triangle",
"triangles_other": "{{formatted}} triangles",
"bodies_one": "{{count}} corps",
"bodies_other": "{{count}} corps",
"size": "{{x}} × {{y}} × {{z}} mm",
"issues": {
"unreadable": {
"title": "Impossible d’ouvrir le fichier",
"desc": "Le fichier est endommagé ou n’est pas un fichier de modèle valide."
},
"empty": {
"title": "Aucune géométrie",
"desc": "Le fichier ne contient rien à imprimer."
},
"missingParts": {
"title_one": "{{count}} pièce manquante",
"title_other": "{{count}} pièces manquantes",
"desc": "Le modèle fait référence à des fichiers de pièces absents : des parties manqueront."
},
"openEdges": {
"title": "Trous dans la surface",
"desc_one": "{{count}} arête ouverte : la surface n’est pas fermée, le slicer doit donc deviner ce qui se trouve à l’intérieur.",
"desc_other": "{{count}} arêtes ouvertes : la surface n’est pas fermée, le slicer doit donc deviner ce qui se trouve à l’intérieur."
},
"nonManifoldEdges": {
"title": "Arêtes non manifold",
"desc_one": "{{count}} arête est partagée par plus de deux faces, ce que les slicers peuvent mal interpréter.",
"desc_other": "{{count}} arêtes sont partagées par plus de deux faces, ce que les slicers peuvent mal interpréter."
},
"flippedFaces": {
"title": "Faces inversées",
"desc_one": "{{count}} arête sépare des faces orientées en sens opposés : certaines parties peuvent être creuses ou manquer.",
"desc_other": "{{count}} arêtes séparent des faces orientées en sens opposés : certaines parties peuvent être creuses ou manquer."
},
"insideOut": {
"title": "À l’envers",
"desc": "La surface est tournée vers l’intérieur. Certains slicers la considèrent comme vide."
},
"fragments": {
"title_one": "{{count}} fragment isolé",
"title_other": "{{count}} fragments isolés",
"desc": "De minuscules morceaux flottent à l’écart du modèle. Ils s’impriment en amas ou pas du tout."
},
"tooSmall": {
"title": "Très petit",
"desc": "Moins de 1 mm : probablement exporté dans la mauvaise unité. Agrandissez-le dans le slicer."
},
"tooLarge": {
"title": "Très grand",
"desc": "Plus de 2 m : probablement exporté dans la mauvaise unité. Réduisez-le dans le slicer."
}
},
"toastMinor": "Problèmes d’impression mineurs détectés dans ce modèle. Les slicers s’en accommodent généralement.",
"severity": {
"critical": "Critique",
"criticalHint": "L’impression échoue ou sort incomplète.",
"major": "Majeur",
"majorHint": "Le modèle s’imprimera probablement avec des défauts.",
"minor": "Mineur",
"minorHint": "Les slicers s’en accommodent généralement, ou cela se corrige vite dans l’un d’eux."
},
"manageSettings": "Gérer le contrôle de qualité"
}
},
"edit": {
"title": "Modifier le modèle",
+134 -1
View File
@@ -1321,6 +1321,56 @@
"saveFailed": "Nepavyko išsaugoti atvaizdavimo nustatymų.",
"illustrationBefore": "Detalus",
"illustrationAfter": "Supaprastintas"
},
"quality": {
"heading": "Spausdinimo kokybės patikra",
"subtitle": "Ieškoti problemų, kurios gali sugadinti spaudinį.",
"infoTitle": "Kaip veikia kokybės patikra",
"infoWhen": "Modelis tikrinamas pirmą kartą jį atidarius ir tik tada. Niekada neatidaryti modeliai netikrinami.",
"infoChecks": "Patikra ieško skylių, sugadintų ar apverstų paviršių, atskirų nuolaužų, trūkstamų dalių ir neįtikėtino dydžio, pavyzdžiui, kai modelis eksportuotas netinkamais vienetais.",
"infoCost": "Patikra serveryje nuskaito visą modelį: daugumai modelių tai užtrunka kelias sekundes, labai detaliems – ilgiau.",
"label": "Tikrinti modelius dėl spausdinimo problemų",
"helpOn": "Įjungta: kiekvienas modelis patikrinamas pirmą kartą jį atidarius. Įjungta pagal numatytuosius nustatymus.",
"helpOff": "Išjungta: modeliai netikrinami.",
"loadFailed": "Nepavyko įkelti kokybės patikros nustatymų.",
"saveFailed": "Nepavyko išsaugoti kokybės patikros nustatymų.",
"configure": "Pasirinkti, ką tikrinti",
"dialogTitle": "Tikrinamos problemos",
"dialogHint": "Išjungta problema iš karto paslepiama visų modelių rezultatuose. Modeliai iš naujo netikrinami.",
"checks": {
"damaged": {
"title": "Sugadinti failai",
"desc": "Failai, kurių nepavyksta atidaryti arba kuriuose nėra nieko, ką būtų galima atspausdinti."
},
"missingParts": {
"title": "Trūkstamos dalys",
"desc": "3MF failas, nurodantis dalių failus, kurių jame nėra."
},
"openEdges": {
"title": "Skylės paviršiuje",
"desc": "Atviros briaunos: paviršius neuždaras, todėl pjaustyklei tenka spėti, kas yra viduje."
},
"nonManifoldEdges": {
"title": "Nedaugdarės briaunos",
"desc": "Briaunos, priklausančios daugiau nei dviem sienelėms, kurias pjaustyklės gali klaidingai interpretuoti."
},
"flippedFaces": {
"title": "Apverstos sienelės",
"desc": "Sienelės, atsuktos priešinga kryptimi nei kaimyninės."
},
"insideOut": {
"title": "Išverstas",
"desc": "Uždaras paviršius, atsuktas į vidų."
},
"fragments": {
"title": "Atskiros nuolaužos",
"desc": "Mažyčiai atskiri gabaliukai, plūduriuojantys atokiai nuo modelio."
},
"scale": {
"title": "Neįtikėtinas dydis",
"desc": "Mažiau nei 1 mm arba daugiau nei 2 m: tikriausiai eksportuotas netinkamais vienetais."
}
}
}
},
"importQueue": {
@@ -2771,7 +2821,90 @@
"sizeHint": "Bendras visų šio modelio failų dydis saugykloje",
"author": "Autorius",
"downloadModelFiles": "Atsisiųsti modelio failus",
"imported": "Importuota {{date}}"
"imported": "Importuota {{date}}",
"quality": {
"scanning": "Tikrinama spausdinimo kokybė…",
"scanningFiles": "Patikrinta failų: {{done}} iš {{total}}",
"doneGood": "Tinka spausdinti",
"doneIssues": "Rasta problemų",
"toastIssues": "Šis modelis gali būti prastai atspausdintas. Žr. įspėjimą šalia 3D peržiūros.",
"badgeGood": "Kokybės patikra sėkminga",
"badgeIssues": "Rasta spausdinimo problemų",
"dialogTitle": "Spausdinimo kokybė",
"noIssues": "Problemų nerasta",
"skipped": "Nepatikrinta: failas per didelis arba jo struktūros patikra negali nuskaityti.",
"notYet": "Dar nepatikrinta.",
"slicerNote": "Pjaustyklės daug smulkių problemų ištaiso pačios. Prieš spausdindami peržiūrėkite supjaustytą modelį.",
"triangles_one": "{{formatted}} trikampis",
"triangles_few": "{{formatted}} trikampiai",
"triangles_other": "{{formatted}} trikampių",
"bodies_one": "{{count}} kūnas",
"bodies_few": "{{count}} kūnai",
"bodies_other": "{{count}} kūnų",
"size": "{{x}} × {{y}} × {{z}} mm",
"issues": {
"unreadable": {
"title": "Failo nepavyksta atidaryti",
"desc": "Failas sugadintas arba tai nėra tinkamas modelio failas."
},
"empty": {
"title": "Nėra geometrijos",
"desc": "Faile nėra nieko, ką būtų galima atspausdinti."
},
"missingParts": {
"title_one": "Trūksta {{count}} dalies",
"title_few": "Trūksta {{count}} dalių",
"title_other": "Trūksta {{count}} dalių",
"desc": "Modelis nurodo dalių failus, kurių jame nėra, todėl dalies detalių trūks."
},
"openEdges": {
"title": "Skylės paviršiuje",
"desc_one": "{{count}} atvira briauna: paviršius neuždaras, todėl pjaustyklei tenka spėti, kas yra viduje.",
"desc_few": "{{count}} atviros briaunos: paviršius neuždaras, todėl pjaustyklei tenka spėti, kas yra viduje.",
"desc_other": "{{count}} atvirų briaunų: paviršius neuždaras, todėl pjaustyklei tenka spėti, kas yra viduje."
},
"nonManifoldEdges": {
"title": "Nedaugdarės briaunos",
"desc_one": "{{count}} briauna priklauso daugiau nei dviem sienelėms, todėl pjaustyklės gali ją klaidingai interpretuoti.",
"desc_few": "{{count}} briaunos priklauso daugiau nei dviem sienelėms, todėl pjaustyklės gali jas klaidingai interpretuoti.",
"desc_other": "{{count}} briaunų priklauso daugiau nei dviem sienelėms, todėl pjaustyklės gali jas klaidingai interpretuoti."
},
"flippedFaces": {
"title": "Apverstos sienelės",
"desc_one": "{{count}} briauna skiria priešingomis kryptimis atsuktas sieneles, todėl dalys gali būti tuščiavidurės arba dingti.",
"desc_few": "{{count}} briaunos skiria priešingomis kryptimis atsuktas sieneles, todėl dalys gali būti tuščiavidurės arba dingti.",
"desc_other": "{{count}} briaunų skiria priešingomis kryptimis atsuktas sieneles, todėl dalys gali būti tuščiavidurės arba dingti."
},
"insideOut": {
"title": "Išverstas",
"desc": "Paviršius atsuktas į vidų. Kai kurios pjaustyklės jį laiko tuščiu."
},
"fragments": {
"title_one": "{{count}} atskira nuolauža",
"title_few": "{{count}} atskiros nuolaužos",
"title_other": "{{count}} atskirų nuolaužų",
"desc": "Mažyčiai atskiri gabaliukai plūduriuoja atokiai nuo modelio. Jie atsispausdina kaip gumulėliai arba visai neatsispausdina."
},
"tooSmall": {
"title": "Labai mažas",
"desc": "Mažiau nei 1 mm: tikriausiai eksportuotas netinkamais vienetais. Padidinkite jį pjaustyklėje."
},
"tooLarge": {
"title": "Labai didelis",
"desc": "Daugiau nei 2 m: tikriausiai eksportuotas netinkamais vienetais. Sumažinkite jį pjaustyklėje."
}
},
"toastMinor": "Šiame modelyje rasta smulkių spausdinimo problemų. Pjaustyklės paprastai su jomis susitvarko.",
"severity": {
"critical": "Kritinė",
"criticalHint": "Spausdinimas nepavyks arba spaudinys bus nepilnas.",
"major": "Rimta",
"majorHint": "Modelis greičiausiai bus atspausdintas su defektais.",
"minor": "Smulki",
"minorHint": "Pjaustyklės paprastai susitvarko pačios arba tai greitai pataisoma jose."
},
"manageSettings": "Tvarkyti kokybės patikrą"
}
},
"edit": {
"title": "Redaguoti modelį",
@@ -0,0 +1,52 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import { keyframes } from "@mui/material/styles";
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
import type { PrintQuality } from "../../../api/prints";
import { QUALITY_SEVERITY_COLOR, QUALITY_SEVERITY_ICON, worstSeverity } from "../../../components/QualitySeverityChip";
import QualityReportDialog from "./QualityReportDialog";
const pop = keyframes`
0% { transform: scale(0.3); opacity: 0; }
65% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); }
`;
/** Beside the 3D preview button: a tick for a model that passed, otherwise its worst problem's icon and colour. Opens
* the report. */
export default function QualityBadge({ quality, isAdmin }: { quality: PrintQuality | null; isAdmin?: boolean }) {
const { t } = useTranslation("models");
const [open, setOpen] = useState(false);
if (!quality || (quality.status !== "good" && quality.status !== "issues")) return null;
const good = quality.status === "good";
const worst = worstSeverity(quality);
const label = good ? t("detail.quality.badgeGood") : t("detail.quality.badgeIssues");
const Icon = good ? VerifiedRoundedIcon : QUALITY_SEVERITY_ICON[worst];
return (
<>
<Tooltip title={label}>
<IconButton
size="small"
aria-label={label}
onClick={() => setOpen(true)}
sx={{
width: 32,
height: 32,
bgcolor: "rgba(0, 0, 0, 0.65)",
color: good ? "success.light" : `${QUALITY_SEVERITY_COLOR[worst]}.light`,
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.8)" },
animation: `${pop} 320ms ease-out both`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
}}
>
<Icon fontSize="small" />
</IconButton>
</Tooltip>
<QualityReportDialog open={open} quality={quality} isAdmin={isAdmin} onClose={() => setOpen(false)} />
</>
);
}
@@ -0,0 +1,137 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
import HelpOutlineRoundedIcon from "@mui/icons-material/HelpOutlineRounded";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import type { FileQuality, PrintQuality, QualityIssue } from "../../../api/prints";
import { QUALITY_CHECK_OF_ISSUE } from "../../../api/settings";
import QualitySeverityChip from "../../../components/QualitySeverityChip";
import QualityCheckTile from "../../../components/QualityCheckTile";
type Props = {
open: boolean;
quality: PrintQuality;
/** Offers the way to the quality check settings. */
isAdmin?: boolean;
onClose: () => void;
};
function IssueRow({ issue }: { issue: QualityIssue }) {
const { t } = useTranslation("models");
const count = issue.count ?? 1;
return (
<Stack direction="row" spacing={2} alignItems="center" component="li">
<QualityCheckTile check={QUALITY_CHECK_OF_ISSUE[issue.code]} badge />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" fontWeight={600}>
{t(`detail.quality.issues.${issue.code}.title`, { count })}
</Typography>
<QualitySeverityChip severity={issue.severity} />
</Stack>
<Typography variant="body2" color="text.secondary">
{t(`detail.quality.issues.${issue.code}.desc`, { count })}
</Typography>
</Box>
</Stack>
);
}
function FileReport({ file, showName }: { file: FileQuality; showName: boolean }) {
const { t, i18n } = useTranslation("models");
const number = (value: number) => value.toLocaleString(i18n.language);
const stats =
file.triangles !== null && file.size
? [
t("detail.quality.triangles", { count: file.triangles, formatted: number(file.triangles) }),
t("detail.quality.bodies", { count: file.shells ?? 0 }),
t("detail.quality.size", {
x: number(Math.round(file.size[0])),
y: number(Math.round(file.size[1])),
z: number(Math.round(file.size[2])),
}),
].join(" · ")
: null;
return (
<Box>
{showName && (
<Typography variant="subtitle2" noWrap title={file.filename} sx={{ mb: 0.5 }}>
{file.filename}
</Typography>
)}
{file.status === "good" && (
<Stack direction="row" spacing={1.25} alignItems="center">
<CheckCircleRoundedIcon fontSize="small" sx={{ color: "success.main" }} />
<Typography variant="body2" fontWeight={600}>
{t("detail.quality.noIssues")}
</Typography>
</Stack>
)}
{file.status === "issues" && (
<Stack spacing={2} component="ul" sx={{ m: 0, p: 0, pb: 0.75, listStyle: "none" }}>
{file.issues.map((issue) => (
<IssueRow key={issue.code} issue={issue} />
))}
</Stack>
)}
{(file.status === "skipped" || file.status === "unchecked" || file.status === "checking") && (
<Stack direction="row" spacing={1.25} alignItems="center">
<HelpOutlineRoundedIcon fontSize="small" sx={{ color: "text.disabled" }} />
<Typography variant="body2" color="text.secondary">
{t(file.status === "skipped" ? "detail.quality.skipped" : "detail.quality.notYet")}
</Typography>
</Stack>
)}
{stats && (
<Typography variant="caption" color="text.secondary" component="div" sx={{ mt: 0.75 }}>
{stats}
</Typography>
)}
</Box>
);
}
/** What the printability check found in each of the model's files. */
export default function QualityReportDialog({ open, quality, isAdmin, onClose }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const showNames = quality.files.length > 1;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t("models:detail.quality.dialogTitle")}</DialogTitle>
<DialogContent>
<Stack spacing={2.5}>
{quality.files.map((file) => (
<FileReport key={file.plate_id} file={file} showName={showNames} />
))}
{quality.status === "issues" && (
<Typography variant="caption" color="text.secondary">
{t("models:detail.quality.slicerNote")}
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>
{isAdmin && (
<Button
startIcon={<SettingsOutlinedIcon fontSize="small" />}
onClick={() => navigate("/admin-rendering#quality-checks")}
sx={{ mr: "auto" }}
>
{t("models:detail.quality.manageSettings")}
</Button>
)}
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { alpha, keyframes } from "@mui/material/styles";
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
import ViewInArRoundedIcon from "@mui/icons-material/ViewInArRounded";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import type { PrintQuality } from "../../../api/prints";
import type { QualityOutcome } from "./useQualityCheck";
// How long the result stays up before the overlay leaves.
const LINGER_MS = 1800;
const enter = keyframes`
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: none; }
`;
const leave = keyframes`
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
`;
// A band of light sweeping down the model view, as if it were being scanned.
const sweep = keyframes`
from { transform: translateY(-100%); }
to { transform: translateY(250%); }
`;
// The scan line inside the icon, top to bottom and back.
const iconScan = keyframes`
0%, 100% { transform: translateY(-9px); opacity: 0.4; }
50% { transform: translateY(9px); opacity: 1; }
`;
const pop = keyframes`
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); }
`;
const still = { "@media (prefers-reduced-motion: reduce)": { animation: "none" } } as const;
type Props = {
quality: PrintQuality | null;
outcome: QualityOutcome;
};
/** Sits over the model view while its printability check runs, and briefly shows the verdict. */
export default function QualityScanOverlay({ quality, outcome }: Props) {
const { t } = useTranslation("models");
const scanning = quality?.status === "checking";
const [leaving, setLeaving] = useState(false);
const [gone, setGone] = useState(false);
useEffect(() => {
if (!outcome) return;
const fade = setTimeout(() => setLeaving(true), LINGER_MS);
const remove = setTimeout(() => setGone(true), LINGER_MS + 250);
return () => {
clearTimeout(fade);
clearTimeout(remove);
};
}, [outcome]);
if (gone || !quality || (!scanning && !outcome)) return null;
const total = quality.files.length;
const done = quality.files.filter((file) => file.status !== "checking" && file.status !== "unchecked").length;
const label = scanning
? t("detail.quality.scanning")
: outcome === "good"
? t("detail.quality.doneGood")
: t("detail.quality.doneIssues");
return (
<>
{scanning && (
<Box aria-hidden sx={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none", zIndex: 1 }}>
<Box
sx={(theme) => ({
height: "40%",
background: `linear-gradient(to bottom, transparent, ${alpha(theme.palette.primary.main, 0.18)} 70%, ${alpha(theme.palette.primary.main, 0.55)} 99%, transparent)`,
animation: `${sweep} 2.2s cubic-bezier(0.45, 0, 0.55, 1) infinite`,
"@media (prefers-reduced-motion: reduce)": { display: "none" },
})}
/>
</Box>
)}
<Paper
elevation={6}
component="output"
aria-live="polite"
sx={{
display: "block",
position: "absolute",
right: 12,
bottom: 12,
zIndex: 2,
width: 236,
px: 1.5,
py: 1.25,
borderRadius: "12px",
bgcolor: "rgba(0, 0, 0, 0.7)",
backdropFilter: "blur(8px)",
color: "#fff",
animation: `${leaving ? leave : enter} 220ms ease-out both`,
...still,
}}
>
<Stack direction="row" spacing={1.25} alignItems="center">
<Box
sx={{ position: "relative", width: 28, height: 28, flexShrink: 0, display: "grid", placeItems: "center" }}
>
{scanning ? (
<>
<ViewInArRoundedIcon sx={{ fontSize: 26, opacity: 0.9 }} />
<Box
sx={(theme) => ({
position: "absolute",
left: 1,
right: 1,
top: "50%",
height: 2,
borderRadius: 1,
bgcolor: theme.palette.primary.light,
boxShadow: `0 0 8px ${theme.palette.primary.light}`,
animation: `${iconScan} 1.4s ease-in-out infinite`,
...still,
})}
/>
</>
) : outcome === "good" ? (
<CheckCircleRoundedIcon
sx={{ fontSize: 26, color: "success.light", animation: `${pop} 320ms ease-out both`, ...still }}
/>
) : (
<WarningAmberRoundedIcon
sx={{ fontSize: 26, color: "warning.light", animation: `${pop} 320ms ease-out both`, ...still }}
/>
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" fontWeight={600} noWrap>
{label}
</Typography>
{scanning && total > 1 && (
<Typography variant="caption" sx={{ opacity: 0.75 }}>
{t("detail.quality.scanningFiles", { done, total })}
</Typography>
)}
</Box>
</Stack>
{scanning && (
<LinearProgress
variant={total > 1 ? "determinate" : "indeterminate"}
value={total > 1 ? (done / total) * 100 : undefined}
aria-label={label}
sx={{ mt: 1, height: 3, borderRadius: 2, bgcolor: "rgba(255, 255, 255, 0.15)" }}
/>
)}
</Paper>
</>
);
}
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { UnauthorizedError } from "../../../api/client";
import { printsApi, type PrintQuality } from "../../../api/prints";
import { useToast } from "../../../components/ToastProvider";
import { worstSeverity } from "../../../components/QualitySeverityChip";
const POLL_MS = 800;
/** A check that finished while the page was open, for the overlay's closing beat. */
export type QualityOutcome = "good" | "issues" | null;
/** Reads the model's printability check and, the first time it's opened with checks on, runs it and follows along. */
export function useQualityCheck(printId: string, onUnauthorized?: () => void) {
const { t } = useTranslation("models");
const showToast = useToast();
const [quality, setQuality] = useState<PrintQuality | null>(null);
const [outcome, setOutcome] = useState<QualityOutcome>(null);
// Kept out of the effect's deps: a new toast function or translation mustn't restart a running check.
const notify = useRef({ showToast, t, onUnauthorized });
useEffect(() => {
notify.current = { showToast, t, onUnauthorized };
}, [showToast, t, onUnauthorized]);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
setQuality(null);
setOutcome(null);
const settle = (next: PrintQuality, watched: boolean) => {
setQuality(next);
if (next.status === "checking") {
timer = setTimeout(() => void poll(), POLL_MS);
return;
}
if (!watched || (next.status !== "good" && next.status !== "issues")) return;
setOutcome(next.status);
if (next.status === "issues") {
// Only minor problems, which slicers usually cope with: a note rather than a warning.
const minor = worstSeverity(next) === "minor";
notify.current.showToast({
message: notify.current.t(minor ? "detail.quality.toastMinor" : "detail.quality.toastIssues"),
severity: minor ? "info" : "warning",
});
}
};
const fail = (err: unknown) => {
if (cancelled) return;
if (err instanceof UnauthorizedError) notify.current.onUnauthorized?.();
// Otherwise the check just doesn't show: it's an extra, not something to raise an error over.
else console.error(err);
};
const poll = async () => {
try {
const next = await printsApi.getQuality(printId);
if (!cancelled) settle(next, true);
} catch (err) {
fail(err);
}
};
(async () => {
try {
let current = await printsApi.getQuality(printId);
if (cancelled || !current.enabled) return;
const watched = current.status === "unchecked" || current.status === "checking";
if (current.status === "unchecked") current = await printsApi.startQuality(printId);
if (!cancelled) settle(current, watched);
} catch (err) {
fail(err);
}
})();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [printId]);
return { quality, outcome };
}
+28 -18
View File
@@ -32,6 +32,9 @@ import ModelSidePanel from "./ModelSidePanel";
import MarkdownDescription from "../../components/MarkdownDescription";
import AiDescriptionReview, { AiDescriptionChip } from "./AiDescriptionReview";
import Skeleton from "@mui/material/Skeleton";
import { useQualityCheck } from "./QualityCheck/useQualityCheck";
import QualityScanOverlay from "./QualityCheck/QualityScanOverlay";
import QualityBadge from "./QualityCheck/QualityBadge";
type Props = {
theme: ResolvedTheme;
@@ -51,6 +54,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
const [renderUnavailable, setRenderUnavailable] = useState(false);
const onRenderUnavailable = useCallback(() => setRenderUnavailable(true), []);
const [previewOpen, setPreviewOpen] = useState(false);
const { quality, outcome } = useQualityCheck(printId ?? "", onUnauthorized);
// Returns wherever the user came from; useSmartBack skips same-route history entries.
const goBack = useSmartBack();
@@ -275,24 +279,30 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
</>
)}
{canPreview3d && (
<Button
variant="contained"
size="small"
startIcon={<ViewInArIcon fontSize="small" />}
onClick={() => setPreviewOpen(true)}
sx={{
position: "absolute",
left: 12,
bottom: 12,
bgcolor: "rgba(0, 0, 0, 0.65)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.8)" },
}}
>
{t("models:detail.preview3d")}
</Button>
)}
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ position: "absolute", left: 12, bottom: 12, zIndex: 2 }}
>
{canPreview3d && (
<Button
variant="contained"
size="small"
startIcon={<ViewInArIcon fontSize="small" />}
onClick={() => setPreviewOpen(true)}
sx={{
bgcolor: "rgba(0, 0, 0, 0.65)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.8)" },
}}
>
{t("models:detail.preview3d")}
</Button>
)}
<QualityBadge quality={quality} isAdmin={viewer?.role === "ADMIN"} />
</Stack>
<QualityScanOverlay key={print.id} quality={quality} outcome={outcome} />
</Box>
<ProviderBadge
provider={print.source_provider}
@@ -0,0 +1,187 @@
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { alpha, useTheme } from "@mui/material/styles";
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
const TILE = 76;
const CUBE = 30;
// One pass over all three models, then they start again.
const CYCLE_S = 7.5;
// Each model's turn starts this long after the one before, and its scan takes SCAN_S of it.
const STEP_S = 2;
const SCAN_S = 1.4;
const pct = (seconds: number) => `${((seconds / CYCLE_S) * 100).toFixed(2)}%`;
// Per model, since each one's beat sits at a different point of the shared cycle.
const scanLine = (start: number) => keyframes`
0%, ${pct(start)} { transform: translateY(0); opacity: 0; }
${pct(start + 0.1)} { opacity: 1; }
${pct(start + SCAN_S - 0.1)} { opacity: 1; }
${pct(start + SCAN_S)}, 100% { transform: translateY(${TILE - 4}px); opacity: 0; }
`;
const tileGlow = (start: number, color: string) => keyframes`
0%, ${pct(start)}, ${pct(start + SCAN_S + 0.1)}, 100% { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); }
${pct(start + 0.2)}, ${pct(start + SCAN_S - 0.1)} { box-shadow: inset 0 0 0 2px ${color}; }
`;
const badgeIn = (start: number) => keyframes`
0%, ${pct(start + SCAN_S + 0.1)} { transform: scale(0.3); opacity: 0; }
${pct(start + SCAN_S + 0.35)} { transform: scale(1.18); opacity: 1; }
${pct(start + SCAN_S + 0.5)}, 92% { transform: scale(1); opacity: 1; }
96%, 100% { transform: scale(1); opacity: 0; }
`;
// The hole's open edges, lit once the scan has found them and pulsing until the cycle ends.
const holeFound = (start: number) => {
const found = start + SCAN_S + 0.1;
const pulses = Array.from({ length: 4 }, (_, i) => found + 0.3 + i * 0.5);
return keyframes`
0%, ${pct(found)} { opacity: 0; }
${pulses.map((at, i) => `${pct(at)} { opacity: ${i % 2 ? 0.45 : 1}; }`).join("\n")}
92% { opacity: 1; }
96%, 100% { opacity: 0; }
`;
};
const turn = keyframes`
from { transform: rotateX(-34deg) rotateY(0deg); }
to { transform: rotateX(-34deg) rotateY(360deg); }
`;
const still = { "@media (prefers-reduced-motion: reduce)": { animation: "none" } } as const;
// A cube's faces; the broken one is missing half its top, so the hole shows as it turns.
const FACES = [
{ key: "front", transform: `translateZ(${CUBE / 2}px)` },
{ key: "back", transform: `rotateY(180deg) translateZ(${CUBE / 2}px)` },
{ key: "right", transform: `rotateY(90deg) translateZ(${CUBE / 2}px)` },
{ key: "left", transform: `rotateY(-90deg) translateZ(${CUBE / 2}px)` },
{ key: "top", transform: `rotateX(90deg) translateZ(${CUBE / 2}px)` },
{ key: "bottom", transform: `rotateX(-90deg) translateZ(${CUBE / 2}px)` },
];
function Model({ index, broken }: { index: number; broken?: boolean }) {
const theme = useTheme();
const edge = theme.palette.primary.main;
const warning = theme.palette.warning.main;
const start = index * STEP_S;
const Badge = broken ? WarningRoundedIcon : CheckCircleRoundedIcon;
return (
<Box
sx={{
position: "relative",
width: TILE,
height: TILE,
borderRadius: "16px",
bgcolor: "#ffffff",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
perspective: "300px",
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08)",
animation: `${tileGlow(start, alpha(edge, 0.55))} ${CYCLE_S}s linear infinite`,
...still,
}}
>
<Box
sx={{
position: "relative",
width: 0,
height: 0,
transformStyle: "preserve-3d",
transform: "rotateX(-34deg) rotateY(35deg)",
animation: `${turn} 12s linear infinite`,
animationDelay: `${-index * 3}s`,
...still,
}}
>
{FACES.map((face) => (
<Box
key={face.key}
component="svg"
width={CUBE}
height={CUBE}
viewBox={`0 0 ${CUBE} ${CUBE}`}
sx={{
position: "absolute",
left: -CUBE / 2,
top: -CUBE / 2,
overflow: "visible",
transform: face.transform,
}}
>
{broken && face.key === "top" ? (
<>
{/* Only one of the top's two triangles: the other is the hole. */}
<path
d={`M0 ${CUBE} L${CUBE} 0 L${CUBE} ${CUBE} Z`}
fill={alpha(edge, 0.16)}
stroke={edge}
strokeWidth={0.9}
/>
<Box
component="path"
d={`M0 ${CUBE} L0 0 L${CUBE} 0 L0 ${CUBE}`}
fill={alpha(warning, 0.18)}
stroke={warning}
strokeWidth={2}
strokeDasharray="3 2"
strokeLinejoin="round"
sx={{ animation: `${holeFound(start)} ${CYCLE_S}s linear infinite`, ...still }}
/>
</>
) : (
<>
<rect width={CUBE} height={CUBE} fill={alpha(edge, 0.16)} stroke={edge} strokeWidth={0.9} />
<path d={`M0 ${CUBE} L${CUBE} 0`} stroke={edge} strokeWidth={0.5} strokeOpacity={0.6} />
</>
)}
</Box>
))}
</Box>
<Box
sx={{
position: "absolute",
left: 6,
right: 6,
top: 2,
height: 2,
borderRadius: 1,
bgcolor: edge,
boxShadow: `0 0 10px 2px ${alpha(edge, 0.6)}`,
opacity: 0,
animation: `${scanLine(start)} ${CYCLE_S}s linear infinite`,
"@media (prefers-reduced-motion: reduce)": { display: "none" },
}}
/>
<Box
sx={{
position: "absolute",
top: 5,
right: 5,
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: "#ffffff",
display: "grid",
placeItems: "center",
animation: `${badgeIn(start)} ${CYCLE_S}s ease-out infinite`,
...still,
}}
>
<Badge sx={{ fontSize: 20, color: broken ? "warning.main" : "success.main" }} />
</Box>
</Box>
);
}
/** Three models scanned in turn: two pass with a tick; the third has a hole in its top, which the scan lights up. */
export default function QualityCheckIllustration() {
return (
<Stack direction="row" spacing={1.5} aria-hidden sx={{ flexShrink: 0 }}>
<Model index={0} />
<Model index={1} />
<Model index={2} broken />
</Stack>
);
}
@@ -0,0 +1,68 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { QUALITY_CHECK_KEYS, QUALITY_CHECK_SEVERITY, type QualityCheckKey } from "../../api/settings";
import QualitySeverityChip from "../../components/QualitySeverityChip";
import QualityCheckTile from "../../components/QualityCheckTile";
type Props = {
open: boolean;
checksOff: QualityCheckKey[];
onChange: (checksOff: QualityCheckKey[]) => void;
onClose: () => void;
};
/** One switch per kind of print problem. Each saves as soon as it's switched. */
export default function QualityChecksDialog({ open, checksOff, onChange, onClose }: Props) {
const { t } = useTranslation(["app", "common"]);
const toggle = (key: QualityCheckKey, on: boolean) =>
onChange(on ? checksOff.filter((off) => off !== key) : [...checksOff, key]);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t("adminSettings.rendering.quality.dialogTitle")}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t("adminSettings.rendering.quality.dialogHint")}
</Typography>
<Stack divider={<Divider flexItem />}>
{QUALITY_CHECK_KEYS.map((key) => {
const titleId = `quality-check-${key}`;
return (
<Stack key={key} direction="row" spacing={2} alignItems="center" sx={{ py: 1.25 }}>
<QualityCheckTile check={key} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.25 }}>
<Typography id={titleId} variant="body2" fontWeight={600}>
{t(`adminSettings.rendering.quality.checks.${key}.title`)}
</Typography>
<QualitySeverityChip severity={QUALITY_CHECK_SEVERITY[key]} />
</Stack>
<Typography variant="caption" color="text.secondary" component="div">
{t(`adminSettings.rendering.quality.checks.${key}.desc`)}
</Typography>
</Box>
<Switch
checked={!checksOff.includes(key)}
onChange={(e) => toggle(key, e.target.checked)}
slotProps={{ input: { "aria-labelledby": titleId } }}
/>
</Stack>
);
})}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,158 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type QualityCheckSettings } from "../../api/settings";
import HelpTip from "../../components/HelpTip";
import SectionHeader from "../../components/SectionHeader";
import QualityCheckIllustration from "./QualityCheckIllustration";
import QualityChecksDialog from "./QualityChecksDialog";
const SECTION_ID = "quality-checks";
type Props = {
onUnauthorized?: () => void;
};
/** Saves as soon as it's switched. */
export default function QualityChecksSection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [settings, setSettings] = React.useState<QualityCheckSettings | null>(null);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [checksOpen, setChecksOpen] = React.useState(false);
const enabled = settings?.enabled ?? null;
const { hash } = useLocation();
const sectionRef = React.useRef<HTMLDivElement>(null);
const ready = settings !== null;
// A model's quality report links here; scroll once the section has its height.
React.useEffect(() => {
if (ready && hash === `#${SECTION_ID}`) sectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, [hash, ready]);
React.useEffect(() => {
let active = true;
settingsApi.getQualityChecks().then(
(loaded) => {
if (active) setSettings(loaded);
},
(err) => {
if (!active) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("adminSettings.rendering.quality.loadFailed"));
},
);
return () => {
active = false;
};
}, [onUnauthorized, t]);
const save = async (patch: Partial<QualityCheckSettings>) => {
if (!settings) return;
const previous = settings;
setSettings({ ...settings, ...patch });
setSaving(true);
setError(null);
try {
setSettings(await settingsApi.updateQualityChecks(patch));
} catch (err) {
setSettings(previous);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.rendering.quality.saveFailed"));
} finally {
setSaving(false);
}
};
const points = (["infoWhen", "infoChecks", "infoCost"] as const).map((key) =>
t(`adminSettings.rendering.quality.${key}`),
);
const infoText = [t("adminSettings.rendering.quality.infoTitle"), ...points].join(" ");
const info = (
<>
<Typography variant="subtitle2" component="div" sx={{ mb: 0.5 }}>
{t("adminSettings.rendering.quality.infoTitle")}
</Typography>
<Box component="ul" sx={{ mt: 0.5, mb: 0, pl: 2.5 }}>
{points.map((point) => (
<li key={point}>{point}</li>
))}
</Box>
</>
);
return (
<Stack spacing={3} id={SECTION_ID} ref={sectionRef} sx={{ scrollMarginTop: 80 }}>
<SectionHeader
title={t("adminSettings.rendering.quality.heading")}
subtitle={t("adminSettings.rendering.quality.subtitle")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<QualityCheckIllustration />
<Stack direction="row" spacing={1} alignItems="center">
<FormControlLabel
control={
<Switch
checked={enabled ?? false}
disabled={enabled === null || saving}
onChange={(e) => void save({ enabled: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
{t("adminSettings.rendering.quality.label")}
<HelpTip text={infoText} content={info} />
</Typography>
<Typography variant="caption" color="text.secondary">
{t(enabled ? "adminSettings.rendering.quality.helpOn" : "adminSettings.rendering.quality.helpOff")}
</Typography>
</Box>
}
sx={{ alignItems: "flex-start", m: 0, "& .MuiSwitch-root": { mt: -0.5 } }}
/>
<Tooltip title={t("adminSettings.rendering.quality.configure")}>
<span>
<IconButton
size="small"
aria-label={t("adminSettings.rendering.quality.configure")}
disabled={!settings}
onClick={() => setChecksOpen(true)}
>
<SettingsOutlinedIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
{saving && <CircularProgress size={16} />}
</Stack>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
</Stack>
</Paper>
{settings && (
<QualityChecksDialog
open={checksOpen}
checksOff={settings.checks_off}
onChange={(checksOff) => void save({ checks_off: checksOff })}
onClose={() => setChecksOpen(false)}
/>
)}
</Stack>
);
}
@@ -3,6 +3,7 @@ import Stack from "@mui/material/Stack";
import type { PreviewMode } from "../../api/settings";
import PreviewsSection from "./PreviewsSection";
import SimplifySection from "./SimplifySection";
import QualityChecksSection from "./QualityChecksSection";
type Props = {
onUnauthorized?: () => void;
@@ -14,6 +15,7 @@ export default function RenderingPage({ onUnauthorized, onPreviewModeChanged }:
<Stack spacing={4} divider={<Divider />}>
<PreviewsSection onUnauthorized={onUnauthorized} onSaved={onPreviewModeChanged} />
<SimplifySection onUnauthorized={onUnauthorized} />
<QualityChecksSection onUnauthorized={onUnauthorized} />
</Stack>
);
}