Files
dmt-modelLibrary/web/src/pages/app/modules/preview.ts
2026-05-23 09:11:46 +08:00

249 lines
8.8 KiB
TypeScript

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 { ModelItem } from "../../../types";
import { escapeHtml, formatBytes } from "../../../utils/format";
import { notify, notifyError } from "../../../ui/dialogs";
import { api } from "../../../services/api";
import { getCurrentUser } from "../../../services/authState";
type PreviewRuntime = {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
animationId: number;
resizeObserver: ResizeObserver;
};
let runtime: PreviewRuntime | null = null;
export function openModelPreview(model: ModelItem, onThumbnailSaved?: () => Promise<void> | void) {
disposePreview();
const isAdmin = getCurrentUser()?.role === "admin";
const url = model.file_url;
const popup = w2popup.open({
title: `模型预览 - ${escapeHtml(model.name)}`,
width: 860,
height: 620,
modal: true,
body: `
<div class="preview-shell">
<aside class="preview-process-panel">
<div class="preview-process-section">
<div class="preview-process-header">
<strong>工艺播放</strong>
<span>占位</span>
</div>
<div class="preview-model-meta">
<span>模型大小</span>
<strong>${formatBytes(model.file_size)}</strong>
</div>
<ul class="preview-process-tree">
<li class="is-active">
<button type="button" data-process-id="process-1">工艺 1</button>
</li>
<li>
<button type="button" data-process-id="process-2">工艺 2</button>
</li>
<li>
<button type="button" data-process-id="process-3">工艺 3</button>
</li>
</ul>
<div class="preview-process-actions">
<button id="previewPlayBtn" type="button">播放</button>
<button id="previewPauseBtn" type="button">暂停</button>
</div>
</div>
<div class="preview-scene-section">
<div class="preview-process-header">
<strong>导入场景</strong>
<span>占位</span>
</div>
<div class="preview-scene-body">
${isAdmin ? `
<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>
<label class="preview-switch">
<input id="previewUseBasePoint" type="checkbox" />
<span>启用基点导入</span>
</label>
<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>
<label><span>Z</span><input name="baseZ" type="number" value="0" step="0.001" /></label>
<label><span>RX</span><input name="baseRx" type="number" value="0" step="0.001" /></label>
<label><span>RY</span><input name="baseRy" type="number" value="0" step="0.001" /></label>
<label><span>RZ</span><input name="baseRz" type="number" value="0" step="0.001" /></label>
</div>
</div>
</div>
</aside>
<div class="preview-canvas-panel">
<div id="modelPreviewViewport" class="preview-viewport">
<div class="preview-loading">模型加载中...</div>
</div>
</div>
</div>
`,
actions: {
() {
disposePreview();
w2popup.close();
}
}
});
popup.self
.on("open:after", () => {
bindProcessPlaceholder();
if (isAdmin) bindThumbnailCapture(model.id, onThumbnailSaved);
initPreview(url).catch((error) => notifyError(error));
})
.on("close:after", () => disposePreview());
}
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);
}
runtime.renderer.render(runtime.scene, runtime.camera);
const thumbnail = runtime.renderer.domElement.toDataURL("image/png");
if (transparent) {
runtime.scene.background = oldBackground;
runtime.renderer.setClearAlpha(oldClearAlpha);
runtime.renderer.render(runtime.scene, runtime.camera);
}
await api(`/api/models/${modelId}/thumbnail`, {
method: "PUT",
body: JSON.stringify({ thumbnail })
});
await onThumbnailSaved?.();
notify("缩略图已保存");
} catch (error) {
notifyError(error);
}
});
}
function bindProcessPlaceholder() {
document.querySelectorAll<HTMLButtonElement>(".preview-process-tree button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
button.closest("li")?.classList.add("is-active");
});
});
}
async function initPreview(url: string) {
const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
if (!viewport) return;
viewport.innerHTML = "";
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf4f7f9);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
camera.up.set(0, 0, 1);
camera.position.set(3, -4, 2.5);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
viewport.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
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);
const object = gltf.scene;
scene.add(object);
fitCameraToObject(camera, controls, object);
const resize = () => {
const width = Math.max(viewport.clientWidth, 1);
const height = Math.max(viewport.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(viewport);
resize();
const animate = () => {
controls.update();
renderer.render(scene, camera);
if (runtime) {
runtime.animationId = requestAnimationFrame(animate);
}
};
runtime = {
renderer,
scene,
camera,
controls,
animationId: requestAnimationFrame(animate),
resizeObserver
};
}
function fitCameraToObject(camera: THREE.PerspectiveCamera, controls: OrbitControls, object: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxSize = Math.max(size.x, size.y, size.z) || 1;
const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));
camera.up.set(0, 0, 1);
camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
camera.near = Math.max(distance / 100, 0.01);
camera.far = distance * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
controls.minDistance = distance / 8;
controls.maxDistance = distance * 8;
controls.update();
}
function disposePreview() {
if (!runtime) return;
cancelAnimationFrame(runtime.animationId);
runtime.resizeObserver.disconnect();
runtime.controls.dispose();
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
runtime = null;
}