<script setup lang="ts">
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
import { AimOutlined, BorderTopOutlined, ReloadOutlined, RotateRightOutlined, UndoOutlined } from "@ant-design/icons-vue";
|
import * as THREE from "three";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
type AnnotationClass = { code: number; key: string; label: string; color: number[] };
|
const props = defineProps<{ source: string; sourceId: string; detailAvailable?: boolean; classes: AnnotationClass[]; disabled?: boolean; reviewMode?: boolean; initialReviewChanges?: Array<[number, number]> }>();
|
const emit = defineEmits<{ save: [labels: Array<[number, number]>]; reviewSave: [changes: Array<[number, number]>] }>();
|
type Reader = { name: string; bytes: number; read: (view: DataView, offset: number) => number };
|
type InteractionMode = "navigate" | "brush" | "rectangle";
|
|
const readers: Record<string, Omit<Reader, "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) },
|
};
|
const host = ref<HTMLDivElement | null>(null); const loading = ref(true); const error = ref<string | null>(null); const pointCount = ref(0); const brushRadius = ref(1.5); const classCode = ref(16); const pointSize = ref(3); const labelledCount = ref(0); const interactionMode = ref<InteractionMode>("navigate"); const pivotPicking = ref(false); const orientationOpen = ref(false); const orientation = reactive({ x: 0, y: 0, z: 0 }); const selectionBox = ref<{ left: number; top: number; width: number; height: number } | null>(null); const detailStatus = ref(""); const detailPointCount = ref(0);
|
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 cloud: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; let detailCloud: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; let circleTexture: THREE.CanvasTexture | null = null; let resizeObserver: ResizeObserver | null = null; let animation = 0; let requestId = 0; let detailRequestId = 0; let detailTimer = 0; let detailAbort: AbortController | null = null; let overviewCenter = new THREE.Vector3(); let overviewRadius = 0; let detailKey = ""; let activePointer: number | null = null; let rectangleStart: { x: number; y: number } | null = null; let lastBrushPosition: { x: number; y: number } | null = null;
|
let positions = new Float32Array(); let baseColors = new Float32Array(); let shownColors = new Float32Array(); let labels = new Int16Array(); let initialLabels = new Int16Array(); let history: Array<Array<[number, number]>> = []; const reviewChanges = new Map<number, number>();
|
const classOptions = computed(() => [...(props.reviewMode ? [{ value: 0, label: "拒绝候选" }] : []), ...props.classes.map((item) => ({ value: item.code, label: item.label }))]);
|
const interactionOptions = [{ label: "浏览", value: "navigate" }, { label: "笔刷", value: "brush" }, { label: "框选", value: "rectangle" }];
|
const orientationAxes = [{ key: "x" as const, label: "X" }, { key: "y" as const, label: "Y" }, { key: "z" as const, label: "Z" }];
|
const srgbToLinear = (value: number) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
function headerEnd(bytes: Uint8Array) { 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 文件缺少 end_header。"); }
|
function parse(buffer: ArrayBuffer) {
|
const headerLength = headerEnd(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024))); const header = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, headerLength)); 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 count = Number(lines.find((line) => line.startsWith("element vertex "))?.split(/\s+/)[2]); if (!Number.isSafeInteger(count) || count < 1) throw new Error("PLY 顶点数无效。");
|
const properties: Reader[] = []; let vertex = false; for (const line of lines) { if (line.startsWith("element ")) { vertex = line.startsWith("element vertex "); continue; } if (vertex && line.startsWith("property ")) { const [, type, name] = line.trim().split(/\s+/); if (!readers[type] || !name) throw new Error("PLY 顶点属性不受支持。"); 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) || headerLength + stride * count > buffer.byteLength) throw new Error("PLY 坐标数据不完整。");
|
const byName = Object.fromEntries(properties.map((item) => [item.name, item])); const view = new DataView(buffer, headerLength); const xyz = new Float32Array(count * 3); const rgb = new Float32Array(count * 3); const hasRgb = ["red", "green", "blue"].every((name) => offsets[name] !== undefined);
|
for (let index = 0; index < count; index += 1) { const base = index * stride; for (const [name, offset] of [["x", 0], ["y", 1], ["z", 2]] as const) xyz[index * 3 + offset] = byName[name].read(view, base + offsets[name]); for (const [name, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) { const value = hasRgb ? Math.min(Math.max(byName[name].read(view, base + offsets[name]) / 255, 0), 1) : 0.72; rgb[index * 3 + offset] = srgbToLinear(value); } }
|
return { xyz, rgb };
|
}
|
function createCircleTexture() { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); if (!context) throw new Error("无法创建点云渲染纹理。"); const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(0.72, "rgba(255,255,255,1)"); gradient.addColorStop(1, "rgba(255,255,255,0)"); context.fillStyle = gradient; context.fillRect(0, 0, 64, 64); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; }
|
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); } animation = requestAnimationFrame(frame); }
|
function modelCentre() { if (!cloud) return null; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; return sphere ? cloud.localToWorld(sphere.center.clone()) : null; }
|
function reset() { if (!cloud || !camera || !controls) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; 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.25, radius * 0.85, radius * 1.65)); camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 20; camera.updateProjectionMatrix(); controls.update(); }
|
function topDown() { if (!cloud || !camera || !controls || !model) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; const centre = modelCentre(); if (!sphere || !centre) return; const distance = Math.max(camera.position.distanceTo(controls.target), sphere.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.updateProjectionMatrix(); controls.update(); }
|
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 setPointSize() { if (cloud) cloud.material.size = pointSize.value; if (detailCloud) detailCloud.material.size = pointSize.value; }
|
function disposeDetail() {
|
detailAbort?.abort(); detailAbort = null; detailRequestId += 1; detailKey = "";
|
if (detailCloud && model) model.remove(detailCloud);
|
detailCloud?.geometry.dispose(); detailCloud?.material.dispose(); detailCloud = null;
|
if (cloud) cloud.visible = true;
|
detailPointCount.value = 0; detailStatus.value = "";
|
}
|
function detailRequest() {
|
if (!props.detailAvailable || !camera || !controls || !model || !cloud || !overviewRadius) return null;
|
const distance = camera.position.distanceTo(controls.target);
|
if (distance >= overviewRadius * 1.1) return null;
|
const radius = THREE.MathUtils.clamp(distance * 0.55, overviewRadius * 0.01, overviewRadius * 0.25);
|
const center = model.worldToLocal(controls.target.clone()).add(overviewCenter);
|
const step = Math.max(radius * 0.4, 0.001);
|
const key = [...center.toArray().map((value) => Math.round(value / step)), Math.round(radius / step)].join(":");
|
return { center, radius, key };
|
}
|
async function loadDetail() {
|
const request = detailRequest();
|
if (!request) { disposeDetail(); return; }
|
if (request.key === detailKey && detailCloud) return;
|
detailAbort?.abort(); const controller = new AbortController(); detailAbort = controller;
|
const active = ++detailRequestId; detailStatus.value = "正在加载局部高密度点...";
|
try {
|
const query = new URLSearchParams({ sourceId: props.sourceId, x: request.center.x.toString(), y: request.center.y.toString(), z: request.center.z.toString(), radius: request.radius.toString() });
|
const response = await fetch(`/api/3d-pointcloud/annotation-detail?${query.toString()}`, { cache: "no-store", signal: controller.signal });
|
if (!response.ok) { const payload = await response.json().catch(() => ({})) as { error?: string }; throw new Error(payload.error || `局部点云读取失败 (${response.status})。`); }
|
const parsed = parse(await response.arrayBuffer());
|
if (active !== detailRequestId || !model || !cloud) return;
|
if (detailCloud) { model.remove(detailCloud); detailCloud.geometry.dispose(); detailCloud.material.dispose(); }
|
const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(parsed.xyz, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(parsed.rgb, 3));
|
detailCloud = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false, map: circleTexture ?? undefined, transparent: true, opacity: 0.96, alphaTest: 0.12, depthWrite: true }));
|
detailCloud.position.copy(overviewCenter).multiplyScalar(-1); model.add(detailCloud); cloud.visible = false;
|
detailKey = request.key; detailPointCount.value = parsed.xyz.length / 3; detailStatus.value = `局部高密度显示 ${detailPointCount.value.toLocaleString()} 点`;
|
} catch (caught) {
|
if (active !== detailRequestId || (caught instanceof DOMException && caught.name === "AbortError")) return;
|
if (!detailCloud && cloud) cloud.visible = true;
|
detailStatus.value = caught instanceof Error ? caught.message : "局部高密度点加载失败。";
|
}
|
}
|
function scheduleDetail() { window.clearTimeout(detailTimer); detailTimer = window.setTimeout(() => { void loadDetail(); }, 550); }
|
function recolor(indices: number[]) { if (!cloud) return; for (const index of indices) { const item = props.classes.find((value) => value.code === labels[index]); for (let channel = 0; channel < 3; channel += 1) shownColors[index * 3 + channel] = item ? srgbToLinear(item.color[channel] / 255) : props.reviewMode ? 0.28 : baseColors[index * 3 + channel]; } (cloud.geometry.getAttribute("color") as THREE.BufferAttribute).needsUpdate = true; }
|
function updateReviewChange(index: number) { if (!props.reviewMode) return; if (labels[index] === initialLabels[index]) reviewChanges.delete(index); else reviewChanges.set(index, labels[index]); labelledCount.value = reviewChanges.size; }
|
function applyClass(indices: number[]) { const changes: Array<[number, number]> = []; for (const index of indices) if (labels[index] !== classCode.value) { changes.push([index, labels[index]]); labels[index] = classCode.value; updateReviewChange(index); } if (changes.length) { history.push(changes); recolor(changes.map(([index]) => index)); if (!props.reviewMode) labelledCount.value = labels.reduce((total, value) => total + (value ? 1 : 0), 0); } }
|
function brush(event: PointerEvent) { if (props.disabled || !renderer || !camera || !cloud || loading.value) 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, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(cloud, false)[0]; if (hit?.index === undefined) return; const center = new THREE.Vector3().fromBufferAttribute(cloud.geometry.getAttribute("position") as THREE.BufferAttribute, hit.index); const radiusSquared = brushRadius.value ** 2; const selected: number[] = []; for (let index = 0; index < labels.length; index += 1) { const dx = positions[index * 3] - center.x; const dy = positions[index * 3 + 1] - center.y; const dz = positions[index * 3 + 2] - center.z; if (dx * dx + dy * dy + dz * dz <= radiusSquared) selected.push(index); } applyClass(selected); }
|
function canvasPosition(event: PointerEvent) { const rect = renderer!.domElement.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top, width: rect.width, height: rect.height }; }
|
function pickPivot(event: PointerEvent) { if (!renderer || !camera || !cloud || !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, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(detailCloud ?? cloud, false)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; }
|
function togglePivotPicking() { pivotPicking.value = !pivotPicking.value; }
|
function preventMiddleAutoScroll(event: MouseEvent) { if (event.button === 1) event.preventDefault(); }
|
function preventContextMenu(event: MouseEvent) { event.preventDefault(); }
|
function finishPointer(event: PointerEvent) { if (!renderer || activePointer !== event.pointerId) return; activePointer = null; rectangleStart = null; lastBrushPosition = null; selectionBox.value = null; if (controls) controls.enabled = true; if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId); }
|
function selectionStart(event: PointerEvent) { if (pivotPicking.value && event.button === 0) { event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); return; } if (interactionMode.value === "navigate" || event.button !== 0 || props.disabled || detailCloud || !renderer || !controls) return; event.preventDefault(); event.stopImmediatePropagation(); activePointer = event.pointerId; controls.enabled = false; renderer.domElement.setPointerCapture(event.pointerId); const point = canvasPosition(event); if (interactionMode.value === "brush") { lastBrushPosition = { x: point.x, y: point.y }; brush(event); return; } rectangleStart = { x: point.x, y: point.y }; selectionBox.value = { left: point.x, top: point.y, width: 0, height: 0 }; }
|
function selectionMove(event: PointerEvent) { if (!renderer || activePointer !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); const point = canvasPosition(event); if (interactionMode.value === "brush") { if (!lastBrushPosition || Math.hypot(point.x - lastBrushPosition.x, point.y - lastBrushPosition.y) >= 10) { lastBrushPosition = { x: point.x, y: point.y }; brush(event); } return; } if (!rectangleStart) return; selectionBox.value = { left: Math.min(rectangleStart.x, point.x), top: Math.min(rectangleStart.y, point.y), width: Math.abs(point.x - rectangleStart.x), height: Math.abs(point.y - rectangleStart.y) }; }
|
function selectionEnd(event: PointerEvent) { if (!renderer || !camera || activePointer !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); if (interactionMode.value === "brush") { brush(event); finishPointer(event); return; } if (!rectangleStart) { finishPointer(event); return; } const point = canvasPosition(event); const left = Math.min(rectangleStart.x, point.x); const right = Math.max(rectangleStart.x, point.x); const top = Math.min(rectangleStart.y, point.y); const bottom = Math.max(rectangleStart.y, point.y); const selected: number[] = []; const position = new THREE.Vector3(); for (let index = 0; index < labels.length; index += 1) { position.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]); cloud!.localToWorld(position).project(camera); if (position.z < -1 || position.z > 1) continue; const x = (position.x + 1) * 0.5 * point.width; const y = (1 - position.y) * 0.5 * point.height; if (x >= left && x <= right && y >= top && y <= bottom) selected.push(index); } finishPointer(event); applyClass(selected); }
|
function undo() { const changes = history.pop(); if (!changes) return; for (const [index, old] of changes) { labels[index] = old; updateReviewChange(index); } recolor(changes.map(([index]) => index)); if (!props.reviewMode) labelledCount.value = labels.reduce((total, value) => total + (value ? 1 : 0), 0); }
|
function clear() { if (props.reviewMode) { const indices = [...reviewChanges.keys()]; for (const index of indices) labels[index] = initialLabels[index]; reviewChanges.clear(); history = []; labelledCount.value = 0; recolor(indices); return; } const indices = Array.from({ length: labels.length }, (_, index) => index).filter((index) => labels[index]); labels.fill(0); history = []; recolor(indices); labelledCount.value = 0; }
|
function save() { if (props.reviewMode) { emit("reviewSave", [...reviewChanges.entries()].sort(([left], [right]) => left - right)); return; } emit("save", Array.from(labels.entries()).filter(([, code]) => code > 0).map(([index, code]) => [index, code])); }
|
function labelFromColor(index: number) { for (const item of props.classes) { const expected = item.color.map((value) => srgbToLinear(value / 255)); if (Math.abs(baseColors[index * 3] - expected[0]) < 0.005 && Math.abs(baseColors[index * 3 + 1] - expected[1]) < 0.005 && Math.abs(baseColors[index * 3 + 2] - expected[2]) < 0.005) return item.code; } return 0; }
|
function initializeReview() { if (!props.reviewMode || loading.value || !labels.length || initialLabels.length === labels.length) return; for (let index = 0; index < labels.length; index += 1) labels[index] = labelFromColor(index); initialLabels = labels.slice(); reviewChanges.clear(); for (const item of props.initialReviewChanges ?? []) if (item[0] >= 0 && item[0] < labels.length && item[1] >= 0 && props.classes.some((value) => value.code === item[1])) { labels[item[0]] = item[1]; updateReviewChange(item[0]); } labelledCount.value = reviewChanges.size; }
|
async function load(source: string) { const active = ++requestId; loading.value = true; error.value = null; disposeDetail(); try { const response = await fetch(source, { cache: "no-store" }); if (!response.ok) throw new Error(`点云读取失败 (${response.status})。`); const parsed = parse(await response.arrayBuffer()); if (active !== requestId || !scene) return; if (model) scene.remove(model); cloud?.geometry.dispose(); cloud?.material.dispose(); positions = parsed.xyz; baseColors = parsed.rgb; shownColors = parsed.rgb.slice(); labels = new Int16Array(positions.length / 3); history = []; const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(shownColors, 3)); geometry.computeBoundingSphere(); const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3(); overviewCenter = centre.clone(); overviewRadius = Math.max(geometry.boundingSphere?.radius ?? 0, 0.01); cloud = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false, map: circleTexture ?? undefined, transparent: true, opacity: 0.9, alphaTest: 0.12, depthWrite: true })); model = new THREE.Group(); model.position.copy(centre); cloud.position.copy(centre).multiplyScalar(-1); model.add(cloud); scene.add(model); applyOrientation(); pointCount.value = labels.length; labelledCount.value = 0; reset(); } catch (caught) { if (active === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。"; } finally { if (active === 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; circleTexture = createCircleTexture(); host.value.appendChild(renderer.domElement); renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", selectionStart); renderer.domElement.addEventListener("pointermove", selectionMove); renderer.domElement.addEventListener("pointerup", selectionEnd); renderer.domElement.addEventListener("pointercancel", finishPointer); 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 }; controls.addEventListener("end", scheduleDetail); resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void load(props.source); });
|
watch(() => props.source, (source) => { initialLabels = new Int16Array(); void load(source); });
|
watch(() => props.classes, (classes) => { if (!classes.some((item) => item.code === classCode.value)) classCode.value = classes[0]?.code ?? 0; }, { immediate: true });
|
watch(pointSize, setPointSize); watch(orientation, applyOrientation, { deep: true }); watch(loading, initializeReview);
|
watch(() => props.initialReviewChanges, (values) => { if (!props.reviewMode || !initialLabels.length) return; const changed = new Set<number>(reviewChanges.keys()); for (const index of changed) labels[index] = initialLabels[index]; reviewChanges.clear(); for (const item of values ?? []) if (item[0] >= 0 && item[0] < labels.length && item[1] >= 0 && props.classes.some((value) => value.code === item[1])) { labels[item[0]] = item[1]; updateReviewChange(item[0]); changed.add(item[0]); } recolor([...changed]); }, { deep: true });
|
onBeforeUnmount(() => { cancelAnimationFrame(animation); window.clearTimeout(detailTimer); disposeDetail(); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", selectionStart); renderer?.domElement.removeEventListener("pointermove", selectionMove); renderer?.domElement.removeEventListener("pointerup", selectionEnd); renderer?.domElement.removeEventListener("pointercancel", finishPointer); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); controls?.removeEventListener("end", scheduleDetail); controls?.dispose(); cloud?.geometry.dispose(); cloud?.material.dispose(); circleTexture?.dispose(); renderer?.dispose(); renderer?.domElement.remove(); });
|
</script>
|
|
<template>
|
<div v-if="detailStatus" class="detail-status">{{ detailStatus }}<span v-if="detailPointCount">;细节层仅用于查看,标注请缩小回概览</span></div>
|
<div class="annotation-viewer"><div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" /><div v-if="selectionBox" class="selection-box" :style="{ left: `${selectionBox.left}px`, top: `${selectionBox.top}px`, width: `${selectionBox.width}px`, height: `${selectionBox.height}px` }" /><div class="annotation-toolbar"><a-select v-model:value="classCode" :options="classOptions" :disabled="disabled" /><a-segmented v-model:value="interactionMode" :options="interactionOptions" :disabled="disabled" /><label v-if="interactionMode === 'brush'">刷选半径<a-slider v-model:value="brushRadius" :min="0.2" :max="8" :step="0.1" :disabled="disabled" /></label><label>点大小<a-slider v-model:value="pointSize" :min="1" :max="7" :step="0.2" /></label><span>{{ labelledCount.toLocaleString() }} / {{ pointCount.toLocaleString() }} 已标注</span><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"><RotateRightOutlined /></a-button></a-tooltip></a-popover><a-tooltip title="设置旋转中心"><a-button :type="pivotPicking ? 'primary' : 'text'" @click="togglePivotPicking"><AimOutlined /></a-button></a-tooltip><a-tooltip title="俯视"><a-button type="text" @click="topDown"><BorderTopOutlined /></a-button></a-tooltip><a-tooltip title="复位视角"><a-button type="text" @click="reset"><ReloadOutlined /></a-button></a-tooltip><a-tooltip title="撤销上一次标注"><a-button type="text" :disabled="disabled || !history.length" @click="undo"><UndoOutlined /></a-button></a-tooltip><a-button size="small" :disabled="disabled || !labelledCount" @click="clear">清空</a-button><a-button size="small" type="primary" :loading="disabled" :disabled="disabled || !labelledCount" @click="save">保存标注版本</a-button></div><div v-if="loading || error" class="viewer-state"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div></div>
|
</template>
|
|
<style scoped>
|
.annotation-viewer { position: relative; height: 620px; 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; }
|
.selection-box { position: absolute; z-index: 2; pointer-events: none; border: 1px solid #62b7e6; background: rgba(98, 183, 230, .15); }
|
.detail-status { padding: 7px 10px; color: #dceff9; background: #182936; font-size: 13px; }
|
.annotation-toolbar { position: absolute; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 10px; padding: 8px 10px; color: #e8f1f5; background: rgba(12, 20, 29, .9); font-size: 13px; } .annotation-toolbar label { display: flex; align-items: center; gap: 6px; white-space: nowrap; } .annotation-toolbar :deep(.ant-slider) { width: 82px; margin: 0; } .annotation-toolbar :deep(.ant-btn) { color: #e8f1f5; } .annotation-toolbar :deep(.ant-select) { width: 110px; }
|
.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, .65); } @media (max-width: 767px) { .annotation-viewer { height: 480px; } .annotation-toolbar { left: 8px; right: 8px; bottom: 8px; flex-wrap: wrap; } }
|
</style>
|