shuishen
14 hours ago 385be2eca72eb3833efa4be0a0088b34e764788a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
<script setup lang="ts">
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 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);
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 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 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 sphere = modelSphere(); const centre = modelCentre(); if (!sphere || !centre) return;
  const radius = Math.max(sphere.radius, 0.01);
  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 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() {
  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());
  });
}
 
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; pivotPicking.value = false; resetOrientation();
  try {
    const gltf = await new GLTFLoader().loadAsync(source); if (activeId !== requestId || !scene) return;
    disposeModel();
    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); 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);
  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(); 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" :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-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>
</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), .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; } .viewer-toolbar { left: 8px; right: 8px; flex-wrap: wrap; } }
</style>