Threejs实现立体3D园区解决方案及代码

一、实现方案

单独贴代码可能容易混乱,所以这里只讲实现思路,代码放在最后汇总了下。

想要实现一个简单的工业园区、主要包含的内容是一个大楼、左右两片停车位、四条道路以及多个可在道路上随机移动的车辆、遇到停车位时随机选择是否要停车,简单设计图如下

二、实现步奏

2.1 引入环境,天空和地面

 引入天空有三种方式:

  1) 第一种通过添加天空盒导入六个不同角度的天空图片可以形成,简单方便,缺点是在两个面之间会有视觉差

  2) 第二种是设置scene的背景和环境是一张天空图片来实现的,缺点图片单一,而且在天、地斜街处很生硬

  3) 不需要导入外部图片,通过在一个球体上添加渐变色实现,缺点球体只有一部分是天空颜色,内部为白色,需要设定旋转范围

  4) 使用Three.js中的example中的Sky.js实现,效果比较完美

 引入地面:给一个大平面添加一张草地纹理即可。

2.2 创建一块地基

  创建一个固定长度的平面,然后绕X轴旋转即可

2.3 布置围墙

  导入一个围墙模型作为一个围墙的基本单位A,算出围墙所占的长和宽,为了完整性,可以将园区的长和宽设定为A的整数倍。

2.4 办公楼、停车场、充电桩加载

  1)导入一个办公大楼模型

  2)创建一个停车场类Parking.js,主要用来创建单个停车位,其中需要计算出停车位的进入点,方便以后车辆进入。

  3)导入一个充电桩,每两个停车位使用一个充电桩

2.5 添加办公楼前景观、树、公交站点

  1)在指定位置导入景观模型和公交站点模型

  2)导入树模型,在园区前侧围墙均匀分布

2.6 铺设路面

       

           

  首先道路可以细化为上下行多个车道,而车辆则是行驶在各车道的中心线位置处,所以为了方便后续车辆的控制,需要先将道路拆分,然后获取各个道路中心线和车道中心线信息

  1)创建一个道路类Road.js,道路点信息传入的是图中红色点信息(图中菱形点),需要标记出从哪个点开始道路非直线,

    比如点信息格式为:[{ coord: [10, 0], type: 1}, { coord: [10, 10], type: 0}, { coord: [0, ], type: 1}] ;0代表曲线点,1代表直线点

  2)由于使用传入的原始道路点无法绘制出平滑的曲线而且在细化道路点的时候直线点数据细化不明显,所以需要先按照一定的间隔插入部分点信息(图中绿色五角星点)

  3)根据细化后的道路点按照道路宽度向两边开始扩展点信息,扩展方式通过获取当前点A和前一个点B组成的直线,取AB上垂线且距AB直线距离均为路宽的点即可,最终得到道路左侧点A和右侧点B

  4)通过ThreeJS中创建一条平滑曲线获取曲线上的多个点即可得到三条平滑的曲线A、B、C。

  5)经过第四步虽然可以得到道路数据,但是无法区分上下行,仍然不满足使用,通过图二上下行车辆最后生成组合成的一条闭合轨迹应该是逆时针的,

     所以需要将最后生成的A、B线顶点反转拼接成一个完整的多边形,如果是逆时针则可以得到正确的上下行路线。

  6)根据道路顶点即可画出道路面以及道路边界和中心线。

2.7 添加车辆以及车辆在道路随机移动的逻辑

  

             

  创建一个移动类,可以承接车辆或者行人,当前以车辆为主,主要包含移动轨迹、当前移动所在道路和车道、车位停车、驶离车位等内容。

  1)创建一个Move.js类,创建对象时传入停车场对象信息、道路对象信息,方便后续移动时可以计算出轨迹信息

  2)根据提供的初始位置计算出最近的道路和车道信息,与当前位置拼接在一起即可生成行动轨迹。

  3)当车辆移动到道路尽头时可以获取到本道路的另外一条车道即可实现掉头

  4)路口的判断:图三中,车辆由M车道途径N车道时,由M车道左侧当前位置和上一个位置组成的线段与N车道右侧车道起始或者终止点组成的线段有交集时则代表有路口,同样方法可以得到右侧道路的路口信息

  5)路口处拐入其他车道的轨迹生成:根据4)可以找到转向N的车道信息,但是无法保证平稳转向,所以可以通过找到M和N的车道中心线所在直线获取到交点C,然后由A、C、B生成一条贝塞尔曲线即可平滑转弯

2.8 添加停车逻辑以及车辆驶离逻辑

  1)寻找停车场:如图四,车辆在向前移动时,移动到的每个点都和所有停车场的入口B的位置判断距离,如果小于一个固定值的则代表临近车位,可以停车。

  2)停车方式:根据6)获取到的停车位,同时在当前路径上继续向前取15个点的位置C、B、A组成的曲线则是倒车入口的路径线。

三、遗留问题、待优化点

1. 拐弯添加的点不多,所以在拐弯处速度较快

  ---  可以通过在拐弯处组成的多个点通过生成的线获取多个点来解决这个问题

2. 需要添加一个路口来管理各条之间的关系

  --- 优点:(1). 有了路口后,可以解决车辆在路口移动时实时计算和其他路口的位置关系,可能会导致路口转弯混乱,通过在路口中心点生成一个外接圆,如果进入路口,则锁死移动方向,如果移出路口则解除锁定

    (2). 解决在路口处,各道路绘制的边线有重叠问题,使各个道路之间能看着更平滑

    缺点:最好不需要导入路口,而是由各个道路之间的相交关系计算得出,计算逻辑较为复杂。

3. 最好能添加一个停车场方便管理车位以及车辆驶入、驶离停车位

  --- 添加停车场,车辆只需要和停车场的位置计算即可,不需要和每个停车位计算位置,减少冗余计算,而且车辆如果和单个停车位计算位置,可能存在从停车位A使出,途径相邻的停车位B,又会进入。

    添加停车场通过给停车场添加标识即可解决这个问题

4. 车位和车道的边缘线无法加宽

  --- Three.js目前的缺陷,尝试几种办法暂时没有解决

5. 没有添加车辆防碰撞功能

四、完整的代码

  为了简单点,没有用Node安装依赖包,下述JS中引入的其他文件均在threeJS安装包中可以找到,拷贝过来即可。

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>园区案例</title>
</head><body style="margin: 0;"><div id="webgl" style="border: 1px solid;"></div>
<script type="importmap">{"imports": {"three": "./three.module.js"}}
</script><script type="module" src="./Objects/Main.js"></script></script>
</body></html>主页面index.html
/*** 办公园区*/
import * as THREE from 'three';
import { OrbitControls } from '../OrbitControls.js';
import { GLTFLoader } from '../GLTFLoader.js';
import { addEnviorment, segmentsIntr } from '../Objects/Common.js';
import Move from './Move.js';
import Road from './Road.js';
import Parking from './Parking.js';/*** 1. 先引入环境 天空和地面* 2. 创建一块全区的地皮* 3. 布置围墙* 4. 办公楼、停车场、充电桩的位置* 5. 添加办公楼前装饰物、树、公交站点* 6. 铺设路面* 7. 写动态逻辑,设置页面动态化*/const wWidth = window.innerWidth; // 屏幕宽度
const wHeight = window.innerHeight; // 屏幕高度const scene = new THREE.Scene();
let renderer = null;
let camera = null;
let controls = null;
const roadObj = []; // 存储道路数据
const moveObj = []; // 存储车辆数据// 园区宽度本身
const long = 600; // 园区的长
const width = 300; // 园区的宽
// 停车场的长和宽
const [parkingW, parkingH] = [20, 30];
const parks = []; // 存储停车场数据let everyL = 0; // 单个围墙的长度
let everyW = 0; // 单个围墙的厚度
let buildH = 0; // 办公楼的厚度
let wallNumL = 0; // 展示园区占多少个墙的长度,当前设置为最大的整数-1
let wallNumW = 0;/*** 初始化*/
function init() {addEnvir(true, false);createBase();loadWall();setTimeout(() => {loadBuildings();setTimeout(() => {loadOrnament();}, 200)loadRoad();loadBusAndPeople();addClick();}, 500)
}/*** 添加相机等基础功能*/
function addEnvir(lightFlag = true, axFlag = true, gridFlag = false) {// 初始化相机camera = new THREE.PerspectiveCamera(100, wWidth / wHeight, 1, 3000);camera.position.set(300, 100, 300);camera.lookAt(0, 0, 0);// 创建灯光// 创建环境光const ambientLight = new THREE.AmbientLight(0xf0f0f0, 1.0);ambientLight.position.set(0,0,0);scene.add(ambientLight);if (lightFlag) {// 创建点光源const pointLight = new THREE.PointLight(0xffffff, 1);pointLight.decay = 0.0;pointLight.position.set(200, 200, 50);scene.add(pointLight);}// 添加辅助坐标系if (axFlag) {const axesHelper = new THREE.AxesHelper(150);scene.add(axesHelper);}// 添加网格坐标if (gridFlag) {const gridHelper = new THREE.GridHelper(300, 25, 0x004444, 0x004444);scene.add(gridHelper);}// 创建渲染器renderer = new THREE.WebGLRenderer({ antialias:true, logarithmicDepthBuffer: true });renderer.setPixelRatio(window.devicePixelRatio);renderer.setClearColor(0xf0f0f0, 0.8);renderer.setSize(wWidth, wHeight); //设置three.js渲染区域的尺寸(像素px)renderer.render(scene, camera); //执行渲染操作controls = new OrbitControls(camera, renderer.domElement);// 设置拖动范围controls.minPolarAngle = - Math.PI / 2;controls.maxPolarAngle = Math.PI / 2 - Math.PI / 360;controls.addEventListener('change', () => {renderer.render(scene, camera);})// 添加天空和草地scene.add(...addEnviorment());function render() {// 随机选择一个移动物体作为第一视角// const cur = moveObj[3];// if (cur) {//   const relativeCameraOffset = new THREE.Vector3(0, 20, -15);  //   const cameraOffset = relativeCameraOffset.applyMatrix4( cur.target.matrixWorld );  //   camera.position.x = cameraOffset.x;//   camera.position.y = cameraOffset.y;//   camera.position.z = cameraOffset.z;//   // 始终让相机看向物体  //   controls.target = cur.target.position;  //   camera.lookAt(...cur.target.position.toArray());// }renderer.render(scene, camera);requestAnimationFrame(render);}render();document.getElementById('webgl').appendChild(renderer.domElement);
}/*** 创建园区的地基*/
function createBase() {const baseGeo = new THREE.PlaneGeometry(long, width);baseGeo.rotateX(-Math.PI / 2);const baseMesh = new THREE.Mesh(baseGeo,new THREE.MeshBasicMaterial({color: '#808080',side: THREE.FrontSide}));baseMesh.name = 'BASE';scene.add(baseMesh);
}/*** 加载围墙*/
function loadWall() {const loader = new GLTFLoader();loader.load('./Objects/model/wall.gltf', (gltf) => {gltf.scene.scale.set(3, 3, 3);const source = gltf.scene.clone();// 获取单个围墙的大小const box3 = new THREE.Box3().setFromObject(gltf.scene);everyL = box3.max.x - box3.min.x;everyW = box3.max.z - box3.min.z;wallNumL = Math.floor(long / everyL) - 1;wallNumW = Math.floor(width / everyL) - 1;// 加载后墙// 墙的起点和终点const backS = [-long / 2, 0, -width / 2];for (let i = 0; i < wallNumL; i++) {const cloneWall = source.clone();cloneWall.position.x = backS[0] + everyL * i + everyL / 2;cloneWall.position.z = backS[2];scene.add(cloneWall);}// 加载左侧墙const leftS = [-long / 2, 0, -width / 2];for (let i = 0; i < wallNumW; i++) {const cloneWall = source.clone();cloneWall.rotateY(Math.PI / 2);cloneWall.position.x = leftS[0];cloneWall.position.z = leftS[2] + everyL * i + everyL / 2;scene.add(cloneWall);}// 加载右侧墙const rightS = [-long / 2 + wallNumL * everyL, 0, -width / 2];for (let i = 0; i < wallNumW; i++) {const cloneWall = source.clone();cloneWall.rotateY(Math.PI / 2);cloneWall.position.x = rightS[0];cloneWall.position.z = rightS[2] + everyL * i + everyL / 2;scene.add(cloneWall);}// 加载前侧墙const frontS = [-long / 2, 0, -width / 2 + wallNumW * everyL];for (let i = 0; i < wallNumL; i++) {if (i !== Math.floor(wallNumL / 2)) {const cloneWall = source.clone();cloneWall.position.x = frontS[0] + everyL * i + everyL / 2;cloneWall.position.z = frontS[2];scene.add(cloneWall);}}})
}/*** 加载办公大楼以及停车场和充电桩*/
function loadBuildings() {const loader = new GLTFLoader();loader.load('./Objects/model/buildings.gltf', (gltf) => {gltf.scene.scale.set(4, 4, 4);// 获取大楼的大小const box3 = new THREE.Box3().setFromObject(gltf.scene);buildH = box3.max.z - box3.min.z;gltf.scene.position.z = -width / 2 + buildH / 2;scene.add(gltf.scene);})// 添加左侧停车场// 左侧停车场起始点坐标const leftSPos = [-long / 2 + everyW + parkingH / 2, 0, -width / 2 + everyW + parkingW / 2 + 3];for (let i = 0; i < 4; i++) {const z =  leftSPos[2] + i * parkingW;const parking = new Parking({name: `A00${i + 1}`,width: parkingW,height: parkingH,position: [leftSPos[0], leftSPos[1] + 1, z]})scene.add(parking.group);parks.push(parking);}// 右侧充电桩起始点坐标 并预留位置给充电枪const rightSPos = [-long / 2 + wallNumL * everyL - everyW - parkingH / 2 - 10, 0, -width / 2 + everyW + parkingW / 2 + 3];for (let i = 0; i < 4; i++) {const parking = new Parking({name: `B00${i + 1}`,width: parkingW,height: parkingH,position: [rightSPos[0], rightSPos[1] + 1, rightSPos[2] + i * parkingW],rotate: Math.PI})scene.add(parking.group);parks.push(parking);}// 添加充电桩const chargePos = [-long / 2 + wallNumL * everyL - everyW - 4, 0, -width / 2 + everyW + 3 + parkingW];loader.load('./Objects/model/charging.gltf', (gltf) => {for (let i = 0; i < 2; i++) {const source = gltf.scene.clone();source.scale.set(6, 6, 6);source.rotateY(Math.PI / 2);source.position.x = chargePos[0];source.position.y = chargePos[1];source.position.z = chargePos[2] + i * 2 * parkingW;scene.add(source);}})
}/*** 添加办公楼前装饰物、树、公交站点*/
function loadOrnament() {// 加载办公室前方雕塑const loader = new GLTFLoader();loader.load('./Objects/model/bed.gltf', (bedGltf) => {bedGltf.scene.scale.set(2, 2, 2);bedGltf.scene.rotateY(-Math.PI * 7 / 12);loader.load('./Objects/model/sculpture.gltf', (sculGltf) => {sculGltf.scene.scale.set(20, 20, 20);sculGltf.scene.y = sculGltf.scene.y + 4;const group = new THREE.Group();group.add(bedGltf.scene);group.add(sculGltf.scene);group.position.set(0, 0, -width / 2 + everyW + buildH + 10);scene.add(group);});});// 加载树木,沿街用的是柏树loader.load('./Objects/model/songshu.gltf', (gltf) => {const source = gltf.scene;source.scale.set(8, 8, 8);// 前面墙的树木, 单个墙的中间区域放置一棵树const frontS = [-long / 2 + everyL / 2, 0, -width / 2 + wallNumW * everyL - 5];for (let i = 0; i < wallNumL; i++) {// 同样门口不放置树if (i !== Math.floor(wallNumL / 2)) {const temp = source.clone();temp.position.set(frontS[0] + i * everyL, frontS[1], frontS[2]);scene.add(temp);}}});// 加载公交站点,位置在距离大门右侧第二单面墙处loader.load('./Objects/model/busStops.gltf', (gltf) => {const source = gltf.scene;source.scale.set(4, 4, 4);gltf.scene.position.set(-long / 2 + (Math.floor(wallNumL / 2) + 3) * everyL, 0, -width / 2 + wallNumW * everyL + everyW + 3);scene.add(gltf.scene);});
}/*** 铺设园区和园区外面的公路* 包含公路以及部分人行道路*/
function loadRoad() {const space = 40;const outWidth = 40;// 加载园区外面的公路const outerP1 = [{ coord: [-long / 2, 0, -width / 2 + wallNumW * everyL + space], type: 1 },{ coord: [long / 2, 0, -width / 2 + wallNumW * everyL + space], type: 1 },];const road1 = new Road({name: 'road_1',sourceCoord: outerP1,width: outWidth,showCenterLine: true});scene.add(road1.group);const outerP2 = [{ coord: [-long / 2 + wallNumL * everyL + outWidth / 2  + 10, 0, -width / 2 + wallNumW * everyL + space - outWidth / 2 + 0.5], type: 1 },{ coord: [-long / 2 + wallNumL * everyL + outWidth / 2  + 10, 0, -width / 2], type: 1 },];const road2 = new Road({name: 'road_2',sourceCoord: outerP2,width: outWidth,showCenterLine: true,zIndex: 0.8});scene.add(road2.group);// 加载园区内的道路const innerWidth = 25;const color = 0x787878;const lineColor = 0xc2c2c2;// 加载到停车场的道路const innerP1 = [{ coord: [-long / 2 + Math.floor(wallNumL / 2) * everyL + everyL / 2, 0, -width / 2 + wallNumW * everyL + space - outWidth / 2 + 0.5], type: 1 },{ coord: [-long / 2 + Math.floor(wallNumL / 2) * everyL + everyL / 2, 0, -width / 2 + wallNumW * everyL + space - 60], type: 0 },{ coord: [-long / 2 + Math.floor(wallNumL / 2) * everyL + everyL / 2 - innerWidth / 2, 0, -width / 2 + wallNumW * everyL + space - 60 - innerWidth / 2], type: 1 },{ coord: [-long / 2 + parkingH + 20 + innerWidth / 2, 0, -width / 2 + wallNumW * everyL + space - 60 - innerWidth / 2], type: 0 },{ coord: [-long / 2 + parkingH + 20, 0, -width / 2 + wallNumW * everyL + space - 60 - innerWidth], type: 1 },{ coord: [-long / 2 + parkingH + 20, 0, -width / 2 + everyW + 10], type: 1 },];const street1 = new Road({name: 'street_1',sourceCoord: innerP1,width: innerWidth,showCenterLine: true,zIndex: 0.8,planeColor: color,sideColor: lineColor});scene.add(street1.group);// 加载到充电桩的道路const innerP2 = [{ coord: [-long / 2 + Math.floor(wallNumL / 2) * everyL + everyL / 2, 0, -width / 2 + wallNumW * everyL + space - outWidth / 2 + 0.5], type: 1 },{ coord: [-long / 2 + Math.floor(wallNumL / 2) * everyL + everyL / 2, 0, -width / 2 + wallNumW * everyL + space - 60], type: 0 },{ coord: [-long / 2 + Math.floor(wallNumL / 2) * everyL + everyL / 2 + innerWidth / 2, 0, -width / 2 + wallNumW * everyL + space - 60 - innerWidth / 2], type: 1 },{ coord: [-long / 2 + wallNumL * everyL - parkingH - everyW - 39, 0, -width / 2 + wallNumW * everyL + space - 60 - innerWidth / 2], type: 0 },{ coord: [-long / 2 + wallNumL * everyL - parkingH - everyW - 39 + innerWidth / 2, 0, -width / 2 + wallNumW * everyL + space - 60 - innerWidth], type: 1 },{ coord: [-long / 2 + wallNumL * everyL - parkingH - everyW - 39 + innerWidth / 2, 0, -width / 2 + everyW + 10], type: 1 },];const street2 = new Road({name: 'street_2',sourceCoord: innerP2,width: innerWidth,showCenterLine: true,zIndex: 0.8,planeColor: color,sideColor: lineColor});scene.add(street2.group);roadObj.push(road1,road2,street1,street2);calFork();
}/*** 计算pointA和pointB 组成的直线与点集points是否有相交* @param {*} points * @param {*} pontA * @param {*} pointB */
function judgeIntersect(points, pointA, pointB) {let res = { flag: false, interP: [] };for (let i = 0; i < points.length - 1; i++) {const cur = points[i];const nextP = points[i + 1];const interP = segmentsIntr(cur, nextP, pointA, pointB, true)if ( interP !== false) {res.flag = true;res.interP = interP;res.index = i;break;}}return res;
}/*** 计算各条道路的岔口信息并统计到道路对象中*/
function calFork() {function setInter(cur, next, interP, corner, width) {const circle = new THREE.ArcCurve(corner[0], corner[2], width * 2).getPoints(20);const cirPoints = circle.map(e => new THREE.Vector3(e.x, 0, e.y));cur.intersect.push({ name: next.name,interPoint: interP,corner: cirPoints,cornerCenter: corner});next.intersect.push({name: cur.name,interPoint: interP,corner: cirPoints,cornerCenter: corner});}roadObj.forEach((e, i) => {if (i < roadObj.length - 1) {for (let j = i + 1; j < roadObj.length; j++) {if (e.intersect.map(e => e.name).indexOf(roadObj[j].name) < 0) {const middle = roadObj[j].middle;// 计算路牙和其他道路是否有相交// 左边路牙和下一条路的起始位置做对比let inter = judgeIntersect(e.left, middle[0], middle[1]);if (inter.flag) {const cornerCenter = segmentsIntr(e.middle[inter.index], e.middle[inter.index + 1], middle[0], middle[1]);setInter(e, roadObj[j], inter.interP, cornerCenter, roadObj[j].width);continue;}// 左边路牙和下一条路的终止位置做对比inter = judgeIntersect(e.left, middle[middle.length - 1], middle[middle.length - 2])if (inter.flag) {const cornerCenter = segmentsIntr(e.middle[inter.index], e.middle[inter.index + 1], middle[middle.length - 1], middle[middle.length - 2]);setInter(e, roadObj[j], inter.interP, cornerCenter, roadObj[j].width);continue;} // 右边路牙和下一条路的起始位置做对比inter = judgeIntersect(e.right, middle[0], middle[1]);if (inter.flag) {const cornerCenter = segmentsIntr(e.middle[inter.index], e.middle[inter.index + 1], middle[0], middle[1]);setInter(e, roadObj[j], inter.interP, cornerCenter, roadObj[j].width);continue;}// 右边路牙和下一条路的终止位置做对比inter = judgeIntersect(e.right, middle[middle.length - 1], middle[middle.length - 2]);if (inter.flag) {const cornerCenter = segmentsIntr(e.middle[inter.index], e.middle[inter.index + 1], middle[middle.length - 1], middle[middle.length - 2]);setInter(e, roadObj[j], inter.interP, cornerCenter, roadObj[j].width);continue;}}}}})
}function actionTemp(target, name, flag, moveName) {const filter = roadObj.filter(e => e.name === name)[0];const carObject = new Move({name: moveName,target: target,roads: roadObj,startPos: flag ? filter.left[0] : filter.right[0],parks: parks});moveObj.push(carObject);
}/*** 加载行人和汽车*/
function loadBusAndPeople() {// 加载汽车和公交车const loader = new GLTFLoader();const carId = ['car0','car2','car4','car5','bus','car3',];const roadIds = ['road_1','road_2','street_1','street_2','street_2','road_2',];carId.forEach((e, i) => {loader.load(`./Objects/model/${e}.gltf`, (gltf) => {gltf.scene.scale.set(4, 4, 4);scene.add(gltf.scene);gltf.scene.name = e;actionTemp(gltf.scene, roadIds[i], false, e);});})
}/*** 点击汽车驶离停车位*/
function addClick() {renderer.domElement.addEventListener('click', (event) => {const px = event.offsetX;const py = event.offsetY;const x = (px / wWidth) * 2 - 1;const y = -(py / wHeight) * 2 + 1;//创建一个射线发射器const raycaster = new THREE.Raycaster();// .setFormCamera()计算射线投射器的射线属性ray// 即在点击位置创造一条射线,被射线穿过的模型代表选中raycaster.setFromCamera(new THREE.Vector2(x, y), camera);const intersects = raycaster.intersectObjects(moveObj.map(e => e.target));if (intersects.length > 0) {const move = moveObj.filter(e => e.name === intersects[0].object.parent.name || e.name === intersects[0].object.parent.parent.name)[0];if (move && move.pause) {move.unParkCar();}}})
}init();控制器Main.js
import * as THREE from 'three';
import { getCurvePoint, getSidePoints, segmentsIntr, clone, isClockWise } from './Common.js';/*** 移动类,实现物体如何按照路径运动以及在岔路口如何选择等功能* 后期可以增加碰撞检测避让等功能*/
class Road {constructor(props) {// 道路的原始点信息,通过这些点信息扩展道路this.sourceCoord = props.sourceCoord;// 道路名称this.name = props.name;// 道路宽度this.width = props.width;// 是否显示道路中心线this.showCenterLine = props.showCenterLine === false ? false : true;// 左侧路牙点集合this.left = [];// 道路中心线点集合this.middle = [];// 右侧路牙点集合this.right = [];// 道路面的颜色this.planeColor = props.planeColor || 0x606060;// 道路边线的颜色this.sideColor = props.sideColor || 0xffffff;// 道路中心线的颜色this.middleColor = props.middleColor || 0xe0e0e0;// 道路的层级this.zIndex = props.zIndex || 0.5;// 车道信息this.lanes = [];// 道路组合对象this.group = null;// 相交的道路名称 数据格式{name: ***, interPoint: [xx,xx,xx]}this.intersect = [];this.lineInsert();this.create();}/*** 由于直线获取贝塞尔点的时候插入的点较少导致物体运动较快,所以在* 平行与X、Y轴的线插入部分点,保证物体运动平滑,插入点时保证X或者Z轴间距为路宽的一半*/lineInsert() {const temp = [];const half = this.width / 2;this.sourceCoord.forEach((cur, i) => {temp.push(cur);if (i < this.sourceCoord.length - 1) {const e = cur.coord;const nextP = this.sourceCoord[i + 1].coord;// 处理直线if (cur.type === 1) {if (e[0] - nextP[0] === 0) {// 平行Z轴if (e[2] < nextP[2]) {for (let i = e[2] + half; i < nextP[2]; i += half) {temp.push({coord: [e[0], e[1], i],type: 1});}} else {for (let i = e[2] - half; i > nextP[2]; i -= half) {temp.push({coord: [e[0], e[1], i],type: 1});}}} else if (e[2] - nextP[2] === 0) {// 平行X轴if (e[0] < nextP[0]) {for (let i = e[0] + half; i < nextP[0]; i += half) {temp.push({coord: [i, e[1], e[2]],type: 1});}} else {for (let i = e[0] - half; i > nextP[0]; i -= half) {temp.push({coord: [i, e[1], e[2]],type: 1});}}}}}})this.sourceCoord = temp;}/*** 创建道路*/create() {const group = new THREE.Group();const roadPoints = this.getPoints(this.sourceCoord, this.width);this.left = roadPoints[0];this.middle = roadPoints[1];this.right = roadPoints[2];const isWise = isClockWise(this.left.concat(clone(this.right).reverse()));// 添加左车道this.lanes.push(new Lane({name: `${this.name}_lane_0`,type: 'left',isReverse: isWise,side: this.left,middle: this.middle}));// 添加右车道this.lanes.push(new Lane({name: `${this.name}_lane_1`,type: 'right',isReverse: !isWise,side: this.right,middle: this.middle}));const outlinePoint = roadPoints[0].concat(clone(roadPoints[2]).reverse());outlinePoint.push(roadPoints[0][0]);const shape = new THREE.Shape();outlinePoint.forEach((e, i) => {if (i === 0) {shape.moveTo(e[0], e[2], e[1]);} else {shape.lineTo(e[0], e[2], e[1]);}})// 创建道路面const plane = new THREE.Mesh(new THREE.ShapeGeometry(shape),new THREE.MeshBasicMaterial({color: this.planeColor,side: THREE.DoubleSide}))plane.rotateX(Math.PI / 2);group.add(plane);// 创建道路边沿线const sideL = new THREE.Line(new THREE.BufferGeometry().setFromPoints(roadPoints[0].map(e => new THREE.Vector3(...e))),new THREE.LineBasicMaterial({ color: this.sideColor, linewidth: 10 }));const sideR = new THREE.Line(new THREE.BufferGeometry().setFromPoints(roadPoints[2].map(e => new THREE.Vector3(...e))),new THREE.LineBasicMaterial({ color: this.sideColor, linewidth: 10 }));group.add(sideL, sideR);// 创建道路中心虚线if (this.showCenterLine) {const sideM = new THREE.Line(new THREE.BufferGeometry().setFromPoints(roadPoints[1].map(e => new THREE.Vector3(...e))),new THREE.LineDashedMaterial({ color: this.middleColor, linewidth: 10, gapSize: 5, dashSize: 5 }));sideM.computeLineDistances();group.add(sideM);}group.position.y = group.position.y + this.zIndex;group.name = this.name;this.group = group;}/*** 获取道路的顶点信息*/getPoints(points) {const half = this.width / 2;// 存储左中右三条线路的顶点信息const [left, middle, right] = [[], [], []];for (let i = 0; i < points.length;) {const e = points[i].coord;if (points[i].type === 1) {// 直线处理方式if (i === 0) {const nextP = points[i + 1].coord;const side = getSidePoints(e, nextP, e, half);left.push(side[0]);middle.push(e);right.push(side[1]);} else {const preP = points[i - 1].coord;const side = getSidePoints(preP, e, e, half);left.push(side[0]);middle.push(e);right.push(side[1]);}i++;} else {// 曲线处理方式const preMidP = points[i - 1].coord;const nextMidP1 = points[i + 1].coord;const nextMidP2 = points[i + 2].coord;// 获取两侧点信息const sideP1 = getSidePoints(preMidP, e, e, half);const sideP2 = getSidePoints(nextMidP1, nextMidP2, nextMidP1, half);const sideP3 = getSidePoints(nextMidP1, nextMidP2, nextMidP2, half);// 左侧const interLeft = segmentsIntr(left[left.length - 1], sideP1[0], sideP2[0], sideP3[0]);const curveLeft = getCurvePoint(sideP1[0], interLeft, sideP2[0]);left.push(...curveLeft);// 中间const interMid = segmentsIntr(middle[middle.length - 1], e, nextMidP1, nextMidP2);const curveMid = getCurvePoint(e, interMid, nextMidP1);middle.push(...curveMid);// 右侧const interRight = segmentsIntr(right[right.length - 1], sideP1[1], sideP2[1], sideP3[1]);const curveRight = getCurvePoint(sideP1[1], interRight, sideP2[1]);right.push(...curveRight);i += 2;}}return [left, middle, right];}
}/*** 车道对象*/
class Lane {constructor(options) {//车道名称this.name = options.name;// 标识左车道还是右车道this.type = options.type;// 行驶方向和点的方向是否一致this.direction = options.direction;// 车道中心线this.coord = this.getCenter(options.middle, options.side, options.isReverse);}getCenter(middle, side, reverseFlag) {const center = middle.map((e, i) => {return [(e[0] + side[i][0]) / 2, e[1], (e[2] + side[i][2]) / 2];});return reverseFlag ? center.reverse() : center;}
}export default Road;道路类Road.js
import * as THREE from 'three';
import { calDistance, drawLine, clone, pointInPolygon, segmentsIntr } from './Common.js';/*** 移动类,实现物体如何按照路径运动以及在岔路口如何选择等功能* 后期可以增加碰撞检测避让等功能*/
class Move {constructor(props) {// 所有道路信息this.roads = {};props.roads.forEach(e => this.roads[e.name] = e);this.name = props.name;// 物体对象this.target = props.target;// 前进还是倒车 1:前进;-1:倒车this.direction = 1;// 物体初始位置this.startPos = props.startPos;// 移动轨迹this.trace = [];// 当前形式的道路this.curRoad = null;// 锁定下一步行动趋势,主要是为了解决在路口物体获取到下一步行动后再次获取行动导致功能乱掉this.trendLock = false;this.preRoadName = null;// 当前移动所在的车道this.curLane = null;// 是否暂停移动this.pause = false;// 园区停车场信息this.parks = props.parks;this.parkObj = null;this.init();}/*** 获取当前实体所在道路以及移动方向和移动轨迹*/init() {let minDis = {roadName: '',  // 起始位置所在道路的道路名distance: 100000, // 起始物体与车道左右边线的最小距离curLane: null, // 移动的车道};for (let o in this.roads) {const road = this.roads[o];const startLeftDis = calDistance(road.lanes[0].coord[0], this.startPos);const startRightDis = calDistance(road.lanes[1].coord[0], this.startPos);const endLeftDis = calDistance(road.lanes[0].coord[road.lanes[0].coord.length - 1], this.startPos);const endRightDis = calDistance(road.lanes[1].coord[road.lanes[1].coord.length - 1], this.startPos);const min = Math.min(startLeftDis, startRightDis, endLeftDis, endRightDis);if (minDis.distance > min) {minDis = {roadName: o,distance: min,curLane: (min === startRightDis || min === endRightDis) ? road.lanes[0] : road.lanes[1],index: (startLeftDis === min || startRightDis === min) ? 0 : road.left.length - 1};}}this.curLane = minDis.curLane;this.curRoad = this.roads[minDis.roadName];this.trace = this.getSpreadPoint(this.curLane.coord);this.moveIndex = minDis.index;this.move();}/*** 获取车道中心线* 将车道的左右线取平均值即可* @param {*} params */getLaneCenter(points1, points2) {return points1.map((e, i) => {return [(e[0] + points2[i][0]) / 2, e[1], (e[2] + points2[i][2]) / 2];})}move() {if (!this.pause) {this.checkMoveOutCorner();this.parkCar();// 如果移动到当前轨迹的最后一个点时,需要进行掉头重新寻找轨迹const forks = this.checkFork();if (forks.length !== 0) {this.getRandomTrend(forks);} else if (this.moveIndex === this.trace.length - 1) {this.getTurnPoint();}const curPosition = this.trace[this.moveIndex].toArray();this.target.position.set(...curPosition);if (this.direction === 1) {// 前进if (this.moveIndex !== this.trace.length - 1) {this.target.lookAt(...this.trace[this.moveIndex + 1].toArray());}} else {// 倒车if (this.moveIndex !== 0) {this.target.lookAt(...this.trace[this.moveIndex - 1].toArray());}}this.moveIndex ++;}requestAnimationFrame(this.move.bind(this));}/*** 在锁定后检查物体是否已经移出路口,如果移出则解除锁定*/checkMoveOutCorner() {if (!this.trendLock) {return false;}const preObj = this.curRoad.intersect.filter(e => e.name === this.preRoadName)[0];if (preObj && !pointInPolygon(this.trace[this.moveIndex], preObj.corner)) {this.trendLock = false;this.preRoadName = null;}return this.trendLock;}/*** 根据提供的点信息寻找最近的点值*/findNearIndex(points, pointA) {let min = 100000;let index = -1;for (let i = 0; i < points.length; i++) {const dis = calDistance(points[i], pointA);if (dis < min) {min = dis;} else {index = i - 1;break;}}return index;}/*** 在岔路口时随机获取下一步行动方向*/getRandomTrend(forks) {const isEnd = calDistance(this.trace[this.moveIndex].toArray(), this.trace[this.trace.length - 1].toArray()) < this.curRoad.width / 2;// 从多条路中随机选择一条,当前园区路况简单 路口数据目前只有一条const randomRoad = forks[Math.floor(Math.random() * forks.length)];// 分别代表掉头、转弯、直行四种情况let types = [0, 1, 2];if (isEnd) {// 如果是道路的尽头 可以选择掉头或者转弯types = [0, 1, 2];} else {// 如果不是道路的尽头,可以选择转弯或者直行types = [1, 2];}const random = types[Math.floor(Math.random() * types.length)];if (random === 0) {// 掉头this.trendLock = true;this.getTurnPoint();} else if (random === 1) {// 转弯this.trendLock = true;this.getForkPoint(randomRoad, isEnd);} else if (random === 2) {this.preRoadName = randomRoad;// 直线this.trendLock = true;}}/*** 在岔路口时根据获取道路轨迹信息*/getForkPoint(name, isEnd) {this.preRoadName = this.curRoad.name;const roadObj = this.roads[name];let splitPoint = [];if (isEnd) {// 如果在道路尽头转弯,随机产生是左侧还是右侧道路const leftOrRight = Math.floor(Math.random() * roadObj.lanes.length);const coord = roadObj.lanes[leftOrRight].coord;this.curLane = roadObj.lanes;const index = this.findNearIndex(coord, this.trace[this.moveIndex].toArray());splitPoint = coord.slice(index + 1);// 为了平滑过渡获取道路末端和当前行驶位置的交点const corInter = segmentsIntr(splitPoint[0], splitPoint[1], this.trace[this.trace.length - 2].toArray(), this.trace[this.trace.length - 3].toArray());if (corInter) {splitPoint.unshift(corInter);}splitPoint.unshift(this.trace[this.moveIndex]);} else {// 转弯前需要判断当前路可以向那个方向转弯,比如临近路口只能转向右车道,非临近路口需要转到对象车道// 可以根据当前点和车道点的距离来判断const lane1Dis = calDistance(roadObj.lanes[0].coord[0], this.trace[this.moveIndex].toArray());const lane2Dis = calDistance(roadObj.lanes[1].coord[0], this.trace[this.moveIndex].toArray());let temp = null;if (lane1Dis < lane2Dis) {temp = clone(roadObj.lanes[0].coord);this.curLane = roadObj.lanes[0];} else {temp = clone(roadObj.lanes[1].coord);this.curLane = roadObj.lanes[1];}this.curRoad = roadObj;const index = this.findNearIndex(temp, this.trace[this.moveIndex].toArray());splitPoint = temp.slice(index + 1);// 为了平滑过渡获取道路末端和当前行驶位置的交点const corInter = segmentsIntr(splitPoint[0], splitPoint[1], this.trace[this.moveIndex].toArray(), this.trace[this.moveIndex + 1].toArray());if (corInter) {splitPoint.unshift(corInter);  }splitPoint.unshift(this.trace[this.moveIndex]);}this.trace = this.getSpreadPoint(splitPoint).map(e => new THREE.Vector3(...e));// drawLine(this.target.parent, this.trace);this.moveIndex = 0;}/*** 掉头后获取道路轨迹信息*/getTurnPoint() {const roadObj = this.curRoad;const nextLane = roadObj.lanes.filter(e => e.name !== this.curLane.name)[0]const clonePoint = clone(nextLane.coord);clonePoint.unshift(this.trace[this.moveIndex].toArray());this.trace = this.getSpreadPoint(clonePoint);this.curLane = nextLane;// drawLine(this.target.parent, this.trace);this.moveIndex = 0;}/*** 获取离散点即将点与点之间更加细化,使物体运行起来更加平滑* @param {*} points * @returns */getSpreadPoint(points, beta = 1) {const trail = new THREE.CatmullRomCurve3([...points.map(e => new THREE.Vector3(...e))]);return trail.getPoints(trail.getLength() * beta);}/*** 将车辆停入停车场内*/parkCar() {// 是否允许停车if (this.parkEnable) {return;}if (this.parkObj) {if (this.direction === -1 && this.moveIndex === this.trace.length - 2) {this.pause = true;this.parkObj.object.placed = true;}if (this.moveIndex === this.trace.length - 1) {this.direction = -1;this.trace = this.parkObj.backTrace;// drawLine(this.target.parent, this.trace);this.moveIndex = 0;} else {return;}} else {let flag = false;let parkObj = null;const frontIndex = 13;for (let i = 0; i < this.parks.length; i++) {if (calDistance(this.parks[i].entryPoint, this.trace[this.moveIndex].toArray()) < 13 && !this.parks[i].placed) {flag = true;parkObj = this.parks[i];break;}}// return flag;if (flag) {const random = Math.floor(Math.random() * 2);// 1代表停车if (random === 1) {const front = this.trace.slice(this.moveIndex, this.moveIndex + frontIndex).map(e => new THREE.Vector3(...e));const back = this.getSpreadPoint([front[front.length - 1], parkObj.entryPoint, parkObj.position]);this.moveIndex = 0;this.trace = front;this.parkObj = { object: parkObj, backTrace: back };}}}}unParkCar() {this.direction = 1;const parkObj = this.parkObj.object;parkObj.placed = false;const index = this.findNearIndex(this.curLane.coord, parkObj.entryPoint);const temp = this.curLane.coord.slice(index);temp.unshift(parkObj.entryPoint);temp.unshift(parkObj.position);this.trace = this.getSpreadPoint(temp);this.moveIndex = 0;this.pause = false;this.parkObj = null;this.parkEnable = true;// 不想再扩展功能了 就在这临时用标识符处理了下出库时不断循环查询车位的情况setTimeout(() => {this.parkEnable = false;}, 3000)}/*** 检查岔路口*/checkFork() {if (this.trendLock) {return [];}const forks = [];for (let i = 0; i < this.curRoad.intersect.length; i++) {if (this.moveIndex < this.trace.length - 1) {const dis1 = calDistance(this.trace[this.moveIndex].toArray(), this.curRoad.intersect[i].interPoint);const dis2 = calDistance(this.trace[this.moveIndex + 1].toArray(), this.curRoad.intersect[i].interPoint);if (dis1 < this.curRoad.width && dis1 > dis2) {forks.push(this.curRoad.intersect[i].name);}}}return forks;}
}export default Move;移动类Move.js

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

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

相关文章

解决ssh登录Permission denied, please try again

现象截图如下&#xff1a; 确定root的密码是正确的&#xff0c;最后的原因找到了&#xff0c;是远程的服务器&#xff0c;禁用了root账户可以被远程访问的权限。开启操作如下&#xff1a; 1.编辑配置文件 vi /etc/ssh/sshd_config 2.文件中找到PermitRootLogin #PermitRoo…

seaborn可视化示例详解

目录 1、散点图 2、散点图回归线 3、折线图 4、频数柱状图 5、分组散点图 6、箱型图 7、数值分布柱状图 8、频数分布图 9、联合分布图 10、数值分布柱状图 11、相关系数热力图 划重点 少走10年弯路 Seaborn是一个基于Python的数据可视化库&#xff0c;Seaborn提供了许多用…

链表|数据结构|C语言深入学习

什么是链表 离散&#xff0c;就是“分离的、散开的” 链表是什么样子的&#xff1a; 有限个节点离散分配 彼此间通过指针相连 除了首尾节点&#xff0c;每个节点都只有一个前驱节点和一个后继节点 首节点没有前驱结点&#xff0c;尾节点没有后继节点 基本概念术语&#xf…

1.使用分布式文件系统Minio管理文件

分布式文件系统DFS分类 文件系统 文件系统是操作系统用于组织管理存储设备(磁盘)或分区上文件信息的方法和数据结构,负责对文件存储设备空间进行组织和分配,并对存入文件进行保护和检索 文件系统是负责管理和存储文件的系统软件&#xff0c;操作系统通过文件系统提供的接口去…

Docker五部曲之五:通过Docker和GitHub Action搭建个人CICD项目

文章目录 项目介绍Dockerfile解析compose.yml解析Nginx反向代理到容器以及SSL证书设置MySQL的准备工作Spring和环境变量的交互 GitHub Action解析项目测试结语 项目介绍 该项目是一个入门CICD-Demo&#xff0c;它由以下几部分组成&#xff1a; Dockerfile&#xff1a;用于构建…

「 典型安全漏洞系列 」05.XML外部实体注入XXE详解

1. XXE简介 XXE&#xff08;XML external entity injection&#xff0c;XML外部实体注入&#xff09;是一种web安全漏洞&#xff0c;允许攻击者干扰应用程序对XML数据的处理。它通常允许攻击者查看应用程序服务器文件系统上的文件&#xff0c;并与应用程序本身可以访问的任何后…

【Unity小技巧】3D人物移动脚步和跳跃下落音效控制

文章目录 单脚步声多脚步声&#xff0c;跳跃落地音效播放不同材质的多脚步声完结 单脚步声 public AudioClip walkingSound; public AudioClip runningSound;//移动音效 public void MoveSound() {// 如果在地面上并且移动长度大于0.9if (isGround && moveDirection.s…

动物免疫(羊驼免疫)-泰克生物

在过去几十年里&#xff0c;抗体研究和应用的领域已经经历了革命性的变化。在这个进程中&#xff0c;一种特殊来源的抗体 —— 来自骆驼科动物&#xff08;包括羊驼&#xff09;的单链抗体&#xff08;也称纳米抗体&#xff09;引起了全球科学家的广泛关注。 羊驼等骆驼科动物…

【Linux】python版本控制和环境管理

文章目录 1.查看目前python的版本2.添加软件源并更新3.选择你想要下载的版本4.警示&#xff1a;没必要设置默认版本误区千万千万不要覆盖python3软链接解决办法 5.pip软件包管理最省心稍微麻烦换源 网上有很多教程都是教导小白去官方下载之后编译安装。但是&#xff0c;小白连c…

4.go 基础类型及类型转换

目录 概述basic types总结例子 Zero values总结例子 类型转换总结例子 结束 概述 go 版本&#xff1a; go1.20.13 basic types 总结 基本类型如下&#xff1a; boolstringint int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptrbyte // alias for uint8…

[pytorch入门] 2. tensorboard

tensorboard简介 TensorBoard 是一组用于数据可视化的工具。它包含在流行的开源机器学习库 Tensorflow 中.但是也可以独立安装&#xff0c;服务Pytorch等其他的框架 可以常常用来观察训练过程中每一阶段如何输出的 安装pip install tensorboard启动tensorboard --logdir<d…

蓝桥杯理历年真题 —— 数学

1. 买不到的数目 这道题目&#xff0c;考得就是一个日常数学的积累&#xff0c;如果你学过这个公式的话&#xff0c;就是一道非常简单的输出问题&#xff1b;可是如果没学过&#xff0c;就非常吃亏&#xff0c;在考场上只能暴力求解&#xff0c;或是寻找规律。这就要求我们什么…

Python图像处理【19】基于霍夫变换的目标检测

基于霍夫变换的目标检测 0. 前言1. 使用圆形霍夫变换统计图像中圆形对象2. 使用渐进概率霍夫变换检测直线2.1 渐进霍夫变换原理2.2 直线检测 3. 使用广义霍夫变换检测任意形状的对象3.1 广义霍夫变换原理3.2 检测自定义形状 小结系列链接 0. 前言 霍夫变换 (Hough Transform,…

H5112C PWM调光 无频闪 高性价比 支持12V 24V 36V 48V 60V 72V 内置MOS

PWM调光芯片是一种常用于LED调光控制的芯片&#xff0c;其工作原理如下&#xff1a; 脉冲宽度调制&#xff08;PWM&#xff09;&#xff1a;PWM是一种调制技术&#xff0c;通过改变信号的脉冲宽度来控制输出信号的平均功率。在PWM调光中&#xff0c;芯片会以一定的频率产生一系…

SpringCloud Alibaba 深入源码 - Nacos 和 Eureka 的区别(健康检测、服务的拉取和订阅)

目录 一、Nacos 和 Eureka 的区别 1.1、以 Nacos 注册流程来解析区别 一、Nacos 和 Eureka 的区别 1.1、以 Nacos 注册流程来解析区别 a&#xff09;首先&#xff0c;我们的服务启动时。都会把自己的信息提交给注册中心&#xff0c;然后注册中心就会把信息保存下来. 注册的…

ELK日志分析

目录 一、ELK概述 &#xff08;一&#xff09;ELK的定义 &#xff08;二&#xff09;ELK工具 1.ElasticSearch 2.Kiabana 3.Logstash &#xff08;1&#xff09;定义 &#xff08;2&#xff09;插件 ① input ② filter ③ output &#xff08;三&#xff09;可以添…

快速排序(三)——hoare法

目录 ​一.前言 二.快速排序 hoare排法​ 三.结语 一.前言 本文给大家带来的是快速排序&#xff0c;快速排序是一种很强大的排序方法&#xff0c;相信大家在学习完后一定会有所收获。 码字不易&#xff0c;希望大家多多支持我呀&#xff01;&#xff08;三连&#xff0b;关…

Spring Boot3整合Druid(监控功能)

目录 1.前置条件 2.导依赖 错误依赖&#xff1a; 正确依赖&#xff1a; 3.配置 1.前置条件 已经初始化好一个spring boot项目且版本为3X&#xff0c;项目可正常启动。 作者版本为3.2.2最新版 2.导依赖 错误依赖&#xff1a; 这个依赖对于spring boot 3的支持不够&#…

H5嵌入小程序适配方案

时间过去了两个多月&#xff0c;2024已经到来&#xff0c;又老了一岁。头发也掉了好多。在这两个月时间里都忙着写页面&#xff0c;感觉时间过去得很快。没有以前那么轻松了。也不是遇到了什么难点技术&#xff0c;而是接手了一个很烂得项目。能有多烂&#xff0c;一个页面发起…

开源无代码应用程序生成器Saltcorn

什么是 Saltcorn &#xff1f; Saltcorn 是一个无需编写任何代码即可构建数据库 Web 应用程序的平台。它配备了一个吸睛的仪表板&#xff0c;丰富的生态系统、视图生成器以及支持主题的界面&#xff0c;使用直观的点击、拖放用户界面来构建整个应用程序。 软件的特点&#xff1…