model-preview 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/package.json +30 -0
- package/src/ModelPreviewViewport.ts +259 -0
- package/src/PreviewModel.ts +242 -0
- package/src/animation/format.ts +8 -0
- package/src/constants.ts +8 -0
- package/src/dispose/disposeObject3D.ts +28 -0
- package/src/gltf/metalRoughConverter.ts +70 -0
- package/src/index.ts +89 -0
- package/src/loaders/fbxLoader.ts +53 -0
- package/src/loaders/glbLoader.ts +39 -0
- package/src/loaders/objLoader.ts +70 -0
- package/src/loaders/registry.ts +69 -0
- package/src/loaders/types.ts +15 -0
- package/src/shims/empty-node-module.ts +3 -0
- package/src/thumbnail/captureModelThumbnail.ts +127 -0
- package/src/thumbnail/thumbnailValidation.ts +44 -0
- package/src/types.ts +22 -0
- package/src/viewport/CustomControls.ts +268 -0
- package/src/viewport/ViewGrid.ts +268 -0
- package/src/viewport/ViewportUtils.ts +173 -0
- package/src/viewport/WorldAxes.ts +131 -0
- package/src/viewport/renderQuality.ts +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# model-preview
|
|
2
|
+
|
|
3
|
+
Three.js utilities for loading, previewing, and thumbnailing 3D models (GLB/GLTF, FBX, OBJ).
|
|
4
|
+
|
|
5
|
+
Depends on [`model-file-types`](../model-file-types).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add model-preview model-file-types three
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { ModelPreviewViewport, loadModel } from 'model-preview';
|
|
17
|
+
|
|
18
|
+
const buffer = await file.arrayBuffer();
|
|
19
|
+
const model = await loadModel(buffer, { filename: file.name });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Development
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm install
|
|
26
|
+
pnpm --dir ../model-file-types run build
|
|
27
|
+
pnpm run check
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Browser bundlers
|
|
31
|
+
|
|
32
|
+
Some loaders reference Node built-ins. Alias them to the package shim when bundling for the browser:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// vite.config.ts
|
|
36
|
+
resolve: {
|
|
37
|
+
alias: {
|
|
38
|
+
'node:fs': 'model-preview/src/shims/empty-node-module.ts',
|
|
39
|
+
'node:path': 'model-preview/src/shims/empty-node-module.ts',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "model-preview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Three.js-based 3D model preview, loaders, viewport, and thumbnail capture",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": ["src"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsc -b",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"check": "pnpm run typecheck && pnpm run test"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@gltf-transform/core": "^4.4.1",
|
|
20
|
+
"@gltf-transform/extensions": "^4.4.1",
|
|
21
|
+
"@gltf-transform/functions": "^4.4.1",
|
|
22
|
+
"model-file-types": "file:../model-file-types",
|
|
23
|
+
"three": "^0.185.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/three": "^0.185.4",
|
|
27
|
+
"typescript": "^5.8.2",
|
|
28
|
+
"vitest": "^4.1.10"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AmbientLight,
|
|
3
|
+
Box3,
|
|
4
|
+
DirectionalLight,
|
|
5
|
+
PerspectiveCamera,
|
|
6
|
+
Scene,
|
|
7
|
+
WebGLRenderer,
|
|
8
|
+
} from 'three';
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_VIEW_FOV } from './constants.js';
|
|
11
|
+
import { CustomControls, MOUSE } from './viewport/CustomControls.js';
|
|
12
|
+
import { ViewGrid } from './viewport/ViewGrid.js';
|
|
13
|
+
import { WorldAxes } from './viewport/WorldAxes.js';
|
|
14
|
+
import {
|
|
15
|
+
applySceneEnvironment,
|
|
16
|
+
configureRenderer,
|
|
17
|
+
createRoomEnvironmentMap,
|
|
18
|
+
} from './viewport/renderQuality.js';
|
|
19
|
+
import { createViewportBackground, frameObjectInView } from './viewport/ViewportUtils.js';
|
|
20
|
+
import type { PreviewModelSource } from './types.js';
|
|
21
|
+
|
|
22
|
+
const _box = new Box3();
|
|
23
|
+
|
|
24
|
+
export type ModelPreviewViewportOptions = {
|
|
25
|
+
fov?: number;
|
|
26
|
+
padding?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Isolated Three.js viewport for GLB model and animation preview.
|
|
31
|
+
* Used by UEditor animation panel and Netdisk model preview.
|
|
32
|
+
*/
|
|
33
|
+
export class ModelPreviewViewport {
|
|
34
|
+
private canvas: HTMLCanvasElement;
|
|
35
|
+
private viewport: HTMLElement;
|
|
36
|
+
private sourceModel: PreviewModelSource | null = null;
|
|
37
|
+
private previewModel: PreviewModelSource | null = null;
|
|
38
|
+
private scene: Scene | null = null;
|
|
39
|
+
private camera: PerspectiveCamera | null = null;
|
|
40
|
+
private renderer: WebGLRenderer | null = null;
|
|
41
|
+
private controls: CustomControls | null = null;
|
|
42
|
+
private viewGrid = new ViewGrid();
|
|
43
|
+
private worldAxes = new WorldAxes();
|
|
44
|
+
private rafId: number | null = null;
|
|
45
|
+
private lastTime = 0;
|
|
46
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
47
|
+
private frameClipName: string | null = null;
|
|
48
|
+
private environmentHandle: ReturnType<typeof createRoomEnvironmentMap> | null = null;
|
|
49
|
+
private readonly fov: number;
|
|
50
|
+
private readonly padding: number;
|
|
51
|
+
|
|
52
|
+
constructor(canvas: HTMLCanvasElement, options: ModelPreviewViewportOptions = {}) {
|
|
53
|
+
this.canvas = canvas;
|
|
54
|
+
this.viewport = canvas.parentElement ?? canvas;
|
|
55
|
+
this.fov = options.fov ?? DEFAULT_VIEW_FOV;
|
|
56
|
+
this.padding = options.padding ?? 1.8;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
mount(sourceModel: PreviewModelSource, clipName: string | null = null) {
|
|
60
|
+
this.dispose();
|
|
61
|
+
|
|
62
|
+
this.sourceModel = sourceModel;
|
|
63
|
+
this.previewModel = sourceModel.createPreviewInstance();
|
|
64
|
+
this.frameClipName = clipName;
|
|
65
|
+
|
|
66
|
+
this.scene = new Scene();
|
|
67
|
+
this.scene.background = createViewportBackground();
|
|
68
|
+
|
|
69
|
+
const ambient = new AmbientLight(0xffffff, 0.15);
|
|
70
|
+
const directional = new DirectionalLight(0xfff4e0, 2.2);
|
|
71
|
+
directional.position.set(2, 4, 3);
|
|
72
|
+
directional.castShadow = true;
|
|
73
|
+
this.scene.add(ambient);
|
|
74
|
+
this.scene.add(directional);
|
|
75
|
+
|
|
76
|
+
if (this.previewModel.object) {
|
|
77
|
+
this.scene.add(this.previewModel.object);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const width = Math.max(this.viewport.clientWidth, 1);
|
|
81
|
+
const height = Math.max(this.viewport.clientHeight, 1);
|
|
82
|
+
|
|
83
|
+
this.camera = new PerspectiveCamera(this.fov, width / height, 0.01, 1000);
|
|
84
|
+
|
|
85
|
+
this.renderer = new WebGLRenderer({ canvas: this.canvas, antialias: true, alpha: true });
|
|
86
|
+
configureRenderer(this.renderer, { pixelRatio: Math.min(window.devicePixelRatio || 1, 2) });
|
|
87
|
+
this.environmentHandle = createRoomEnvironmentMap(this.renderer);
|
|
88
|
+
applySceneEnvironment(this.scene, this.environmentHandle.envMap, 0.85);
|
|
89
|
+
this.resize();
|
|
90
|
+
|
|
91
|
+
this.controls = new CustomControls(this.camera, this.renderer.domElement);
|
|
92
|
+
this.controls.enableDamping = false;
|
|
93
|
+
this.controls.zoomSpeed = 0.3;
|
|
94
|
+
this.controls.mouseButtons = { LEFT: null, MIDDLE: MOUSE.ROTATE, RIGHT: null };
|
|
95
|
+
|
|
96
|
+
this.scene.add(this.worldAxes.create(this.camera));
|
|
97
|
+
this.scene.add(this.viewGrid.create(this.camera, this.controls));
|
|
98
|
+
|
|
99
|
+
this.frameInitialView();
|
|
100
|
+
this.bindResizeObserver();
|
|
101
|
+
this.render();
|
|
102
|
+
this.startLoop();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private bindResizeObserver() {
|
|
106
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
107
|
+
|
|
108
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
109
|
+
this.resize();
|
|
110
|
+
this.render();
|
|
111
|
+
});
|
|
112
|
+
this.resizeObserver.observe(this.viewport);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
resize() {
|
|
116
|
+
if (!this.renderer || !this.camera) return;
|
|
117
|
+
|
|
118
|
+
const width = Math.max(this.viewport.clientWidth, 1);
|
|
119
|
+
const height = Math.max(this.viewport.clientHeight, 1);
|
|
120
|
+
|
|
121
|
+
this.camera.aspect = width / height;
|
|
122
|
+
this.camera.updateProjectionMatrix();
|
|
123
|
+
this.renderer.setSize(width, height, false);
|
|
124
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private getModelBounds() {
|
|
128
|
+
if (!this.previewModel?.object) {
|
|
129
|
+
return _box.makeEmpty();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.frameClipName) {
|
|
133
|
+
return this.previewModel.getAnimatedBounds(this.frameClipName);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_box.setFromObject(this.previewModel.object);
|
|
137
|
+
return _box;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private frameInitialView() {
|
|
141
|
+
if (!this.camera || !this.controls) return;
|
|
142
|
+
|
|
143
|
+
frameObjectInView({
|
|
144
|
+
camera: this.camera,
|
|
145
|
+
controls: this.controls,
|
|
146
|
+
getBounds: () => this.getModelBounds(),
|
|
147
|
+
fov: this.fov,
|
|
148
|
+
aspect: this.camera.aspect,
|
|
149
|
+
padding: this.padding,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setClipName(clipName: string) {
|
|
154
|
+
this.frameClipName = clipName;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
isReady() {
|
|
158
|
+
return Boolean(this.previewModel?.isReady());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getAnimationClips() {
|
|
162
|
+
return this.sourceModel?.getAnimationClips() ?? [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
play(clipName: string) {
|
|
166
|
+
if (!this.previewModel) return false;
|
|
167
|
+
|
|
168
|
+
this.frameClipName = clipName;
|
|
169
|
+
this.previewModel.playAnimation(clipName, { loop: true });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pause() {
|
|
174
|
+
this.previewModel?.pauseAnimation();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stop() {
|
|
178
|
+
if (!this.previewModel) return;
|
|
179
|
+
|
|
180
|
+
this.previewModel.stopAnimation();
|
|
181
|
+
this.previewModel.resetPose();
|
|
182
|
+
this.render();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private startLoop() {
|
|
186
|
+
this.stopLoop();
|
|
187
|
+
this.lastTime = performance.now();
|
|
188
|
+
|
|
189
|
+
const tick = (now: number) => {
|
|
190
|
+
this.rafId = requestAnimationFrame(tick);
|
|
191
|
+
const delta = Math.min((now - this.lastTime) / 1000, 0.1);
|
|
192
|
+
this.lastTime = now;
|
|
193
|
+
this.update(delta);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
this.rafId = requestAnimationFrame(tick);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private stopLoop() {
|
|
200
|
+
if (this.rafId !== null) {
|
|
201
|
+
cancelAnimationFrame(this.rafId);
|
|
202
|
+
this.rafId = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
update(delta: number) {
|
|
207
|
+
if (!this.previewModel) return;
|
|
208
|
+
|
|
209
|
+
this.previewModel.update(delta);
|
|
210
|
+
this.render();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
render() {
|
|
214
|
+
if (!this.renderer || !this.scene || !this.camera) return;
|
|
215
|
+
|
|
216
|
+
const height = Math.max(this.viewport.clientHeight, 1);
|
|
217
|
+
|
|
218
|
+
this.controls?.update();
|
|
219
|
+
this.viewGrid.update(height);
|
|
220
|
+
this.worldAxes.update();
|
|
221
|
+
this.renderer.render(this.scene, this.camera);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
dispose() {
|
|
225
|
+
this.stop();
|
|
226
|
+
this.stopLoop();
|
|
227
|
+
|
|
228
|
+
this.resizeObserver?.disconnect();
|
|
229
|
+
this.resizeObserver = null;
|
|
230
|
+
|
|
231
|
+
this.controls?.dispose();
|
|
232
|
+
this.controls = null;
|
|
233
|
+
|
|
234
|
+
if (this.viewGrid.grid) {
|
|
235
|
+
this.viewGrid.grid.geometry?.dispose();
|
|
236
|
+
(this.viewGrid.grid.material as { dispose?: () => void })?.dispose?.();
|
|
237
|
+
this.viewGrid.grid = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.worldAxes.lines.forEach((line) => {
|
|
241
|
+
line.geometry?.dispose();
|
|
242
|
+
(line.material as { dispose?: () => void })?.dispose?.();
|
|
243
|
+
});
|
|
244
|
+
this.worldAxes.lines = [];
|
|
245
|
+
this.worldAxes.axes = null;
|
|
246
|
+
|
|
247
|
+
this.renderer?.dispose();
|
|
248
|
+
this.renderer = null;
|
|
249
|
+
|
|
250
|
+
this.environmentHandle?.dispose();
|
|
251
|
+
this.environmentHandle = null;
|
|
252
|
+
|
|
253
|
+
this.previewModel = null;
|
|
254
|
+
this.sourceModel = null;
|
|
255
|
+
this.scene = null;
|
|
256
|
+
this.camera = null;
|
|
257
|
+
this.frameClipName = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnimationMixer,
|
|
3
|
+
Box3,
|
|
4
|
+
LoopOnce,
|
|
5
|
+
LoopRepeat,
|
|
6
|
+
type AnimationAction,
|
|
7
|
+
type AnimationClip,
|
|
8
|
+
type Object3D,
|
|
9
|
+
} from 'three';
|
|
10
|
+
import { clone as cloneSkinnedScene } from 'three/addons/utils/SkeletonUtils.js';
|
|
11
|
+
|
|
12
|
+
import { HELPER_OBJECT_TYPE } from './constants.js';
|
|
13
|
+
import { formatAnimationClipName } from './animation/format.js';
|
|
14
|
+
import type { AnimationClipInfo, PreviewModelSource } from './types.js';
|
|
15
|
+
|
|
16
|
+
function cloneObjectTree(object: Object3D) {
|
|
17
|
+
const savedUserData: Array<{ child: Object3D; backup: Record<string, unknown> }> = [];
|
|
18
|
+
const detachedHelpers: Array<{ child: Object3D; parent: Object3D }> = [];
|
|
19
|
+
|
|
20
|
+
object.traverse((child) => {
|
|
21
|
+
const backup: Record<string, unknown> = {};
|
|
22
|
+
if (child.userData?.sceneMesh) {
|
|
23
|
+
backup.sceneMesh = child.userData.sceneMesh;
|
|
24
|
+
delete child.userData.sceneMesh;
|
|
25
|
+
}
|
|
26
|
+
if (child.userData?.root) {
|
|
27
|
+
backup.root = child.userData.root;
|
|
28
|
+
delete child.userData.root;
|
|
29
|
+
}
|
|
30
|
+
if (Object.keys(backup).length > 0) {
|
|
31
|
+
savedUserData.push({ child, backup });
|
|
32
|
+
}
|
|
33
|
+
if (child.userData?.objectType === HELPER_OBJECT_TYPE && child.parent) {
|
|
34
|
+
detachedHelpers.push({ child, parent: child.parent });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
detachedHelpers.forEach(({ child, parent }) => {
|
|
39
|
+
parent.remove(child);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const cloned = cloneSkinnedScene(object);
|
|
43
|
+
|
|
44
|
+
detachedHelpers.forEach(({ child, parent }) => {
|
|
45
|
+
parent.add(child);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
savedUserData.forEach(({ child, backup }) => {
|
|
49
|
+
Object.assign(child.userData, backup);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return cloned;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function removeHelperNodes(object: Object3D) {
|
|
56
|
+
const helpers: Object3D[] = [];
|
|
57
|
+
object.traverse((child) => {
|
|
58
|
+
if (child.userData?.objectType === HELPER_OBJECT_TYPE) {
|
|
59
|
+
helpers.push(child);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
helpers.forEach((child) => {
|
|
63
|
+
child.parent?.remove(child);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Loaded 3D model with animation playback for preview viewports and the editor. */
|
|
68
|
+
export class PreviewModel implements PreviewModelSource {
|
|
69
|
+
object: Object3D | null = null;
|
|
70
|
+
animations: AnimationClip[] = [];
|
|
71
|
+
mixer: AnimationMixer | null = null;
|
|
72
|
+
activeAction: AnimationAction | null = null;
|
|
73
|
+
|
|
74
|
+
/** When true, {@link update} is skipped (timeline-driven models in the editor). */
|
|
75
|
+
timelineControlled = false;
|
|
76
|
+
|
|
77
|
+
static fromLoadedScene(object: Object3D, animations: AnimationClip[] = []) {
|
|
78
|
+
const model = new PreviewModel();
|
|
79
|
+
model.object = object;
|
|
80
|
+
model.animations = animations;
|
|
81
|
+
model.resetPose();
|
|
82
|
+
|
|
83
|
+
if (model.animations.length > 0) {
|
|
84
|
+
model.mixer = new AnimationMixer(model.object);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return model;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
isReady() {
|
|
91
|
+
return Boolean(this.mixer);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
resetPose() {
|
|
95
|
+
if (!this.object) return;
|
|
96
|
+
|
|
97
|
+
this.object.traverse((child) => {
|
|
98
|
+
const skinned = child as Object3D & { isSkinnedMesh?: boolean; skeleton?: { pose(): void } };
|
|
99
|
+
if (skinned.isSkinnedMesh && skinned.skeleton) {
|
|
100
|
+
skinned.skeleton.pose();
|
|
101
|
+
child.updateMatrixWorld(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
hasAnimations() {
|
|
107
|
+
return this.animations.length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getAnimationClips(): AnimationClipInfo[] {
|
|
111
|
+
return this.animations.map((clip, index) => ({
|
|
112
|
+
name: formatAnimationClipName(clip.name, index),
|
|
113
|
+
duration: clip.duration,
|
|
114
|
+
clip,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
findAnimationClip(clipName: string) {
|
|
119
|
+
return (
|
|
120
|
+
this.animations.find((item, index) => {
|
|
121
|
+
const name = formatAnimationClipName(item.name, index);
|
|
122
|
+
return name === clipName;
|
|
123
|
+
}) ||
|
|
124
|
+
this.animations[0] ||
|
|
125
|
+
null
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getAnimatedBounds(clipName: string, sampleCount = 16) {
|
|
130
|
+
const box = new Box3();
|
|
131
|
+
if (!this.object) return box;
|
|
132
|
+
|
|
133
|
+
const clip = this.findAnimationClip(clipName);
|
|
134
|
+
if (!clip || !this.mixer) {
|
|
135
|
+
box.setFromObject(this.object);
|
|
136
|
+
return box;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const action = this.mixer.clipAction(clip);
|
|
140
|
+
action.play();
|
|
141
|
+
action.paused = true;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i <= sampleCount; i++) {
|
|
144
|
+
action.time = (i / sampleCount) * clip.duration;
|
|
145
|
+
this.mixer.update(0);
|
|
146
|
+
this.object.traverse((child) => {
|
|
147
|
+
const skinned = child as Object3D & { isSkinnedMesh?: boolean; skeleton?: { update(): void } };
|
|
148
|
+
if (skinned.isSkinnedMesh && skinned.skeleton) {
|
|
149
|
+
skinned.skeleton.update();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
this.object.updateMatrixWorld(true);
|
|
153
|
+
box.expandByObject(this.object);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
action.stop();
|
|
157
|
+
this.mixer.stopAllAction();
|
|
158
|
+
this.resetPose();
|
|
159
|
+
|
|
160
|
+
return box;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
playAnimation(clipName: string, { loop = true } = {}) {
|
|
164
|
+
if (!this.mixer || this.animations.length === 0) return false;
|
|
165
|
+
|
|
166
|
+
this.stopAnimation();
|
|
167
|
+
|
|
168
|
+
const clip = this.findAnimationClip(clipName);
|
|
169
|
+
if (!clip) return false;
|
|
170
|
+
|
|
171
|
+
this.activeAction = this.mixer.clipAction(clip);
|
|
172
|
+
this.activeAction.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Infinity : 1);
|
|
173
|
+
this.activeAction.clampWhenFinished = !loop;
|
|
174
|
+
this.activeAction.reset().play();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
pauseAnimation() {
|
|
179
|
+
if (this.activeAction) {
|
|
180
|
+
this.activeAction.paused = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
resumeAnimation() {
|
|
185
|
+
if (this.activeAction) {
|
|
186
|
+
this.activeAction.paused = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
stopAnimation() {
|
|
191
|
+
if (this.mixer) {
|
|
192
|
+
this.mixer.stopAllAction();
|
|
193
|
+
}
|
|
194
|
+
this.activeAction = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
isAnimationPlaying() {
|
|
198
|
+
return Boolean(this.activeAction && !this.activeAction.paused);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
update(delta: number) {
|
|
202
|
+
if (!this.mixer || this.timelineControlled) return;
|
|
203
|
+
|
|
204
|
+
this.mixer.update(delta);
|
|
205
|
+
this._updateSkeletons();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
createPreviewInstance() {
|
|
209
|
+
if (!this.object) {
|
|
210
|
+
throw new Error('Cannot create preview instance without a loaded model');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const instance = new PreviewModel();
|
|
214
|
+
instance.object = cloneObjectTree(this.object);
|
|
215
|
+
removeHelperNodes(instance.object);
|
|
216
|
+
|
|
217
|
+
instance.object.position.set(0, 0, 0);
|
|
218
|
+
instance.object.rotation.set(0, 0, 0);
|
|
219
|
+
instance.object.scale.set(1, 1, 1);
|
|
220
|
+
instance.object.updateMatrixWorld(true);
|
|
221
|
+
|
|
222
|
+
instance.animations = this.animations;
|
|
223
|
+
instance.resetPose();
|
|
224
|
+
|
|
225
|
+
if (instance.animations.length > 0) {
|
|
226
|
+
instance.mixer = new AnimationMixer(instance.object);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return instance;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private _updateSkeletons() {
|
|
233
|
+
this.object?.traverse((child) => {
|
|
234
|
+
const skinned = child as Object3D & { isSkinnedMesh?: boolean; skeleton?: { update(): void } };
|
|
235
|
+
if (skinned.isSkinnedMesh && skinned.skeleton) {
|
|
236
|
+
skinned.skeleton.update();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export { cloneObjectTree, removeHelperNodes };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function formatAnimationClipName(name: string, index: number): string {
|
|
2
|
+
return name.trim() || `Animation ${index + 1}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatAnimationDuration(seconds: number): string {
|
|
6
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '—';
|
|
7
|
+
return `${seconds.toFixed(2)} 秒`;
|
|
8
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Marker for helper objects (grid, axes) excluded from model clones; matches UEditor OBJECT_TYPE.HELPER. */
|
|
2
|
+
export const HELPER_OBJECT_TYPE = 0;
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_VIEW_FOV = 45;
|
|
5
|
+
|
|
6
|
+
export const AXIS_X_COLOR = 0xff3653;
|
|
7
|
+
export const AXIS_Y_COLOR = 0x7ecb50;
|
|
8
|
+
export const AXIS_Z_COLOR = 0x4da6ff;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Material, Object3D } from 'three';
|
|
2
|
+
|
|
3
|
+
function disposeMaterial(material: Material) {
|
|
4
|
+
for (const value of Object.values(material)) {
|
|
5
|
+
if (value && typeof value === 'object' && 'isTexture' in value && (value as { isTexture?: boolean }).isTexture) {
|
|
6
|
+
(value as { dispose(): void }).dispose();
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
material.dispose();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Release geometry / material / texture resources on an object subtree. */
|
|
13
|
+
export function disposeObject3D(object: Object3D | null | undefined) {
|
|
14
|
+
if (!object) return;
|
|
15
|
+
|
|
16
|
+
object.traverse((child) => {
|
|
17
|
+
const mesh = child as Object3D & { geometry?: { dispose(): void }; material?: Material | Material[] };
|
|
18
|
+
mesh.geometry?.dispose();
|
|
19
|
+
|
|
20
|
+
const materials = Array.isArray(mesh.material)
|
|
21
|
+
? mesh.material
|
|
22
|
+
: mesh.material
|
|
23
|
+
? [mesh.material]
|
|
24
|
+
: [];
|
|
25
|
+
|
|
26
|
+
materials.forEach(disposeMaterial);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { WebIO } from '@gltf-transform/core';
|
|
2
|
+
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
3
|
+
import { metalRough } from '@gltf-transform/functions';
|
|
4
|
+
|
|
5
|
+
const GLTF_MAGIC = 0x46546c67;
|
|
6
|
+
const SPEC_GLOSS_EXTENSION = 'KHR_materials_pbrSpecularGlossiness';
|
|
7
|
+
|
|
8
|
+
let ioInstance: WebIO | null = null;
|
|
9
|
+
|
|
10
|
+
function getIo() {
|
|
11
|
+
if (!ioInstance) {
|
|
12
|
+
ioInstance = new WebIO().registerExtensions(ALL_EXTENSIONS);
|
|
13
|
+
}
|
|
14
|
+
return ioInstance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parse GLB JSON chunk to detect Spec/Gloss materials that need conversion. */
|
|
18
|
+
export function glbNeedsSpecGlossConversion(arrayBuffer: ArrayBuffer): boolean {
|
|
19
|
+
if (!(arrayBuffer instanceof ArrayBuffer) || arrayBuffer.byteLength < 20) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const view = new DataView(arrayBuffer);
|
|
24
|
+
if (view.getUint32(0, true) !== GLTF_MAGIC) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const jsonChunkLength = view.getUint32(12, true);
|
|
29
|
+
if (jsonChunkLength <= 0 || 20 + jsonChunkLength > arrayBuffer.byteLength) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const jsonBytes = new Uint8Array(arrayBuffer, 20, jsonChunkLength);
|
|
35
|
+
const json = JSON.parse(new TextDecoder().decode(jsonBytes)) as {
|
|
36
|
+
extensionsRequired?: string[];
|
|
37
|
+
extensionsUsed?: string[];
|
|
38
|
+
materials?: Array<{ extensions?: Record<string, unknown> }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (json.extensionsRequired?.includes(SPEC_GLOSS_EXTENSION)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!json.extensionsUsed?.includes(SPEC_GLOSS_EXTENSION)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return json.materials?.some((material) => material.extensions?.[SPEC_GLOSS_EXTENSION]) ?? false;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Convert Spec/Gloss workflow GLB to Metal/Rough for Three.js. */
|
|
56
|
+
export async function convertGlbToMetalRough(arrayBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
|
57
|
+
const io = getIo();
|
|
58
|
+
const document = await io.readBinary(new Uint8Array(arrayBuffer));
|
|
59
|
+
await document.transform(metalRough());
|
|
60
|
+
const converted = await io.writeBinary(document);
|
|
61
|
+
return converted.buffer.slice(converted.byteOffset, converted.byteOffset + converted.byteLength);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Preprocess GLB before load: convert materials only when needed. */
|
|
65
|
+
export async function prepareGlbArrayBuffer(arrayBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
|
66
|
+
if (!glbNeedsSpecGlossConversion(arrayBuffer)) {
|
|
67
|
+
return arrayBuffer;
|
|
68
|
+
}
|
|
69
|
+
return convertGlbToMetalRough(arrayBuffer);
|
|
70
|
+
}
|