上传模型时自动生成预览图
This commit is contained in:
@@ -7,10 +7,12 @@ import { downloadFileFromUrl } from "../../../utils/fileDownload";
|
|||||||
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
import { escapeHtml, modelNameFromFile } from "../../../utils/format";
|
||||||
import { resolveStorageUrl } from "../../../config/runtime";
|
import { resolveStorageUrl } from "../../../config/runtime";
|
||||||
import { appState } from "../appState";
|
import { appState } from "../appState";
|
||||||
|
import { generateModelThumbnailFromFile } from "./thumbnail";
|
||||||
|
|
||||||
type UploadFormState = {
|
type UploadFormState = {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
files: File[];
|
files: File[];
|
||||||
|
thumbnailFailures: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UploadModelPayload = {
|
type UploadModelPayload = {
|
||||||
@@ -24,6 +26,10 @@ type UploadModelPayload = {
|
|||||||
properties?: Record<string, string>;
|
properties?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UploadModelResponse = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||||
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
|
const requestSceneModelMessageType = "DMT_MODEL_LIBRARY_REQUEST_SCENE_MODEL";
|
||||||
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
|
const sceneModelMessageType = "DMT_MODEL_LIBRARY_SCENE_MODEL";
|
||||||
@@ -470,10 +476,11 @@ export async function uploadModelToBackend(payload: UploadModelPayload) {
|
|||||||
form.set(`prop.${key}`, value);
|
form.set(`prop.${key}`, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
await api("/api/models/upload", {
|
const result = await api<UploadModelResponse>("/api/models/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form
|
body: form
|
||||||
});
|
});
|
||||||
|
return result.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openUploadModelDialog() {
|
async function openUploadModelDialog() {
|
||||||
@@ -482,7 +489,7 @@ async function openUploadModelDialog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: UploadFormState = { file: null, files: [] };
|
const state: UploadFormState = { file: null, files: [], thumbnailFailures: [] };
|
||||||
await loadDictionaries();
|
await loadDictionaries();
|
||||||
const result = await formDialog<boolean>({
|
const result = await formDialog<boolean>({
|
||||||
title: "增加模型",
|
title: "增加模型",
|
||||||
@@ -543,7 +550,7 @@ async function openUploadModelDialog() {
|
|||||||
fileName: file.name
|
fileName: file.name
|
||||||
});
|
});
|
||||||
await waitForUploadStatusPaint();
|
await waitForUploadStatusPaint();
|
||||||
await uploadModelToBackend({
|
const modelId = await uploadModelToBackend({
|
||||||
file,
|
file,
|
||||||
name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name),
|
name: state.files.length === 1 ? name ?? "" : modelNameFromFile(file.name),
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -552,6 +559,20 @@ async function openUploadModelDialog() {
|
|||||||
operationTree: defaultOperationTree,
|
operationTree: defaultOperationTree,
|
||||||
properties
|
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;
|
completedCount += 1;
|
||||||
const completedMessage = `已完成 ${completedCount}/${totalCount}`;
|
const completedMessage = `已完成 ${completedCount}/${totalCount}`;
|
||||||
updateUploadStatus(completedMessage);
|
updateUploadStatus(completedMessage);
|
||||||
@@ -576,7 +597,11 @@ async function openUploadModelDialog() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
notify("模型上传完成");
|
if (state.thumbnailFailures.length > 0) {
|
||||||
|
notify(`模型上传完成,${state.thumbnailFailures.length} 个预览图生成失败,可稍后在预览窗口手动更新。`);
|
||||||
|
} else {
|
||||||
|
notify("模型上传和预览图生成完成");
|
||||||
|
}
|
||||||
invalidateDictionaries();
|
invalidateDictionaries();
|
||||||
await reloadFoldersAndModels();
|
await reloadFoldersAndModels();
|
||||||
}
|
}
|
||||||
@@ -592,7 +617,7 @@ export async function openProcessModelUploadDialog(input?: {
|
|||||||
return;
|
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();
|
await loadDictionaries();
|
||||||
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
|
const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : "");
|
||||||
const result = await formDialog<boolean>({
|
const result = await formDialog<boolean>({
|
||||||
@@ -618,7 +643,7 @@ export async function openProcessModelUploadDialog(input?: {
|
|||||||
}
|
}
|
||||||
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
|
const form = new FormData(document.querySelector<HTMLFormElement>("#processModelUploadPopupForm")!);
|
||||||
const name = String(form.get("name") ?? "").trim();
|
const name = String(form.get("name") ?? "").trim();
|
||||||
await uploadModelToBackend({
|
const modelId = await uploadModelToBackend({
|
||||||
file: state.file,
|
file: state.file,
|
||||||
name,
|
name,
|
||||||
fileName: state.file.name,
|
fileName: state.file.name,
|
||||||
@@ -629,6 +654,11 @@ export async function openProcessModelUploadDialog(input?: {
|
|||||||
model: String(form.get("model") ?? "")
|
model: String(form.get("model") ?? "")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await generateAndSaveModelThumbnail(modelId, state.file);
|
||||||
|
} catch {
|
||||||
|
notify("模型已上传,预览图生成失败,可稍后在预览窗口手动更新。");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -773,6 +803,14 @@ function hideUploadProgressOverlay() {
|
|||||||
if (overlay) overlay.hidden = true;
|
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) {
|
function setUploadDialogBusy(state: UploadFormState, busy: boolean) {
|
||||||
const form = document.querySelector<HTMLFormElement>("#modelUploadPopupForm");
|
const form = document.querySelector<HTMLFormElement>("#modelUploadPopupForm");
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|||||||
96
web/src/pages/app/modules/thumbnail.ts
Normal file
96
web/src/pages/app/modules/thumbnail.ts
Normal file
@@ -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<ReturnType<GLTFLoader["loadAsync"]>>;
|
||||||
|
type ParseableGltfLoader = GLTFLoader & {
|
||||||
|
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
for (const value of values) {
|
||||||
|
if (value && typeof value === "object" && "isTexture" in value) {
|
||||||
|
(value as Texture).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user