diff --git a/web/src/pages/app/modules/preview.ts b/web/src/pages/app/modules/preview.ts index 563c0a9..955efeb 100644 --- a/web/src/pages/app/modules/preview.ts +++ b/web/src/pages/app/modules/preview.ts @@ -36,6 +36,29 @@ type ParseableGltfLoader = GLTFLoader & { parseAsync(data: ArrayBuffer | string, path: string): Promise; }; +type PreviewObjectSnapshot = { + position: ReturnType; + quaternion: ReturnType; + visible: boolean; + parent: Object3D | null; +}; + +type PreviewPlaybackState = { + operation: PreviewOperation | null; + isPlaying: boolean; + rafId: number | null; + startedAtMs: number; + baseTimeMs: number; + currentTimeMs: number; + currentFrameIndex: number; + frameTimesMs: number[]; + totalTimeMs: number; + objectSnapshots: Map; + objectMap: Map; + attachedOriginalParents: Map; + rotatingObjects: Map; +}; + export type PreviewBasePoint = { x?: number; y?: number; @@ -86,10 +109,13 @@ const zeroBasePoint: PreviewResolvedBasePoint = { }; const previewEnvironmentUrl = "/DayCityOutdoor.exr"; +const DEFAULT_PREVIEW_FRAME_INTERVAL_MS = 20; +const SECOND_MS = 1000; let runtime: PreviewRuntime | null = null; let activePreviewContext: PreviewLoadContext | null = null; let previewEnvironmentTexturePromise: Promise | null = null; +let previewPlayback: PreviewPlaybackState = createEmptyPlaybackState(); export function openModelPreview( model: ModelItem, @@ -406,6 +432,7 @@ function isAbortError(error: unknown) { } function cleanupPreviewContextResources(context: PreviewLoadContext) { + stopPreviewPlayback(); if (context.animationId !== undefined) { cancelAnimationFrame(context.animationId); context.animationId = undefined; @@ -581,6 +608,7 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) { document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active")); button.closest("li")?.classList.add("is-active"); const index = Number(button.dataset.processIndex ?? 0); + stopPreviewPlayback(); updateStatus(operations[index] ?? null); }); }); @@ -600,15 +628,387 @@ function bindProcessPlaceholder(operations: PreviewOperation[]) { notify("当前工艺暂无播放数据"); return; } - notify(`${operation.CraftName || "当前工艺"} 已读取 ${operation.frameCount} 帧,播放逻辑待接入`); + startPreviewPlayback(operation); }); document.querySelector("#previewPauseBtn")?.addEventListener("click", () => { updateStatus(getSelectedOperation(operations)); - notify("暂停逻辑待接入"); + pausePreviewPlayback(); }); } +function startPreviewPlayback(operation: PreviewOperation) { + if (!runtime?.context.modelObject || !operation.parsedCraftPlayData) { + notify("模型还未加载完成"); + return; + } + + if (previewPlayback.isPlaying && previewPlayback.operation === operation) { + return; + } + + if (previewPlayback.operation === operation && previewPlayback.currentTimeMs > 0 && previewPlayback.currentTimeMs < previewPlayback.totalTimeMs) { + resumePreviewPlayback(); + return; + } + + resetPreviewPlaybackScene(); + const frames = operation.parsedCraftPlayData.OPERATION.frames; + const frameTimesMs = buildPreviewFrameTimes(frames); + previewPlayback = { + operation, + isPlaying: true, + rafId: null, + startedAtMs: performance.now(), + baseTimeMs: 0, + currentTimeMs: 0, + currentFrameIndex: -1, + frameTimesMs, + totalTimeMs: frameTimesMs[frameTimesMs.length - 1] ?? 0, + objectSnapshots: capturePreviewObjectSnapshots(runtime.context.modelObject), + objectMap: buildPreviewObjectMap(runtime.context.modelObject, operation), + attachedOriginalParents: new Map(), + rotatingObjects: new Map() + }; + setPlaybackButtons(true); + updatePreviewPlaybackStatus(operation, 0); + previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback); +} + +function pausePreviewPlayback() { + if (!previewPlayback.isPlaying) return; + refreshPreviewPlaybackClock(); + cancelPreviewPlaybackRaf(); + previewPlayback.isPlaying = false; + setPlaybackButtons(false); + updatePreviewPlaybackStatus(previewPlayback.operation, previewPlayback.currentTimeMs); +} + +function resumePreviewPlayback() { + if (!previewPlayback.operation || previewPlayback.isPlaying) return; + if (previewPlayback.currentTimeMs >= previewPlayback.totalTimeMs) { + resetPreviewPlaybackScene(); + previewPlayback.currentTimeMs = 0; + previewPlayback.currentFrameIndex = -1; + previewPlayback.baseTimeMs = 0; + } + previewPlayback.isPlaying = true; + previewPlayback.startedAtMs = performance.now(); + previewPlayback.baseTimeMs = previewPlayback.currentTimeMs; + setPlaybackButtons(true); + previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback); +} + +function stopPreviewPlayback() { + cancelPreviewPlaybackRaf(); + resetPreviewPlaybackScene(); + previewPlayback = createEmptyPlaybackState(); + setPlaybackButtons(false); +} + +function stepPreviewPlayback(timestamp: number) { + if (!previewPlayback.isPlaying || !previewPlayback.operation?.parsedCraftPlayData || !runtime?.context.modelObject) return; + + const nextTimeMs = Math.min( + previewPlayback.baseTimeMs + Math.max(0, timestamp - previewPlayback.startedAtMs), + previewPlayback.totalTimeMs + ); + applyPreviewContinuousRotations(nextTimeMs); + previewPlayback.currentTimeMs = nextTimeMs; + const nextFrameIndex = resolvePreviewFrameIndex(previewPlayback.currentTimeMs, previewPlayback.frameTimesMs); + applyPreviewFramesUntil(nextFrameIndex); + updatePreviewPlaybackStatus(previewPlayback.operation, previewPlayback.currentTimeMs); + + if (previewPlayback.currentTimeMs >= previewPlayback.totalTimeMs) { + cancelPreviewPlaybackRaf(); + previewPlayback.isPlaying = false; + setPlaybackButtons(false); + return; + } + + previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback); +} + +function refreshPreviewPlaybackClock() { + if (!previewPlayback.isPlaying) return; + previewPlayback.currentTimeMs = Math.min( + previewPlayback.baseTimeMs + Math.max(0, performance.now() - previewPlayback.startedAtMs), + previewPlayback.totalTimeMs + ); + previewPlayback.baseTimeMs = previewPlayback.currentTimeMs; + previewPlayback.startedAtMs = performance.now(); +} + +function applyPreviewFramesUntil(nextFrameIndex: number) { + const frames = previewPlayback.operation?.parsedCraftPlayData?.OPERATION.frames ?? []; + if (nextFrameIndex <= previewPlayback.currentFrameIndex) return; + for (let index = previewPlayback.currentFrameIndex + 1; index <= nextFrameIndex && index < frames.length; index += 1) { + applyPreviewFrame(frames[index]); + } + previewPlayback.currentFrameIndex = Math.min(nextFrameIndex, frames.length - 1); +} + +function applyPreviewFrame(frame: P_OPERATION["OPERATION"]["frames"][number]) { + for (const attachData of frame.attachs ?? []) { + if (attachData.IsAttach) { + applyPreviewAttach(attachData.AttachToModelCode, attachData.List_AttachModel ?? []); + } + } + for (const visibleData of frame.visibles ?? []) { + const object = findPreviewObject(visibleData.ModelCode); + if (object) object.visible = parseInteger(visibleData.Visible) === 1; + } + for (const craftData of frame.crafts ?? []) { + if (parseInteger(craftData.CraftValue) === 1) { + const operation = findPreviewOperation(craftData.CraftCode); + if (operation?.parsedCraftPlayData) { + applyPreviewFrame(operation.parsedCraftPlayData.OPERATION.frames[0]); + } + } + } + for (const rotationData of frame.rotations ?? []) { + applyPreviewRotation(rotationData.ModelCode, rotationData.Axis, Number(rotationData.Rate)); + } + for (const moveData of frame.objStates ?? []) { + applyPreviewObjState(moveData); + } + for (const attachData of frame.attachs ?? []) { + if (!attachData.IsAttach) { + detachPreviewAttach(attachData.List_AttachModel ?? []); + } + } +} + +function applyPreviewObjState(moveData: P_OPERATION["OPERATION"]["frames"][number]["objStates"][number]) { + const object = findPreviewObject(moveData.i); + if (!object || !isValidFrameTransform(moveData)) return; + const position = parsePreviewNumbers(moveData.tx, moveData.ty, moveData.tz); + const quaternion = parsePreviewNumbers(moveData.qx, moveData.qy, moveData.qz, moveData.qw); + if (!position || !quaternion) return; + object.position.set(position[0], position[1], position[2]); + object.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]); + object.updateMatrixWorld(); +} + +function applyPreviewRotation(modelCode: string, axisValue: string, rate: number) { + const object = findPreviewObject(modelCode); + const axis = normalizeAxis(axisValue); + if (!object || !axis || !Number.isFinite(rate)) return; + if (Math.abs(rate) <= 0.000001) { + previewPlayback.rotatingObjects.delete(object); + return; + } + previewPlayback.rotatingObjects.set(object, { axis, rate }); +} + +function applyPreviewContinuousRotations(nextTimeMs: number) { + if (previewPlayback.rotatingObjects.size === 0) return; + const deltaSeconds = Math.max(0, nextTimeMs - previewPlayback.currentTimeMs) / SECOND_MS; + for (const [object, rotation] of previewPlayback.rotatingObjects) { + object.rotation[rotation.axis] += rotation.rate * deltaSeconds; + object.updateMatrixWorld(); + } +} + +function applyPreviewAttach(rootCode: string, attachModels: Array<{ ModelCode: string }>) { + const root = findPreviewObject(rootCode); + if (!root) return; + for (const attachModel of attachModels) { + const object = findPreviewObject(attachModel.ModelCode); + if (!object || object === root) continue; + if (!previewPlayback.attachedOriginalParents.has(object)) { + previewPlayback.attachedOriginalParents.set(object, object.parent); + } + root.attach(object); + } +} + +function detachPreviewAttach(attachModels: Array<{ ModelCode: string }>) { + for (const attachModel of attachModels) { + const object = findPreviewObject(attachModel.ModelCode); + if (!object) continue; + const originalParent = previewPlayback.attachedOriginalParents.get(object); + if (originalParent) { + originalParent.attach(object); + } else { + runtime?.scene.attach(object); + } + previewPlayback.attachedOriginalParents.delete(object); + } +} + +function resetPreviewPlaybackScene() { + cancelPreviewPlaybackRaf(); + previewPlayback.rotatingObjects.clear(); + for (const [object, originalParent] of previewPlayback.attachedOriginalParents) { + if (originalParent) { + originalParent.attach(object); + } + } + previewPlayback.attachedOriginalParents.clear(); + for (const [object, snapshot] of previewPlayback.objectSnapshots) { + if (snapshot.parent && object.parent !== snapshot.parent) { + snapshot.parent.attach(object); + } + object.position.copy(snapshot.position); + object.quaternion.copy(snapshot.quaternion); + object.visible = snapshot.visible; + object.updateMatrixWorld(); + } +} + +function buildPreviewFrameTimes(frames: P_OPERATION["OPERATION"]["frames"]) { + let previousTime = 0; + return frames.map((frame, index) => { + const parsed = Number(frame.time); + const timeMs = Number.isFinite(parsed) ? Math.max(0, parsed * SECOND_MS) : index * DEFAULT_PREVIEW_FRAME_INTERVAL_MS; + previousTime = index === 0 ? timeMs : Math.max(previousTime + 1, timeMs); + return previousTime; + }); +} + +function resolvePreviewFrameIndex(currentTimeMs: number, frameTimesMs: number[]) { + let index = -1; + for (let i = 0; i < frameTimesMs.length; i += 1) { + if (currentTimeMs >= frameTimesMs[i]) index = i; + else break; + } + return index; +} + +function buildPreviewObjectMap(root: Object3D, operation: PreviewOperation) { + const objectMap = new Map(); + root.traverse((object) => { + const values = [ + object.userData?.ModelId, + object.userData?.ModelCode, + object.userData?.RootId, + object.userData?.id, + object.name + ]; + for (const value of values) { + const key = normalizePreviewCode(value); + if (key && !objectMap.has(key)) objectMap.set(key, object); + } + }); + const targetCodes = collectPreviewOperationModelCodes(operation); + if (targetCodes.length > 0 && !targetCodes.some(code => objectMap.has(code))) { + for (const code of targetCodes) objectMap.set(code, root); + } + return objectMap; +} + +function capturePreviewObjectSnapshots(root: Object3D) { + const snapshots = new Map(); + root.traverse((object) => { + snapshots.set(object, { + position: object.position.clone(), + quaternion: object.quaternion.clone(), + visible: object.visible, + parent: object.parent + }); + }); + return snapshots; +} + +function collectPreviewOperationModelCodes(operation: PreviewOperation) { + const codes = new Set(); + if (operation.ModelCode) codes.add(normalizePreviewCode(operation.ModelCode)); + if (operation.ModelCodeParent) codes.add(normalizePreviewCode(operation.ModelCodeParent)); + for (const frame of operation.parsedCraftPlayData?.OPERATION.frames ?? []) { + for (const state of frame.objStates ?? []) codes.add(normalizePreviewCode(state.i)); + for (const item of frame.visibles ?? []) codes.add(normalizePreviewCode(item.ModelCode)); + for (const item of frame.rotations ?? []) codes.add(normalizePreviewCode(item.ModelCode)); + for (const item of frame.attachs ?? []) { + codes.add(normalizePreviewCode(item.AttachToModelCode)); + for (const child of item.List_AttachModel ?? []) codes.add(normalizePreviewCode(child.ModelCode)); + } + } + return [...codes].filter(Boolean); +} + +function findPreviewObject(modelCode: string) { + const code = normalizePreviewCode(modelCode); + return code ? previewPlayback.objectMap.get(code) ?? null : null; +} + +function findPreviewOperation(craftCode: string) { + const code = normalizePreviewCode(craftCode); + if (!code || !previewPlayback.operation) return null; + return previewPlayback.operation.CraftCode === code ? previewPlayback.operation : null; +} + +function parsePreviewNumbers(...valuesRaw: string[]) { + const values = valuesRaw.map(Number); + if (values.some(value => !Number.isFinite(value))) return null; + return values; +} + +function isValidFrameTransform(moveData: P_OPERATION["OPERATION"]["frames"][number]["objStates"][number]) { + return [moveData.tx, moveData.ty, moveData.tz, moveData.qx, moveData.qy, moveData.qz, moveData.qw] + .every(value => value !== "NaN" && value !== ""); +} + +function normalizeAxis(axis: string): "x" | "y" | "z" | null { + const value = axis.trim().toLowerCase(); + return value === "x" || value === "y" || value === "z" ? value : null; +} + +function parseInteger(value: string) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizePreviewCode(value: unknown) { + return String(value ?? "").trim(); +} + +function updatePreviewPlaybackStatus(operation: PreviewOperation | null, currentTimeMs: number) { + const status = document.querySelector("#previewProcessStatus"); + if (!status || !operation) return; + const total = Math.max(previewPlayback.totalTimeMs, 0); + const frameText = previewPlayback.currentFrameIndex >= 0 + ? `${previewPlayback.currentFrameIndex + 1}/${operation.frameCount}` + : `0/${operation.frameCount}`; + status.innerHTML = `播放 ${frameText},${formatPlaybackSeconds(currentTimeMs)}/${formatPlaybackSeconds(total)}s`; +} + +function setPlaybackButtons(isPlaying: boolean) { + const playButton = document.querySelector("#previewPlayBtn"); + const pauseButton = document.querySelector("#previewPauseBtn"); + if (playButton) playButton.textContent = isPlaying ? "播放中" : "播放"; + if (pauseButton) pauseButton.disabled = !isPlaying; +} + +function cancelPreviewPlaybackRaf() { + if (previewPlayback.rafId !== null) { + window.cancelAnimationFrame(previewPlayback.rafId); + previewPlayback.rafId = null; + } +} + +function createEmptyPlaybackState(): PreviewPlaybackState { + return { + operation: null, + isPlaying: false, + rafId: null, + startedAtMs: 0, + baseTimeMs: 0, + currentTimeMs: 0, + currentFrameIndex: -1, + frameTimesMs: [], + totalTimeMs: 0, + objectSnapshots: new Map(), + objectMap: new Map(), + attachedOriginalParents: new Map(), + rotatingObjects: new Map() + }; +} + +function formatPlaybackSeconds(ms: number) { + return (Math.max(0, ms) / SECOND_MS).toFixed(2); +} + async function initPreview(url: string, context: PreviewLoadContext) { ensurePreviewContext(context); const viewport = document.querySelector("#modelPreviewViewport");