绘图,智慧城市,数字孪生,商业智能BI,数据可视化大屏(Cesiumjs/Three.js/Openlayers)

 绘图工具

图表(数据可视化):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.GeoAtlasIndex 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>

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值