优化模型上传与预览体验
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user