接入模型预览工艺播放
This commit is contained in:
@@ -36,6 +36,29 @@ type ParseableGltfLoader = GLTFLoader & {
|
||||
parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
|
||||
};
|
||||
|
||||
type PreviewObjectSnapshot = {
|
||||
position: ReturnType<Object3D["position"]["clone"]>;
|
||||
quaternion: ReturnType<Object3D["quaternion"]["clone"]>;
|
||||
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<Object3D, PreviewObjectSnapshot>;
|
||||
objectMap: Map<string, Object3D>;
|
||||
attachedOriginalParents: Map<Object3D, Object3D | null>;
|
||||
rotatingObjects: Map<Object3D, { axis: "x" | "y" | "z"; rate: number }>;
|
||||
};
|
||||
|
||||
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<Texture> | 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<HTMLButtonElement>("#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<string, Object3D>();
|
||||
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<Object3D, PreviewObjectSnapshot>();
|
||||
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<string>();
|
||||
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<HTMLDivElement>("#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<HTMLButtonElement>("#previewPlayBtn");
|
||||
const pauseButton = document.querySelector<HTMLButtonElement>("#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<HTMLDivElement>("#modelPreviewViewport");
|
||||
|
||||
Reference in New Issue
Block a user