我的点云文件格式是ply,需求是实现点云的测量,标注两个点之后连起来,计算他们的距离;
首先我们需要明白 展示点云 必须要创建场景,相机,渲染器
参考代码 vue-3d-model
vue-3d-model是支持3d预览的一个插件 但是这个插件并不能满足我们的需求 所以我们就自己写了一个
<template>
<div
ref="plyContainer"
style="position: relative; width: 100%; height: 100%; margin: 0; border: 0; padding: 0"
>
<canvas
ref="canvasRef"
style="width: 100% !important; height: 100% !important"
/>
</div>
</template>
<script setup lang="ts">
/* eslint-disable */
import {
Object3D,
Vector2,
Vector3,
Color,
Scene,
Group,
Light,
Raycaster,
WebGLRenderer,
PerspectiveCamera,
AmbientLight,
PointLight,
HemisphereLight,
DirectionalLight,
LinearEncoding,
WebGLRendererParameters,
Float32BufferAttribute,
PointsMaterial,
Points,
TextureEncoding,
ColorRepresentation
} from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { getSize, getCenter } from './util';
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader';
import { ElLoading } from 'element-plus';
const DEFAULT_GL_OPTIONS = {
antialias: true,
alpha: true
};
type EmitType = {
(e: 'mousedown', event: MouseEvent, intersected: any): void;
(e: 'mousemove', event: MouseEvent, intersected: any): void;
(e: 'mouseup', event: MouseEvent, intersected: any): void;
(e: 'click', event: MouseEvent, intersected: any): void;
(e: 'progress', progressEvent: ProgressEvent): void;
(e: 'error', errEvent: ErrorEvent): void;
(e: 'load'): void;
(e: 'loaded'): void;
(e: 'addObject'): void;
};
const emit = defineEmits<EmitType>();
//声明父组件传过来的数据以及类型
interface ModelPlyParams {
src: string;
width?: number;
height?: number;
position?: Record<string, any>;
rotation?: Record<string, any>;
scale?: Record<string, any>;
lights?: number[];
cameraPosition?: Record<string, any>;
cameraRotation?: Record<string, any>;
cameraUp?: Record<string, any>;
cameraLookAt?: Record<string, any>;
backgroundColor?: string;
backgroundAlpha?: number;
controlsOptions?: Record<string, any>;
crossOrigin?: string;
requestHeader?: Record<string, any>;
outputEncoding?: number;
glOptions?: Record<string, any>;
}
//声明默认值的写法
const props = withDefaults(defineProps<ModelPlyParams>(), {
position: () => {
return { x: 0, y: 0, z: 0 };
},
rotation: () => {
return { x: 0, y: Math.PI, z: 0 };
},
scale: () => {
return { x: 1, y: 1, z: 1 };
},
lights: () => {
return [];
},
cameraPosition: () => {
return { x: 0, y: 0, z: 0 };
},
cameraRotation: () => {
return { x: 1, y: 1, z: 1 };
},
backgroundColor: 'black',
backgroundAlpha: 1,
crossOrigin: 'anonymous',
requestHeader: () => {
return {};
},
outputEncoding: LinearEncoding
});
let object: Object3D | null = null;
let raycaster = new Raycaster();
let mouse = new Vector2();
let camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 100000); // 透视投影相机 PerspectiveCamera( fov, aspect, near, far )
let scene = new Scene();
let group = new Group(); // 三维对象 Object3D的实例都有一个矩阵matrix来保存该对象的position、rotation以及scale
let renderer: null | WebGLRenderer = null;
let controls: null | OrbitControls = null;
let allLights: Light[] = [];
let clock = typeof performance === 'undefined' ? Date : performance;
let reqId: null | number = null; // requestAnimationFrame id,
let loader = new PLYLoader(); // 会被具体实现的组件覆盖
let cloudGeometry: any = null;
let loading: any;
let size = {
width: props.width,
height: props.height
};
const plyContainer = ref<InstanceType<typeof HTMLDivElement>>();
const canvasRef = ref<InstanceType<typeof HTMLCanvasElement>>();
// Object.assign(this, result);
onMounted(() => {
if (props.width === undefined || props.height === undefined) {
size = {
width: plyContainer.value?.offsetWidth,
height: plyContainer.value?.offsetHeight
};
}
const options: WebGLRendererParameters = Object.assign({}, DEFAULT_GL_OPTIONS, props.glOptions, {
canvas: canvasRef.value
});
renderer = new WebGLRenderer(options);
renderer.shadowMap.enabled = true;
renderer.outputEncoding = props.outputEncoding as TextureEncoding;
controls = new OrbitControls(camera, plyContainer.value);
// this.controls.type = 'orbit';
controls.maxDistance = 10000; // 设置最远位置 也就是缩小的最小程度
controls.rotateSpeed = 3.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
// this.controls.keys = ['KeyA', 'KeyS', 'KeyD'];
scene.add(group);
load();
update();
const element = plyContainer.value as HTMLDivElement;
element.addEventListener('mousedown', onMouseDown, false);
element.addEventListener('mousemove', onMouseMove, false);
element.addEventListener('mouseup', onMouseUp, false);
element.addEventListener('click', onClick, false);
window.addEventListener('resize', onResize, false);
animate();
});
onBeforeUnmount(() => {
cancelAnimationFrame(reqId!);
renderer!.dispose();
if (controls) {
controls.dispose();
}
const element = plyContainer.value as HTMLDivElement;
element.removeEventListener('mousedown', onMouseDown, false);
element.removeEventListener('mousemove', onMouseMove, false);
element.removeEventListener('mouseup', onMouseUp, false);
element.removeEventListener('click', onClick, false);
window.removeEventListener('resize', onResize, false);
});
const onResize = () => {
if (props.width === undefined || props.height === undefined) {
nextTick(() => {
size = {
width: plyContainer.value?.offsetWidth,
height: plyContainer.value?.offsetHeight
};
});
}
};
const onMouseDown = (event: MouseEvent) => {
emit('mousedown', event, pick(event.clientX, event.clientY));
};
const onMouseMove = (event: MouseEvent) => {
emit('mousemove', event, pick(event.clientX, event.clientY));
};
const onMouseUp = (event: MouseEvent) => {
emit('mouseup', event, pick(event.clientX, event.clientY));
};
const onClick = (event: MouseEvent) => {
emit('click', event, pick(event.clientX, event.clientY));
};
const pick = (x: number, y: number) => {
// 计算鼠标点击位置的标准化设备坐标 屏幕坐标转换为标准化设备坐标
if (!object) return null;
if (!plyContainer.value) return;
const rect = plyContainer.value?.getBoundingClientRect();
x -= rect.left;
y -= rect.top;
mouse.x = (x / size.width!) * 2 - 1;
mouse.y = -(y / size.height!) * 2 + 1;
// 通过射线投射来获取鼠标点击位置的真实坐标 可以使用Raycaster来检测在点击位置处是否存在可交互的点云。
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(object, true);
return (intersects && intersects.length) > 0 ? intersects[0] : null;
};
const update = () => {
updateRenderer();
updateCamera();
updateLights();
updateControls();
};
const updateModel = () => {
if (!object) return;
const { position } = props;
const { rotation } = props;
const { scale } = props;
object.position.set(position.x, position.y, position.z);
object.rotation.set(rotation.x, rotation.y, rotation.z);
object.scale.set(scale.x, scale.y, scale.z);
};
const updateRenderer = () => {
if (!renderer) {
return;
}
renderer!.setSize(size.width!, size.height!);
renderer!.setPixelRatio(window.devicePixelRatio || 1);
renderer!.setClearColor(new Color(props.backgroundColor as ColorRepresentation).getHex());
renderer!.setClearAlpha(props.backgroundAlpha);
};
const updateCamera = () => {
camera.aspect = size.width! / size.height!;
// 更新场景的渲染
camera.updateProjectionMatrix();
if (!props.cameraLookAt || !props.cameraUp) {
if (!object) return;
const distance = getSize(object).length();
camera.position.set(props.cameraPosition.x, props.cameraPosition.y, props.cameraPosition.z);
camera.rotation.set(props.cameraRotation.x, props.cameraRotation.y, props.cameraRotation.z);
if (props.cameraPosition.x === 0 && props.cameraPosition.y === 0 && props.cameraPosition.z === 0) {
camera.position.z = distance;
}
// 相机朝向 相机不晓得自己要朝着物体看,他只知道直勾勾的往前看,所以要加上一句 camera.lookAt(); 让相机看向原点 它的参数是一个点
camera.lookAt(new Vector3());
} else {
camera.position.set(props.cameraPosition.x, props.cameraPosition.y, props.cameraPosition.z);
camera.rotation.set(props.cameraRotation.x, props.cameraRotation.y, props.cameraRotation.z);
camera.up.set(props.cameraUp.x, props.cameraUp.y, props.cameraUp.z);
camera.lookAt(new Vector3(props.cameraLookAt.x, props.cameraLookAt.y, props.cameraLookAt.z));
}
};
const updateLights = () => {
scene.remove(...allLights);
allLights = [];
props.lights.forEach((item: any) => {
if (!item || !item.type) return;
const type = item.type.toLowerCase();
let light: null | Light = null;
if (type === 'ambient' || type === 'ambientlight') {
const color = item.color === 0x000000 ? item.color : item.color || 0x404040;
const intensity = item.intensity === 0 ? item.intensity : item.intensity || 1;
// 环境光(AmbientLight) 笼罩在整个空间无处不在的光,不能产生阴影
light = new AmbientLight(color, intensity);
} else if (type === 'point' || type === 'pointlight') {
const color = item.color === 0x000000 ? item.color : item.color || 0xffffff;
const intensity = item.intensity === 0 ? item.intensity : item.intensity || 1;
const distance = item.distance || 0;
const decay = item.decay === 0 ? item.decay : item.decay || 1;
// 点光源(PointLight ) 向四面八方发射的单点光源,不能产生阴影
light = new PointLight(color, intensity, distance, decay);
if (item.position) {
light.position.copy(item.position);
}
} else if (type === 'directional' || type === 'directionallight') {
const color = item.color === 0x000000 ? item.color : item.color || 0xffffff;
const intensity = item.intensity === 0 ? item.intensity : item.intensity || 1;
// 平行光(DirectinalLight) 平行光,类似太阳光,距离很远的光,会产生阴影
light = new DirectionalLight(color, intensity);
if (item.position) {
light.position.copy(item.position);
}
if (item.target) {
(light as DirectionalLight).target.copy(item.target);
}
} else if (type === 'hemisphere' || type === 'hemispherelight') {
const skyColor = item.skyColor === 0x000000 ? item.skyColor : item.skyColor || 0xffffff;
const groundColor = item.groundColor === 0x000000 ? item.groundColor : item.groundColor || 0xffffff;
const intensity = item.intensity === 0 ? item.intensity : item.intensity || 1;
light = new HemisphereLight(skyColor, groundColor, intensity);
if (item.position) {
light.position.copy(item.position);
}
}
if (light) {
allLights.push(light);
scene.add(light);
}
});
};
const updateControls = () => {
if (props.controlsOptions) {
Object.assign(controls!, props.controlsOptions);
}
};
const load = () => {
if (!props.src) return;
if (object) {
group.remove(object);
}
loading = ElLoading.service({
target: plyContainer.value,
lock: true,
background: 'rgba(0, 0, 0, 0.05)'
});
loader.setRequestHeader(props.requestHeader);
(loader as any).load(
props.src,
(...args: any) => {
getObject(args[0]);
emit('load');
},
(event: ProgressEvent) => {
emit('progress', event);
onProgress(event);
},
(event: ErrorEvent) => {
emit('error', event);
}
);
};
const getObject = (geometry: any) => {
const colorArray: number[] = [];
const positionArray = geometry.attributes.position.array;
for (let i = 0; i < positionArray.length / 3; i++) {
colorArray.push(1, 1, 1);
}
// this.positionArray = positionArray;
geometry.setAttribute('position', new Float32BufferAttribute(positionArray, 3));
geometry.setAttribute('color', new Float32BufferAttribute(colorArray, 3));
geometry.center();
cloudGeometry = geometry;
geometry.computeBoundingSphere();
const cloudObject = new Points(geometry, new PointsMaterial()); // 是否使用顶点着色 使颜色显示的关键 { vertexColors: VertexColors }
addObject(cloudObject);
};
const addObject = (cloudObject: any) => {
const center = getCenter(cloudObject);
// correction position
group.position.copy(center.negate());
object = cloudObject;
group.add(cloudObject);
// 区域测量 保证group中的第一项是点云
emit('addObject');
updateCamera();
updateModel();
};
const getGroupObject = () => {
return object;
};
const removeObject = () => {
if (object) {
group.remove(object);
}
};
const onProgress = (e: ProgressEvent) => {
if (e.loaded / e.total === 1) {
loading!.close();
emit('loaded');
}
};
const animate = () => {
reqId = requestAnimationFrame(animate);
render();
controls!.update();
};
const render = () => {
renderer!.render(scene, camera);
};
const getGroup = () => group;
// 清除出了点云图外的所有
const removeDrawerObject = () => {
while (group.children.length > 1) {
group.remove(group.children[1]);
}
};
watch(
() => props.src,
() => {
load();
}
);
watch(
() => props.rotation,
(newValue: any) => {
if (!object) return;
object.rotation.set(newValue.x, newValue.y, newValue.z);
},
{ deep: true }
);
watch(
() => props.position,
(newValue: any) => {
if (!object) return;
object.position.set(newValue.x, newValue.y, newValue.z);
},
{ deep: true }
);
watch(
() => props.scale,
(newValue: any) => {
if (!object) return;
object.scale.set(newValue.x, newValue.y, newValue.z);
},
{ deep: true }
);
watch(
() => props.lights,
() => {
updateLights();
},
{ deep: true }
);
watch(
() => size,
() => {
updateCamera();
updateRenderer();
},
{ deep: true }
);
watch(
() => props.controlsOptions,
() => {
updateControls();
},
{ deep: true }
);
watch(
() => props.backgroundAlpha,
() => {
updateRenderer();
}
);
watch(
() => props.backgroundColor,
() => {
updateRenderer();
}
);
defineExpose({
getGroup,
getGroupObject,
removeObject,
addObject,
onResize,
updateCamera,
updateRenderer,
removeDrawerObject
});
</script>
按照上面的代码,我们是吧点云添加到group中,然后在添加到场景中显示;那么测量的思路就是添加两个点,一个直线;
那么我们需要思考:怎么看鼠标点击的坐标是否在点云中:
const onMouseDown = (event: MouseEvent) => {
emit('mousedown', event, pick(event.clientX, event.clientY));
};
// 创建射线
let raycaster = new Raycaster();
const pick = (x: number, y: number) => {
// 计算鼠标点击位置的标准化设备坐标 屏幕坐标转换为标准化设备坐标
if (!object) return null;
if (!plyContainer.value) return;
const rect = plyContainer.value?.getBoundingClientRect();
x -= rect.left; // 鼠标事件在页面中的横坐标位置
y -= rect.top;
// size.width 点云显示的区域大小 (x / size.width!)鼠标事件位置相对于窗口宽度的比例 然后,通过将该比例值乘以2,并减去1,可以将其转换为位于[-1, 1]范围的归一化坐标值。对于水平方向,窗口的左边界对应-1,右边界对应1。
mouse.x = (x / size.width!) * 2 - 1;
mouse.y = -(y / size.height!) * 2 + 1;
// mouse 是一个包含鼠标位置信息的 THREE.Vector2 对象。在这个上下文中,我们使用鼠标的归一化坐标值来表示其位置,这些归一化坐标值已经通过上述的转换过程计算得到。
// camera 是用来定义射线起点与方向的相机对象。通过传入相机,raycaster 将使用相机的位置和方向来计算射线。
// raycaster 设置的射线起点和方向:将使用相机的属性来确定射线起点,使用鼠标位置信息来确定射线的方向
raycaster.setFromCamera(mouse, camera);
// 传入要检测的对象作为参数
// 可以检测射线与指定对象之间的相交情况,并获取相交结果的数组
const intersects = raycaster.intersectObject(object, true);
return (intersects && intersects.length) > 0 ? intersects[0] : null;
};
至此 我们就可以把点添加到场景中了
添加点的关键代码
modelRef.value就是我的点云组件;getGroup就是组件中的group;
const addPoint = (point: any) => {
// 创建点的几何体
const positions = new Float32Array([point.x, point.y, point.z]); // 添加点的坐标
const pointGeometry = new BufferGeometry();
pointGeometry.setAttribute('position', new BufferAttribute(positions, 3));
// 创建点的材质
const material = new PointsMaterial({ color: 0xff0000, size: 10 });
// 创建点的对象
const pointObject = new Points(pointGeometry, material);
// 将点的对象添加到场景中
modelRef.value?.getGroup().add(pointObject);
};
添加线的关键代码
clickPoints就是我记录下的点击的点云的点坐标
const addLine = () => {
// eslint-disable-next-line spellcheck/spell-checker
// 请注意,线条材质的linewidth属性只在部分渲染器中有效,并且在某些浏览器中可能无效或显示为1像素。这是由于底层WebGL规范的限制造成的。
// 在大多数情况下,线条的粗细将受限于渲染器和浏览器的支持程度。
const material = new LineBasicMaterial({ color: 0xff0000 });
const lineGeometry = new BufferGeometry().setFromPoints(clickPoints.value);
const line = new Line(lineGeometry, material);
modelRef.value?.getGroup().add(line);
};