From 385be2eca72eb3833efa4be0a0088b34e764788a Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Mon, 31 Aug 2026 09:04:45 +0800
Subject: [PATCH] feat(pointcloud): complete annotation and result lifecycle workflows
---
apps/workbench-console/src/components/SparsePointCloudViewer.vue | 233 +++++++++++++++++++++------------------------------------
1 files changed, 87 insertions(+), 146 deletions(-)
diff --git a/apps/workbench-console/src/components/SparsePointCloudViewer.vue b/apps/workbench-console/src/components/SparsePointCloudViewer.vue
index cbd92f0..588c12a 100644
--- a/apps/workbench-console/src/components/SparsePointCloudViewer.vue
+++ b/apps/workbench-console/src/components/SparsePointCloudViewer.vue
@@ -1,49 +1,45 @@
<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";
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) },
+ 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]; // end_header
+ 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;
@@ -51,177 +47,117 @@
if (bytes[end] === 10) return end + 1;
}
}
- throw new Error("PLY 文件缺少 end_header。");
+ throw new Error("PLY file is missing end_header.");
}
function parsePly(buffer: ArrayBuffer): { positions: Float32Array; colors: Float32Array } {
- const bytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024));
- const end = headerEnd(bytes);
- const header = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, end));
- const lines = header.split(/\r?\n/);
- if (!lines.some((line) => line.trim() === "format binary_little_endian 1.0")) {
- throw new Error("当前仅支持 binary_little_endian PLY。");
- }
- const vertexLine = lines.find((line) => line.startsWith("element vertex "));
- const count = Number(vertexLine?.split(/\s+/)[2]);
- if (!Number.isSafeInteger(count) || count < 1) throw new Error("PLY 顶点数量无效。");
+ 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 (line.startsWith("element ")) { inVertex = line.startsWith("element vertex "); continue; }
if (!inVertex || !line.startsWith("property ")) continue;
const [, type, name] = line.trim().split(/\s+/);
- const reader = readers[type];
- if (!reader || !name) throw new Error(`不支持的顶点属性类型:${type ?? "未知"}。`);
- properties.push({ name, ...reader });
+ if (!readers[type] || !name) throw new Error(`Unsupported PLY vertex property: ${type ?? "unknown"}.`);
+ properties.push({ name, ...readers[type] });
}
- const stride = properties.reduce((total, property) => total + property.bytes, 0);
- if (end + stride * count > buffer.byteLength) throw new Error("PLY 顶点数据不完整。");
- const offsets = Object.fromEntries(properties.reduce<[string, number][]>((items, property) => {
- const offset = items.length ? items[items.length - 1][1] + properties[items.length - 1].bytes : 0;
- items.push([property.name, offset]);
- return items;
- }, []));
- const required = ["x", "y", "z"];
- if (required.some((name) => offsets[name] === undefined)) throw new Error("PLY 缺少 x/y/z 顶点坐标。");
- const propertyByName = Object.fromEntries(properties.map((property) => [property.name, property]));
+ 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] = propertyByName[axis].read(view, base + offsets[axis]);
- }
- for (const [channel, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) {
- colors[index * 3 + offset] = hasColor ? propertyByName[channel].read(view, base + offsets[channel]) / 255 : 0.72;
- }
+ 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 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 sphere = new THREE.Sphere();
- points.geometry.computeBoundingSphere();
- if (!points.geometry.boundingSphere) return;
- sphere.copy(points.geometry.boundingSphere);
- const radius = Math.max(sphere.radius, 0.01);
- controls.target.copy(sphere.center);
- camera.position.copy(sphere.center).add(new THREE.Vector3(radius * 1.25, radius * 0.85, radius * 1.65));
- camera.near = radius / 100;
- camera.far = radius * 100;
- camera.updateProjectionMatrix();
- controls.update();
+ 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 || !points) return;
- points.geometry.computeBoundingSphere();
- if (!points.geometry.boundingSphere) return;
- const sphere = points.geometry.boundingSphere;
- 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();
+ 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 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;
+ 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(`点云读取失败 (${response.status})。`);
- const parsed = parsePly(await response.arrayBuffer());
- if (activeId !== requestId || !scene) return;
- 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));
- const material = new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false });
- points = new THREE.Points(geometry, material);
- scene.add(points);
- pointCount.value = parsed.positions.length / 3;
- resetView();
- } catch (caught) {
- if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。";
- } finally {
- if (activeId === requestId) loading.value = false;
- }
+ 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));
- 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"); 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();
- controls?.dispose();
- points?.geometry.dispose();
- points?.material.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); 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" aria-label="稀疏点云交互视图" />
+ <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-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"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div>
@@ -231,11 +167,16 @@
<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 :deep(canvas) { display: block; 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; } .viewer-toolbar :deep(.ant-slider) { width: min(92px, 24vw); } }
+@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>
--
Gitblit v1.9.3