fix: stl plate preview thumbnails + add them to downloads modal

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 23:06:13 +02:00
1 parent 2813d47049
commit 56f842ed6f
4 files changed
+63 -14

No files matched your search

@@ -3,6 +3,7 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { printsApi } from "../../../api/prints";
import { type ResolvedTheme } from "../../../constants/settingsOptions";
@@ -15,8 +16,17 @@ type ModelSnapshotProps = {
plateId?: string;
theme: ResolvedTheme;
mode?: "automatic" | "on-demand";
/** For tiny slots (e.g. a plate list's 32px thumbnail): no status text or buttons, just the
* image once ready (a small spinner while generating). Also renders at a fixed card-sized
* resolution instead of the slot's own size -- the result is persisted as the plate's
* thumbnail (see uploadGeneratedThumbnail) and reused by full-size grid cards. */
compact?: boolean;
};
// Render size for compact snapshots: the same 4:3 as a grid card, large enough to stay sharp there.
const COMPACT_RENDER_WIDTH = 480;
const COMPACT_RENDER_HEIGHT = 360;
// Every ModelSnapshot on the page shares one job queue (see queueSnapshotJob), so a single model
// that hangs mid-load (bad network response, pathological geometry) would otherwise wedge every
// other card's preview behind it forever. This bounds each job so the queue always keeps moving.
@@ -38,7 +48,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
});
}
export function ModelSnapshot({ url, ext, plateId, mode = "automatic" }: ModelSnapshotProps) {
export function ModelSnapshot({ url, ext, plateId, mode = "automatic", compact = false }: ModelSnapshotProps) {
const { t } = useTranslation(["library", "common"]);
const containerRef = useRef<HTMLDivElement | null>(null);
const [snapshot, setSnapshot] = useState<string | null>(null);
@@ -63,8 +73,8 @@ export function ModelSnapshot({ url, ext, plateId, mode = "automatic" }: ModelSn
setState("loading");
try {
const rect = containerRef.current.getBoundingClientRect();
const width = Math.max(120, Math.floor(rect.width || 240));
const height = Math.max(120, Math.floor(rect.height || 180));
const width = compact ? COMPACT_RENDER_WIDTH : Math.max(120, Math.floor(rect.width || 240));
const height = compact ? COMPACT_RENDER_HEIGHT : Math.max(120, Math.floor(rect.height || 180));
const image = await queueSnapshotJob(async () => {
if (disposed) return null;
return withTimeout(generateModelSnapshot(url, ext, width, height, "light"), SNAPSHOT_TIMEOUT_MS);
@@ -109,7 +119,7 @@ export function ModelSnapshot({ url, ext, plateId, mode = "automatic" }: ModelSn
// retryToken isn't read in the effect body -- it's a bump counter whose only job is to
// force this effect to re-run when the user clicks Retry.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [url, ext, plateId, mode, requested, retryToken]);
}, [url, ext, plateId, mode, requested, retryToken, compact]);
return (
<Box
@@ -132,6 +142,8 @@ export function ModelSnapshot({ url, ext, plateId, mode = "automatic" }: ModelSn
alt={t("library:modelViewer.previewAlt")}
sx={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : compact ? (
state === "loading" && <CircularProgress size={14} />
) : state === "loading" ? (
<Typography variant="caption" color="text.secondary">
{t("library:modelViewer.generatingPreview")}
@@ -0,0 +1,40 @@
import Box from "@mui/material/Box";
import { type Plate, printsApi } from "../../api/prints";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
import { ModelSnapshot } from "./ModelViewer/ModelSnapshot";
type Props = {
plate: Plate;
/** Square edge in px. */
size?: number;
};
/** A small square preview of one plate for plate lists (3D preview switcher, download picker):
* its stored thumbnail when it has one; otherwise, for a 3D file (STL/OBJ/STEP carry no
* embedded thumbnail, and only a model's first plate gets one from its grid card), a snapshot
* rendered on the spot -- which ModelSnapshot also saves server-side, so it's there next time;
* otherwise a plain placeholder. */
export default function PlateThumbnail({ plate, size = 32 }: Props) {
const boxSx = { width: size, height: size, flexShrink: 0, borderRadius: 0.75, overflow: "hidden" };
const ext = extOf(plate.filename);
if (plate.thumb_url) {
return (
<Box
component="img"
src={printsApi.fileUrl(plate.thumb_url)}
alt={plate.filename}
sx={{ ...boxSx, objectFit: "cover" }}
/>
);
}
if (MODEL_EXTS.has(ext)) {
return (
<Box sx={boxSx}>
<ModelSnapshot url={printsApi.fileUrl(plate.url)} ext={ext} plateId={plate.id} theme="light" compact />
</Box>
);
}
return <Box sx={{ ...boxSx, bgcolor: "action.hover" }} />;
}
@@ -8,10 +8,12 @@ import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import CircularProgress from "@mui/material/CircularProgress";
import DownloadIcon from "@mui/icons-material/Download";
import type { Plate } from "../../api/prints";
import PlateThumbnail from "../../components/media/PlateThumbnail";
type Props = {
open: boolean;
@@ -44,6 +46,9 @@ export default function DownloadPickerDialog({ open, onClose, downloading, sorte
<List disablePadding>
{sortedPlates.map((plate, idx) => (
<ListItemButton key={plate.id} onClick={() => downloadPlate(plate)} disabled={downloading} sx={{ borderRadius: 1 }}>
<ListItemIcon sx={{ minWidth: 60 }}>
<PlateThumbnail plate={plate} size={48} />
</ListItemIcon>
<ListItemText
primary={t("models:detail.plateLabel", { n: idx + 1 })}
secondary={plate.filename}
@@ -15,6 +15,7 @@ import CloseIcon from "@mui/icons-material/Close";
import { type Print, printsApi } from "../../api/prints";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
import PlateThumbnail from "../../components/media/PlateThumbnail";
import ModelViewer, { type CameraView, type ModelViewerHandle, type RenderStyle } from "../../components/media/ModelViewer";
import type { PlateSummary } from "../../utils/bambuThreeMf";
import PreviewToolbar, { DEFAULT_PREVIEW_COLOR } from "./PreviewToolbar";
@@ -137,16 +138,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>
{plate.thumb_url ? (
<Box
component="img"
src={printsApi.fileUrl(plate.thumb_url)}
alt={plate.filename}
sx={{ width: 32, height: 32, borderRadius: 0.75, objectFit: "cover" }}
/>
) : (
<Box sx={{ width: 32, height: 32, borderRadius: 0.75, bgcolor: "action.hover" }} />
)}
<PlateThumbnail plate={plate} />
</ListItemIcon>
<ListItemText
primary={t("models:detail.plateLabel", { n: idx + 1 })}