feat(3d-viewer): improve ui and ux, fix flickering

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-01 21:25:12 +02:00
1 parent 58001edd40
commit eb0b9876cf
5 files changed
+126 -40

No files matched your search

@@ -26,11 +26,13 @@ import BrandMark from "../../BrandMark";
// Partly adapted (with modifications) from github.com/maziggy/bambuddy, AGPL-3.0-only.
export type RenderStyle = "solid" | "wire" | "xray";
export type CameraView = "top" | "front" | "side";
export type CameraView = "top" | "front" | "side" | "bottom" | "topFront";
/** A camera preset is a one-off action (clicking "Top" twice re-frames twice), not state. */
export type ModelViewerHandle = {
setCameraView: (view: CameraView) => void;
/** Scales the camera's distance to the orbit target: below 1 zooms in, above 1 zooms out. */
zoom: (factor: number) => void;
};
type ModelViewerProps = {
@@ -73,16 +75,19 @@ const SERVER_PREVIEW_POLL_MS = 3000;
const SERVER_PREVIEW_WAIT_MS = 10 * 60 * 1000;
const BAMBU_PLATE_COLOR = 0x00ae42;
const Y_AXIS = new THREE.Vector3(0, 1, 0);
// Bambu Studio's three-quarter view for multi-plate 3MF; mostly front-on for everything else.
const BAMBU_VIEW_DIRECTION = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
const FRONT_VIEW_DIRECTION = new THREE.Vector3(0.9, 0.7, 2.1).normalize();
// Top keeps a hair of +Z so OrbitControls' up vector isn't parallel to the view direction.
// Top/bottom keep a hair of +Z so OrbitControls' up vector isn't parallel to the view direction.
const CAMERA_VIEW_DIRECTIONS: Record<CameraView, THREE.Vector3> = {
top: new THREE.Vector3(0, 1, 0.0001).normalize(),
front: new THREE.Vector3(0, 0, 1),
side: new THREE.Vector3(1, 0, 0),
bottom: new THREE.Vector3(0, -1, 0.0001).normalize(),
topFront: new THREE.Vector3(0, 1, 1).normalize(),
};
/** Frames the camera on a bounding box using its circumscribed sphere against both vertical and
@@ -135,6 +140,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
const sceneApiRef = useRef<{
applyAppearance: () => void;
setCameraView: (view: CameraView) => void;
zoom: (factor: number) => void;
} | null>(null);
// Lets plate switches rebuild the already-parsed group instead of refetching the file.
const rebuildBambuPlateRef = useRef<((plateId: number | null) => void) | null>(null);
@@ -177,13 +183,18 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
const environment = pmrem.fromScene(new RoomEnvironment(), 0.04);
scene.environment = environment.texture;
// Direction for the front view; turned with the camera's azimuth each frame so the lighting
// stays put relative to the viewer while orbiting or spinning.
const KEY_LIGHT_DIRECTION = new THREE.Vector3(60, 260, 90).normalize();
let keyLightDistance = 280;
const keyLight = new THREE.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(60, 260, 90);
keyLight.position.copy(KEY_LIGHT_DIRECTION).multiplyScalar(keyLightDistance);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.0005;
keyLight.shadow.normalBias = 0.02;
scene.add(keyLight);
scene.add(keyLight.target);
camera.position.set(150, 150, 150);
@@ -229,6 +240,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
plateMesh.rotation.x = -Math.PI / 2;
@@ -237,8 +249,11 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
scene.add(plateMesh);
const shadowCatcher = new THREE.Mesh(
new THREE.PlaneGeometry(buildVolume.x, buildVolume.y),
new THREE.ShadowMaterial({ opacity: 0.22 }),
new THREE.ShadowMaterial({ opacity: 0.22, depthWrite: false }),
);
// The planes are too close for the depth buffer to separate, so without a fixed draw order and
// no depth writes they z-fight and the shadow flickers as the camera moves.
shadowCatcher.renderOrder = 1;
shadowCatcher.rotation.x = -Math.PI / 2;
shadowCatcher.position.y = -0.49;
shadowCatcher.receiveShadow = true;
@@ -257,7 +272,9 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
buildPlateLaidOut = true;
syncBuildPlateVisibility();
const shadowExtent = Math.max(buildVolume.x, buildVolume.y) * 0.75;
// Covers the plate's diagonal so the rotating shadow frustum never clips it, which flickers.
const shadowExtent = Math.hypot(buildVolume.x, buildVolume.y) * 0.55;
keyLightDistance = shadowExtent * 3;
keyLight.shadow.camera.left = -shadowExtent;
keyLight.shadow.camera.right = shadowExtent;
keyLight.shadow.camera.top = shadowExtent;
@@ -524,10 +541,40 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
gizmo.dispose();
};
// Eased toward over a few frames so button zooms don't jump.
let zoomTargetDistance: number | null = null;
const zoomOffset = new THREE.Vector3();
const stepZoom = () => {
if (zoomTargetDistance === null || !controls) return;
zoomOffset.subVectors(camera.position, controls.target);
const current = zoomOffset.length();
const next = THREE.MathUtils.lerp(current, zoomTargetDistance, 0.25);
if (Math.abs(next - zoomTargetDistance) < zoomTargetDistance * 0.001) zoomTargetDistance = null;
camera.position.copy(controls.target).addScaledVector(zoomOffset.normalize(), next);
// fitCameraToBox sized near/far for the fitted distance; shift them so zooming out doesn't clip.
camera.near = Math.max(next / 1000, 0.01);
camera.far += next - current;
camera.updateProjectionMatrix();
};
// A drag or wheel zoom takes over from an unfinished button zoom.
controls?.addEventListener("start", () => {
zoomTargetDistance = null;
});
const animate = () => {
if (disposed) return;
stepZoom();
if (controls) controls.autoRotate = appearanceRef.current.autoRotate;
controls?.update();
if (controls) {
// Aim at the plate centre: the shadow frustum is centred on the light's target.
keyLight.target.position.set(plateMesh.position.x, 0, plateMesh.position.z);
keyLight.position
.copy(KEY_LIGHT_DIRECTION)
.applyAxisAngle(Y_AXIS, controls.getAzimuthalAngle())
.multiplyScalar(keyLightDistance)
.add(keyLight.target.position);
}
renderer.render(scene, camera);
gizmo.render(camera, controls?.target ?? new THREE.Vector3(), width, height);
requestAnimationFrame(animate);
@@ -539,7 +586,13 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
applyAppearance(activeObject);
syncBuildPlateVisibility();
},
zoom: (factor) => {
if (!controls) return;
const from = zoomTargetDistance ?? camera.position.distanceTo(controls.target);
zoomTargetDistance = THREE.MathUtils.clamp(from * factor, controls.minDistance, controls.maxDistance);
},
setCameraView: (view) => {
zoomTargetDistance = null;
presetDirection = CAMERA_VIEW_DIRECTIONS[view];
if (lastFitBox && controls) {
fitCameraToBox(camera, controls, lastFitBox, CAMERA_VIEW_DIRECTIONS[view], undefined, bedExtent());
@@ -597,6 +650,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
ref,
() => ({
setCameraView: (view) => sceneApiRef.current?.setCameraView(view),
zoom: (factor) => sceneApiRef.current?.zoom(factor),
}),
[],
);
+4
View File
@@ -1211,11 +1211,15 @@
"top": "Top",
"front": "Front",
"side": "Side",
"bottom": "Bottom",
"topFront": "Top / Front",
"solid": "Solid",
"wire": "Wire",
"xray": "X-Ray",
"grid": "Grid",
"spin": "Spin",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"colorLabel": "Model color: {{name}}",
"colors": {
"red": "Red",
+4
View File
@@ -1214,11 +1214,15 @@
"top": "Viršus",
"front": "Priekis",
"side": "Šonas",
"bottom": "Apačia",
"topFront": "Viršus / priekis",
"solid": "Vientisas",
"wire": "Tinklelis",
"xray": "Rentgenas",
"grid": "Plokštė",
"spin": "Sukimas",
"zoomIn": "Priartinti",
"zoomOut": "Nutolinti",
"colorLabel": "Modelio spalva: {{name}}",
"colors": {
"red": "Raudona",
@@ -1,8 +1,10 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
@@ -12,6 +14,8 @@ import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import LayersIcon from "@mui/icons-material/Layers";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import { type Print, printsApi } from "../../api/prints";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
@@ -25,8 +29,16 @@ import type { PlateSummary } from "../../utils/bambuThreeMf";
import PreviewToolbar, { DEFAULT_PREVIEW_COLOR } from "./PreviewToolbar";
import { fileRowText } from "./fileRowText";
// Fixed regardless of theme: this is a product-shot style preview.
const PREVIEW_BG = "#e7e7ea";
// Light mode keeps a product-shot style grey; dark mode uses the page background so it doesn't glare.
const LIGHT_PREVIEW_BG = "#e7e7ea";
const ZOOM_STEP = 1.25;
const overlayButtonSx = {
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.65)" },
};
type Props = {
print: Print;
@@ -36,6 +48,9 @@ type Props = {
/** The plate list is always shown, even for one plate, so the layout doesn't jump. */
export default function Model3DPreviewModal({ print, onClose }: Props) {
const { t } = useTranslation(["models", "library", "common"]);
const muiTheme = useTheme();
const themeMode = muiTheme.palette.mode;
const previewBg = themeMode === "dark" ? muiTheme.thingport.pageBackground : LIGHT_PREVIEW_BG;
const sortedPlates = useMemo(() => print.plates.toSorted((a, b) => a.position - b.position), [print.plates]);
const [activePlateId, setActivePlateId] = useState<string | null>(sortedPlates[0]?.id ?? null);
const activePlate = sortedPlates.find((p) => p.id === activePlateId) || sortedPlates[0];
@@ -51,13 +66,11 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
const viewerRef = useRef<ModelViewerHandle>(null);
const [renderStyle, setRenderStyle] = useState<RenderStyle>("solid");
const [modelColor, setModelColor] = useState<string>(DEFAULT_PREVIEW_COLOR);
const [cameraView, setCameraView] = useState<CameraView>("front");
const [cameraView, setCameraView] = useState<CameraView>("topFront");
const [showGrid, setShowGrid] = useState(true);
const [spin, setSpin] = useState(true);
// Spinning would carry the camera away from the preset.
const handleCameraView = (view: CameraView) => {
setSpin(false);
setCameraView(view);
viewerRef.current?.setCameraView(view);
};
@@ -89,20 +102,12 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
fullWidth
maxWidth="lg"
onClose={onClose}
slotProps={{ paper: { sx: { height: "85vh", bgcolor: PREVIEW_BG, backgroundImage: "none" } } }}
slotProps={{ paper: { sx: { height: "85vh", bgcolor: previewBg, backgroundImage: "none" } } }}
>
<IconButton
onClick={onClose}
aria-label={t("common:close") ?? undefined}
sx={{
position: "absolute",
top: 10,
right: 10,
zIndex: 2,
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.65)" },
}}
sx={{ position: "absolute", top: 10, right: 10, zIndex: 2, ...overlayButtonSx }}
>
<CloseIcon fontSize="small" />
</IconButton>
@@ -120,6 +125,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
overflow: "auto",
p: 1,
borderRadius: "12px",
...(themeMode === "dark" && { "& .MuiListItemText-primary": { color: "#FFFFFF" } }),
}}
>
{showFileRows && (
@@ -212,7 +218,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
url={printsApi.fileUrl(activePlate.url)}
ext={ext}
initialCameraView={cameraView}
theme="light"
theme={themeMode}
colorOverride={modelColor}
renderStyle={renderStyle}
showBuildPlate={showGrid}
@@ -229,6 +235,26 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
)}
</Box>
{is3d && (
// Lined up with the close button, just above the viewer's orientation cube (72px, 12px from the corner).
<Stack spacing={1} sx={{ position: "absolute", bottom: 92, right: 10, zIndex: 2 }}>
<IconButton
onClick={() => viewerRef.current?.zoom(1 / ZOOM_STEP)}
aria-label={t("models:detail.previewToolbar.zoomIn") ?? undefined}
sx={overlayButtonSx}
>
<AddIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => viewerRef.current?.zoom(ZOOM_STEP)}
aria-label={t("models:detail.previewToolbar.zoomOut") ?? undefined}
sx={overlayButtonSx}
>
<RemoveIcon fontSize="small" />
</IconButton>
</Stack>
)}
{is3d && (
<PreviewToolbar
cameraView={cameraView}
@@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next";
import ButtonBase from "@mui/material/ButtonBase";
import Divider from "@mui/material/Divider";
import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import GridOnIcon from "@mui/icons-material/GridOn";
import ThreeSixtyIcon from "@mui/icons-material/ThreeSixty";
@@ -22,7 +23,7 @@ export const PREVIEW_COLORS = [
export const DEFAULT_PREVIEW_COLOR = "#00b800";
const CAMERA_VIEWS: CameraView[] = ["top", "front", "side"];
const CAMERA_VIEWS: CameraView[] = ["topFront", "front", "side", "top", "bottom"];
const RENDER_STYLES: RenderStyle[] = ["solid", "wire", "xray"];
type Props = {
@@ -38,6 +39,8 @@ type Props = {
onSpinChange: (spin: boolean) => void;
};
const selectSx = { minWidth: 110, fontSize: 14, "& .MuiSelect-select": { py: 0.5 } };
const sectionDivider = <Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />;
/** Stateless: the modal owns every value. */
@@ -74,35 +77,30 @@ export default function PreviewToolbar({
}}
>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ width: "max-content" }}>
{/* Re-clicking the selected preset yields null here -- still re-frame to it, since the
camera may have been orbited away since. */}
<ToggleButtonGroup
size="small"
exclusive
value={cameraView}
onChange={(_, value: CameraView | null) => onCameraView(value ?? cameraView)}
>
{/* Selecting goes through MenuItem onClick so re-picking the current view still re-frames,
since the camera may have been orbited away since. */}
<Select size="small" value={cameraView} sx={selectSx}>
{CAMERA_VIEWS.map((view) => (
<ToggleButton key={view} value={view} sx={{ px: 1.25, py: 0.5, textTransform: "none" }}>
<MenuItem key={view} value={view} onClick={() => onCameraView(view)}>
{t(`models:detail.previewToolbar.${view}`)}
</ToggleButton>
</MenuItem>
))}
</ToggleButtonGroup>
</Select>
{sectionDivider}
<ToggleButtonGroup
<Select
size="small"
exclusive
value={renderStyle}
onChange={(_, value: RenderStyle | null) => value && onRenderStyleChange(value)}
onChange={(e) => onRenderStyleChange(e.target.value as RenderStyle)}
sx={selectSx}
>
{RENDER_STYLES.map((style) => (
<ToggleButton key={style} value={style} sx={{ px: 1.25, py: 0.5, textTransform: "none" }}>
<MenuItem key={style} value={style}>
{t(`models:detail.previewToolbar.${style}`)}
</ToggleButton>
</MenuItem>
))}
</ToggleButtonGroup>
</Select>
{sectionDivider}