initial commit
This commit is contained in:
248
web/src/pages/app/modules/preview.ts
Normal file
248
web/src/pages/app/modules/preview.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user