【数字孪生平台】使用 Three.js 以 3D 形式可视化日本新宿站地图

在本文中,我们将使用日本新宿站的室内地图数据,并使用 Three.js 将其进行 3D 可视化。更多精彩内容尽在数字孪生平台。

image.png

使用的数据

这次,我们将使用日本空间信息中心发布的“新宿站室内地图开放数据”的集成版本(ShapeFile)。

要素数据

在QGIS中显示

网络数据

在QGIS中显示

数据转换

所有这些数据都是 ShapeFile,我们可以使用 GDAL 命令将 ShapeFile 转换为 GeoJson。

mkdir geojsonfor f in *.shp; doogr2ogr -f GeoJSON -t_srs EPSG:6677 "geojson/${f%.*}.geojson" $f
done

使用 Three.js 绘制

创建场景

创建场景并添加相机和控制器。在本文中,我将把移动操作分配给 MapControls,将缩放操作分配给 TrackballControls。

import * as THREE from 'three';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js';const sizes = {width: window.innerWidth,height: window.innerHeight,
};// 创建画布
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);// 创建场景
const scene = new THREE.Scene();// 创建相机
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100000);
camera.position.set(-190, 280, -350);
scene.add(camera);// 创建控制器
const mapControls = new MapControls(camera, canvas);
mapControls.enableDamping = true;
mapControls.enableZoom = false;
mapControls.maxDistance = 1000;const zoomControls = new TrackballControls(camera, canvas);
zoomControls.noPan = true;
zoomControls.noRotate = true;
zoomControls.noZoom = false;
zoomControls.zoomSpeed = 0.5;// 渲染器
const renderer = new THREE.WebGLRenderer({canvas: canvas,alpha: true,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));// 当屏幕大小调整时,画布也会调整大小
const onResize = () => {// 获取尺寸大小const width = window.innerWidth;const height = window.innerHeight;// 调整渲染器大小renderer.setPixelRatio(window.devicePixelRatio);renderer.setSize(width, height);// 修正相机纵横比camera.aspect = width / height;camera.updateProjectionMatrix();
};
window.addEventListener('resize', onResize);const animate = () => {requestAnimationFrame(animate);const target = mapControls.target;mapControls.update();zoomControls.target.set(target.x, target.y, target.z);zoomControls.update();renderer.render(scene, camera);
};
animate();

另外,在 WebGLRenderer 选项中设置 alpha: true 以使背景透明并使用 CSS 将渐变应用于背景。

canvas {background-image: radial-gradient(#382c6e, #000000);
}

背景

创建 GUI 和组

在场景中绘制要素数据。这次我们按图层对要素进行分组,因此需要提前在场景中创建一个组,并将使用要素数据创建的对象添加到每个图层的组中。我们还将添加一个复选框 GUI,以便可以切换每个层次结构的显示。

import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';// 创建 dat.GUI 的实例
const gui = new GUI({ width: 150 });// 创建一个群组
const groupList = [4, 3, 2, 1, 0, -1, -2, -3];
const layers = ['4F', '3F', '2F', '1F', '0', 'B1', 'B2', 'B3'];groupList.forEach((num, i) => {const group = new THREE.Group();group.name = `group${num}`;scene.add(group);const key = `group${num}`;// 将复选框添加到 GUIgui.add({[key]: true,},key,).onChange((isVisible) => {scene.getObjectByName(key).visible = isVisible;}).name(layers[i]);
});

添加要素数据

往每个层组添加要素数据。由于要素数据是二维多边形数据,因此可以使用ExtrudeGeometry对其进行挤压,使其变为三维。虽然文件名中有层级结构信息,但没有高度信息,因此我们将根据特征数据的类型来划分高度。

另外,由于上述转换过程,特征数据已转换为 EPSG:6677,为了方便在threejs中查看,需要对要素进行偏移。首先,确定 EPSG:6677 上的哪个坐标点应作为场景的原点 0。这里我们将点 -12035.29、-34261.85(x,y) 与世界坐标原点 0 对齐,这样可以更好的查看。
image.png
当从要素数据的多边形坐标值创建ExtrudeGeometry时,通过从每个坐标点减去中心点([-12035.29,-34261.85])来应用偏移,这样要素就会被绘制到靠近场景原点的位置。

另外需要注意的是,Three.js(世界坐标)上的Y和Z向量方向与GIS(地理坐标)上的Y和Z向量方向相差90度,所以创建ExtrudeGeometry后,要从x轴开始。
地理坐标和threejs世界坐标
接下来我们从 GeoJson 创建 ExtrudeGeometry。将Space、Floor、Fixture数据写入每个数组并循环处理,使用函数getFloorNumber获取层级信息,使用函数loadAndAddToScene输入GeoJson信息、层级信息和高度值。

// Space数组
const SpaceLists = ['./ShinjukuTerminal/ShinjukuTerminal_B3_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_B2_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_B1_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_0_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_1_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_2_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_2out_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_3_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_3out_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_4_Space.geojson','./ShinjukuTerminal/ShinjukuTerminal_4out_Space.geojson',
];// Space加载
SpaceLists.forEach((geojson) => {const floorNumber = getFloorNumber(geojson, 'Space');if (floorNumber !== null) {loadAndAddToScene(geojson, floorNumber, 5);}
});// Floor数组
const FloorLists = ['./ShinjukuTerminal/ShinjukuTerminal_B3_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_B2_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_B1_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_0_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_1_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_2_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_2out_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_3_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_3out_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_4_Floor.geojson','./ShinjukuTerminal/ShinjukuTerminal_4out_Floor.geojson',
];// Floor加载
FloorLists.forEach((geojson) => {const floorNumber = getFloorNumber(geojson, 'Floor');if (floorNumber !== null) {loadAndAddToScene(geojson, floorNumber, 0.5);}
});// Fixture数组
const FixtureLists = ['./ShinjukuTerminal/ShinjukuTerminal_B3_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_B2_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_B1_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_0_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_2_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_2out_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_3_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_3out_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_4_Fixture.geojson','./ShinjukuTerminal/ShinjukuTerminal_4out_Fixture.geojson',
];// Fixture加载
FixtureLists.forEach((geojson) => {const floorNumber = getFloorNumber(geojson, 'Fixture');if (floorNumber !== null) {loadAndAddToScene(geojson, floorNumber, 5);}
});

getFloorNumber函数如下。我使用正则表达式提取文件名的数字部分,如果它包含“B”(位于地下),则返回负数。

// 使用正则表达式获取层数
const getFloorNumber = (geojson, type) => {const regex = new RegExp(`ShinjukuTerminal_([-B\\d]+)(out)?_${type}`);const match = geojson.match(regex);if (!match) return null;let floor = match[1].replace('B', '-');return parseInt(match[2] === 'out' ? floor.replace('out', '') : floor, 10);
};

loadAndAddToScene函数如下。使用 FileLoader 加载 GeoJson,使用名为 createExtrudedGeometry 的函数生成 ExtrudeGeometry,并通过从 ExtrudeGeometry 创建 EdgesGeometry 来创建轮廓立方多边形。此时,立方体多边形是垂直方向的,因此使用 applyMatrix4 将其旋转 90 度。
然后,我们决定在Y轴上放置每层信息的要素并将其添加到每层的Group中。


// 每层Y轴方向距离
const verticalOffset = 30;// 实例化文件加载器。获取JSON格式的数据
const loader = new THREE.FileLoader().setResponseType('json');// 加载文件并将其添加到场景中,排除那些没有几何信息的
const loadAndAddToScene = (geojson, floorNumber, depth) => {loader.load(geojson, (data) => {// Line材质const lineMaterial = new THREE.LineBasicMaterial({ color: 'rgb(255, 255, 255)' });// 排除那些没有几何信息的data.features.filter((feature) => feature.geometry).forEach((feature) => {// 生成ExtrudeGeometryconst geometry = createExtrudedGeometry(feature.geometry.coordinates, depth);// 旋转 90 度const matrix = new THREE.Matrix4().makeRotationX(Math.PI / -2);geometry.applyMatrix4(matrix);// 生成EdgesGeometryconst edges = new THREE.EdgesGeometry(geometry);const line = new THREE.LineSegments(edges, lineMaterial);line.position.y += floorNumber * verticalOffset - 1;// 添加到Groupconst group = scene.getObjectByName(`group${floorNumber}`);group.add(line);});});
};

createExtrudedGeometry 如下。如上所述,这里我们根据 GeoJson 的坐标创建一个 ShapeGeometry,它是 ExtrudedGeometry 的来源。每个坐标点都预先从中心减去其地理坐标。

此外,地理空间多边形数据需要顶点来闭合多边形,因此顶点数组的开头和结尾具有相同的顶点坐标(矩形多边形有 5 个顶点)。 ShapeGeometry 不需要最后一个顶点,因此我们跳过最后一个顶点的处理。ExtrudeGeometrydepth选项表示挤出高度。

// 场景中心的地理坐标 (EPSG:6677)
const center = [-12035.29, -34261.85];// 从多边形返回 ExtrudeGeometry 的函数
const createExtrudedGeometry = (coordinates, depth) => {const shape = new THREE.Shape();// 从多边形坐标创建形状coordinates[0].forEach((point, index) => {const [x, y] = point.map((coord, idx) => coord - center[idx]);if (index === 0) {// 移动到第一个点shape.moveTo(x, y);} else if (index + 1 === coordinates[0].length) {// 使用 closePath 关闭最后一个点shape.closePath();} else {// 其他 lineToshape.lineTo(x, y);}});return new THREE.ExtrudeGeometry(shape, {steps: 1,depth: depth,bevelEnabled: false,});
};

添加要素数据后的场景

添加网络数据

接下来,将网络数据(行人网络)添加到场景中。使用之前实例化的FileLoader加载上面转换的节点数据的GeoJson,获取nodeIdordinal(楼层信息),创建一个数组,并将其传递给名为createLink的函数。

// 从节点数据中获取node_id和层次结构(ordinal)
loader.load('./nw/Shinjuku_node.geojson', (data) => {const nodeIds = data.features.map((feature) => {return {node_id: feature.properties.node_id,ordinal: feature.properties.ordinal,};});// 创建行人网络creatingLink(nodeIds);
});

creatingLink函数如下所示。加载链接数据并从之前创建的数组中获取链接数据的起点和终点的层数结构,这是因为链接的数据不包含楼层信息(可以提前使用 QGIS 表连接添加楼层信息)。

虽然我们可以看到链接数据的起点和终点的层数结构,但是不知道之间的线的楼层落在哪里。因此,我们将准备一个条件分支,如果只找到起点的节点数据,则在起点的层高创建一条链接数据线,如果只找到终点的节点数据,则在终点的层高创建链接数据线。如果起点和终点的节点数据都找到了,如果起点和终点在同一层,就知道该链接数据线只存在于该层,但如果起点和终点在同一层的不同楼层,那么,我就暂时在中间层划一条线。

另外,链接数据线采用MeshLine,因为可以画宽线。在代码的后半部分,我特意将线分割成顶点位于两点之间的线,使用BufferGeometryUtils将它们合并,然后将它们添加到场景中,其原因将在后面解释。

import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { MeshLine, MeshLineMaterial } from 'three.meshline';// 线材质
const linkMaterial = new MeshLineMaterial({transparent: true,lineWidth: 1,color: new THREE.Color('rgb(0, 255, 255)'),
});const meshLines = [];// 创建步行网络
const creatingLink = (nodeId) => {loader.load('./nw/Shinjuku_link.geojson', (data) => {data.features.forEach((feature) => {const coordinates = feature.geometry.coordinates;// 从节点数据中获取start_id和end_idconst start_id = nodeId.find((node) => node.node_id === feature.properties.start_id);const end_id = nodeId.find((node) => node.node_id === feature.properties.end_id);// 创建 3D 点数组const points = coordinates.map((point, index) => {let y;if (!start_id && !end_id) {// 如果没有start_id和end_id,则放在第0层y = 0;} else if (start_id && !end_id) {// 如果只有start_id,则将其放入start_id层次结构中y = start_id.ordinal;} else if (!start_id && end_id) {// 如果只有end_id,则将其放入end_id的层次结构中y = end_id.ordinal;} else {// 如果有 start_id 和 end_idif (index === 0) {// 对于第一点,将其放置在 start_id 层次结构中y = start_id.ordinal;} else if (index === coordinates.length - 1) {// 如果是最后一个点,则将其放入end_id的层次结构中y = end_id.ordinal;} else if (start_id.ordinal === end_id.ordinal) {// 如果 start_id 和 end_id 位于同一层次结构中,则将它们放入该层次结构中y = end_id.ordinal;} else {// 如果start_id和end_id位于不同的层次结构中,则将它们放在中间层次结构中y = Math.round((start_id.ordinal + end_id.ordinal) / 2);}}return new THREE.Vector3(point[0] - center[0], y * verticalOffset + 1, -(point[1] - center[1]));});// 从point数组创建MeshLinepoints.forEach((point, index) => {// 如果是最后一点,则结束流程if (index + 1 === points.length) return;// 创建MeshLine。在两点之间创建单独的网格线const geometry = new THREE.BufferGeometry().setFromPoints([point, points[index + 1]]);const line = new MeshLine();line.setGeometry(geometry);// 添加到 MeshLine 数组const mesh = new THREE.Mesh(line, linkMaterial);meshLines.push(mesh.geometry);});});// 合并MeshLineconst linkGeometry = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(meshLines), linkMaterial);linkGeometry.name = 'link';// 添加到场景scene.add(linkGeometry);});
};

行人网络添加后

编写着色器

接下来我们使行人网络连接线流动起来。这次我们使用的数据有两种模式:(1)双向和(2)从起点到终点的方向,所以我们准备了两个简单的着色器应用于网格线。

首先,从之前定义的 MeshLineMaterial 中删除颜色信息。然后,使用onBeforeCompile添加着色器以覆盖MeshLine中现有的着色器。


// 添加着色器
linkMaterial.onBeforeCompile = (shader) => {// 将 uniforms 添加到 userDataObject.assign(shader.uniforms, linkMaterial.userData.uniforms);const keyword1 = 'void main() {';shader.vertexShader = shader.vertexShader.replace(keyword1,/* GLSL */ `varying vec2 vUv;attribute float uDistance;attribute float uDirection;varying float vDistance;varying float vDirection;${keyword1}`,);// 替换并添加到着色器const keyword2 = 'vUV = uv;';shader.vertexShader = shader.vertexShader.replace(keyword2,/* GLSL */ `${keyword2}vUv = uv;vDistance = uDistance;vDirection = uDirection;`,);const keyword3 = 'void main() {';shader.fragmentShader = shader.fragmentShader.replace(keyword3,/* GLSL */ `uniform float uTime;varying float vDirection;varying float vDistance;varying vec2 vUv;${keyword3}`,);// 替换并添加到着色器const keyword4 = 'gl_FragColor.a *= step(vCounters, visibility);';shader.fragmentShader = shader.fragmentShader.replace(keyword4,/* GLSL */ `${keyword4}vec2 p;p.x = vUv.x * vDistance;p.y = vUv.y * 1.0 - 0.5;float centerDistY = p.y; // 距中心 Y 距离float offset = abs(centerDistY) * 0.5; // 控制对角线角度float time = uTime;// 更改中心上方和下方的对角线方向if(centerDistY < 0.0) {if(vDirection == 1.0){time = -uTime;offset = -offset;}else if(vDirection == 2.0) {offset = offset;}}// 使用 mod 函数和基于距中心 y 距离的偏移生成线float line = mod(p.x - time + offset, 1.9) < 0.9 ? 1.0 : 0.0;vec3 mainColor;// 根据方向改变颜色if(vDirection == 1.0) {mainColor = vec3(0.0, 1.0, 1.0);} else if(vDirection == 2.0) {mainColor = vec3(1.0, 1.0, 0.0);}vec3 color = mix(mainColor, mainColor, line);gl_FragColor = vec4(color, line * 0.7);`,);
};

然后将其添加到createLink函数中。为了匹配每条 MeshLine 的 UV 坐标的长宽比,获取该线的两点之间的距离,并将名为 uDirection 的属性变量传递给着色器。此外,有关连接线数据方向的信息也会使用名为 uDirection 的属性变量传递到着色器。我添加了一个名为 uTime 的uniform变量来制作动画。

               // 计算两点之间的距离const distance = point.distanceTo(points[index + 1]);// 获取MeshLine的顶点数const numVerticesAfter = line.geometry.getAttribute('position').count;// 根据顶点数量生成距离数组,并使用 setAttribute 添加顶点属性,用于计算UV坐标的纵横比const distances = new Float32Array(numVerticesAfter).fill(distance);line.setAttribute('uDistance', new THREE.BufferAttribute(distances, 1));// 根据顶点数量生成方向数组,并使用 setAttribute 添加顶点属性,代表连接线数据的方向const directions = new Float32Array(numVerticesAfter).fill(feature.properties.direction);line.setAttribute('uDirection', new THREE.BufferAttribute(directions, 1));// 将uTime(时间)添加到uniforms 变量中,用于动画Object.assign(linkMaterial.userData, {uniforms: {uTime: { value: 0 },},});

接下来,在animate函数中编写添加到uTime的过程。

   // 行人网络动画if (linkMaterial.uniforms.uTime) {linkMaterial.uniforms.uTime.value += 0.1;}

https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_1575765_75d57b39-c3a5-0e31-4045-3ab1904d3baa.gif

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/578069.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

使用CRXjs、Vite、Vue 开发 Chrome 多页面插件,手动配置 vite.config.ts 和 manifest.json 文件

一、使用CRXjs、Vite、Vue 开发 Chrome 多页面插件&#xff0c;手动配置 vite.config.ts 和 manifest.json 文件 一、创建 Vue 项目 1. 使用 Vite 创建 Vue 项目 npm create vitelatest # npm yarn create vite # yarn pnpm create vite # pnpm选择 Vue 和 TS 进入项目…

Node.js v20.12.0 (LTS) 发布,带来这些主要功能!

Node.js 20.12.0&#xff08;LTS&#xff09;发布&#xff0c;看其版本号属于一个重大更新了&#xff0c;这些功能是基于 21.7.1 版本。 曾介绍过的主要功能 新增 crypto.hash() 方法加载和解析环境变量&#xff1a; process.loadEnvFile(path) path 参数可选&#xff0c;默认从…

Unity类银河恶魔城学习记录11-8 p110 Enemy modifiers源代码

Alex教程每一P的教程原代码加上我自己的理解初步理解写的注释&#xff0c;可供学习Alex教程的人参考 此代码仅为较上一P有所改变的代码 【Unity教程】从0编程制作类银河恶魔城游戏_哔哩哔哩_bilibili EnemyStat.cs using System.Collections; using System.Collections.Gener…

Midjourney绘图欣赏系列(十四)

Midjourney介绍 Midjourney 是生成式人工智能的一个很好的例子&#xff0c;它根据文本提示创建图像。它与 Dall-E 和 Stable Diffusion 一起成为最流行的 AI 艺术创作工具之一。与竞争对手不同&#xff0c;Midjourney 是自筹资金且闭源的&#xff0c;因此确切了解其幕后内容尚不…

webpack项目打包console git分支、打包时间等信息 exec

相关链接 MDN toLocaleString child_process Node.js strftime 格式 代码 buildinfo.js const { execSync, exec } require("child_process"); // exec: 在 Windows 执行 bat 和 cmd 脚本// execSync 同步 // exec 异步// exec 使用方法 // exec(git show -s,…

LLMs之Mistral:Mistral 7B v0.2的简介、安装和使用方法、案例应用之详细攻略

LLMs之Mistral&#xff1a;Mistral 7B v0.2的简介、安装和使用方法、案例应用之详细攻略 导读&#xff1a;Mistral AI首个7B模型发布于2023年9月&#xff0c;在基准测试中超越Llama 2 13B&#xff0c;一下子声名大振。Mistral 7B v0.2对应的指令调优版本Mistral-7B-Instruct-v0…

YOLOv9改进策略 :block优化 | 无需TokenMixer也能达成SOTA性能的极简ViT架构 | CVPR2023 RIFormer

&#x1f4a1;&#x1f4a1;&#x1f4a1;本文改进内容&#xff1a; token mixer被验证能够大幅度提升性能&#xff0c;但典型的token mixer为自注意力机制&#xff0c;推理耗时长&#xff0c;计算代价大&#xff0c;而RIFormers是无需TokenMixer也能达成SOTA性能的极简ViT架构…

Verilog语法之always语句学习

always语法是Verilog_HDL中最常用的一种语法。 always过程语句和语句块组成的&#xff0c;语法格式如下所示。 always(敏感信号1 or 敏感信号2.....) always实现组合逻辑和时序逻辑。用always实现组合逻辑要将所有的敏感信号加入敏感列表中&#xff1b;用always实现时序逻辑时…

计算机毕业设计Python+Spark知识图谱高考志愿推荐系统 高考数据分析 高考可视化 高考大数据 大数据毕业设计 机器学习 深度学习 人工智能

学院&#xff08;全称&#xff09;&#xff1a; 专业&#xff08;全称&#xff09;&#xff1a; 姓名 学号 年级 班级 设计&#xff08;论文&#xff09; 题目 基于Spark的高考志愿推荐系统设计与实现 指导教师姓名 职称 拟…

SQLite中的隔离(八)

返回&#xff1a;SQLite—系列文章目录 上一篇&#xff1a;SQLite版本3中的文件锁定和并发(七&#xff09; 下一篇&#xff1a;SQLite—系列文章目录 数据库的“isolation”属性确定何时对 一个操作的数据库对其他并发操作可见。 数据库连接之间的隔离 如果使用两个不…

Android R 广播注册与发送流程分析

静态广播注册时序图 动态广播注册时序图 发送广播时序图 前言 广播接收器可以分为动态和静态&#xff0c;静态广播接收器就是在 AndroidManifest.xml 中注册的&#xff0c;而动态的广播接收器是在代码中通过 Context#registerReceiver() 注册的。 这里先从静态广播的流程开始…

Jenkins详细安装配置部署

目录 简介一、安装jdk二、安装jenkins这里如果熟悉 Jenkins &#xff0c;可以【选择插件来安装】&#xff0c;如果不熟悉&#xff0c;还是按照推荐来吧。注意&#xff1a; 三、插件安装如果上面插件安装&#xff0c;选择的不是【安装推荐的插件】&#xff0c;而是【选择插件来安…