| | |
| | | <script setup lang="ts"> |
| | | import { onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| | | import { BorderTopOutlined, ReloadOutlined } from "@ant-design/icons-vue"; |
| | | import { onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; |
| | | import { AimOutlined, BorderTopOutlined, ReloadOutlined, RotateRightOutlined } from "@ant-design/icons-vue"; |
| | | import * as THREE from "three"; |
| | | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; |
| | | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; |
| | |
| | | const error = ref<string | null>(null); |
| | | const triangleCount = ref(0); |
| | | const wireframe = ref(false); |
| | | const pivotPicking = ref(false); |
| | | const orientationOpen = ref(false); |
| | | const orientation = reactive({ x: 0, y: 0, z: 0 }); |
| | | const orientationAxes = [{ key: "x" as const, label: "X" }, { key: "y" as const, label: "Y" }, { key: "z" as const, label: "Z" }]; |
| | | |
| | | let renderer: THREE.WebGLRenderer | null = null; |
| | | let scene: THREE.Scene | null = null; |
| | |
| | | let animationFrame = 0; |
| | | let requestId = 0; |
| | | |
| | | function resize() { |
| | | if (!host.value || !renderer || !camera) return; |
| | | const width = Math.max(host.value.clientWidth, 1); |
| | | const height = Math.max(host.value.clientHeight, 1); |
| | | renderer.setSize(width, height, false); |
| | | camera.aspect = width / height; |
| | | camera.updateProjectionMatrix(); |
| | | } |
| | | |
| | | function frame() { |
| | | if (renderer && scene && camera && controls) { |
| | | controls.update(); |
| | | renderer.render(scene, camera); |
| | | } |
| | | animationFrame = requestAnimationFrame(frame); |
| | | } |
| | | function resize() { if (!host.value || !renderer || !camera) return; const width = Math.max(host.value.clientWidth, 1); const height = Math.max(host.value.clientHeight, 1); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } |
| | | function frame() { if (renderer && scene && camera && controls) { controls.update(); renderer.render(scene, camera); } animationFrame = requestAnimationFrame(frame); } |
| | | function modelCentre() { return model ? model.localToWorld(new THREE.Vector3()) : null; } |
| | | function modelSphere() { return model ? new THREE.Box3().setFromObject(model).getBoundingSphere(new THREE.Sphere()) : null; } |
| | | |
| | | function resetView() { |
| | | if (!camera || !controls || !model) return; |
| | | const bounds = new THREE.Box3().setFromObject(model); |
| | | const sphere = bounds.getBoundingSphere(new THREE.Sphere()); |
| | | const sphere = modelSphere(); const centre = modelCentre(); if (!sphere || !centre) return; |
| | | const radius = Math.max(sphere.radius, 0.01); |
| | | controls.target.copy(sphere.center); |
| | | camera.position.copy(sphere.center).add(new THREE.Vector3(radius * 1.2, radius * 0.8, radius * 1.65)); |
| | | camera.near = radius / 100; |
| | | camera.far = radius * 100; |
| | | camera.updateProjectionMatrix(); |
| | | controls.update(); |
| | | controls.target.copy(centre); controls.minDistance = radius * 0.08; controls.maxDistance = radius * 8; |
| | | camera.position.copy(centre).add(new THREE.Vector3(radius * 1.2, radius * 0.8, radius * 1.65)); |
| | | camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 100; camera.updateProjectionMatrix(); controls.update(); |
| | | } |
| | | |
| | | function topDownView() { |
| | | if (!camera || !controls || !model) return; |
| | | const bounds = new THREE.Box3().setFromObject(model); |
| | | const sphere = bounds.getBoundingSphere(new THREE.Sphere()); |
| | | const radius = Math.max(sphere.radius, 0.01); |
| | | const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5); |
| | | controls.target.copy(sphere.center); |
| | | camera.up.set(0, 1, 0); |
| | | camera.position.copy(sphere.center).add(new THREE.Vector3(0, 0, distance)); |
| | | camera.near = radius / 100; |
| | | camera.far = radius * 100; |
| | | camera.updateProjectionMatrix(); |
| | | controls.update(); |
| | | const sphere = modelSphere(); const centre = modelCentre(); if (!sphere || !centre) return; |
| | | const radius = Math.max(sphere.radius, 0.01); const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5); |
| | | controls.target.copy(centre); camera.up.set(0, 1, 0); |
| | | camera.position.copy(centre).add(new THREE.Vector3(0, 0, 1).applyQuaternion(model.quaternion).multiplyScalar(distance)); |
| | | camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 100; camera.updateProjectionMatrix(); controls.update(); |
| | | } |
| | | |
| | | function disposeModel() { |
| | |
| | | node.geometry.dispose(); |
| | | const materials = Array.isArray(node.material) ? node.material : [node.material]; |
| | | materials.forEach((material) => { |
| | | Object.values(material).forEach((value) => { |
| | | if (value instanceof THREE.Texture) value.dispose(); |
| | | }); |
| | | Object.values(material).forEach((value) => { if (value instanceof THREE.Texture) value.dispose(); }); |
| | | material.dispose(); |
| | | }); |
| | | }); |
| | |
| | | }); |
| | | } |
| | | |
| | | function applyOrientation() { if (model) model.rotation.set(THREE.MathUtils.degToRad(orientation.x), THREE.MathUtils.degToRad(orientation.y), THREE.MathUtils.degToRad(orientation.z), "XYZ"); } |
| | | function resetOrientation() { orientation.x = 0; orientation.y = 0; orientation.z = 0; } |
| | | function togglePivotPicking() { pivotPicking.value = !pivotPicking.value; } |
| | | function pickPivot(event: PointerEvent) { |
| | | if (!renderer || !camera || !model || !controls) return; |
| | | const rect = renderer.domElement.getBoundingClientRect(); |
| | | const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); |
| | | const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, camera); |
| | | const hit = raycaster.intersectObject(model, true)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; |
| | | } |
| | | function handleCanvasPointerDown(event: PointerEvent) { if (!pivotPicking.value || event.button !== 0) return; event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); } |
| | | function preventMiddleAutoScroll(event: MouseEvent) { if (event.button === 1) event.preventDefault(); } |
| | | function preventContextMenu(event: MouseEvent) { event.preventDefault(); } |
| | | |
| | | async function loadSource(source: string) { |
| | | const activeId = ++requestId; |
| | | loading.value = true; |
| | | error.value = null; |
| | | const activeId = ++requestId; loading.value = true; error.value = null; pivotPicking.value = false; resetOrientation(); |
| | | try { |
| | | const gltf = await new GLTFLoader().loadAsync(source); |
| | | if (activeId !== requestId || !scene) return; |
| | | const gltf = await new GLTFLoader().loadAsync(source); if (activeId !== requestId || !scene) return; |
| | | disposeModel(); |
| | | model = gltf.scene; |
| | | preparePreviewMaterials(model); |
| | | const content = gltf.scene; |
| | | const centre = new THREE.Box3().setFromObject(content).getCenter(new THREE.Vector3()); |
| | | content.position.sub(centre); |
| | | model = new THREE.Group(); model.position.copy(centre); model.add(content); |
| | | preparePreviewMaterials(content); |
| | | triangleCount.value = 0; |
| | | model.traverse((node) => { |
| | | if (node instanceof THREE.Mesh) triangleCount.value += node.geometry.index ? node.geometry.index.count / 3 : node.geometry.attributes.position.count / 3; |
| | | }); |
| | | scene.add(model); |
| | | applyWireframe(); |
| | | resetView(); |
| | | } catch (caught) { |
| | | if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "纹理模型无法读取。"; |
| | | } finally { |
| | | if (activeId === requestId) loading.value = false; |
| | | } |
| | | model.traverse((node) => { if (node instanceof THREE.Mesh) triangleCount.value += node.geometry.index ? node.geometry.index.count / 3 : node.geometry.attributes.position.count / 3; }); |
| | | scene.add(model); applyOrientation(); applyWireframe(); resetView(); |
| | | } catch (caught) { if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "Mesh preview could not be loaded."; } |
| | | finally { if (activeId === requestId) loading.value = false; } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | if (!host.value) return; |
| | | scene = new THREE.Scene(); |
| | | scene.background = new THREE.Color("#101821"); |
| | | scene.add(new THREE.HemisphereLight("#dce9ff", "#273137", 2.1)); |
| | | const keyLight = new THREE.DirectionalLight("#ffffff", 2.4); |
| | | keyLight.position.set(4, 6, 5); |
| | | scene.add(keyLight); |
| | | camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); |
| | | renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); |
| | | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| | | renderer.outputColorSpace = THREE.SRGBColorSpace; |
| | | host.value.appendChild(renderer.domElement); |
| | | controls = new OrbitControls(camera, renderer.domElement); |
| | | controls.enableDamping = true; |
| | | controls.dampingFactor = 0.08; |
| | | controls.screenSpacePanning = true; |
| | | resizeObserver = new ResizeObserver(resize); |
| | | resizeObserver.observe(host.value); |
| | | resize(); |
| | | frame(); |
| | | void loadSource(props.source); |
| | | scene = new THREE.Scene(); scene.background = new THREE.Color("#101821"); scene.add(new THREE.HemisphereLight("#dce9ff", "#273137", 2.1)); |
| | | const keyLight = new THREE.DirectionalLight("#ffffff", 2.4); keyLight.position.set(4, 6, 5); scene.add(keyLight); |
| | | camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; host.value.appendChild(renderer.domElement); |
| | | renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", handleCanvasPointerDown); renderer.domElement.addEventListener("contextmenu", preventContextMenu); |
| | | controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.rotateSpeed = 0.55; controls.zoomSpeed = 0.8; controls.panSpeed = 0.75; controls.screenSpacePanning = true; controls.minPolarAngle = 0.04; controls.maxPolarAngle = Math.PI - 0.04; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; |
| | | resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void loadSource(props.source); |
| | | }); |
| | | |
| | | watch(() => props.source, (source) => { void loadSource(source); }); |
| | | watch(() => props.surface, () => { if (props.source) void loadSource(props.source); }); |
| | | watch(wireframe, applyWireframe); |
| | | watch(orientation, applyOrientation, { deep: true }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | cancelAnimationFrame(animationFrame); |
| | | resizeObserver?.disconnect(); |
| | | disposeModel(); |
| | | controls?.dispose(); |
| | | renderer?.dispose(); |
| | | renderer?.domElement.remove(); |
| | | cancelAnimationFrame(animationFrame); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", handleCanvasPointerDown); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); disposeModel(); controls?.dispose(); renderer?.dispose(); renderer?.domElement.remove(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="textured-mesh-viewer"> |
| | | <div ref="host" class="canvas-host" aria-label="纹理网格交互视图" /> |
| | | <div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" aria-label="Mesh interactive view" /> |
| | | <div class="viewer-toolbar"> |
| | | <span>{{ Math.round(triangleCount).toLocaleString() }} 面</span> |
| | | <a-checkbox v-model:checked="wireframe">线框</a-checkbox> |
| | | <a-tooltip title="俯视模型(沿本地 Z 轴)"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip> |
| | | <a-popover v-model:open="orientationOpen" title="模型朝向" trigger="click"><template #content><div class="orientation-controls"><label v-for="axis in orientationAxes" :key="axis.key">{{ axis.label }}<a-slider v-model:value="orientation[axis.key]" :min="0" :max="360" :step="1" /><a-input-number v-model:value="orientation[axis.key]" :min="0" :max="360" :precision="0" addon-after="度" /></label><a-button size="small" @click="resetOrientation">重置</a-button></div></template><a-tooltip title="模型朝向"><a-button type="text" aria-label="模型朝向"><RotateRightOutlined /></a-button></a-tooltip></a-popover> |
| | | <a-tooltip title="设置旋转中心后,在模型上点击"><a-button :type="pivotPicking ? 'primary' : 'text'" aria-label="设置旋转中心" @click="togglePivotPicking"><AimOutlined /></a-button></a-tooltip> |
| | | <a-tooltip title="俯视模型"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip> |
| | | <a-tooltip title="复位视角"><a-button type="text" aria-label="复位视角" @click="resetView"><ReloadOutlined /></a-button></a-tooltip> |
| | | </div> |
| | | <div v-if="loading || error" class="viewer-state" :class="{ error }">{{ error || "正在加载纹理模型..." }}</div> |
| | | <div v-if="loading || error" class="viewer-state" :class="{ error }">{{ error || "正在加载网格模型..." }}</div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .textured-mesh-viewer { position: relative; height: 500px; overflow: hidden; border: 1px solid #243342; background: #101821; } |
| | | .canvas-host { width: 100%; height: 100%; } |
| | | .canvas-host.pivot-picking { cursor: crosshair; } |
| | | .canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; touch-action: none; } |
| | | .viewer-toolbar { position: absolute; right: 12px; top: 12px; display: flex; align-items: center; gap: 8px; padding: 5px 8px; color: #eef5f8; background: rgba(9, 18, 26, 0.78); } |
| | | .viewer-toolbar :deep(.ant-checkbox-wrapper) { color: #eef5f8; } |
| | | .viewer-toolbar :deep(.ant-btn) { color: #eef5f8; } |
| | | .viewer-toolbar :deep(.ant-checkbox-wrapper), .viewer-toolbar :deep(.ant-btn) { color: #eef5f8; } |
| | | .orientation-controls { display: grid; gap: 8px; min-width: 250px; } |
| | | .orientation-controls label { display: grid; grid-template-columns: 16px 1fr 84px; align-items: center; gap: 8px; } |
| | | .orientation-controls :deep(.ant-slider) { margin: 0; } |
| | | .orientation-controls :deep(.ant-input-number-group-wrapper) { width: 84px; } |
| | | .viewer-state { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; color: #eef5f8; background: rgba(16, 24, 33, 0.7); text-align: center; } |
| | | .viewer-state.error { color: #ffd7cf; } |
| | | @media (max-width: 767px) { .textured-mesh-viewer { height: 360px; } } |
| | | @media (max-width: 767px) { .textured-mesh-viewer { height: 360px; } .viewer-toolbar { left: 8px; right: 8px; flex-wrap: wrap; } } |
| | | </style> |