New 'model3d' builtin viewer backed by three.js: GLTFLoader for gltf/glb, FBXLoader and OBJLoader for the rest, OrbitControls for camera, and bounding-sphere auto-framing. The renderer lazy-chunks (~186KB gz) so it only loads when a model is opened. Registered in the default file_viewers config; a schema patch appends it to existing installs on the next release bump. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3582/head
parent
d264d3c734
commit
c6bf098b87
@ -0,0 +1,75 @@
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { enqueueSnackbar } from "notistack";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { closeModelViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getFileLinkedUri } from "../../../util";
|
||||
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
|
||||
const ModelViewerCanvas = React.lazy(() => import("./ModelViewerCanvas.tsx"));
|
||||
|
||||
const ModelViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewerState = useAppSelector((state) => state.globalState.modelViewer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [src, setSrc] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSrc("");
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(viewerState.file)],
|
||||
entity: viewerState.version,
|
||||
}),
|
||||
)
|
||||
.then((res) => setSrc(res.urls[0].url))
|
||||
.catch(() => onClose())
|
||||
.finally(() => setLoading(false));
|
||||
}, [viewerState]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeModelViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
enqueueSnackbar({
|
||||
message: t("fileManager.modelLoadFailed"),
|
||||
variant: "error",
|
||||
action: DefaultCloseAction,
|
||||
});
|
||||
onClose();
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
fullScreen: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
{!src && <ViewerLoading />}
|
||||
{src && (
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<ModelViewerCanvas src={src} fileName={viewerState?.file.name ?? ""} onError={onError} />
|
||||
</Suspense>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelViewer;
|
||||
@ -0,0 +1,135 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||||
import { fileExtension } from "../../../util";
|
||||
|
||||
export interface ModelViewerCanvasProps {
|
||||
src: string;
|
||||
fileName: string;
|
||||
onLoaded?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
const loadModel = (src: string, ext: string): Promise<THREE.Object3D> => {
|
||||
switch (ext) {
|
||||
case "gltf":
|
||||
case "glb":
|
||||
return new Promise((resolve, reject) =>
|
||||
new GLTFLoader().load(src, (g) => resolve(g.scene), undefined, reject),
|
||||
);
|
||||
case "fbx":
|
||||
return new Promise((resolve, reject) => new FBXLoader().load(src, resolve, undefined, reject));
|
||||
case "obj":
|
||||
return new Promise((resolve, reject) => new OBJLoader().load(src, resolve, undefined, reject));
|
||||
default:
|
||||
return Promise.reject(new Error(`unsupported model format: ${ext}`));
|
||||
}
|
||||
};
|
||||
|
||||
const ModelViewerCanvas = ({ src, fileName, onLoaded, onError }: ModelViewerCanvasProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 5000);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 1.5);
|
||||
dir.position.set(3, 5, 4);
|
||||
scene.add(dir);
|
||||
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x2a2a2a));
|
||||
|
||||
let disposed = false;
|
||||
let model: THREE.Object3D | undefined;
|
||||
loadModel(src, fileExtension(fileName) ?? "")
|
||||
.then((obj) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
model = obj;
|
||||
// Center on origin and scale so the model fits the view.
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const radius = Math.max(size.x, size.y, size.z, 1e-6);
|
||||
obj.position.sub(center);
|
||||
const scale = 4 / radius;
|
||||
obj.scale.setScalar(scale);
|
||||
scene.add(obj);
|
||||
|
||||
const sphere = new THREE.Box3().setFromObject(obj).getBoundingSphere(new THREE.Sphere());
|
||||
camera.position.set(sphere.radius * 1.8, sphere.radius * 1.2, sphere.radius * 1.8);
|
||||
camera.near = sphere.radius / 100;
|
||||
camera.far = sphere.radius * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
onLoaded?.();
|
||||
})
|
||||
.catch(() => onError?.());
|
||||
|
||||
const resize = () => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
if (w === 0 || h === 0) {
|
||||
return;
|
||||
}
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
};
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(container);
|
||||
|
||||
const animate = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
model?.traverse((o) => {
|
||||
const mesh = o as THREE.Mesh;
|
||||
mesh.geometry?.dispose();
|
||||
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((m) => m.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
renderer.dispose();
|
||||
container.removeChild(renderer.domElement);
|
||||
};
|
||||
}, [src, fileName]);
|
||||
|
||||
return <Box ref={containerRef} sx={{ width: "100%", height: "100%", minHeight: "calc(100vh - 200px)" }} />;
|
||||
};
|
||||
|
||||
export default ModelViewerCanvas;
|
||||
Loading…
Reference in new issue