├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── auto.svg ├── draco-gltf │ ├── draco_decoder.js │ ├── draco_decoder.wasm │ ├── draco_encoder.js │ └── draco_wasm_wrapper.js └── gentilis_regular.typeface.json ├── src ├── App.css ├── App.tsx ├── assets │ ├── models │ │ ├── robot.glb │ │ ├── su7-draco.glb │ │ └── su7.glb │ ├── test.png │ └── textures │ │ ├── crate.gif │ │ └── halo.png ├── components │ ├── chart │ │ ├── index.css │ │ ├── index.tsx │ │ └── worker.js │ ├── image │ │ ├── index.css │ │ ├── index.tsx │ │ └── worker.js │ └── overlay │ │ ├── index.css │ │ └── index.tsx ├── helper │ ├── index.ts │ ├── promise.ts │ └── three │ │ └── OrbitControls.js ├── index.css ├── main.tsx ├── mock │ ├── freespace.ts │ └── line.ts ├── renderer │ ├── arrow.ts │ ├── crosswalk.ts │ ├── cube.ts │ ├── egoCar │ │ └── index.ts │ ├── freespace.ts │ ├── index.ts │ ├── line.ts │ ├── polygonCylinder.ts │ ├── roadMarker.ts │ ├── robot.ts │ ├── text.ts │ ├── trafficLight.ts │ └── trafficSign.ts ├── types │ ├── common.ts │ └── renderer.ts ├── views │ ├── autopilot │ │ ├── index.css │ │ └── index.tsx │ └── scene-editor │ │ ├── components │ │ ├── header │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── overlay │ │ │ ├── index.css │ │ │ └── index.tsx │ │ └── right-sider │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── renderer │ │ ├── base.ts │ │ └── index.ts │ │ └── store │ │ ├── index.ts │ │ └── type.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # autopilot 2 | 3 | 尝试做个智驾 web3d 应用,欢迎交流和学习 ~ 4 | 5 | > 基于 three^0.167.1 版本 6 | 7 | ## 本地启动服务 8 | 9 | 暂时只支持本地预览效果,线上地址等我有空弄一个 10 | 11 | 找到对应文章末尾对应的 tag,下载对应代码到本地预览。最新版的代码可能跟你阅读的文章对不上 12 | 13 | ```bash 14 | git clone https://github.com/GitHubJackson/autopilot.git 15 | cd autopilot 16 | pnpm install 17 | pnpm run dev 18 | ``` 19 | 20 | ## 跳转门 21 | 22 | - [掘金专栏](https://juejin.cn/column/7338674902280650779) 23 | - [我的博客](https://blog.zhouweibin.top/) 24 | - [前端环境搭建 mac 版](https://docs.zhouweibin.top/docs/awesome-dev/%E5%89%8D%E7%AB%AF%E7%8E%AF%E5%A2%83/) 25 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | autopilot 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autopilot", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@ant-design/icons": "5.x", 14 | "@tweenjs/tween.js": "^25.0.0", 15 | "@types/lodash": "^4.17.16", 16 | "antd": "^5.20.6", 17 | "fabric": "^6.6.1", 18 | "konva": "^9.3.20", 19 | "lodash": "^4.17.21", 20 | "mobx": "^6.13.7", 21 | "mobx-react-lite": "^4.1.0", 22 | "plotly.js-dist-min": "^3.0.1", 23 | "polyline-normals": "^2.0.2", 24 | "react": "^18.3.1", 25 | "react-dom": "^18.3.1", 26 | "react-router-dom": "^7.5.1", 27 | "stats.js": "^0.17.0", 28 | "three": "^0.167.1" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.9.0", 32 | "@types/node": "^22.5.0", 33 | "@types/react": "^18.3.3", 34 | "@types/react-dom": "^18.3.0", 35 | "@types/three": "^0.167.2", 36 | "@vitejs/plugin-react": "^4.3.1", 37 | "eslint": "^9.9.0", 38 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 39 | "eslint-plugin-react-refresh": "^0.4.9", 40 | "globals": "^15.9.0", 41 | "typescript": "^5.5.3", 42 | "typescript-eslint": "^8.0.1", 43 | "vite": "^5.4.1", 44 | "vite-plugin-commonjs": "^0.10.1", 45 | "vite-plugin-node-polyfills": "^0.22.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/auto.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/draco-gltf/draco_decoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/public/draco-gltf/draco_decoder.wasm -------------------------------------------------------------------------------- /public/draco-gltf/draco_wasm_wrapper.js: -------------------------------------------------------------------------------- 1 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE? 7 | $jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))}; 8 | $jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)}; 9 | var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q=y}},"es6","es3"); 19 | $jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(kp;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3"); 20 | $jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5"); 21 | $jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5"); 22 | var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e); 24 | e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)}); 25 | if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+ 26 | 12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+ 27 | 16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0=d?b++:2047>=d?b+=2:55296<=d&&57343>= 29 | d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"=== 30 | typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]= 31 | this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr= 32 | Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3=e[1]?!0:0!=e[0]||10< 33 | e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e, 34 | b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1>>=0;if(2147483648=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)} 38 | function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")|| 39 | P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0= 40 | function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0= 41 | a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0= 42 | a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0= 43 | function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0= 44 | a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0= 45 | function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1= 46 | function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb= 47 | a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0= 48 | a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb= 49 | a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null, 50 | arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0= 51 | a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0= 52 | a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0= 53 | a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib= 54 | a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0= 55 | function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1= 56 | a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1= 57 | function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0= 58 | a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0= 59 | function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0= 60 | a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0= 61 | function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2= 62 | a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null, 63 | arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0= 64 | function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2= 65 | a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)}, 66 | oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3= 67 | function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc= 68 | a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3= 69 | a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3= 70 | a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3= 71 | a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1= 72 | a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM= 73 | function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM= 74 | a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION= 75 | a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD= 76 | a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD= 77 | a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc= 78 | a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32= 79 | a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32= 80 | function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT= 81 | a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER= 82 | function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)}; 83 | a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0=r.size?(0>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;gb.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule); 117 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { BrowserRouter, Routes, Route, Link } from "react-router-dom"; 3 | import Autopilot from "./views/autopilot"; 4 | import SceneEditor from "./views/scene-editor"; 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | }> 11 | }> 12 | 13 | 14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/assets/models/robot.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/src/assets/models/robot.glb -------------------------------------------------------------------------------- /src/assets/models/su7-draco.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/src/assets/models/su7-draco.glb -------------------------------------------------------------------------------- /src/assets/models/su7.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/src/assets/models/su7.glb -------------------------------------------------------------------------------- /src/assets/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/src/assets/test.png -------------------------------------------------------------------------------- /src/assets/textures/crate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/src/assets/textures/crate.gif -------------------------------------------------------------------------------- /src/assets/textures/halo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GitHubJackson/autopilot/9fe4d8f58dee1ee98423587b66956545232639e0/src/assets/textures/halo.png -------------------------------------------------------------------------------- /src/components/chart/index.css: -------------------------------------------------------------------------------- 1 | .chart-container { 2 | position: absolute; 3 | top: 100px; 4 | left: 100px; 5 | z-index: 10; 6 | width: 400px; 7 | height: 240px; 8 | background: #ccc; 9 | border-radius: 4px; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/chart/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | // import MyWorker from "./worker.js?worker"; 3 | import Plotly from "plotly.js-dist-min"; 4 | import { 5 | ChartOptions, 6 | createChart, 7 | createOptionsChart, 8 | createYieldCurveChart, 9 | LineSeries, 10 | } from "lightweight-charts"; 11 | import "./index.css"; 12 | 13 | interface IProps { 14 | id: string; 15 | top: number; 16 | left: number; 17 | } 18 | 19 | // 容器初始化 20 | const layout = { 21 | title: "实时多维度数据监控", 22 | xaxis: { 23 | title: "时间戳", 24 | rangeslider: { visible: true }, // 启用时间滑块 25 | range: [Date.now() - 60000, Date.now()], // 初始显示最近60秒 26 | }, 27 | yaxis: { title: "数值范围", fixedrange: false }, 28 | showlegend: true, 29 | // plot_bgcolor: "#1f1f1f", // 暗色主题优化 30 | // paper_bgcolor: "#2d2d2d", 31 | }; 32 | 33 | // 数据轨迹定义(三条不同属性的折线) 34 | const traces = [ 35 | { 36 | name: "温度(℃)", 37 | y: [], 38 | x: [], 39 | line: { color: "#FF5722" }, 40 | range: [0, 50], // 温度波动范围 41 | // TODO 实测优化效果从 20ms>10ms,优化一半耗时 42 | // 数据量较小,还需要进一步验证 43 | // type: "scattergl", // 关键参数 44 | // mode: "lines", 45 | }, 46 | { 47 | name: "湿度(%)", 48 | y: [], 49 | x: [], 50 | line: { color: "#2196F3" }, 51 | range: [30, 80], 52 | // type: "scattergl", // 关键参数 53 | // mode: "lines", 54 | }, 55 | { 56 | name: "压力(kPa)", 57 | y: [], 58 | x: [], 59 | line: { color: "#4CAF50" }, 60 | range: [90, 110], 61 | // type: "scattergl", // 关键参数 62 | // mode: "lines", 63 | }, 64 | ]; 65 | 66 | // 智能数据生成器(避免突变) 67 | function generateValue(traceIndex) { 68 | const trace = traces[traceIndex]; 69 | const lastValue = 70 | trace.y.length > 0 71 | ? trace.y[trace.y.length - 1] 72 | : Math.random() * (trace.range[1] - trace.range[0]) + trace.range[0]; 73 | 74 | // 带约束的随机波动(最大±5%范围) 75 | const delta = (trace.range[1] - trace.range[0]) * 0.05; 76 | return Math.max( 77 | trace.range[0], 78 | Math.min(trace.range[1], lastValue + (Math.random() - 0.5) * delta * 2) 79 | ); 80 | } 81 | 82 | export const ChartBlock = (props: IProps) => { 83 | const canvasId = "chart-canvas-" + props.id; 84 | 85 | useEffect(() => { 86 | // 启用WebGL渲染(性能关键[1](@ref)) 87 | Plotly.newPlot(canvasId, traces, layout, { 88 | scrollZoom: true, 89 | // plotGlPixelRatio: 2, 90 | doubleBuffer: true, 91 | }); 92 | let isRendering = false; 93 | function updateChart() { 94 | if (isRendering) return; 95 | isRendering = true; 96 | const now = Date.now(); 97 | // 生成各轨迹新数据点 98 | const updateData = { 99 | x: [[now], [now], [now]], // 三维数组结构 100 | y: [[generateValue(0)], [generateValue(1)], [generateValue(2)]], 101 | }; 102 | // 执行增量更新(性能关键[1](@ref)) 103 | Plotly.extendTraces(canvasId, updateData, [0, 1, 2], 1000); // 保留1000个历史点 104 | // 动态调整时间轴范围 105 | if (traces[0].x.length % 10 === 0) { 106 | // 每10次更新调整一次 107 | Plotly.relayout(canvasId, { 108 | "xaxis.range": [now - 60000, now], 109 | }); 110 | } 111 | isRendering = false; 112 | } 113 | // const traceType = Plotly.plotlyjs.getPlotly()._fullData[0].type; 114 | const plot = document.getElementById(canvasId); 115 | setInterval(() => { 116 | console.log("实际渲染类型:", plot._fullData[0].type); 117 | console.time("===draw"); 118 | updateChart(); 119 | console.timeEnd("===draw"); 120 | }, 100); 121 | }, []); 122 | 123 | // useEffect(() => { 124 | // const canvasDom = document.getElementById(canvasId)!; 125 | // const chartOptions = { 126 | // layout: { 127 | // textColor: "black", 128 | // background: { type: "solid", color: "white" }, 129 | // }, 130 | // }; 131 | // const chart = createOptionsChart(canvasDom, chartOptions); 132 | // const lineSeries = chart.addSeries(LineSeries, { color: "#2962FF" }); 133 | // function mockData() { 134 | // const data = []; 135 | // for (let i = 0; i < 10000; i++) { 136 | // data.push({ 137 | // time: i * 0.25, 138 | // value: Math.sin(i / 100) + i / 500, 139 | // }); 140 | // } 141 | // return data; 142 | // } 143 | // lineSeries.setData(mockData()); 144 | // chart.timeScale().fitContent(); 145 | // setInterval(() => { 146 | // const data = mockData(); 147 | // console.time("==draw"); 148 | // lineSeries.setData(data); 149 | // console.timeEnd("==draw"); 150 | // }, 1000); 151 | // }, []); 152 | 153 | return ( 154 |
e.stopPropagation()} 158 | onMouseDown={(e) => e.stopPropagation()} 159 | onMouseMove={(e) => e.stopPropagation()} 160 | onClick={(e) => e.stopPropagation()} 161 | > 162 |
163 |
164 | ); 165 | }; 166 | -------------------------------------------------------------------------------- /src/components/chart/worker.js: -------------------------------------------------------------------------------- 1 | import { Chart } from "chart.js/auto"; 2 | 3 | let chart; 4 | let dataPoints = []; 5 | 6 | self.onmessage = (e) => { 7 | switch (e.data.type) { 8 | case "init": 9 | initChart(e.data); 10 | break; 11 | case "data": 12 | addDataPoint(e.data); 13 | break; 14 | case "resize": 15 | resizeChart(e.data); 16 | break; 17 | } 18 | }; 19 | 20 | function initChart({ canvas, width, height }) { 21 | chart = new Chart(canvas, { 22 | type: "line", 23 | data: { 24 | labels: [], 25 | datasets: [ 26 | { 27 | label: "实时数据", 28 | data: [], 29 | borderColor: "rgb(75, 192, 192)", 30 | tension: 0.1, 31 | }, 32 | ], 33 | }, 34 | // options: { 35 | // animation: false, // 关闭动画 36 | // responsive: false, // 关闭响应式 37 | // maintainAspectRatio: false, 38 | // scales: { 39 | // x: { 40 | // type: "time", // 时间轴 41 | // time: { 42 | // unit: "second", 43 | // }, 44 | // }, 45 | // }, 46 | // }, 47 | options: { 48 | responsive: true, 49 | plugins: { 50 | legend: { 51 | position: "top", 52 | }, 53 | title: { 54 | display: true, 55 | text: "Chart.js Line Chart", 56 | }, 57 | }, 58 | }, 59 | }); 60 | } 61 | 62 | function addDataPoint({ value, timestamp }) { 63 | // 保持数据长度(示例保留最近100点) 64 | if (dataPoints.length >= 100) { 65 | dataPoints.shift(); 66 | chart.data.labels.shift(); 67 | chart.data.datasets[0].data.shift(); 68 | } 69 | 70 | dataPoints.push({ x: timestamp, y: value }); 71 | chart.data.labels.push(new Date(timestamp).toISOString()); 72 | chart.data.datasets[0].data.push(value); 73 | 74 | // 增量更新(避免全量重绘) 75 | chart.update("none"); // 参数 'none' 表示跳过动画 76 | } 77 | 78 | function resizeChart({ width, height }) { 79 | chart.canvas.width = width; 80 | chart.canvas.height = height; 81 | chart.resize(width, height); 82 | } 83 | -------------------------------------------------------------------------------- /src/components/image/index.css: -------------------------------------------------------------------------------- 1 | .image-container { 2 | position: absolute; 3 | top: 100px; 4 | left: 100px; 5 | z-index: 10; 6 | width: 240px; 7 | height: 240px; 8 | background: #ccc; 9 | border-radius: 4px; 10 | } 11 | 12 | .image-container canvas { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/image/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import MyWorker from "./worker.js?worker"; 3 | import TestImg from "@/assets/test.png"; 4 | import "./index.css"; 5 | 6 | interface IProps { 7 | id: string; 8 | top: number; 9 | left: number; 10 | } 11 | 12 | export const ImageBlock = (props: IProps) => { 13 | const canvasId = "image-canvas-" + props.id; 14 | 15 | useEffect(() => { 16 | const mainCanvas = document.getElementById(canvasId) as HTMLCanvasElement; 17 | const offscreenCanvas = mainCanvas.transferControlToOffscreen(); 18 | // worker.postMessage("start"); 19 | const worker = new MyWorker(); 20 | worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas]); 21 | const img = new Image(); 22 | img.src = TestImg; 23 | img.onload = () => { 24 | const canvas = document.createElement("canvas"); 25 | canvas.width = img.width; 26 | canvas.height = img.height; 27 | const ctx = canvas.getContext("2d")!; 28 | ctx.drawImage(img, 0, 0); 29 | canvas.toBlob( 30 | (blob) => { 31 | worker.postMessage({ blob }); 32 | }, 33 | "image/jpeg", 34 | 0.86 35 | ); 36 | }; 37 | }, []); 38 | 39 | return ( 40 |
44 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/image/worker.js: -------------------------------------------------------------------------------- 1 | let workerCanvas; 2 | let ctx; 3 | let imgBlob; 4 | let cacheCanvas; 5 | 6 | onmessage = async (e) => { 7 | console.log("Message received from main script"); 8 | 9 | if (e.data.blob) { 10 | const blob = new Blob([e.data.blob], { type: "image/jpeg" }); 11 | imgBlob = blob; 12 | } 13 | // const workerResult = "Result: " + e.data[0] * e.data[1]; 14 | // console.log("Posting message back to main script"); 15 | // postMessage(workerResult); 16 | if (e.data.canvas) { 17 | // 初始化OffscreenCanvas上下文 18 | workerCanvas = e.data.canvas; 19 | ctx = workerCanvas.getContext("2d"); 20 | cacheCanvas = new OffscreenCanvas(workerCanvas.width, workerCanvas.height); 21 | 22 | // setInterval(() => { 23 | setTimeout(() => { 24 | generateElements(); 25 | setTimeout(() => { 26 | generateElements(); 27 | }, 1000); 28 | }, 2000); 29 | } 30 | }; 31 | 32 | function randomColor() { 33 | return `rgba(${Math.floor(Math.random() * 256)}, 34 | ${Math.floor(Math.random() * 256)}, 35 | ${Math.floor(Math.random() * 256)}, 36 | ${Math.random().toFixed(2)})`; 37 | } 38 | 39 | function createRandomRect() { 40 | return { 41 | x: Math.random() * workerCanvas.width * 0.9, 42 | y: Math.random() * workerCanvas.height * 0.9, 43 | width: 50 + Math.random() * 150, 44 | height: 30 + Math.random() * 120, 45 | color: randomColor(), 46 | isFill: Math.random() > 0.5, // 随机实心/空心 47 | }; 48 | } 49 | 50 | function createRandomText() { 51 | const chars = 52 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 53 | return { 54 | text: Array(5) 55 | .fill() 56 | .map(() => chars[Math.floor(Math.random() * chars.length)]) 57 | .join(""), 58 | x: Math.random() * workerCanvas.width * 0.9, 59 | y: Math.random() * workerCanvas.height * 0.9, 60 | size: 12 + Math.random() * 36, 61 | color: randomColor(), 62 | angle: -30 + Math.random() * 60, // 随机旋转角度 63 | }; 64 | } 65 | 66 | async function generateElements() { 67 | ctx.clearRect(0, 0, workerCanvas.width, workerCanvas.height); 68 | let cacheCtx; 69 | 70 | if (imgBlob) { 71 | // 先填充缓冲区 72 | if (!cacheCanvas) { 73 | cacheCtx = cacheCanvas.getContext("2d"); 74 | const imgBitmap = await createImageBitmap(imgBlob); 75 | cacheCtx.drawImage( 76 | imgBitmap, 77 | 0, 78 | 0, 79 | workerCanvas.width, 80 | workerCanvas.height 81 | ); 82 | } else { 83 | // 交换缓冲区 84 | // const imgBitmap = await createImageBitmap(imgBlob); // 零拷贝解码[1](@ref) 85 | // 创建离屏Canvas 86 | // const canvas = new OffscreenCanvas(imgBitmap.width, imgBitmap.height); 87 | // const ctx = canvas.getContext('2d'); 88 | ctx.drawImage(cacheCanvas, 0, 0, workerCanvas.width, workerCanvas.height); 89 | cacheCtx = cacheCanvas.getContext("2d"); 90 | const imgBitmapForCache = await createImageBitmap(imgBlob); 91 | cacheCtx.drawImage( 92 | imgBitmapForCache, 93 | 0, 94 | 0, 95 | workerCanvas.width, 96 | workerCanvas.height 97 | ); 98 | imgBitmapForCache.close(); 99 | } 100 | } 101 | 102 | for (let i = 0; i < 20; i++) { 103 | const rect = createRandomRect(); 104 | cacheCtx.save(); 105 | if (rect.isFill) { 106 | cacheCtx.fillStyle = rect.color; 107 | cacheCtx.fillRect(rect.x, rect.y, rect.width, rect.height); 108 | } else { 109 | cacheCtx.strokeStyle = rect.color; 110 | cacheCtx.lineWidth = 2; 111 | cacheCtx.strokeRect(rect.x, rect.y, rect.width, rect.height); 112 | } 113 | cacheCtx.restore(); 114 | } 115 | 116 | for (let i = 0; i < 10; i++) { 117 | const text = createRandomText(); 118 | cacheCtx.save(); 119 | cacheCtx.translate(text.x, text.y); 120 | cacheCtx.rotate((text.angle * Math.PI) / 180); 121 | cacheCtx.font = `${text.size}px Arial`; 122 | cacheCtx.fillStyle = text.color; 123 | cacheCtx.fillText(text.text, 0, 0); 124 | cacheCtx.restore(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/components/overlay/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | pointer-events: none; 8 | } 9 | 10 | .view-container { 11 | pointer-events: all; 12 | position: absolute; 13 | right: 0; 14 | background-color: #666; 15 | color: #fff; 16 | top: 30px; 17 | padding: 16px; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/overlay/index.tsx: -------------------------------------------------------------------------------- 1 | import { Radio, RadioChangeEvent } from "antd"; 2 | import { useState } from "react"; 3 | import "./index.css"; 4 | import { myRenderer } from "../../renderer"; 5 | // import { ImageBlock } from "../image"; 6 | // import { ChartBlock } from "../chart"; 7 | 8 | enum EViewType { 9 | FollowCar, 10 | Overlook, 11 | OverlookVertical, 12 | } 13 | 14 | export function Overlay() { 15 | const [view, setView] = useState(EViewType.FollowCar); 16 | 17 | function changeView(e: RadioChangeEvent) { 18 | setView(e.target.value); 19 | myRenderer.switchCameraView(e.target.value); 20 | } 21 | 22 | // const images = new Array(7).fill(undefined) 23 | 24 | return ( 25 |
26 |
27 | 28 | 跟车 29 | 俯视横向 30 | 俯视纵向 31 | 32 |
33 | {/* 34 | 35 | 36 | 37 | 38 | */} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/helper/index.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; 3 | import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; 4 | 5 | export function loadGLTFWithPromise(url: string): Promise { 6 | return new Promise((resolve, reject) => { 7 | const loader = new GLTFLoader(); 8 | loader.load( 9 | url, 10 | function (gltf) { 11 | resolve(gltf); 12 | }, 13 | // 加载进度回调 14 | undefined, 15 | function (error) { 16 | reject(error); 17 | } 18 | ); 19 | }); 20 | } 21 | 22 | export function loadDracoGLTFWithPromise(url: string): Promise { 23 | return new Promise((resolve, reject) => { 24 | const loader = new GLTFLoader(); 25 | const dracoLoader = new DRACOLoader(); 26 | // 设置解压相关文件的路径 27 | dracoLoader.setDecoderPath( 28 | // 将 three/examples/jsm/libs/draco/gltf/ 拷贝到 public 目录来用 29 | "./draco-gltf/" 30 | // 或者也可以直接用这个 31 | // "https://threejs.org/examples/jsm/libs/draco/gltf/" 32 | ); 33 | // 使用js方式解压 34 | // dracoLoader.setDecoderConfig({ type: "js" }); 35 | // 初始化 initDecoder 解码器 36 | dracoLoader.preload(); 37 | // 设置GLTFLoader使用的压缩器 38 | loader.setDRACOLoader(dracoLoader); 39 | loader.load( 40 | url, 41 | function (gltf) { 42 | resolve(gltf); 43 | }, 44 | // 加载进度回调 45 | undefined, 46 | function (error) { 47 | reject(error); 48 | } 49 | ); 50 | }); 51 | } 52 | 53 | export function loadTexture(url: string): Promise { 54 | return new Promise((resolve, reject) => { 55 | const loader = new THREE.TextureLoader(); 56 | loader.load( 57 | url, 58 | (texture) => { 59 | resolve(texture); 60 | }, 61 | undefined, 62 | (error) => { 63 | reject(error); 64 | } 65 | ); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /src/helper/promise.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | export function abortWrapper(p1: Promise): Promise { 4 | let abort; 5 | const p2 = new Promise((resolve, reject) => (abort = reject)); 6 | const p = Promise.race([p1, p2]); 7 | // @ts-ignore 8 | p.abort = abort; 9 | return p; 10 | } 11 | -------------------------------------------------------------------------------- /src/helper/three/OrbitControls.js: -------------------------------------------------------------------------------- 1 | import { 2 | EventDispatcher, 3 | MOUSE, 4 | Quaternion, 5 | Spherical, 6 | TOUCH, 7 | Vector2, 8 | Vector3, 9 | Plane, 10 | Ray, 11 | MathUtils 12 | } from 'three'; 13 | 14 | // OrbitControls performs orbiting, dollying (zooming), and panning. 15 | // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). 16 | // 17 | // Orbit - left mouse / touch: one-finger move 18 | // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish 19 | // Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move 20 | 21 | const _changeEvent = { type: 'change' }; 22 | const _startEvent = { type: 'start' }; 23 | const _endEvent = { type: 'end' }; 24 | const _ray = new Ray(); 25 | const _plane = new Plane(); 26 | const TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD ); 27 | 28 | class OrbitControls extends EventDispatcher { 29 | 30 | constructor( object, domElement ) { 31 | 32 | super(); 33 | 34 | this.object = object; 35 | this.domElement = domElement; 36 | this.domElement.style.touchAction = 'none'; // disable touch scroll 37 | 38 | // Set to false to disable this control 39 | this.enabled = true; 40 | 41 | // "target" sets the location of focus, where the object orbits around 42 | this.target = new Vector3(); 43 | 44 | // Sets the 3D cursor (similar to Blender), from which the maxTargetRadius takes effect 45 | this.cursor = new Vector3(); 46 | 47 | // How far you can dolly in and out ( PerspectiveCamera only ) 48 | this.minDistance = 0; 49 | this.maxDistance = Infinity; 50 | 51 | // How far you can zoom in and out ( OrthographicCamera only ) 52 | this.minZoom = 0; 53 | this.maxZoom = Infinity; 54 | 55 | // Limit camera target within a spherical area around the cursor 56 | this.minTargetRadius = 0; 57 | this.maxTargetRadius = Infinity; 58 | 59 | // How far you can orbit vertically, upper and lower limits. 60 | // Range is 0 to Math.PI radians. 61 | this.minPolarAngle = 0; // radians 62 | this.maxPolarAngle = Math.PI; // radians 63 | 64 | // How far you can orbit horizontally, upper and lower limits. 65 | // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) 66 | this.minAzimuthAngle = - Infinity; // radians 67 | this.maxAzimuthAngle = Infinity; // radians 68 | 69 | // Set to true to enable damping (inertia) 70 | // If damping is enabled, you must call controls.update() in your animation loop 71 | this.enableDamping = false; 72 | this.dampingFactor = 0.05; 73 | 74 | // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. 75 | // Set to false to disable zooming 76 | this.enableZoom = true; 77 | this.zoomSpeed = 1.0; 78 | 79 | // Set to false to disable rotating 80 | this.enableRotate = true; 81 | this.rotateSpeed = 1.0; 82 | 83 | // Set to false to disable panning 84 | this.enablePan = true; 85 | this.panSpeed = 1.0; 86 | this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up 87 | this.keyPanSpeed = 7.0; // pixels moved per arrow key push 88 | this.zoomToCursor = false; 89 | 90 | // Set to true to automatically rotate around the target 91 | // If auto-rotate is enabled, you must call controls.update() in your animation loop 92 | this.autoRotate = false; 93 | this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60 94 | 95 | // The four arrow keys 96 | this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' }; 97 | 98 | // Mouse buttons 99 | this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }; 100 | 101 | // Touch fingers 102 | this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }; 103 | 104 | // for reset 105 | this.target0 = this.target.clone(); 106 | this.position0 = this.object.position.clone(); 107 | this.zoom0 = this.object.zoom; 108 | 109 | // the target DOM element for key events 110 | this._domElementKeyEvents = null; 111 | 112 | // 113 | // public methods 114 | // 115 | 116 | this.getPolarAngle = function () { 117 | 118 | return spherical.phi; 119 | 120 | }; 121 | 122 | this.getAzimuthalAngle = function () { 123 | 124 | return spherical.theta; 125 | 126 | }; 127 | 128 | this.getDistance = function () { 129 | 130 | return this.object.position.distanceTo( this.target ); 131 | 132 | }; 133 | 134 | this.listenToKeyEvents = function ( domElement ) { 135 | 136 | domElement.addEventListener( 'keydown', onKeyDown ); 137 | this._domElementKeyEvents = domElement; 138 | 139 | }; 140 | 141 | this.stopListenToKeyEvents = function () { 142 | 143 | this._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown ); 144 | this._domElementKeyEvents = null; 145 | 146 | }; 147 | 148 | this.saveState = function () { 149 | 150 | scope.target0.copy( scope.target ); 151 | scope.position0.copy( scope.object.position ); 152 | scope.zoom0 = scope.object.zoom; 153 | 154 | }; 155 | 156 | this.reset = function () { 157 | 158 | scope.target.copy( scope.target0 ); 159 | scope.object.position.copy( scope.position0 ); 160 | scope.object.zoom = scope.zoom0; 161 | 162 | scope.object.updateProjectionMatrix(); 163 | scope.dispatchEvent( _changeEvent ); 164 | 165 | scope.update(); 166 | 167 | state = STATE.NONE; 168 | 169 | }; 170 | 171 | // this method is exposed, but perhaps it would be better if we can make it private... 172 | this.update = function () { 173 | 174 | const offset = new Vector3(); 175 | 176 | // so camera.up is the orbit axis 177 | const quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) ); 178 | const quatInverse = quat.clone().invert(); 179 | 180 | const lastPosition = new Vector3(); 181 | const lastQuaternion = new Quaternion(); 182 | const lastTargetPosition = new Vector3(); 183 | 184 | const twoPI = 2 * Math.PI; 185 | 186 | return function update( deltaTime = null ) { 187 | 188 | const position = scope.object.position; 189 | 190 | offset.copy( position ).sub( scope.target ); 191 | 192 | // rotate offset to "y-axis-is-up" space 193 | offset.applyQuaternion( quat ); 194 | 195 | // angle from z-axis around y-axis 196 | spherical.setFromVector3( offset ); 197 | 198 | if ( scope.autoRotate && state === STATE.NONE ) { 199 | 200 | rotateLeft( getAutoRotationAngle( deltaTime ) ); 201 | 202 | } 203 | 204 | if ( scope.enableDamping ) { 205 | 206 | spherical.theta += sphericalDelta.theta * scope.dampingFactor; 207 | spherical.phi += sphericalDelta.phi * scope.dampingFactor; 208 | 209 | } else { 210 | 211 | spherical.theta += sphericalDelta.theta; 212 | spherical.phi += sphericalDelta.phi; 213 | 214 | } 215 | 216 | // restrict theta to be between desired limits 217 | 218 | let min = scope.minAzimuthAngle; 219 | let max = scope.maxAzimuthAngle; 220 | 221 | if ( isFinite( min ) && isFinite( max ) ) { 222 | 223 | if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI; 224 | 225 | if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI; 226 | 227 | if ( min <= max ) { 228 | 229 | spherical.theta = Math.max( min, Math.min( max, spherical.theta ) ); 230 | 231 | } else { 232 | 233 | spherical.theta = ( spherical.theta > ( min + max ) / 2 ) ? 234 | Math.max( min, spherical.theta ) : 235 | Math.min( max, spherical.theta ); 236 | 237 | } 238 | 239 | } 240 | 241 | // restrict phi to be between desired limits 242 | spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) ); 243 | 244 | spherical.makeSafe(); 245 | 246 | 247 | // move target to panned location 248 | 249 | if ( scope.enableDamping === true ) { 250 | 251 | scope.target.addScaledVector( panOffset, scope.dampingFactor ); 252 | 253 | } else { 254 | 255 | scope.target.add( panOffset ); 256 | 257 | } 258 | 259 | // Limit the target distance from the cursor to create a sphere around the center of interest 260 | scope.target.sub( scope.cursor ); 261 | scope.target.clampLength( scope.minTargetRadius, scope.maxTargetRadius ); 262 | scope.target.add( scope.cursor ); 263 | 264 | let zoomChanged = false; 265 | // adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera 266 | // we adjust zoom later in these cases 267 | if ( scope.zoomToCursor && performCursorZoom || scope.object.isOrthographicCamera ) { 268 | 269 | spherical.radius = clampDistance( spherical.radius ); 270 | 271 | } else { 272 | 273 | const prevRadius = spherical.radius; 274 | spherical.radius = clampDistance( spherical.radius * scale ); 275 | zoomChanged = prevRadius != spherical.radius; 276 | 277 | } 278 | 279 | offset.setFromSpherical( spherical ); 280 | 281 | // rotate offset back to "camera-up-vector-is-up" space 282 | offset.applyQuaternion( quatInverse ); 283 | 284 | position.copy( scope.target ).add( offset ); 285 | 286 | scope.object.lookAt( scope.target ); 287 | 288 | if ( scope.enableDamping === true ) { 289 | 290 | sphericalDelta.theta *= ( 1 - scope.dampingFactor ); 291 | sphericalDelta.phi *= ( 1 - scope.dampingFactor ); 292 | 293 | panOffset.multiplyScalar( 1 - scope.dampingFactor ); 294 | 295 | } else { 296 | 297 | sphericalDelta.set( 0, 0, 0 ); 298 | 299 | panOffset.set( 0, 0, 0 ); 300 | 301 | } 302 | 303 | // adjust camera position 304 | if ( scope.zoomToCursor && performCursorZoom ) { 305 | 306 | let newRadius = null; 307 | if ( scope.object.isPerspectiveCamera ) { 308 | 309 | // move the camera down the pointer ray 310 | // this method avoids floating point error 311 | const prevRadius = offset.length(); 312 | newRadius = clampDistance( prevRadius * scale ); 313 | 314 | const radiusDelta = prevRadius - newRadius; 315 | scope.object.position.addScaledVector( dollyDirection, radiusDelta ); 316 | scope.object.updateMatrixWorld(); 317 | 318 | zoomChanged = !! radiusDelta; 319 | 320 | } else if ( scope.object.isOrthographicCamera ) { 321 | 322 | // adjust the ortho camera position based on zoom changes 323 | const mouseBefore = new Vector3( mouse.x, mouse.y, 0 ); 324 | mouseBefore.unproject( scope.object ); 325 | 326 | const prevZoom = scope.object.zoom; 327 | scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) ); 328 | scope.object.updateProjectionMatrix(); 329 | 330 | zoomChanged = prevZoom !== scope.object.zoom; 331 | 332 | const mouseAfter = new Vector3( mouse.x, mouse.y, 0 ); 333 | mouseAfter.unproject( scope.object ); 334 | 335 | scope.object.position.sub( mouseAfter ).add( mouseBefore ); 336 | scope.object.updateMatrixWorld(); 337 | 338 | newRadius = offset.length(); 339 | 340 | } else { 341 | 342 | console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' ); 343 | scope.zoomToCursor = false; 344 | 345 | } 346 | 347 | // handle the placement of the target 348 | if ( newRadius !== null ) { 349 | 350 | if ( this.screenSpacePanning ) { 351 | 352 | // position the orbit target in front of the new camera position 353 | scope.target.set( 0, 0, - 1 ) 354 | .transformDirection( scope.object.matrix ) 355 | .multiplyScalar( newRadius ) 356 | .add( scope.object.position ); 357 | 358 | } else { 359 | 360 | // get the ray and translation plane to compute target 361 | _ray.origin.copy( scope.object.position ); 362 | _ray.direction.set( 0, 0, - 1 ).transformDirection( scope.object.matrix ); 363 | 364 | // if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid 365 | // extremely large values 366 | if ( Math.abs( scope.object.up.dot( _ray.direction ) ) < TILT_LIMIT ) { 367 | 368 | object.lookAt( scope.target ); 369 | 370 | } else { 371 | 372 | _plane.setFromNormalAndCoplanarPoint( scope.object.up, scope.target ); 373 | _ray.intersectPlane( _plane, scope.target ); 374 | 375 | } 376 | 377 | } 378 | 379 | } 380 | 381 | } else if ( scope.object.isOrthographicCamera ) { 382 | 383 | const prevZoom = scope.object.zoom; 384 | scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) ); 385 | 386 | if ( prevZoom !== scope.object.zoom ) { 387 | 388 | scope.object.updateProjectionMatrix(); 389 | zoomChanged = true; 390 | 391 | } 392 | 393 | } 394 | 395 | scale = 1; 396 | performCursorZoom = false; 397 | 398 | // update condition is: 399 | // min(camera displacement, camera rotation in radians)^2 > EPS 400 | // using small-angle approximation cos(x/2) = 1 - x^2 / 8 401 | 402 | if ( zoomChanged || 403 | lastPosition.distanceToSquared( scope.object.position ) > EPS || 404 | 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS || 405 | lastTargetPosition.distanceToSquared( scope.target ) > EPS ) { 406 | 407 | scope.dispatchEvent( _changeEvent ); 408 | 409 | lastPosition.copy( scope.object.position ); 410 | lastQuaternion.copy( scope.object.quaternion ); 411 | lastTargetPosition.copy( scope.target ); 412 | 413 | return true; 414 | 415 | } 416 | 417 | return false; 418 | 419 | }; 420 | 421 | }(); 422 | 423 | this.rotate = function (degrees) { 424 | rotateLeft(degrees); 425 | this.update(); 426 | } 427 | 428 | this.dispose = function () { 429 | 430 | scope.domElement.removeEventListener( 'contextmenu', onContextMenu ); 431 | 432 | scope.domElement.removeEventListener( 'pointerdown', onPointerDown ); 433 | scope.domElement.removeEventListener( 'pointercancel', onPointerUp ); 434 | scope.domElement.removeEventListener( 'wheel', onMouseWheel ); 435 | 436 | scope.domElement.removeEventListener( 'pointermove', onPointerMove ); 437 | scope.domElement.removeEventListener( 'pointerup', onPointerUp ); 438 | 439 | const document = scope.domElement.getRootNode(); // offscreen canvas compatibility 440 | 441 | document.removeEventListener( 'keydown', interceptControlDown, { capture: true } ); 442 | 443 | if ( scope._domElementKeyEvents !== null ) { 444 | 445 | scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown ); 446 | scope._domElementKeyEvents = null; 447 | 448 | } 449 | 450 | //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? 451 | 452 | }; 453 | 454 | // 455 | // internals 456 | // 457 | 458 | const scope = this; 459 | 460 | const STATE = { 461 | NONE: - 1, 462 | ROTATE: 0, 463 | DOLLY: 1, 464 | PAN: 2, 465 | TOUCH_ROTATE: 3, 466 | TOUCH_PAN: 4, 467 | TOUCH_DOLLY_PAN: 5, 468 | TOUCH_DOLLY_ROTATE: 6 469 | }; 470 | 471 | let state = STATE.NONE; 472 | 473 | const EPS = 0.000001; 474 | 475 | // current position in spherical coordinates 476 | const spherical = new Spherical(); 477 | const sphericalDelta = new Spherical(); 478 | 479 | let scale = 1; 480 | const panOffset = new Vector3(); 481 | 482 | const rotateStart = new Vector2(); 483 | const rotateEnd = new Vector2(); 484 | const rotateDelta = new Vector2(); 485 | 486 | const panStart = new Vector2(); 487 | const panEnd = new Vector2(); 488 | const panDelta = new Vector2(); 489 | 490 | const dollyStart = new Vector2(); 491 | const dollyEnd = new Vector2(); 492 | const dollyDelta = new Vector2(); 493 | 494 | const dollyDirection = new Vector3(); 495 | const mouse = new Vector2(); 496 | let performCursorZoom = false; 497 | 498 | const pointers = []; 499 | const pointerPositions = {}; 500 | 501 | let controlActive = false; 502 | 503 | function getAutoRotationAngle( deltaTime ) { 504 | 505 | if ( deltaTime !== null ) { 506 | 507 | return ( 2 * Math.PI / 60 * scope.autoRotateSpeed ) * deltaTime; 508 | 509 | } else { 510 | 511 | return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; 512 | 513 | } 514 | 515 | } 516 | 517 | function getZoomScale( delta ) { 518 | 519 | const normalizedDelta = Math.abs( delta * 0.01 ); 520 | return Math.pow( 0.95, scope.zoomSpeed * normalizedDelta ); 521 | 522 | } 523 | 524 | function rotateLeft( angle ) { 525 | 526 | sphericalDelta.theta -= angle; 527 | 528 | } 529 | 530 | function rotateUp( angle ) { 531 | 532 | sphericalDelta.phi -= angle; 533 | 534 | } 535 | 536 | const panLeft = function () { 537 | 538 | const v = new Vector3(); 539 | 540 | return function panLeft( distance, objectMatrix ) { 541 | 542 | v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix 543 | v.multiplyScalar( - distance ); 544 | 545 | panOffset.add( v ); 546 | 547 | }; 548 | 549 | }(); 550 | 551 | const panUp = function () { 552 | 553 | const v = new Vector3(); 554 | 555 | return function panUp( distance, objectMatrix ) { 556 | 557 | if ( scope.screenSpacePanning === true ) { 558 | 559 | v.setFromMatrixColumn( objectMatrix, 1 ); 560 | 561 | } else { 562 | 563 | v.setFromMatrixColumn( objectMatrix, 0 ); 564 | v.crossVectors( scope.object.up, v ); 565 | 566 | } 567 | 568 | v.multiplyScalar( distance ); 569 | 570 | panOffset.add( v ); 571 | 572 | }; 573 | 574 | }(); 575 | 576 | // deltaX and deltaY are in pixels; right and down are positive 577 | const pan = function () { 578 | 579 | const offset = new Vector3(); 580 | 581 | return function pan( deltaX, deltaY ) { 582 | 583 | const element = scope.domElement; 584 | 585 | if ( scope.object.isPerspectiveCamera ) { 586 | 587 | // perspective 588 | const position = scope.object.position; 589 | offset.copy( position ).sub( scope.target ); 590 | let targetDistance = offset.length(); 591 | 592 | // half of the fov is center to top of screen 593 | targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 ); 594 | 595 | // we use only clientHeight here so aspect ratio does not distort speed 596 | panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix ); 597 | panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix ); 598 | 599 | } else if ( scope.object.isOrthographicCamera ) { 600 | 601 | // orthographic 602 | panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix ); 603 | panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix ); 604 | 605 | } else { 606 | 607 | // camera neither orthographic nor perspective 608 | console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' ); 609 | scope.enablePan = false; 610 | 611 | } 612 | 613 | }; 614 | 615 | }(); 616 | 617 | function dollyOut( dollyScale ) { 618 | 619 | if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) { 620 | 621 | scale /= dollyScale; 622 | 623 | } else { 624 | 625 | console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); 626 | scope.enableZoom = false; 627 | 628 | } 629 | 630 | } 631 | 632 | function dollyIn( dollyScale ) { 633 | 634 | if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) { 635 | 636 | scale *= dollyScale; 637 | 638 | } else { 639 | 640 | console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); 641 | scope.enableZoom = false; 642 | 643 | } 644 | 645 | } 646 | 647 | function updateZoomParameters( x, y ) { 648 | 649 | if ( ! scope.zoomToCursor ) { 650 | 651 | return; 652 | 653 | } 654 | 655 | performCursorZoom = true; 656 | 657 | const rect = scope.domElement.getBoundingClientRect(); 658 | const dx = x - rect.left; 659 | const dy = y - rect.top; 660 | const w = rect.width; 661 | const h = rect.height; 662 | 663 | mouse.x = ( dx / w ) * 2 - 1; 664 | mouse.y = - ( dy / h ) * 2 + 1; 665 | 666 | dollyDirection.set( mouse.x, mouse.y, 1 ).unproject( scope.object ).sub( scope.object.position ).normalize(); 667 | 668 | } 669 | 670 | function clampDistance( dist ) { 671 | 672 | return Math.max( scope.minDistance, Math.min( scope.maxDistance, dist ) ); 673 | 674 | } 675 | 676 | // 677 | // event callbacks - update the object state 678 | // 679 | 680 | function handleMouseDownRotate( event ) { 681 | 682 | rotateStart.set( event.clientX, event.clientY ); 683 | 684 | } 685 | 686 | function handleMouseDownDolly( event ) { 687 | 688 | updateZoomParameters( event.clientX, event.clientX ); 689 | dollyStart.set( event.clientX, event.clientY ); 690 | 691 | } 692 | 693 | function handleMouseDownPan( event ) { 694 | 695 | panStart.set( event.clientX, event.clientY ); 696 | 697 | } 698 | 699 | function handleMouseMoveRotate( event ) { 700 | 701 | rotateEnd.set( event.clientX, event.clientY ); 702 | 703 | rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); 704 | 705 | const element = scope.domElement; 706 | 707 | rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height 708 | 709 | rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); 710 | 711 | rotateStart.copy( rotateEnd ); 712 | 713 | scope.update(); 714 | 715 | } 716 | 717 | function handleMouseMoveDolly( event ) { 718 | 719 | dollyEnd.set( event.clientX, event.clientY ); 720 | 721 | dollyDelta.subVectors( dollyEnd, dollyStart ); 722 | 723 | if ( dollyDelta.y > 0 ) { 724 | 725 | dollyOut( getZoomScale( dollyDelta.y ) ); 726 | 727 | } else if ( dollyDelta.y < 0 ) { 728 | 729 | dollyIn( getZoomScale( dollyDelta.y ) ); 730 | 731 | } 732 | 733 | dollyStart.copy( dollyEnd ); 734 | 735 | scope.update(); 736 | 737 | } 738 | 739 | function handleMouseMovePan( event ) { 740 | 741 | panEnd.set( event.clientX, event.clientY ); 742 | 743 | panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); 744 | 745 | pan( panDelta.x, panDelta.y ); 746 | 747 | panStart.copy( panEnd ); 748 | 749 | scope.update(); 750 | 751 | } 752 | 753 | function handleMouseWheel( event ) { 754 | 755 | updateZoomParameters( event.clientX, event.clientY ); 756 | 757 | if ( event.deltaY < 0 ) { 758 | 759 | dollyIn( getZoomScale( event.deltaY ) ); 760 | 761 | } else if ( event.deltaY > 0 ) { 762 | 763 | dollyOut( getZoomScale( event.deltaY ) ); 764 | 765 | } 766 | 767 | scope.update(); 768 | 769 | } 770 | 771 | function handleKeyDown( event ) { 772 | 773 | let needsUpdate = false; 774 | 775 | switch ( event.code ) { 776 | 777 | case scope.keys.UP: 778 | 779 | if ( event.ctrlKey || event.metaKey || event.shiftKey ) { 780 | 781 | rotateUp( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); 782 | 783 | } else { 784 | 785 | pan( 0, scope.keyPanSpeed ); 786 | 787 | } 788 | 789 | needsUpdate = true; 790 | break; 791 | 792 | case scope.keys.BOTTOM: 793 | 794 | if ( event.ctrlKey || event.metaKey || event.shiftKey ) { 795 | 796 | rotateUp( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); 797 | 798 | } else { 799 | 800 | pan( 0, - scope.keyPanSpeed ); 801 | 802 | } 803 | 804 | needsUpdate = true; 805 | break; 806 | 807 | case scope.keys.LEFT: 808 | 809 | if ( event.ctrlKey || event.metaKey || event.shiftKey ) { 810 | 811 | rotateLeft( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); 812 | 813 | } else { 814 | 815 | pan( scope.keyPanSpeed, 0 ); 816 | 817 | } 818 | 819 | needsUpdate = true; 820 | break; 821 | 822 | case scope.keys.RIGHT: 823 | 824 | if ( event.ctrlKey || event.metaKey || event.shiftKey ) { 825 | 826 | rotateLeft( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight ); 827 | 828 | } else { 829 | 830 | pan( - scope.keyPanSpeed, 0 ); 831 | 832 | } 833 | 834 | needsUpdate = true; 835 | break; 836 | 837 | } 838 | 839 | if ( needsUpdate ) { 840 | 841 | // prevent the browser from scrolling on cursor keys 842 | event.preventDefault(); 843 | 844 | scope.update(); 845 | 846 | } 847 | 848 | 849 | } 850 | 851 | function handleTouchStartRotate( event ) { 852 | 853 | if ( pointers.length === 1 ) { 854 | 855 | rotateStart.set( event.pageX, event.pageY ); 856 | 857 | } else { 858 | 859 | const position = getSecondPointerPosition( event ); 860 | 861 | const x = 0.5 * ( event.pageX + position.x ); 862 | const y = 0.5 * ( event.pageY + position.y ); 863 | 864 | rotateStart.set( x, y ); 865 | 866 | } 867 | 868 | } 869 | 870 | function handleTouchStartPan( event ) { 871 | 872 | if ( pointers.length === 1 ) { 873 | 874 | panStart.set( event.pageX, event.pageY ); 875 | 876 | } else { 877 | 878 | const position = getSecondPointerPosition( event ); 879 | 880 | const x = 0.5 * ( event.pageX + position.x ); 881 | const y = 0.5 * ( event.pageY + position.y ); 882 | 883 | panStart.set( x, y ); 884 | 885 | } 886 | 887 | } 888 | 889 | function handleTouchStartDolly( event ) { 890 | 891 | const position = getSecondPointerPosition( event ); 892 | 893 | const dx = event.pageX - position.x; 894 | const dy = event.pageY - position.y; 895 | 896 | const distance = Math.sqrt( dx * dx + dy * dy ); 897 | 898 | dollyStart.set( 0, distance ); 899 | 900 | } 901 | 902 | function handleTouchStartDollyPan( event ) { 903 | 904 | if ( scope.enableZoom ) handleTouchStartDolly( event ); 905 | 906 | if ( scope.enablePan ) handleTouchStartPan( event ); 907 | 908 | } 909 | 910 | function handleTouchStartDollyRotate( event ) { 911 | 912 | if ( scope.enableZoom ) handleTouchStartDolly( event ); 913 | 914 | if ( scope.enableRotate ) handleTouchStartRotate( event ); 915 | 916 | } 917 | 918 | function handleTouchMoveRotate( event ) { 919 | 920 | if ( pointers.length == 1 ) { 921 | 922 | rotateEnd.set( event.pageX, event.pageY ); 923 | 924 | } else { 925 | 926 | const position = getSecondPointerPosition( event ); 927 | 928 | const x = 0.5 * ( event.pageX + position.x ); 929 | const y = 0.5 * ( event.pageY + position.y ); 930 | 931 | rotateEnd.set( x, y ); 932 | 933 | } 934 | 935 | rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); 936 | 937 | const element = scope.domElement; 938 | 939 | rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height 940 | 941 | rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); 942 | 943 | rotateStart.copy( rotateEnd ); 944 | 945 | } 946 | 947 | function handleTouchMovePan( event ) { 948 | 949 | if ( pointers.length === 1 ) { 950 | 951 | panEnd.set( event.pageX, event.pageY ); 952 | 953 | } else { 954 | 955 | const position = getSecondPointerPosition( event ); 956 | 957 | const x = 0.5 * ( event.pageX + position.x ); 958 | const y = 0.5 * ( event.pageY + position.y ); 959 | 960 | panEnd.set( x, y ); 961 | 962 | } 963 | 964 | panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); 965 | 966 | pan( panDelta.x, panDelta.y ); 967 | 968 | panStart.copy( panEnd ); 969 | 970 | } 971 | 972 | function handleTouchMoveDolly( event ) { 973 | 974 | const position = getSecondPointerPosition( event ); 975 | 976 | const dx = event.pageX - position.x; 977 | const dy = event.pageY - position.y; 978 | 979 | const distance = Math.sqrt( dx * dx + dy * dy ); 980 | 981 | dollyEnd.set( 0, distance ); 982 | 983 | dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) ); 984 | 985 | dollyOut( dollyDelta.y ); 986 | 987 | dollyStart.copy( dollyEnd ); 988 | 989 | const centerX = ( event.pageX + position.x ) * 0.5; 990 | const centerY = ( event.pageY + position.y ) * 0.5; 991 | 992 | updateZoomParameters( centerX, centerY ); 993 | 994 | } 995 | 996 | function handleTouchMoveDollyPan( event ) { 997 | 998 | if ( scope.enableZoom ) handleTouchMoveDolly( event ); 999 | 1000 | if ( scope.enablePan ) handleTouchMovePan( event ); 1001 | 1002 | } 1003 | 1004 | function handleTouchMoveDollyRotate( event ) { 1005 | 1006 | if ( scope.enableZoom ) handleTouchMoveDolly( event ); 1007 | 1008 | if ( scope.enableRotate ) handleTouchMoveRotate( event ); 1009 | 1010 | } 1011 | 1012 | // 1013 | // event handlers - FSM: listen for events and reset state 1014 | // 1015 | 1016 | function onPointerDown( event ) { 1017 | 1018 | if ( scope.enabled === false ) return; 1019 | 1020 | if ( pointers.length === 0 ) { 1021 | 1022 | scope.domElement.setPointerCapture( event.pointerId ); 1023 | 1024 | scope.domElement.addEventListener( 'pointermove', onPointerMove ); 1025 | scope.domElement.addEventListener( 'pointerup', onPointerUp ); 1026 | 1027 | } 1028 | 1029 | // 1030 | 1031 | if ( isTrackingPointer( event ) ) return; 1032 | 1033 | // 1034 | 1035 | addPointer( event ); 1036 | 1037 | if ( event.pointerType === 'touch' ) { 1038 | 1039 | onTouchStart( event ); 1040 | 1041 | } else { 1042 | 1043 | onMouseDown( event ); 1044 | 1045 | } 1046 | 1047 | } 1048 | 1049 | function onPointerMove( event ) { 1050 | 1051 | if ( scope.enabled === false ) return; 1052 | 1053 | if ( event.pointerType === 'touch' ) { 1054 | 1055 | onTouchMove( event ); 1056 | 1057 | } else { 1058 | 1059 | onMouseMove( event ); 1060 | 1061 | } 1062 | 1063 | } 1064 | 1065 | function onPointerUp( event ) { 1066 | 1067 | removePointer( event ); 1068 | 1069 | switch ( pointers.length ) { 1070 | 1071 | case 0: 1072 | 1073 | scope.domElement.releasePointerCapture( event.pointerId ); 1074 | 1075 | scope.domElement.removeEventListener( 'pointermove', onPointerMove ); 1076 | scope.domElement.removeEventListener( 'pointerup', onPointerUp ); 1077 | 1078 | scope.dispatchEvent( _endEvent ); 1079 | 1080 | state = STATE.NONE; 1081 | 1082 | break; 1083 | 1084 | case 1: 1085 | 1086 | const pointerId = pointers[ 0 ]; 1087 | const position = pointerPositions[ pointerId ]; 1088 | 1089 | // minimal placeholder event - allows state correction on pointer-up 1090 | onTouchStart( { pointerId: pointerId, pageX: position.x, pageY: position.y } ); 1091 | 1092 | break; 1093 | 1094 | } 1095 | 1096 | } 1097 | 1098 | function onMouseDown( event ) { 1099 | 1100 | let mouseAction; 1101 | 1102 | switch ( event.button ) { 1103 | 1104 | case 0: 1105 | 1106 | mouseAction = scope.mouseButtons.LEFT; 1107 | break; 1108 | 1109 | case 1: 1110 | 1111 | mouseAction = scope.mouseButtons.MIDDLE; 1112 | break; 1113 | 1114 | case 2: 1115 | 1116 | mouseAction = scope.mouseButtons.RIGHT; 1117 | break; 1118 | 1119 | default: 1120 | 1121 | mouseAction = - 1; 1122 | 1123 | } 1124 | 1125 | switch ( mouseAction ) { 1126 | 1127 | case MOUSE.DOLLY: 1128 | 1129 | if ( scope.enableZoom === false ) return; 1130 | 1131 | handleMouseDownDolly( event ); 1132 | 1133 | state = STATE.DOLLY; 1134 | 1135 | break; 1136 | 1137 | case MOUSE.ROTATE: 1138 | 1139 | if ( event.ctrlKey || event.metaKey || event.shiftKey ) { 1140 | 1141 | if ( scope.enablePan === false ) return; 1142 | 1143 | handleMouseDownPan( event ); 1144 | 1145 | state = STATE.PAN; 1146 | 1147 | } else { 1148 | 1149 | if ( scope.enableRotate === false ) return; 1150 | 1151 | handleMouseDownRotate( event ); 1152 | 1153 | state = STATE.ROTATE; 1154 | 1155 | } 1156 | 1157 | break; 1158 | 1159 | case MOUSE.PAN: 1160 | 1161 | if ( event.ctrlKey || event.metaKey || event.shiftKey ) { 1162 | 1163 | if ( scope.enableRotate === false ) return; 1164 | 1165 | handleMouseDownRotate( event ); 1166 | 1167 | state = STATE.ROTATE; 1168 | 1169 | } else { 1170 | 1171 | if ( scope.enablePan === false ) return; 1172 | 1173 | handleMouseDownPan( event ); 1174 | 1175 | state = STATE.PAN; 1176 | 1177 | } 1178 | 1179 | break; 1180 | 1181 | default: 1182 | 1183 | state = STATE.NONE; 1184 | 1185 | } 1186 | 1187 | if ( state !== STATE.NONE ) { 1188 | 1189 | scope.dispatchEvent( _startEvent ); 1190 | 1191 | } 1192 | 1193 | } 1194 | 1195 | function onMouseMove( event ) { 1196 | 1197 | switch ( state ) { 1198 | 1199 | case STATE.ROTATE: 1200 | 1201 | if ( scope.enableRotate === false ) return; 1202 | 1203 | handleMouseMoveRotate( event ); 1204 | 1205 | break; 1206 | 1207 | case STATE.DOLLY: 1208 | 1209 | if ( scope.enableZoom === false ) return; 1210 | 1211 | handleMouseMoveDolly( event ); 1212 | 1213 | break; 1214 | 1215 | case STATE.PAN: 1216 | 1217 | if ( scope.enablePan === false ) return; 1218 | 1219 | handleMouseMovePan( event ); 1220 | 1221 | break; 1222 | 1223 | } 1224 | 1225 | } 1226 | 1227 | function onMouseWheel( event ) { 1228 | 1229 | if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE ) return; 1230 | 1231 | event.preventDefault(); 1232 | 1233 | scope.dispatchEvent( _startEvent ); 1234 | 1235 | handleMouseWheel( customWheelEvent( event ) ); 1236 | 1237 | scope.dispatchEvent( _endEvent ); 1238 | 1239 | } 1240 | 1241 | function customWheelEvent( event ) { 1242 | 1243 | const mode = event.deltaMode; 1244 | 1245 | // minimal wheel event altered to meet delta-zoom demand 1246 | const newEvent = { 1247 | clientX: event.clientX, 1248 | clientY: event.clientY, 1249 | deltaY: event.deltaY, 1250 | }; 1251 | 1252 | switch ( mode ) { 1253 | 1254 | case 1: // LINE_MODE 1255 | newEvent.deltaY *= 16; 1256 | break; 1257 | 1258 | case 2: // PAGE_MODE 1259 | newEvent.deltaY *= 100; 1260 | break; 1261 | 1262 | } 1263 | 1264 | // detect if event was triggered by pinching 1265 | if ( event.ctrlKey && ! controlActive ) { 1266 | 1267 | newEvent.deltaY *= 10; 1268 | 1269 | } 1270 | 1271 | return newEvent; 1272 | 1273 | } 1274 | 1275 | function interceptControlDown( event ) { 1276 | 1277 | if ( event.key === 'Control' ) { 1278 | 1279 | controlActive = true; 1280 | 1281 | 1282 | const document = scope.domElement.getRootNode(); // offscreen canvas compatibility 1283 | 1284 | document.addEventListener( 'keyup', interceptControlUp, { passive: true, capture: true } ); 1285 | 1286 | } 1287 | 1288 | } 1289 | 1290 | function interceptControlUp( event ) { 1291 | 1292 | if ( event.key === 'Control' ) { 1293 | 1294 | controlActive = false; 1295 | 1296 | 1297 | const document = scope.domElement.getRootNode(); // offscreen canvas compatibility 1298 | 1299 | document.removeEventListener( 'keyup', interceptControlUp, { passive: true, capture: true } ); 1300 | 1301 | } 1302 | 1303 | } 1304 | 1305 | function onKeyDown( event ) { 1306 | 1307 | if ( scope.enabled === false || scope.enablePan === false ) return; 1308 | 1309 | handleKeyDown( event ); 1310 | 1311 | } 1312 | 1313 | function onTouchStart( event ) { 1314 | 1315 | trackPointer( event ); 1316 | 1317 | switch ( pointers.length ) { 1318 | 1319 | case 1: 1320 | 1321 | switch ( scope.touches.ONE ) { 1322 | 1323 | case TOUCH.ROTATE: 1324 | 1325 | if ( scope.enableRotate === false ) return; 1326 | 1327 | handleTouchStartRotate( event ); 1328 | 1329 | state = STATE.TOUCH_ROTATE; 1330 | 1331 | break; 1332 | 1333 | case TOUCH.PAN: 1334 | 1335 | if ( scope.enablePan === false ) return; 1336 | 1337 | handleTouchStartPan( event ); 1338 | 1339 | state = STATE.TOUCH_PAN; 1340 | 1341 | break; 1342 | 1343 | default: 1344 | 1345 | state = STATE.NONE; 1346 | 1347 | } 1348 | 1349 | break; 1350 | 1351 | case 2: 1352 | 1353 | switch ( scope.touches.TWO ) { 1354 | 1355 | case TOUCH.DOLLY_PAN: 1356 | 1357 | if ( scope.enableZoom === false && scope.enablePan === false ) return; 1358 | 1359 | handleTouchStartDollyPan( event ); 1360 | 1361 | state = STATE.TOUCH_DOLLY_PAN; 1362 | 1363 | break; 1364 | 1365 | case TOUCH.DOLLY_ROTATE: 1366 | 1367 | if ( scope.enableZoom === false && scope.enableRotate === false ) return; 1368 | 1369 | handleTouchStartDollyRotate( event ); 1370 | 1371 | state = STATE.TOUCH_DOLLY_ROTATE; 1372 | 1373 | break; 1374 | 1375 | default: 1376 | 1377 | state = STATE.NONE; 1378 | 1379 | } 1380 | 1381 | break; 1382 | 1383 | default: 1384 | 1385 | state = STATE.NONE; 1386 | 1387 | } 1388 | 1389 | if ( state !== STATE.NONE ) { 1390 | 1391 | scope.dispatchEvent( _startEvent ); 1392 | 1393 | } 1394 | 1395 | } 1396 | 1397 | function onTouchMove( event ) { 1398 | 1399 | trackPointer( event ); 1400 | 1401 | switch ( state ) { 1402 | 1403 | case STATE.TOUCH_ROTATE: 1404 | 1405 | if ( scope.enableRotate === false ) return; 1406 | 1407 | handleTouchMoveRotate( event ); 1408 | 1409 | scope.update(); 1410 | 1411 | break; 1412 | 1413 | case STATE.TOUCH_PAN: 1414 | 1415 | if ( scope.enablePan === false ) return; 1416 | 1417 | handleTouchMovePan( event ); 1418 | 1419 | scope.update(); 1420 | 1421 | break; 1422 | 1423 | case STATE.TOUCH_DOLLY_PAN: 1424 | 1425 | if ( scope.enableZoom === false && scope.enablePan === false ) return; 1426 | 1427 | handleTouchMoveDollyPan( event ); 1428 | 1429 | scope.update(); 1430 | 1431 | break; 1432 | 1433 | case STATE.TOUCH_DOLLY_ROTATE: 1434 | 1435 | if ( scope.enableZoom === false && scope.enableRotate === false ) return; 1436 | 1437 | handleTouchMoveDollyRotate( event ); 1438 | 1439 | scope.update(); 1440 | 1441 | break; 1442 | 1443 | default: 1444 | 1445 | state = STATE.NONE; 1446 | 1447 | } 1448 | 1449 | } 1450 | 1451 | function onContextMenu( event ) { 1452 | 1453 | if ( scope.enabled === false ) return; 1454 | 1455 | event.preventDefault(); 1456 | 1457 | } 1458 | 1459 | function addPointer( event ) { 1460 | 1461 | pointers.push( event.pointerId ); 1462 | 1463 | } 1464 | 1465 | function removePointer( event ) { 1466 | 1467 | delete pointerPositions[ event.pointerId ]; 1468 | 1469 | for ( let i = 0; i < pointers.length; i ++ ) { 1470 | 1471 | if ( pointers[ i ] == event.pointerId ) { 1472 | 1473 | pointers.splice( i, 1 ); 1474 | return; 1475 | 1476 | } 1477 | 1478 | } 1479 | 1480 | } 1481 | 1482 | function isTrackingPointer( event ) { 1483 | 1484 | for ( let i = 0; i < pointers.length; i ++ ) { 1485 | 1486 | if ( pointers[ i ] == event.pointerId ) return true; 1487 | 1488 | } 1489 | 1490 | return false; 1491 | 1492 | } 1493 | 1494 | function trackPointer( event ) { 1495 | 1496 | let position = pointerPositions[ event.pointerId ]; 1497 | 1498 | if ( position === undefined ) { 1499 | 1500 | position = new Vector2(); 1501 | pointerPositions[ event.pointerId ] = position; 1502 | 1503 | } 1504 | 1505 | position.set( event.pageX, event.pageY ); 1506 | 1507 | } 1508 | 1509 | function getSecondPointerPosition( event ) { 1510 | 1511 | const pointerId = ( event.pointerId === pointers[ 0 ] ) ? pointers[ 1 ] : pointers[ 0 ]; 1512 | 1513 | return pointerPositions[ pointerId ]; 1514 | 1515 | } 1516 | 1517 | // 1518 | 1519 | scope.domElement.addEventListener( 'contextmenu', onContextMenu ); 1520 | 1521 | scope.domElement.addEventListener( 'pointerdown', onPointerDown ); 1522 | scope.domElement.addEventListener( 'pointercancel', onPointerUp ); 1523 | scope.domElement.addEventListener( 'wheel', onMouseWheel, { passive: false } ); 1524 | 1525 | const document = scope.domElement.getRootNode(); // offscreen canvas compatibility 1526 | 1527 | document.addEventListener( 'keydown', interceptControlDown, { passive: true, capture: true } ); 1528 | 1529 | // force an update at start 1530 | 1531 | this.update(); 1532 | 1533 | } 1534 | 1535 | } 1536 | 1537 | export { OrbitControls }; 1538 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | // import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | createRoot(document.getElementById("root")!).render(); 7 | -------------------------------------------------------------------------------- /src/mock/freespace.ts: -------------------------------------------------------------------------------- 1 | import { IArrow } from "../renderer/arrow"; 2 | import { ICube } from "../renderer/cube"; 3 | import { IFreespace } from "../renderer/freespace"; 4 | import { IPolygonCylinder } from "../renderer/polygonCylinder"; 5 | 6 | export const freespaceData1: IFreespace = { 7 | id: "freespace-0", 8 | position: { 9 | x: 0, 10 | y: 0, 11 | z: 0, 12 | }, 13 | contour: [ 14 | { x: -30, y: -1, z: 0 }, 15 | { x: 30, y: -1, z: 0 }, 16 | { x: 30, y: 0.6, z: 0 }, 17 | { x: -30, y: 0.6, z: 0 }, 18 | ], 19 | // 洞可能有多个,所以这里应该设置成二维数组 20 | // holes: [ 21 | // [ 22 | // { x: 0.2, y: 0.8, z: 0 }, 23 | // { x: 0.5, y: 0.7, z: 0 }, 24 | // { x: 0.5, y: 2, z: 0 }, 25 | // { x: 0.2, y: 2, z: 0 }, 26 | // ], 27 | // ], 28 | color: { 29 | r: 58, 30 | g: 58, 31 | b: 58, 32 | }, 33 | }; 34 | 35 | export const freespaceData2: IFreespace = { 36 | id: "freespace-1", 37 | position: { 38 | x: 0, 39 | y: 0, 40 | z: 0, 41 | }, 42 | contour: [ 43 | { x: 3, y: 10, z: 0 }, 44 | { x: 1, y: 10, z: 0 }, 45 | { x: 1, y: -10, z: 0 }, 46 | { x: 3, y: -10, z: 0 }, 47 | ], 48 | color: { 49 | r: 58, 50 | g: 58, 51 | b: 58, 52 | }, 53 | }; 54 | 55 | export const cubeData1: ICube = { 56 | id: "cube1", 57 | type: "BUS", 58 | position: { 59 | x: 1.3, 60 | y: 0.2, 61 | z: 0.18, 62 | }, 63 | color: { 64 | r: 0, 65 | g: 1, 66 | b: 0, 67 | }, 68 | width: 0.4, 69 | height: 0.3, 70 | length: 1, 71 | }; 72 | 73 | export const cubeData2: ICube = { 74 | id: "cube2", 75 | type: "CAR", 76 | position: { 77 | x: 2.3, 78 | y: 0.2, 79 | z: 0.12, 80 | }, 81 | color: { 82 | r: 0, 83 | g: 1, 84 | b: 0, 85 | }, 86 | width: 0.24, 87 | height: 0.15, 88 | length: 0.6, 89 | }; 90 | 91 | export const cubeData3: ICube = { 92 | id: "cube3", 93 | type: "CAR", 94 | position: { 95 | x: -1, 96 | y: -0.4, 97 | z: 0.12, 98 | }, 99 | color: { 100 | r: 0, 101 | g: 1, 102 | b: 0, 103 | }, 104 | width: 0.24, 105 | height: 0.12, 106 | length: 0.5, 107 | }; 108 | 109 | export const cubeData4: ICube = { 110 | id: "cube4", 111 | type: "CAR", 112 | position: { 113 | x: 1, 114 | y: -0.5, 115 | z: 0.12, 116 | }, 117 | color: { 118 | r: 0, 119 | g: 1, 120 | b: 0, 121 | }, 122 | width: 0.24, 123 | height: 0.16, 124 | length: 0.4, 125 | }; 126 | 127 | export const textData1 = { 128 | id: "text1", 129 | content: "text1", 130 | size: 0.2, 131 | color: { 132 | r: 0, 133 | g: 0, 134 | b: 1, 135 | }, 136 | position: { 137 | x: 0, 138 | y: -2, 139 | z: 0.5, 140 | }, 141 | }; 142 | 143 | export const arrowData1: IArrow = { 144 | id: "text1", 145 | origin: { x: 0, y: 1, z: 0 }, 146 | endPoint: { x: 0, y: 1, z: 0 }, 147 | }; 148 | 149 | export const polygonCylinderData1: IPolygonCylinder = { 150 | id: "polygonCylinderData1", 151 | contour: [ 152 | { x: 0.6, y: -0.4, z: 0 }, 153 | { x: 0.6, y: -0.8, z: 0 }, 154 | { x: 0.4, y: -0.8, z: 0 }, 155 | { x: 0.2, y: -0.6, z: 0 }, 156 | { x: 0.4, y: -0.4, z: 0 }, 157 | ], 158 | height: 0.16, 159 | color: { 160 | r: 1, 161 | g: 0, 162 | b: 0, 163 | }, 164 | }; 165 | 166 | export const polygonCylinderData2: IPolygonCylinder = { 167 | id: "polygonCylinderData2", 168 | contour: [ 169 | { x: -0.6, y: 0, z: -1.4 }, 170 | { x: -0.6, y: 0, z: -1.8 }, 171 | { x: -0.8, y: 0, z: -1.8 }, 172 | { x: -0.8, y: 0, z: -1.4 }, 173 | ], 174 | height: 0.16, 175 | color: { 176 | r: 0, 177 | g: 1, 178 | b: 0, 179 | }, 180 | }; 181 | 182 | export const polygonCylinderData3: IPolygonCylinder = { 183 | id: "polygonCylinderData3", 184 | contour: [ 185 | { x: -0.8, y: 0, z: -0.7 }, 186 | { x: -0.8, y: 0, z: -0.8 }, 187 | { x: -0.9, y: 0, z: -0.8 }, 188 | { x: -0.9, y: 0, z: -0.7 }, 189 | ], 190 | height: 0.2, 191 | color: { 192 | r: 0, 193 | g: 0, 194 | b: 1, 195 | }, 196 | }; 197 | 198 | export const polygonCylinderData4: IPolygonCylinder = { 199 | id: "polygonCylinderData4", 200 | contour: [ 201 | { x: -0.6, y: 0, z: -0.7 }, 202 | { x: -0.6, y: 0, z: -0.8 }, 203 | { x: -0.7, y: 0, z: -0.8 }, 204 | { x: -0.7, y: 0, z: -0.7 }, 205 | ], 206 | height: 0.2, 207 | color: { 208 | r: 0, 209 | g: 0, 210 | b: 1, 211 | }, 212 | }; 213 | -------------------------------------------------------------------------------- /src/mock/line.ts: -------------------------------------------------------------------------------- 1 | import { ELineType, ILine } from "../renderer/line"; 2 | 3 | export const lineData1: ILine = { 4 | width: 0.02, 5 | type: ELineType.Solid, 6 | points: [ 7 | [-20, 0.4, 0], 8 | [20, 0.4, 0], 9 | ], 10 | }; 11 | export const lineData2: ILine = { 12 | width: 0.02, 13 | type: ELineType.Solid, 14 | points: [ 15 | [-20, -0.8, 0], 16 | [20, -0.8, 0], 17 | ], 18 | }; 19 | 20 | export const lineData3: ILine = { 21 | width: 0.02, 22 | type: ELineType.Dash, 23 | points: [ 24 | [-20, -0.2, 0], 25 | [-10, -0.2, 0], 26 | [0, -0.2, 0], 27 | [10, -0.2, 0], 28 | [20, -0.2, 0], 29 | ], 30 | }; 31 | 32 | export const lineData4: ILine = { 33 | width: 0.3, 34 | type: ELineType.Gradual, 35 | color: "#ffff00", 36 | endColor: "orange", 37 | points: [ 38 | [0, 0, 0], 39 | [0, -2, 0], 40 | [-0.2, -4, 0], 41 | [-0.5, -5, 0], 42 | [-0.6, -6, 0], 43 | [-0.6, -8, 0], 44 | [-0.6, -10, 0], 45 | ], 46 | }; 47 | -------------------------------------------------------------------------------- /src/renderer/arrow.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IPos } from "../types/common"; 3 | 4 | class Arrow { 5 | scene = new THREE.Scene(); 6 | 7 | constructor(scene: THREE.Scene) { 8 | this.scene = scene; 9 | } 10 | 11 | draw(data: IArrow) { 12 | const arrowHelper = drawArrow(data); 13 | this.scene.add(arrowHelper); 14 | } 15 | } 16 | 17 | export default Arrow; 18 | 19 | export function drawArrow(data: IArrow) { 20 | const { origin, endPoint, hex = 0xffff00 } = data; 21 | // 通过箭头起点和终点计算方向向量 22 | const dir = new THREE.Vector3( 23 | endPoint.x - origin.x, 24 | endPoint.y - origin.y, 25 | (endPoint.z ?? 0) - (origin.z ?? 0) 26 | ); 27 | // 获取箭头长度 28 | const length = dir.length(); 29 | const dirData = new THREE.Vector3(dir.x, dir.y, dir.z); 30 | dirData.normalize(); 31 | const originPos = new THREE.Vector3(origin.x, origin.y, origin.z ?? 0); 32 | const arrowHelper = new THREE.ArrowHelper(dirData, originPos, length, hex); 33 | return arrowHelper; 34 | } 35 | 36 | export interface IArrow { 37 | id: string; 38 | // 箭头尾部坐标,用于确立方向 39 | endPoint: IPos; 40 | origin: IPos; 41 | length?: number; 42 | // 颜色哈希值 43 | hex?: string; 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/crosswalk.ts: -------------------------------------------------------------------------------- 1 | // import * as THREE from "three"; 2 | // import { IColor, IPos } from "../types/common"; 3 | 4 | // class Crosswalk { 5 | // scene = new THREE.Scene(); 6 | 7 | // constructor(scene: THREE.Scene) { 8 | // this.scene = scene; 9 | // } 10 | 11 | // draw(data: ICrosswalk) { 12 | // // 13 | // } 14 | // } 15 | 16 | // export default Crosswalk; 17 | 18 | // export interface ICrosswalk { 19 | // id: number; 20 | // position: IPos; // 中心点 21 | // points: IPos[]; // 点集, 相对中心点 22 | // rotation: number; // 偏转角 23 | // color: string; 24 | // } 25 | 26 | import { Vector2, Vector3 } from "three"; 27 | import { ConvexGeometry } from "three/examples/jsm/geometries/ConvexGeometry.js"; 28 | 29 | class OBox2 { 30 | points: Vector2[]; 31 | boundingRect: { 32 | minX: number; 33 | minY: number; 34 | maxX: number; 35 | maxY: number; 36 | angle: number; 37 | } | null; 38 | 39 | constructor(points: Vector2[]) { 40 | this.points = points.map((p) => new Vector2(p.x, p.y)); 41 | this.boundingRect = this.computeMinBoundingRectangle(); 42 | } 43 | 44 | // 旋转点的辅助函数 45 | rotatePoint(point: Vector2, angle: number): Vector2 { 46 | const cos = Math.cos(angle); 47 | const sin = Math.sin(angle); 48 | const x = point.x * cos - point.y * sin; 49 | const y = point.x * sin + point.y * cos; 50 | return new Vector2(x, y); 51 | } 52 | 53 | // 获取最小旋转包围矩形 54 | private computeMinBoundingRectangle() { 55 | let minArea = Infinity; 56 | let bestRect = null; 57 | 58 | // 计算凸包 59 | const convexGeometry = new ConvexGeometry( 60 | this.points.map((p) => new Vector3(p.x, p.y, 0)) 61 | ); 62 | const positionAttribute = convexGeometry.getAttribute("position"); 63 | const hullPoints = []; 64 | for (let i = 0; i < positionAttribute.count; i++) { 65 | const x = positionAttribute.getX(i); 66 | const y = positionAttribute.getY(i); 67 | hullPoints.push(new Vector2(x, y)); 68 | } 69 | 70 | // 遍历凸包的每一条边 71 | for (let i = 0; i < hullPoints.length; i++) { 72 | const p1 = hullPoints[i]; 73 | const p2 = hullPoints[(i + 1) % hullPoints.length]; 74 | 75 | // 计算边的角度并旋转点集 76 | const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x); 77 | const rotatedPoints = this.points.map((p) => this.rotatePoint(p, -angle)); 78 | 79 | // 计算旋转后的包围盒 80 | const minX = Math.min(...rotatedPoints.map((p) => p.x)); 81 | const maxX = Math.max(...rotatedPoints.map((p) => p.x)); 82 | const minY = Math.min(...rotatedPoints.map((p) => p.y)); 83 | const maxY = Math.max(...rotatedPoints.map((p) => p.y)); 84 | 85 | const width = maxX - minX; 86 | const height = maxY - minY; 87 | const area = width * height; 88 | 89 | // 找到面积最小的矩形 90 | if (area < minArea) { 91 | minArea = area; 92 | bestRect = { minX, minY, maxX, maxY, angle }; 93 | } 94 | } 95 | 96 | return bestRect; 97 | } 98 | 99 | getBoundingRectPoints() { 100 | if (!this.boundingRect) return null; 101 | const { minX, maxX, minY, maxY, angle } = this.boundingRect; 102 | // 计算四个角点 103 | return [ 104 | this.rotatePoint(new Vector2(maxX, maxY), angle), // 右上角 105 | this.rotatePoint(new Vector2(minX, maxY), angle), // 左上角 106 | this.rotatePoint(new Vector2(maxX, maxY), angle), // 右上角 107 | this.rotatePoint(new Vector2(maxX, minY), angle), // 右下角 108 | this.rotatePoint(new Vector2(maxX, minY), angle), // 右下角 109 | this.rotatePoint(new Vector2(minX, minY), angle), // 左下角 110 | this.rotatePoint(new Vector2(minX, minY), angle), // 左下角 111 | this.rotatePoint(new Vector2(minX, maxY), angle), // 左上角 112 | ]; 113 | } 114 | } 115 | 116 | export default OBox2; 117 | -------------------------------------------------------------------------------- /src/renderer/cube.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import * as THREE from "three"; 3 | import { IColor, IPos } from "../types/common"; 4 | import crateGif from "@/assets/textures/crate.gif"; 5 | import { renderTextMesh } from "./text"; 6 | import { drawArrow } from "./arrow"; 7 | import { loadTexture } from "../helper"; 8 | 9 | type TextCache = Record; 10 | 11 | class Cube { 12 | scene = new THREE.Scene(); 13 | camera = new THREE.Camera(); 14 | textCache: TextCache = {}; 15 | cubes: THREE.Group[] = []; 16 | // id和cube映射 17 | cubeMap = {}; 18 | 19 | constructor(scene: THREE.Scene, camera: THREE.Camera) { 20 | this.scene = scene; 21 | this.camera = camera; 22 | this.triggerLabelBox = this.triggerLabelBox.bind(this); 23 | this.updateLabelBox = this.updateLabelBox.bind(this); 24 | } 25 | 26 | draw(datas: ICube[]) { 27 | datas.forEach((data) => { 28 | const { id, type, position, color, width, height, length } = data; 29 | const group = new THREE.Group(); 30 | const material = new THREE.MeshBasicMaterial({ 31 | transparent: true, 32 | opacity: 0.2, 33 | }); 34 | material.color.setRGB(color.r, color.g, color.b); 35 | const geometry = new THREE.BoxGeometry(width, height, length); 36 | const mesh = new THREE.Mesh(geometry, material); 37 | mesh.rotateX(Math.PI / 2); 38 | mesh.rotateY(Math.PI / 2); 39 | group.add(mesh); 40 | const edges = new THREE.EdgesGeometry(geometry); 41 | const edgesMaterial = new THREE.LineBasicMaterial(); 42 | edgesMaterial.color.setRGB(color.r, color.g, color.b); 43 | const line = new THREE.LineSegments(edges, edgesMaterial); 44 | line.position.copy(mesh.position); 45 | line.rotation.copy(mesh.rotation); 46 | group.add(line); 47 | // 绘制顶部文字 48 | // const text = id + "-" + type; 49 | // if (this.textCache[text]) { 50 | // const textMesh = this.textCache[text]; 51 | // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 52 | // // @ts-ignore 53 | // mesh.textMesh = textMesh; 54 | // group.add(textMesh); 55 | // } else { 56 | // const textMesh = renderTextMesh({ 57 | // id: text, 58 | // content: text, 59 | // position: { 60 | // x: mesh.position.x, 61 | // y: mesh.position.y + width / 2, 62 | // z: mesh.position.z + height / 2 + 0.03, 63 | // }, 64 | // }); 65 | // // 挂载到他车Mesh上 66 | // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 67 | // // @ts-ignore 68 | // mesh.textMesh = textMesh; 69 | // group.add(textMesh); 70 | // } 71 | // 绘制朝向箭头 72 | const arrowMesh = drawArrow({ 73 | id: data.id + "-" + "arrow", 74 | endPoint: { 75 | x: mesh.position.x + length * 1.5, 76 | y: mesh.position.y, 77 | z: mesh.position.z, 78 | }, 79 | origin: { 80 | x: mesh.position.x, 81 | y: mesh.position.y, 82 | z: mesh.position.z, 83 | }, 84 | }); 85 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 86 | // @ts-ignore 87 | mesh.arrowMesh = arrowMesh; 88 | group.add(arrowMesh); 89 | group.userData.id = data.id; 90 | group.userData.type = data.type; 91 | group.userData.width = data.width; 92 | group.userData.height = data.height; 93 | group.position.set(position.x, position.y, position.z ?? 0); 94 | this.scene.add(group); 95 | this.cubes.push(group); 96 | }); 97 | this.triggerLabelBox(); 98 | // MOCK 99 | setInterval(() => { 100 | this.updateLabelBox(); 101 | }, 100); 102 | return this.cubes; 103 | } 104 | 105 | update() { 106 | // 更新位置信息时需要同步更新下标签文本 107 | } 108 | 109 | triggerLabelBox() { 110 | // @ts-ignore 111 | const canvasContainer = window.canvasRef.container!; 112 | this.cubes.forEach((cube) => { 113 | const dom = document.getElementById(`cube-label-${cube.id}`); 114 | if (!dom) { 115 | const newBox = document.createElement("div"); 116 | newBox.setAttribute("id", `cube-label-${cube.id}`); 117 | newBox.setAttribute("class", "label-box"); 118 | canvasContainer.appendChild(newBox); 119 | this.updateLabelBox(); 120 | } else { 121 | dom.style.display = "block"; 122 | this.updateLabelBox(); 123 | } 124 | }); 125 | } 126 | 127 | updateLabelBox() { 128 | // @ts-ignore 129 | const canvasRef = window.canvasRef; 130 | this.cubes.forEach((cube) => { 131 | const dom = document.getElementById(`cube-label-${cube.id}`); 132 | if (dom) { 133 | const x = cube.position.x; 134 | const y = cube.position.y; 135 | const vector = new THREE.Vector3(x, y, 0.1); 136 | // 将世界坐标转为标准设备坐标 137 | vector.project(this.camera); 138 | const w = canvasRef.width / 2; 139 | const h = canvasRef.height / 2; 140 | const screenX = Math.round(vector.x * w + w); 141 | const screenY = Math.round(-vector.y * h + h); 142 | dom.innerText = `${cube.userData.id}-${cube.userData.type}`; 143 | dom.style.transform = `translate(${screenX}px,${screenY}px)`; 144 | } 145 | }); 146 | } 147 | } 148 | 149 | export default Cube; 150 | 151 | export interface ICube { 152 | id: string; 153 | type: string; 154 | position: IPos; 155 | color: IColor; 156 | width: number; 157 | height: number; 158 | length: number; 159 | } 160 | -------------------------------------------------------------------------------- /src/renderer/egoCar/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 3 | import * as THREE from "three"; 4 | import carModelWithDraco from "@/assets/models/su7-draco.glb"; 5 | import haloImg from "@/assets/textures/halo.png"; 6 | import { abortWrapper } from "../../helper/promise"; 7 | import { loadDracoGLTFWithPromise, loadTexture } from "../../helper"; 8 | import { Easing, Tween } from "@tweenjs/tween.js"; 9 | 10 | const egoCarLabelString = "egoCar-label"; 11 | 12 | export default class EgoCar { 13 | scene = new THREE.Scene(); 14 | camera = new THREE.Camera(); 15 | group = new THREE.Group(); 16 | car = new THREE.Group(); 17 | container: HTMLCanvasElement | null = null; 18 | showLabel = false; 19 | carData = { 20 | name: "egoCar", 21 | velocity: { 22 | x: 10, 23 | y: 20, 24 | }, 25 | }; 26 | 27 | constructor(scene: THREE.Scene, camera: THREE.Camera) { 28 | this.scene = scene; 29 | this.camera = camera; 30 | this.group.position.set(0, 0, 0); 31 | this.initialze(); 32 | this.clickObject = this.clickObject.bind(this); 33 | this.triggerLabelBox = this.triggerLabelBox.bind(this); 34 | this.updateLabelBox = this.updateLabelBox.bind(this); 35 | // @ts-ignore 36 | window.canvasRef.container.addEventListener("click", this.clickObject); 37 | // @ts-ignore 38 | this.container = window.canvasRef.container; 39 | } 40 | 41 | clickObject(e: any) { 42 | // @ts-ignore 43 | const canvasRef = window.canvasRef; 44 | const mouseVector = new THREE.Vector2(); 45 | const raycaster = new THREE.Raycaster(); 46 | mouseVector.x = (e.offsetX / canvasRef.width) * 2 - 1; 47 | mouseVector.y = -(e.offsetY / canvasRef.height) * 2 + 1; 48 | raycaster.setFromCamera(mouseVector, this.camera); 49 | // @ts-ignore 50 | const intersects = raycaster.intersectObjects(this.car.children, true); 51 | if (intersects.length > 0) { 52 | this.triggerLabelBox(); 53 | } 54 | } 55 | 56 | triggerLabelBox() { 57 | const canvasContainer = this.container!; 58 | const dom = document.getElementById(egoCarLabelString); 59 | if (!dom) { 60 | const newBox = document.createElement("div"); 61 | newBox.setAttribute("id", egoCarLabelString); 62 | newBox.setAttribute("class", "label-box"); 63 | canvasContainer.appendChild(newBox); 64 | this.updateLabelBox(newBox); 65 | this.showLabel = true; 66 | } else { 67 | if (!this.showLabel) { 68 | dom.style.display = "block"; 69 | this.updateLabelBox(dom); 70 | this.showLabel = true; 71 | } else { 72 | dom.style.display = "none"; 73 | this.showLabel = false; 74 | } 75 | } 76 | } 77 | 78 | updateLabelBox(dom: HTMLElement) { 79 | // @ts-ignore 80 | const canvasRef = window.canvasRef; 81 | const x = this.group.position.x; 82 | const y = this.group.position.y; 83 | const groupVector = new THREE.Vector3(x, y, 0.1); 84 | // 将世界坐标转为标准设备坐标 85 | groupVector.project(this.camera); 86 | const w = canvasRef.width / 2; 87 | const h = canvasRef.height / 2; 88 | const screenX = Math.round(groupVector.x * w + w); 89 | const screenY = Math.round(-groupVector.y * h + h); 90 | dom.innerText = `${this.carData.name}\nvx:${this.carData.velocity.x} vy:${this.carData.velocity.y}`; 91 | dom.style.transform = `translate(${screenX}px,${screenY}px)`; 92 | } 93 | 94 | loadEgoCar() { 95 | const loadEgoCar = abortWrapper( 96 | loadDracoGLTFWithPromise(carModelWithDraco) 97 | ); 98 | return loadEgoCar.then((gltf) => { 99 | const car = gltf.scene; 100 | car.scale.set(0.1, 0.1, 0.1); 101 | car.rotateX(Math.PI / 2); 102 | car.rotateY(-Math.PI / 2); 103 | this.car = car; 104 | this.group.add(car); 105 | this.scene.add(this.group); 106 | }); 107 | } 108 | 109 | async initialze() { 110 | await this.loadEgoCar(); 111 | await this.drawDynamicHalo(); 112 | await this.drawFrontLight(); 113 | } 114 | 115 | async drawDynamicHalo() { 116 | // const egoCarHalo = await loadTexture(haloImg); 117 | const geometry = new THREE.CircleGeometry(0.05, 32); 118 | const material = getHaloShaderWithAngle({}); 119 | // const material = new THREE.MeshBasicMaterial({ 120 | // map: egoCarHalo, 121 | // transparent: true, 122 | // }); 123 | const mesh = new THREE.Mesh(geometry, material); 124 | const mesh2 = new THREE.Mesh(geometry.clone(), material.clone()); 125 | const mesh3 = new THREE.Mesh(geometry.clone(), material.clone()); 126 | mesh.position.z = 0.02; 127 | mesh2.position.z = 0.02; 128 | mesh3.position.z = 0.02; 129 | this.group.add(mesh); 130 | this.group.add(mesh2); 131 | this.group.add(mesh3); 132 | const tweenScale = new Tween(mesh.scale) 133 | .to({ x: 12, y: 12, z: 1 }, 3000) 134 | .start(500) 135 | .onStart(() => { 136 | mesh.material.uniforms.opacity.value = 1; 137 | }); 138 | const tweenOpacity = new Tween(mesh.material.uniforms.opacity).to( 139 | { value: 0.1 }, 140 | 500 141 | ); 142 | const tweenScale2 = new Tween(mesh2.scale) 143 | .to({ x: 12, y: 12, z: 1 }, 3000) 144 | .start(2000) 145 | .onStart(() => { 146 | mesh2.material.uniforms.opacity.value = 1; 147 | }); 148 | const tweenOpacity2 = new Tween(mesh2.material.uniforms.opacity).to( 149 | { value: 0.1 }, 150 | 500 151 | ); 152 | const tweenScale3 = new Tween(mesh3.scale) 153 | .to({ x: 12, y: 12, z: 1 }, 3000) 154 | .start(3000) 155 | .onStart(() => { 156 | mesh3.material.uniforms.opacity.value = 1; 157 | }); 158 | const tweenOpacity3 = new Tween(mesh3.material.uniforms.opacity).to( 159 | { value: 0.1 }, 160 | 500 161 | ); 162 | // 衔接两种补间动画 163 | tweenScale.chain(tweenOpacity); 164 | tweenOpacity.chain(tweenScale); 165 | tweenScale2.chain(tweenOpacity2); 166 | tweenOpacity2.chain(tweenScale2); 167 | tweenScale3.chain(tweenOpacity3); 168 | tweenOpacity3.chain(tweenScale3); 169 | // 可以直接用定时器做更新 170 | setInterval(() => { 171 | tweenScale.update(); 172 | tweenOpacity.update(); 173 | tweenScale2.update(); 174 | tweenOpacity2.update(); 175 | tweenScale3.update(); 176 | tweenOpacity3.update(); 177 | }, 50); 178 | } 179 | 180 | drawFrontLight() { 181 | const target1 = new THREE.Object3D(); 182 | target1.position.set(0.2, 0.1, 0.3); 183 | const light1 = new THREE.SpotLight("#fff", 1.2, 2, Math.PI / 6, 0.1); 184 | light1.position.set(-0.2, 0.1, 0.3); 185 | light1.castShadow = true; 186 | light1.target = target1; 187 | this.group.add(target1); 188 | this.group.add(light1); 189 | const target2 = new THREE.Object3D(); 190 | target2.position.set(0.2, -0.1, 0.3); 191 | const light2 = new THREE.SpotLight("#fff", 1.2, 2, Math.PI / 6, 0.1); 192 | light2.position.set(-0.2, -0.1, 0.3); 193 | light2.castShadow = true; 194 | light2.target = target2; 195 | this.group.add(target2); 196 | this.group.add(light2); 197 | } 198 | } 199 | 200 | export function getHaloShader(option: { 201 | // 最大半径 202 | radius?: number; 203 | opacity?: number; 204 | // 颜色定义 205 | color?: string; 206 | }) { 207 | const material = new THREE.ShaderMaterial({ 208 | uniforms: { 209 | radius: { value: option.radius ?? 1 }, 210 | opacity: { value: option.opacity ?? 1.0 }, 211 | color: { 212 | value: option.color ?? new THREE.Color("#00ffff"), 213 | }, 214 | }, 215 | vertexShader: ` 216 | varying vec2 vUv; 217 | void main() { 218 | vUv = uv; 219 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 220 | } 221 | `, 222 | fragmentShader: ` 223 | uniform vec3 color; 224 | uniform float opacity; 225 | varying vec2 vUv; 226 | void main() { 227 | float radius = length(vUv - 0.5); // uv坐标到中心的距离 228 | float alpha = smoothstep(0.36, 0.5, radius) * opacity; 229 | gl_FragColor = vec4(color, alpha); 230 | } 231 | `, 232 | }); 233 | material.transparent = true; 234 | material.side = THREE.FrontSide; 235 | return material; 236 | } 237 | 238 | export function getHaloShaderWithAngle(option: { 239 | // 最大半径 240 | radius?: number; 241 | opacity?: number; 242 | angle?: number; 243 | // 颜色定义 244 | color?: string; 245 | }) { 246 | const material = new THREE.ShaderMaterial({ 247 | uniforms: { 248 | radius: { value: option.radius ?? 1 }, 249 | opacity: { value: option.opacity ?? 1.0 }, 250 | angle: { value: option.angle ?? Math.PI / 2 }, 251 | color: { 252 | value: option.color ?? new THREE.Color("#00ffff"), 253 | }, 254 | }, 255 | vertexShader: ` 256 | varying vec2 vUv; 257 | void main() { 258 | vUv = uv; 259 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 260 | } 261 | `, 262 | fragmentShader: ` 263 | uniform vec3 color; 264 | uniform float opacity; 265 | varying vec2 vUv; 266 | void main() { 267 | // 假设vUv.x的范围是0到1,我们需要将其映射到0到2π(或你想要的扇形角度范围) 268 | float startAngle = 0.0; // 扇形的起始角度(弧度) 269 | float endAngle = 3.14159 * 2.0; // 扇形的结束角度(弧度),这里是一个90度的扇形 270 | float angleRange = endAngle - startAngle; 271 | 272 | // 将vUv.x从0-1映射到startAngle-endAngle 273 | float angle = startAngle + vUv.y * angleRange; 274 | 275 | float radius = length(vUv - 0.5); // uv坐标到中心的距离 276 | float alpha = smoothstep(0.36, 0.5, radius) * opacity; 277 | // 检查当前角度是否在扇形范围内 278 | if (angle >= startAngle && angle <= endAngle) { 279 | gl_FragColor = vec4(color, alpha); 280 | } else { 281 | gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0); // 在不支持discard的上下文中 282 | } 283 | 284 | } 285 | `, 286 | }); 287 | material.transparent = true; 288 | material.side = THREE.FrontSide; 289 | material.depthWrite = false; 290 | return material; 291 | } 292 | -------------------------------------------------------------------------------- /src/renderer/freespace.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IColor, IPos } from "../types/common"; 3 | 4 | class Freespace { 5 | scene = new THREE.Scene(); 6 | 7 | constructor(scene: THREE.Scene) { 8 | this.scene = scene; 9 | } 10 | 11 | draw(data: IFreespace) { 12 | const { 13 | contour, 14 | holes = [], 15 | color = { r: 0, g: 0, b: 0 }, 16 | position, 17 | } = data; 18 | if (contour.length < 3) { 19 | return; 20 | } 21 | const shape = new THREE.Shape(); 22 | // 先绘制轮廓 23 | // 设置起点 24 | shape.moveTo(contour[0].x, contour[0].y); 25 | contour.forEach((item) => shape.lineTo(item.x, item.y)); 26 | // 绘制洞 27 | holes.forEach((item) => { 28 | if (item.length < 3) { 29 | return; 30 | } 31 | const path = new THREE.Path(); 32 | path.moveTo(item[0].x, item[0].y); 33 | item.forEach((subItem) => { 34 | path.lineTo(subItem.x, subItem.y); 35 | }); 36 | shape.holes.push(path); 37 | }); 38 | const shapeGeometry = new THREE.ShapeGeometry(shape); 39 | const material = new THREE.MeshPhongMaterial(); 40 | // setRGB传参颜色值需要介于0-1之间 41 | material.color.setRGB( 42 | (color?.r || 58) / 255, 43 | (color?.g || 58) / 255, 44 | (color?.b || 58) / 255 45 | ); 46 | material.opacity = color.a || 1; 47 | const mesh = new THREE.Mesh(shapeGeometry, material); 48 | mesh.position.set(position?.x || 0, position?.y || 0, position?.z || 0); 49 | // mesh.rotateX(-Math.PI / 2); 50 | this.scene.add(mesh); 51 | } 52 | } 53 | 54 | export default Freespace; 55 | 56 | export interface IFreespace { 57 | // 一般可以用于判断元素是否可复用 58 | id: string; 59 | position?: IPos; 60 | contour: IPos[]; 61 | // 洞可能有多个,所以这里应该设置成二维数组 62 | holes?: IPos[][]; 63 | color?: IColor; 64 | } 65 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 3 | import * as THREE from "three"; 4 | // import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; 5 | // @ts-ignore 6 | import { OrbitControls } from "../helper/three/OrbitControls.js"; 7 | import Freespace from "./freespace"; 8 | import { 9 | arrowData1, 10 | cubeData1, 11 | cubeData2, 12 | cubeData3, 13 | cubeData4, 14 | freespaceData1, 15 | freespaceData2, 16 | polygonCylinderData1, 17 | polygonCylinderData2, 18 | polygonCylinderData3, 19 | polygonCylinderData4, 20 | } from "../mock/freespace"; 21 | import Cube from "./cube"; 22 | import Text from "./text"; 23 | import Arrow from "./arrow"; 24 | import PolygonCylinder from "./polygonCylinder"; 25 | import Line from "./line"; 26 | import { lineData1, lineData2, lineData3, lineData4 } from "../mock/line"; 27 | import { EViewType } from "../types/renderer"; 28 | import EgoCar from "./egoCar"; 29 | import Robot from "./robot"; 30 | import { Easing, Tween } from "@tweenjs/tween.js"; 31 | import * as TWEEN from "@tweenjs/tween.js"; 32 | import { ISceneData } from "../views/scene-editor/store/type.js"; 33 | 34 | const manager = new THREE.LoadingManager(); 35 | manager.onLoad = () => { 36 | console.log("===Loading complete!"); 37 | }; 38 | manager.onProgress = (url, loaded, total) => { 39 | console.log("===loading", url, loaded, total); 40 | }; 41 | 42 | const fakeCameraDirection = new THREE.Vector3(); 43 | const tweenGroup = new TWEEN.Group(); 44 | 45 | class Renderer { 46 | scene = new THREE.Scene(); 47 | camera = new THREE.PerspectiveCamera(); 48 | fakeCamera = new THREE.PerspectiveCamera(); 49 | resetCamera = new THREE.PerspectiveCamera(); 50 | controls: OrbitControls | null = null; 51 | renderer = new THREE.WebGLRenderer(); 52 | // 场景尺寸 53 | dimensions = [0, 0]; 54 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 | renderers: Record = {}; 56 | 57 | egoCar: EgoCar | null = null; 58 | vehicles: any[] = []; 59 | robot: any = null; 60 | 61 | constructor() { 62 | this.renderers = { 63 | freespace: () => new Freespace(this.scene), 64 | cube: () => new Cube(this.scene, this.camera), 65 | text: () => new Text(this.scene), 66 | arrow: () => new Arrow(this.scene), 67 | polygonCylinder: () => new PolygonCylinder(this.scene), 68 | line: () => new Line(this.scene), 69 | }; 70 | } 71 | 72 | initialize() { 73 | const container = document.getElementById("my-canvas")!; 74 | const width = container.offsetWidth, 75 | height = container.offsetHeight; 76 | // @ts-ignore 77 | window.canvasRef = { 78 | container, 79 | width, 80 | height, 81 | }; 82 | this.dimensions = [width, height]; 83 | const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000); 84 | camera.up.set(0, 0, 1); 85 | camera.position.set(-4, -0.4, 1.4); 86 | this.camera = camera; 87 | const scene = this.scene; 88 | const renderer = new THREE.WebGLRenderer({ antialias: true }); 89 | renderer.setSize(width, height); 90 | this.renderer = renderer; 91 | // 设置背景色(颜色值,透明度) 92 | renderer.setClearColor(0x000000, 0.8); 93 | container.appendChild(renderer.domElement); 94 | const fakeCamera = camera.clone(); 95 | const controls = new OrbitControls(fakeCamera, renderer.domElement); 96 | this.fakeCamera = fakeCamera; 97 | this.resetCamera = this.fakeCamera.clone(); 98 | this.controls = controls; 99 | // light 100 | const ambient = new THREE.AmbientLight(0xffffff, 0.8); 101 | scene.add(ambient); 102 | // 平行光 103 | const directionalLight = new THREE.DirectionalLight(0xffffff, 1); 104 | // 设置光源的方向:通过光源position属性和目标指向对象的position属性计算 105 | directionalLight.position.set(60, 80, 40); 106 | // 方向光指向对象,默认指向是0,0,0 107 | // directionalLight.target = mesh; 108 | scene.add(directionalLight); 109 | // const dirLightHelper = new THREE.DirectionalLightHelper(directionalLight, 5,0xff0000); 110 | // scene.add(dirLightHelper); 111 | // const axes = new THREE.AxesHelper(0.6); 112 | // axes.position.z = 0.05; 113 | // this.scene.add(axes); 114 | // 50表示网格模型的尺寸大小,25表示纵横细分线条数量 115 | const gridHelper = new THREE.GridHelper(50, 20); 116 | gridHelper.rotateX(Math.PI / 2); 117 | scene.add(gridHelper); 118 | const egoCar = new EgoCar(scene, camera); 119 | this.camera.lookAt(egoCar.group.position); 120 | this.egoCar = egoCar; 121 | this.robot = new Robot(scene, renderer); 122 | this.registerDefaultEvents(); 123 | 124 | // // 屏幕相机 125 | // TODO 配合fakeCamera有点问题 126 | // const camera2 = new THREE.PerspectiveCamera(45, width / height, 0.01, 1000); 127 | // camera2.position.set(-10, -5, 4); 128 | // // camera2.lookAt(0, 0, 0); 129 | // camera2.up.set(0, 0, 1); 130 | // // 观察原有相机 131 | // const cameraHelper = new THREE.CameraHelper(camera); 132 | // scene.add(cameraHelper); 133 | // const controls = new OrbitControls(camera2, renderer.domElement); 134 | // this.controls = controls; 135 | 136 | // setTimeout(() => { 137 | // this.mockData(); 138 | // }, 5000); 139 | 140 | renderer.setAnimationLoop(this.animate); 141 | } 142 | 143 | // TODO 辅助自车模拟行驶 144 | startAutoDrive = false; 145 | totalDuration = 0; 146 | pathPoints: any[] = []; 147 | startTime = 0; 148 | currentIndex = 0; 149 | mockAutoDrive() { 150 | if (!this.startTime) this.startTime = performance.now(); 151 | const elapsed = performance.now() - this.startTime; 152 | const progress = Math.min(elapsed / this.totalDuration, 1); 153 | // 计算当前点索引 154 | this.currentIndex = Math.floor(progress * (this.pathPoints.length - 1)); 155 | if (this.currentIndex < this.pathPoints.length - 1) { 156 | const currentPoint = this.pathPoints[this.currentIndex]; 157 | const nextPoint = this.pathPoints[this.currentIndex + 1]; 158 | // console.log("===currentPoint", currentPoint); 159 | // TODO 坐标没对齐 160 | this.egoCar!.group.position.set(currentPoint.x, -currentPoint.y, 0); 161 | // this.egoCar!.group.position.copy(currentPoint); 162 | // // 更新位置 163 | // const newPos = new THREE.Vector3(); 164 | // newPos.x = currentPoint.x; 165 | // newPos.y = currentPoint.y; 166 | // this.egoCar!.group.position.copy(newPos); 167 | // 计算朝向 168 | const dx = nextPoint.x - currentPoint.x; 169 | const dy = nextPoint.y - currentPoint.y; 170 | this.egoCar!.group.rotation.z = -Math.PI / 2 + Math.atan2(dx, dy); 171 | } 172 | } 173 | mockVehiclesDrive() { 174 | this.vehicles.forEach((vehicle) => { 175 | if (!vehicle?.pathPoints || vehicle.pathPoints.length === 0) { 176 | return; 177 | } 178 | if (!vehicle.startTime) vehicle.startTime = performance.now(); 179 | const elapsed = performance.now() - vehicle.startTime; 180 | const progress = Math.min(elapsed / vehicle.totalDuration, 1); 181 | vehicle.currentIndex = Math.floor( 182 | progress * (vehicle.pathPoints.length - 1) 183 | ); 184 | if (vehicle.currentIndex < vehicle.pathPoints.length - 1) { 185 | const currentPoint = vehicle.pathPoints[vehicle.currentIndex]; 186 | const nextPoint = vehicle.pathPoints[vehicle.currentIndex + 1]; 187 | vehicle.position.set( 188 | currentPoint.x, 189 | -currentPoint.y, 190 | vehicle.position.z 191 | ); 192 | // 计算朝向 193 | const dx = nextPoint.x - currentPoint.x; 194 | const dy = nextPoint.y - currentPoint.y; 195 | vehicle.rotation.z = -Math.PI / 2 + Math.atan2(dx, dy); 196 | } 197 | }); 198 | } 199 | 200 | animate = () => { 201 | this.updateCamera(); 202 | // tweenGroup.update(); 203 | this.controls!.update(); 204 | if (this.startAutoDrive) { 205 | this.mockAutoDrive(); 206 | this.mockVehiclesDrive(); 207 | } 208 | this.renderer.render(this.scene, this.camera); 209 | }; 210 | 211 | updateCamera = () => { 212 | if (this.egoCar) { 213 | const position = this.egoCar.group.position; 214 | const rotation = this.egoCar.group.rotation; 215 | // 将 fakeCamera 的属性同步给 camera 216 | this.camera.copy(this.fakeCamera); 217 | const x = this.fakeCamera.position.x; 218 | const y = this.fakeCamera.position.y; 219 | // 相机和自车保持一个固定的偏移 220 | // camera.position.x = position.x + x; 221 | // camera.position.y = position.y + y; 222 | this.fakeCamera.getWorldDirection(fakeCameraDirection); 223 | const directionTheta = Math.atan2( 224 | fakeCameraDirection.y, 225 | fakeCameraDirection.x 226 | ); 227 | const camera2egocarDistance = Math.sqrt(x * x + y * y); 228 | this.camera.position.x = 229 | position.x - 230 | camera2egocarDistance * Math.cos(rotation.z + directionTheta); 231 | this.camera.position.y = 232 | position.y - 233 | camera2egocarDistance * Math.sin(rotation.z + directionTheta); 234 | this.camera.lookAt(position.x, position.y, position.z); 235 | } 236 | }; 237 | 238 | runEgoCar() { 239 | if (this.egoCar) { 240 | const animate2 = new Tween(this.egoCar.group.position) 241 | .to( 242 | { 243 | y: -0.5, 244 | }, 245 | 2000 246 | ) 247 | .start(); 248 | const animate = new Tween(this.egoCar.group.position) 249 | .delay(500) 250 | .to( 251 | { 252 | x: 10, 253 | }, 254 | 5000 255 | ) 256 | .easing(Easing.Quadratic.In) 257 | .start(); 258 | const rotationAnimate = new Tween(this.egoCar.group.rotation) 259 | .to( 260 | { 261 | z: -Math.PI / 4, 262 | }, 263 | 1200 264 | ) 265 | // .easing(Easing.Quadratic.InOut) 266 | .start() 267 | .onComplete(() => { 268 | const rotationAnimate2 = new Tween(this.egoCar!.group.rotation) 269 | .to( 270 | { 271 | z: 0, 272 | }, 273 | 1600 274 | ) 275 | // .easing(Easing.Quadratic.InOut) 276 | .start(); 277 | tweenGroup.add(rotationAnimate2); 278 | }); 279 | tweenGroup.add(animate, animate2, rotationAnimate); 280 | } 281 | } 282 | 283 | runOtherCar() { 284 | if (this.otherCar2) { 285 | const animate = new Tween(this.otherCar2.position) 286 | .delay(2000) 287 | .easing(Easing.Quadratic.InOut) 288 | .to( 289 | { 290 | x: 10, 291 | }, 292 | 5000 293 | ) 294 | .start(); 295 | tweenGroup.add(animate); 296 | } 297 | if (this.otherCar3) { 298 | const animate2 = new Tween(this.otherCar3.position) 299 | .delay(1500) 300 | .easing(Easing.Quadratic.InOut) 301 | .to( 302 | { 303 | x: 9.4, 304 | }, 305 | 6000 306 | ) 307 | .start(); 308 | tweenGroup.add(animate2); 309 | } 310 | if (this.otherCar4) { 311 | const animate3 = new Tween(this.otherCar4.position) 312 | .delay(800) 313 | .easing(Easing.Quadratic.InOut) 314 | .to( 315 | { 316 | x: 12, 317 | }, 318 | 6000 319 | ) 320 | .start(); 321 | tweenGroup.add(animate3); 322 | } 323 | } 324 | 325 | otherCar2: THREE.Mesh | null = null; 326 | otherCar3: THREE.Mesh | null = null; 327 | otherCar4: THREE.Mesh | null = null; 328 | mockData() { 329 | this.renderers.freespace().draw(freespaceData1); 330 | this.renderers.freespace().draw(freespaceData2); 331 | const otherCars = this.renderers 332 | .cube() 333 | .draw([cubeData1, cubeData2, cubeData3, cubeData4]); 334 | this.otherCar2 = otherCars[1]; 335 | this.otherCar3 = otherCars[2]; 336 | this.otherCar4 = otherCars[3]; 337 | // this.renderers.cube().draw(cubeData4); 338 | // this.renderers.arrow().draw(arrowData1); 339 | // this.renderers.polygonCylinder().draw(polygonCylinderData1); 340 | // this.renderers.polygonCylinder().draw(polygonCylinderData2); 341 | // this.renderers.polygonCylinder().draw(polygonCylinderData3); 342 | // this.renderers.polygonCylinder().draw(polygonCylinderData4); 343 | this.renderers.line().draw(lineData1); 344 | this.renderers.line().draw(lineData2); 345 | this.renderers.line().draw(lineData3); 346 | // this.renderers.line().draw(lineData4); 347 | this.runEgoCar(); 348 | this.runOtherCar(); 349 | } 350 | loadSceneData(data: ISceneData) { 351 | const { autoCar, map, scene } = data; 352 | const { path, speed } = autoCar; 353 | const { lines, lanes } = map; 354 | const { vehicles } = scene; 355 | // 这里按1/100比例换算下单位 356 | lines.forEach((line) => { 357 | line.points = line.points.map((point) => { 358 | return [point[0] / 50, point[1] / 50, 0]; 359 | }); 360 | line.width = line.width / 50; 361 | line.color = "yellow"; 362 | this.renderers.line().draw(line); 363 | }); 364 | lanes.forEach((lane) => { 365 | lane.contour = lane.contour.map((point) => { 366 | return { x: point.x / 50, y: point.y / 50, z: 0 }; 367 | }); 368 | this.renderers.freespace().draw(lane); 369 | }); 370 | vehicles.forEach((vehicle) => { 371 | vehicle.position = { 372 | x: vehicle.position.x / 50, 373 | y: vehicle.position.y / 50, 374 | z: vehicle.position.z / 50 + 0.2, 375 | }; 376 | vehicle.width = vehicle.width / 50; 377 | vehicle.height = 0.2; 378 | vehicle.length = vehicle.length / 50; 379 | const vehicleEle = this.renderers.cube().draw([vehicle])[0]; 380 | this.vehicles.push(vehicleEle); 381 | // 模拟行驶 382 | if (vehicle.path.length > 0) { 383 | const curve = new THREE.CatmullRomCurve3( 384 | vehicle.path.map((p) => new THREE.Vector3(p[0] / 50, -p[1] / 50, 0)), 385 | false // 闭合路径 386 | ); 387 | const totalLength = curve.getLength(); 388 | vehicleEle.totalDuration = (totalLength / vehicle.speed) * 1000; 389 | vehicleEle.pathPoints = curve.getPoints(5000); 390 | } 391 | }); 392 | // TODO 模拟行驶 393 | if (path.length > 0) { 394 | const curve = new THREE.CatmullRomCurve3( 395 | path.map((p) => new THREE.Vector3(p[0] / 50, -p[1] / 50, 0)), 396 | false // 闭合路径 397 | ); 398 | const totalLength = curve.getLength(); 399 | this.totalDuration = (totalLength / speed) * 1000; // 总时长(毫秒) 400 | this.pathPoints = curve.getPoints(5000); // 拆分为1000个点 401 | setTimeout(() => { 402 | this.startAutoDrive = true; 403 | }, 2000); 404 | } 405 | } 406 | 407 | registerDefaultEvents() { 408 | window.addEventListener("resize", this.onResize.bind(this), false); 409 | } 410 | unmountDefaultEvents() { 411 | window.removeEventListener("resize", this.onResize.bind(this), false); 412 | } 413 | onResize() { 414 | const container = document.getElementById("my-canvas")!; 415 | const width = container.offsetWidth, 416 | height = container.offsetHeight; 417 | // @ts-ignore 418 | window.canvasRef.width = width; 419 | // @ts-ignore 420 | window.canvasRef.height = height; 421 | this.camera.aspect = width / height; 422 | this.camera.updateProjectionMatrix(); 423 | this.renderer.setSize(width, height); 424 | } 425 | 426 | resetFakeCamera = () => { 427 | this.fakeCamera.copy(this.resetCamera); 428 | this.controls!.reset(); 429 | }; 430 | 431 | cameraView = EViewType.FollowCar; 432 | switchCameraView(view = EViewType.FollowCar) { 433 | // TODO 切换时有一个闪屏 434 | this.cameraView = view; 435 | switch (view) { 436 | case EViewType.FollowCar: { 437 | this.resetFakeCamera(); 438 | this.fakeCamera.position.set(-4, -0.4, 1.4); 439 | break; 440 | } 441 | case EViewType.Overlook: { 442 | this.resetFakeCamera(); 443 | this.fakeCamera.position.set(0, 0, 20); 444 | break; 445 | } 446 | case EViewType.OverlookVertical: { 447 | this.resetFakeCamera(); 448 | this.fakeCamera.position.set(0, 0, 20); 449 | // this.fakeCamera.rotation.x = -Math.PI / 2; 450 | this.controls.rotate(Math.PI / 2); 451 | break; 452 | } 453 | default: 454 | break; 455 | } 456 | } 457 | } 458 | 459 | export const myRenderer = new Renderer(); 460 | -------------------------------------------------------------------------------- /src/renderer/line.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-require-imports 4 | const getNormals = require("polyline-normals"); 5 | 6 | // 获取绝对距离,用于虚线和渐变线的实现 7 | function getPointsDistance(pt1: number[], pt2: number[]) { 8 | return Math.sqrt(Math.pow(pt1[0] - pt2[0], 2) + Math.pow(pt1[1] - pt2[1], 2)); 9 | } 10 | 11 | class Line { 12 | scene = new THREE.Scene(); 13 | 14 | constructor(scene: THREE.Scene) { 15 | this.scene = scene; 16 | } 17 | 18 | createGeometry(data: ILine, needDistance: boolean = false) { 19 | const { points } = data; 20 | const vertices: number[][] = []; 21 | const indices: number[] = []; 22 | const lineNormal: number[][] = []; 23 | const lineMiter: number[][] = []; 24 | const lineDistance: number[][] = []; 25 | const lineAllDistance: number[][] = []; 26 | // const uv: number[][] = []; 27 | const geometry = new THREE.BufferGeometry(); 28 | // 计算各个点的法向量 29 | const normalsByPolyline = getNormals(points); 30 | let indicesIdx = 0; 31 | let index = 0; 32 | let distance = 0; 33 | points.forEach((point, i, list) => { 34 | const idx = index; 35 | if (i !== points.length - 1) { 36 | // 添加索引以形成两个三角形 37 | indices[indicesIdx++] = idx + 0; 38 | indices[indicesIdx++] = idx + 1; 39 | indices[indicesIdx++] = idx + 2; 40 | indices[indicesIdx++] = idx + 2; 41 | indices[indicesIdx++] = idx + 1; 42 | indices[indicesIdx++] = idx + 3; 43 | } 44 | // 这里不用先计算,后面直接在shader里面借助GPU计算就行 45 | vertices.push(point); 46 | // uv.push([1, 0]); 47 | vertices.push(point); 48 | // uv.push([1, 0]); 49 | index = index + 2; 50 | if (needDistance) { 51 | let d = 0; 52 | if (i > 0) { 53 | d = getPointsDistance( 54 | [point[0], point[1]], 55 | [list[i - 1][0], list[i - 1][1]] 56 | ); 57 | } 58 | distance += d; 59 | lineDistance.push([distance], [distance]); 60 | } 61 | }); 62 | normalsByPolyline.forEach((item: any) => { 63 | const norm = item[0]; 64 | const miter = item[1]; 65 | lineNormal.push([norm[0], norm[1]], [norm[0], norm[1]]); 66 | lineMiter.push([-miter], [miter]); 67 | }); 68 | geometry.setAttribute( 69 | "position", 70 | new THREE.Float32BufferAttribute(vertices.flat(), 3) 71 | ); 72 | geometry.setAttribute( 73 | "lineNormal", 74 | new THREE.Float32BufferAttribute(lineNormal.flat(), 2) 75 | ); 76 | geometry.setAttribute( 77 | "lineMiter", 78 | new THREE.Float32BufferAttribute(lineMiter.flat(), 1) 79 | ); 80 | geometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1)); 81 | // geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uv.flat(), 2)); 82 | if (needDistance) { 83 | geometry.setAttribute( 84 | "lineDistance", 85 | new THREE.Float32BufferAttribute(lineDistance.flat(), 1) 86 | ); 87 | lineDistance.forEach(() => { 88 | lineAllDistance.push([distance]); 89 | }); 90 | geometry.setAttribute( 91 | "lineAllDistance", 92 | new THREE.Float32BufferAttribute(lineAllDistance.flat(), 1) 93 | ); 94 | } 95 | return geometry; 96 | } 97 | 98 | draw(data: ILine) { 99 | const { color = "#ffffff", width, type, endColor } = data; 100 | let geometry; 101 | let shader; 102 | switch (type) { 103 | case ELineType.Solid: { 104 | geometry = this.createGeometry(data); 105 | shader = getSolidLineShader({ 106 | width: width ?? 0.01, 107 | color: color, 108 | }); 109 | break; 110 | } 111 | case ELineType.Dash: { 112 | geometry = this.createGeometry(data, true); 113 | shader = getDashedLineShader({ 114 | width: width ?? 0.01, 115 | color: color, 116 | }); 117 | break; 118 | } 119 | case ELineType.Gradual: { 120 | geometry = this.createGeometry(data, true); 121 | shader = getGradientLineShader({ 122 | width: width ?? 0.01, 123 | color: color, 124 | endColor: endColor, 125 | }); 126 | break; 127 | } 128 | default: 129 | break; 130 | } 131 | const plane = new THREE.Mesh(geometry, shader); 132 | plane.position.z = 0.01; 133 | this.scene.add(plane); 134 | } 135 | } 136 | 137 | export default Line; 138 | 139 | export interface ILine { 140 | points: number[][]; // 点集 141 | width: number; 142 | type: ELineType; // 默认是实线 143 | color?: string; 144 | endColor?: string; // 渐变色,作为终点颜色,color是起点颜色 145 | opacity?: number; 146 | dashInfo?: { 147 | // 实线长度 148 | solidLength?: number; 149 | // 虚线长度 150 | dashLength?: number; 151 | }; 152 | } 153 | // export interface ILinePoint { 154 | // pos: IPos; 155 | // } 156 | export enum ELineType { 157 | Solid = 0, // 实线 158 | Dash = 1, // 虚线 159 | Gradual = 10, // 渐变线 160 | } 161 | 162 | export function getSolidLineShader(option: { 163 | width?: number; 164 | opacity?: number; 165 | color?: string; 166 | }) { 167 | const material = new THREE.ShaderMaterial({ 168 | uniforms: { 169 | thickness: { value: option.width ?? 0.1 }, 170 | opacity: { value: option.opacity ?? 1.0 }, 171 | diffuse: { 172 | value: option.color 173 | ? new THREE.Color(option.color) 174 | : new THREE.Color("#ffffff"), 175 | }, 176 | }, 177 | vertexShader: ` 178 | uniform float thickness; 179 | attribute float lineMiter; 180 | attribute vec2 lineNormal; 181 | void main() { 182 | // 沿着法线方向计算线段中点对应的两个顶点 183 | vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0); 184 | gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0); 185 | } 186 | `, 187 | fragmentShader: ` 188 | uniform vec3 diffuse; 189 | uniform float opacity; 190 | void main() { 191 | gl_FragColor = vec4(diffuse, opacity); 192 | } 193 | `, 194 | }); 195 | material.transparent = true; 196 | material.side = THREE.BackSide; 197 | return material; 198 | } 199 | 200 | // 用于画单色虚线 201 | export function getDashedLineShader(option: any = {}) { 202 | const material = new THREE.ShaderMaterial({ 203 | uniforms: { 204 | thickness: { value: option.width ?? 0.1 }, 205 | opacity: { value: option.opacity ?? 1.0 }, 206 | diffuse: { value: new THREE.Color(option.color) }, 207 | // 虚线部分的长度 208 | dashLength: { value: option?.dashInfo?.dashLength ?? 1.0 }, 209 | // 实线部分的长度 210 | solidLength: { value: option?.dashInfo?.solidLength ?? 2.0 }, 211 | }, 212 | vertexShader: ` 213 | uniform float thickness; 214 | attribute float lineMiter; 215 | attribute vec2 lineNormal; 216 | attribute float lineDistance; 217 | varying float lineU; 218 | 219 | void main() { 220 | // 累积距离 221 | lineU = lineDistance; 222 | vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0); 223 | gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0); 224 | } 225 | `, 226 | fragmentShader: ` 227 | varying float lineU; 228 | uniform vec3 diffuse; 229 | uniform float opacity; 230 | uniform float dashLength; 231 | uniform float solidLength; 232 | 233 | void main() { 234 | // 取模, 235 | float lineUMod = mod(lineU, dashLength + solidLength); 236 | // lineUMod>solidLength则返回0.0,说明在实线区域;否则返回1.0,说明在虚线区域 237 | float dash = 1.0 - step(solidLength, lineUMod); 238 | gl_FragColor = vec4(diffuse * vec3(dash), opacity * dash); 239 | } 240 | `, 241 | }); 242 | material.transparent = true; 243 | material.side = THREE.BackSide; 244 | return material; 245 | } 246 | 247 | // 渐变色 248 | export function getGradientLineShader(option: any = {}) { 249 | const material = new THREE.ShaderMaterial({ 250 | uniforms: { 251 | thickness: { value: option.width ?? 0.1 }, 252 | opacity: { value: option.opacity ?? 1.0 }, 253 | diffuse: { value: new THREE.Color(option.color) }, 254 | endColor: { value: new THREE.Color(option.endColor) }, 255 | }, 256 | vertexShader: ` 257 | uniform float thickness; 258 | attribute vec2 lineNormal; 259 | attribute float lineMiter; 260 | attribute float lineDistance; 261 | attribute float lineAllDistance; 262 | varying float lineU; 263 | varying float lineAll; 264 | 265 | void main() { 266 | lineU = lineDistance; 267 | lineAll = lineAllDistance; 268 | vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0); 269 | gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0); 270 | } 271 | `, 272 | fragmentShader: ` 273 | // 累积长度 274 | varying float lineU; 275 | varying float lineAll; 276 | uniform float opacity; 277 | uniform vec3 diffuse; 278 | uniform vec3 endColor; 279 | 280 | void main() { 281 | vec3 aColor = (1.0-lineU/lineAll)*(diffuse-endColor)+endColor; 282 | gl_FragColor =vec4(aColor, opacity); 283 | } 284 | `, 285 | }); 286 | material.transparent = true; 287 | material.side = THREE.DoubleSide; 288 | return material; 289 | } 290 | -------------------------------------------------------------------------------- /src/renderer/polygonCylinder.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IColor, IPos } from "../types/common"; 3 | 4 | export class PolygonCylinder { 5 | scene = new THREE.Scene(); 6 | 7 | constructor(scene: THREE.Scene) { 8 | this.scene = scene; 9 | } 10 | 11 | draw(data: IPolygonCylinder) { 12 | const { contour, height, color = { r: 0, g: 0, b: 0 } } = data; 13 | // 确保顶点顺序为逆时针 14 | if (THREE.ShapeUtils.isClockWise(contour)) { 15 | contour.reverse(); 16 | } 17 | const vertices: number[][] = []; 18 | const normals: number[][] = []; 19 | const indexes: number[] = []; 20 | // 索引辅助下标 21 | let indexesIndex = 0; 22 | // 总共的顶点数量 = 顶面顶点+底面顶点 23 | // 确定顶面 24 | for (let i = 0; i < contour.length; i++) { 25 | const current = contour[i]; 26 | vertices.push([current.x, current?.y + height, current.z]); 27 | normals.push([0, 1, 0]); 28 | // 设置顶面索引, 底面一般看不到, 所以可以不用设置索引 29 | // 三个点确定一个面, 注意按逆时针方向加入顶点索引 30 | if (i >= 2) { 31 | indexes[indexesIndex] = 0; 32 | indexes[indexesIndex + 1] = i - 1; 33 | indexes[indexesIndex + 2] = i; 34 | indexesIndex += 3; 35 | } 36 | } 37 | // 确定底面 38 | for (let i = 0; i < contour.length; i++) { 39 | const current = contour[i]; 40 | vertices.push([current.x, current.y, current.z]); 41 | normals.push([-1, 0, -1]); 42 | } 43 | // 确定侧面, 这里复用下上下面的顶点就行 44 | for (let topIndex = 0; topIndex < contour.length; topIndex++) { 45 | const bottomIndex = topIndex + contour.length; 46 | // 终点处理, 这里的topIndex+1==底面起点, bottomIndex就是底部终点 47 | if (bottomIndex + 1 === 2 * contour.length) { 48 | indexes[indexesIndex] = topIndex; 49 | indexes[indexesIndex + 1] = bottomIndex; 50 | indexes[indexesIndex + 2] = topIndex + 1; 51 | indexes[indexesIndex + 3] = topIndex + 1; 52 | indexes[indexesIndex + 4] = 0; 53 | indexes[indexesIndex + 5] = topIndex; 54 | } else { 55 | // 一个面对应俩个三角形 56 | indexes[indexesIndex] = topIndex; 57 | indexes[indexesIndex + 1] = bottomIndex; 58 | indexes[indexesIndex + 2] = bottomIndex + 1; 59 | indexes[indexesIndex + 3] = bottomIndex + 1; 60 | indexes[indexesIndex + 4] = topIndex + 1; 61 | indexes[indexesIndex + 5] = topIndex; 62 | } 63 | indexesIndex += 6; 64 | } 65 | // 设置缓冲几何体属性 66 | const geometry = new THREE.BufferGeometry(); 67 | geometry.setAttribute( 68 | "position", 69 | new THREE.Float32BufferAttribute(vertices.flat(), 3) 70 | ); 71 | // 自动计算法向量, 柱体结构不够清晰 72 | // geometry.computeVertexNormals(); 73 | geometry.setAttribute( 74 | "normal", 75 | new THREE.Float32BufferAttribute(normals.flat(), 3) 76 | ); 77 | geometry.index = new THREE.Uint16BufferAttribute(indexes, 1); 78 | const polygonMaterial = new THREE.MeshLambertMaterial({ 79 | transparent: true, 80 | opacity: 0.8, 81 | }); 82 | polygonMaterial.color.setRGB(color.r, color.g, color.b); 83 | const polygonMesh = new THREE.Mesh(geometry, polygonMaterial); 84 | this.scene.add(polygonMesh); 85 | } 86 | } 87 | export default PolygonCylinder; 88 | 89 | export interface IPolygonCylinder { 90 | id: string; 91 | // 顶点,只需要顶面几个顶点 92 | contour: IPos[]; 93 | // 高度 94 | height: number; 95 | color?: IColor; 96 | } 97 | -------------------------------------------------------------------------------- /src/renderer/roadMarker.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IColor, IPos } from "../types/common"; 3 | 4 | class RoadMarker { 5 | scene = new THREE.Scene(); 6 | 7 | constructor(scene: THREE.Scene) { 8 | this.scene = scene; 9 | } 10 | 11 | draw(data: IRoadMarker) { 12 | // 13 | } 14 | } 15 | 16 | export default RoadMarker; 17 | 18 | export interface IRoadMarker { 19 | id: number; 20 | points: IPos[]; // 点集 21 | position: IPos; // 中心点 22 | rotation: number; // 偏转角 23 | color: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/robot.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 3 | import * as THREE from "three"; 4 | import robotModel from "@/assets/models/robot.glb"; 5 | import { loadGLTFWithPromise } from "../helper"; 6 | import { SkeletonUtils } from "three/examples/jsm/Addons.js"; 7 | 8 | export default class Robot { 9 | scene = new THREE.Scene(); 10 | renderer = new THREE.WebGLRenderer(); 11 | skeleton: any = null; 12 | mixer: any = null; 13 | mixers: any[] = []; 14 | actions: any[] = []; 15 | clock = new THREE.Clock(); 16 | constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) { 17 | this.scene = scene; 18 | this.renderer = renderer; 19 | this.initialze(); 20 | } 21 | 22 | loadRobotModel() { 23 | let self = this; 24 | const loadEgoCar = loadGLTFWithPromise(robotModel); 25 | return loadEgoCar.then((gltf) => { 26 | const robot = gltf.scene; 27 | robot.scale.set(0.15, 0.15, 0.15); 28 | robot.position.set(0.5, -0.5, 0.02); 29 | robot.rotateX(Math.PI / 2); 30 | robot.rotateY(Math.PI / 2); 31 | const clips = gltf.animations; 32 | robot.traverse(function (object) { 33 | // @ts-ignore 34 | if (object.isMesh) object.castShadow = true; 35 | }); 36 | // const skeleton = new THREE.SkeletonHelper(robot); 37 | const model1 = SkeletonUtils.clone(robot); 38 | const model2 = SkeletonUtils.clone(robot); 39 | const model3 = SkeletonUtils.clone(robot); 40 | const mixer1 = new THREE.AnimationMixer(model1); 41 | const mixer2 = new THREE.AnimationMixer(model2); 42 | const mixer3 = new THREE.AnimationMixer(model3); 43 | model1.position.x = -1; 44 | model2.position.y = -1; 45 | model3.position.y = 1; 46 | mixer1.clipAction(clips[0]).play(); // idle 47 | mixer2.clipAction(clips[1]).play(); // run 48 | mixer3.clipAction(clips[3]).play(); // walk 49 | this.scene.add(model3, model2); 50 | this.mixers.push(mixer1, mixer2, mixer3); 51 | setInterval(() => { 52 | animate(); 53 | }, 50); 54 | function animate() { 55 | const delta = self.clock.getDelta(); 56 | for (const mixer of self.mixers) mixer.update(delta); 57 | } 58 | }); 59 | } 60 | 61 | activateAllActions() { 62 | // setWeight(idleAction, settings["modify idle weight"]); 63 | // setWeight(walkAction, settings["modify walk weight"]); 64 | // setWeight(runAction, settings["modify run weight"]); 65 | this.actions.forEach(function (action) { 66 | action.play(); 67 | }); 68 | } 69 | 70 | async initialze() { 71 | // await this.loadRobotModel(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/renderer/text.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IColor, IPos } from "../types/common"; 3 | import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js"; 4 | import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | let font: any = null; 8 | const fontLoader = new FontLoader(); 9 | fontLoader.load("gentilis_regular.typeface.json", (res) => { 10 | font = res; 11 | }); 12 | 13 | export function renderTextMesh(data: IText) { 14 | const { content, color = { r: 1, g: 1, b: 0 }, position, size = 0.06 } = data; 15 | const textGeo = new TextGeometry(content, { 16 | font, 17 | size, 18 | depth: 0.01, 19 | }); 20 | textGeo.computeBoundingBox(); 21 | const material = new THREE.MeshBasicMaterial(); 22 | material.color.setRGB(color.r, color.g, color.b); 23 | const centerOffset = 24 | -position.y * (textGeo.boundingBox!.max.y - textGeo.boundingBox!.min.y); 25 | const textMesh = new THREE.Mesh(textGeo, material); 26 | textMesh.position.set(position.x, position.y, position.z || 0); 27 | textMesh.rotateX(Math.PI / 2); 28 | textMesh.rotateY(-Math.PI / 2); 29 | return textMesh; 30 | } 31 | 32 | class Text { 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | scene = new THREE.Scene(); 35 | 36 | constructor(scene?: THREE.Scene) { 37 | if (scene) { 38 | this.scene = scene; 39 | } 40 | } 41 | 42 | draw(data: IText) { 43 | const textMesh = renderTextMesh(data); 44 | this.scene.add(textMesh); 45 | } 46 | } 47 | 48 | export default Text; 49 | 50 | export interface IText { 51 | id: string; 52 | // 字体大小 53 | position: IPos; 54 | content: string; 55 | size?: number; 56 | color?: IColor; 57 | } 58 | -------------------------------------------------------------------------------- /src/renderer/trafficLight.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IColor, IPos } from "../types/common"; 3 | 4 | class TrafficLight { 5 | scene = new THREE.Scene(); 6 | 7 | constructor(scene: THREE.Scene) { 8 | this.scene = scene; 9 | } 10 | 11 | draw(data: ITrafficLight) { 12 | // 13 | } 14 | } 15 | 16 | export default TrafficLight; 17 | 18 | export interface ITrafficLight { 19 | id: number; 20 | points: IPos[]; // 点集 21 | position: IPos; // 中心点 22 | rotation: number; // 偏转角 23 | color: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/trafficSign.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { IColor, IPos } from "../types/common"; 3 | 4 | class TrafficSign { 5 | scene = new THREE.Scene(); 6 | 7 | constructor(scene: THREE.Scene) { 8 | this.scene = scene; 9 | } 10 | 11 | draw(data: ITrafficSign) { 12 | // 13 | } 14 | } 15 | 16 | export default TrafficSign; 17 | 18 | export interface ITrafficSign { 19 | id: number; 20 | points: IPos[]; // 点集 21 | position: IPos; // 中心点 22 | rotation: number; // 偏转角 23 | color: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | export interface IPos { 2 | x: number; 3 | y: number; 4 | z: number; 5 | } 6 | 7 | export interface IColor { 8 | r: number; 9 | g: number; 10 | b: number; 11 | a?: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/renderer.ts: -------------------------------------------------------------------------------- 1 | export enum EViewType { 2 | FollowCar, 3 | Overlook, 4 | OverlookVertical, 5 | } 6 | -------------------------------------------------------------------------------- /src/views/autopilot/index.css: -------------------------------------------------------------------------------- 1 | #my-canvas { 2 | width: 100vw; 3 | height: 100vh; 4 | } 5 | 6 | /* 标签文本样式 */ 7 | .label-box { 8 | display: block; 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | padding: 2px; 13 | color: #fff; 14 | font-size: 10px; 15 | border-radius: 2px; 16 | background-color: rgba(0, 0, 0, 0.6); 17 | } 18 | 19 | .monitor { 20 | position: absolute; 21 | right: 0px; 22 | top: 0px; 23 | width: 200px; 24 | height: 50px; 25 | } 26 | 27 | .fps { 28 | position: absolute; 29 | right: 0px; 30 | top: 0px; 31 | width: 80px; 32 | height: 100%; 33 | } 34 | -------------------------------------------------------------------------------- /src/views/autopilot/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import Stats from "stats.js"; 3 | import { myRenderer } from "../../renderer"; 4 | import { Overlay } from "../../components/overlay"; 5 | import "./index.css"; 6 | import { ISceneData } from "../scene-editor/store/type"; 7 | 8 | function Autopilot() { 9 | const statsRef = useRef(null); 10 | const containerRef = useRef(null); 11 | 12 | useEffect(() => { 13 | const stats = new Stats(); 14 | stats.showPanel(0); 15 | statsRef.current = stats; 16 | containerRef.current.appendChild(stats.dom); 17 | const animate = () => { 18 | stats.begin(); 19 | stats.end(); 20 | requestAnimationFrame(animate); 21 | }; 22 | animate(); 23 | return () => { 24 | containerRef.current?.removeChild(stats.dom); 25 | }; 26 | }, []); 27 | 28 | useEffect(() => { 29 | myRenderer.initialize(); 30 | }, []); 31 | 32 | useEffect(() => { 33 | try { 34 | const sceneData = localStorage.getItem("sceneData"); 35 | if (sceneData) { 36 | return; 37 | } 38 | const data = JSON.parse(sceneData || "{}") as ISceneData; 39 | console.log("===sceneData===", data); 40 | myRenderer.loadSceneData(data); 41 | } catch (err) { 42 | console.log(err); 43 | } 44 | }, []); 45 | 46 | return ( 47 | <> 48 |
49 | 50 |
51 |
52 |
53 | 54 | ); 55 | } 56 | 57 | export default Autopilot; 58 | -------------------------------------------------------------------------------- /src/views/scene-editor/components/header/index.css: -------------------------------------------------------------------------------- 1 | .scene-editor-header { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100vw; 6 | display: flex; 7 | align-items: center; 8 | padding: 0 16px; 9 | height: 48px; 10 | color: #fff; 11 | background-color: #333; 12 | gap: 64px; 13 | z-index: 9; 14 | box-sizing: border-box; 15 | border-bottom: 1px solid #444; 16 | } 17 | .scene-editor-header .title { 18 | font-size: 16px; 19 | font-weight: 500; 20 | color: #fff; 21 | } 22 | 23 | .scene-editor-header-right { 24 | display: flex; 25 | align-items: center; 26 | gap: 16px; 27 | color: #fff; 28 | } 29 | 30 | .scene-editor-header-right .ant-radio-wrapper { 31 | color: #fff; 32 | } 33 | 34 | .scene-editor-header-right .btns { 35 | display: flex; 36 | align-items: center; 37 | gap: 8px; 38 | } 39 | 40 | .scene-editor-header .ant-btn-default:disabled { 41 | color: #ccc; 42 | } 43 | -------------------------------------------------------------------------------- /src/views/scene-editor/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Divider, Radio } from "antd"; 2 | import { useState } from "react"; 3 | import { observer } from "mobx-react-lite"; 4 | import { editorStore } from "../../store"; 5 | import { EditMode, EMapElement, ESceneElement } from "../../store/type"; 6 | import "./index.css"; 7 | 8 | export const Header = observer(() => { 9 | const { editMode, selectedElement } = editorStore; 10 | 11 | const changeEditMode = (e: any) => { 12 | editorStore.editMode = e.target.value; 13 | }; 14 | 15 | const addMapElement = (type: EMapElement) => { 16 | editorStore.drawCallForMap(type); 17 | }; 18 | const addSceneElement = (type: ESceneElement) => { 19 | editorStore.drawCallForScene(type); 20 | }; 21 | 22 | const saveFile = () => { 23 | editorStore.saveFile(); 24 | }; 25 | 26 | const sim = () => { 27 | location.href = "http://localhost:5173"; 28 | // window.open("http://localhost:5173", "_blank"); 29 | }; 30 | 31 | const drawPath = () => { 32 | editorStore.drawCallForScene(ESceneElement.Path); 33 | }; 34 | 35 | return ( 36 |
37 |
38 |
Scene Editor
39 |
40 |
41 | 50 | 51 | 54 | {editMode === EditMode.Map ? ( 55 |
56 | 57 | 60 | 66 | 67 | 68 |
69 | ) : ( 70 |
71 | 74 | 80 |
81 | )} 82 | 83 | 84 |
85 |
86 | ); 87 | }); 88 | -------------------------------------------------------------------------------- /src/views/scene-editor/components/overlay/index.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | pointer-events: none; 8 | z-index: 10; 9 | } 10 | 11 | .overlay-tr { 12 | position: absolute; 13 | padding: 16px; 14 | right: 300px; 15 | top: 48px; 16 | display: flex; 17 | align-items: center; 18 | gap: 8px; 19 | pointer-events: all; 20 | } 21 | 22 | /* .icon { 23 | color: #fff; 24 | font-size: 16px; 25 | padding: 8px; 26 | background-color: #ccc; 27 | } */ 28 | -------------------------------------------------------------------------------- /src/views/scene-editor/components/overlay/index.tsx: -------------------------------------------------------------------------------- 1 | import { AimOutlined } from "@ant-design/icons"; 2 | import "./index.css"; 3 | import { Button } from "antd"; 4 | import { editorStore } from "../../store"; 5 | 6 | export const Overlay = () => { 7 | const { stage } = editorStore; 8 | 9 | const focusStage = () => { 10 | editorStore.focusOrigin(); 11 | }; 12 | 13 | return ( 14 |
15 |
16 | 20 |
21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/views/scene-editor/components/right-sider/index.css: -------------------------------------------------------------------------------- 1 | .scene-editor-right-sider { 2 | position: absolute; 3 | top: 48px; 4 | right: 0; 5 | width: 300px; 6 | height: calc(100vh - 48px); 7 | background-color: #333; 8 | z-index: 9; 9 | color: #fff; 10 | } 11 | 12 | .scene-editor-right-sider .title { 13 | font-size: 16px; 14 | font-weight: 500; 15 | color: #fff; 16 | padding: 8px; 17 | } 18 | 19 | .scene-editor-right-sider .content { 20 | padding: 8px 0; 21 | } 22 | 23 | .scene-editor-right-sider .ant-form-item .ant-form-item-label > label { 24 | color: #ccc; 25 | } 26 | 27 | .scene-editor-right-sider .ant-input-outlined[disabled] { 28 | color: #ccc; 29 | } 30 | -------------------------------------------------------------------------------- /src/views/scene-editor/components/right-sider/index.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Input, InputNumber } from "antd"; 2 | import { observer } from "mobx-react-lite"; 3 | import { useEffect } from "react"; 4 | import { editorStore } from "../../store"; 5 | import "./index.css"; 6 | 7 | export const RightSider = observer(() => { 8 | const { selectedElement } = editorStore; 9 | const [form] = Form.useForm(); 10 | 11 | const onFormChange = (values: any) => { 12 | console.log("==values", values); 13 | const key = Object.keys(values)[0]; 14 | const value = values[key]; 15 | selectedElement?.setAttr(key, value); 16 | }; 17 | 18 | useEffect(() => { 19 | if (selectedElement) { 20 | form.setFieldsValue({ 21 | name: selectedElement.name(), 22 | height: 2, 23 | length: selectedElement.height, 24 | width: selectedElement.width(), 25 | speed: selectedElement.getAttr("speed"), 26 | x: selectedElement.x(), 27 | y: selectedElement.y(), 28 | }); 29 | } else { 30 | form.resetFields(); 31 | } 32 | }, [selectedElement]); 33 | 34 | return ( 35 |
36 |
属性面板
37 |
38 |
47 | 52 | 53 | 54 | {/* 59 | 60 | */} 61 | 66 | 67 | 68 | 73 | 74 | 75 | 80 | 81 | 82 | 87 | 88 | 89 | 94 | 95 | 96 | 101 | 102 | 103 | {/* 104 | Remember me 105 | */} 106 |
107 |
108 |
109 | ); 110 | }); 111 | -------------------------------------------------------------------------------- /src/views/scene-editor/index.css: -------------------------------------------------------------------------------- 1 | .scene-editor { 2 | height: 100vh; 3 | width: 100vw; 4 | background-color: #000; 5 | } 6 | 7 | .scene-editor-canvas { 8 | position: absolute; 9 | top: 48px; 10 | left: 0; 11 | width: calc(100vw - 300px); 12 | height: calc(100vh - 48px); 13 | z-index: 9; 14 | } 15 | -------------------------------------------------------------------------------- /src/views/scene-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import Konva from "konva"; 3 | import "./index.css"; 4 | import { 5 | createCircle, 6 | createLine, 7 | createRect, 8 | createTriangle, 9 | } from "./renderer"; 10 | import { Header } from "./components/header"; 11 | import { RightSider } from "./components/right-sider"; 12 | import { observer } from "mobx-react-lite"; 13 | import { editorStore } from "./store"; 14 | import { Overlay } from "./components/overlay"; 15 | 16 | const MIN_SCALE = 0.5; // 最小缩放比例 17 | const MAX_SCALE = 5; // 最大缩放比例 18 | 19 | const SceneEditor = observer(() => { 20 | const { stage } = editorStore; 21 | const containerRef = useRef(null); 22 | const [shapes, setShapes] = useState([]); 23 | const [selectedId, setSelectedId] = useState(); 24 | 25 | // TODO 地图和元素分层 26 | 27 | const mock = () => { 28 | const layer = stage.ref!.getLayers()[0]; 29 | // 创建图形 30 | const shapes = [ 31 | createRect({ x: 100, y: 100, fill: "#FF6B6B" }), 32 | createCircle({ x: 300, y: 150, strokeWidth: 4 }), 33 | createTriangle({ x: 700, y: 180, rotation: 45 }), 34 | createLine([50, 400, 750, 400], { stroke: "#45B7D1" }), 35 | ]; 36 | // 添加到图层 37 | shapes.forEach((shape) => { 38 | layer.add(shape); 39 | }); 40 | layer.batchDraw(); 41 | }; 42 | 43 | // 初始化画布 44 | useEffect(() => { 45 | if (containerRef.current) { 46 | const container = containerRef.current; 47 | stage.ref = new Konva.Stage({ 48 | container: containerRef.current, 49 | width: container.clientWidth, 50 | height: container.clientHeight, 51 | draggable: true, 52 | }); 53 | // 初始化图层 54 | const layer = new Konva.Layer(); 55 | stage.ref.add(layer); 56 | editorStore.initStage(); 57 | // mock(); 58 | const resizeStage = () => { 59 | stage.ref!.width(container.clientWidth); 60 | stage.ref!.height(container.clientHeight); 61 | stage.ref!.batchDraw(); 62 | }; 63 | // 监听窗口变化 64 | window.addEventListener("resize", resizeStage); 65 | // 监听缩放 66 | stage.ref.on("wheel", (e) => { 67 | e.evt.preventDefault(); 68 | handleWheel(e.evt); 69 | }); 70 | return () => { 71 | stage.ref?.destroy(); 72 | window.removeEventListener("resize", resizeStage); 73 | }; 74 | } 75 | }, []); 76 | 77 | // const [scale, setScale] = useState(1); 78 | const handleWheel = (e: any) => { 79 | const stageRef = stage.ref!; 80 | // const newScale = e.deltaY < 0 ? scale * 1.1 : scale / 1.1; 81 | // setScale(Math.min(Math.max(newScale, MIN_SCALE), MAX_SCALE)); 82 | // if (stageRef.current) { 83 | // stageRef.current.scale({ x: newScale, y: newScale }); 84 | // stageRef.current.batchDraw(); 85 | // } 86 | const step = 1.1; 87 | const oldScale = stageRef.scaleX(); 88 | const pointer = stageRef.getPointerPosition()!; 89 | let newScale = e.deltaY < 0 ? oldScale * step : oldScale / step; 90 | newScale = Math.min(Math.max(newScale, MIN_SCALE), MAX_SCALE); 91 | const mousePointTo = { 92 | x: (pointer.x - stageRef.x()) / oldScale, 93 | y: (pointer.y - stageRef.y()) / oldScale, 94 | }; 95 | stageRef.scale({ x: newScale, y: newScale }); 96 | const newPos = { 97 | x: pointer.x - mousePointTo.x * newScale, 98 | y: pointer.y - mousePointTo.y * newScale, 99 | }; 100 | stageRef.position(newPos); 101 | stageRef.batchDraw(); 102 | }; 103 | 104 | return ( 105 |
106 |
107 |
108 | 109 | 110 | {/* */} 111 |
112 | ); 113 | }); 114 | 115 | export default SceneEditor; 116 | -------------------------------------------------------------------------------- /src/views/scene-editor/renderer/base.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | 3 | type ShapeType = "rect" | "circle" | "line" | "polygon" | "triangle"; 4 | 5 | /** 6 | * 图形基类封装 7 | * @param {string} type 图形类型 8 | * @param {Object} config 配置参数 9 | */ 10 | export function createShape(type: ShapeType, config: any) { 11 | const defaults = { 12 | x: 0, 13 | y: 0, 14 | fill: Konva.Util.getRandomColor(), 15 | draggable: true, 16 | stroke: "#333", 17 | strokeWidth: 2, 18 | }; 19 | 20 | const shapeConfig = { ...defaults, ...config }; 21 | let shape: Konva.Shape; 22 | 23 | switch (type) { 24 | case "rect": 25 | shape = new Konva.Rect(shapeConfig); 26 | break; 27 | case "circle": 28 | shape = new Konva.Circle({ 29 | radius: 50, 30 | ...shapeConfig, 31 | }); 32 | break; 33 | case "line": 34 | shape = new Konva.Line({ 35 | points: [0, 0, 100, 100], // 默认对角线 36 | lineCap: "round", 37 | ...shapeConfig, 38 | }); 39 | break; 40 | case "polygon": 41 | shape = new Konva.RegularPolygon({ 42 | sides: 5, // 默认五边形 43 | radius: 60, 44 | ...shapeConfig, 45 | }); 46 | break; 47 | case "triangle": 48 | shape = new Konva.Shape({ 49 | sceneFunc: function (context: Konva.Context, shape: Konva.Shape) { 50 | context.beginPath(); 51 | context.moveTo(0, -30); 52 | context.lineTo(30, 30); 53 | context.lineTo(-30, 30); 54 | context.closePath(); 55 | context.fillStrokeShape(shape); 56 | }, 57 | ...shapeConfig, 58 | }); 59 | break; 60 | } 61 | 62 | // 添加通用事件处理[8](@ref) 63 | shape!.on("click", () => console.log(`${type} clicked`)); 64 | shape!.on("dragend", () => console.log(`${type} moved`)); 65 | 66 | return shape!; 67 | } 68 | -------------------------------------------------------------------------------- /src/views/scene-editor/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { createShape } from "./base"; 3 | 4 | export function createRect(config: Konva.RectConfig) { 5 | return createShape("rect", { 6 | width: 100, 7 | height: 80, 8 | // cornerRadius: 5, // 圆角支持 9 | ...config, 10 | }); 11 | } 12 | 13 | export function createCircle(config: Konva.CircleConfig) { 14 | return createShape("circle", { 15 | radius: config?.radius || 50, 16 | ...config, 17 | }); 18 | } 19 | 20 | export function createTriangle(config: Konva.ShapeConfig) { 21 | return createShape("triangle", { 22 | offset: { x: 0, y: -15 }, // 居中调整 23 | ...config, 24 | }); 25 | } 26 | 27 | export function createLine(points: number[], config: Konva.LineConfig) { 28 | return createShape("line", { 29 | points, 30 | dash: config?.dashed ? [10, 5] : null, // 虚线支持 31 | ...config, 32 | }); 33 | } 34 | 35 | // TODO: 多边形支持 36 | // export function createPolygon(sides = 5, config: Konva.RegularPolygonConfig) { 37 | // return createShape("polygon", { 38 | // sides, 39 | // radius: sides * 10, // 动态计算半径 40 | // ...config, 41 | // }); 42 | // } 43 | 44 | // TODO: 组合图形支持 45 | // function createCompositeShape() { 46 | // const group = new Konva.Group({ draggable: true }); 47 | // group.add(createRect({ width: 200, height: 120 })); 48 | // group.add(createLine([20,60, 180,60], { stroke: '#FFF' })); 49 | // return group; 50 | // } 51 | 52 | // TODO: 动画支持 53 | // function createAnimatedCircle() { 54 | // const circle = createCircle(); 55 | // new Konva.Tween({ 56 | // node: circle, 57 | // duration: 2, 58 | // scaleX: 1.2, 59 | // scaleY: 1.2, 60 | // easing: Konva.Easings.EaseInOut, 61 | // yoyo: true 62 | // }).play(); 63 | // return circle; 64 | // } 65 | -------------------------------------------------------------------------------- /src/views/scene-editor/store/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import Konva from "konva"; 3 | import { makeObservable, observable, action } from "mobx"; 4 | import { createCircle, createRect } from "../renderer"; 5 | import { 6 | EditMode, 7 | EMapElement, 8 | ESceneElement, 9 | IAutoCar, 10 | IMapElements, 11 | ISceneData, 12 | ISceneElements, 13 | } from "./type"; 14 | import { ELineType, ILine } from "../../../renderer/line"; 15 | import _ from "lodash"; 16 | import { IFreespace } from "../../../renderer/freespace"; 17 | 18 | class EditorStore { 19 | stage: IStage = { 20 | ref: null, 21 | }; 22 | autoCar: IAutoCar = { 23 | ref: null, 24 | path: [], 25 | pathLine: null, 26 | speed: 0, 27 | }; 28 | // 当前地图偏移值 29 | offset = { x: 0, y: 0 }; 30 | focusOrigin = () => { 31 | if (this.stage.ref) { 32 | // 清除拖拽产生的位移 33 | this.stage.ref.x(0); 34 | this.stage.ref.y(0); 35 | // // 设置缩放比例为5倍 36 | // const scale = 5; 37 | // this.stage.ref.scale({ x: scale, y: scale }); 38 | // 计算缩放后的中心偏移量(需除以缩放比例) 39 | const centerX = -this.stage.ref.width() / 2; 40 | const centerY = -this.stage.ref.height() / 2; 41 | this.offset = { x: centerX, y: centerY }; 42 | this.stage.ref.offset(this.offset); 43 | } 44 | }; 45 | editMode = EditMode.Map; 46 | isEdit = false; 47 | isDrawMapElement: EMapElement | null = null; 48 | // 车道辅助绘制 49 | drawLaneCount = 0; 50 | // 当前车道 51 | currentLane: Konva.Shape | null = null; 52 | // 当前正在绘制的场景元素 53 | isDrawSceneElement: ESceneElement | null = null; 54 | // 绘制结束的钩子,在这里保存数据 55 | drawDone = (type: EMapElement | ESceneElement) => { 56 | const layer = this.stage.ref!.getLayers()[0]; 57 | if (type === EMapElement.Line) { 58 | const data: ILine = { 59 | points: _.chunk(this.currentLine!.points(), 2), 60 | width: 1, 61 | type: ELineType.Solid, 62 | }; 63 | this.mapElements.lines.push(data); 64 | this.isDrawMapElement = null; 65 | this.currentLine = null; 66 | } else if (type === EMapElement.Lane) { 67 | const contour = _.chunk(this.currentLane?.getAttr("points"), 2).map( 68 | (item) => 69 | ({ 70 | x: item[0], 71 | y: item[1], 72 | z: 0, 73 | } as { x: number; y: number; z: number }) 74 | ); 75 | const data: IFreespace = { 76 | id: "freespace" + this.mapElements.lanes.length, 77 | contour, 78 | }; 79 | this.mapElements.lanes.push(data); 80 | this.currentLine?.destroy(); 81 | this.currentLane = null; 82 | this.currentLine = null; 83 | this.drawLaneCount = 0; 84 | } else if (type === ESceneElement.Vehicle) { 85 | this.isDrawSceneElement = null; 86 | } else if (type === ESceneElement.Path) { 87 | if (this.selectedElement?.name() === "autoCar") { 88 | this.autoCar.pathLine = this.currentLine; 89 | } else if (this.selectedElement?.getAttr("vehicleType") === "CAR") { 90 | // TODO 有点绕 91 | this.selectedElement.getAttr("userData").path = _.chunk( 92 | this.currentLine?.points(), 93 | 2 94 | ); 95 | } 96 | this.currentLine = null; 97 | this.isDrawSceneElement = null; 98 | } 99 | layer.batchDraw(); 100 | this.isEdit = false; 101 | }; 102 | drawCallForScene = (type: ESceneElement) => { 103 | this.isEdit = true; 104 | switch (type) { 105 | case ESceneElement.Vehicle: { 106 | this.isDrawSceneElement = ESceneElement.Vehicle; 107 | break; 108 | } 109 | case ESceneElement.Path: { 110 | this.isDrawSceneElement = ESceneElement.Path; 111 | break; 112 | } 113 | default: { 114 | break; 115 | } 116 | } 117 | }; 118 | drawCallForMap = (type: EMapElement) => { 119 | this.isEdit = true; 120 | switch (type) { 121 | case EMapElement.Line: { 122 | this.isDrawMapElement = EMapElement.Line; 123 | break; 124 | } 125 | case EMapElement.Lane: { 126 | this.isDrawMapElement = EMapElement.Lane; 127 | break; 128 | } 129 | default: { 130 | break; 131 | } 132 | } 133 | this.isEdit = false; 134 | }; 135 | transformer: Konva.Transformer | null = null; 136 | currentLine: Konva.Line | null = null; 137 | initStage = () => { 138 | const stage = this.stage.ref!; 139 | const layer = stage.getLayers()[0]; 140 | // 绘制原点 141 | const origin = createCircle({ 142 | fill: "red", 143 | radius: 4, 144 | x: 0, 145 | y: 0, 146 | strokeWidth: 1, 147 | }); 148 | layer.add(origin); 149 | // 绘制自车 150 | const autoCar = createRect({ 151 | name: "autoCar", 152 | fill: "green", 153 | x: 0, 154 | y: 0, 155 | width: 30, 156 | height: 20, 157 | offsetX: 15, 158 | offsetY: 10, 159 | strokeWidth: 1, 160 | }); 161 | autoCar.setAttr("speed", 0.8); 162 | layer.add(autoCar); 163 | this.autoCar.ref = autoCar; 164 | this.focusOrigin(); 165 | // 初始化元素控制器 166 | this.transformer = new Konva.Transformer({ 167 | rotateEnabled: true, // 启用旋转 168 | rotationSnaps: [0, 90, 180, 270], // 设置旋转吸附角度 169 | }); 170 | stage.batchDraw(); 171 | layer.add(this.transformer); 172 | stage.on("mousedown", () => { 173 | const pos = stage.getPointerPosition()!; 174 | // TODO 这个偏移量每次都要计算...有点别扭,估计是我用法不对 175 | const pointX = stage.offset().x + pos.x; 176 | const pointY = stage.offset().y + pos.y; 177 | if (this.isDrawMapElement === EMapElement.Line) { 178 | if (!this.currentLine) { 179 | this.currentLine = new Konva.Line({ 180 | points: [pointX, pointY], 181 | fill: "yellow", 182 | stroke: "yellow", 183 | strokeWidth: 2, 184 | }); 185 | } else { 186 | this.currentLine.points().push(pointX, pointY); 187 | } 188 | layer.add(this.currentLine); 189 | } else if (this.isDrawMapElement === EMapElement.Lane) { 190 | if (this.drawLaneCount === 0) { 191 | this.currentLine = new Konva.Line({ 192 | points: [pointX, pointY], 193 | fill: "yellow", 194 | stroke: "yellow", 195 | strokeWidth: 2, 196 | }); 197 | layer.add(this.currentLine); 198 | } else if (this.drawLaneCount === 1) { 199 | this.currentLine!.points().push(pointX, pointY); 200 | } else if (this.drawLaneCount === 2) { 201 | this.currentLane?.destroy(); 202 | const points = [ 203 | this.currentLine!.points()[0], 204 | this.currentLine!.points()[1], 205 | this.currentLine!.points()[2], 206 | this.currentLine!.points()[3], 207 | pointX, 208 | pointY, 209 | pointX - 210 | (this.currentLine!.points()[2] - this.currentLine!.points()[0]), 211 | pointY - 212 | (this.currentLine!.points()[3] - this.currentLine!.points()[1]), 213 | ]; 214 | this.currentLane = new Konva.Shape({ 215 | // 顶点坐标数组 216 | points, 217 | fill: "yellow", 218 | stroke: "green", 219 | opacity: 0.2, 220 | strokeWidth: 2, 221 | sceneFunc: function (ctx, shape) { 222 | const points = shape.getAttr("points"); 223 | ctx.beginPath(); 224 | ctx.moveTo(points[0], points[1]); 225 | for (let i = 2; i < points.length; i += 2) { 226 | ctx.lineTo(points[i], points[i + 1]); 227 | } 228 | ctx.closePath(); 229 | ctx.fillStrokeShape(shape); 230 | }, 231 | }); 232 | layer.add(this.currentLane); 233 | this.drawDone(EMapElement.Lane); 234 | } 235 | this.drawLaneCount++; 236 | } else if (this.isDrawSceneElement === ESceneElement.Path) { 237 | if (!this.currentLine) { 238 | this.currentLine = new Konva.Line({ 239 | points: [this.selectedElement!.x(), this.selectedElement!.y()], 240 | fill: "#fff", 241 | stroke: "#fff", 242 | strokeWidth: 1, 243 | }); 244 | } else { 245 | this.currentLine.points().push(pointX, pointY); 246 | } 247 | layer.add(this.currentLine); 248 | } 249 | }); 250 | stage.on("mousemove", () => { 251 | if (this.currentLine && this.isDrawMapElement === EMapElement.Line) { 252 | const pos = stage.getPointerPosition()!; 253 | const pointX = stage.offset().x + pos.x; 254 | const pointY = stage.offset().y + pos.y; 255 | let newPoints = []; 256 | if (this.currentLine.points().length > 2) { 257 | newPoints = this.currentLine 258 | .points() 259 | .slice(0, -2) 260 | .concat([pointX, pointY]); 261 | } else { 262 | newPoints = this.currentLine.points().concat([pointX, pointY]); 263 | } 264 | this.currentLine.points(newPoints); 265 | layer.batchDraw(); 266 | } 267 | if (this.currentLine && this.isDrawMapElement === EMapElement.Lane) { 268 | const pos = stage.getPointerPosition()!; 269 | const pointX = stage.offset().x + pos.x; 270 | const pointY = stage.offset().y + pos.y; 271 | let newPoints = []; 272 | if (this.drawLaneCount === 1) { 273 | if (this.currentLine.points().length > 2) { 274 | newPoints = this.currentLine 275 | .points() 276 | .slice(0, -2) 277 | .concat([pointX, pointY]); 278 | } else { 279 | newPoints = this.currentLine.points().concat([pointX, pointY]); 280 | } 281 | this.currentLine.points(newPoints); 282 | layer.batchDraw(); 283 | } else if (this.drawLaneCount === 2) { 284 | this.currentLane?.destroy(); 285 | const points = [ 286 | this.currentLine!.points()[0], 287 | this.currentLine!.points()[1], 288 | this.currentLine!.points()[2], 289 | this.currentLine!.points()[3], 290 | pointX, 291 | pointY, 292 | pointX - 293 | (this.currentLine!.points()[2] - this.currentLine!.points()[0]), 294 | pointY - 295 | (this.currentLine!.points()[3] - this.currentLine!.points()[1]), 296 | ]; 297 | this.currentLane = new Konva.Shape({ 298 | points, 299 | fill: "yellow", 300 | stroke: "green", 301 | opacity: 0.2, 302 | strokeWidth: 2, 303 | sceneFunc: function (ctx, shape) { 304 | const points = shape.getAttr("points"); 305 | ctx.beginPath(); 306 | ctx.moveTo(points[0], points[1]); 307 | for (let i = 2; i < points.length; i += 2) { 308 | ctx.lineTo(points[i], points[i + 1]); 309 | } 310 | ctx.closePath(); 311 | ctx.fillStrokeShape(shape); 312 | }, 313 | }); 314 | layer.add(this.currentLane); 315 | } 316 | } 317 | if (this.currentLine && this.isDrawSceneElement === ESceneElement.Path) { 318 | const pos = stage.getPointerPosition()!; 319 | const pointX = stage.offset().x + pos.x; 320 | const pointY = stage.offset().y + pos.y; 321 | let newPoints = []; 322 | if (this.currentLine.points().length > 2) { 323 | newPoints = this.currentLine 324 | .points() 325 | .slice(0, -2) 326 | .concat([pointX, pointY]); 327 | } else { 328 | newPoints = this.currentLine.points().concat([pointX, pointY]); 329 | } 330 | this.currentLine.points(newPoints); 331 | layer.batchDraw(); 332 | } 333 | }); 334 | stage.on("click", (e) => { 335 | if (e.target === stage) { 336 | if (this.isDrawSceneElement === ESceneElement.Path) { 337 | return; 338 | } 339 | this.transformer!.nodes([]); 340 | this.selectedElement = null; 341 | if (this.isDrawSceneElement === ESceneElement.Vehicle) { 342 | const pos = stage.getPointerPosition()!; 343 | const pointX = stage.offset().x + pos.x; 344 | const pointY = stage.offset().y + pos.y; 345 | const vehicle = createRect({ 346 | name: "vehicle" + this.sceneElements.vehicles.length, 347 | fill: "blue", 348 | x: pointX, 349 | y: pointY, 350 | width: 30, 351 | height: 20, 352 | strokeWidth: 1, 353 | offsetX: 15, 354 | offsetY: 10, 355 | }); 356 | layer.add(vehicle); 357 | vehicle.setAttr("speed", 0.8); 358 | vehicle.setAttr("type", "CAR"); 359 | const vehicleObj = { 360 | id: "vehicle" + this.sceneElements.vehicles.length, 361 | // TODO not work hear 362 | type: "CAR", 363 | position: { 364 | x: vehicle.x(), 365 | y: vehicle.y(), 366 | z: 0, 367 | }, 368 | color: { 369 | r: 0, 370 | g: 0, 371 | b: 1, 372 | }, 373 | width: vehicle.width(), 374 | length: vehicle.height(), 375 | height: 2, 376 | speed: vehicle.getAttr("speed"), 377 | path: [], 378 | }; 379 | this.sceneElements.vehicles.push(vehicleObj); 380 | this.drawDone(ESceneElement.Vehicle); 381 | // 自动聚焦 382 | this.transformer!.nodes([vehicle]); 383 | vehicle.setAttr("userData", vehicleObj); 384 | vehicle.setAttr("vehicleType", "CAR"); 385 | this.selectedElement = vehicle; 386 | this.isDrawSceneElement = null; 387 | } 388 | } else { 389 | const target = e.target as Konva.Shape; 390 | if (target !== this.currentLine) { 391 | this.transformer!.nodes([target]); 392 | this.selectedElement = target; 393 | } 394 | } 395 | }); 396 | const onKeydown = (e: KeyboardEvent) => { 397 | if (e.key === "q") { 398 | if (this.isDrawMapElement === EMapElement.Line && this.currentLine) { 399 | const newPoints = this.currentLine.points().slice(0, -2); 400 | this.currentLine.points(newPoints); 401 | this.drawDone(EMapElement.Line); 402 | } else if ( 403 | this.isDrawSceneElement === ESceneElement.Path && 404 | this.currentLine 405 | ) { 406 | const newPoints = this.currentLine.points().slice(0, -2); 407 | this.currentLine.points(newPoints); 408 | this.drawDone(ESceneElement.Path); 409 | } 410 | } 411 | }; 412 | window.addEventListener("keydown", onKeydown); 413 | }; 414 | // 地图 415 | mapElements: IMapElements = { 416 | lines: [], 417 | lanes: [], 418 | }; 419 | currentMap = ""; 420 | // 场景 421 | sceneElements: ISceneElements = { 422 | vehicles: [], 423 | obstacles: [], 424 | }; 425 | currentScene = ""; 426 | // 元素 427 | selectedElement: Konva.Shape | null = null; 428 | // 当前选中的元素的属性,基础属性包括位置、颜色、旋转、大小、名称等,直接挂载到selectedElement 429 | // selectedElementProps: any = null; 430 | 431 | saveFile = () => { 432 | // TODO 坐标系还有点问题,需要翻转y轴坐标 433 | const data: ISceneData = { 434 | autoCar: { 435 | pos: [this.autoCar.ref!.x(), -this.autoCar.ref!.y()], 436 | rotation: this.autoCar.ref!.rotation(), 437 | path: _.chunk(this.autoCar.pathLine?.points(), 2).map((item) => [ 438 | item[0], 439 | -item[1], 440 | ]), 441 | speed: this.autoCar.ref?.getAttr("speed") || 0.5, 442 | }, 443 | map: { 444 | lines: this.mapElements.lines.map((item) => { 445 | const points = item.points.map((point) => { 446 | return [point[0], -point[1]]; 447 | }); 448 | return { 449 | ...item, 450 | points, 451 | }; 452 | }), 453 | lanes: this.mapElements.lanes.map((item) => { 454 | const contour = item.contour.map((point) => { 455 | return { 456 | x: point.x, 457 | y: -point.y, 458 | z: point.z, 459 | }; 460 | }); 461 | return { 462 | ...item, 463 | contour, 464 | }; 465 | }), 466 | }, 467 | scene: { 468 | vehicles: this.sceneElements.vehicles.map((item) => { 469 | return { 470 | ...item, 471 | position: { 472 | x: item.position.x, 473 | y: -item.position.y, 474 | z: item.position.z, 475 | }, 476 | path: item.path.map((item) => [item[0], -item[1]]) ?? [], 477 | }; 478 | }), 479 | obstacles: this.sceneElements.obstacles, 480 | }, 481 | }; 482 | console.log("===saveFile===", data); 483 | localStorage.setItem("sceneData", JSON.stringify(data)); 484 | }; 485 | 486 | constructor() { 487 | makeObservable(this, { 488 | stage: observable.shallow, 489 | editMode: observable, 490 | isEdit: observable, 491 | currentMap: observable, 492 | currentScene: observable, 493 | selectedElement: observable, 494 | drawCallForScene: action, 495 | drawCallForMap: action, 496 | initStage: action, 497 | }); 498 | } 499 | } 500 | 501 | export const editorStore = new EditorStore(); 502 | 503 | interface IStage { 504 | ref: Konva.Stage | null; 505 | } 506 | -------------------------------------------------------------------------------- /src/views/scene-editor/store/type.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { ILine } from "../../../renderer/line"; 3 | import { IFreespace } from "../../../renderer/freespace"; 4 | import { ICube } from "../../../renderer/cube"; 5 | 6 | export enum EditMode { 7 | Map, 8 | Scene, 9 | } 10 | 11 | export enum EMapElement { 12 | Line = "line", 13 | Lane = "lane", 14 | Junction = "junction", 15 | TrafficLight = "trafficLight", 16 | ParkingSpace = "parkingSpace", 17 | } 18 | 19 | export enum ESceneElement { 20 | Vehicle = "vehicle", 21 | Obstacle = "obstacle", 22 | Path = "path", 23 | } 24 | 25 | export interface IAutoCar { 26 | ref: Konva.Rect | null; 27 | path: number[][]; 28 | speed: number; 29 | pathLine: Konva.Line | null; 30 | } 31 | 32 | export interface IMapElements { 33 | lines: ILine[]; 34 | lanes: IFreespace[]; 35 | } 36 | 37 | export interface ISceneElements { 38 | vehicles: (ICube & { speed: number; path: number[][] })[]; 39 | obstacles: ICube[]; 40 | } 41 | export interface ISceneData { 42 | autoCar: { 43 | pos: number[]; 44 | rotation: number; 45 | path: number[][]; 46 | speed: number; 47 | }; 48 | map: IMapElements; 49 | scene: ISceneElements; 50 | } 51 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.glb"; 4 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "resolveJsonModule": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src"] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { fileURLToPath, URL } from "node:url"; 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | import commonjs from "vite-plugin-commonjs"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [react(), commonjs()], 11 | // 指定路径别名 12 | resolve: { 13 | alias: { 14 | "@": fileURLToPath(new URL("./src", import.meta.url)), 15 | }, 16 | }, 17 | assetsInclude: ["**/*.glb"], 18 | }); 19 | --------------------------------------------------------------------------------