249 lines
8.8 KiB
TypeScript
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;
|
|
}
|