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/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export { PreviewModel, cloneObjectTree, removeHelperNodes } from './PreviewModel.js';
|
|
2
|
+
export { ModelPreviewViewport, type ModelPreviewViewportOptions } from './ModelPreviewViewport.js';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
ModelLoaderRegistry,
|
|
6
|
+
defaultModelLoaderRegistry,
|
|
7
|
+
getFileExtension,
|
|
8
|
+
isPreviewableModelFile,
|
|
9
|
+
loadModel,
|
|
10
|
+
loadModelFromFile,
|
|
11
|
+
} from './loaders/registry.js';
|
|
12
|
+
|
|
13
|
+
export { glbLoader, loadGlbBuffer } from './loaders/glbLoader.js';
|
|
14
|
+
export { fbxLoader, loadFbxBuffer } from './loaders/fbxLoader.js';
|
|
15
|
+
export { objLoader, loadObjBuffer } from './loaders/objLoader.js';
|
|
16
|
+
export type { ModelLoadOptions, ModelLoader } from './loaders/types.js';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
DEFAULT_VIEW_FOV,
|
|
20
|
+
HELPER_OBJECT_TYPE,
|
|
21
|
+
AXIS_X_COLOR,
|
|
22
|
+
AXIS_Y_COLOR,
|
|
23
|
+
AXIS_Z_COLOR,
|
|
24
|
+
} from './constants.js';
|
|
25
|
+
|
|
26
|
+
export type { AnimationClipInfo, PreviewModelSource } from './types.js';
|
|
27
|
+
|
|
28
|
+
export { formatAnimationClipName, formatAnimationDuration } from './animation/format.js';
|
|
29
|
+
|
|
30
|
+
export { disposeObject3D } from './dispose/disposeObject3D.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
glbNeedsSpecGlossConversion,
|
|
34
|
+
convertGlbToMetalRough,
|
|
35
|
+
prepareGlbArrayBuffer,
|
|
36
|
+
} from './gltf/metalRoughConverter.js';
|
|
37
|
+
|
|
38
|
+
export { CustomControls, MOUSE } from './viewport/CustomControls.js';
|
|
39
|
+
export { ViewGrid } from './viewport/ViewGrid.js';
|
|
40
|
+
export { WorldAxes } from './viewport/WorldAxes.js';
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
DEFAULT_TONE_MAPPING_EXPOSURE,
|
|
44
|
+
configureRenderer,
|
|
45
|
+
createRoomEnvironmentMap,
|
|
46
|
+
applySceneEnvironment,
|
|
47
|
+
} from './viewport/renderQuality.js';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
degToRad,
|
|
51
|
+
computeCameraPlacement,
|
|
52
|
+
getViewHeightAtDistance,
|
|
53
|
+
getViewAspect,
|
|
54
|
+
createViewportBackground,
|
|
55
|
+
frameObjectInView,
|
|
56
|
+
type ViewCamera,
|
|
57
|
+
} from './viewport/ViewportUtils.js';
|
|
58
|
+
|
|
59
|
+
export { captureModelThumbnailBlob } from './thumbnail/captureModelThumbnail.js';
|
|
60
|
+
export type { CaptureModelThumbnailOptions } from './thumbnail/captureModelThumbnail.js';
|
|
61
|
+
export {
|
|
62
|
+
assertThumbnailBlobSize,
|
|
63
|
+
assertThumbnailHasVisibleContent,
|
|
64
|
+
countNonBackgroundPixels,
|
|
65
|
+
MIN_THUMBNAIL_BYTES,
|
|
66
|
+
MIN_VISIBLE_THUMBNAIL_PIXELS,
|
|
67
|
+
} from './thumbnail/thumbnailValidation.js';
|
|
68
|
+
|
|
69
|
+
/** @deprecated Use {@link PreviewModel} */
|
|
70
|
+
export { PreviewModel as GlbModel } from './PreviewModel.js';
|
|
71
|
+
|
|
72
|
+
/** @deprecated Use {@link loadModel} or {@link loadGlbBuffer} */
|
|
73
|
+
export { loadGlbBuffer as loadGlbModel } from './loaders/glbLoader.js';
|
|
74
|
+
|
|
75
|
+
/** @deprecated Use {@link loadModel} */
|
|
76
|
+
export const GlbLoader = {
|
|
77
|
+
load: (arrayBuffer: ArrayBuffer, options: import('./loaders/types.js').ModelLoadOptions = {}) =>
|
|
78
|
+
import('./loaders/glbLoader.js').then(({ loadGlbBuffer }) => loadGlbBuffer(arrayBuffer, options)),
|
|
79
|
+
loadFromFile: async (file: File, options: import('./loaders/types.js').ModelLoadOptions = {}) => {
|
|
80
|
+
const { loadGlbBuffer } = await import('./loaders/glbLoader.js');
|
|
81
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
82
|
+
return loadGlbBuffer(arrayBuffer, {
|
|
83
|
+
...options,
|
|
84
|
+
filename: file.name,
|
|
85
|
+
name: options.name || file.name.replace(/\.[^.]+$/, ''),
|
|
86
|
+
mimeType: file.type || 'application/octet-stream',
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
|
|
2
|
+
import type { AnimationClip, Object3D } from 'three';
|
|
3
|
+
|
|
4
|
+
import { PreviewModel } from '../PreviewModel.js';
|
|
5
|
+
import type { ModelLoadOptions, ModelLoader } from './types.js';
|
|
6
|
+
|
|
7
|
+
function collectAnimations(object: Object3D): AnimationClip[] {
|
|
8
|
+
const clips: AnimationClip[] = [];
|
|
9
|
+
object.traverse((node) => {
|
|
10
|
+
const nodeClips = (node as Object3D & { animations?: AnimationClip[] }).animations;
|
|
11
|
+
if (nodeClips?.length) {
|
|
12
|
+
clips.push(...nodeClips);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
return clips;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function applyModelName(model: PreviewModel, options: ModelLoadOptions) {
|
|
19
|
+
const baseName = (options.name || (options.filename || '').replace(/\.[^.]+$/, '') || '').trim();
|
|
20
|
+
if (baseName && model.object) {
|
|
21
|
+
model.object.name = baseName;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function loadFbxBuffer(arrayBuffer: ArrayBuffer, options: ModelLoadOptions): Promise<PreviewModel> {
|
|
26
|
+
const loader = new FBXLoader();
|
|
27
|
+
|
|
28
|
+
if (options.resourceBaseUrl) {
|
|
29
|
+
loader.setResourcePath(options.resourceBaseUrl);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const blob = new Blob([arrayBuffer], {
|
|
33
|
+
type: options.mimeType || 'application/octet-stream',
|
|
34
|
+
});
|
|
35
|
+
const url = URL.createObjectURL(blob);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const object = await loader.loadAsync(url);
|
|
39
|
+
const animations = collectAnimations(object);
|
|
40
|
+
const model = PreviewModel.fromLoadedScene(object, animations);
|
|
41
|
+
applyModelName(model, options);
|
|
42
|
+
return model;
|
|
43
|
+
} finally {
|
|
44
|
+
URL.revokeObjectURL(url);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const fbxLoader: ModelLoader = {
|
|
49
|
+
extensions: ['.fbx'],
|
|
50
|
+
load: loadFbxBuffer,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export { loadFbxBuffer };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
2
|
+
|
|
3
|
+
import { PreviewModel } from '../PreviewModel.js';
|
|
4
|
+
import { prepareGlbArrayBuffer } from '../gltf/metalRoughConverter.js';
|
|
5
|
+
import type { ModelLoadOptions, ModelLoader } from './types.js';
|
|
6
|
+
|
|
7
|
+
function applyModelName(model: PreviewModel, options: ModelLoadOptions) {
|
|
8
|
+
const baseName = (options.name || (options.filename || '').replace(/\.[^.]+$/, '') || '').trim();
|
|
9
|
+
if (baseName && model.object) {
|
|
10
|
+
model.object.name = baseName;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function loadGlbBuffer(arrayBuffer: ArrayBuffer, options: ModelLoadOptions): Promise<PreviewModel> {
|
|
15
|
+
const preparedBuffer = await prepareGlbArrayBuffer(arrayBuffer);
|
|
16
|
+
|
|
17
|
+
const loader = new GLTFLoader();
|
|
18
|
+
const blob = new Blob([preparedBuffer], {
|
|
19
|
+
type: options.mimeType || 'application/octet-stream',
|
|
20
|
+
});
|
|
21
|
+
const url = URL.createObjectURL(blob);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const gltf = await loader.loadAsync(url);
|
|
25
|
+
const model = PreviewModel.fromLoadedScene(gltf.scene, gltf.animations || []);
|
|
26
|
+
applyModelName(model, options);
|
|
27
|
+
return model;
|
|
28
|
+
} finally {
|
|
29
|
+
URL.revokeObjectURL(url);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const glbLoader: ModelLoader = {
|
|
34
|
+
extensions: ['.glb', '.gltf'],
|
|
35
|
+
mimeTypes: ['model/gltf-binary', 'model/gltf+json'],
|
|
36
|
+
load: loadGlbBuffer,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { loadGlbBuffer };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
|
|
2
|
+
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
|
3
|
+
|
|
4
|
+
import { PreviewModel } from '../PreviewModel.js';
|
|
5
|
+
import type { ModelLoadOptions, ModelLoader } from './types.js';
|
|
6
|
+
|
|
7
|
+
function applyModelName(model: PreviewModel, options: ModelLoadOptions) {
|
|
8
|
+
const baseName = (options.name || (options.filename || '').replace(/\.[^.]+$/, '') || '').trim();
|
|
9
|
+
if (baseName && model.object) {
|
|
10
|
+
model.object.name = baseName;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function objMaterialBasename(filename?: string): string | null {
|
|
15
|
+
const base = (filename || '').replace(/\.[^.]+$/, '').trim();
|
|
16
|
+
return base || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function tryLoadMtlMaterials(
|
|
20
|
+
objLoader: OBJLoader,
|
|
21
|
+
options: ModelLoadOptions,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (!options.resourceBaseUrl) return;
|
|
24
|
+
|
|
25
|
+
const basename = objMaterialBasename(options.filename);
|
|
26
|
+
if (!basename) return;
|
|
27
|
+
|
|
28
|
+
const mtlLoader = new MTLLoader();
|
|
29
|
+
mtlLoader.setResourcePath(options.resourceBaseUrl);
|
|
30
|
+
|
|
31
|
+
const mtlUrl = `${options.resourceBaseUrl}${basename}.mtl`;
|
|
32
|
+
const materials = await mtlLoader.loadAsync(mtlUrl);
|
|
33
|
+
materials.preload();
|
|
34
|
+
objLoader.setMaterials(materials);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loadObjBuffer(arrayBuffer: ArrayBuffer, options: ModelLoadOptions): Promise<PreviewModel> {
|
|
38
|
+
const objLoader = new OBJLoader();
|
|
39
|
+
|
|
40
|
+
if (options.resourceBaseUrl) {
|
|
41
|
+
objLoader.setResourcePath(options.resourceBaseUrl);
|
|
42
|
+
try {
|
|
43
|
+
await tryLoadMtlMaterials(objLoader, options);
|
|
44
|
+
} catch {
|
|
45
|
+
// Companion MTL is optional; geometry preview still works without it.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const blob = new Blob([arrayBuffer], {
|
|
50
|
+
type: options.mimeType || 'text/plain',
|
|
51
|
+
});
|
|
52
|
+
const url = URL.createObjectURL(blob);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const object = await objLoader.loadAsync(url);
|
|
56
|
+
const model = PreviewModel.fromLoadedScene(object, []);
|
|
57
|
+
applyModelName(model, options);
|
|
58
|
+
return model;
|
|
59
|
+
} finally {
|
|
60
|
+
URL.revokeObjectURL(url);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const objLoader: ModelLoader = {
|
|
65
|
+
extensions: ['.obj'],
|
|
66
|
+
mimeTypes: ['model/obj'],
|
|
67
|
+
load: loadObjBuffer,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export { loadObjBuffer };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getModelFileExtension as getFileExtension,
|
|
3
|
+
isPreviewableModelFile,
|
|
4
|
+
} from 'model-file-types';
|
|
5
|
+
import { fbxLoader } from './fbxLoader.js';
|
|
6
|
+
import { glbLoader } from './glbLoader.js';
|
|
7
|
+
import { objLoader } from './objLoader.js';
|
|
8
|
+
import type { ModelLoadOptions, ModelLoader } from './types.js';
|
|
9
|
+
import type { PreviewModel } from '../PreviewModel.js';
|
|
10
|
+
|
|
11
|
+
export { getFileExtension, isPreviewableModelFile };
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LOADERS: ModelLoader[] = [glbLoader, fbxLoader, objLoader];
|
|
14
|
+
|
|
15
|
+
export class ModelLoaderRegistry {
|
|
16
|
+
private loaders: ModelLoader[];
|
|
17
|
+
|
|
18
|
+
constructor(loaders: ModelLoader[] = DEFAULT_LOADERS) {
|
|
19
|
+
this.loaders = [...loaders];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
register(loader: ModelLoader) {
|
|
23
|
+
this.loaders.push(loader);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resolve(filename?: string, mimeType?: string): ModelLoader | null {
|
|
27
|
+
const ext = getFileExtension(filename);
|
|
28
|
+
const mime = (mimeType || '').toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (ext) {
|
|
31
|
+
const byExt = this.loaders.find((loader) => loader.extensions.includes(ext));
|
|
32
|
+
if (byExt) return byExt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (mime) {
|
|
36
|
+
const byMime = this.loaders.find((loader) =>
|
|
37
|
+
loader.mimeTypes?.some((item) => mime.includes(item.replace('*', '')) || mime === item),
|
|
38
|
+
);
|
|
39
|
+
if (byMime) return byMime;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async load(arrayBuffer: ArrayBuffer, options: ModelLoadOptions = {}): Promise<PreviewModel> {
|
|
46
|
+
const loader = this.resolve(options.filename, options.mimeType);
|
|
47
|
+
if (!loader) {
|
|
48
|
+
const hint = options.filename || options.mimeType || 'unknown format';
|
|
49
|
+
throw new Error(`No model loader registered for ${hint}`);
|
|
50
|
+
}
|
|
51
|
+
return loader.load(arrayBuffer, options);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const defaultModelLoaderRegistry = new ModelLoaderRegistry();
|
|
56
|
+
|
|
57
|
+
export async function loadModel(arrayBuffer: ArrayBuffer, options: ModelLoadOptions = {}): Promise<PreviewModel> {
|
|
58
|
+
return defaultModelLoaderRegistry.load(arrayBuffer, options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadModelFromFile(file: File, options: ModelLoadOptions = {}): Promise<PreviewModel> {
|
|
62
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
63
|
+
return loadModel(arrayBuffer, {
|
|
64
|
+
...options,
|
|
65
|
+
filename: file.name,
|
|
66
|
+
name: options.name || file.name.replace(/\.[^.]+$/, ''),
|
|
67
|
+
mimeType: file.type || options.mimeType || 'application/octet-stream',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PreviewModel } from '../PreviewModel.js';
|
|
2
|
+
|
|
3
|
+
export type ModelLoadOptions = {
|
|
4
|
+
name?: string;
|
|
5
|
+
filename?: string;
|
|
6
|
+
mimeType?: string;
|
|
7
|
+
/** Base URL for resolving external textures (FBX/OBJ). */
|
|
8
|
+
resourceBaseUrl?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface ModelLoader {
|
|
12
|
+
readonly extensions: readonly string[];
|
|
13
|
+
readonly mimeTypes?: readonly string[];
|
|
14
|
+
load(source: ArrayBuffer, options: ModelLoadOptions): Promise<PreviewModel>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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 {
|
|
12
|
+
applySceneEnvironment,
|
|
13
|
+
configureRenderer,
|
|
14
|
+
createRoomEnvironmentMap,
|
|
15
|
+
} from '../viewport/renderQuality.js';
|
|
16
|
+
import { createViewportBackground, frameObjectInView } from '../viewport/ViewportUtils.js';
|
|
17
|
+
import { disposeObject3D } from '../dispose/disposeObject3D.js';
|
|
18
|
+
import type { PreviewModelSource } from '../types.js';
|
|
19
|
+
import { assertThumbnailBlobSize, assertThumbnailHasVisibleContent } from './thumbnailValidation.js';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_THUMBNAIL_SIZE = 256;
|
|
22
|
+
const _box = new Box3();
|
|
23
|
+
|
|
24
|
+
export type CaptureModelThumbnailOptions = {
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
padding?: number;
|
|
28
|
+
mimeType?: string;
|
|
29
|
+
quality?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render one WebGL preview frame in the browser and export it as an image blob.
|
|
34
|
+
* Matches the Netdisk / UEditor model preview pipeline (PBR + IBL).
|
|
35
|
+
*/
|
|
36
|
+
export async function captureModelThumbnailBlob(
|
|
37
|
+
sourceModel: PreviewModelSource,
|
|
38
|
+
options: CaptureModelThumbnailOptions = {},
|
|
39
|
+
): Promise<Blob> {
|
|
40
|
+
if (typeof document === 'undefined') {
|
|
41
|
+
throw new Error('captureModelThumbnailBlob requires a browser environment');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const width = options.width ?? DEFAULT_THUMBNAIL_SIZE;
|
|
45
|
+
const height = options.height ?? DEFAULT_THUMBNAIL_SIZE;
|
|
46
|
+
const padding = options.padding ?? 1.8;
|
|
47
|
+
const mimeType = options.mimeType ?? 'image/webp';
|
|
48
|
+
const quality = options.quality ?? 0.9;
|
|
49
|
+
|
|
50
|
+
const canvas = document.createElement('canvas');
|
|
51
|
+
canvas.width = width;
|
|
52
|
+
canvas.height = height;
|
|
53
|
+
|
|
54
|
+
const previewModel = sourceModel.createPreviewInstance();
|
|
55
|
+
const scene = new Scene();
|
|
56
|
+
scene.background = createViewportBackground();
|
|
57
|
+
|
|
58
|
+
const ambient = new AmbientLight(0xffffff, 0.15);
|
|
59
|
+
const directional = new DirectionalLight(0xfff4e0, 2.2);
|
|
60
|
+
directional.position.set(2, 4, 3);
|
|
61
|
+
directional.castShadow = true;
|
|
62
|
+
scene.add(ambient);
|
|
63
|
+
scene.add(directional);
|
|
64
|
+
|
|
65
|
+
if (previewModel.object) {
|
|
66
|
+
scene.add(previewModel.object);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const camera = new PerspectiveCamera(DEFAULT_VIEW_FOV, width / height, 0.01, 1000);
|
|
70
|
+
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false });
|
|
71
|
+
configureRenderer(renderer, { pixelRatio: 1 });
|
|
72
|
+
renderer.setSize(width, height, false);
|
|
73
|
+
|
|
74
|
+
const environmentHandle = createRoomEnvironmentMap(renderer);
|
|
75
|
+
applySceneEnvironment(scene, environmentHandle.envMap, 0.85);
|
|
76
|
+
|
|
77
|
+
frameObjectInView({
|
|
78
|
+
camera,
|
|
79
|
+
controls: null,
|
|
80
|
+
getBounds: () => {
|
|
81
|
+
_box.makeEmpty();
|
|
82
|
+
if (previewModel.object) {
|
|
83
|
+
_box.setFromObject(previewModel.object);
|
|
84
|
+
}
|
|
85
|
+
return _box;
|
|
86
|
+
},
|
|
87
|
+
fov: DEFAULT_VIEW_FOV,
|
|
88
|
+
aspect: width / height,
|
|
89
|
+
padding,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
renderer.render(scene, camera);
|
|
93
|
+
|
|
94
|
+
const sampleCanvas = document.createElement('canvas');
|
|
95
|
+
sampleCanvas.width = width;
|
|
96
|
+
sampleCanvas.height = height;
|
|
97
|
+
const sampleCtx = sampleCanvas.getContext('2d');
|
|
98
|
+
if (!sampleCtx) {
|
|
99
|
+
throw new Error('缩略图校验失败');
|
|
100
|
+
}
|
|
101
|
+
sampleCtx.drawImage(canvas, 0, 0, width, height);
|
|
102
|
+
assertThumbnailHasVisibleContent(sampleCtx, width, height);
|
|
103
|
+
|
|
104
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
105
|
+
canvas.toBlob(
|
|
106
|
+
(result) => {
|
|
107
|
+
if (!result) {
|
|
108
|
+
reject(new Error('缩略图导出失败'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
resolve(result);
|
|
112
|
+
},
|
|
113
|
+
mimeType,
|
|
114
|
+
quality,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
assertThumbnailBlobSize(blob.size);
|
|
119
|
+
|
|
120
|
+
environmentHandle.dispose();
|
|
121
|
+
renderer.dispose();
|
|
122
|
+
if (previewModel.object) {
|
|
123
|
+
disposeObject3D(previewModel.object);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return blob;
|
|
127
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Minimum non-background pixels for a usable model thumbnail. */
|
|
2
|
+
export const MIN_VISIBLE_THUMBNAIL_PIXELS = 120;
|
|
3
|
+
|
|
4
|
+
/** Minimum encoded WebP size (bytes). */
|
|
5
|
+
export const MIN_THUMBNAIL_BYTES = 64;
|
|
6
|
+
|
|
7
|
+
type PixelSampleContext = {
|
|
8
|
+
getImageData(sx: number, sy: number, sw: number, sh: number): { data: Uint8ClampedArray };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Count pixels that differ noticeably from the dark viewport background. */
|
|
12
|
+
export function countNonBackgroundPixels(
|
|
13
|
+
ctx: PixelSampleContext,
|
|
14
|
+
width: number,
|
|
15
|
+
height: number,
|
|
16
|
+
): number {
|
|
17
|
+
const data = ctx.getImageData(0, 0, width, height).data;
|
|
18
|
+
let count = 0;
|
|
19
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
20
|
+
const r = data[i];
|
|
21
|
+
const g = data[i + 1];
|
|
22
|
+
const b = data[i + 2];
|
|
23
|
+
if (r > 90 || g > 90 || b > 90) {
|
|
24
|
+
count += 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return count;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertThumbnailHasVisibleContent(
|
|
31
|
+
ctx: PixelSampleContext,
|
|
32
|
+
width: number,
|
|
33
|
+
height: number,
|
|
34
|
+
): void {
|
|
35
|
+
if (countNonBackgroundPixels(ctx, width, height) < MIN_VISIBLE_THUMBNAIL_PIXELS) {
|
|
36
|
+
throw new Error('缩略图生成失败:模型画面为空');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertThumbnailBlobSize(byteLength: number): void {
|
|
41
|
+
if (byteLength < MIN_THUMBNAIL_BYTES) {
|
|
42
|
+
throw new Error('缩略图生成失败:输出文件无效');
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AnimationClip, Box3, Object3D } from 'three';
|
|
2
|
+
|
|
3
|
+
export type AnimationClipInfo = {
|
|
4
|
+
name: string;
|
|
5
|
+
duration: number;
|
|
6
|
+
clip: AnimationClip;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Contract for models that can be mounted in {@link ModelPreviewViewport}. */
|
|
10
|
+
export interface PreviewModelSource {
|
|
11
|
+
readonly object: Object3D | null;
|
|
12
|
+
createPreviewInstance(): PreviewModelSource;
|
|
13
|
+
getAnimationClips(): AnimationClipInfo[];
|
|
14
|
+
hasAnimations(): boolean;
|
|
15
|
+
playAnimation(clipName: string, options?: { loop?: boolean }): boolean;
|
|
16
|
+
pauseAnimation(): void;
|
|
17
|
+
stopAnimation(): void;
|
|
18
|
+
resetPose(): void;
|
|
19
|
+
update(delta: number): void;
|
|
20
|
+
getAnimatedBounds(clipName: string, sampleCount?: number): Box3;
|
|
21
|
+
isReady(): boolean;
|
|
22
|
+
}
|