Compare commits

...

3 Commits

Author SHA1 Message Date
zhangshun
738476c7f9 修改预览界面样式和按钮位置 2026-06-01 09:20:04 +08:00
zhangshun
6f6be751a9 接入模型预览工艺播放 2026-06-01 08:58:14 +08:00
zhangshun
0ace6c0413 调整模型预览左侧宽度 2026-05-31 11:41:40 +08:00
2 changed files with 460 additions and 49 deletions

View File

@@ -106,7 +106,7 @@
padding: 0;
background: #f4f7f9;
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 250px 1fr;
min-height: 0;
overflow: hidden;
}
@@ -134,7 +134,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
padding: 0 10px;
border-bottom: 1px solid #d8e0e8;
}
@@ -150,7 +150,7 @@
overscroll-behavior: contain;
list-style: none;
margin: 0;
padding: 8px;
padding: 5px 0;
}
.preview-model-meta {
@@ -211,6 +211,7 @@
}
.preview-process-status {
display: none;
min-height: 44px;
max-height: 78px;
overflow: auto;

View File

@@ -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,
@@ -127,18 +153,14 @@ export function openModelPreview(
</div>
<div class="preview-scene-section">
<div class="preview-scene-body">
${canWrite ? `
<button id="previewCaptureThumbBtn" type="button">截缩略图</button>
<label class="preview-switch">
<input id="previewTransparentThumb" type="checkbox" />
<span>透明背景截图</span>
</label>
` : ""}
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
<div style="display: flex; align-items: center; ">
<button id="previewImportSceneBtn" class="primary-btn" type="button">导入</button>
<label class="preview-switch">
<input id="previewUseBasePoint" type="checkbox" />
<span>启用基点导入</span>
<span>启用基点</span>
</label>
</div>
<div class="preview-basepoint-grid">
<label><span>X</span><input name="baseX" type="number" value="0" step="0.001" /></label>
<label><span>Y</span><input name="baseY" type="number" value="0" step="0.001" /></label>
@@ -158,6 +180,13 @@ export function openModelPreview(
</div>
`,
actions: {
() {
if (canWrite) {
screenshotModel(model.id, onThumbnailSaved).catch((error) => {
})
}
},
() {
disposePreview();
w2popup.close();
@@ -166,7 +195,8 @@ export function openModelPreview(
});
popup.self
.on("open:after", () => {
.on("open:after", () => {popup
console.log(popup)
bindProcessPlaceholder(operations);
bindImportScene(model, operations, options.onImportScene);
if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
@@ -406,6 +436,7 @@ function isAbortError(error: unknown) {
}
function cleanupPreviewContextResources(context: PreviewLoadContext) {
stopPreviewPlayback();
if (context.animationId !== undefined) {
cancelAnimationFrame(context.animationId);
context.animationId = undefined;
@@ -454,7 +485,14 @@ function disposeMaterial(material: unknown) {
}
function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", async () => {
document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click", () => {
screenshotModel(modelId, onThumbnailSaved).catch((error) => {
})
});
}
async function screenshotModel(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
try {
if (!runtime) {
throw new Error("模型还未加载完成");
@@ -463,7 +501,7 @@ function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<
if (!renderer || !scene || !camera || !controls) {
throw new Error("模型还未加载完成");
}
const transparent = document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
const transparent = true; // document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
controls.update();
const oldBackground = scene.background;
const oldClearAlpha = renderer.getClearAlpha();
@@ -487,7 +525,6 @@ function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<
} catch (error) {
notifyError(error);
}
});
}
function getSelectedOperation(operations: PreviewOperation[]) {
@@ -581,6 +618,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 +638,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");