=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 |
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 |
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 | }
18 | onClick={focusStage}
19 | >
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 |
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 |
--------------------------------------------------------------------------------