From b0054fd51c7775eb26917241d68115fe60f44c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 19 Sep 2026 16:13:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=203D=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=99=A8=E7=8B=AC=E7=AB=8B=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 packages/model3d-viewer(@genarrative/model3d-viewer),含 Model3DViewer、scene、loader、framing、status 与 index - 组件只走 WebGL2 单一路径渲染 glb、单文件 gltf 与 fbx,未承诺格式或超过 32 MiB 时只渲染一行状态文案 - 取景按真实顶点做投影拟合,与模型尺度无关,复位回到打开时的取景 - 新增 framing 与 loader 的纯函数单测 - 根 package.json 的 workspaces 增加 packages/model3d-viewer - scripts/check-npm-workspaces.mjs 与 scripts/check-npm-workspaces.test.mjs 同步工作区清单 - vitest.config.ts 增加新包测试匹配 - 同步 package-lock.json 的工作区链接 --- package-lock.json | 144 ++++--- package.json | 1 + packages/model3d-viewer/package.json | 21 + packages/model3d-viewer/src/Model3DViewer.tsx | 180 +++++++++ packages/model3d-viewer/src/framing.test.ts | 211 ++++++++++ packages/model3d-viewer/src/framing.ts | 246 ++++++++++++ packages/model3d-viewer/src/index.ts | 18 + packages/model3d-viewer/src/loader.test.ts | 41 ++ packages/model3d-viewer/src/loader.ts | 167 ++++++++ packages/model3d-viewer/src/scene.ts | 361 ++++++++++++++++++ packages/model3d-viewer/src/status.ts | 26 ++ scripts/check-npm-workspaces.mjs | 2 + scripts/check-npm-workspaces.test.mjs | 1 + vitest.config.ts | 2 + 14 files changed, 1344 insertions(+), 77 deletions(-) create mode 100644 packages/model3d-viewer/package.json create mode 100644 packages/model3d-viewer/src/Model3DViewer.tsx create mode 100644 packages/model3d-viewer/src/framing.test.ts create mode 100644 packages/model3d-viewer/src/framing.ts create mode 100644 packages/model3d-viewer/src/index.ts create mode 100644 packages/model3d-viewer/src/loader.test.ts create mode 100644 packages/model3d-viewer/src/loader.ts create mode 100644 packages/model3d-viewer/src/scene.ts create mode 100644 packages/model3d-viewer/src/status.ts diff --git a/package-lock.json b/package-lock.json index ab6ee8ab6..717a0ac37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "packages/image-canvas-core", "packages/image-canvas-react", "packages/agc-plugin-sdk", + "packages/model3d-viewer", "packages/shared", "plugins/agc-cocos-editor", "tools/spine-json-export-validator" @@ -265,7 +266,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -4093,7 +4093,6 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", "license": "MIT", - "peer": true, "dependencies": { "@expo/log-box": "^56.0.13", "anser": "^1.4.9", @@ -5185,6 +5184,10 @@ "resolved": "apps/mobile-shell", "link": true }, + "node_modules/@genarrative/model3d-viewer": { + "resolved": "packages/model3d-viewer", + "link": true + }, "node_modules/@genarrative/preview-deployer-web": { "resolved": "apps/preview-deployer-web", "link": true @@ -8138,6 +8141,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -8150,6 +8154,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8163,7 +8168,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@testing-library/react": { "version": "16.3.2", @@ -8254,7 +8260,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -8307,8 +8314,7 @@ "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/chai-subset": { "version": "1.3.6", @@ -8416,7 +8422,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -8435,7 +8440,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8444,7 +8448,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -8573,7 +8576,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -8986,7 +8988,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9193,6 +9194,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -9653,7 +9655,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10713,7 +10714,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/domexception": { "version": "4.0.0", @@ -10922,7 +10924,6 @@ "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -11398,7 +11399,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11689,7 +11689,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.12.tgz", "integrity": "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.16", @@ -11874,7 +11873,6 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", "integrity": "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==", "license": "MIT", - "peer": true, "dependencies": { "@expo/env": "~2.3.0" }, @@ -11930,7 +11928,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -12158,7 +12155,6 @@ "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.16.0" } @@ -13321,7 +13317,6 @@ "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -14033,6 +14028,7 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", + "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -14297,7 +14293,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "dev": true, - "peer": true, "dependencies": { "abab": "^2.0.6", "cssstyle": "^3.0.0", @@ -14542,6 +14537,7 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -15352,6 +15348,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -17611,8 +17608,7 @@ "version": "2.16.0", "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/overlayscrollbars-react": { "version": "0.5.6", @@ -18042,7 +18038,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -18347,7 +18342,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18466,7 +18460,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18520,7 +18513,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", "license": "MIT", - "peer": true, "dependencies": { "@react-native/assets-registry": "0.86.0", "@react-native/codegen": "0.86.0", @@ -18590,7 +18582,6 @@ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", "license": "MIT", - "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -18773,7 +18764,6 @@ "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20504,7 +20494,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -20580,7 +20569,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20984,7 +20972,6 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -21972,7 +21959,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", "dev": true, - "peer": true, "dependencies": { "@types/chai": "^4.3.5", "@types/chai-subset": "^1.3.3", @@ -22751,7 +22737,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -23092,6 +23077,22 @@ "react-dom": "^19.0.0" } }, + "packages/model3d-viewer": { + "name": "@genarrative/model3d-viewer", + "version": "0.1.0", + "dependencies": { + "three": "^0.184.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/three": "^0.184.1", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "packages/shared": { "name": "@genarrative/shared", "version": "0.1.0", @@ -23175,7 +23176,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -25698,7 +25698,6 @@ "version": "56.0.15", "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.15.tgz", "integrity": "sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==", - "peer": true, "requires": { "@expo/log-box": "^56.0.13", "anser": "^1.4.9", @@ -26620,6 +26619,15 @@ } } }, + "@genarrative/model3d-viewer": { + "version": "file:packages/model3d-viewer", + "requires": { + "@testing-library/react": "^16.3.2", + "@types/three": "^0.184.1", + "three": "^0.184.0", + "vitest": "^0.34.6" + } + }, "@genarrative/preview-deployer-web": { "version": "file:apps/preview-deployer-web", "requires": { @@ -28333,13 +28341,15 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true + "dev": true, + "peer": true }, "pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "requires": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -28350,7 +28360,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true } } }, @@ -28410,7 +28421,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "@types/babel__core": { "version": "7.20.5", @@ -28462,8 +28474,7 @@ "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "peer": true + "dev": true }, "@types/chai-subset": { "version": "1.3.6", @@ -28559,7 +28570,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "peer": true, "requires": { "undici-types": "~6.21.0" } @@ -28577,7 +28587,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "peer": true, "requires": { "csstype": "^3.2.2" } @@ -28586,7 +28595,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "peer": true, "requires": {} }, "@types/react-window": { @@ -28685,7 +28693,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -28956,8 +28963,7 @@ "acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "peer": true + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==" }, "acorn-jsx": { "version": "5.3.2", @@ -29099,6 +29105,7 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, + "peer": true, "requires": { "dequal": "^2.0.3" } @@ -29404,7 +29411,6 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -30106,7 +30112,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "domexception": { "version": "4.0.0", @@ -30273,7 +30280,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -30604,7 +30610,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -30798,7 +30803,6 @@ "version": "56.0.12", "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.12.tgz", "integrity": "sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==", - "peer": true, "requires": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.16", @@ -31041,7 +31045,6 @@ "version": "56.0.18", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", "integrity": "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==", - "peer": true, "requires": { "@expo/env": "~2.3.0" }, @@ -31079,7 +31082,6 @@ "version": "56.0.7", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", - "peer": true, "requires": { "fontfaceobserver": "^2.1.0" } @@ -31226,8 +31228,7 @@ "expo-server": { "version": "56.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", - "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", - "peer": true + "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==" }, "expo-sharing": { "version": "56.0.18", @@ -31897,8 +31898,7 @@ "version": "16.8.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", - "dev": true, - "peer": true + "dev": true }, "graphql-tag": { "version": "2.12.6", @@ -32351,7 +32351,8 @@ "isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", - "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==" + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "peer": true }, "istanbul-lib-coverage": { "version": "3.2.2", @@ -32541,7 +32542,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "dev": true, - "peer": true, "requires": { "abab": "^2.0.6", "cssstyle": "^3.0.0", @@ -32724,6 +32724,7 @@ "version": "0.2.117", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "peer": true, "requires": { "isomorphic.js": "^0.2.4" } @@ -33173,7 +33174,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true + "dev": true, + "peer": true }, "magic-string": { "version": "0.30.21", @@ -34664,8 +34666,7 @@ "overlayscrollbars": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz", - "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==", - "peer": true + "integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==" }, "overlayscrollbars-react": { "version": "0.5.6", @@ -34963,7 +34964,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "peer": true, "requires": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -35171,8 +35171,7 @@ "react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "peer": true + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" }, "react-arborist": { "version": "3.16.0", @@ -35241,7 +35240,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "peer": true, "requires": { "scheduler": "^0.27.0" } @@ -35279,7 +35277,6 @@ "version": "0.86.0", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", - "peer": true, "requires": { "@react-native/assets-registry": "0.86.0", "@react-native/codegen": "0.86.0", @@ -35432,7 +35429,6 @@ "version": "13.16.1", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", - "peer": true, "requires": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -35441,8 +35437,7 @@ "react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "peer": true + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" }, "react-remove-scroll": { "version": "2.7.2", @@ -36615,7 +36610,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, - "peer": true, "requires": { "esbuild": "~0.27.0", "fsevents": "~2.3.3", @@ -36665,8 +36659,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, - "peer": true + "devOptional": true }, "ufo": { "version": "1.6.3", @@ -36911,7 +36904,6 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "peer": true, "requires": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -37339,7 +37331,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", "dev": true, - "peer": true, "requires": { "@types/chai": "^4.3.5", "@types/chai-subset": "^1.3.3", @@ -37755,7 +37746,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "peer": true, "requires": {} }, "xcode": { diff --git a/package.json b/package.json index 20a64195f..ee9a5e899 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "packages/image-canvas-core", "packages/image-canvas-react", "packages/agc-plugin-sdk", + "packages/model3d-viewer", "packages/shared", "plugins/agc-cocos-editor", "tools/spine-json-export-validator" diff --git a/packages/model3d-viewer/package.json b/packages/model3d-viewer/package.json new file mode 100644 index 000000000..b31a2b2c8 --- /dev/null +++ b/packages/model3d-viewer/package.json @@ -0,0 +1,21 @@ +{ + "name": "@genarrative/model3d-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "three": "^0.184.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/three": "^0.184.1", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/model3d-viewer/src/Model3DViewer.tsx b/packages/model3d-viewer/src/Model3DViewer.tsx new file mode 100644 index 000000000..1528dde02 --- /dev/null +++ b/packages/model3d-viewer/src/Model3DViewer.tsx @@ -0,0 +1,180 @@ +import { + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; + +import { + describeModel3dViewerError, + Model3dViewerAbortedError, + Model3dViewerLoadError, + type Model3DViewerSource, +} from './loader'; +import { createModel3dViewerScene, type Model3dViewerScene } from './scene'; +import { + MODEL3D_VIEWER_LOADING_TEXT, + MODEL3D_VIEWER_STATUS_ATTRIBUTE, + type Model3DViewerStatus, + type Model3dViewerStatusDetail, + resolveModel3dViewerErrorText, +} from './status'; + +export type Model3DViewerHandle = { + /** 回到打开模型时的归一化取景。 */ + resetView(): void; +}; + +export type Model3DViewerProps = { + /** 模型来源:宿主已解析好的签名地址,或已读入的字节。 */ + source: Model3DViewerSource; + autoRotate?: boolean; + /** 失败时那一行状态文案;不传用内置文案。 */ + errorText?: string; + onStatusChange?: ( + status: Model3DViewerStatus, + detail: Model3dViewerStatusDetail, + ) => void; + className?: string; + ref?: Ref; +}; + +/** + * WebGL2 模型查看器:只负责加载与交互查看,不取业务数据、不产出缩略图。 + * 宿主要给容器一个非零尺寸,并在祖先上带 platform-theme 主题类才能取到平台色值。 + */ +export function Model3DViewer({ + source, + autoRotate = false, + errorText, + onStatusChange, + className, + ref, +}: Model3DViewerProps) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const sceneRef = useRef(null); + const onStatusChangeRef = useRef(onStatusChange); + const autoRotateRef = useRef(autoRotate); + const [status, setStatus] = useState('idle'); + + useEffect(() => { + onStatusChangeRef.current = onStatusChange; + }, [onStatusChange]); + + const reportStatus = useCallback( + (next: Model3DViewerStatus, detail: Model3dViewerStatusDetail) => { + setStatus(next); + onStatusChangeRef.current?.(next, detail); + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + resetView() { + sceneRef.current?.resetView(); + }, + }), + [], + ); + + const sourceKind = source.kind; + const sourceFormat = source.format; + const sourceUrl = source.kind === 'url' ? source.url : null; + const sourceData = source.kind === 'bytes' ? source.data : null; + + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) { + return; + } + + let aborted = false; + let createdScene: Model3dViewerScene | null = null; + const activeSource: Model3DViewerSource = sourceData + ? { kind: 'bytes', data: sourceData, format: sourceFormat } + : { kind: 'url', url: sourceUrl ?? '', format: sourceFormat }; + + reportStatus('loading', {}); + + createModel3dViewerScene({ + canvas, + container, + source: activeSource, + autoRotate: autoRotateRef.current, + isAborted: () => aborted, + }) + .then((scene) => { + if (aborted) { + scene.dispose(); + return; + } + createdScene = scene; + sceneRef.current = scene; + reportStatus('ready', {}); + }) + .catch((error: unknown) => { + if (aborted || error instanceof Model3dViewerAbortedError) { + return; + } + if (error instanceof Model3dViewerLoadError) { + reportStatus('failed', { + reason: error.reason, + message: error.message, + byteLength: error.byteLength, + }); + return; + } + reportStatus('failed', { + reason: 'load-failed', + message: describeModel3dViewerError(error), + }); + }); + + return () => { + aborted = true; + createdScene?.dispose(); + if (sceneRef.current === createdScene) { + sceneRef.current = null; + } + }; + }, [sourceKind, sourceFormat, sourceUrl, sourceData, reportStatus]); + + useEffect(() => { + autoRotateRef.current = autoRotate; + sceneRef.current?.setAutoRotate(autoRotate); + }, [autoRotate]); + + return ( +
+ + {status === 'loading' ? ( +
+ {MODEL3D_VIEWER_LOADING_TEXT} +
+ ) : null} + {status === 'failed' ? ( +
+ {resolveModel3dViewerErrorText(errorText)} +
+ ) : null} +
+ ); +} diff --git a/packages/model3d-viewer/src/framing.test.ts b/packages/model3d-viewer/src/framing.test.ts new file mode 100644 index 000000000..a30c2f2ee --- /dev/null +++ b/packages/model3d-viewer/src/framing.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeModel3dViewerBoxCenter, + computeModel3dViewerBoxRadius, + computeModel3dViewerFitDistance, + computeModel3dViewerFraming, + createModel3dViewerFitSolver, + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + MODEL3D_VIEWER_FIT_FRACTION, + type Model3dViewerBox, +} from './framing'; + +type Point = [number, number, number]; + +function box(min: Point, max: Point): Model3dViewerBox { + return { + min: { x: min[0], y: min[1], z: min[2] }, + max: { x: max[0], y: max[1], z: max[2] }, + }; +} + +function scaleBox(source: Model3dViewerBox, factor: number): Model3dViewerBox { + return { + min: { + x: source.min.x * factor, + y: source.min.y * factor, + z: source.min.z * factor, + }, + max: { + x: source.max.x * factor, + y: source.max.y * factor, + z: source.max.z * factor, + }, + }; +} + +function solveForPoints( + source: Model3dViewerBox, + points: Point[], + aspect: number, +) { + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(source), + aspect, + fallbackRadius: computeModel3dViewerBoxRadius(source), + }); + for (const [x, y, z] of points) { + solver.add(x, y, z); + } + return solver.resolve(); +} + +/** 旧口径的包围球拟合距离,作为「不该退回到这么远」的对照基线。 */ +function sphereBaselineDistance(source: Model3dViewerBox): number { + const halfFovY = (MODEL3D_VIEWER_CAMERA_FOV_DEGREES * Math.PI) / 360; + return ( + computeModel3dViewerBoxRadius(source) / + (MODEL3D_VIEWER_FIT_FRACTION * Math.tan(halfFovY)) + ); +} + +const CUBE = box([-1, -1, -1], [1, 1, 1]); +const WINGS = box([-4, -0.4, -0.4], [4, 0.4, 0.4]); +const CUBE_CORNERS: Point[] = [ + [-1, -1, -1], + [-1, -1, 1], + [-1, 1, -1], + [-1, 1, 1], + [1, -1, -1], + [1, -1, 1], + [1, 1, -1], + [1, 1, 1], +]; + +describe('computeModel3dViewerFitDistance', () => { + it('与模型尺度成正比,任意尺度都占同样的视野比例', () => { + const small = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1.5 }); + const large = computeModel3dViewerFitDistance({ + box: scaleBox(CUBE, 10000), + aspect: 1.5, + }); + + expect(small).toBeGreaterThan(0); + expect(large / small).toBeCloseTo(10000, 3); + }); + + it('按包围盒投影取景,明显近于按包围球取景', () => { + for (const source of [CUBE, WINGS]) { + const boxFit = computeModel3dViewerFitDistance({ + box: source, + aspect: 2, + }); + expect(boxFit).toBeLessThan(sphereBaselineDistance(source) * 0.9); + } + }); + + it('竖屏时相机更远,避免横向出框', () => { + const landscape = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 }); + const portrait = computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 0.5, + }); + + expect(portrait).toBeGreaterThan(landscape); + }); + + it('退化或非法包围盒回落到可用的默认取景', () => { + const degenerate = [ + box([0, 0, 0], [0, 0, 0]), + box([Number.NaN, 0, 0], [1, 1, 1]), + box([Number.POSITIVE_INFINITY, 0, 0], [Number.NEGATIVE_INFINITY, 1, 1]), + ]; + + for (const candidate of degenerate) { + const distance = computeModel3dViewerFitDistance({ + box: candidate, + aspect: 1, + }); + expect(Number.isFinite(distance)).toBe(true); + expect(distance).toBeGreaterThan(0); + } + }); + + it('fitFraction 越小相机越远', () => { + const near = computeModel3dViewerFitDistance({ box: CUBE, aspect: 1 }); + const far = computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 1, + fitFraction: 0.31, + }); + + expect(far).toBeGreaterThan(near); + }); + + it('传入已算好的距离时直接采用,不再按角点重算', () => { + const distance = computeModel3dViewerFitDistance({ + box: CUBE, + aspect: 1, + distance: 7, + }); + + expect(distance).toBe(7); + }); +}); + +describe('createModel3dViewerFitSolver', () => { + it('喂包围盒角点时与包围盒口径一致', () => { + const expected = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 }); + + expect(solveForPoints(CUBE, CUBE_CORNERS, 2)).toBeCloseTo(expected, 6); + }); + + it('喂真实顶点时避开包围盒空角,取景更近', () => { + // 斜向细杆:包围盒是 -1..1 的立方体,但 6 个角点都是空的。 + const rod: Point[] = [ + [-1, -1, -1], + [1, 1, 1], + ]; + const boxFit = computeModel3dViewerFitDistance({ box: CUBE, aspect: 2 }); + const vertexFit = solveForPoints(CUBE, rod, 2); + + expect(vertexFit).toBeLessThan(boxFit * 0.9); + }); + + it('没有任何顶点时回落到与尺度相关的可用距离', () => { + const fit = solveForPoints(CUBE, [], 1); + + expect(Number.isFinite(fit)).toBe(true); + expect(fit).toBeGreaterThan(0); + }); +}); + +describe('computeModel3dViewerFraming', () => { + it('推导出合法的裁剪面与缩放范围', () => { + const framing = computeModel3dViewerFraming({ + box: box([-1, -1, -1], [3, 5, 7]), + aspect: 1.6, + }); + + expect(framing.center).toEqual({ x: 1, y: 2, z: 3 }); + expect(framing.near).toBeGreaterThan(0); + expect(framing.near).toBeLessThan(framing.far); + expect(framing.minDistance).toBeLessThan(framing.distance); + expect(framing.maxDistance).toBeGreaterThan(framing.distance); + }); + + it('复位方向是单位向量且带正向仰角', () => { + const { resetDirection } = computeModel3dViewerFraming({ + box: CUBE, + aspect: 1, + }); + + expect( + Math.hypot(resetDirection.x, resetDirection.y, resetDirection.z), + ).toBeCloseTo(1, 6); + expect(resetDirection.y).toBeGreaterThan(0); + expect(resetDirection.z).toBeGreaterThan(0); + }); +}); + +describe('computeModel3dViewerBoxCenter', () => { + it('非法包围盒回落到原点,避免相机拿到 NaN', () => { + expect( + computeModel3dViewerBoxCenter({ + min: { x: Number.POSITIVE_INFINITY, y: 0, z: 0 }, + max: { x: Number.NEGATIVE_INFINITY, y: 0, z: 0 }, + }), + ).toEqual({ x: 0, y: 0, z: 0 }); + }); +}); diff --git a/packages/model3d-viewer/src/framing.ts b/packages/model3d-viewer/src/framing.ts new file mode 100644 index 000000000..78c7ba7bb --- /dev/null +++ b/packages/model3d-viewer/src/framing.ts @@ -0,0 +1,246 @@ +export const MODEL3D_VIEWER_CAMERA_FOV_DEGREES = 45; + +/** + * 顶点在视野较短一边上占据的比例:与模型真实尺度无关的归一化取景。 + * 取 0.8 是因为包围盒对角线方向与屏幕上下轴并不重合,实际观感约为六成。 + */ +export const MODEL3D_VIEWER_FIT_FRACTION = 0.8; + +const FALLBACK_RADIUS = 1; +const FALLBACK_ASPECT = 1; +const NEAR_RADIUS_RATIO = 0.01; +const FAR_RADIUS_RATIO = 12; +const MIN_DISTANCE_RATIO = 0.2; +const MAX_DISTANCE_RATIO = 8; +/** 复位视角仰角:过度抬高会把「上轴投影范围」放大,导致模型看起来比目标比例小。 */ +const RESET_ELEVATION = 0.22; +const FIT_SAFETY = 1.02; + +export type Model3dViewerVector3 = { + x: number; + y: number; + z: number; +}; + +export type Model3dViewerBox = { + min: Model3dViewerVector3; + max: Model3dViewerVector3; +}; + +export type Model3dViewerFramingInput = { + box: Model3dViewerBox; + aspect: number; + direction?: Model3dViewerVector3; + fovDegrees?: number; + fitFraction?: number; + /** 已按实际顶点算好的取景距离;缺省时退回包围盒 8 个角点。 */ + distance?: number; +}; + +export type Model3dViewerFitSolverInput = { + center: Model3dViewerVector3; + aspect: number; + direction?: Model3dViewerVector3; + fovDegrees?: number; + fitFraction?: number; + fallbackRadius?: number; +}; + +export type Model3dViewerFitSolver = { + add(x: number, y: number, z: number): void; + resolve(): number; +}; + +export type Model3dViewerFraming = { + center: Model3dViewerVector3; + radius: number; + distance: number; + near: number; + far: number; + minDistance: number; + maxDistance: number; + /** 复位视角时的相机方向(已归一化,带少量仰角)。 */ + resetDirection: Model3dViewerVector3; +}; + +function normalizePositive(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function subtract( + left: Model3dViewerVector3, + right: Model3dViewerVector3, +): Model3dViewerVector3 { + return { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z, + }; +} + +function dot(left: Model3dViewerVector3, right: Model3dViewerVector3): number { + return left.x * right.x + left.y * right.y + left.z * right.z; +} + +function cross( + left: Model3dViewerVector3, + right: Model3dViewerVector3, +): Model3dViewerVector3 { + return { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x, + }; +} + +function normalize( + value: Model3dViewerVector3, + fallback: Model3dViewerVector3, +): Model3dViewerVector3 { + const length = Math.hypot(value.x, value.y, value.z); + if (!Number.isFinite(length) || length <= Number.EPSILON) { + return fallback; + } + return { x: value.x / length, y: value.y / length, z: value.z / length }; +} + +/** 复位视角的默认方向:从正前方略微抬高,避免正投影视角。 */ +export function resolveModel3dViewerResetDirection( + direction?: Model3dViewerVector3, +): Model3dViewerVector3 { + if (direction) { + return normalize(direction, { x: 0, y: 0, z: 1 }); + } + return normalize({ x: 0, y: RESET_ELEVATION, z: 1 }, { x: 0, y: 0, z: 1 }); +} + +export function computeModel3dViewerBoxCenter( + box: Model3dViewerBox, +): Model3dViewerVector3 { + const center = { + x: (box.min.x + box.max.x) / 2, + y: (box.min.y + box.max.y) / 2, + z: (box.min.z + box.max.z) / 2, + }; + const valid = + Number.isFinite(center.x) && + Number.isFinite(center.y) && + Number.isFinite(center.z); + return valid ? center : { x: 0, y: 0, z: 0 }; +} + +export function computeModel3dViewerBoxRadius(box: Model3dViewerBox): number { + return ( + Math.hypot( + box.max.x - box.min.x, + box.max.y - box.min.y, + box.max.z - box.min.z, + ) / 2 + ); +} + +/** + * 取景求解器:把每个采样点投影到相机右轴与上轴,取满足「点落在 fitFraction + * 视野内」的最小距离。逐点求解而不是用包围球,因为包围球会把「翅膀斜向展开」 + * 这类形状的取景距离放大一倍以上;用真实顶点又可以避开包围盒空角。 + */ +export function createModel3dViewerFitSolver( + input: Model3dViewerFitSolverInput, +): Model3dViewerFitSolver { + const aspect = normalizePositive(input.aspect, FALLBACK_ASPECT); + const fovDegrees = normalizePositive( + input.fovDegrees ?? MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + ); + const fitFraction = normalizePositive( + input.fitFraction ?? MODEL3D_VIEWER_FIT_FRACTION, + MODEL3D_VIEWER_FIT_FRACTION, + ); + const center = input.center; + const fallbackRadius = normalizePositive( + input.fallbackRadius ?? FALLBACK_RADIUS, + FALLBACK_RADIUS, + ); + const direction = resolveModel3dViewerResetDirection(input.direction); + const forward = { x: -direction.x, y: -direction.y, z: -direction.z }; + const right = normalize(cross(forward, { x: 0, y: 1, z: 0 }), { + x: 1, + y: 0, + z: 0, + }); + const up = cross(right, forward); + + const halfFovY = (fovDegrees * Math.PI) / 360; + const limitY = fitFraction * Math.tan(halfFovY); + const limitX = limitY * aspect; + + let required = 0; + const fallbackDistance = fallbackRadius / (fitFraction * Math.tan(halfFovY)); + + return { + add(x: number, y: number, z: number) { + const corner = subtract({ x, y, z }, center); + const depth = dot(corner, forward); + required = Math.max( + required, + Math.abs(dot(corner, up)) / limitY - depth, + Math.abs(dot(corner, right)) / limitX - depth, + ); + }, + resolve() { + if (!Number.isFinite(required) || required <= 0) { + return fallbackDistance; + } + return Math.max(required * FIT_SAFETY, fallbackRadius); + }, + }; +} + +/** 没有顶点可用时,用包围盒 8 个角点求解取景距离。 */ +export function computeModel3dViewerFitDistance( + input: Model3dViewerFramingInput, +): number { + if (input.distance !== undefined) { + return input.distance; + } + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(input.box), + aspect: input.aspect, + direction: input.direction, + fovDegrees: input.fovDegrees, + fitFraction: input.fitFraction, + fallbackRadius: computeModel3dViewerBoxRadius(input.box), + }); + for (const x of [input.box.min.x, input.box.max.x]) { + for (const y of [input.box.min.y, input.box.max.y]) { + for (const z of [input.box.min.z, input.box.max.z]) { + solver.add(x, y, z); + } + } + } + return solver.resolve(); +} + +/** 由包围盒推导取景、裁剪面与轨道缩放范围。 */ +export function computeModel3dViewerFraming( + input: Model3dViewerFramingInput, +): Model3dViewerFraming { + const radius = normalizePositive( + computeModel3dViewerBoxRadius(input.box), + FALLBACK_RADIUS, + ); + const distance = computeModel3dViewerFitDistance({ + ...input, + box: input.box, + }); + return { + center: computeModel3dViewerBoxCenter(input.box), + radius, + distance, + near: Math.max(radius * NEAR_RADIUS_RATIO, distance / 1000), + far: Math.max(distance + radius * FAR_RADIUS_RATIO, distance * 10), + minDistance: radius * MIN_DISTANCE_RATIO, + maxDistance: distance * MAX_DISTANCE_RATIO, + resetDirection: resolveModel3dViewerResetDirection(input.direction), + }; +} diff --git a/packages/model3d-viewer/src/index.ts b/packages/model3d-viewer/src/index.ts new file mode 100644 index 000000000..ab8123843 --- /dev/null +++ b/packages/model3d-viewer/src/index.ts @@ -0,0 +1,18 @@ +export type { Model3DViewerFormat, Model3DViewerSource } from './loader'; +export { + isModel3dViewerFormatSupported, + isModel3dViewerOverSize, + MODEL3D_VIEWER_MAX_MODEL_BYTES, +} from './loader'; +export type { Model3DViewerHandle, Model3DViewerProps } from './Model3DViewer'; +export { Model3DViewer } from './Model3DViewer'; +export type { + Model3dViewerFailureReason, + Model3DViewerStatus, + Model3dViewerStatusDetail, +} from './status'; +export { + MODEL3D_VIEWER_ERROR_TEXT, + MODEL3D_VIEWER_STATUS_ATTRIBUTE, + resolveModel3dViewerErrorText, +} from './status'; diff --git a/packages/model3d-viewer/src/loader.test.ts b/packages/model3d-viewer/src/loader.test.ts new file mode 100644 index 000000000..3d78d4323 --- /dev/null +++ b/packages/model3d-viewer/src/loader.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { + isModel3dViewerFormatSupported, + isModel3dViewerOverSize, + MODEL3D_VIEWER_MAX_MODEL_BYTES, +} from './loader'; + +describe('模型格式承诺范围', () => { + it('只承诺 glb / gltf / fbx', () => { + for (const format of ['glb', 'gltf', 'fbx']) { + expect(isModel3dViewerFormatSupported(format)).toBe(true); + } + for (const format of [ + 'obj', + 'stl', + 'usdz', + '3mf', + '', + null, + undefined, + 7, + ]) { + expect(isModel3dViewerFormatSupported(format)).toBe(false); + } + }); +}); + +describe('模型体积上限', () => { + it('超过 32 MiB 判定为超限', () => { + expect(isModel3dViewerOverSize(0)).toBe(false); + expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false); + expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe( + true, + ); + }); + + it('非有限值不算超限,由加载流程自行失败', () => { + expect(isModel3dViewerOverSize(Number.NaN)).toBe(false); + }); +}); diff --git a/packages/model3d-viewer/src/loader.ts b/packages/model3d-viewer/src/loader.ts new file mode 100644 index 000000000..018b5b949 --- /dev/null +++ b/packages/model3d-viewer/src/loader.ts @@ -0,0 +1,167 @@ +import type * as ThreeTypes from 'three'; + +import type { Model3dViewerFailureReason } from './status'; + +export type Model3DViewerFormat = 'glb' | 'gltf' | 'fbx'; + +export type Model3DViewerSource = + | { kind: 'url'; url: string; format: Model3DViewerFormat } + | { kind: 'bytes'; data: ArrayBuffer; format: Model3DViewerFormat }; + +const SUPPORTED_FORMATS: readonly Model3DViewerFormat[] = [ + 'glb', + 'gltf', + 'fbx', +]; + +/** 单模型可渲染上限;超过后整体失败,不做半渲染。 */ +export const MODEL3D_VIEWER_MAX_MODEL_BYTES = 32 * 1024 * 1024; + +export function isModel3dViewerFormatSupported( + value: unknown, +): value is Model3DViewerFormat { + return SUPPORTED_FORMATS.includes(value as Model3DViewerFormat); +} + +export function isModel3dViewerOverSize(byteLength: number): boolean { + return ( + Number.isFinite(byteLength) && byteLength > MODEL3D_VIEWER_MAX_MODEL_BYTES + ); +} + +export function describeModel3dViewerError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return typeof error === 'string' ? error : '未知错误'; +} + +export class Model3dViewerLoadError extends Error { + readonly reason: Model3dViewerFailureReason; + readonly byteLength?: number; + + constructor( + reason: Model3dViewerFailureReason, + message: string, + byteLength?: number, + ) { + super(message); + this.name = 'Model3dViewerLoadError'; + this.reason = reason; + this.byteLength = byteLength; + } +} + +export class Model3dViewerAbortedError extends Error { + constructor() { + super('模型加载已取消'); + this.name = 'Model3dViewerAbortedError'; + } +} + +function assertModel3dViewerByteLength(byteLength: number) { + if (isModel3dViewerOverSize(byteLength)) { + throw new Model3dViewerLoadError( + 'too-large', + `模型体积 ${byteLength} 字节,超过 ${MODEL3D_VIEWER_MAX_MODEL_BYTES} 字节上限`, + byteLength, + ); + } +} + +/** 取模型字节:url 走一次 fetch,bytes 直接复用调用方已读入的字节。 */ +export async function readModel3dViewerSourceBytes( + source: Model3DViewerSource, +): Promise { + if (source.kind === 'bytes') { + assertModel3dViewerByteLength(source.data.byteLength); + return source.data; + } + + let response: Response; + try { + response = await fetch(source.url); + } catch (error) { + throw new Model3dViewerLoadError( + 'load-failed', + `模型地址不可读取:${describeModel3dViewerError(error)}`, + ); + } + if (!response.ok) { + throw new Model3dViewerLoadError( + 'load-failed', + `模型地址返回 ${response.status}`, + ); + } + + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength)) { + assertModel3dViewerByteLength(declaredLength); + } + + const data = await response.arrayBuffer(); + assertModel3dViewerByteLength(data.byteLength); + return data; +} + +function resolveModel3dViewerBasePath(source: Model3DViewerSource): string { + if (source.kind !== 'url') { + return ''; + } + const separatorIndex = source.url.lastIndexOf('/'); + return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : ''; +} + +/** + * 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoader,fbx 走 FBXLoader; + * 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。 + */ +export async function loadModel3dViewerObject( + source: Model3DViewerSource, + isAborted: () => boolean = () => false, +): Promise { + if (!isModel3dViewerFormatSupported(source.format)) { + throw new Model3dViewerLoadError( + 'unsupported-format', + `不支持的模型格式:${String(source.format)}`, + ); + } + + const data = await readModel3dViewerSourceBytes(source); + if (isAborted()) { + throw new Model3dViewerAbortedError(); + } + + const basePath = resolveModel3dViewerBasePath(source); + try { + if (source.format === 'fbx') { + const { FBXLoader } = await import( + 'three/examples/jsm/loaders/FBXLoader.js' + ); + return new FBXLoader().parse(data, basePath); + } + + const { GLTFLoader } = await import( + 'three/examples/jsm/loaders/GLTFLoader.js' + ); + const loader = new GLTFLoader(); + const payload: string | ArrayBuffer = + source.format === 'gltf' + ? new TextDecoder().decode(new Uint8Array(data)) + : data; + const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>( + (resolve, reject) => { + loader.parse(payload, basePath, resolve, reject); + }, + ); + return gltf.scene; + } catch (error) { + if (error instanceof Model3dViewerLoadError) { + throw error; + } + throw new Model3dViewerLoadError( + 'load-failed', + `模型解析失败:${describeModel3dViewerError(error)}`, + ); + } +} diff --git a/packages/model3d-viewer/src/scene.ts b/packages/model3d-viewer/src/scene.ts new file mode 100644 index 000000000..14decdc9e --- /dev/null +++ b/packages/model3d-viewer/src/scene.ts @@ -0,0 +1,361 @@ +import type * as ThreeTypes from 'three'; + +import { + computeModel3dViewerBoxCenter, + computeModel3dViewerBoxRadius, + computeModel3dViewerFraming, + createModel3dViewerFitSolver, + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, +} from './framing'; +import { + describeModel3dViewerError, + loadModel3dViewerObject, + Model3dViewerAbortedError, + Model3dViewerLoadError, + type Model3DViewerSource, +} from './loader'; + +const MAX_PIXEL_RATIO = 2; +/** 与共享组件同口径的半球光 / 主光,保证 PBR 材质可见;场景背景交给宿主。 */ +const HEMISPHERE_LIGHT_SKY = 0xffffff; +const HEMISPHERE_LIGHT_GROUND = 0x445566; +const HEMISPHERE_LIGHT_INTENSITY = 2.2; +const KEY_LIGHT_INTENSITY = 2.0; +const ENVIRONMENT_INTENSITY = 0.9; +const TONE_MAPPING_EXPOSURE = 1.1; +const DAMPING_FACTOR = 0.08; +const AUTO_ROTATE_SPEED = 1.6; +/** 单个几何最多采样多少顶点,避免超大模型在打开瞬间卡住主线程。 */ +const MAX_FIT_SAMPLES_PER_GEOMETRY = 200_000; + +export type Model3dViewerScene = { + resetView(): void; + setAutoRotate(enabled: boolean): void; + dispose(): void; +}; + +export type Model3dViewerSceneInput = { + canvas: HTMLCanvasElement; + container: HTMLElement; + source: Model3DViewerSource; + autoRotate: boolean; + isAborted: () => boolean; +}; + +/** 释放模型与场景里所有几何、材质、贴图,避免反复开关时显存只涨不降。 */ +export function disposeModel3dViewerObject(object: ThreeTypes.Object3D) { + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + mesh.geometry?.dispose?.(); + const material = mesh.material; + const materials = Array.isArray(material) + ? material + : material + ? [material] + : []; + for (const entry of materials) { + for (const value of Object.values( + entry as unknown as Record, + )) { + const texture = value as + | { isTexture?: boolean; dispose?: () => void } + | undefined; + if (texture?.isTexture && typeof texture.dispose === 'function') { + texture.dispose(); + } + } + (entry as { dispose?: () => void }).dispose?.(); + } + }); +} + +/** 把模型真实顶点按世界坐标喂给取景求解器,避免包围球/包围盒空角带来的过度取景。 */ +function feedModel3dViewerFitSolver( + object: ThreeTypes.Object3D, + add: (x: number, y: number, z: number) => void, +) { + const readElement = (source: ArrayLike, index: number) => + source[index] ?? 0; + + object.updateMatrixWorld(true); + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + const position = mesh.geometry?.attributes?.position; + if (!position || position.count === 0) { + return; + } + const elements = mesh.matrixWorld.elements; + const m0 = readElement(elements, 0); + const m1 = readElement(elements, 1); + const m2 = readElement(elements, 2); + const m4 = readElement(elements, 4); + const m5 = readElement(elements, 5); + const m6 = readElement(elements, 6); + const m8 = readElement(elements, 8); + const m9 = readElement(elements, 9); + const m10 = readElement(elements, 10); + const m12 = readElement(elements, 12); + const m13 = readElement(elements, 13); + const m14 = readElement(elements, 14); + const push = (index: number) => { + const x = position.getX(index); + const y = position.getY(index); + const z = position.getZ(index); + add( + m0 * x + m4 * y + m8 * z + m12, + m1 * x + m5 * y + m9 * z + m13, + m2 * x + m6 * y + m10 * z + m14, + ); + }; + const stride = Math.max( + 1, + Math.ceil(position.count / MAX_FIT_SAMPLES_PER_GEOMETRY), + ); + for (let index = 0; index < position.count; index += stride) { + push(index); + } + if (stride > 1) { + // 抽样可能漏掉端点的极值,补最后一个顶点。 + push(position.count - 1); + } + }); +} + +/** + * 创建 WebGL2 交互场景:加载模型、归一化取景、轨道操作、按需释放。 + * 未挂载即完成加载时(React StrictMode 双调用)整体丢弃并释放,不留下第二个上下文。 + */ +export async function createModel3dViewerScene( + input: Model3dViewerSceneInput, +): Promise { + const { canvas, container, source, isAborted } = input; + + const [THREE, orbitControlsModule] = await Promise.all([ + import('three'), + import('three/examples/jsm/controls/OrbitControls.js'), + ]); + + let renderer: ThreeTypes.WebGLRenderer | null = null; + let scene: ThreeTypes.Scene | null = null; + let model: ThreeTypes.Object3D | null = null; + let environmentTexture: ThreeTypes.Texture | null = null; + let pmremGenerator: ThreeTypes.PMREMGenerator | null = null; + let roomEnvironment: ThreeTypes.Scene | null = null; + let controls: { dispose: () => void } | null = null; + let resizeObserver: ResizeObserver | null = null; + let frameHandle = 0; + let disposed = false; + + function teardown() { + if (disposed) { + return; + } + disposed = true; + if (frameHandle !== 0) { + window.cancelAnimationFrame(frameHandle); + frameHandle = 0; + } + resizeObserver?.disconnect(); + resizeObserver = null; + controls?.dispose(); + controls = null; + if (model) { + disposeModel3dViewerObject(model); + model = null; + } + environmentTexture?.dispose(); + environmentTexture = null; + pmremGenerator?.dispose(); + pmremGenerator = null; + if (roomEnvironment) { + disposeModel3dViewerObject(roomEnvironment); + roomEnvironment = null; + } + if (renderer) { + renderer.dispose(); + try { + renderer.forceContextLoss(); + } catch { + // 上下文已经丢失时忽略,释放流程继续。 + } + renderer = null; + } + } + + function abortIfNeeded() { + if (!isAborted()) { + return; + } + teardown(); + throw new Model3dViewerAbortedError(); + } + + try { + renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + }); + } catch (error) { + throw new Model3dViewerLoadError( + 'webgl-unavailable', + `WebGL2 上下文不可用:${describeModel3dViewerError(error)}`, + ); + } + + scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera( + MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + 1, + 0.01, + 1000, + ); + renderer.setPixelRatio( + Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO), + ); + renderer.toneMapping = THREE.NeutralToneMapping; + renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE; + + const { OrbitControls } = orbitControlsModule; + const orbitControls = new OrbitControls(camera, canvas); + controls = orbitControls; + orbitControls.enableDamping = true; + orbitControls.dampingFactor = DAMPING_FACTOR; + orbitControls.screenSpacePanning = true; + orbitControls.autoRotate = input.autoRotate; + orbitControls.autoRotateSpeed = AUTO_ROTATE_SPEED; + + scene.add( + new THREE.HemisphereLight( + HEMISPHERE_LIGHT_SKY, + HEMISPHERE_LIGHT_GROUND, + HEMISPHERE_LIGHT_INTENSITY, + ), + ); + const keyLight = new THREE.DirectionalLight(0xffffff, KEY_LIGHT_INTENSITY); + keyLight.position.set(2, 3, 4); + scene.add(keyLight); + + try { + const { RoomEnvironment } = await import( + 'three/examples/jsm/environments/RoomEnvironment.js' + ); + roomEnvironment = new RoomEnvironment(); + pmremGenerator = new THREE.PMREMGenerator(renderer); + environmentTexture = pmremGenerator.fromScene( + roomEnvironment, + 0.04, + ).texture; + scene.environment = environmentTexture; + scene.environmentIntensity = ENVIRONMENT_INTENSITY; + } catch { + // 环境贴图只影响材质观感,取不到时退化为纯光照渲染。 + roomEnvironment = null; + } + + abortIfNeeded(); + + const model3d = await loadModel3dViewerObject(source, isAborted).catch( + (error: unknown) => { + teardown(); + throw error; + }, + ); + model = model3d; + scene.add(model3d); + + abortIfNeeded(); + + const bounds = new THREE.Box3().setFromObject(model3d); + const size = measureViewport(); + const fitBox = { + min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }, + max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z }, + }; + const fitCenter = computeModel3dViewerBoxCenter(fitBox); + const fitSolver = createModel3dViewerFitSolver({ + center: fitCenter, + aspect: size.aspect, + fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + fallbackRadius: computeModel3dViewerBoxRadius(fitBox), + }); + feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z)); + const framing = computeModel3dViewerFraming({ + box: fitBox, + aspect: size.aspect, + fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + distance: fitSolver.resolve(), + }); + + function measureViewport(): { + width: number; + height: number; + aspect: number; + } { + const rect = container.getBoundingClientRect(); + const width = Math.max(Math.round(rect.width), 0); + const height = Math.max(Math.round(rect.height), 0); + return { + width, + height, + aspect: width > 0 && height > 0 ? width / height : 1, + }; + } + + function applyViewport() { + const current = measureViewport(); + if (current.width === 0 || current.height === 0 || !renderer) { + return; + } + renderer.setSize(current.width, current.height, false); + camera.aspect = current.aspect; + camera.updateProjectionMatrix(); + } + + function resetView() { + camera.near = framing.near; + camera.far = framing.far; + camera.updateProjectionMatrix(); + orbitControls.minDistance = framing.minDistance; + orbitControls.maxDistance = framing.maxDistance; + orbitControls.target.set( + framing.center.x, + framing.center.y, + framing.center.z, + ); + camera.position.set( + framing.center.x + framing.resetDirection.x * framing.distance, + framing.center.y + framing.resetDirection.y * framing.distance, + framing.center.z + framing.resetDirection.z * framing.distance, + ); + orbitControls.update(); + } + + resetView(); + applyViewport(); + + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => { + applyViewport(); + }); + resizeObserver.observe(container); + } + + function renderFrame() { + if (disposed || !renderer || !scene) { + return; + } + frameHandle = window.requestAnimationFrame(renderFrame); + orbitControls.update(); + renderer.render(scene, camera); + } + frameHandle = window.requestAnimationFrame(renderFrame); + + return { + resetView, + setAutoRotate(enabled: boolean) { + orbitControls.autoRotate = enabled; + }, + dispose: teardown, + }; +} diff --git a/packages/model3d-viewer/src/status.ts b/packages/model3d-viewer/src/status.ts new file mode 100644 index 000000000..822a7e051 --- /dev/null +++ b/packages/model3d-viewer/src/status.ts @@ -0,0 +1,26 @@ +export type Model3DViewerStatus = 'idle' | 'loading' | 'ready' | 'failed'; + +export type Model3dViewerFailureReason = + | 'unsupported-format' + | 'too-large' + | 'load-failed' + | 'webgl-unavailable'; + +export type Model3dViewerStatusDetail = { + reason?: Model3dViewerFailureReason; + message?: string; + byteLength?: number; +}; + +/** 容器上的状态属性,宿主与测试按它判定渲染结果。 */ +export const MODEL3D_VIEWER_STATUS_ATTRIBUTE = 'data-3d-viewer-status'; + +export const MODEL3D_VIEWER_LOADING_TEXT = '加载中'; + +export const MODEL3D_VIEWER_ERROR_TEXT = '模型加载失败'; + +/** 失败时只渲染一行状态文案:宿主可覆盖,空串回落到内置文案。 */ +export function resolveModel3dViewerErrorText(value?: string | null): string { + const trimmed = value?.trim(); + return trimmed ? trimmed : MODEL3D_VIEWER_ERROR_TEXT; +} diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index 0f34205c4..1f4b003d5 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -13,6 +13,7 @@ export const REQUIRED_WORKSPACES = Object.freeze([ 'packages/image-canvas-core', 'packages/image-canvas-react', 'packages/agc-plugin-sdk', + 'packages/model3d-viewer', 'packages/shared', 'plugins/agc-cocos-editor', 'tools/spine-json-export-validator', @@ -27,6 +28,7 @@ const WORKSPACE_NAMES = Object.freeze({ 'packages/image-canvas-core': '@genarrative/image-canvas-core', 'packages/image-canvas-react': '@genarrative/image-canvas-react', 'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk', + 'packages/model3d-viewer': '@genarrative/model3d-viewer', 'packages/shared': '@genarrative/shared', 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'tools/spine-json-export-validator': diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index 5f995b859..1d12e7a7e 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -21,6 +21,7 @@ const workspaceNames = { 'packages/image-canvas-core': '@genarrative/image-canvas-core', 'packages/image-canvas-react': '@genarrative/image-canvas-react', 'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk', + 'packages/model3d-viewer': '@genarrative/model3d-viewer', 'packages/shared': '@genarrative/shared', 'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor', 'tools/spine-json-export-validator': diff --git a/vitest.config.ts b/vitest.config.ts index deea4b4f9..6137594bb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -142,6 +142,8 @@ export default defineConfig({ 'packages/image-canvas-react/src/**/*.test.ts', 'packages/image-canvas-react/src/**/*.test.tsx', 'packages/agc-plugin-sdk/src/**/*.test.ts', + 'packages/model3d-viewer/src/**/*.test.ts', + 'packages/model3d-viewer/src/**/*.test.tsx', ], exclude: ['scripts/loadtest/**'], },