diff --git a/frontend/package.json b/frontend/package.json
index 79bff799..ad8fbb9f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -82,6 +82,7 @@
"redux": "^5.0.0",
"streamsaver": "^2.0.6",
"styled-components": "^6.1.11",
+ "three": "^0.186.0",
"timeago-react": "^3.0.6"
},
"devDependencies": {
@@ -90,6 +91,7 @@
"@types/node": "^20.10.5",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
+ "@types/three": "^0.186.0",
"@types/webappsec-credential-management": "^0.6.9",
"@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/eslint-plugin": "^6.14.0",
diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json
index f8fabe2d..c9b49d14 100644
--- a/frontend/public/locales/en-US/application.json
+++ b/frontend/public/locales/en-US/application.json
@@ -235,6 +235,8 @@
"subtitleStyles": "Subtitle styles",
"subtitles": "Subtitles",
"markdownEditor": "Markdown Editor",
+ "modelViewer": "3D Model Viewer",
+ "modelLoadFailed": "Failed to load 3D model",
"saveSuccess": "Saved successfully at {{time}}",
"drawioLng": "en",
"charset": "Charset",
diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json
index 0ae05648..811525ae 100644
--- a/frontend/public/locales/zh-CN/application.json
+++ b/frontend/public/locales/zh-CN/application.json
@@ -235,6 +235,8 @@
"subtitleStyles": "字幕样式",
"subtitles": "字幕",
"markdownEditor": "Markdown 编辑器",
+ "modelViewer": "3D 模型查看器",
+ "modelLoadFailed": "3D 模型加载失败",
"saveSuccess": "在 {{time}} 保存成功",
"drawioLng": "zh",
"charset": "编码",
diff --git a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx
index 054e6a53..a194b8f3 100644
--- a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx
+++ b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx
@@ -22,6 +22,7 @@ import VideoViewer from "../../Viewers/Video/VideoViewer.tsx";
import PdfViewer from "../../Viewers/PdfViewer.tsx";
import CustomViewer from "../../Viewers/CustomViewer.tsx";
import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx";
+import ModelViewer from "../../Viewers/ModelViewer/ModelViewer.tsx";
import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx";
import CreateNew from "./CreateNew.tsx";
import { useAppSelector } from "../../../redux/hooks.ts";
@@ -71,6 +72,7 @@ const Dialogs = () => {
+
{showCreateArchive != undefined && }
{showExtractArchive != undefined && }
{showRemoteDownload != undefined && }
diff --git a/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx b/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx
new file mode 100644
index 00000000..bccd5bbc
--- /dev/null
+++ b/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx
@@ -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 (
+
+ {!src && }
+ {src && (
+ }>
+
+
+ )}
+
+ );
+};
+
+export default ModelViewer;
diff --git a/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx b/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx
new file mode 100644
index 00000000..3ef43c58
--- /dev/null
+++ b/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx
@@ -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 => {
+ 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(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 ;
+};
+
+export default ModelViewerCanvas;
diff --git a/frontend/src/redux/globalStateSlice.ts b/frontend/src/redux/globalStateSlice.ts
index 6d8a8747..42b4d11b 100644
--- a/frontend/src/redux/globalStateSlice.ts
+++ b/frontend/src/redux/globalStateSlice.ts
@@ -245,6 +245,7 @@ export interface GlobalStateSlice {
pdfViewer?: GeneralViewerState;
customViewer?: CustomViewerState;
epubViewer?: GeneralViewerState;
+ modelViewer?: GeneralViewerState;
musicPlayer?: MusicPlayerState;
excalidrawViewer?: GeneralViewerState;
archiveViewer?: GeneralViewerState;
@@ -423,6 +424,7 @@ export const globalStateSlice = createSlice({
state.pdfViewer = undefined;
state.customViewer = undefined;
state.epubViewer = undefined;
+ state.modelViewer = undefined;
state.excalidrawViewer = undefined;
state.archiveViewer = undefined;
},
@@ -575,6 +577,12 @@ export const globalStateSlice = createSlice({
closeEpubViewer: (state) => {
state.epubViewer && (state.epubViewer.open = false);
},
+ setModelViewer: (state, action: PayloadAction) => {
+ state.modelViewer = action.payload;
+ },
+ closeModelViewer: (state) => {
+ state.modelViewer && (state.modelViewer.open = false);
+ },
setCustomViewer: (state, action: PayloadAction) => {
state.customViewer = action.payload;
},
@@ -874,6 +882,8 @@ export const {
closeMusicPlayer,
setEpubViewer,
closeEpubViewer,
+ setModelViewer,
+ closeModelViewer,
setCustomViewer,
closeCustomViewer,
setPdfViewer,
diff --git a/frontend/src/redux/thunks/viewer.ts b/frontend/src/redux/thunks/viewer.ts
index 4ecd68d4..3e048cf4 100644
--- a/frontend/src/redux/thunks/viewer.ts
+++ b/frontend/src/redux/thunks/viewer.ts
@@ -23,6 +23,7 @@ import {
setImageEditor,
setImageViewer,
setMarkdownViewer,
+ setModelViewer,
setMusicPlayer,
setPdfViewer,
setPhotopeaViewer,
@@ -54,6 +55,7 @@ export const builtInViewers = {
music: "music",
excalidraw: "excalidraw",
archive: "archive",
+ model3d: "model3d",
};
export function openViewers(
@@ -239,6 +241,15 @@ export function openViewer(
}),
);
break;
+ case builtInViewers.model3d:
+ dispatch(
+ setModelViewer({
+ open: true,
+ file,
+ version: preferredVersion ?? primaryEntity,
+ }),
+ );
+ break;
case builtInViewers.archive:
dispatch(
setArchiveViewer({
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 5cf35793..31e85433 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1297,6 +1297,11 @@
react-devtools-inline "4.4.0"
react-is "^17.0.2"
+"@dimforge/rapier3d-compat@~0.12.0":
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389"
+ integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
+
"@emotion/babel-plugin@^11.11.0":
version "11.11.0"
resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz"
@@ -3800,6 +3805,11 @@
dependencies:
tippy.js "^6.3.1"
+"@tweenjs/tween.js@~23.1.3":
+ version "23.1.3"
+ resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
+ integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
+
"@types/acorn@^4.0.0":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22"
@@ -4056,6 +4066,11 @@
resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz"
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
+"@types/stats.js@*":
+ version "0.17.4"
+ resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e"
+ integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==
+
"@types/streamsaver@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/streamsaver/-/streamsaver-2.0.4.tgz#b57e47f9246dc0ed69378f7d1387857c94043ecf"
@@ -4073,6 +4088,18 @@
dependencies:
"@types/geojson" "*"
+"@types/three@^0.186.0":
+ version "0.186.0"
+ resolved "https://registry.yarnpkg.com/@types/three/-/three-0.186.0.tgz#fc936323d7eeb885052fd5de352e3772f488ea99"
+ integrity sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==
+ dependencies:
+ "@dimforge/rapier3d-compat" "~0.12.0"
+ "@tweenjs/tween.js" "~23.1.3"
+ "@types/stats.js" "*"
+ "@types/webxr" ">=0.5.17"
+ fflate "~0.8.3"
+ meshoptimizer "~1.1.1"
+
"@types/trusted-types@^2.0.2":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
@@ -4103,6 +4130,11 @@
resolved "https://registry.yarnpkg.com/@types/webappsec-credential-management/-/webappsec-credential-management-0.6.9.tgz#c6c17c631afdfad5859deb69c6a106886b263f73"
integrity sha512-4uDknRaO9G1fonoEALN6SDYvyRb7luVYP7lm7y0OkdVdMcU7rIh+Nq/VepDe0arkh+gfUzwCr5cNfqmKm+x5wA==
+"@types/webxr@>=0.5.17":
+ version "0.5.24"
+ resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44"
+ integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==
+
"@types/wicg-file-system-access@^2023.10.5":
version "2023.10.5"
resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz#14b3c25eb4d914b5734795bdea71da229f918b9d"
@@ -6177,6 +6209,11 @@ fdir@^6.4.3:
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72"
integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==
+fflate@~0.8.3:
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc"
+ integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==
+
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
@@ -7702,6 +7739,11 @@ mermaid@10.9.3:
uuid "^9.0.0"
web-worker "^1.2.0"
+meshoptimizer@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz#20c7d050d59bdc573154814f6a37d47d89908cca"
+ integrity sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==
+
micromark-core-commonmark@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
@@ -9913,6 +9955,11 @@ terser@^5.17.4:
commander "^2.20.0"
source-map-support "~0.5.20"
+three@^0.186.0:
+ version "0.186.0"
+ resolved "https://registry.yarnpkg.com/three/-/three-0.186.0.tgz#08f70ce80dffa9247a567b421165bec630e86f8d"
+ integrity sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==
+
timeago-react@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/timeago-react/-/timeago-react-3.0.6.tgz#d1b55af67ed21b1c5429b9ba8043d0d0f7dab295"
diff --git a/inventory/migration.go b/inventory/migration.go
index a00b5883..f69470c0 100644
--- a/inventory/migration.go
+++ b/inventory/migration.go
@@ -728,6 +728,47 @@ var patches = []Patch{
return nil
},
},
+ {
+ Name: "apply_default_model3d_viewer",
+ EndVersion: "4.15.0",
+ Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
+ fileViewersSetting, err := client.Setting.Query().Where(setting.Name("file_viewers")).First(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to query file_viewers setting: %w", err)
+ }
+
+ var fileViewers []types.ViewerGroup
+ if err := json.Unmarshal([]byte(fileViewersSetting.Value), &fileViewers); err != nil {
+ return fmt.Errorf("failed to unmarshal file_viewers setting: %w", err)
+ }
+
+ for _, viewer := range fileViewers[0].Viewers {
+ if viewer.ID == "model3d" {
+ return nil
+ }
+ }
+
+ var defaultModelViewer types.Viewer
+ for _, viewer := range defaultFileViewers[0].Viewers {
+ if viewer.ID == "model3d" {
+ defaultModelViewer = viewer
+ break
+ }
+ }
+
+ fileViewers[0].Viewers = append(fileViewers[0].Viewers, defaultModelViewer)
+ newFileViewersSetting, err := json.Marshal(fileViewers)
+ if err != nil {
+ return fmt.Errorf("failed to marshal file_viewers setting: %w", err)
+ }
+
+ if _, err := client.Setting.UpdateOne(fileViewersSetting).SetValue(string(newFileViewersSetting)).Save(ctx); err != nil {
+ return fmt.Errorf("failed to update file_viewers setting: %w", err)
+ }
+
+ return nil
+ },
+ },
{
Name: "reset_secret_key",
EndVersion: "4.13.0",
diff --git a/inventory/setting.go b/inventory/setting.go
index ed4aa989..0879cd47 100644
--- a/inventory/setting.go
+++ b/inventory/setting.go
@@ -294,6 +294,12 @@ var (
DisplayName: "fileManager.imageViewer",
Exts: []string{"bmp", "png", "gif", "jpg", "jpeg", "svg", "webp", "heic", "heif"},
},
+ {
+ ID: "model3d",
+ Type: types.ViewerTypeBuiltin,
+ DisplayName: "fileManager.modelViewer",
+ Exts: []string{"fbx", "obj", "gltf", "glb"},
+ },
{
ID: "monaco",
Type: types.ViewerTypeBuiltin,