diff --git a/web/src/pages/app/modules/models.ts b/web/src/pages/app/modules/models.ts index c55dbfc..4061f5e 100644 --- a/web/src/pages/app/modules/models.ts +++ b/web/src/pages/app/modules/models.ts @@ -7,10 +7,12 @@ import { downloadFileFromUrl } from "../../../utils/fileDownload"; import { escapeHtml, modelNameFromFile } from "../../../utils/format"; import { resolveStorageUrl } from "../../../config/runtime"; import { appState } from "../appState"; +import { generateModelThumbnailFromFile } from "./thumbnail"; type UploadFormState = { file: File | null; files: File[]; + thumbnailFailures: string[]; }; type UploadModelPayload = { @@ -24,6 +26,10 @@ type UploadModelPayload = { properties?: Record; }; +type UploadModelResponse = { + id: number; +}; + const defaultOperationTree = JSON.stringify({ OperationTree: "[]" }); const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL"; const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL"; @@ -470,10 +476,11 @@ export async function uploadModelToBackend(payload: UploadModelPayload) { form.set(`prop.${key}`, value); } - await api("/api/models/upload", { + const result = await api("/api/models/upload", { method: "POST", body: form }); + return result.id; } async function openUploadModelDialog() { @@ -482,7 +489,7 @@ async function openUploadModelDialog() { return; } - const state: UploadFormState = { file: null, files: [] }; + const state: UploadFormState = { file: null, files: [], thumbnailFailures: [] }; await loadDictionaries(); const result = await formDialog({ title: "增加模型", @@ -543,7 +550,7 @@ async function openUploadModelDialog() { fileName: file.name }); await waitForUploadStatusPaint(); - await uploadModelToBackend({ + const modelId = await uploadModelToBackend({ file, name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name), fileName: file.name, @@ -552,6 +559,20 @@ async function openUploadModelDialog() { operationTree: defaultOperationTree, properties }); + const thumbnailMessage = `正在生成预览图 ${index + 1}/${totalCount}:${file.name}`; + updateUploadStatus(thumbnailMessage); + updateUploadProgressOverlay({ + total: totalCount, + completed: completedCount, + message: thumbnailMessage, + fileName: file.name + }); + await waitForUploadStatusPaint(); + try { + await generateAndSaveModelThumbnail(modelId, file); + } catch { + state.thumbnailFailures.push(file.name); + } completedCount += 1; const completedMessage = `已完成 ${completedCount}/${totalCount}`; updateUploadStatus(completedMessage); @@ -576,7 +597,11 @@ async function openUploadModelDialog() { }); if (result) { - notify("模型上传完成"); + if (state.thumbnailFailures.length > 0) { + notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`); + } else { + notify("模型上传和预览图生成完成"); + } invalidateDictionaries(); await reloadFoldersAndModels(); } @@ -592,7 +617,7 @@ export async function openProcessModelUploadDialog(input?: { return; } - const state: UploadFormState = { file: input?.file ?? null, files: input?.file ? [input.file] : [] }; + const state: UploadFormState = { file: input?.file ?? null, files: input?.file ? [input.file] : [], thumbnailFailures: [] }; await loadDictionaries(); const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : ""); const result = await formDialog({ @@ -618,7 +643,7 @@ export async function openProcessModelUploadDialog(input?: { } const form = new FormData(document.querySelector("#processModelUploadPopupForm")!); const name = String(form.get("name") ?? "").trim(); - await uploadModelToBackend({ + const modelId = await uploadModelToBackend({ file: state.file, name, fileName: state.file.name, @@ -629,6 +654,11 @@ export async function openProcessModelUploadDialog(input?: { model: String(form.get("model") ?? "") } }); + try { + await generateAndSaveModelThumbnail(modelId, state.file); + } catch { + notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。"); + } return true; } }); @@ -773,6 +803,14 @@ function hideUploadProgressOverlay() { if (overlay) overlay.hidden = true; } +async function generateAndSaveModelThumbnail(modelId: number, file: File) { + const thumbnail = await generateModelThumbnailFromFile(file); + await api(`/api/models/${modelId}/thumbnail`, { + method: "PUT", + body: JSON.stringify({ thumbnail }) + }); +} + function setUploadDialogBusy(state: UploadFormState, busy: boolean) { const form = document.querySelector("#modelUploadPopupForm"); if (!form) return; diff --git a/web/src/pages/app/modules/thumbnail.ts b/web/src/pages/app/modules/thumbnail.ts new file mode 100644 index 0000000..c01f8af --- /dev/null +++ b/web/src/pages/app/modules/thumbnail.ts @@ -0,0 +1,96 @@ +import type { Material, Object3D, PerspectiveCamera, Texture } from "three"; +import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; + +const THUMBNAIL_WIDTH = 512; +const THUMBNAIL_HEIGHT = 384; + +type ThreeModule = typeof import("three"); +type LoadedGltf = Awaited>; +type ParseableGltfLoader = GLTFLoader & { + parseAsync(data: ArrayBuffer | string, path: string): Promise; +}; + +export async function generateModelThumbnailFromFile(file: File) { + const [THREE, { GLTFLoader }] = await Promise.all([ + import("three"), + import("three/examples/jsm/loaders/GLTFLoader.js") + ]); + + THREE.Object3D.DEFAULT_UP.set(0, 0, 1); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.01, 1000); + camera.up.set(0, 0, 1); + + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + preserveDrawingBuffer: true + }); + renderer.setPixelRatio(1); + renderer.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, false); + renderer.setClearColor(0x000000, 0); + renderer.outputColorSpace = THREE.SRGBColorSpace; + + const ambientLight = new THREE.HemisphereLight(0xffffff, 0x9aa8b5, 2.2); + const keyLight = new THREE.DirectionalLight(0xffffff, 2.8); + keyLight.position.set(3, -4, 5); + scene.add(ambientLight, keyLight); + + let object: Object3D | null = null; + try { + const buffer = await file.arrayBuffer(); + const gltf = await (new GLTFLoader() as ParseableGltfLoader).parseAsync(buffer, ""); + if (!gltf.scene) { + throw new Error("模型文件中未找到可渲染场景"); + } + object = gltf.scene; + scene.add(object); + fitCameraToObject(THREE, camera, object); + renderer.render(scene, camera); + return renderer.domElement.toDataURL("image/png"); + } finally { + if (object) disposeObject3D(object); + renderer.dispose(); + renderer.forceContextLoss(); + renderer.domElement.remove(); + } +} + +function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, object: Object3D) { + const box = new THREE.Box3().setFromObject(object); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const maxSize = Math.max(size.x, size.y, size.z) || 1; + const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360)); + + camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65)); + camera.near = Math.max(distance / 100, 0.01); + camera.far = distance * 100; + camera.lookAt(center); + camera.updateProjectionMatrix(); +} + +function disposeObject3D(root: Object3D) { + root.traverse((object) => { + const maybeMesh = object as Object3D & { + geometry?: { dispose?: () => void }; + material?: Material | Material[]; + }; + maybeMesh.geometry?.dispose?.(); + const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : maybeMesh.material ? [maybeMesh.material] : []; + for (const material of materials) { + disposeMaterial(material); + } + }); +} + +function disposeMaterial(material: Material) { + const values = Object.values(material as Material & Record); + for (const value of values) { + if (value && typeof value === "object" && "isTexture" in value) { + (value as Texture).dispose(); + } + } + material.dispose(); +}