diff --git a/server/src/folders.ts b/server/src/folders.ts index a326488..1c7daa4 100644 --- a/server/src/folders.ts +++ b/server/src/folders.ts @@ -37,6 +37,9 @@ function buildFolderNodes(rows: FolderRow[], userInfo: { id: number; username: s ownerUserId: row.owner_user_id, isSystem: Boolean(row.is_system) }, + state: { + opened: row.library_type === "system" && row.parent_id === null + }, children: [] as unknown[] })); } @@ -52,6 +55,9 @@ function buildTree(rows: FolderRow[], userInfo: { id: number; username: string; permissions: { read: true, write: false }, virtual: true }, + state: { + opened: true + }, children: [] as unknown[] }; const byId = new Map(nodes.map((node) => [Number(node.id), node])); diff --git a/server/src/models.ts b/server/src/models.ts index 8a2caf6..279de2c 100644 --- a/server/src/models.ts +++ b/server/src/models.ts @@ -41,6 +41,24 @@ function getProperties(modelId: number) { return Object.fromEntries(rows.map((row) => [row.property_key, row.property_value])); } +function getPropertiesMap(modelIds: number[]) { + const properties = new Map>(); + if (modelIds.length === 0) return properties; + const placeholders = modelIds.map(() => "?").join(","); + const rows = db.prepare(` + SELECT model_id, property_key, property_value + FROM model_properties + WHERE model_id IN (${placeholders}) + `).all(...modelIds) as { model_id: number; property_key: string; property_value: string }[]; + + for (const row of rows) { + const modelProperties = properties.get(row.model_id) ?? {}; + modelProperties[row.property_key] = row.property_value; + properties.set(row.model_id, modelProperties); + } + return properties; +} + function saveProperties(modelId: number, properties: Record) { const stmt = db.prepare(` INSERT INTO model_properties (model_id, property_key, property_value) @@ -67,6 +85,22 @@ function modelPayload(row: ModelRow, userInfo?: { id: number; username: string; }; } +function modelListPayload( + rows: ModelRow[], + userInfo?: { id: number; username: string; role: "admin" | "user" } +) { + const propertiesMap = getPropertiesMap(rows.map((row) => row.id)); + return rows.map((row) => ({ + ...row, + file_url: publicObjectUrl(row.file_path, row.storage_provider), + thumbnail_url: row.thumbnail_path && row.thumbnail_provider + ? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider) + : null, + permissions: userInfo ? modelPermissions(userInfo, row) : { read: true, write: false }, + properties: propertiesMap.get(row.id) ?? {} + })); +} + function dataImageToBuffer(dataUrl: string) { const match = dataUrl.match(/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/); if (!match) { @@ -193,28 +227,32 @@ export async function modelRoutes(app: FastifyInstance) { whereParts.push("m.type_id = ?"); params.push(query.typeId); } - if (query.keyword?.trim()) { + const keyword = query.keyword?.trim() ?? ""; + const hasKeyword = Boolean(keyword); + if (hasKeyword) { whereParts.push("(m.name LIKE ? OR m.original_filename LIKE ? OR mp.property_value LIKE ?)"); - const like = `%${query.keyword.trim()}%`; + const like = `%${keyword}%`; params.push(like, like, like); } const where = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : ""; + const propertyJoin = hasKeyword ? "LEFT JOIN model_properties mp ON mp.model_id = m.id" : ""; + const distinct = hasKeyword ? "DISTINCT" : ""; const totalStmt = db.prepare(` - SELECT COUNT(DISTINCT m.id) as count + SELECT COUNT(${distinct} m.id) as count FROM models m - LEFT JOIN model_properties mp ON mp.model_id = m.id + ${propertyJoin} ${where} `); const total = totalStmt.get(...params) as { count: number }; const listStmt = db.prepare(` - SELECT DISTINCT + SELECT ${distinct} m.*, b.name AS brand_name, t.name AS type_name FROM models m LEFT JOIN brands b ON b.id = m.brand_id LEFT JOIN model_types t ON t.id = m.type_id - LEFT JOIN model_properties mp ON mp.model_id = m.id + ${propertyJoin} ${where} ORDER BY m.updated_at DESC, m.id DESC LIMIT ? OFFSET ? @@ -225,7 +263,7 @@ export async function modelRoutes(app: FastifyInstance) { total: total.count, page: query.page, pageSize: query.pageSize, - items: rows.map((row) => modelPayload(row, request.userInfo)) + items: modelListPayload(rows, request.userInfo) }; }); diff --git a/web/public/DayCityOutdoor.exr b/web/public/DayCityOutdoor.exr new file mode 100644 index 0000000..7b123ef Binary files /dev/null and b/web/public/DayCityOutdoor.exr differ diff --git a/web/src/pages/app/dialogs.css b/web/src/pages/app/dialogs.css index 2cad060..96c9b93 100644 --- a/web/src/pages/app/dialogs.css +++ b/web/src/pages/app/dialogs.css @@ -64,6 +64,42 @@ color: #405469; } +.upload-target-path { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid #dbe3ea; + border-radius: 6px; + background: #f8fafb; +} + +.upload-target-path span { + color: #647586; + font-size: 12px; +} + +.upload-target-path strong { + min-width: 0; + overflow: hidden; + color: #243447; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-popup-form textarea { + width: 100%; + min-height: 140px; + resize: vertical; + border: 1px solid #c9d3dd; + border-radius: 4px; + padding: 7px 8px; + background: #ffffff; + color: #1c2733; + font: 12px/1.5 Consolas, "Microsoft YaHei", monospace; +} + .preview-shell { height: 100%; padding: 0; @@ -240,7 +276,54 @@ inset: 0; display: grid; place-items: center; + z-index: 2; color: #647586; + background: rgba(244, 247, 249, 0.86); +} + +.preview-loading-box { + width: min(280px, calc(100% - 40px)); + display: grid; + gap: 8px; + padding: 14px 16px; + border: 1px solid #d8e0e8; + border-radius: 6px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(28, 39, 51, 0.12); +} + +.preview-loading-box strong { + color: #243447; + font-size: 14px; +} + +.preview-loading-box span { + min-height: 18px; + color: #647586; +} + +.preview-loading-track { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: #e6edf2; +} + +.preview-loading-track i { + display: block; + height: 100%; + min-width: 10px; + border-radius: inherit; + background: #1f6f8b; + transition: width 0.16s ease; +} + +.preview-loading-box.is-error { + border-color: #f0c0b8; +} + +.preview-loading-box.is-error strong { + color: #b42318; } .dictionary-manager { diff --git a/web/src/pages/app/layout.ts b/web/src/pages/app/layout.ts index 29b3470..62713a0 100644 --- a/web/src/pages/app/layout.ts +++ b/web/src/pages/app/layout.ts @@ -37,6 +37,7 @@ export async function renderApp() {
${isAdmin ? `` : ""} ${isAdmin ? `` : ""} +
diff --git a/web/src/pages/app/modules/models.ts b/web/src/pages/app/modules/models.ts index 32b86d4..a101732 100644 --- a/web/src/pages/app/modules/models.ts +++ b/web/src/pages/app/modules/models.ts @@ -10,6 +10,17 @@ type UploadFormState = { file: File | null; }; +type UploadModelPayload = { + file: File; + name: string; + fileName?: string; + folderId?: number; + brandName?: string; + typeName?: string; + operationTree?: string; + properties?: Record; +}; + const defaultOperationTree = JSON.stringify({ OperationTree: "[]" }); export function bindModelActions() { @@ -43,6 +54,14 @@ export function bindModelActions() { } }); + document.querySelector("#addProcessModelBtn")?.addEventListener("click", async () => { + try { + await openProcessModelUploadDialog(); + } catch (error) { + notifyError(error); + } + }); + document.querySelector("#brandFilter")!.addEventListener("change", async (event) => { appState.filters.brandId = (event.currentTarget as HTMLSelectElement).value; appState.page = 1; @@ -120,8 +139,18 @@ export async function loadModels() { button.addEventListener("click", () => editModel(Number(button.dataset.id))); }); grid.querySelectorAll("[data-action='import']").forEach((button) => { - button.addEventListener("click", () => { - notify("导入功能预留,后续接入当前模型的导入逻辑"); + const model = result.items.find((item) => item.id === Number(button.dataset.id)); + if (!model) return; + button.addEventListener("click", async () => { + const { createImportScenePayload, emitImportScenePayload } = await import("./preview"); + const payload = createImportScenePayload({ + model, + basePointEnabled: false, + basePoint: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 }, + selectedOperationIndex: null + }); + emitImportScenePayload(payload); + notify("导入场景数据已输出"); }); }); grid.querySelectorAll("[data-action='delete']").forEach((button) => { @@ -182,7 +211,40 @@ function syncSelectedFolderActions() { const currentFolder = appState.folders.find((folder) => folder.id === appState.selectedFolderId); const canWrite = Boolean(currentFolder?.permissions.write); const addModelBtn = document.querySelector("#addModelBtn"); + const addProcessModelBtn = document.querySelector("#addProcessModelBtn"); if (addModelBtn) addModelBtn.hidden = !canWrite; + if (addProcessModelBtn) addProcessModelBtn.hidden = !canWrite; +} + +export async function uploadModelToBackend(payload: UploadModelPayload) { + const fileName = (payload.fileName || payload.file.name || payload.name).trim(); + const displayName = (payload.name || modelNameFromFile(fileName)).trim(); + if (!displayName) { + throw new Error("模型名称不能为空"); + } + if (!fileName.toLowerCase().endsWith(".glb")) { + throw new Error("当前阶段只允许上传 .glb 模型"); + } + const folderId = payload.folderId ?? appState.selectedFolderId; + if (!folderId) { + throw new Error("请先选择目录"); + } + + const form = new FormData(); + form.set("folderId", String(folderId)); + form.set("name", modelNameFromFile(displayName)); + form.set("brandName", payload.brandName ?? ""); + form.set("typeName", payload.typeName ?? ""); + form.set("operationTree", normalizeOperationTreeInput(payload.operationTree)); + form.set("file", payload.file, fileName); + for (const [key, value] of Object.entries(payload.properties ?? {})) { + form.set(`prop.${key}`, value); + } + + await api("/api/models/upload", { + method: "POST", + body: form + }); } async function openUploadModelDialog() { @@ -232,19 +294,97 @@ async function openUploadModelDialog() { throw new Error("模型名称不能为空"); } const form = new FormData(document.querySelector("#modelUploadPopupForm")!); - const payload = new FormData(); - payload.set("folderId", String(appState.selectedFolderId)); - payload.set("name", name); - payload.set("brandName", String(form.get("brandName") ?? "")); - payload.set("typeName", String(form.get("typeName") ?? "")); - payload.set("operationTree", defaultOperationTree); - payload.set("file", state.file); - for (const key of ["model", "price", "weight"]) { - payload.set(`prop.${key}`, String(form.get(key) ?? "")); + await uploadModelToBackend({ + file: state.file, + name, + fileName: state.file.name, + brandName: String(form.get("brandName") ?? ""), + typeName: String(form.get("typeName") ?? ""), + operationTree: defaultOperationTree, + properties: { + model: String(form.get("model") ?? ""), + price: String(form.get("price") ?? ""), + weight: String(form.get("weight") ?? "") + } + }); + return true; + } + }); + + if (result) { + await loadModels(); + } +} + +export async function openProcessModelUploadDialog(input?: { + file?: File | null; + modelName?: string; + operationTreeJson?: string; +}) { + if (!appState.selectedFolderId) { + notify("请先选择目录"); + return; + } + + const state: UploadFormState = { file: input?.file ?? null }; + await loadDictionaries(); + const initialName = input?.modelName || (input?.file ? modelNameFromFile(input.file.name) : ""); + const result = await formDialog({ + title: "上传工艺模型", + width: 640, + height: 650, + body: ` + + `, + onOpen: () => bindProcessUploadDialogEvents(state), + onSubmit: async () => { + if (!state.file) { + throw new Error("请选择 .glb 模型文件"); } - await api("/api/models/upload", { - method: "POST", - body: payload + const form = new FormData(document.querySelector("#processModelUploadPopupForm")!); + const name = String(form.get("name") ?? "").trim(); + await uploadModelToBackend({ + file: state.file, + name, + fileName: name || state.file.name, + brandName: String(form.get("brandName") ?? ""), + typeName: String(form.get("typeName") ?? ""), + operationTree: String(form.get("operationTree") ?? ""), + properties: { + model: String(form.get("model") ?? ""), + price: String(form.get("price") ?? ""), + weight: String(form.get("weight") ?? "") + } }); return true; } @@ -297,6 +437,83 @@ function bindUploadDialogEvents(state: UploadFormState) { }); } +function bindProcessUploadDialogEvents(state: UploadFormState) { + const dropZone = document.querySelector("#processModelDropZone")!; + const fileInput = document.querySelector("#processModelUploadFile")!; + const nameInput = document.querySelector("#processModelUploadName")!; + const selectedFileName = document.querySelector("#processSelectedFileName")!; + + const selectFile = (file: File) => { + if (!file.name.toLowerCase().endsWith(".glb")) { + notify("当前阶段只允许上传 .glb 模型"); + return; + } + state.file = file; + selectedFileName.textContent = file.name; + if (!nameInput.value.trim()) { + nameInput.value = file.name; + } + }; + + if (state.file) { + selectedFileName.textContent = state.file.name; + if (!nameInput.value.trim()) { + nameInput.value = state.file.name; + } + } + + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (file) selectFile(file); + }); + + dropZone.addEventListener("click", (event) => { + if (event.target !== fileInput) fileInput.click(); + }); + + dropZone.addEventListener("dragover", (event) => { + event.preventDefault(); + dropZone.classList.add("is-dragover"); + }); + dropZone.addEventListener("dragleave", () => { + dropZone.classList.remove("is-dragover"); + }); + dropZone.addEventListener("drop", (event) => { + event.preventDefault(); + dropZone.classList.remove("is-dragover"); + const file = event.dataTransfer?.files?.[0]; + if (file) selectFile(file); + }); +} + +function normalizeOperationTreeInput(value?: string) { + const clean = value?.trim(); + if (!clean) return defaultOperationTree; + let parsed: unknown; + try { + parsed = JSON.parse(clean); + } catch { + throw new Error("工艺数据必须是合法 JSON 字符串"); + } + + if (Array.isArray(parsed)) { + return JSON.stringify({ OperationTree: JSON.stringify(parsed) }); + } + + if (parsed && typeof parsed === "object" && "OperationTree" in parsed) { + const operationTree = (parsed as { OperationTree: unknown }).OperationTree; + if (typeof operationTree === "string") { + JSON.parse(operationTree); + return JSON.stringify({ OperationTree: operationTree }); + } + if (Array.isArray(operationTree)) { + return JSON.stringify({ OperationTree: JSON.stringify(operationTree) }); + } + } + + throw new Error('工艺数据格式必须是 {"OperationTree":"[...]"} 或 OperationTree[] 数组'); +} + async function editModel(id: number) { const card = document.querySelector(`button[data-id="${id}"]`)?.closest(".model-card"); const oldName = card?.querySelector("strong")?.textContent ?? ""; diff --git a/web/src/pages/app/modules/preview.ts b/web/src/pages/app/modules/preview.ts index d02a7c2..b47d885 100644 --- a/web/src/pages/app/modules/preview.ts +++ b/web/src/pages/app/modules/preview.ts @@ -1,7 +1,7 @@ -import * as THREE from "three"; -import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { w2popup } from "w2ui"; +import type { Object3D, PerspectiveCamera, Scene, Texture, WebGLRenderer } from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import type { ModelItem } from "../../../types"; import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree"; import { P_OPERATION } from "../../../types/OPERATION_BaseClass"; @@ -10,24 +10,85 @@ import { notify, notifyError } from "../../../ui/dialogs"; import { api } from "../../../services/api"; type PreviewRuntime = { - renderer: THREE.WebGLRenderer; - scene: THREE.Scene; - camera: THREE.PerspectiveCamera; - controls: OrbitControls; - animationId: number; - resizeObserver: ResizeObserver; + renderer: WebGLRenderer; + scene: Scene; + camera: PerspectiveCamera; + context: PreviewLoadContext; }; -type PreviewOperation = OperationTree & { +type PreviewLoadContext = { + controller: AbortController; + disposed: boolean; + renderer?: WebGLRenderer; + scene?: Scene; + camera?: PerspectiveCamera; + controls?: OrbitControls; + environmentMap?: Texture | null; + modelObject?: Object3D; + animationId?: number; + resizeObserver?: ResizeObserver; +}; + +type ThreeModule = typeof import("three"); +type LoadedGltf = Awaited>; + +type ParseableGltfLoader = GLTFLoader & { + parseAsync(data: ArrayBuffer | string, path: string): Promise; +}; + +export type PreviewBasePoint = { + x?: number; + y?: number; + z?: number; + rx?: number; + ry?: number; + rz?: number; +}; + +export type PreviewResolvedBasePoint = Required; + +export type PreviewOperation = OperationTree & { parsedCraftPlayData: P_OPERATION | null; frameCount: number; parseError: string | null; }; -let runtime: PreviewRuntime | null = null; +export type PreviewImportScenePayload = { + model: ModelItem; + modelUrl: string; + basePointEnabled: boolean; + basePoint: PreviewResolvedBasePoint; + rawOperationTree: string; + operations: PreviewOperation[]; + selectedOperation: PreviewOperation | null; + selectedCraftPlayData: P_OPERATION | null; +}; -export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Promise | void) { - disposePreview(); +export type ModelPreviewOptions = { + onImportScene?: (payload: PreviewImportScenePayload) => Promise | void; +}; + +const zeroBasePoint: PreviewResolvedBasePoint = { + x: 0, + y: 0, + z: 0, + rx: 0, + ry: 0, + rz: 0 +}; + +const previewEnvironmentUrl = "/DayCityOutdoor.exr"; + +let runtime: PreviewRuntime | null = null; +let activePreviewContext: PreviewLoadContext | null = null; +let previewEnvironmentTexturePromise: Promise | null = null; + +export function openModelPreview( + model: ModelItem, + onThumbnailSaved?: () => Promise | void, + options: ModelPreviewOptions = {} +) { + const context = beginPreviewContext(); const canWrite = model.permissions.write; const url = model.file_url; const operations = parseModelOperations(model.operation_tree); @@ -82,7 +143,7 @@ export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Prom
-
模型加载中...
+ ${previewLoadingHtml("准备加载模型...")}
@@ -98,12 +159,63 @@ export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Prom popup.self .on("open:after", () => { bindProcessPlaceholder(operations); + bindImportScene(model, operations, options.onImportScene); if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved); - initPreview(url).catch((error) => notifyError(error)); + requestAnimationFrame(() => { + initPreview(url, context).catch((error) => { + if (!isAbortError(error)) notifyError(error); + }); + }); }) .on("close:after", () => disposePreview()); } +export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true) { + const fieldMap: Array<[keyof PreviewResolvedBasePoint, string]> = [ + ["x", "baseX"], + ["y", "baseY"], + ["z", "baseZ"], + ["rx", "baseRx"], + ["ry", "baseRy"], + ["rz", "baseRz"] + ]; + for (const [key, name] of fieldMap) { + const input = document.querySelector(`.preview-basepoint-grid input[name="${name}"]`); + if (input && basePoint[key] !== undefined) { + input.value = String(basePoint[key]); + } + } + + const checkbox = document.querySelector("#previewUseBasePoint"); + if (checkbox) { + checkbox.checked = enabled; + } +} + +export function createImportScenePayload(options: { + model: ModelItem; + basePointEnabled?: boolean; + basePoint?: PreviewBasePoint; + selectedOperationIndex?: number | null; +}): PreviewImportScenePayload { + const operations = parseModelOperations(options.model.operation_tree); + const selectedOperation = options.selectedOperationIndex === null + ? null + : operations[options.selectedOperationIndex ?? 0] ?? null; + + return buildImportScenePayload({ + model: options.model, + operations, + selectedOperation, + basePointEnabled: options.basePointEnabled ?? false, + basePoint: resolveBasePoint(options.basePoint) + }); +} + +export function emitImportScenePayload(payload: PreviewImportScenePayload) { + window.dispatchEvent(new CustomEvent("dmt:import-scene", { detail: payload })); +} + function parseModelOperations(value: string): PreviewOperation[] { try { const dbValue = JSON.parse(value || "{}") as Partial; @@ -172,26 +284,153 @@ function operationStatusHtml(operation: PreviewOperation | null) { return `已读取 ${operation.frameCount} 帧播放数据`; } +function previewLoadingHtml(text: string) { + return ` +
+
+ 模型加载中 + ${escapeHtml(text)} +
+ +
+
+
+ `; +} + +function updatePreviewLoading(event: ProgressEvent) { + const text = document.querySelector("#previewLoadingText"); + const bar = document.querySelector("#previewLoadingBar"); + if (!text || !bar) return; + + if (event.lengthComputable && event.total > 0) { + const percent = Math.min(100, Math.round((event.loaded / event.total) * 100)); + text.textContent = `${percent}%`; + bar.style.width = `${percent}%`; + return; + } + + text.textContent = `已加载 ${formatBytes(event.loaded)}`; + bar.style.width = "28%"; +} + +function updatePreviewLoadingByCount(loaded: number, total: number) { + const text = document.querySelector("#previewLoadingText"); + const bar = document.querySelector("#previewLoadingBar"); + if (!text || !bar || total <= 0) return; + + const percent = Math.min(100, Math.round((loaded / total) * 100)); + text.textContent = `资源加载 ${loaded}/${total}`; + bar.style.width = `${percent}%`; +} + +function showPreviewLoadingError(message: string) { + const loading = document.querySelector("#previewLoading"); + if (loading) { + loading.innerHTML = `
加载失败${escapeHtml(message)}
`; + } +} + +function hidePreviewLoading() { + document.querySelector("#previewLoading")?.remove(); +} + +function beginPreviewContext() { + disposePreview(); + const context: PreviewLoadContext = { + controller: new AbortController(), + disposed: false + }; + activePreviewContext = context; + return context; +} + +function ensurePreviewContext(context: PreviewLoadContext) { + if (!isPreviewContextActive(context)) { + throw new DOMException("预览已关闭", "AbortError"); + } +} + +function isPreviewContextActive(context: PreviewLoadContext) { + return !context.disposed && activePreviewContext === context && !context.controller.signal.aborted; +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} + +function cleanupPreviewContextResources(context: PreviewLoadContext) { + if (context.animationId !== undefined) { + cancelAnimationFrame(context.animationId); + context.animationId = undefined; + } + context.resizeObserver?.disconnect(); + context.resizeObserver = undefined; + context.controls?.dispose(); + context.controls = undefined; + disposeObject3D(context.modelObject); + context.modelObject = undefined; + context.environmentMap?.dispose(); + context.environmentMap = null; + context.renderer?.dispose(); + context.renderer?.domElement.remove(); + context.renderer = undefined; + if (runtime?.context === context) { + runtime = null; + } +} + +function disposeObject3D(object?: Object3D) { + if (!object) return; + object.traverse((child) => { + const mesh = child as Object3D & { + geometry?: { dispose?: () => void }; + material?: unknown; + }; + mesh.geometry?.dispose?.(); + disposeMaterial(mesh.material); + }); +} + +function disposeMaterial(material: unknown) { + if (Array.isArray(material)) { + material.forEach(disposeMaterial); + return; + } + if (!material || typeof material !== "object") return; + const record = material as Record & { dispose?: () => void }; + for (const value of Object.values(record)) { + if (value && typeof value === "object" && "isTexture" in value && "dispose" in value) { + (value as Texture).dispose(); + } + } + record.dispose?.(); +} + function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise | void) { document.querySelector("#previewCaptureThumbBtn")?.addEventListener("click", async () => { try { if (!runtime) { throw new Error("模型还未加载完成"); } - const transparent = document.querySelector("#previewTransparentThumb")?.checked ?? false; - runtime.controls.update(); - const oldBackground = runtime.scene.background; - const oldClearAlpha = runtime.renderer.getClearAlpha(); - if (transparent) { - runtime.scene.background = null; - runtime.renderer.setClearColor(0x000000, 0); + const { renderer, scene, camera, controls } = runtime.context; + if (!renderer || !scene || !camera || !controls) { + throw new Error("模型还未加载完成"); } - runtime.renderer.render(runtime.scene, runtime.camera); - const thumbnail = runtime.renderer.domElement.toDataURL("image/png"); + const transparent = document.querySelector("#previewTransparentThumb")?.checked ?? false; + controls.update(); + const oldBackground = scene.background; + const oldClearAlpha = renderer.getClearAlpha(); if (transparent) { - runtime.scene.background = oldBackground; - runtime.renderer.setClearAlpha(oldClearAlpha); - runtime.renderer.render(runtime.scene, runtime.camera); + scene.background = null; + renderer.setClearColor(0x000000, 0); + } + renderer.render(scene, camera); + const thumbnail = renderer.domElement.toDataURL("image/png"); + if (transparent) { + scene.background = oldBackground; + renderer.setClearAlpha(oldClearAlpha); + renderer.render(scene, camera); } await api(`/api/models/${modelId}/thumbnail`, { method: "PUT", @@ -205,13 +444,85 @@ function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise< }); } -function bindProcessPlaceholder(operations: PreviewOperation[]) { - const getSelectedOperation = () => { - const selectedButton = document.querySelector(".preview-process-tree li.is-active button"); - const index = Number(selectedButton?.dataset.processIndex ?? 0); - return operations[index] ?? null; - }; +function getSelectedOperation(operations: PreviewOperation[]) { + const selectedButton = document.querySelector(".preview-process-tree li.is-active button"); + const index = Number(selectedButton?.dataset.processIndex ?? 0); + return operations[index] ?? null; +} +function readPreviewBasePoint(): PreviewResolvedBasePoint { + return resolveBasePoint({ + x: readNumberInput("baseX"), + y: readNumberInput("baseY"), + z: readNumberInput("baseZ"), + rx: readNumberInput("baseRx"), + ry: readNumberInput("baseRy"), + rz: readNumberInput("baseRz") + }); +} + +function readNumberInput(name: string) { + const value = document.querySelector(`.preview-basepoint-grid input[name="${name}"]`)?.value ?? "0"; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : 0; +} + +function bindImportScene( + model: ModelItem, + operations: PreviewOperation[], + onImportScene?: (payload: PreviewImportScenePayload) => Promise | void +) { + document.querySelector("#previewImportSceneBtn")?.addEventListener("click", async () => { + try { + const selectedOperation = getSelectedOperation(operations); + const payload = buildImportScenePayload({ + model, + operations, + selectedOperation, + basePointEnabled: document.querySelector("#previewUseBasePoint")?.checked ?? false, + basePoint: readPreviewBasePoint() + }); + + emitImportScenePayload(payload); + await onImportScene?.(payload); + notify("导入场景数据已输出"); + } catch (error) { + notifyError(error); + } + }); +} + +function resolveBasePoint(basePoint?: PreviewBasePoint): PreviewResolvedBasePoint { + return { + x: basePoint?.x ?? zeroBasePoint.x, + y: basePoint?.y ?? zeroBasePoint.y, + z: basePoint?.z ?? zeroBasePoint.z, + rx: basePoint?.rx ?? zeroBasePoint.rx, + ry: basePoint?.ry ?? zeroBasePoint.ry, + rz: basePoint?.rz ?? zeroBasePoint.rz + }; +} + +function buildImportScenePayload(options: { + model: ModelItem; + operations: PreviewOperation[]; + selectedOperation: PreviewOperation | null; + basePointEnabled: boolean; + basePoint: PreviewResolvedBasePoint; +}): PreviewImportScenePayload { + return { + model: options.model, + modelUrl: options.model.file_url, + basePointEnabled: options.basePointEnabled, + basePoint: options.basePoint, + rawOperationTree: options.model.operation_tree, + operations: options.operations, + selectedOperation: options.selectedOperation, + selectedCraftPlayData: options.selectedOperation?.parsedCraftPlayData ?? null + }; +} + +function bindProcessPlaceholder(operations: PreviewOperation[]) { const updateStatus = (operation: PreviewOperation | null) => { const status = document.querySelector("#previewProcessStatus"); if (status) { @@ -229,7 +540,7 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) { }); document.querySelector("#previewPlayBtn")?.addEventListener("click", () => { - const operation = getSelectedOperation(); + const operation = getSelectedOperation(operations); updateStatus(operation); if (!operation) { notify("请先选择工艺"); @@ -247,15 +558,24 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) { }); document.querySelector("#previewPauseBtn")?.addEventListener("click", () => { - updateStatus(getSelectedOperation()); + updateStatus(getSelectedOperation(operations)); notify("暂停逻辑待接入"); }); } -async function initPreview(url: string) { +async function initPreview(url: string, context: PreviewLoadContext) { + ensurePreviewContext(context); const viewport = document.querySelector("#modelPreviewViewport"); if (!viewport) return; - viewport.innerHTML = ""; + viewport.innerHTML = previewLoadingHtml("正在加载 3D 预览引擎..."); + + const [THREE, { OrbitControls: OrbitControlsClass }, { GLTFLoader: GLTFLoaderClass }, { EXRLoader: EXRLoaderClass }] = await Promise.all([ + import("three"), + import("three/examples/jsm/controls/OrbitControls.js"), + import("three/examples/jsm/loaders/GLTFLoader.js"), + import("three/examples/jsm/loaders/EXRLoader.js") + ]); + ensurePreviewContext(context); THREE.Object3D.DEFAULT_UP.set(0, 0, 1); @@ -271,23 +591,35 @@ async function initPreview(url: string) { alpha: true, preserveDrawingBuffer: true }); + ensurePreviewContext(context); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; viewport.appendChild(renderer.domElement); + context.renderer = renderer; - const controls = new OrbitControls(camera, renderer.domElement); + ensurePreviewContext(context); + const environmentMap = await loadPreviewEnvironment(THREE, EXRLoaderClass, renderer, scene, context); + context.environmentMap = environmentMap; + ensurePreviewContext(context); + + const controls = new OrbitControlsClass(camera, renderer.domElement); controls.enableDamping = true; + context.controls = controls; - scene.add(new THREE.HemisphereLight(0xffffff, 0xb7c3cc, 1.2)); - const keyLight = new THREE.DirectionalLight(0xffffff, 2); - keyLight.position.set(4, -5, 6); - scene.add(keyLight); - - const loader = new GLTFLoader(); - const gltf = await loader.loadAsync(url); + updatePreviewLoadingByText("正在请求模型文件...", 4); + const loader = new GLTFLoaderClass(); + const gltf = await loadGltfWithProgress(loader, url, context); const object = gltf.scene; + if (!isPreviewContextActive(context)) { + disposeObject3D(object); + throw new DOMException("预览已关闭", "AbortError"); + } + context.modelObject = object; scene.add(object); - fitCameraToObject(camera, controls, object); + fitCameraToObject(THREE, camera, controls, object); + hidePreviewLoading(); + context.camera = camera; + context.scene = scene; const resize = () => { const width = Math.max(viewport.clientWidth, 1); @@ -299,13 +631,14 @@ async function initPreview(url: string) { const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(viewport); + context.resizeObserver = resizeObserver; resize(); const animate = () => { controls.update(); renderer.render(scene, camera); - if (runtime) { - runtime.animationId = requestAnimationFrame(animate); + if (runtime?.context === context && !context.disposed) { + context.animationId = requestAnimationFrame(animate); } }; @@ -313,13 +646,131 @@ async function initPreview(url: string) { renderer, scene, camera, - controls, - animationId: requestAnimationFrame(animate), - resizeObserver + context }; + context.animationId = requestAnimationFrame(animate); } -function fitCameraToObject(camera: THREE.PerspectiveCamera, controls: OrbitControls, object: THREE.Object3D) { +async function loadPreviewEnvironment( + THREE: ThreeModule, + EXRLoaderClass: typeof import("three/examples/jsm/loaders/EXRLoader.js").EXRLoader, + renderer: WebGLRenderer, + scene: Scene, + context: PreviewLoadContext +) { + try { + updatePreviewLoadingByText("正在准备环境光...", 2); + const exrTexture = await getPreviewEnvironmentTexture(EXRLoaderClass); + ensurePreviewContext(context); + exrTexture.mapping = THREE.EquirectangularReflectionMapping; + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentMap = pmremGenerator.fromEquirectangular(exrTexture).texture; + scene.environment = environmentMap; + // scene.background = environmentMap; + pmremGenerator.dispose(); + return environmentMap; + } catch (error) { + if (isAbortError(error)) throw error; + scene.background = new THREE.Color(0xf4f7f9); + return null; + } +} + +function getPreviewEnvironmentTexture( + EXRLoaderClass: typeof import("three/examples/jsm/loaders/EXRLoader.js").EXRLoader +) { + if (!previewEnvironmentTexturePromise) { + previewEnvironmentTexturePromise = new EXRLoaderClass() + .loadAsync(previewEnvironmentUrl) + .catch((error) => { + previewEnvironmentTexturePromise = null; + throw error; + }); + } + return previewEnvironmentTexturePromise; +} + +async function loadGltfWithProgress(loader: GLTFLoader, url: string, context: PreviewLoadContext) { + try { + const buffer = await fetchModelArrayBuffer(url, context); + ensurePreviewContext(context); + updatePreviewLoadingByText("正在解析模型...", 96); + const gltf = await (loader as ParseableGltfLoader).parseAsync(buffer, basePathFromUrl(url)); + ensurePreviewContext(context); + updatePreviewLoadingByText("模型解析完成", 100); + return gltf; + } catch (error) { + if (!isAbortError(error)) { + showPreviewLoadingError(error instanceof Error ? error.message : "模型文件加载失败"); + } + throw error; + } +} + +async function fetchModelArrayBuffer(url: string, context: PreviewLoadContext) { + const response = await fetch(url, { signal: context.controller.signal }); + ensurePreviewContext(context); + if (!response.ok) { + throw new Error(`模型文件请求失败:${response.status}`); + } + + const total = Number(response.headers.get("content-length") ?? 0); + if (!response.body) { + const buffer = await response.arrayBuffer(); + ensurePreviewContext(context); + updatePreviewLoadingByText(`已加载 ${formatBytes(buffer.byteLength)}`, 92); + return buffer; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + + while (true) { + ensurePreviewContext(context); + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + chunks.push(value); + loaded += value.byteLength; + updatePreviewLoadingFromBytes(loaded, total); + } + ensurePreviewContext(context); + + const buffer = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + return buffer.buffer; +} + +function updatePreviewLoadingFromBytes(loaded: number, total: number) { + if (total > 0) { + updatePreviewLoadingByText(`${Math.round((loaded / total) * 100)}% (${formatBytes(loaded)} / ${formatBytes(total)})`, (loaded / total) * 92); + return; + } + updatePreviewLoadingByText(`已加载 ${formatBytes(loaded)}`, 28); +} + +function updatePreviewLoadingByText(message: string, percent: number) { + const text = document.querySelector("#previewLoadingText"); + const bar = document.querySelector("#previewLoadingBar"); + if (!text || !bar) return; + text.textContent = message; + bar.style.width = `${Math.min(100, Math.max(0, Math.round(percent)))}%`; +} + +function basePathFromUrl(url: string) { + try { + return new URL(".", url).href; + } catch { + return url.slice(0, url.lastIndexOf("/") + 1); + } +} + +function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, controls: OrbitControls, object: Object3D) { const box = new THREE.Box3().setFromObject(object); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); @@ -339,11 +790,14 @@ function fitCameraToObject(camera: THREE.PerspectiveCamera, controls: OrbitContr } function disposePreview() { - if (!runtime) return; - cancelAnimationFrame(runtime.animationId); - runtime.resizeObserver.disconnect(); - runtime.controls.dispose(); - runtime.renderer.dispose(); - runtime.renderer.domElement.remove(); + const context = activePreviewContext ?? runtime?.context ?? null; + if (context) { + context.disposed = true; + context.controller.abort(); + cleanupPreviewContextResources(context); + if (activePreviewContext === context) { + activePreviewContext = null; + } + } runtime = null; }