<script setup lang="ts">
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { BorderTopOutlined, ReloadOutlined } 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 props = withDefaults(defineProps<{ source: string; surface: "textured" | "geometry" | "point_colors" }>(), { surface: "textured" });
|
|
const host = ref<HTMLDivElement | null>(null);
|
const loading = ref(true);
|
const error = ref<string | null>(null);
|
const triangleCount = ref(0);
|
const wireframe = ref(false);
|
|
let renderer: THREE.WebGLRenderer | null = null;
|
let scene: THREE.Scene | null = null;
|
let camera: THREE.PerspectiveCamera | null = null;
|
let controls: OrbitControls | null = null;
|
let model: THREE.Group | null = null;
|
let resizeObserver: ResizeObserver | 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 resetView() {
|
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);
|
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();
|
}
|
|
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();
|
}
|
|
function disposeModel() {
|
if (!model || !scene) return;
|
model.traverse((node) => {
|
if (!(node instanceof THREE.Mesh)) return;
|
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();
|
});
|
material.dispose();
|
});
|
});
|
scene.remove(model);
|
model = null;
|
}
|
|
function applyWireframe() {
|
model?.traverse((node) => {
|
if (!(node instanceof THREE.Mesh)) return;
|
const materials = Array.isArray(node.material) ? node.material : [node.material];
|
materials.forEach((material) => { material.wireframe = wireframe.value; material.needsUpdate = true; });
|
});
|
}
|
|
function preparePreviewMaterials(group: THREE.Group) {
|
group.traverse((node) => {
|
if (!(node instanceof THREE.Mesh)) return;
|
const materials = Array.isArray(node.material) ? node.material : [node.material];
|
const previewMaterials = materials.map((material) => {
|
const source = material as THREE.MeshStandardMaterial;
|
return new THREE.MeshBasicMaterial({
|
map: props.surface === "textured" ? source.map ?? null : null,
|
color: props.surface === "textured" || props.surface === "point_colors" ? 0xffffff : "#9ba5a8",
|
vertexColors: props.surface === "point_colors",
|
side: THREE.DoubleSide,
|
transparent: source.transparent,
|
opacity: source.opacity,
|
});
|
});
|
node.material = Array.isArray(node.material) ? previewMaterials : previewMaterials[0];
|
materials.forEach((material) => material.dispose());
|
});
|
}
|
|
async function loadSource(source: string) {
|
const activeId = ++requestId;
|
loading.value = true;
|
error.value = null;
|
try {
|
const gltf = await new GLTFLoader().loadAsync(source);
|
if (activeId !== requestId || !scene) return;
|
disposeModel();
|
model = gltf.scene;
|
preparePreviewMaterials(model);
|
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;
|
}
|
}
|
|
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);
|
});
|
|
watch(() => props.source, (source) => { void loadSource(source); });
|
watch(() => props.surface, () => { if (props.source) void loadSource(props.source); });
|
watch(wireframe, applyWireframe);
|
|
onBeforeUnmount(() => {
|
cancelAnimationFrame(animationFrame);
|
resizeObserver?.disconnect();
|
disposeModel();
|
controls?.dispose();
|
renderer?.dispose();
|
renderer?.domElement.remove();
|
});
|
</script>
|
|
<template>
|
<div class="textured-mesh-viewer">
|
<div ref="host" class="canvas-host" aria-label="纹理网格交互视图" />
|
<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-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>
|
</template>
|
|
<style scoped>
|
.textured-mesh-viewer { position: relative; height: 500px; overflow: hidden; border: 1px solid #243342; background: #101821; }
|
.canvas-host { width: 100%; height: 100%; }
|
.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-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; } }
|
</style>
|