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) { 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: `
模型加载中...
`, 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) { document.querySelector("#previewCaptureThumbBtn")?.addEventListener("click", async () => { try { if (!runtime) { throw new Error("模型还未加载完成"); } const transparent = document.querySelector("#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(".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("#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; }