mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat(3d-viewer): improve ui and ux, fix flickering
This commit is contained in:
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),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user