绘图工具
图表(数据可视化):Chart.js 、ECharts.js、Highcharts.js、D3.js
流程图:vueflow
Canvas 3D(webGL):Three.js、Babylonjs
Canvas 2D:Fabric.js、ZRender.js
矢量图(SVG,VML):SVG.js、ZRender.js
地图层叠(GIS):Echarts Map、Mapv、AntV L7、deck.gl、Leafletjs、Openlayers、Maptalks.js、Mapbox-gljs(不开源)
三维地图:Cesiumjs
地图辅助工具:Turf.js(地图计算)、Gcoord.js(地图坐标)、chaikin-smooth(平滑曲线)
地图数据源:腾讯地图、百度地图、高德地图、天地图、BigMap
地图区域数据:GeoJSON、 DataV.GeoAtlas、Index of /examples/data/asset/geo
建模方式:激光点云建模、航拍倾斜摄影建模、GIS建模、BIM建模、手工建模
建模工具:C4D Blender GeoBuilding ArcGIS
Canvas 2D
案例:图片上画8点框
<template>
<div class="image-json" :style="{ width: canvas2dWidth + 'px', height: canvas2dHeight + 'px', backgroundImage: `url(${imgUrl})` }">
<canvas ref="canvas_2d" :width="canvas2dWidth" :height="canvas2dHeight"></canvas>
</div>
</template>
<script setup>
import { ref } from 'vue';
let imgUrl = ref('');
let jsonUrl = ref(''); // 框数据
let canvas2dWidth = ref(800);
let canvas2dHeight = ref(450);
let canvas_2d = ref();
const renderImageJson = () => {
let imgPro = new Promise((resolve, reject) => {
let img = new Image();
img.src = imgUrl.value;
img.onload = () => {
resolve(img);
};
});
let jsonPro = new Promise((resolve, reject) => {
fetch(jsonUrl.value).then((res) => {
res.json().then((result) => {
resolve(result);
});
});
});
Promise.all([imgPro, jsonPro]).then((result) => {
let img = result[0];
let json = result[1];
let zoom = 1; // 缩放比
let left = 0, top = 0; // 图片左上角在画布中的坐标
if (img.naturalWidth / img.naturalHeight > canvas2dWidth.value / canvas2dHeight.value) {
zoom = canvas2dWidth.value / img.naturalWidth;
left = 0;
top = (canvas2dHeight.value - img.naturalHeight * zoom) / 2;
} else {
zoom = canvas2dHeight.value / img.naturalHeight;
top = 0;
left = (canvas2dWidth.value - img.naturalWidth * zoom) / 2;
}
let context2d = canvas_2d.value.getContext('2d');
context2d.clearRect(0, 0, canvas2dWidth.value, canvas2dHeight.value);
// 图片也可以通过这种方式画上 context2d.drawImage(img, left, top, img.naturalWidth * zoom, img.naturalHeight * zoom);
json.forEach((box) => {
// 在一次 beginPath() 和 stroke() 之间,strokeStyle 只能生效一次
context2d.beginPath();
context2d.strokeStyle = '#ff0000';
// 下底 4 条边
context2d.moveTo(left + box[0].x * zoom, top + box[0].y * zoom);
context2d.lineTo(left + box[1].x * zoom, top + box[1].y * zoom);
context2d.lineTo(left + box[2].x * zoom, top + box[2].y * zoom);
context2d.lineTo(left + box[3].x * zoom, top + box[3].y * zoom);
context2d.lineTo(left + box[0].x * zoom, top + box[0].y * zoom);
// 上底 4 条边
context2d.moveTo(left + box[4].x * zoom, top + box[4].y * zoom);
context2d.lineTo(left + box[5].x * zoom, top + box[5].y * zoom);
context2d.lineTo(left + box[6].x * zoom, top + box[6].y * zoom);
context2d.lineTo(left + box[7].x * zoom, top + box[7].y * zoom);
context2d.lineTo(left + box[4].x * zoom, top + box[4].y * zoom);
// 竖边
context2d.moveTo(left + box[0].x * zoom, top + box[0].y * zoom);
context2d.lineTo(left + box[4].x * zoom, top + box[4].y * zoom);
context2d.moveTo(left + box[1].x * zoom, top + box[1].y * zoom);
context2d.lineTo(left + box[5].x * zoom, top + box[5].y * zoom);
context2d.moveTo(left + box[2].x * zoom, top + box[2].y * zoom);
context2d.lineTo(left + box[6].x * zoom, top + box[6].y * zoom);
context2d.moveTo(left + box[3].x * zoom, top + box[3].y * zoom);
context2d.lineTo(left + box[7].x * zoom, top + box[7].y * zoom);
// 每次 beginPath() 后得 stroke() 才能生效
context2d.stroke();
});
});
};
</script>
<style lang="scss" scoped>
.image-json {
position: relative;
background-size: contain; // 图片比容器小时会放大图片到贴边
background-position: center;
background-repeat: no-repeat;
}
</style>
Cesiumjs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="../Build/Cesium/Cesium.js"></script>
<link href="../Build/Cesium/Widgets/widgets.css" rel="stylesheet"/>
<style>
html,body{
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="Cesium"></div>
<script>
// 在 Cesium官网上 注册用户获取 token
Cesium.Ion.defaultAccessToken = '***';
// 基础图层,在线方式,从 Cesium 官方下载 瓦片
let baseLayer = Cesium.ImageryLayer.fromProviderAsync(
Cesium.IonImageryProvider.fromAssetId(3813) // 需要在 Cesium官网 Asset Depot 添加 对应图层的 权限
);
// 离线方式,自行维护一个可访问的瓦片目录
let baseLayer = Cesium.ImageryLayer.fromProviderAsync(
Cesium.TileMapServiceImageryProvider.fromUrl(
// 用模块的方式引入 Cesium 时,会有 /cesium/Assets/Textures 这个目录
Cesium.buildModuleUrl('/cesium/Assets/Textures/3813')
// 获取瓦片:用Chrome扩展程序 Save All Resources 保存用在线方式访问到的瓦片
)
);
baseLayer.gamma = 0; // 伽玛校正(对比度、亮度)
baseLayer.hue = Cesium.Math.toRadians(0); // 色调【色相】,取值范围在 0-PI,参考色调环
baseLayer.saturation = 1; // 饱和度,饱和度数值越低越(亮度高时)泛白(亮度低时)范黑
baseLayer.alpha = 1; // 透明度
baseLayer.brightness = 1; // 亮度
// 3D地图查看器
const viewer = new Cesium.Viewer('Cesium', {
baseLayerPicker: false, // 底图[卫星、地形、矢量]切换按钮
animation: false, // 左下角 时间播放控件
timeline: false, // 下方 时间轴
homeButton: false, // 右上角 主页按钮
navigationHelpButton: false, // 右上角 问号按钮
geocoder: false, // 右边上角 搜索框
fullscreenButton: false, // 右下角 全屏按钮
infoBox: false, // 点击实体时右侧出现的信息框
selectionIndicator: false, // 点击地球时鼠标处出现的指示框
contextOptions: {
webgl: {
alpha: true, // 允许透明背景
}
},
baseLayer,
});
viewer.scene.globe.show = true; // 显示地球
viewer.scene.skyBox.show = false; // 不显示星空
viewer.scene.sun.show = false; // 不显示太阳
viewer.scene.moon.show = false; // 不显示月球
viewer.scene.skyAtmosphere.show = false; // 不显示大气
viewer.scene.backgroundColor = Cesium.Color.TRANSPARENT; // 透明背景,需设置viewer.contextOptions.webgl.alpha
Cesium.GeoJsonDataSource.load("./world.json", { // 载入 GeoJson 矢量数据
fill: Cesium.Color.TRANSPARENT, // 透明填充
})
let headingPitchRange = new Cesium.HeadingPitchRange(Cesium.Math.toRadians(50), Cesium.Math.toRadians(-90), 2000);
// viewer.camera.lookAt(Cesium.Cartesian3.fromDegrees(116.39, 39.91), headingPitchRange); // 设置相机观察目标,同时设定了相机控制器的环绕点
viewer.scene.camera.setView({ // 切换相机视口
destination: Cesium.Cartesian3.fromDegrees(116.39, 39.91, 500000), // 相机经纬度和高度
orientation: { // 相机姿态
heading: Cesium.Math.toRadians(0), // 偏航角,在(相机与地心连线的法面)上的旋转,0为正北
pitch: Cesium.Math.toRadians(-100), // 俯仰角,在(相机与地心连线所在的经线平面)上选择,-90朝向地心
roll: 0 // 翻滚角
}
});
let position = Cesium.Cartesian3.fromDegrees(116.39, 39.91, 400);
viewer.entities.add({ // 添加实体
polyline: { // 线条实体
show: true,
positions: Cesium.Cartesian3.fromDegreesArray([116.39, 39.91, 116.40, 39.91]),
width: 5,
material: new Cesium.Color(0,0,1,1)
}
});
viewer.entities.add({ // 添加实体
id: 'point',
position, // 实体位置
point: { // 圆点实体
pixelSize: 100, // 圆点尺寸,为屏幕的像素尺寸,不随地图缩放和旋转
color: new Cesium.Color(0,1,0,1) // 圆点颜色
},
description: '<div>html</div>' // 被点击时右侧弹窗的内容
});
viewer.entities.add({ // 添加实体
position: Cesium.Cartesian3.fromDegrees(116.39, 39.91, 50), // 实体位置
plane: { // 矩形平面实体
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0), // 朝向
dimensions: new Cesium.Cartesian2(400, 300),
material: Cesium.Color.RED.withAlpha(0.5), // 可以为图片
outline: true,
outlineColor: Cesium.Color.BLACK
}
});
let polygon = viewer.entities.add({ // 添加实体
id: 'polygon',
polygon: { // 多边形实体
hierarchy: Cesium.Cartesian3.fromDegreesArray([116.39, 39.91, 116.40, 39.91, 116.40, 39.90]),
material: Cesium.Color.YELLOW, // 可以为图片
extrudedHeight: 200 // 拉伸为三维物体
}
});
viewer.entities.getById("polygon"); // 获取实体
viewer.entities.remove(polygon); // 删除
viewer.entities.add({ // 添加实体
position: Cesium.Cartesian3.fromDegrees(116.39, 39.91, 150), // 实体位置
label: { // 标签实体
text: '标签',
font: '50px Helvetica',
fillColor: Cesium.Color.SKYBLUE
}
});
viewer.entities.add({ // 添加实体
position, // 实体位置
orientation: Cesium.Transforms.headingPitchRollQuaternion(position, new Cesium.HeadingPitchRoll(-90, 0, 0)), // 实体姿态
model: { // 3D模型实体
uri: './***.glb', // 载入模型
minimumPixelSize: 128, // 模型缩放时最小像素尺寸
maximumScale: 1000, // 模型缩放最大比率
show: true, // 是否显示
}
});
viewer.camera.viewBoundingSphere(new Cesium.BoundingSphere(position,20),new Cesium.HeadingPitchRange(0,0,0)); // 设置相机控制器360度环绕点
// viewer.trackedEntity = entity; // 相机控制器的环绕点
/* Cesium 坐标系 */
// WGS84弧度坐标系 new Cesium.Cartographic(经弧度, 维弧度, 高度);Cesium.Cartographic.fromDegrees(经度,维度,高度)
// 笛卡尔空间直角坐标系,原点为地心 new Cesium.Cartesian3(x,y,z);Cesium.Cartesian3.fromDegrees(经度,维度,高度)
// 屏幕坐标系 new Cesium.Cartesian2(x,y)
/* 坐标转换 */
// 弧度与角度互转:Cesium.Math.toRadians(),Cesium.Math.toDegrees()
// WGS84坐标系与笛卡尔坐标系互转: Cesium.Ellipsoid.WGS84.cartographicToCartesian(wgs84);Cesium.Ellipsoid.WGS84.cartesianToCartographic(cartesian3);Cesium.Cartographic.fromCartesian(cartesian3)
// 笛卡尔坐标系与屏幕坐标系互转:viewer.scene.pickPosition(cartesian2);viewer.scene.globe.pick(viewer.camera.getPickRay(cartesian2),viewer.scene);viewer.scene.camera.pickEllipsoid(cartesian2)
// Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene,cartesian3);scene.cartesianToCanvasCoordinates(cartesian3)
// 鼠标拾取
let handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(function (action) {
let pick = viewer.scene.pick(action.position);
if(Cesium.defined(pick)){
console.log(pick.id.id)
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
</script>
</body>
</html>
三维建筑物
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="../Build/Cesium/Cesium.js"></script>
<link href="../Build/Cesium/Widgets/widgets.css" rel="stylesheet"/>
<style>
html,body{
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="Cesium"></div>
<script>
// 在 Cesium官网上 注册用户获取 token
Cesium.Ion.defaultAccessToken = '****';
// 加载ArcGIS卫星地图栅格数据,比 Cesium 自带地图更加精细
const viewer = new Cesium.Viewer('Cesium', {
baseLayerPicker: false,
imageryProvider: new Cesium.ArcGisMapServerImageryProvider({
url: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer'
}),
// 地形,需要在 Cesium官网 Asset Depot 添加 Cesium World Terrain 权限;Ctrl+鼠标滑动改变相机视角可以进入地形
terrainProvider: new Cesium.CesiumTerrainProvider({
url: Cesium.IonResource.fromAssetId(1),
requestVertexNormals: true,
requestWaterMask: true, // 水面效果
}),
});
// 添加建筑物模型,需要在 Cesium官网 Asset Depot 添加 Cesium OSM Buildings 权限
const tileset = viewer.scene.primitives.add(
new Cesium.Cesium3DTileset({
url: Cesium.IonResource.fromAssetId(96188),
})
);
// 建筑物模型样式
tileset.style = new Cesium.Cesium3DTileStyle({
color: "color('blue', 0.5)",
show: true
});
/* 加载夜晚地图,需要在 Cesium官网 Asset Depot 添加 Earth at Night 权限 */
// const viewer = new Cesium.Viewer('Cesium', {
// baseLayerPicker: false
// });
// 从 My Assets 里拷贝
// const layer = viewer.imageryLayers.addImageryProvider(
// new Cesium.IonImageryProvider({ assetId: 3812 })
// );
</script>
</body>
</html>
自转
rotate(116.39); // 北京经度
function rotate(longitude) {
viewer.scene.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(longitude, 20, 30000000), // 相机经纬度和高度
duration: 20, // 飞行时间
flyOverLongitude: longitude > 0 ? 180 : 0, // 转动时要经过的经度,从而确定转动的方向
easingFunction: Cesium.EasingFunction.LINEAR_NONE, // 均匀转动
complete() {
rotate(longitude > 0 ? longitude - 180 : 180 + longitude); // 转到背面
},
});
}
点位呼吸效果
viewer.entities.add({
id: "NewYork",
position: Cesium.Cartesian3.fromDegrees(-74.00, 40.43, 50),
billboard: {
image: './position.png',
}
});
viewer.entities.add({
id: 'NewYorkLabel',
position: Cesium.Cartesian3.fromDegrees(-74.00, 40.43, 50),
label: { // 标签
text: '纽约分公司',
font: '200 12px sans-serif', // font-weight font-size font-family
fillColor: Cesium.Color.fromCssColorString('#3D3D3D'), // 字体颜色
showBackground: true,
backgroundColor: Cesium.Color.fromCssColorString('#F0DBAF'), // 背景色,没法渐变
backgroundPadding: new Cesium.Cartesian2(10, 6),
pixelOffset: new Cesium.Cartesian2(0, -30) // 在position基础上的屏幕偏移
}
});
let NewYork = viewer.entities.getById("NewYork");
NewYork.billboard.scale = 1;
let progress = 0; // 呼吸渐变进度
let up = true; // 呼吸渐变方向
breath();
function breath() {
requestAnimationFrame(function () {
NewYork.billboard.scale = 1 + 0.1 * progress;
if(up){
if(progress >= 5){
up = false;
progress = progress - 1;
}
else {
progress = progress + 1;
}
}
else {
if(progress <= 0){
up = true;
progress = progress + 1;
}
else {
progress = progress - 1;
}
}
setTimeout(breath, 150);
});
}
地点连线(OD线 Origin-Destination Line)
<img id="gif" src="" style="position: absolute" />
var gif = {
name: "curve.gif", // 箭头从左侧中间点到右侧中间点
width: 1920,
height: 392,
};
// 终点位置
let toDegree = [108.947, 34.259];
let toCartesian3 = Cesium.Cartesian3.fromDegrees(toDegree[0], toDegree[1]);
let toCartesian2 = Cesium.SceneTransforms.wgs84ToWindowCoordinates(
viewer.scene,
toCartesian3
);
// 起点位置
let fromDegree = [121.506377, 31.245105];
let fromCartesian3 = Cesium.Cartesian3.fromDegrees(fromDegree[0], fromDegree[1]);
if (!isVisible(fromCartesian3)) {
fromCartesian3 = findVisibleEdge(fromDegree);
}
let fromCartesian2 =
Cesium.SceneTransforms.wgs84ToWindowCoordinates(
viewer.scene,
fromCartesian3
);
// 屏幕距离
let distance = Cesium.Cartesian2.distance(
toCartesian2,
fromCartesian2
);
/* 计算连线角度,试用 Cesium.Cartesian2.angleBetween 计算角度发现不对 */
let angle = 0;
let deltaY = toCartesian2.y - fromCartesian2.y;
if (toCartesian2.x > fromCartesian2.x) {
if (deltaY > 0) {
angle = Math.asin(deltaY / distance);
} else {
angle = -Math.asin(Math.abs(deltaY) / distance);
}
} else {
if (deltaY > 0) {
angle = Math.PI - Math.asin(deltaY / distance);
} else {
angle = Math.asin(Math.abs(deltaY) / distance) - Math.PI;
}
}
let width = distance; // 图片显示宽度
let height = (width / gif.width) * gif.height; // 图片显示高度
$("#gif")
.attr("src", gif.name)
.css("width", width + "px")
.css(
"left",
(fromCartesian2.x + toCartesian2.x) / 2 - width / 2 + "px"
)
.css(
"top",
(fromCartesian2.y + toCartesian2.y) / 2- height / 2 + "px"
)
.css("transform", `rotate(${angle}rad)`);
// 判断一个点是否可见,即是否在地球背面
function isVisible(cartesian3) {
return new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, viewer.camera.position).isPointVisible(cartesian3);
}
// 给一个不可见的点找一个同维度的、可见的、离原点最近的点,此点在可见范围边缘上
function findVisibleEdge(fromDegree) {
let cartesian3From;
let fromLongitude = fromDegree[0];
let toLongitude = toDegree[0];
let fromEast = fromLongitude - toLongitude > 0;
let moveEast;
if (fromEast) {
moveEast = fromLongitude - toLongitude > 180;
} else {
moveEast = toLongitude - fromLongitude < 180;
}
do {
if (moveEast) {
fromLongitude = fromLongitude + 0.1;
fromLongitude = fromLongitude < 180 ? fromLongitude : fromLongitude - 360;
} else {
fromLongitude = fromLongitude - 0.1;
fromLongitude = fromLongitude > -180 ? fromLongitude : 360 + fromLongitude;
}
cartesian3From = Cesium.Cartesian3.fromDegrees(fromLongitude, fromDegree[1]);
} while (!isVisible(cartesian3From));
return cartesian3From;
},
判断一个点是否在GeoJSON内
import chinaJson from './100000.json'; // 中国区域
handler.setInputAction((action) => {
let inChina = false;
// 屏幕坐标,如果用了 autofit.js,要进行处理
position = this.autoFitPosition(action.endPosition);
// 笛卡尔坐标
let cartesian3 = viewer.scene.camera.pickEllipsoid(position);
if (cartesian3) {
// 经纬弧度坐标
let cartographic = Ellipsoid.WGS84.cartesianToCartographic(cartesian3);
chinaJson.features.forEach((feature) => {
feature.geometry.coordinates[0].forEach((polygon) => {
// 是否在区域内
if (this.isInPolygon([CesiumMath.toDegrees(cartographic.longitude), CesiumMath.toDegrees(cartographic.latitude)], polygon)) {
inChina = true;
}
});
});
}
}, ScreenSpaceEventType.MOUSE_MOVE);
isInPolygon(checkPoint, polygonPoints) {
let counter = 0;
let pointCount = polygonPoints.length;
let p1 = polygonPoints[0];
let i, xinters, p2;
for (i = 1; i <= pointCount; i++) {
p2 = polygonPoints[i % pointCount];
if (checkPoint[0] > Math.min(p1[0], p2[0]) && checkPoint[0] <= Math.max(p1[0], p2[0])) {
if (checkPoint[1] <= Math.max(p1[1], p2[1])) {
if (p1[0] !== p2[0]) {
xinters = ((checkPoint[0] - p1[0]) * (p2[1] - p1[1])) / (p2[0] - p1[0]) + p1[1];
if (p1[1] === p2[1] || checkPoint[1] <= xinters) {
counter++;
}
}
}
}
p1 = p2;
}
return counter % 2 > 0;
},
autoFitPosition(position) {
let scale = 1;
let transform = document.querySelector('body').style.transform;
if (transform) {
scale = transform.split('(')[1].split(')')[0];
scale = parseFloat(scale);
}
return new Cartesian2(position.x / scale, position.y / scale);
},
Three.js
概念
indices 顶点
PBR 基于物理的渲染
贴图库 poliigon
资源:Threejs/examples/js
Float32Array 32位浮点数数组
几何体
BufferGeometry 几何体,每三个点组成一个三角形面
attributes.position.count 顶点数量,多个三角形面之间重合的顶点分别算
attributes.position.array 顶点坐标数组,一个顶点占三个轴坐标
attributes.position.uv 几何体展开图,用于确定贴图位置
attributes.position.normal 确定姿态
BoxGeometry 立方体 attributes.position.count 是24,估计是顶点复用
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="three.min.js"></script>
<!-- threejs项目源码中 /examples/js 下有很多插件 -->
<script src="../examples/js/controls/OrbitControls.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.10.4/gsap.min.js"></script>
</head>
<body>
<script>
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
// 场景
const scene = new THREE.Scene();
// 物体
const geometry = new THREE.BoxGeometry(100, 150, 200); // 几何体
const material = new THREE.MeshLambertMaterial({ // 材质
color: 0x00ff00,
transparent: true,
opacity: 0.6
});
const mesh = new THREE.Mesh(geometry, material); // 物体(由几何体和材质确定)
mesh.position.set(0, 0, 100); // 物体质心的位置
mesh.scale.set(2, 1, 3); // 缩放
/* 参考世界空间坐标系旋转,绕着穿过质心的轴线(平行于世界坐标系的轴)旋转,设置最终旋转弧度 */
// 无论书写顺序如何,都是先绕Z轴,再绕Y轴,再绕X轴
// 观察者朝轴正方向观察,物体绕轴顺时针转动的弧度
mesh.rotation.x = Math.PI / 4;
mesh.rotation.y = Math.PI / 4;
mesh.rotation.z = Math.PI / 4;
/* 参考局部空间坐标系旋转,即以穿过质心的轴线(平行于世界坐标系的轴)为初始参考轴线;参考轴线随物体旋转,累加旋转 */
mesh.rotateX(Math.PI / 4);
mesh.rotateY(Math.PI / 4);
mesh.rotateZ(Math.PI / 4);
/* 参考局部空间坐标系旋转,即以穿过质心的向量为参考轴线;参考轴线随物体旋转,累加旋转 */
// 适用于欧拉角位姿(yaw,pitch,roll)
carMesh.rotateOnAxis(new THREE.Vector3(0, 0, 1), yaw);
carMesh.rotateOnAxis(new THREE.Vector3(0, 1, 0), pitch);
carMesh.rotateOnAxis(new THREE.Vector3(1, 0, 0), roll);
scene.add(mesh); // 往场景里添加物体
// 光源
const light = new THREE.PointLight(0xffffff, 1, 10000); // 点光源
light.position.set(300, 400, 500); // 光源位置
scene.add(light);
// 坐标轴
const axesHelper = new THREE.AxesHelper(500); // x红 y绿 z蓝
scene.add(axesHelper);
// 可视化点光源
const pointLightHelper = new THREE.PointLightHelper(light, 1);
scene.add(pointLightHelper);
// 透视相机(fov水平视场角,fov和aspect间接确定了垂直视场角,near和far确定了相机观察的距离区间)
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(600, 600, 600); // 相机位置
// 拍摄目标,即朝向;或者用camera.up.set(0, 1, 0);
camera.lookAt(0, 0, 0);
/* 相机姿态:
1、相机鼻线、视线会与穿过target且平行于Y轴的轴线在同一个平面,且鼻线正方向,指向Y轴正方向
2、如果视线指向Y轴正方向,此时鼻线垂直于Y轴,则鼻线指向Z轴正方向
3、如果视线指向Y轴负方向,此时鼻线垂直于Y轴,则鼻线指向Z轴负方向
*/
// 渲染器,即canvas画布
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight); // canvas 尺寸,单位为像素,与场景里的尺寸无关
renderer.setClearColor(0xffffff); // 画布颜色
renderer.render(scene, camera);
document.body.appendChild(renderer.domElement); // 将canvas加入到 dom
// 相机控制器,改变的是相机的位置
// 滚轮,改变相机位置-朝向保持不变(即相机在视线上移动,始终朝向拍摄目标)
// 鼠标右键拖动,平移,改变拍摄目标
// 鼠标左键拖动,改变相机位置-与拍摄目标距离保持不变(即相机在球面上移动,始终朝向拍摄目标)
// 左键左右拖动,场景水平旋转,即绕Y轴旋转
// 右键上下拖动,场景垂直旋转,即绕穿过target且平行于相机双眼线的轴旋转
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0); // 拍摄目标,即朝向
controls.update(); // 会覆盖 camera.lookAt
controls.addEventListener("change", () => {
renderer.render(scene, camera); // 移动相机后,重新渲染画布
});
// 动画
const clock = new THREE.Clock();
function animate() {
// console.log(clock.getDelta()); // 间隔时间,用于获取渲染耗时
renderer.render(scene, camera);
mesh.rotateY(0.01);
window.requestAnimationFrame(animate)
}
animate();
// GSAP 动画库
let animate1 = gsap.to(mesh.position, {
x: 300,
duration: 5,
ease: "bounce.inOut", // 速度曲线
delay: 2,
repeat: 2,
yoyo: true, // 往返
onStart: ()=>{
console.log('动画开始');
},
onComplete: () => {
console.log('动画结束');
}
});
window.addEventListener('click', (event) => {
if(animate1.isActive){
animate1.pause(); // 暂停动画
}
else{
animate1.resume(); // 恢复动画
}
});
// 画布点投射,即画布上的一点沿着视锥线画一条射线;用于寻找与射线交汇的物体,即鼠标拾取,进而实现交互;透明Mesh可拾取,Group不可拾取
window.addEventListener('click', (event) => {
const pointer = new THREE.Vector2(); // 画布点
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster(); // 射线
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(scene.children); // 找出与射线交汇的物体
for (let i = 0; i < intersects.length; i++) {
console.log(intersects[i]);
}
});
/* webgl坐标转画布坐标,画布外的webgl坐标仍然有效 */
function webgl2screen(webglVector) {
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const standardVector = webglVector.project(camera);
const screenX = Math.round(centerX * standardVector.x + centerX);
const screenY = Math.round(-centerY * standardVector.y + centerY);
return new THREE.Vector2(screenX, screenY);
}
/* 加载3D模型 */
let loader = new GLTFLoader();
loader.load(`https://**.glb`, (gltf) => {
// 如果比较暗淡,需要 自发光处理
gltf.scene.traverse(function (child) {
if (child.isMesh) {
child.material.emissive = child.material.color;
child.material.emissiveMap = child.material.map;
}
});
// 如果C4D的设计单位是mm,导出比率是1米,即设计稿里的1米为glb里的1单位长度;例如设计长度为4000mm,glb里为4单位长度
scene.add(gltf.scene);
});
// 释放资源
function clear(){
// 递归遍历所有后代
scene.traverse(function(obj) {
if (obj.type === 'Mesh') {
obj.geometry.dispose();
obj.material.dispose();
}
});
scene.remove(...scene.children);
}
/* 计算 */
let box = new THREE.Box3().setFromObject(mesh);
console.log(box.max.x - box.min.x); // 物体的坐标范围
// 视觉尺寸保持,传入需要保持的视角大小
function getSizeByDeg(deg) {
// 等腰三角形的底边垂线h,底边l,底边对角rad,tan(rad/2)*h=l/2
let rad = THREE.MathUtils.degToRad(deg); // 角度转弧度
let h = camera.position.z;
let l = Math.tan(rad / 2) * h * 2;
return l;
}
// 俯视一个物体及其周边,横向前后100
fitViewToMesh(mesh) {
// 求出纵向
let y = 100 * (renderer.domElement.clientHeight / renderer.domElement.clientWidth);
// fov是视场纵向角度
let z = y / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
camera.position.set(mesh.position.x, mesh.position.y, z + mesh.position.z);
camera.lookAt(mesh.position.x, mesh.position.y, mesh.position.z);
},
</script>
</body>
</html>
向量
let vector1 = new THREE.Vector3(1, 0, 0);
let vector2 = new THREE.Vector3(0, 1, 0);
vector1.angleTo(vector2); // 向量之间的夹角
vector1.distanceTo(vector2); // 两个点之间的距离
// 向量绕着指定穿过世界坐标原点的轴旋转
vector.applyAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI/2);
// 向量1 转到与 向量2 平行,所需位姿调整(yaw,pitch)
function getYawAndPitch(vector1, vector2) {
// vector2在 XY 平面上的投影
let projectionVector = new THREE.Vector3(vector2.x, vector2.y, 0);
// vector2 投影 与 vector1 的夹角
let yaw = vector1.angleTo(projectionVector);
// yaw 是 观察者朝z轴正方向观察,物体绕z轴顺时针转动的弧度
yaw = vector2.y > 0 ? yaw : 2 * Math.PI - yaw;
// vector2 的XY平面投影,与自身的夹角
let pitch = projectionVector.angleTo(vector2);
// pitch 是 观察者朝y轴正方向观察,物体绕y轴顺时针转动的弧度
pitch = vector2.z < 0 ? pitch : 2 * Math.PI - pitch;
return {
yaw,
pitch,
};
}
组
let group = new THREE.Group();
// 世界坐标转局部坐标
let localPoint = group.worldToLocal(new THREE.Vector3(x, y, z));
// 局部坐标转世界坐标
let worldPosition = group.localToWorld(new THREE.Vector3(x, y, z));
正交相机
/* 保持正交视场长宽比与画布一致,物体才不会变形 */
let width = 200;
let height = width * (canvas_wrap.clientHeight / canvas_wrap.clientWidth);
// 以camera.position为原点,垂直于camra.up,画一个矩形;参数值都是相对于原点
camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, 0.01, 10000);
// 滚轮控制的是 camera.zoom
controls = new OrbitControls(camera, renderer.domElement);
// 视觉尺寸保持,传入需要保持的画布占比
getSizeByPercent(percent) {
let width = (camera.right - camera.left) / camera.zoom;
return width * percent;
},
// 俯视一个物体及其周边,横向前后100
fitViewToMesh(mesh) {
camera.zoom = 1;
// 横向前后100米
let width = 200;
camera.left = -width / 2;
camera.right = width / 2;
// 正交视场长宽比与画布保持一致,物体才不会变形
let height = width * (canvas_wrap.clientHeight / canvas_wrap.clientWidth);
camera.top = height / 2;
camera.bottom = -height / 2;
camera.position.set(mesh.position.x, mesh.position.y, mesh.position.z + 100);
camera.updateProjectionMatrix();
controls.target.set(mesh.position.x, mesh.position.y, mesh.position.z);
controls.update();
},
截图
camera.updateProjectionMatrix();
renderer.clear();
renderer.render(scene, camera);
const dataURL = renderer.domElement.toDataURL('image/png');
方案
一、给 圆CircleGeometry 包边
1、边缘几何体EdgesGeometry,设置线宽无效
2、椭圆曲线EllipseCurve,设置线宽无效
3、圆环几何体RingGeometry、TorusGeometry
二、给矩形描边,解决 THREE.Line 设置 linewidth 无效的问题
import { Line2 } from 'three/examples/jsm/lines/Line2';
import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial';
const planeMeshBox = new THREE.Box3().setFromObject(planeMesh);
const pointArr = [
areaMeshBox.min.x,
areaMeshBox.max.y,
0,
areaMeshBox.max.x,
areaMeshBox.max.y,
0,
areaMeshBox.max.x,
areaMeshBox.min.y,
0,
areaMeshBox.min.x,
areaMeshBox.min.y,
0,
areaMeshBox.min.x,
areaMeshBox.max.y,
0,
];
const geometry = new LineGeometry();
geometry.setPositions(pointArr); // 不能用edge_geom.setFromPoints()
let material = new LineMaterial({ linewidth: 5 });
material.resolution.set(window.innerWidth, window.innerHeight); // 这一句必须有
const lineMesh = new Line2(geometry, material);
裁剪
// 用于裁剪的平面
// 第一个参数必须是单位向量,即.normalize()过
// 第二个参数为平面到原点的距离,正负以法向量方向为基准
const plane = new THREE.Plane(new THREE.Vector3(1,0,0), -Math.sqrt(2));
const geometry = new THREE.CylinderGeometry( 2, 2, Math.sqrt(8), 64);
// clippingPlanes 缺点:裁剪平面为世界坐标系里的平面,物体移动旋转时,裁剪平面也要相应处理,否则裁剪部分变化
const material = new THREE.MeshBasicMaterial( {color: 0x00ffff, clippingPlanes: [plane] } );
const cylinder = new THREE.Mesh( geometry, material );
案例
1、视锥体,由圆柱侧面+2个圆锥内表面+2个三角形 组成
可进行视场角、视场宽高比、位姿 调整
// 摄像机视锥体,已知 水平fov,图像宽高比
let cameraVisionCone = (horizontal_fov_deg, image_size_x, image_size_y) => {
let horizontal_fov = THREE.MathUtils.degToRad(horizontal_fov_deg);
let fovSign = horizontal_fov > Math.PI ? Math.PI * 2 - horizontal_fov : horizontal_fov;
// 视锥横向三角的底边
let width = horizontal_fov > Math.PI ? radius * 2 : radius * Math.sin(fovSign / 2) * 2;
// 视锥体前弧面的高度
let height = (width * image_size_y) / image_size_x;
return getVisionCone(horizontal_fov_deg, height / 2, height / 2);
};
// 毫米波雷达视锥体,已知 水平fov,垂直fov
let getRadarSensor = (horizontal_fov_deg, vertical_fov_deg) => {
let vertical_fov_half = THREE.MathUtils.degToRad(vertical_fov_deg / 2);
// 视锥体前弧面的高度
let height_half = Math.tan(vertical_fov_half) * radius;
return getVisionCone(horizontal_fov_deg, height_half, height_half);
};
// 激光雷达视锥体,已知 水平fov,上下垂直fov
let lidarVisionCone = (horizontal_fov_deg, upper_fov_deg, lower_fov_deg) => {
let upper_fov = THREE.MathUtils.degToRad(upper_fov_deg);
let lower_fov = THREE.MathUtils.degToRad(lower_fov_deg);
// 视锥体前弧面的高度
let height_upper = Math.tan(upper_fov) * radius;
let height_lower = Math.tan(lower_fov) * radius;
return getVisionCone(horizontal_fov_deg, height_upper, height_lower);
};
let getVisionCone = (horizontal_fov_deg, height_upper, height_lower) => {
let radius = 5;
let horizontal_fov = THREE.MathUtils.degToRad(horizontal_fov_deg);
let fovSign = horizontal_fov > Math.PI ? Math.PI * 2 - horizontal_fov : horizontal_fov;
const group = new THREE.Group();
let material = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, opacity });
let edgeMaterial = new THREE.LineBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, opacity: opacity + 0.2 });
// 视锥体前弧面
let geometry = new THREE.CylinderGeometry(radius, radius, height_upper + height_lower, 64, 1, true, (Math.PI - horizontal_fov) / 2, horizontal_fov);
const cylinder = new THREE.Mesh(geometry, material);
cylinder.position.y = (height_upper - height_lower) / 2;
group.add(cylinder);
// 补上圆锥内表面
geometry = new THREE.ConeGeometry(radius, height_upper, 64, 1, true, (Math.PI - horizontal_fov) / 2, horizontal_fov);
let cone = new THREE.Mesh(geometry, material);
cone.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI);
cone.position.y = height_upper / 2;
group.add(cone);
// 补下圆锥内表面
geometry = new THREE.ConeGeometry(radius, height_lower, 64, 1, true, (Math.PI - horizontal_fov) / 2, horizontal_fov);
cone = new THREE.Mesh(geometry, material);
cone.position.y = -height_lower / 2;
group.add(cone);
// 描边
let curve = new THREE.EllipseCurve(0, 0, radius, radius, (Math.PI - horizontal_fov) / 2, (Math.PI + horizontal_fov) / 2);
geometry = new THREE.BufferGeometry().setFromPoints(curve.getPoints(64));
let ellipse = new THREE.Line(geometry, edgeMaterial);
ellipse.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2);
ellipse.rotateOnAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
ellipse.position.y = height_upper;
group.add(ellipse);
ellipse = ellipse.clone();
ellipse.position.y = -height_lower;
group.add(ellipse);
// 补侧面三角形
if(horizontal_fov_deg < 360){
let xSign = horizontal_fov > Math.PI ? -1 : 1;
geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
0,
0,
0,
Math.cos(fovSign / 2) * radius * xSign,
height_upper,
Math.sin(fovSign / 2) * radius,
Math.cos(fovSign / 2) * radius * xSign,
-height_lower,
Math.sin(fovSign / 2) * radius,
0,
0,
0,
Math.cos(fovSign / 2) * radius * xSign,
height_upper,
-Math.sin(fovSign / 2) * radius,
Math.cos(fovSign / 2) * radius * xSign,
-height_lower,
-Math.sin(fovSign / 2) * radius,
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
let mesh = new THREE.Mesh(geometry, material);
group.add(mesh);
// 描边
let edge = new THREE.EdgesGeometry(geometry);
let line = new THREE.LineSegments(edge, edgeMaterial);
group.add(line);
}
group.rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2);
const result = new THREE.Group();
result.add(group);
return result;
};
2、PCD点云播放
<template>
<div style="height: 100%; width: 100%; position: relative" ref="pcd_canvas">
<div class="pcd-control">
<div class="btn" @click="toggleStop">
<svg class="icon" aria-hidden="true" v-if="pcdStop">
<use xlink:href="#icon-run"></use>
</svg>
<svg class="icon" aria-hidden="true" v-else>
<use xlink:href="#icon-pause"></use>
</svg>
</div>
<el-slider v-model="curIndex" :min="0" :max="pcdTiming.length" :show-tooltip="false" @change="pcdSliderChange" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';
import { PCDLoader } from './PCDLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
onMounted(() => {
fetchPcdList();
});
let pcd_canvas = ref();
let canvas_width = 857;
let canvas_height = 484;
let cameraFov = 90;
const scene = new THREE.Scene();
const pcdLoader = new PCDLoader();
const camera = new THREE.PerspectiveCamera(cameraFov, canvas_width / canvas_height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(canvas_width, canvas_height);
renderer.render(scene, camera);
const controls = new OrbitControls(camera, renderer.domElement);
controls.minAzimuthAngle = 0;
controls.maxAzimuthAngle = 0;
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI;
let pcdTiming = ref([]); // pcd文件路径列表
let handControl = false;
let pcdStop = ref(false);
let curIndex = ref(0);
let preLoadIndex = 0;
let preFrameTime;
let fetchPcdList = () => {
handControl = false;
pcdStop.value = false;
curIndex.value = 0;
preLoadIndex = 0;
pcdCache = {};
fetch(pcd_list_url).then((response) => {
response.json().then((data) => {
pcdTiming.value = data.timing;
preLoad();
renderPCD();
});
});
};
let renderPCD = () => {
pcd_canvas.value.appendChild(renderer.domElement);
controls.addEventListener('change', () => {
handControl = true;
renderer.render(scene, camera);
});
animationPCD();
};
let animationPCD = () => {
if (pcdStop.value) {
return;
}
if (curIndex.value >= pcdTiming.value.length) {
pcdStop.value = true;
return;
}
if (!preFrameTime || new Date().getTime() - preFrameTime >= 50) {
if (pcdCache[curIndex.value]) {
updateScene(pcdCache[curIndex.value]);
} else {
requestAnimationFrame(animationPCD);
}
} else {
requestAnimationFrame(animationPCD);
}
};
let updateScene = (pcd) => {
scene.clear();
pcd = pcd.clone(); // 重复使用时避免重复旋转
// PCD点云的初始朝向为 X 轴正方向,相机的头顶方向为 Y 轴正方向,需要把点云转到 Y 轴正方向上
pcd.rotateOnAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
if (!handControl) {
let bbox = new THREE.Box3().setFromObject(pcd);
// 纵向离原点的最大距离
let maxY = Math.max(Math.abs(bbox.max.y), Math.abs(bbox.min.y));
// 横向离原点的最大距离
let maxX = Math.max(Math.abs(bbox.max.x), Math.abs(bbox.min.x));
// 纵向能看到这个距离才能横向和纵向都看全
maxY = Math.max(maxY, maxX * (canvas_height / canvas_width));
// 相机在这个位置才能看全横向和纵向
let cameraZ = maxY / Math.tan(THREE.MathUtils.degToRad(cameraFov) / 2);
// 垂向也看全
cameraZ = cameraZ + bbox.max.z;
camera.position.set(0, 0, cameraZ);
controls.target.set(0, 0, 0);
}
scene.add(pcd);
controls.update();
renderer.render(scene, camera);
preFrameTime = new Date().getTime();
curIndex.value++;
requestAnimationFrame(animationPCD);
};
let toggleStop = () => {
pcdStop.value = !pcdStop.value;
if (!pcdStop.value) {
animationPCD();
}
};
let pcdSliderChange = () => {
// 之前的预加载是否已结束
if(preLoadIndex >= pcdTiming.value.length) {
preLoadIndex = curIndex.value;
preLoad();
}
else {
preLoadIndex = curIndex.value;
}
};
// 预加载 pcd
let preLoad = () => {
if (preLoadIndex >= pcdTiming.value.length) {
return;
}
if (pcdCache[preLoadIndex]) {
preLoadIndex++;
preLoad();
} else {
let index = preLoadIndex;
let pcdItem = pcdTiming.value[index];
pcdLoader.load(pcdItem.url, (pcd) => {
pcdCache[index] = pcd;
// 拖动进度条时,preLoadIndex 发生改变
if (index === preLoadIndex) {
preLoadIndex++;
}
preLoad();
});
}
};
</script>
<style lang="scss" scoped>
.pcd-control {
position: absolute;
width: 100%;
height: 50px;
bottom: 0;
background: rgba(0, 0, 0, 0);
padding: 5px 10px;
display: flex;
align-items: center;
.btn {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 50%;
color: #fff;
text-align: center;
line-height: 40px;
margin-right: 20px;
&:hover {
background: #151515;
}
}
}
}
</style>
PCDLoader.js
if ( PCDheader.data === 'ascii' ) {
const offset = PCDheader.offset;
const pcdData = textData.slice( PCDheader.headerLen );
const lines = pcdData.split( '\n' );
// 获取 intensity 存在范围
let minIntensity,maxIntensity;
for ( let i = 0, l = lines.length; i < l; i ++ ) {
if ( lines[ i ] === '' ) continue;
const line = lines[ i ].split( ' ' );
let intensity = parseFloat( line[ offset.intensity ] );
minIntensity = minIntensity === undefined || minIntensity > intensity ? intensity : minIntensity;
maxIntensity = maxIntensity === undefined || maxIntensity < intensity ? intensity : maxIntensity;
}
let intensityRange = maxIntensity - minIntensity;
for ( let i = 0, l = lines.length; i < l; i ++ ) {
if ( lines[ i ] === '' ) continue;
const line = lines[ i ].split( ' ' );
// 根据 intensity 设置亮度和颜色
if ( offset.intensity !== undefined ) {
let intensity = parseFloat( line[ offset.intensity ] );
let intensityWeight = (intensity - minIntensity) / intensityRange;
color.push( intensityWeight );
color.push( 1 - Math.abs(intensityWeight - 0.5) / 0.5 );
color.push( 1 - intensityWeight );
}
}
}
3、PCD点云画3D框
<template>
<div v-loading="pcdLoading" ref="canvas_3d" :style="{ width: canvas3dWidth + 'px', height: canvas3dHeight + 'px' }"></div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import * as THREE from 'three';
import { PCDLoader } from './PCDLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
let pcdUrl = '';
let jsonUrl = ''; // 框数据
let canvas3dWidth = ref(800);
let canvas3dHeight = ref(450);
let cameraFov = 90;
let canvas_3d = ref();
let pcdLoading = ref(false);
let scene, renderer, camera, controls;
const pcdLoader = new PCDLoader();
let renderPcdJson = (row) => {
pcdLoading.value = true;
if( !scene ) {
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer();
camera = new THREE.PerspectiveCamera(cameraFov, canvas3dWidth.value / canvas3dHeight.value, 0.1, 10000);
renderer.setSize(canvas3dWidth.value, canvas3dHeight.value);
renderer.render(scene, camera);
controls = new OrbitControls(camera, renderer.domElement);
controls.minAzimuthAngle = 0;
controls.maxAzimuthAngle = 0;
controls.minPolarAngle = Math.PI / 2;
controls.maxPolarAngle = Math.PI;
controls.addEventListener('change', () => {
renderer.render(scene, camera);
});
canvas_3d.value.appendChild(renderer.domElement);
}
scene.clear();
let pcdPro = new Promise((resolve, reject) => {
pcdLoader.load(pcdUrl, (pcd) => {
resolve(pcd);
});
});
let jsonPro = new Promise((resolve, reject) => {
fetch(jsonUrl).then((res) => {
res.json().then((result) => {
resolve(result);
});
});
});
Promise.all([pcdPro, jsonPro]).then((result) => {
let pcd = result[0];
let json = result[1];
canvas_3d.value.appendChild(renderer.domElement);
const group = new THREE.Group();
group.add(pcd);
json.forEach((boxObj) => {
let box = boxObj.corners;
let material = new THREE.LineBasicMaterial({ color: '#FF0000' });
let points = [
new THREE.Vector3(box[0][0], box[0][1], box[0][2]),
new THREE.Vector3(box[1][0], box[1][1], box[1][2]),
new THREE.Vector3(box[2][0], box[2][1], box[2][2]),
new THREE.Vector3(box[3][0], box[3][1], box[3][2]),
];
let line = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [
new THREE.Vector3(box[4][0], box[4][1], box[4][2]),
new THREE.Vector3(box[5][0], box[5][1], box[5][2]),
new THREE.Vector3(box[6][0], box[6][1], box[6][2]),
new THREE.Vector3(box[7][0], box[7][1], box[7][2]),
];
line = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[0][0], box[0][1], box[0][2]), new THREE.Vector3(box[4][0], box[4][1], box[4][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[1][0], box[1][1], box[1][2]), new THREE.Vector3(box[5][0], box[5][1], box[5][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[2][0], box[2][1], box[2][2]), new THREE.Vector3(box[6][0], box[6][1], box[6][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
points = [new THREE.Vector3(box[3][0], box[3][1], box[3][2]), new THREE.Vector3(box[7][0], box[7][1], box[7][2])];
line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
group.add(line);
});
scene.add(group);
// PCD点云的初始朝向为 X 轴正方向,相机的头顶方向为 Y 轴正方向,需要把点云转到 Y 轴正方向上
group.rotateOnAxis(new THREE.Vector3(0, 0, 1), -Math.PI / 2);
let bbox = new THREE.Box3().setFromObject(pcd);
// 纵向离原点的最大距离
let maxY = Math.max(Math.abs(bbox.max.y), Math.abs(bbox.min.y));
// 横向离原点的最大距离
let maxX = Math.max(Math.abs(bbox.max.x), Math.abs(bbox.min.x));
// 纵向能看到这个距离才能横向和纵向都看全
maxY = Math.max(maxY, maxX * (canvas3dWidth.value / canvas3dHeight.value));
// 相机在这个位置才能看全横向和纵向
let cameraZ = maxY / Math.tan(THREE.MathUtils.degToRad(cameraFov) / 2);
camera.position.set(0, 0, cameraZ + bbox.max.z);
controls.target.set(0, 0, 0);
controls.update();
renderer.render(scene, camera);
pcdLoading.value = false;
});
};
</script>
4、GLB 转 OBJ
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js';
import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js';
new GLTFLoader().load(glb_url, (gltf) => {
// 尺寸修复,导出尺寸受 mesh.children[0].scale 影响
let bbox = new THREE.Box3().setFromObject(mesh);
let originScale = mesh.children[0].scale;
originScale = Math.max(originScale.x, originScale.y, originScale.z);
// 未知原因,导出尺寸 不一定受 mesh.children[0].scale 影响,通过合理尺寸区分
let scale = bbox.max.x - bbox.min.x > 0.2 ? 1 : 1 / originScale;
mesh.scale.set(scale, scale, scale);
// 缩放旋转调整 后 需要 scene.add 并 renderer.render ,才能对导出的obj生效
gltf.scene.rotateX(Math.PI / 2);
scene.add(mesh);
renderer.render(scene, camera);
// 减面
gltf.scene.traverse((child) => {
if (child.isMesh) {
try {
const count = Math.ceil(Math.sqrt(child.geometry.attributes.position.count));
const simplifiedGeometry = new SimplifyModifier().modify(child.geometry, count);
child.geometry.dispose();
child.geometry = simplifiedGeometry;
} catch(e){}
}
});
const exporter = new OBJExporter();
let obj = exporter.parse(mesh);
console.log(obj);
});
5、生成GLB缩略图
let ambientLight = new THREE.AmbientLight(0xffffff, 1); // 自然光
let directionalLight = new THREE.DirectionalLight(0xffffff, 1); // 平行光
new GLTFLoader().load(glb_url, (gltf) => {
// 需要用原模型,clone() 可能导致尺寸变化
let mesh = gltf.scene;
scene.add(ambientLight);
scene.add(directionalLight);
// scene.add(new THREE.AxesHelper(50)); // 坐标轴 辅助
// 假设原模型为一辆车,位姿为:平底平行并贴于XZ平面,车头朝向X轴正方向,车底中心位于坐标轴原点
mesh.rotateY(Math.PI * 1.25);
scene.add(mesh);
// bbox 不受 rotate 影响,但受 mesh.children[0].scale的影响
let bbox = new THREE.Box3().setFromObject(mesh);
let originScale = mesh.children[0].scale;
originScale = Math.max(originScale.x, originScale.y, originScale.z);
// 未知原因,bbox 不一定受 mesh.children[0].scale 影响,通过合理尺寸区分
let scale = bbox.max.x - bbox.min.x > 0.2 ? 1 : 1 / originScale;
camera.position.set(0, bbox.max.y * scale, bbox.max.x * scale * 1.25);
camera.lookAt(0, 0, 0);
let position = camera.position.clone();
directionalLight.position.set(position.x, position.y, position.z);
renderer.render(scene, camera);
let dataUrl = renderer.domElement.toDataURL('image/png');
});
Openlayers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Title</title>
<link href="https://lib.baomitu.com/ol3/4.6.5/ol.css" rel="stylesheet" />
<script src="https://lib.baomitu.com/ol3/4.6.5/ol.js"></script>
<style>
.ol-zoomslider {
top: 7.5em;
}
</style>
</head>
<body>
<div id="map_ele"></div>
<script>
/* 图层与地图 */
// 瓦片图层
const gaode = new ol.layer.Tile({
title: "高德地图",
source: new ol.source.XYZ({
url: "http://wprd0{1-4}.is.autonavi.com/appmaptile?x={x}&y={y}&z={z}&lang=zh_cn&size=1&scl=1&style=7", // 高德地图瓦片地址
wrapX: false,
// 自定义瓦片服务
tileUrlFunction: (zxy) => {
let [z, x, y] = zxy; // z 即 缩放级别,xy 为序号,参照Web墨卡托投影坐标系
return `/tile/${z}/${x}/${y}.png`; // 256px * 256px 的图片
},
}),
});
// 地图
const map = new ol.Map({
target: "map_ele",
layers: [gaode], // 使用图层
view: new ol.View({
center: [114.3, 30.5], // 视图中心点
zoom: 10, // 缩放级别
projection: "EPSG:4326", // 坐标系
}),
});
/* 控件 */
// 跳到指定范围 按钮控件
const zoomToExtent = new ol.control.ZoomToExtent({
extent: [110, 30, 120, 40],
});
map.addControl(zoomToExtent);
// 调整缩放级别 滑块控件
map.addControl(new ol.control.ZoomSlider());
// 全屏 控件
map.addControl(new ol.control.FullScreen());
/* 矢量元素 */
// 元素样式
let style = new ol.style.Style({
image: new ol.style.Circle({
radius: 10, // 单位是像素
fill: new ol.style.Fill({
color: "#ff2d51",
}),
stroke: new ol.style.Stroke({
width: 2, // 单位是像素
color: "#333",
}),
}),
});
// 点元素
const point = new ol.Feature({
geometry: new ol.geom.Point([114.3, 30.5]),
});
point.setStyle(style);
// 矢量图层
let layer = new ol.layer.Vector({
source: new ol.source.Vector({
features: [point],
}),
});
map.addLayer(layer);
/* geojson 矢量元素之点 */
let geojson = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Point",
coordinates: [114.3, 30.6],
},
},
],
};
layer = new ol.layer.Vector({
source: new ol.source.Vector({
features: new ol.format.GeoJSON().readFeatures(geojson),
}),
});
layer.setStyle(style);
map.addLayer(layer);
/* geojson 矢量元素之线条、区域 */
geojson = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[114.3, 30.5],
[114.3, 30.6],
],
},
},
{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [
[
[114.4, 30.5],
[114.4, 30.6],
[114.5, 30.5],
],
],
},
},
],
};
layer = new ol.layer.Vector({
source: new ol.source.Vector({
features: new ol.format.GeoJSON().readFeatures(geojson),
}),
});
style = new ol.style.Style({
stroke: new ol.style.Stroke({
color: "#ff2d51",
width: 3,
}),
fill: new ol.style.Fill({
color: "rgba(50, 50, 50, 0.3)",
}),
});
layer.setStyle(style);
map.addLayer(layer);
/* 加载geojson */
layer = new ol.layer.Vector({
source: new ol.source.Vector({
url: "./USA.json",
format: new ol.format.GeoJSON(),
}),
});
map.addLayer(layer);
/* 点击事件 */
map.on("click", (evt) => {
let { coordinate } = evt;
const view = map.getView();
// 飞行
view.animate({
center: coordinate,
zoom: 8,
duration: 3000,
});
});
</script>
</body>
</html>