mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
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:
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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user