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,