优化模型上传与预览体验

This commit is contained in:
zhangshun
2026-05-25 20:35:07 +08:00
parent a31c620420
commit efddf5adee
7 changed files with 878 additions and 79 deletions

View File

@@ -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]));

View File

@@ -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<number, Record<string, string>>();
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<string, string>) {
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)
};
});

Binary file not shown.

View File

@@ -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 {

View File

@@ -37,6 +37,7 @@ export async function renderApp() {
<div class="content-actions">
${isAdmin ? `<button id="manageUsersBtn" type="button">人员权限</button>` : ""}
${isAdmin ? `<button id="manageDictionariesBtn" type="button">字典维护</button>` : ""}
<button id="addProcessModelBtn" type="button" hidden>上传工艺模型</button>
<button id="addModelBtn" class="primary-btn" type="button" hidden>增加模型</button>
</div>
</div>

View File

@@ -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<string, string>;
};
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<HTMLSelectElement>("#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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("#addModelBtn");
const addProcessModelBtn = document.querySelector<HTMLButtonElement>("#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<HTMLFormElement>("#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<boolean>({
title: "上传工艺模型",
width: 640,
height: 650,
body: `
<form id="processModelUploadPopupForm" class="popup-form upload-popup-form">
<div class="upload-target-path">
<span>上传目录</span>
<strong>${escapeHtml(appState.selectedFolderName || "当前目录")}</strong>
</div>
<label>
<span>模型文件</span>
<div id="processModelDropZone" class="drop-zone">
<input id="processModelUploadFile" type="file" accept=".glb" />
<strong>选择模型</strong>
<em>或拖拽 .glb 模型到这里</em>
<small id="processSelectedFileName">${escapeHtml(input?.file?.name ?? "未选择文件")}</small>
</div>
</label>
<label>
<span>模型名称</span>
<input id="processModelUploadName" name="name" placeholder="例如 AA.glb" value="${escapeHtml(initialName)}" />
</label>
<div class="popup-form-grid">
<label><span>品牌</span><input name="brandName" list="brandOptions" /></label>
<label><span>类型</span><input name="typeName" list="typeOptions" /></label>
<label><span>型号</span><input name="model" /></label>
<label><span>价钱</span><input name="price" /></label>
<label><span>重量</span><input name="weight" /></label>
</div>
<label>
<span>工艺数据 JSON</span>
<textarea id="processOperationTreeJson" name="operationTree" placeholder='可以粘贴 {"OperationTree":"[...]"} 或 OperationTree[] 数组 JSON'>${escapeHtml(input?.operationTreeJson ?? "")}</textarea>
</label>
${dictionaryDatalistHtml()}
</form>
`,
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<HTMLFormElement>("#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<HTMLDivElement>("#processModelDropZone")!;
const fileInput = document.querySelector<HTMLInputElement>("#processModelUploadFile")!;
const nameInput = document.querySelector<HTMLInputElement>("#processModelUploadName")!;
const selectedFileName = document.querySelector<HTMLElement>("#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<HTMLButtonElement>(`button[data-id="${id}"]`)?.closest(".model-card");
const oldName = card?.querySelector("strong")?.textContent ?? "";

View File

@@ -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<ReturnType<GLTFLoader["loadAsync"]>>;
type ParseableGltfLoader = GLTFLoader & {
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
};
export type PreviewBasePoint = {
x?: number;
y?: number;
z?: number;
rx?: number;
ry?: number;
rz?: number;
};
export type PreviewResolvedBasePoint = Required<PreviewBasePoint>;
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> | void) {
disposePreview();
export type ModelPreviewOptions = {
onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | 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<Texture> | null = null;
export function openModelPreview(
model: ModelItem,
onThumbnailSaved?: () => Promise<void> | 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
</aside>
<div class="preview-canvas-panel">
<div id="modelPreviewViewport" class="preview-viewport">
<div class="preview-loading">模型加载中...</div>
${previewLoadingHtml("准备加载模型...")}
</div>
</div>
</div>
@@ -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<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`);
if (input && basePoint[key] !== undefined) {
input.value = String(basePoint[key]);
}
}
const checkbox = document.querySelector<HTMLInputElement>("#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<PreviewImportScenePayload>("dmt:import-scene", { detail: payload }));
}
function parseModelOperations(value: string): PreviewOperation[] {
try {
const dbValue = JSON.parse(value || "{}") as Partial<OperationTreeDB>;
@@ -172,26 +284,153 @@ function operationStatusHtml(operation: PreviewOperation | null) {
return `已读取 ${operation.frameCount} 帧播放数据`;
}
function previewLoadingHtml(text: string) {
return `
<div id="previewLoading" class="preview-loading">
<div class="preview-loading-box">
<strong>模型加载中</strong>
<span id="previewLoadingText">${escapeHtml(text)}</span>
<div class="preview-loading-track">
<i id="previewLoadingBar" style="width: 0%"></i>
</div>
</div>
</div>
`;
}
function updatePreviewLoading(event: ProgressEvent) {
const text = document.querySelector<HTMLElement>("#previewLoadingText");
const bar = document.querySelector<HTMLElement>("#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<HTMLElement>("#previewLoadingText");
const bar = document.querySelector<HTMLElement>("#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<HTMLElement>("#previewLoading");
if (loading) {
loading.innerHTML = `<div class="preview-loading-box is-error"><strong>加载失败</strong><span>${escapeHtml(message)}</span></div>`;
}
}
function hidePreviewLoading() {
document.querySelector<HTMLElement>("#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<string, unknown> & { 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> | void) {
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
try {
if (!runtime) {
throw new Error("模型还未加载完成");
}
const transparent = document.querySelector<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLInputElement>(`.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> | void
) {
document.querySelector<HTMLButtonElement>("#previewImportSceneBtn")?.addEventListener("click", async () => {
try {
const selectedOperation = getSelectedOperation(operations);
const payload = buildImportScenePayload({
model,
operations,
selectedOperation,
basePointEnabled: document.querySelector<HTMLInputElement>("#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<HTMLDivElement>("#previewProcessStatus");
if (status) {
@@ -229,7 +540,7 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) {
});
document.querySelector<HTMLButtonElement>("#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<HTMLButtonElement>("#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<HTMLDivElement>("#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<HTMLElement>("#previewLoadingText");
const bar = document.querySelector<HTMLElement>("#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;
}