shuishen
19 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
176
177
178
179
180
181
182
<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";
 
const props = defineProps<{ source: string }>();
 
type PropertyReader = { name: string; bytes: number; read: (view: DataView, offset: number) => number };
const host = ref<HTMLDivElement | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const pointCount = ref(0);
const pointSize = ref(2.4);
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 points: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null;
let model: THREE.Group | null = null;
let resizeObserver: ResizeObserver | null = null;
let animationFrame = 0;
let requestId = 0;
 
const readers: Record<string, Omit<PropertyReader, "name">> = {
  char: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, int8: { bytes: 1, read: (view, offset) => view.getInt8(offset) },
  uchar: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, uint8: { bytes: 1, read: (view, offset) => view.getUint8(offset) },
  short: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, int16: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) },
  ushort: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, uint16: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) },
  int: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, int32: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) },
  uint: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, uint32: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) },
  float: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, float32: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) },
  double: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, float64: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) },
};
 
function headerEnd(bytes: Uint8Array): number {
  const marker = [101, 110, 100, 95, 104, 101, 97, 100, 101, 114];
  for (let start = 0; start <= bytes.length - marker.length; start += 1) {
    if (marker.every((value, index) => bytes[start + index] === value)) {
      let end = start + marker.length;
      if (bytes[end] === 13) end += 1;
      if (bytes[end] === 10) return end + 1;
    }
  }
  throw new Error("PLY file is missing end_header.");
}
 
function parsePly(buffer: ArrayBuffer): { positions: Float32Array; colors: Float32Array } {
  const end = headerEnd(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024)));
  const lines = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, end)).split(/\r?\n/);
  if (!lines.some((line) => line.trim() === "format binary_little_endian 1.0")) throw new Error("Only binary_little_endian PLY is supported.");
  const count = Number(lines.find((line) => line.startsWith("element vertex "))?.split(/\s+/)[2]);
  if (!Number.isSafeInteger(count) || count < 1) throw new Error("Invalid PLY vertex count.");
  const properties: PropertyReader[] = [];
  let inVertex = false;
  for (const line of lines) {
    if (line.startsWith("element ")) { inVertex = line.startsWith("element vertex "); continue; }
    if (!inVertex || !line.startsWith("property ")) continue;
    const [, type, name] = line.trim().split(/\s+/);
    if (!readers[type] || !name) throw new Error(`Unsupported PLY vertex property: ${type ?? "unknown"}.`);
    properties.push({ name, ...readers[type] });
  }
  const offsets: Record<string, number> = {};
  let stride = 0;
  for (const property of properties) { offsets[property.name] = stride; stride += property.bytes; }
  if (["x", "y", "z"].some((name) => offsets[name] === undefined) || end + stride * count > buffer.byteLength) throw new Error("PLY coordinate data is incomplete.");
  const byName = Object.fromEntries(properties.map((property) => [property.name, property]));
  const view = new DataView(buffer, end);
  const positions = new Float32Array(count * 3);
  const colors = new Float32Array(count * 3);
  const hasColor = ["red", "green", "blue"].every((name) => offsets[name] !== undefined);
  for (let index = 0; index < count; index += 1) {
    const base = index * stride;
    for (const [axis, offset] of [["x", 0], ["y", 1], ["z", 2]] as const) positions[index * 3 + offset] = byName[axis].read(view, base + offsets[axis]);
    for (const [channel, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) colors[index * 3 + offset] = hasColor ? byName[channel].read(view, base + offsets[channel]) / 255 : 0.72;
  }
  return { positions, colors };
}
 
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 modelRadius() { points?.geometry.computeBoundingSphere(); return Math.max(points?.geometry.boundingSphere?.radius ?? 0.01, 0.01); }
 
function resetView() {
  if (!camera || !controls || !points) return;
  const centre = modelCentre(); if (!centre) return;
  const radius = modelRadius();
  controls.target.copy(centre); controls.minDistance = radius * 0.08; controls.maxDistance = radius * 8;
  camera.position.copy(centre).add(new THREE.Vector3(radius * 1.25, radius * 0.85, 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 centre = modelCentre(); if (!centre) return;
  const radius = modelRadius(); 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 applyPointSize() { if (points) points.material.size = pointSize.value; }
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 || !points || !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.params.Points.threshold = Math.max(0.35, pointSize.value * 0.5); raycaster.setFromCamera(mouse, camera);
  const hit = raycaster.intersectObject(points, false)[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 response = await fetch(source, { cache: "no-store" }); if (!response.ok) throw new Error(`Point cloud request failed (${response.status}).`);
    const parsed = parsePly(await response.arrayBuffer()); if (activeId !== requestId || !scene) return;
    if (model) scene.remove(model); points?.geometry.dispose(); points?.material.dispose();
    const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(parsed.positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(parsed.colors, 3)); geometry.computeBoundingSphere();
    points = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false }));
    const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3(); model = new THREE.Group(); model.position.copy(centre); points.position.copy(centre).multiplyScalar(-1); model.add(points); scene.add(model);
    applyOrientation(); pointCount.value = parsed.positions.length / 3; resetView();
  } catch (caught) { if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "Point cloud 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"); 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(pointSize, applyPointSize);
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); controls?.dispose(); points?.geometry.dispose(); points?.material.dispose(); renderer?.dispose(); renderer?.domElement.remove();
});
</script>
 
<template>
  <div class="sparse-point-viewer">
    <div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" aria-label="Point cloud interactive view" />
    <div class="viewer-toolbar">
      <span>{{ pointCount.toLocaleString() }} 点</span>
      <label>点大小 <a-slider v-model:value="pointSize" :min="1" :max="8" :step="0.2" /></label>
      <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"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div>
  </div>
</template>
 
<style scoped>
.sparse-point-viewer { position: relative; height: 560px; overflow: hidden; background: #101821; border: 1px solid #273543; }
.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; bottom: 12px; display: flex; align-items: center; gap: 12px; padding: 8px 10px; color: #e8f1f5; background: rgba(12, 20, 29, 0.84); font-size: 13px; }
.viewer-toolbar label { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.viewer-toolbar :deep(.ant-slider) { width: 100px; margin: 0; }
.viewer-toolbar :deep(.ant-btn) { color: #e8f1f5; }
.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; color: #e8f1f5; background: rgba(12, 20, 29, 0.65); }
@media (max-width: 767px) { .sparse-point-viewer { height: 420px; } .viewer-toolbar { left: 8px; right: 8px; bottom: 8px; gap: 8px; flex-wrap: wrap; } .viewer-toolbar :deep(.ant-slider) { width: min(92px, 24vw); } }
</style>